diff --git a/src/secrets/CommandEnv.ts b/src/secrets/CommandEnv.ts index bd52464a..d0a46396 100644 --- a/src/secrets/CommandEnv.ts +++ b/src/secrets/CommandEnv.ts @@ -8,41 +8,58 @@ import * as binErrors from '../errors'; import CommandPolykey from '../CommandPolykey'; import * as binOptions from '../utils/options'; +const description = ` +Run a command with the given secrets and env variables. If no command is specified then the variables are printed to stdout in the format specified by env-format. + +When selecting secrets with --env secrets with invalid names can be selected. By default when these are encountered then the command will throw an error. This behaviour can be modified with '--env-invalid'. the invalid name can be silently dropped with 'ignore' or logged out with 'warn' + +Duplicate secret names can be specified, by default with 'overwrite' the env variable will be overwritten with the latest found secret of that name. It can be specified to 'keep' the first found secret of that name, 'error' to throw if there is a duplicate and 'warn' to log a warning while overwriting. +`; + +const helpText = ` +This command has two main ways of functioning. Executing a provided command or outputting formatted env variables to] stdout. + +Running the command with 'polykey secrets env --env vault:secret -- some command' will do process replacement to run 'some command' while providing environment variables selected by '-e' to that process. Note that process replacement is only supported on unix systems such as linux or macos. When running on windows a child process will be used. + +Running the command with 'polykey secrets env --env vault:secret --env-format ' will output the environment variables to stdout with the given . The following formats are supported, 'platform', 'json', 'unix', 'cmd' and 'powershell'. + +'platform' will automatically detect the current platform and select the appropriate format. This is 'unix' for unix based systems and 'cmd' for windows. + +'json' Will format the environment variables as a json object in the form {'key': 'value'}. + +'unix' Will format the environment variables as a '.env' file for use on unix systems. It will include comments before each variable showing the secret path used for that variable. + +'cmd' Will format the environment variables as a '.bat' file for use on windows cmd. It will include comments before each variable showing the secret path used for that variable. + +'powershell' Will format the environment variables as a '.ps1' file for use on windows Powershell. It will include comments before each variable showing the secret path used for that variable. +`; + class CommandEnv extends CommandPolykey { constructor(...args: ConstructorParameters) { super(...args); - - // Modify the --format choices to include `dotenv` and `prepend` - // @ts-ignore: missing type for `options` - const formatOption = this.options.filter((v) => v.short === '-f')[0]; - if (formatOption == null) { - utils.never('The -f --format option should exist'); - } - formatOption.argChoices.push('dotenv', 'prepend'); - this.name('env'); - this.description( - `Run a command with the given secrets and env variables. If no command is specified then the variables are printed to stdout in the format specified by env-format.\n\nWhen selecting secrets with --env secrets with invalid names can be selected. By default when these are encountered then the command will throw an error. This behaviour can be modified with '--env-invalid'. the invalid name can be silently dropped with 'ignore' or logged out with 'warn'\n\nDuplicate secret names can be specified, by default with 'overwrite' the env variable will be overwritten with the latest found secret of that name. It can be specified to 'keep' the first found secret of that name, 'error' to throw if there is a duplicate and 'warn' to log a warning while overwriting.`, - ); + this.description(description); this.addOption(binOptions.nodeId); this.addOption(binOptions.clientHost); this.addOption(binOptions.clientPort); this.addOption(binOptions.envVariables); + this.addOption(binOptions.envFormat); this.addOption(binOptions.envInvalid); this.addOption(binOptions.envDuplicate); this.argument('[cmd] [argv...]', 'command and arguments'); + this.addHelpText('after', helpText); this.action(async (args: Array, options) => { const [cmd, ...argv] = args; const { env: envVariables, envInvalid, envDuplicate, - format, + envFormat, }: { env: Array<[string, string, string?]>; envInvalid: 'error' | 'warn' | 'ignore'; envDuplicate: 'keep' | 'overwrite' | 'warn' | 'error'; - format: 'human' | 'dotenv' | 'json' | 'prepend'; + envFormat: 'platform' | 'unix' | 'cmd' | 'powershell' | 'json'; } = options; // There are a few stages here @@ -84,88 +101,102 @@ class CommandEnv extends CommandPolykey { }); // Getting envs - const envp = await binUtils.retryAuthentication(async (auth) => { - const responseStream = - await pkClient.rpcClient.methods.vaultsSecretsEnv(); - // Writing desired secrets - const secretRenameMap = new Map(); - const writeP = (async () => { - const writer = responseStream.writable.getWriter(); - let first = true; - for (const envVariable of envVariables) { - const [nameOrId, secretName, secretNameNew] = envVariable; - secretRenameMap.set(secretName, secretNameNew); - await writer.write({ - nameOrId, - secretName, - metadata: first ? auth : undefined, - }); - first = false; - } - await writer.close(); - })(); - - const envp: Record = {}; - for await (const value of responseStream.readable) { - const { secretName, secretContent } = value; - let newName = secretRenameMap.get(secretName); - if (newName == null) { - const secretEnvName = path.basename(secretName); - // Validating name - if (!binUtils.validEnvRegex.test(secretEnvName)) { - switch (envInvalid) { + const [envp, envpPath] = await binUtils.retryAuthentication( + async (auth) => { + const responseStream = + await pkClient.rpcClient.methods.vaultsSecretsEnv(); + // Writing desired secrets + const secretRenameMap = new Map(); + const writeP = (async () => { + const writer = responseStream.writable.getWriter(); + let first = true; + for (const envVariable of envVariables) { + const [nameOrId, secretName, secretNameNew] = envVariable; + secretRenameMap.set(secretName, secretNameNew); + await writer.write({ + nameOrId, + secretName, + metadata: first ? auth : undefined, + }); + first = false; + } + await writer.close(); + })(); + + const envp: Record = {}; + const envpPath: Record< + string, + { + nameOrId: string; + secretName: string; + } + > = {}; + for await (const value of responseStream.readable) { + const { nameOrId, secretName, secretContent } = value; + let newName = secretRenameMap.get(secretName); + if (newName == null) { + const secretEnvName = path.basename(secretName); + // Validating name + if (!binUtils.validEnvRegex.test(secretEnvName)) { + switch (envInvalid) { + case 'error': + throw new binErrors.ErrorPolykeyCLIInvalidEnvName( + `The following env variable name (${secretEnvName}) is invalid`, + ); + case 'warn': + this.logger.warn( + `The following env variable name (${secretEnvName}) is invalid and was dropped`, + ); + // Fallthrough + case 'ignore': + continue; + default: + utils.never(); + } + } + newName = secretEnvName; + } + // Handling duplicate names + if (envp[newName] != null) { + switch (envDuplicate) { + // Continue without modifying case 'error': - throw new binErrors.ErrorPolykeyCLIInvalidEnvName( - `The following env variable name (${secretEnvName}) is invalid`, + throw new binErrors.ErrorPolykeyCLIDuplicateEnvName( + `The env variable (${newName}) is duplicate`, ); + // Fallthrough + case 'keep': + continue; + // Log a warning and overwrite case 'warn': this.logger.warn( - `The following env variable name (${secretEnvName}) is invalid and was dropped`, + `The env variable (${newName}) is duplicate, overwriting`, ); // Fallthrough - case 'ignore': - continue; + case 'overwrite': + break; default: utils.never(); } } - newName = secretEnvName; - } - // Handling duplicate names - if (envp[newName] != null) { - switch (envDuplicate) { - // Continue without modifying - case 'error': - throw new binErrors.ErrorPolykeyCLIDuplicateEnvName( - `The env variable (${newName}) is duplicate`, - ); - // Fallthrough - case 'keep': - continue; - // Log a warning and overwrite - case 'warn': - this.logger.warn( - `The env variable (${newName}) is duplicate, overwriting`, - ); - // Fallthrough - case 'overwrite': - break; - default: - utils.never(); - } + envp[newName] = secretContent; + envpPath[newName] = { + nameOrId, + secretName, + }; } - envp[newName] = secretContent; - } - await writeP; - return envp; - }, meta); + await writeP; + return [envp, envpPath]; + }, + meta, + ); // End connection early to avoid errors on server await pkClient.stop(); // Here we want to switch between the different usages + const platform = os.platform(); if (cmd != null) { // If a cmd is| provided then we default to exec it - const platform = os.platform(); switch (platform) { case 'linux': // Fallthrough @@ -191,14 +222,23 @@ class CommandEnv extends CommandPolykey { } } else { // Otherwise we switch between output formats + // If set to platform then we need to infer the format + let format = envFormat; + if (envFormat === 'platform') { + format = + { + darwin: 'unix', + linux: 'unix', + win32: 'cmd', + }[platform] ?? 'unix'; + } switch (format) { - case 'human': - // Fallthrough - case 'dotenv': + case 'unix': { // Formatting as a .env file let data = ''; for (const [key, value] of Object.entries(envp)) { + data += `# ${envpPath[key].nameOrId}:${envpPath[key].secretName}\n`; data += `${key}="${value}"\n`; } process.stdout.write( @@ -209,14 +249,29 @@ class CommandEnv extends CommandPolykey { ); } break; - case 'prepend': + case 'cmd': + { + // Formatting as a .bat file for windows cmd + let data = ''; + for (const [key, value] of Object.entries(envp)) { + data += `REM ${envpPath[key].nameOrId}:${envpPath[key].secretName}\n`; + data += `set "${key}=${value}"\n`; + } + process.stdout.write( + binUtils.outputFormatter({ + type: 'raw', + data, + }), + ); + } + break; + case 'powershell': { - // Formatting as a command input - let first = true; + // Formatting as a .bat file for windows cmd let data = ''; for (const [key, value] of Object.entries(envp)) { - data += `${first ? '' : ' '}${key}="${value}"`; - first = false; + data += `# ${envpPath[key].nameOrId}:${envpPath[key].secretName}\n`; + data += `\$env:${key} = '${value}'\n`; } process.stdout.write( binUtils.outputFormatter({ diff --git a/src/utils/options.ts b/src/utils/options.ts index 746ad351..522082bf 100644 --- a/src/utils/options.ts +++ b/src/utils/options.ts @@ -207,6 +207,13 @@ const envVariables = new commander.Option('-e --env ', 'specify envs') }, ); +const envFormat = new commander.Option( + '-ef --env-format ', + 'Select how the env variables are formatted on stdout if no command is specified', +) + .choices(['platform', 'json', 'unix', 'cmd', 'powershell']) + .default('platform'); + const envInvalid = new commander.Option( '-ei --env-invalid ', 'How invalid env variable names are handled when retrieving secrets. `error` will throw, `warn` will log a warning and drop and `ignore` will silently drop.', @@ -250,6 +257,7 @@ export { depth, commitId, envVariables, + envFormat, envInvalid, envDuplicate, }; diff --git a/tests/secrets/env.test.ts b/tests/secrets/env.test.ts index 5ff5db46..a171b7b4 100644 --- a/tests/secrets/env.test.ts +++ b/tests/secrets/env.test.ts @@ -68,6 +68,8 @@ describe('commandEnv', () => { dataDir, '-e', `${vaultName}:SECRET`, + '--env-format', + 'unix', '--', 'node', '-e', @@ -95,6 +97,8 @@ describe('commandEnv', () => { '-e', `${vaultName}:SECRET1`, `${vaultName}:SECRET2`, + '--env-format', + 'unix', '--', 'node', '-e', @@ -124,6 +128,8 @@ describe('commandEnv', () => { dataDir, '-e', `${vaultName}:dir1`, + '--env-format', + 'unix', '--', 'node', '-e', @@ -151,6 +157,8 @@ describe('commandEnv', () => { dataDir, '-e', `${vaultName}:SECRET=SECRET_NEW`, + '--env-format', + 'unix', '--', 'node', '-e', @@ -179,6 +187,8 @@ describe('commandEnv', () => { dataDir, '-e', `${vaultName}:dir1=SECRET_NEW`, + '--env-format', + 'unix', '--', 'node', '-e', @@ -212,6 +222,8 @@ describe('commandEnv', () => { `${vaultName}:SECRET1`, `${vaultName}:SECRET2`, `${vaultName}:dir1`, + '--env-format', + 'unix', '--', 'node', '-e', @@ -240,6 +252,8 @@ describe('commandEnv', () => { dataDir, '-e', `${vaultName}:SECRET1`, + '--env-format', + 'unix', '--', 'node', '-e', @@ -277,6 +291,8 @@ describe('commandEnv', () => { `${vaultName}:SECRET2=SECRET1`, `${vaultName}:SECRET3=SECRET4`, `${vaultName}:dir1`, + '--env-format', + 'unix', '--', 'node', '-e', @@ -310,8 +326,8 @@ describe('commandEnv', () => { dataDir, '-e', `${vaultName}:.`, - '--format', - 'human', + '--env-format', + 'unix', ]; const result = await testUtils.pkExec([...command]); @@ -321,16 +337,25 @@ describe('commandEnv', () => { expect(result.stdout).toContain('SECRET3="this is the secret3"'); expect(result.stdout).toContain('SECRET4="this is the secret4"'); }); - test('should output dotenv format', async () => { - const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + test('should output unix format', async () => { + const vaultId1 = await polykeyAgent.vaultManager.createVault( + `${vaultName}1`, + ); + const vaultId2 = await polykeyAgent.vaultManager.createVault( + `${vaultName}2`, + ); - await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { - await vaultOps.addSecret(vault, 'SECRET1', 'this is the secret1'); - await vaultOps.addSecret(vault, 'SECRET2', 'this is the secret2'); - await vaultOps.mkdir(vault, 'dir1'); - await vaultOps.addSecret(vault, 'dir1/SECRET3', 'this is the secret3'); - await vaultOps.addSecret(vault, 'dir1/SECRET4', 'this is the secret4'); - }); + await polykeyAgent.vaultManager.withVaults( + [vaultId1, vaultId2], + async (vault1, vault2) => { + await vaultOps.addSecret(vault1, 'SECRET1', 'this is the secret1'); + await vaultOps.addSecret(vault2, 'SECRET2', 'this is the secret2'); + await vaultOps.mkdir(vault1, 'dir1'); + await vaultOps.mkdir(vault2, 'dir1'); + await vaultOps.addSecret(vault1, 'dir1/SECRET3', 'this is the secret3'); + await vaultOps.addSecret(vault2, 'dir1/SECRET4', 'this is the secret4'); + }, + ); command = [ 'secrets', @@ -338,28 +363,42 @@ describe('commandEnv', () => { '-np', dataDir, '-e', - `${vaultName}:.`, - '--format', - 'dotenv', + `${vaultName}1:.`, + `${vaultName}2:.`, + '--env-format', + 'unix', ]; const result = await testUtils.pkExec([...command]); expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('# vault1:SECRET1'); expect(result.stdout).toContain('SECRET1="this is the secret1"'); + expect(result.stdout).toContain('# vault2:SECRET2'); expect(result.stdout).toContain('SECRET2="this is the secret2"'); + expect(result.stdout).toContain('# vault1:dir1/SECRET3'); expect(result.stdout).toContain('SECRET3="this is the secret3"'); + expect(result.stdout).toContain('# vault2:dir1/SECRET4'); expect(result.stdout).toContain('SECRET4="this is the secret4"'); }); - test('should output json format', async () => { - const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + test('should output cmd format', async () => { + const vaultId1 = await polykeyAgent.vaultManager.createVault( + `${vaultName}1`, + ); + const vaultId2 = await polykeyAgent.vaultManager.createVault( + `${vaultName}2`, + ); - await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { - await vaultOps.addSecret(vault, 'SECRET1', 'this is the secret1'); - await vaultOps.addSecret(vault, 'SECRET2', 'this is the secret2'); - await vaultOps.mkdir(vault, 'dir1'); - await vaultOps.addSecret(vault, 'dir1/SECRET3', 'this is the secret3'); - await vaultOps.addSecret(vault, 'dir1/SECRET4', 'this is the secret4'); - }); + await polykeyAgent.vaultManager.withVaults( + [vaultId1, vaultId2], + async (vault1, vault2) => { + await vaultOps.addSecret(vault1, 'SECRET1', 'this is the secret1'); + await vaultOps.addSecret(vault2, 'SECRET2', 'this is the secret2'); + await vaultOps.mkdir(vault1, 'dir1'); + await vaultOps.mkdir(vault2, 'dir1'); + await vaultOps.addSecret(vault1, 'dir1/SECRET3', 'this is the secret3'); + await vaultOps.addSecret(vault2, 'dir1/SECRET4', 'this is the secret4'); + }, + ); command = [ 'secrets', @@ -367,21 +406,67 @@ describe('commandEnv', () => { '-np', dataDir, '-e', - `${vaultName}:.`, - '--format', - 'json', + `${vaultName}1:.`, + `${vaultName}2:.`, + '--env-format', + 'cmd', ]; const result = await testUtils.pkExec([...command]); expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({ - SECRET1: 'this is the secret1', - SECRET2: 'this is the secret2', - SECRET3: 'this is the secret3', - SECRET4: 'this is the secret4', - }); + expect(result.stdout).toContain('REM vault1:SECRET1'); + expect(result.stdout).toContain('set "SECRET1=this is the secret1"'); + expect(result.stdout).toContain('REM vault2:SECRET2'); + expect(result.stdout).toContain('set "SECRET2=this is the secret2"'); + expect(result.stdout).toContain('REM vault1:dir1/SECRET3'); + expect(result.stdout).toContain('set "SECRET3=this is the secret3"'); + expect(result.stdout).toContain('REM vault2:dir1/SECRET4'); + expect(result.stdout).toContain('set "SECRET4=this is the secret4"'); + }); + test('should output powershell format', async () => { + const vaultId1 = await polykeyAgent.vaultManager.createVault( + `${vaultName}1`, + ); + const vaultId2 = await polykeyAgent.vaultManager.createVault( + `${vaultName}2`, + ); + + await polykeyAgent.vaultManager.withVaults( + [vaultId1, vaultId2], + async (vault1, vault2) => { + await vaultOps.addSecret(vault1, 'SECRET1', 'this is the secret1'); + await vaultOps.addSecret(vault2, 'SECRET2', 'this is the secret2'); + await vaultOps.mkdir(vault1, 'dir1'); + await vaultOps.mkdir(vault2, 'dir1'); + await vaultOps.addSecret(vault1, 'dir1/SECRET3', 'this is the secret3'); + await vaultOps.addSecret(vault2, 'dir1/SECRET4', 'this is the secret4'); + }, + ); + + command = [ + 'secrets', + 'env', + '-np', + dataDir, + '-e', + `${vaultName}1:.`, + `${vaultName}2:.`, + '--env-format', + 'powershell', + ]; + + const result = await testUtils.pkExec([...command]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('# vault1:SECRET1'); + expect(result.stdout).toContain(`$env:SECRET1 = 'this is the secret1'`); + expect(result.stdout).toContain('# vault2:SECRET2'); + expect(result.stdout).toContain(`$env:SECRET2 = 'this is the secret2'`); + expect(result.stdout).toContain('# vault1:dir1/SECRET3'); + expect(result.stdout).toContain(`$env:SECRET3 = 'this is the secret3'`); + expect(result.stdout).toContain('# vault2:dir1/SECRET4'); + expect(result.stdout).toContain(`$env:SECRET4 = 'this is the secret4'`); }); - test('should output prepend format', async () => { + test('should output json format', async () => { const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { @@ -399,15 +484,18 @@ describe('commandEnv', () => { dataDir, '-e', `${vaultName}:.`, - '--format', - 'prepend', + '--env-format', + 'json', ]; const result = await testUtils.pkExec([...command]); expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - 'SECRET1="this is the secret1" SECRET2="this is the secret2" SECRET3="this is the secret3" SECRET4="this is the secret4"', - ); + expect(JSON.parse(result.stdout)).toMatchObject({ + SECRET1: 'this is the secret1', + SECRET2: 'this is the secret2', + SECRET3: 'this is the secret3', + SECRET4: 'this is the secret4', + }); }); test('testing valid and invalid rename inputs', async () => { const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); @@ -429,25 +517,14 @@ describe('commandEnv', () => { const invalid = ['123', '123abc', '123_123', '123_abc', '123 abc', ' ']; - command = [ - 'secrets', - 'env', - '-np', - dataDir, - '-e', - `${vaultName}:SECRET=one_123_ABC`, - '--', - 'node', - '-e', - 'console.log(JSON.stringify(process.env))', - ]; - // Checking valid const result = await testUtils.pkExec([ 'secrets', 'env', '-np', dataDir, + '--env-format', + 'unix', '-e', ...valid.map((v) => `${vaultName}:SECRET=${v}`), ]); @@ -460,6 +537,8 @@ describe('commandEnv', () => { 'env', '-np', dataDir, + '--env-format', + 'unix', '-e', `${vaultName}:SECRET=${nameNew}`, ]); @@ -479,6 +558,8 @@ describe('commandEnv', () => { 'env', '-np', dataDir, + '--env-format', + 'unix', '-ei', 'error', '-e', @@ -499,6 +580,8 @@ describe('commandEnv', () => { 'env', '-np', dataDir, + '--env-format', + 'unix', '-ei', 'warn', '-e', @@ -523,6 +606,8 @@ describe('commandEnv', () => { 'env', '-np', dataDir, + '--env-format', + 'unix', '-ei', 'ignore', '-e', @@ -549,6 +634,8 @@ describe('commandEnv', () => { 'env', '-np', dataDir, + '--env-format', + 'unix', '-ed', 'error', '-e', @@ -572,6 +659,8 @@ describe('commandEnv', () => { 'env', '-np', dataDir, + '--env-format', + 'unix', '-ed', 'warn', '-e', @@ -597,6 +686,8 @@ describe('commandEnv', () => { 'env', '-np', dataDir, + '--env-format', + 'unix', '-ed', 'keep', '-e', @@ -620,6 +711,8 @@ describe('commandEnv', () => { 'env', '-np', dataDir, + '--env-format', + 'unix', '-ed', 'overwrite', '-e',