@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/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
+ }
@@ -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
+ }
@@ -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,5 @@
1
+ module.exports = {
2
+ write: async function write() {
3
+ return undefined;
4
+ }
5
+ };
@@ -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
+ });