@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/help.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
|
|
3
|
+
import { fail, Result, succeed } from '@fgv/ts-utils';
|
|
4
|
+
|
|
5
|
+
export type HelpTopic = 'overview' | 'commands' | 'password' | 'template';
|
|
6
|
+
|
|
7
|
+
const AVAILABLE_HELP_TOPICS: readonly HelpTopic[] = ['overview', 'commands', 'password', 'template'];
|
|
8
|
+
|
|
9
|
+
const HELP_OVERVIEW: string = `ks manages ts-extras keystore files.
|
|
10
|
+
|
|
11
|
+
Start here:
|
|
12
|
+
ks help overview
|
|
13
|
+
ks help commands
|
|
14
|
+
ks help password
|
|
15
|
+
ks help template
|
|
16
|
+
|
|
17
|
+
Default keystore:
|
|
18
|
+
~/.fgv-ks
|
|
19
|
+
|
|
20
|
+
Common commands:
|
|
21
|
+
ks init
|
|
22
|
+
ks put <name>
|
|
23
|
+
ks get <name>
|
|
24
|
+
ks list
|
|
25
|
+
ks remove <name>
|
|
26
|
+
ks export
|
|
27
|
+
ks session`;
|
|
28
|
+
|
|
29
|
+
const HELP_COMMANDS: string = `ks commands
|
|
30
|
+
|
|
31
|
+
ks init Create a new keystore
|
|
32
|
+
ks password Change the keystore password
|
|
33
|
+
ks put <name> Store a secret in the keystore
|
|
34
|
+
ks get <name> Read a secret from the keystore
|
|
35
|
+
ks list List stored secrets
|
|
36
|
+
ks remove <name> Remove a stored secret
|
|
37
|
+
ks export Render a shell template from keystore secrets
|
|
38
|
+
ks session Emit a shell export statement for the password
|
|
39
|
+
ks help [topic] Show self-contained help on a specific topic`;
|
|
40
|
+
|
|
41
|
+
const HELP_PASSWORD: string = `ks password sources
|
|
42
|
+
|
|
43
|
+
Passwords may be supplied with:
|
|
44
|
+
--password-file <path>
|
|
45
|
+
--password-stdin
|
|
46
|
+
--password-env <name>
|
|
47
|
+
FGV_KS_PASSWORD
|
|
48
|
+
KS_PASSWORD
|
|
49
|
+
|
|
50
|
+
If no password source is provided, ks prompts interactively.
|
|
51
|
+
The session command can emit a shell export statement:
|
|
52
|
+
ks session --var FGV_KS_PASSWORD`;
|
|
53
|
+
|
|
54
|
+
const HELP_TEMPLATE: string = `ks export template format
|
|
55
|
+
|
|
56
|
+
Templates use simple Mustache-style variables such as {{xai}}.
|
|
57
|
+
Only simple variables are supported; sections and other advanced Mustache features are rejected.
|
|
58
|
+
|
|
59
|
+
Options:
|
|
60
|
+
--template-file <path> Read the shell template from a file
|
|
61
|
+
--template-string <text> Use an inline shell template string
|
|
62
|
+
--persist-missing Save prompted secrets back to the keystore
|
|
63
|
+
--clipboard Copy the rendered output to the clipboard
|
|
64
|
+
|
|
65
|
+
Missing secrets referenced by the template are prompted interactively.
|
|
66
|
+
Rendered values are shell-quoted before output.
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
ks export --template-file ./keystore.template.sh
|
|
70
|
+
ks export --template-string 'export XAI_API_KEY={{xai}}'`;
|
|
71
|
+
|
|
72
|
+
const HELP_TEXTS: Readonly<Record<HelpTopic, string>> = {
|
|
73
|
+
commands: HELP_COMMANDS,
|
|
74
|
+
overview: HELP_OVERVIEW,
|
|
75
|
+
password: HELP_PASSWORD,
|
|
76
|
+
template: HELP_TEMPLATE
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function normalizeHelpTopic(topic: string): string {
|
|
80
|
+
return topic.trim().toLowerCase();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatAvailableTopics(): string {
|
|
84
|
+
return AVAILABLE_HELP_TOPICS.join(', ');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getHelpText(topic: string | undefined = undefined, program?: Command): Result<string> {
|
|
88
|
+
if (topic === undefined || topic.trim().length === 0) {
|
|
89
|
+
return succeed(HELP_TEXTS.overview);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const normalized = normalizeHelpTopic(topic);
|
|
93
|
+
|
|
94
|
+
if (program !== undefined) {
|
|
95
|
+
const command = program.commands.find((candidate: Command) => candidate.name() === normalized);
|
|
96
|
+
if (command !== undefined && command.name() !== 'help') {
|
|
97
|
+
return succeed(command.helpInformation());
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
switch (normalized) {
|
|
102
|
+
case 'help':
|
|
103
|
+
case 'overview':
|
|
104
|
+
return succeed(HELP_TEXTS.overview);
|
|
105
|
+
case 'commands':
|
|
106
|
+
case 'command':
|
|
107
|
+
return succeed(HELP_TEXTS.commands);
|
|
108
|
+
case 'password':
|
|
109
|
+
case 'passwords':
|
|
110
|
+
return succeed(HELP_TEXTS.password);
|
|
111
|
+
case 'template':
|
|
112
|
+
return succeed(HELP_TEXTS.template);
|
|
113
|
+
default:
|
|
114
|
+
return fail(`Unknown help topic '${topic}'. Available topics: ${formatAvailableTopics()}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* \@fgv/ks — CLI tool for managing ts-extras keystore files.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { KsCli } from './app';
|
|
8
|
+
export {
|
|
9
|
+
changeKeystorePassword,
|
|
10
|
+
createKeystore,
|
|
11
|
+
loadKeystoreFile,
|
|
12
|
+
listSecrets,
|
|
13
|
+
openKeystore,
|
|
14
|
+
readSecret,
|
|
15
|
+
removeSecret,
|
|
16
|
+
resolveKeystorePath,
|
|
17
|
+
saveKeystoreFile,
|
|
18
|
+
storeSecret
|
|
19
|
+
} from './keystore';
|
|
20
|
+
export { defaultKeystorePath, copyTextToClipboard, promptHidden, readAllFromStdin, readTextFile } from './io';
|
|
21
|
+
export { extractTemplateVariables, renderShellTemplate, shellQuote } from './template';
|
package/src/io.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import * as readline from 'readline';
|
|
5
|
+
|
|
6
|
+
import clipboardy from 'clipboardy';
|
|
7
|
+
import { FileTree, FileTree as FileTreeNamespace } from '@fgv/ts-json-base';
|
|
8
|
+
import { Result, captureAsyncResult, captureResult, fail, succeed } from '@fgv/ts-utils';
|
|
9
|
+
|
|
10
|
+
function expandHome(filePath: string): string {
|
|
11
|
+
if (!filePath.startsWith('~')) {
|
|
12
|
+
return filePath;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const home = os.homedir();
|
|
16
|
+
if (filePath === '~') {
|
|
17
|
+
return home;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (filePath.startsWith('~/')) {
|
|
21
|
+
return path.join(home, filePath.substring(2));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return filePath;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolvePath(filePath: string): string {
|
|
28
|
+
return path.resolve(expandHome(filePath));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function defaultKeystorePath(): string {
|
|
32
|
+
return path.join(os.homedir(), '.fgv-ks');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readTextFile(filePath: string): Result<string> {
|
|
36
|
+
const resolvedPath = resolvePath(filePath);
|
|
37
|
+
return FileTree.forFilesystem().onSuccess((tree) =>
|
|
38
|
+
tree.getFile(resolvedPath).onSuccess((file) => file.getRawContents())
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function writeTextFile(filePath: string, contents: string): Result<string> {
|
|
43
|
+
const resolvedPath = resolvePath(filePath);
|
|
44
|
+
const directoryPath = path.dirname(resolvedPath);
|
|
45
|
+
const fileName = path.basename(resolvedPath);
|
|
46
|
+
|
|
47
|
+
return FileTree.forFilesystem({ mutable: true }).onSuccess((tree) => {
|
|
48
|
+
const accessors = tree.hal;
|
|
49
|
+
if (!FileTreeNamespace.isMutableAccessors(accessors)) {
|
|
50
|
+
return fail(`Unable to write '${resolvedPath}': filesystem access is read-only`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return accessors.createDirectory(directoryPath).onSuccess(() =>
|
|
54
|
+
tree.getDirectory(directoryPath).onSuccess((directory) => {
|
|
55
|
+
if (!FileTreeNamespace.isMutableDirectoryItem(directory)) {
|
|
56
|
+
return fail(`Unable to write '${resolvedPath}': directory is not mutable`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const tmpFileName = `${fileName}.tmp`;
|
|
60
|
+
return directory.createChildFile(tmpFileName, contents).onSuccess(() =>
|
|
61
|
+
captureResult(() => {
|
|
62
|
+
fs.renameSync(path.join(directoryPath, tmpFileName), resolvedPath);
|
|
63
|
+
return resolvedPath;
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function readAllFromStdin(): Promise<Result<string>> {
|
|
72
|
+
const result = await captureAsyncResult(async () => {
|
|
73
|
+
const chunks: string[] = [];
|
|
74
|
+
const input = process.stdin;
|
|
75
|
+
input.setEncoding('utf8');
|
|
76
|
+
input.resume();
|
|
77
|
+
|
|
78
|
+
for await (const chunk of input) {
|
|
79
|
+
chunks.push(String(chunk));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return chunks.join('');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return result.withErrorFormat((message: string) => `Failed to read from stdin: ${message}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function promptHidden(prompt: string): Promise<Result<string>> {
|
|
89
|
+
if (!process.stdin.isTTY) {
|
|
90
|
+
return fail(`Interactive prompt requires a TTY (prompt: ${prompt})`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return new Promise<Result<string>>((resolve) => {
|
|
94
|
+
const input = process.stdin;
|
|
95
|
+
const output = process.stderr;
|
|
96
|
+
const previousRawMode = input.isRaw;
|
|
97
|
+
let value = '';
|
|
98
|
+
let finished = false;
|
|
99
|
+
|
|
100
|
+
function onData(chunk: string): void {
|
|
101
|
+
for (const char of chunk) {
|
|
102
|
+
if (char === '\r' || char === '\n') {
|
|
103
|
+
finish(succeed(value));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (char === '\u0003') {
|
|
107
|
+
finish(fail('Prompt cancelled'));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (char === '\u007f' || char === '\b') {
|
|
111
|
+
value = value.slice(0, -1);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
value += char;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function finish(result: Result<string>): void {
|
|
119
|
+
if (finished) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
finished = true;
|
|
123
|
+
input.off('data', onData);
|
|
124
|
+
if (input.isTTY && input.isRaw && !previousRawMode) {
|
|
125
|
+
input.setRawMode(false);
|
|
126
|
+
}
|
|
127
|
+
input.pause();
|
|
128
|
+
output.write('\n');
|
|
129
|
+
resolve(result);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
output.write(prompt);
|
|
133
|
+
input.setRawMode(true);
|
|
134
|
+
input.setEncoding('utf8');
|
|
135
|
+
input.resume();
|
|
136
|
+
input.on('data', onData);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function promptVisible(prompt: string): Promise<Result<string>> {
|
|
141
|
+
if (!process.stdin.isTTY) {
|
|
142
|
+
return fail(`Interactive prompt requires a TTY (prompt: ${prompt})`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return new Promise<Result<string>>((resolve) => {
|
|
146
|
+
const input = process.stdin;
|
|
147
|
+
const output = process.stderr;
|
|
148
|
+
const interfaceInstance = readline.createInterface({ input, output, terminal: true });
|
|
149
|
+
let finished = false;
|
|
150
|
+
|
|
151
|
+
function finish(result: Result<string>): void {
|
|
152
|
+
if (finished) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
finished = true;
|
|
157
|
+
interfaceInstance.close();
|
|
158
|
+
output.write('\n');
|
|
159
|
+
resolve(result);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interfaceInstance.on('SIGINT', () => {
|
|
163
|
+
finish(fail('Prompt cancelled'));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
interfaceInstance.question(prompt, (answer: string) => {
|
|
167
|
+
finish(succeed(answer));
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function copyTextToClipboard(text: string): Promise<Result<'copied'>> {
|
|
173
|
+
const result = await captureAsyncResult(async () => {
|
|
174
|
+
await clipboardy.write(text);
|
|
175
|
+
return 'copied' as const;
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return result.withErrorFormat((message: string) => `Failed to copy to clipboard: ${message}`);
|
|
179
|
+
}
|
package/src/keystore.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
|
|
3
|
+
import { CryptoUtils } from '@fgv/ts-extras';
|
|
4
|
+
import { Result, captureResult, fail, succeed } from '@fgv/ts-utils';
|
|
5
|
+
|
|
6
|
+
import { defaultKeystorePath, readTextFile, resolvePath, writeTextFile } from './io';
|
|
7
|
+
|
|
8
|
+
export interface IKeystoreOpenResult {
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly keystore: CryptoUtils.KeyStore.KeyStore;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ISecretWriteOptions {
|
|
14
|
+
readonly description?: string;
|
|
15
|
+
readonly replace?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveKeystorePath(filePath?: string): string {
|
|
19
|
+
return resolvePath(filePath ?? defaultKeystorePath());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadKeystoreFile(resolvedPath: string): Result<CryptoUtils.KeyStore.IKeyStoreFile> {
|
|
23
|
+
return readTextFile(resolvedPath)
|
|
24
|
+
.onSuccess((contents) =>
|
|
25
|
+
captureResult(() => JSON.parse(contents)).withErrorFormat(
|
|
26
|
+
(msg) => `Invalid keystore file '${resolvedPath}': ${msg}`
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
.onSuccess((json) =>
|
|
30
|
+
CryptoUtils.KeyStore.Converters.keystoreFile
|
|
31
|
+
.convert(json)
|
|
32
|
+
.withErrorFormat((msg) => `Invalid keystore file '${resolvedPath}': ${msg}`)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function saveKeystoreFile(
|
|
37
|
+
filePath: string | undefined,
|
|
38
|
+
keystoreFile: CryptoUtils.KeyStore.IKeyStoreFile
|
|
39
|
+
): Result<string> {
|
|
40
|
+
const resolvedPath = resolveKeystorePath(filePath);
|
|
41
|
+
const serialized = `${JSON.stringify(keystoreFile, null, 2)}\n`;
|
|
42
|
+
return writeTextFile(resolvedPath, serialized);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function openKeystore(
|
|
46
|
+
filePath: string | undefined,
|
|
47
|
+
password: string
|
|
48
|
+
): Promise<Result<IKeystoreOpenResult>> {
|
|
49
|
+
const resolvedPath = resolveKeystorePath(filePath);
|
|
50
|
+
return loadKeystoreFile(resolvedPath)
|
|
51
|
+
.onSuccess((file) =>
|
|
52
|
+
CryptoUtils.KeyStore.KeyStore.open({
|
|
53
|
+
cryptoProvider: CryptoUtils.nodeCryptoProvider,
|
|
54
|
+
keystoreFile: file
|
|
55
|
+
})
|
|
56
|
+
)
|
|
57
|
+
.thenOnSuccess((store) => store.unlock(password))
|
|
58
|
+
.onSuccess((keystore) => succeed({ path: resolvedPath, keystore }));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function createKeystore(
|
|
62
|
+
filePath: string | undefined,
|
|
63
|
+
password: string
|
|
64
|
+
): Promise<Result<IKeystoreOpenResult>> {
|
|
65
|
+
const resolvedPath = resolveKeystorePath(filePath);
|
|
66
|
+
if (fs.existsSync(resolvedPath)) {
|
|
67
|
+
const readable = readTextFile(resolvedPath);
|
|
68
|
+
if (readable.isFailure()) {
|
|
69
|
+
return fail(`Keystore file at '${resolvedPath}' exists but cannot be read: ${readable.message}`);
|
|
70
|
+
}
|
|
71
|
+
return fail(`Keystore already exists at '${resolvedPath}'`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const created = CryptoUtils.KeyStore.KeyStore.create({
|
|
75
|
+
cryptoProvider: CryptoUtils.nodeCryptoProvider
|
|
76
|
+
});
|
|
77
|
+
if (created.isFailure()) {
|
|
78
|
+
return fail(`Failed to create keystore: ${created.message}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const keystore = created.value;
|
|
82
|
+
const initialized = await keystore.initialize(password);
|
|
83
|
+
if (initialized.isFailure()) {
|
|
84
|
+
return fail(`Failed to initialize keystore: ${initialized.message}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const saved = await keystore.save(password);
|
|
88
|
+
if (saved.isFailure()) {
|
|
89
|
+
return fail(`Failed to save keystore: ${saved.message}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const savedFile = saveKeystoreFile(resolvedPath, saved.value);
|
|
93
|
+
if (savedFile.isFailure()) {
|
|
94
|
+
return fail(`Failed to write keystore file: ${savedFile.message}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return succeed({ path: resolvedPath, keystore });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function changeKeystorePassword(
|
|
101
|
+
filePath: string | undefined,
|
|
102
|
+
currentPassword: string,
|
|
103
|
+
nextPassword: string
|
|
104
|
+
): Promise<Result<IKeystoreOpenResult>> {
|
|
105
|
+
const opened = await openKeystore(filePath, currentPassword);
|
|
106
|
+
if (opened.isFailure()) {
|
|
107
|
+
return fail(opened.message);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const changed = await opened.value.keystore.changePassword(currentPassword, nextPassword);
|
|
111
|
+
if (changed.isFailure()) {
|
|
112
|
+
return fail(`Failed to change password: ${changed.message}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const saved = await opened.value.keystore.save(nextPassword);
|
|
116
|
+
if (saved.isFailure()) {
|
|
117
|
+
return fail(`Failed to save keystore: ${saved.message}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const persisted = saveKeystoreFile(opened.value.path, saved.value);
|
|
121
|
+
if (persisted.isFailure()) {
|
|
122
|
+
return fail(`Failed to write keystore file: ${persisted.message}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return succeed(opened.value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function storeSecret(
|
|
129
|
+
filePath: string | undefined,
|
|
130
|
+
password: string,
|
|
131
|
+
name: string,
|
|
132
|
+
value: string,
|
|
133
|
+
options?: ISecretWriteOptions
|
|
134
|
+
): Promise<Result<string>> {
|
|
135
|
+
const opened = await openKeystore(filePath, password);
|
|
136
|
+
if (opened.isFailure()) {
|
|
137
|
+
return fail(opened.message);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const writeResult = await opened.value.keystore.importApiKey(name, value, {
|
|
141
|
+
description: options?.description,
|
|
142
|
+
replace: options?.replace
|
|
143
|
+
});
|
|
144
|
+
if (writeResult.isFailure()) {
|
|
145
|
+
return fail(`Failed to store secret '${name}': ${writeResult.message}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const saved = await opened.value.keystore.save(password);
|
|
149
|
+
if (saved.isFailure()) {
|
|
150
|
+
return fail(`Failed to save keystore: ${saved.message}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return saveKeystoreFile(opened.value.path, saved.value).withErrorFormat(
|
|
154
|
+
(msg) => `Failed to write keystore file: ${msg}`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function readSecret(
|
|
159
|
+
filePath: string | undefined,
|
|
160
|
+
password: string,
|
|
161
|
+
name: string
|
|
162
|
+
): Promise<Result<string>> {
|
|
163
|
+
return (await openKeystore(filePath, password)).onSuccess((opened) => opened.keystore.getApiKey(name));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function listSecrets(
|
|
167
|
+
filePath: string | undefined,
|
|
168
|
+
password: string
|
|
169
|
+
): Promise<Result<readonly string[]>> {
|
|
170
|
+
return (await openKeystore(filePath, password)).onSuccess((opened) => opened.keystore.listSecrets());
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function removeSecret(
|
|
174
|
+
filePath: string | undefined,
|
|
175
|
+
password: string,
|
|
176
|
+
name: string
|
|
177
|
+
): Promise<Result<string>> {
|
|
178
|
+
const opened = await openKeystore(filePath, password);
|
|
179
|
+
if (opened.isFailure()) {
|
|
180
|
+
return fail(opened.message);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const removed = await opened.value.keystore.removeSecret(name);
|
|
184
|
+
if (removed.isFailure()) {
|
|
185
|
+
return fail(`Failed to remove secret '${name}': ${removed.message}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const saved = await opened.value.keystore.save(password);
|
|
189
|
+
if (saved.isFailure()) {
|
|
190
|
+
return fail(`Failed to save keystore: ${saved.message}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return saveKeystoreFile(opened.value.path, saved.value).withErrorFormat(
|
|
194
|
+
(msg) => `Failed to write keystore file: ${msg}`
|
|
195
|
+
);
|
|
196
|
+
}
|
package/src/template.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Mustache } from '@fgv/ts-extras';
|
|
2
|
+
import { Result, fail, succeed } from '@fgv/ts-utils';
|
|
3
|
+
|
|
4
|
+
// Mustache is used only for parsing/variable extraction. Rendering is done via regex
|
|
5
|
+
// because Mustache's renderer HTML-escapes output, but we need shell-quoting instead.
|
|
6
|
+
const TEMPLATE_VARIABLE_PATTERN: RegExp = /\{\{\{?\s*(?:&\s*)?([^{}&\s]+)\s*\}?\}\}/g;
|
|
7
|
+
|
|
8
|
+
function shellEscape(value: string): string {
|
|
9
|
+
if (value.length === 0) {
|
|
10
|
+
return "''";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isSupportedToken(tokenType: string): boolean {
|
|
17
|
+
return tokenType === 'name' || tokenType === '&';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function extractTemplateVariables(template: string): Result<readonly string[]> {
|
|
21
|
+
return Mustache.MustacheTemplate.create(template).onSuccess((parsed) => {
|
|
22
|
+
const variables = parsed.extractVariables();
|
|
23
|
+
const unsupported = variables.find((variable) => !isSupportedToken(variable.tokenType));
|
|
24
|
+
if (unsupported) {
|
|
25
|
+
return fail(
|
|
26
|
+
`Unsupported template token '${unsupported.tokenType}' for variable '${unsupported.name}'. ` +
|
|
27
|
+
'Only simple variable substitutions are supported in v1.'
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return succeed(parsed.extractVariableNames());
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function renderShellTemplate(
|
|
36
|
+
template: string,
|
|
37
|
+
context: Readonly<Record<string, string>>
|
|
38
|
+
): Result<string> {
|
|
39
|
+
return extractTemplateVariables(template).onSuccess((variables) => {
|
|
40
|
+
for (const variable of variables) {
|
|
41
|
+
if (!Object.prototype.hasOwnProperty.call(context, variable)) {
|
|
42
|
+
return fail(`Missing template variable '${variable}'`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return succeed(
|
|
47
|
+
template.replace(TEMPLATE_VARIABLE_PATTERN, (__: string, rawName: string) => {
|
|
48
|
+
const name = rawName.trim();
|
|
49
|
+
const value = context[name];
|
|
50
|
+
if (value === undefined) {
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
return shellEscape(value);
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function shellQuote(value: string): string {
|
|
60
|
+
return shellEscape(value);
|
|
61
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
jest.mock('../../src/io', () => {
|
|
2
|
+
const actual = jest.requireActual('../../src/io') as typeof import('../../src/io');
|
|
3
|
+
|
|
4
|
+
return {
|
|
5
|
+
...actual,
|
|
6
|
+
promptHidden: jest.fn(),
|
|
7
|
+
promptVisible: jest.fn()
|
|
8
|
+
};
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
jest.mock('../../src/keystore', () => {
|
|
12
|
+
const actual = jest.requireActual('../../src/keystore') as typeof import('../../src/keystore');
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
storeSecret: jest.fn()
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
import '@fgv/ts-utils-jest';
|
|
21
|
+
|
|
22
|
+
import { succeed } from '@fgv/ts-utils';
|
|
23
|
+
|
|
24
|
+
import { KsCli } from '../../src/app';
|
|
25
|
+
import { promptHidden, promptVisible } from '../../src/io';
|
|
26
|
+
import { storeSecret } from '../../src/keystore';
|
|
27
|
+
|
|
28
|
+
describe('KsCli put command', () => {
|
|
29
|
+
const promptHiddenMock = jest.mocked(promptHidden);
|
|
30
|
+
const promptVisibleMock = jest.mocked(promptVisible);
|
|
31
|
+
const storeSecretMock = jest.mocked(storeSecret);
|
|
32
|
+
const originalFgvPassword = process.env.FGV_KS_PASSWORD;
|
|
33
|
+
const originalKsPassword = process.env.KS_PASSWORD;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
process.env.FGV_KS_PASSWORD = 'test-password';
|
|
37
|
+
delete process.env.KS_PASSWORD;
|
|
38
|
+
|
|
39
|
+
promptHiddenMock.mockReset();
|
|
40
|
+
promptVisibleMock.mockReset();
|
|
41
|
+
storeSecretMock.mockReset();
|
|
42
|
+
|
|
43
|
+
promptHiddenMock.mockResolvedValue(succeed('secret-value'));
|
|
44
|
+
promptVisibleMock.mockResolvedValue(succeed('secret-name'));
|
|
45
|
+
storeSecretMock.mockResolvedValue(succeed('/mock/keystore/path'));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterAll(() => {
|
|
49
|
+
if (originalFgvPassword === undefined) {
|
|
50
|
+
delete process.env.FGV_KS_PASSWORD;
|
|
51
|
+
} else {
|
|
52
|
+
process.env.FGV_KS_PASSWORD = originalFgvPassword;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (originalKsPassword === undefined) {
|
|
56
|
+
delete process.env.KS_PASSWORD;
|
|
57
|
+
} else {
|
|
58
|
+
process.env.KS_PASSWORD = originalKsPassword;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('prompts for both the secret name and value when no name is provided', async () => {
|
|
63
|
+
await new KsCli().run(['node', 'ks', 'put']);
|
|
64
|
+
|
|
65
|
+
expect(promptVisibleMock).toHaveBeenCalledWith('Secret name: ');
|
|
66
|
+
expect(promptHiddenMock).toHaveBeenCalledWith('Secret value: ');
|
|
67
|
+
expect(storeSecretMock).toHaveBeenCalledWith(
|
|
68
|
+
expect.any(String),
|
|
69
|
+
'test-password',
|
|
70
|
+
'secret-name',
|
|
71
|
+
'secret-value',
|
|
72
|
+
{
|
|
73
|
+
description: undefined,
|
|
74
|
+
replace: false
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('uses --name and only prompts for the secret value', async () => {
|
|
80
|
+
await new KsCli().run(['node', 'ks', 'put', '--name', 'api-key']);
|
|
81
|
+
|
|
82
|
+
expect(promptVisibleMock).not.toHaveBeenCalled();
|
|
83
|
+
expect(promptHiddenMock).toHaveBeenCalledWith('Secret value: ');
|
|
84
|
+
expect(storeSecretMock).toHaveBeenCalledWith(
|
|
85
|
+
expect.any(String),
|
|
86
|
+
'test-password',
|
|
87
|
+
'api-key',
|
|
88
|
+
'secret-value',
|
|
89
|
+
{
|
|
90
|
+
description: undefined,
|
|
91
|
+
replace: false
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('continues to accept a positional secret name for compatibility', async () => {
|
|
97
|
+
await new KsCli().run(['node', 'ks', 'put', 'legacy-key']);
|
|
98
|
+
|
|
99
|
+
expect(promptVisibleMock).not.toHaveBeenCalled();
|
|
100
|
+
expect(promptHiddenMock).toHaveBeenCalledWith('Secret value: ');
|
|
101
|
+
expect(storeSecretMock).toHaveBeenCalledWith(
|
|
102
|
+
expect.any(String),
|
|
103
|
+
'test-password',
|
|
104
|
+
'legacy-key',
|
|
105
|
+
'secret-value',
|
|
106
|
+
{
|
|
107
|
+
description: undefined,
|
|
108
|
+
replace: false
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
});
|