Skip to content

Commit 6bc2f06

Browse files
committed
feat: removed newlines by default in secrets env command
feat: added option to preserve newlines chore: removed listeners after closing edit process chore: added fastcheck tests for added features chore: automatically removing -- from parsed command list
1 parent 740c72d commit 6bc2f06

File tree

10 files changed

+221
-65
lines changed

10 files changed

+221
-65
lines changed

src/errors.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ class ErrorPolykeyCLIUnexpectedError<T> extends ErrorPolykeyCLI<T> {
8484
exitCode = sysexits.SOFTWARE;
8585
}
8686

87+
class ErrorPolykeyCLISubprocessFailure<T> extends ErrorPolykeyCLI<T> {
88+
static description = 'A subprocess failed to exit gracefully';
89+
exitCode = sysexits.UNKNOWN;
90+
}
91+
8792
class ErrorPolykeyCLINodePath<T> extends ErrorPolykeyCLI<T> {
8893
static description = 'Cannot derive default node path from unknown platform';
8994
exitCode = sysexits.USAGE;
@@ -191,6 +196,7 @@ export {
191196
ErrorPolykeyCLIUncaughtException,
192197
ErrorPolykeyCLIUnhandledRejection,
193198
ErrorPolykeyCLIUnexpectedError,
199+
ErrorPolykeyCLISubprocessFailure,
194200
ErrorPolykeyCLIAsynchronousDeadlock,
195201
ErrorPolykeyCLINodePath,
196202
ErrorPolykeyCLIClientOptions,

src/secrets/CommandEdit.ts

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ class CommandEdit extends CommandPolykey {
2222
this.addOption(binOptions.nodeId);
2323
this.addOption(binOptions.clientHost);
2424
this.addOption(binOptions.clientPort);
25-
this.action(async (secretPath, options) => {
25+
this.action(async (fullSecretPath, options) => {
26+
const vaultName = fullSecretPath[0];
27+
const secretPath = fullSecretPath[1] ?? '/';
2628
const os = await import('os');
2729
const { spawn } = await import('child_process');
2830
const vaultsErrors = await import('polykey/dist/vaults/errors');
@@ -60,13 +62,13 @@ class CommandEdit extends CommandPolykey {
6062
},
6163
logger: this.logger.getChild(PolykeyClient.name),
6264
});
63-
const tmpFile = path.join(tmpDir, path.basename(secretPath[1]));
65+
const tmpFile = path.join(tmpDir, path.basename(secretPath));
6466
const secretExists = await binUtils.retryAuthentication(
6567
async (auth) => {
6668
let exists = true;
6769
const response = await pkClient.rpcClient.methods.vaultsSecretsGet({
68-
nameOrId: secretPath[0],
69-
secretName: secretPath[1] ?? '/',
70+
nameOrId: vaultName,
71+
secretName: secretPath,
7072
metadata: auth,
7173
});
7274
try {
@@ -86,7 +88,7 @@ class CommandEdit extends CommandPolykey {
8688
// First, write the inline error to standard error like other
8789
// secrets commands do.
8890
process.stderr.write(
89-
`edit: ${secretPath[1] ?? '/'}: No such file or directory\n`,
91+
`edit: ${secretPath}: No such file or directory\n`,
9092
);
9193
// Then, throw an error to get the non-zero exit code. As this
9294
// command is Polykey-specific, the code doesn't really matter
@@ -111,22 +113,33 @@ class CommandEdit extends CommandPolykey {
111113
const editorProc = spawn(process.env.EDITOR ?? 'nano', [tmpFile], {
112114
stdio: 'inherit',
113115
});
114-
editorProc.on('error', (e) => {
115-
const error = new errors.ErrorPolykeyCLIEditSecret(
116-
`Failed to run command ${process.env.EDITOR}`,
116+
// Define event handlers
117+
const cleanup = () => {
118+
editorProc.removeListener('error', onError);
119+
editorProc.removeListener('close', onClose);
120+
};
121+
const onError = (e: Error) => {
122+
cleanup();
123+
const error = new errors.ErrorPolykeyCLISubprocessFailure(
124+
`Failed to run command '${process.env.EDITOR}'`,
117125
{ cause: e },
118126
);
119127
reject(error);
120-
});
121-
editorProc.on('close', (code) => {
128+
};
129+
const onClose = (code: number | null) => {
130+
cleanup();
122131
if (code !== 0) {
123-
const error = new errors.ErrorPolykeyCLIEditSecret(
132+
const error = new errors.ErrorPolykeyCLISubprocessFailure(
124133
`Editor exited with code ${code}`,
125134
);
126135
reject(error);
136+
} else {
137+
resolve();
127138
}
128-
resolve();
129-
});
139+
};
140+
// Connect event handlers to events
141+
editorProc.on('error', onError);
142+
editorProc.on('close', onClose);
130143
});
131144
let content: string;
132145
try {
@@ -160,8 +173,8 @@ class CommandEdit extends CommandPolykey {
160173
async (auth) =>
161174
await pkClient.rpcClient.methods.vaultsSecretsWriteFile({
162175
metadata: auth,
163-
nameOrId: secretPath[0],
164-
secretName: secretPath[1],
176+
nameOrId: vaultName,
177+
secretName: secretPath,
165178
secretContent: content,
166179
}),
167180
meta,

src/secrets/CommandEnv.ts

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type PolykeyClient from 'polykey/dist/PolykeyClient';
2+
import type { ParsedSecretPathValue } from '../types';
23
import path from 'path';
34
import os from 'os';
45
import * as utils from 'polykey/dist/utils';
6+
import CommandPolykey from '../CommandPolykey';
57
import * as binProcessors from '../utils/processors';
68
import * as binUtils from '../utils';
79
import * as binErrors from '../errors';
8-
import CommandPolykey from '../CommandPolykey';
910
import * as binOptions from '../utils/options';
1011
import * as binParsers from '../utils/parsers';
1112

@@ -14,26 +15,22 @@ class CommandEnv extends CommandPolykey {
1415
super(...args);
1516
this.name('env');
1617
this.description(
17-
`Run a command with the given secrets and env variables using process replacement. If no command is specified then the variables are printed to stdout in the format specified by env-format.`,
18+
`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.`,
1819
);
1920
this.addOption(binOptions.nodeId);
2021
this.addOption(binOptions.clientHost);
2122
this.addOption(binOptions.clientPort);
2223
this.addOption(binOptions.envFormat);
2324
this.addOption(binOptions.envInvalid);
2425
this.addOption(binOptions.envDuplicate);
26+
this.addOption(binOptions.preserveNewline);
2527
this.argument(
2628
'<args...>',
2729
'command and arguments formatted as [envPaths...][-- cmd [cmdArgs...]]',
2830
binParsers.parseEnvArgs,
2931
);
30-
this.passThroughOptions(); // Let -- pass through as-is to parse as delimiter for cmd
3132
this.action(
32-
async (
33-
args: [Array<[string, string?, string?]>, Array<string>],
34-
options,
35-
) => {
36-
args[1].shift();
33+
async (args: [Array<ParsedSecretPathValue>, Array<string>], options) => {
3734
const { default: PolykeyClient } = await import(
3835
'polykey/dist/PolykeyClient'
3936
);
@@ -160,7 +157,27 @@ class CommandEnv extends CommandPolykey {
160157
utils.never();
161158
}
162159
}
163-
envp[newName] = secretContent;
160+
161+
// Find if we need to preserve the newline for this secret
162+
let preserveNewline = false;
163+
if (options.preserveNewline) {
164+
for (const pair of options.preserveNewline) {
165+
if (
166+
pair[0] === nameOrId &&
167+
(pair[1] === newName || pair[1] == null)
168+
) {
169+
preserveNewline = true;
170+
break;
171+
}
172+
}
173+
}
174+
175+
// Trim the single trailing newline if it exists
176+
if (!preserveNewline && secretContent.endsWith('\n')) {
177+
envp[newName] = secretContent.slice(0, -1);
178+
} else {
179+
envp[newName] = secretContent;
180+
}
164181
envpPath[newName] = {
165182
nameOrId,
166183
secretName,
@@ -175,14 +192,26 @@ class CommandEnv extends CommandPolykey {
175192
// Here we want to switch between the different usages
176193
const platform = os.platform();
177194
if (cmd != null) {
178-
// If a cmd is| provided then we default to exec it
195+
// If a cmd is provided then we default to exec it
179196
switch (platform) {
180197
case 'linux':
181-
// Fallthrough
182198
case 'darwin':
183199
{
184200
const { exec } = await import('@matrixai/exec');
185-
exec.execvp(cmd, argv, envp);
201+
try {
202+
exec.execvp(cmd, argv, envp);
203+
} catch (e) {
204+
if ('code' in e && e.code === 'GenericFailure') {
205+
throw new binErrors.ErrorPolykeyCLISubprocessFailure(
206+
`Command failed with error ${e}`,
207+
{
208+
cause: e,
209+
data: { command: [cmd, ...argv] },
210+
},
211+
);
212+
}
213+
throw e;
214+
}
186215
}
187216
break;
188217
default: {

src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ type PromiseDeconstructed<T> = {
6161
rejectP: (reason?: any) => void;
6262
};
6363

64+
type ParsedSecretPathValue = [string, string?, string?];
65+
6466
export type {
6567
TableRow,
6668
TableOptions,
@@ -69,4 +71,5 @@ export type {
6971
AgentChildProcessInput,
7072
AgentChildProcessOutput,
7173
PromiseDeconstructed,
74+
ParsedSecretPathValue,
7275
};

src/utils/options.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,17 @@ const parents = new commander.Option(
318318
'If enabled, create all parent directories as well. If the directories exist, do nothing.',
319319
).default(false);
320320

321+
const preserveNewline = new commander.Option(
322+
'-pn --preserve-newline <path>',
323+
'Preserve the last trailing newline for the secret content',
324+
)
325+
.argParser((value: string, previous: Array<[string, string?, string?]>) => {
326+
const out = previous ?? [];
327+
out.push(binParsers.parseSecretPathEnv(value));
328+
return out;
329+
})
330+
.default([]);
331+
321332
export {
322333
nodePath,
323334
format,
@@ -361,4 +372,5 @@ export {
361372
order,
362373
recursive,
363374
parents,
375+
preserveNewline,
364376
};

src/utils/parsers.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Host, Hostname, Port } from 'polykey/dist/network/types';
22
import type { SeedNodes } from 'polykey/dist/nodes/types';
3+
import type { ParsedSecretPathValue } from '../types';
34
import commander from 'commander';
45
import * as validationUtils from 'polykey/dist/validation/utils';
56
import * as validationErrors from 'polykey/dist/validation/errors';
@@ -81,7 +82,7 @@ function parseVaultName(vaultName: string): string {
8182
// If 'vault1:', an error is thrown
8283
// If 'a/b/c', an error is thrown
8384
// Splits out everything after an `=` separator
84-
function parseSecretPath(inputPath: string): [string, string?, string?] {
85+
function parseSecretPath(inputPath: string): ParsedSecretPathValue {
8586
// The colon character `:` is prohibited in vaultName, so it's first occurence
8687
// means that this is the delimiter between vaultName and secretPath.
8788
const colonIndex = inputPath.indexOf(':');
@@ -116,7 +117,7 @@ function parseSecretPath(inputPath: string): [string, string?, string?] {
116117
return [vaultName, secretPath, value];
117118
}
118119

119-
function parseSecretPathValue(secretPath: string): [string, string?, string?] {
120+
function parseSecretPathValue(secretPath: string): ParsedSecretPathValue {
120121
const [vaultName, directoryPath, value] = parseSecretPath(secretPath);
121122
if (value != null && !secretPathValueRegex.test(value)) {
122123
throw new commander.InvalidArgumentError(
@@ -126,7 +127,7 @@ function parseSecretPathValue(secretPath: string): [string, string?, string?] {
126127
return [vaultName, directoryPath, value];
127128
}
128129

129-
function parseSecretPathEnv(secretPath: string): [string, string?, string?] {
130+
function parseSecretPathEnv(secretPath: string): ParsedSecretPathValue {
130131
const [vaultName, directoryPath, value] = parseSecretPath(secretPath);
131132
if (value != null && !environmentVariableRegex.test(value)) {
132133
throw new commander.InvalidArgumentError(
@@ -202,30 +203,29 @@ const parseSeedNodes: (data: string) => [SeedNodes, boolean] =
202203
*/
203204
function parseEnvArgs(
204205
value: string,
205-
prev: [Array<[string, string?, string?]>, Array<string>] | undefined,
206-
): [Array<[string, string?, string?]>, Array<string>] {
207-
const current: [Array<[string, string?, string?]>, Array<string>] = prev ?? [
208-
[],
209-
[],
210-
];
211-
if (current[1].length === 0) {
206+
prev: [Array<ParsedSecretPathValue>, Array<string>, boolean] | undefined,
207+
): [Array<ParsedSecretPathValue>, Array<string>, boolean] {
208+
const current: [Array<ParsedSecretPathValue>, Array<string>, boolean] =
209+
prev ?? [[], [], false];
210+
const [secretsList, commandList, parsingCommandCurrent] = current;
211+
let parsingCommand = parsingCommandCurrent;
212+
if (!parsingCommand) {
212213
// Parse a secret path
213214
if (value !== '--') {
214-
current[0].push(parseSecretPathEnv(value));
215+
secretsList.push(parseSecretPathEnv(value));
215216
} else {
216-
current[1].push(value);
217-
return current;
217+
parsingCommand = true;
218218
}
219219
} else {
220220
// Otherwise we just have the cmd args
221-
current[1].push(value);
221+
commandList.push(value);
222222
}
223-
if (current[0].length === 0 && current[1].length > 0) {
223+
if (secretsList.length === 0 && commandList.length > 0) {
224224
throw new commander.InvalidArgumentError(
225225
'You must provide at least 1 secret path',
226226
);
227227
}
228-
return current;
228+
return [secretsList, commandList, parsingCommand];
229229
}
230230

231231
export {

src/utils/utils.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,11 @@ function outputFormatterError(err: any): string {
448448
}
449449
output += `${indent}timestamp\t${err.timestamp}\n`;
450450
} else {
451-
output += '\n';
451+
if (err.data && !utils.isEmptyObject(err.data)) {
452+
output += `\n${indent}data: ${JSON.stringify(err.data)}\n`;
453+
} else {
454+
output += '\n';
455+
}
452456
}
453457
output += `${indent}cause: `;
454458
err = err.cause;

0 commit comments

Comments
 (0)