@fgv/ks 5.1.0-25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -0
- package/bin/ks.js +18 -0
- package/config/jest.config.json +12 -0
- package/config/rig.json +4 -0
- package/eslint.config.js +24 -0
- package/package.json +55 -0
- package/src/app.ts +595 -0
- package/src/cli.ts +1 -0
- package/src/help.ts +116 -0
- package/src/index.ts +21 -0
- package/src/io.ts +179 -0
- package/src/keystore.ts +196 -0
- package/src/template.ts +61 -0
- package/test/mocks/clipboardy.js +5 -0
- package/test/unit/app.test.ts +112 -0
- package/test/unit/help.test.ts +45 -0
- package/test/unit/io.test.ts +173 -0
- package/test/unit/keystore.test.ts +448 -0
- package/test/unit/template.test.ts +33 -0
- package/tsconfig.json +8 -0
- package/tsconfig.test.json +8 -0
package/src/app.ts
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import pkg from '../package.json';
|
|
3
|
+
|
|
4
|
+
import { CryptoUtils } from '@fgv/ts-extras';
|
|
5
|
+
import { Result, fail, succeed } from '@fgv/ts-utils';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
copyTextToClipboard,
|
|
9
|
+
defaultKeystorePath,
|
|
10
|
+
promptHidden,
|
|
11
|
+
promptVisible,
|
|
12
|
+
readAllFromStdin,
|
|
13
|
+
readTextFile,
|
|
14
|
+
resolvePath
|
|
15
|
+
} from './io';
|
|
16
|
+
import { getHelpText } from './help';
|
|
17
|
+
import {
|
|
18
|
+
changeKeystorePassword,
|
|
19
|
+
createKeystore,
|
|
20
|
+
listSecrets,
|
|
21
|
+
readSecret,
|
|
22
|
+
openKeystore,
|
|
23
|
+
removeSecret,
|
|
24
|
+
saveKeystoreFile,
|
|
25
|
+
storeSecret
|
|
26
|
+
} from './keystore';
|
|
27
|
+
import { extractTemplateVariables, renderShellTemplate, shellQuote } from './template';
|
|
28
|
+
|
|
29
|
+
interface IKeystoreCommandOptions {
|
|
30
|
+
keystore?: string;
|
|
31
|
+
passwordEnv?: string;
|
|
32
|
+
passwordFile?: string;
|
|
33
|
+
passwordStdin?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ISecretValueOptions {
|
|
37
|
+
stdin?: boolean;
|
|
38
|
+
file?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface IPutCommandOptions extends IKeystoreCommandOptions, ISecretValueOptions {
|
|
42
|
+
name?: string;
|
|
43
|
+
description?: string;
|
|
44
|
+
replace?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface IGetCommandOptions extends IKeystoreCommandOptions {
|
|
48
|
+
clipboard?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface IExportCommandOptions extends IKeystoreCommandOptions {
|
|
52
|
+
templateFile?: string;
|
|
53
|
+
templateString?: string;
|
|
54
|
+
clipboard?: boolean;
|
|
55
|
+
persistMissing?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface IPasswordChangeOptions extends IKeystoreCommandOptions {
|
|
59
|
+
newPasswordEnv?: string;
|
|
60
|
+
newPasswordFile?: string;
|
|
61
|
+
newPasswordStdin?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function stripTrailingNewline(value: string): string {
|
|
65
|
+
return value.replace(/\r?\n$/, '');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasExplicitPasswordSource(options: IKeystoreCommandOptions | IPasswordChangeOptions): boolean {
|
|
69
|
+
return Boolean(options.passwordEnv || options.passwordFile || options.passwordStdin);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function readPasswordFromSource(
|
|
73
|
+
options: IKeystoreCommandOptions | IPasswordChangeOptions
|
|
74
|
+
): Promise<Result<string>> {
|
|
75
|
+
if (options.passwordFile) {
|
|
76
|
+
const fileResult = await readTextFile(options.passwordFile);
|
|
77
|
+
if (fileResult.isFailure()) {
|
|
78
|
+
return fail(fileResult.message);
|
|
79
|
+
}
|
|
80
|
+
return succeed(stripTrailingNewline(fileResult.value));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (options.passwordStdin) {
|
|
84
|
+
const stdinResult = await readAllFromStdin();
|
|
85
|
+
if (stdinResult.isFailure()) {
|
|
86
|
+
return fail(stdinResult.message);
|
|
87
|
+
}
|
|
88
|
+
return succeed(stripTrailingNewline(stdinResult.value));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (options.passwordEnv) {
|
|
92
|
+
const envValue = process.env[options.passwordEnv];
|
|
93
|
+
if (envValue === undefined || envValue.length === 0) {
|
|
94
|
+
return fail(`Environment variable '${options.passwordEnv}' is not set`);
|
|
95
|
+
}
|
|
96
|
+
return succeed(envValue);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const defaultEnv = process.env.FGV_KS_PASSWORD ?? process.env.KS_PASSWORD;
|
|
100
|
+
if (defaultEnv !== undefined && defaultEnv.length > 0) {
|
|
101
|
+
return succeed(defaultEnv);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return fail('Password not provided');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function resolvePassword(
|
|
108
|
+
options: IKeystoreCommandOptions | IPasswordChangeOptions,
|
|
109
|
+
label: string
|
|
110
|
+
): Promise<Result<string>> {
|
|
111
|
+
const source = await readPasswordFromSource(options);
|
|
112
|
+
if (source.isSuccess()) {
|
|
113
|
+
return source;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// If an explicit source was configured and failed, surface the error rather than prompting
|
|
117
|
+
if (hasExplicitPasswordSource(options)) {
|
|
118
|
+
return source;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const prompted = await promptHidden(`${label}: `);
|
|
122
|
+
if (prompted.isFailure()) {
|
|
123
|
+
return fail(prompted.message);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return succeed(prompted.value);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function resolveSecretName(
|
|
130
|
+
positionalName: string | undefined,
|
|
131
|
+
options: IPutCommandOptions
|
|
132
|
+
): Promise<Result<string>> {
|
|
133
|
+
if (options.name !== undefined && options.name.length > 0) {
|
|
134
|
+
return succeed(options.name);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (positionalName !== undefined && positionalName.length > 0) {
|
|
138
|
+
return succeed(positionalName);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const prompted = await promptVisible('Secret name: ');
|
|
142
|
+
if (prompted.isFailure()) {
|
|
143
|
+
return fail(prompted.message);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (prompted.value.length === 0) {
|
|
147
|
+
return fail('Secret name cannot be empty');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return succeed(prompted.value);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function resolvePasswordConfirmed(
|
|
154
|
+
options: IKeystoreCommandOptions | IPasswordChangeOptions,
|
|
155
|
+
label: string
|
|
156
|
+
): Promise<Result<string>> {
|
|
157
|
+
// Only skip confirmation when password comes from an explicit non-interactive source.
|
|
158
|
+
// Ambient env vars (FGV_KS_PASSWORD/KS_PASSWORD) are for reading the current password
|
|
159
|
+
// and must not silently bypass confirmation when setting a new one.
|
|
160
|
+
if (hasExplicitPasswordSource(options)) {
|
|
161
|
+
return resolvePassword(options, label);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const first = await promptHidden(`${label}: `);
|
|
165
|
+
if (first.isFailure()) {
|
|
166
|
+
return fail(first.message);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const second = await promptHidden(`${label} (confirm): `);
|
|
170
|
+
if (second.isFailure()) {
|
|
171
|
+
return fail(second.message);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (first.value !== second.value) {
|
|
175
|
+
return fail('Passwords do not match');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return succeed(first.value);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function resolveSecretValue(options: ISecretValueOptions): Promise<Result<string>> {
|
|
182
|
+
if (options.file && options.stdin) {
|
|
183
|
+
return fail('Use either --file or --stdin, not both');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (options.file) {
|
|
187
|
+
const fileResult = await readTextFile(options.file);
|
|
188
|
+
if (fileResult.isFailure()) {
|
|
189
|
+
return fail(fileResult.message);
|
|
190
|
+
}
|
|
191
|
+
return succeed(stripTrailingNewline(fileResult.value));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (options.stdin) {
|
|
195
|
+
const stdinResult = await readAllFromStdin();
|
|
196
|
+
if (stdinResult.isFailure()) {
|
|
197
|
+
return fail(stdinResult.message);
|
|
198
|
+
}
|
|
199
|
+
return succeed(stripTrailingNewline(stdinResult.value));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const prompted = await promptHidden('Secret value: ');
|
|
203
|
+
if (prompted.isFailure()) {
|
|
204
|
+
return fail(prompted.message);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return succeed(prompted.value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function readTemplate(
|
|
211
|
+
options: Pick<IExportCommandOptions, 'templateFile' | 'templateString'>
|
|
212
|
+
): Promise<Result<string>> {
|
|
213
|
+
if (options.templateFile && options.templateString) {
|
|
214
|
+
return fail('Use either --template-file or --template-string, not both');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (options.templateFile) {
|
|
218
|
+
const fileResult = await readTextFile(resolvePath(options.templateFile));
|
|
219
|
+
if (fileResult.isFailure()) {
|
|
220
|
+
return fail(fileResult.message);
|
|
221
|
+
}
|
|
222
|
+
return succeed(fileResult.value);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (options.templateString !== undefined) {
|
|
226
|
+
return succeed(options.templateString);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return fail('Specify either --template-file or --template-string');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
interface ITemplateContextResult {
|
|
233
|
+
readonly context: Record<string, string>;
|
|
234
|
+
readonly missing: readonly [string, string][];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function collectTemplateContext(
|
|
238
|
+
keystore: CryptoUtils.KeyStore.KeyStore,
|
|
239
|
+
template: string
|
|
240
|
+
): Promise<Result<ITemplateContextResult>> {
|
|
241
|
+
const variablesResult = extractTemplateVariables(template);
|
|
242
|
+
if (variablesResult.isFailure()) {
|
|
243
|
+
return fail(variablesResult.message);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const secretListResult = keystore.listSecrets();
|
|
247
|
+
if (secretListResult.isFailure()) {
|
|
248
|
+
return fail(`Failed to list secrets: ${secretListResult.message}`);
|
|
249
|
+
}
|
|
250
|
+
const knownSecrets = new Set(secretListResult.value);
|
|
251
|
+
|
|
252
|
+
const context = Object.create(null) as Record<string, string>;
|
|
253
|
+
const missing: Array<[string, string]> = [];
|
|
254
|
+
|
|
255
|
+
for (const variable of variablesResult.value) {
|
|
256
|
+
const secretResult = keystore.getApiKey(variable);
|
|
257
|
+
if (secretResult.isSuccess()) {
|
|
258
|
+
context[variable] = secretResult.value;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (knownSecrets.has(variable)) {
|
|
263
|
+
return fail(`Secret '${variable}' exists but is not an API key: ${secretResult.message}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const promptResult = await promptHidden(`Secret '${variable}' is missing. Enter value: `);
|
|
267
|
+
if (promptResult.isFailure()) {
|
|
268
|
+
return fail(promptResult.message);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
context[variable] = promptResult.value;
|
|
272
|
+
missing.push([variable, promptResult.value]);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return succeed({ context, missing });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export class KsCli {
|
|
279
|
+
private readonly _program: Command;
|
|
280
|
+
|
|
281
|
+
public constructor() {
|
|
282
|
+
this._program = new Command();
|
|
283
|
+
this._setupCommands();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
public async run(argv: string[]): Promise<void> {
|
|
287
|
+
await this._program.parseAsync(argv);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private _setupCommands(): void {
|
|
291
|
+
this._program.name('ks').description('Manage ts-extras keystore files').version(pkg.version);
|
|
292
|
+
this._program.addHelpCommand(false);
|
|
293
|
+
|
|
294
|
+
this._program
|
|
295
|
+
.command('help [topic]')
|
|
296
|
+
.description('Show overview, commands, password, or template help')
|
|
297
|
+
.action((topic: string | undefined) => {
|
|
298
|
+
const help = getHelpText(topic, this._program);
|
|
299
|
+
if (help.isFailure()) {
|
|
300
|
+
console.error(`Error: ${help.message}`);
|
|
301
|
+
process.exit(1);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
console.log(help.value);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
this._program
|
|
308
|
+
.command('init')
|
|
309
|
+
.description('Initialize a new keystore')
|
|
310
|
+
.option('--keystore <path>', 'Keystore file path', defaultKeystorePath())
|
|
311
|
+
.option('--password-env <name>', 'Environment variable to read the password from')
|
|
312
|
+
.option('--password-file <path>', 'Read the password from a file')
|
|
313
|
+
.option('--password-stdin', 'Read the password from stdin', false)
|
|
314
|
+
.action(async (options: IKeystoreCommandOptions) => {
|
|
315
|
+
const password = await resolvePasswordConfirmed(options, 'New keystore password');
|
|
316
|
+
if (password.isFailure()) {
|
|
317
|
+
console.error(`Error: ${password.message}`);
|
|
318
|
+
process.exit(1);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const created = await createKeystore(options.keystore, password.value);
|
|
322
|
+
if (created.isFailure()) {
|
|
323
|
+
console.error(`Error: ${created.message}`);
|
|
324
|
+
process.exit(1);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
this._program
|
|
329
|
+
.command('password')
|
|
330
|
+
.description('Change the keystore password')
|
|
331
|
+
.option('--keystore <path>', 'Keystore file path', defaultKeystorePath())
|
|
332
|
+
.option('--password-env <name>', 'Environment variable to read the current password from')
|
|
333
|
+
.option('--password-file <path>', 'Read the current password from a file')
|
|
334
|
+
.option('--password-stdin', 'Read the current password from stdin', false)
|
|
335
|
+
.option('--new-password-env <name>', 'Environment variable to read the new password from')
|
|
336
|
+
.option('--new-password-file <path>', 'Read the new password from a file')
|
|
337
|
+
.option('--new-password-stdin', 'Read the new password from stdin', false)
|
|
338
|
+
.action(async (options: IPasswordChangeOptions) => {
|
|
339
|
+
const currentPassword = await resolvePassword(options, 'Current keystore password');
|
|
340
|
+
if (currentPassword.isFailure()) {
|
|
341
|
+
console.error(`Error: ${currentPassword.message}`);
|
|
342
|
+
process.exit(1);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const newPasswordOptions: IPasswordChangeOptions = {
|
|
346
|
+
keystore: options.keystore,
|
|
347
|
+
passwordEnv: options.newPasswordEnv,
|
|
348
|
+
passwordFile: options.newPasswordFile,
|
|
349
|
+
passwordStdin: options.newPasswordStdin
|
|
350
|
+
};
|
|
351
|
+
const nextPassword = await resolvePasswordConfirmed(newPasswordOptions, 'New keystore password');
|
|
352
|
+
if (nextPassword.isFailure()) {
|
|
353
|
+
console.error(`Error: ${nextPassword.message}`);
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const changed = await changeKeystorePassword(
|
|
358
|
+
options.keystore,
|
|
359
|
+
currentPassword.value,
|
|
360
|
+
nextPassword.value
|
|
361
|
+
);
|
|
362
|
+
if (changed.isFailure()) {
|
|
363
|
+
console.error(`Error: ${changed.message}`);
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
this._program
|
|
369
|
+
.command('put [name]')
|
|
370
|
+
.description('Store a secret in the keystore from stdin, a file, or interactive prompts')
|
|
371
|
+
.option('--keystore <path>', 'Keystore file path', defaultKeystorePath())
|
|
372
|
+
.option('--password-env <name>', 'Environment variable to read the password from')
|
|
373
|
+
.option('--password-file <path>', 'Read the password from a file')
|
|
374
|
+
.option('--password-stdin', 'Read the password from stdin', false)
|
|
375
|
+
.option('--name <name>', 'Secret name')
|
|
376
|
+
.option('--stdin', 'Read the secret from stdin', false)
|
|
377
|
+
.option('--file <path>', 'Read the secret from a file')
|
|
378
|
+
.option('--description <text>', 'Optional secret description')
|
|
379
|
+
.option('--replace', 'Replace an existing secret', false)
|
|
380
|
+
.action(async (positionalName: string | undefined, options: IPutCommandOptions) => {
|
|
381
|
+
const name = await resolveSecretName(positionalName, options);
|
|
382
|
+
if (name.isFailure()) {
|
|
383
|
+
console.error(`Error: ${name.message}`);
|
|
384
|
+
process.exit(1);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const password = await resolvePassword(options, 'Keystore password');
|
|
388
|
+
if (password.isFailure()) {
|
|
389
|
+
console.error(`Error: ${password.message}`);
|
|
390
|
+
process.exit(1);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const secret = await resolveSecretValue(options);
|
|
394
|
+
if (secret.isFailure()) {
|
|
395
|
+
console.error(`Error: ${secret.message}`);
|
|
396
|
+
process.exit(1);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const stored = await storeSecret(options.keystore, password.value, name.value, secret.value, {
|
|
400
|
+
description: options.description,
|
|
401
|
+
replace: options.replace
|
|
402
|
+
});
|
|
403
|
+
if (stored.isFailure()) {
|
|
404
|
+
console.error(`Error: ${stored.message}`);
|
|
405
|
+
process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
this._program
|
|
410
|
+
.command('get <name>')
|
|
411
|
+
.description('Read a secret from the keystore')
|
|
412
|
+
.option('--keystore <path>', 'Keystore file path', defaultKeystorePath())
|
|
413
|
+
.option('--password-env <name>', 'Environment variable to read the password from')
|
|
414
|
+
.option('--password-file <path>', 'Read the password from a file')
|
|
415
|
+
.option('--password-stdin', 'Read the password from stdin', false)
|
|
416
|
+
.option('--clipboard', 'Copy the secret to the clipboard', false)
|
|
417
|
+
.action(async (name: string, options: IGetCommandOptions) => {
|
|
418
|
+
const password = await resolvePassword(options, 'Keystore password');
|
|
419
|
+
if (password.isFailure()) {
|
|
420
|
+
console.error(`Error: ${password.message}`);
|
|
421
|
+
process.exit(1);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const secret = await readSecret(options.keystore, password.value, name);
|
|
425
|
+
if (secret.isFailure()) {
|
|
426
|
+
console.error(`Error: ${secret.message}`);
|
|
427
|
+
process.exit(1);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (options.clipboard) {
|
|
431
|
+
const copied = await copyTextToClipboard(secret.value);
|
|
432
|
+
if (copied.isFailure()) {
|
|
433
|
+
console.error(`Error: ${copied.message}`);
|
|
434
|
+
process.exit(1);
|
|
435
|
+
}
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
console.log(secret.value);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
this._program
|
|
443
|
+
.command('list')
|
|
444
|
+
.description('List secrets in the keystore')
|
|
445
|
+
.option('--keystore <path>', 'Keystore file path', defaultKeystorePath())
|
|
446
|
+
.option('--password-env <name>', 'Environment variable to read the password from')
|
|
447
|
+
.option('--password-file <path>', 'Read the password from a file')
|
|
448
|
+
.option('--password-stdin', 'Read the password from stdin', false)
|
|
449
|
+
.action(async (options: IKeystoreCommandOptions) => {
|
|
450
|
+
const password = await resolvePassword(options, 'Keystore password');
|
|
451
|
+
if (password.isFailure()) {
|
|
452
|
+
console.error(`Error: ${password.message}`);
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const names = await listSecrets(options.keystore, password.value);
|
|
457
|
+
if (names.isFailure()) {
|
|
458
|
+
console.error(`Error: ${names.message}`);
|
|
459
|
+
process.exit(1);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
for (const name of names.value) {
|
|
463
|
+
console.log(name);
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
this._program
|
|
468
|
+
.command('remove <name>')
|
|
469
|
+
.description('Remove a secret from the keystore')
|
|
470
|
+
.option('--keystore <path>', 'Keystore file path', defaultKeystorePath())
|
|
471
|
+
.option('--password-env <name>', 'Environment variable to read the password from')
|
|
472
|
+
.option('--password-file <path>', 'Read the password from a file')
|
|
473
|
+
.option('--password-stdin', 'Read the password from stdin', false)
|
|
474
|
+
.action(async (name: string, options: IKeystoreCommandOptions) => {
|
|
475
|
+
const password = await resolvePassword(options, 'Keystore password');
|
|
476
|
+
if (password.isFailure()) {
|
|
477
|
+
console.error(`Error: ${password.message}`);
|
|
478
|
+
process.exit(1);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const removed = await removeSecret(options.keystore, password.value, name);
|
|
482
|
+
if (removed.isFailure()) {
|
|
483
|
+
console.error(`Error: ${removed.message}`);
|
|
484
|
+
process.exit(1);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
this._program
|
|
489
|
+
.command('export')
|
|
490
|
+
.description('Render a shell template using secrets from the keystore')
|
|
491
|
+
.option('--keystore <path>', 'Keystore file path', defaultKeystorePath())
|
|
492
|
+
.option('--password-env <name>', 'Environment variable to read the password from')
|
|
493
|
+
.option('--password-file <path>', 'Read the password from a file')
|
|
494
|
+
.option('--password-stdin', 'Read the password from stdin', false)
|
|
495
|
+
.option('--template-file <path>', 'Read the shell template from a file')
|
|
496
|
+
.option('--template-string <text>', 'Use the supplied shell template string')
|
|
497
|
+
.option('--clipboard', 'Copy the rendered output to the clipboard', false)
|
|
498
|
+
.option('--persist-missing', 'Persist prompted secrets back to the keystore', false)
|
|
499
|
+
.action(async (options: IExportCommandOptions) => {
|
|
500
|
+
const password = await resolvePassword(options, 'Keystore password');
|
|
501
|
+
if (password.isFailure()) {
|
|
502
|
+
console.error(`Error: ${password.message}`);
|
|
503
|
+
process.exit(1);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const template = await readTemplate(options);
|
|
507
|
+
if (template.isFailure()) {
|
|
508
|
+
console.error(`Error: ${template.message}`);
|
|
509
|
+
process.exit(1);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const opened = await openKeystore(options.keystore, password.value);
|
|
513
|
+
if (opened.isFailure()) {
|
|
514
|
+
console.error(`Error: ${opened.message}`);
|
|
515
|
+
process.exit(1);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const contextResult = await collectTemplateContext(opened.value.keystore, template.value);
|
|
519
|
+
if (contextResult.isFailure()) {
|
|
520
|
+
console.error(`Error: ${contextResult.message}`);
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const rendered = renderShellTemplate(template.value, contextResult.value.context);
|
|
525
|
+
if (rendered.isFailure()) {
|
|
526
|
+
console.error(`Error: ${rendered.message}`);
|
|
527
|
+
process.exit(1);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (options.persistMissing && contextResult.value.missing.length > 0) {
|
|
531
|
+
for (const [name, value] of contextResult.value.missing) {
|
|
532
|
+
const stored = await opened.value.keystore.importApiKey(name, value, { replace: true });
|
|
533
|
+
if (stored.isFailure()) {
|
|
534
|
+
console.error(`Error: Failed to persist missing secret '${name}': ${stored.message}`);
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const saved = await opened.value.keystore.save(password.value);
|
|
540
|
+
if (saved.isFailure()) {
|
|
541
|
+
console.error(`Error: ${saved.message}`);
|
|
542
|
+
process.exit(1);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const persisted = await saveKeystoreFile(opened.value.path, saved.value);
|
|
546
|
+
if (persisted.isFailure()) {
|
|
547
|
+
console.error(`Error: ${persisted.message}`);
|
|
548
|
+
process.exit(1);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (options.clipboard) {
|
|
553
|
+
const copied = await copyTextToClipboard(rendered.value);
|
|
554
|
+
if (copied.isFailure()) {
|
|
555
|
+
console.error(`Error: ${copied.message}`);
|
|
556
|
+
process.exit(1);
|
|
557
|
+
}
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
console.log(rendered.value);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
this._program
|
|
565
|
+
.command('session')
|
|
566
|
+
.description('Prompt for a password and emit a shell export statement')
|
|
567
|
+
.option('--var <name>', 'Environment variable name to emit', 'FGV_KS_PASSWORD')
|
|
568
|
+
.option('--clipboard', 'Copy the export statement to the clipboard', false)
|
|
569
|
+
.action(async (options: { var?: string; clipboard?: boolean }) => {
|
|
570
|
+
const varName = options.var ?? 'FGV_KS_PASSWORD';
|
|
571
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(varName)) {
|
|
572
|
+
console.error(`Error: '--var' value '${varName}' is not a valid shell identifier`);
|
|
573
|
+
process.exit(1);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const password = await promptHidden('Keystore password: ');
|
|
577
|
+
if (password.isFailure()) {
|
|
578
|
+
console.error(`Error: ${password.message}`);
|
|
579
|
+
process.exit(1);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const output = `export ${varName}=${shellQuote(password.value)}`;
|
|
583
|
+
if (options.clipboard) {
|
|
584
|
+
const copied = await copyTextToClipboard(output);
|
|
585
|
+
if (copied.isFailure()) {
|
|
586
|
+
console.error(`Error: ${copied.message}`);
|
|
587
|
+
process.exit(1);
|
|
588
|
+
}
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
console.log(output);
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { KsCli } from './app';
|