llm-relay 0.44.0 → 0.46.0
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 +1 -0
- package/dist/authEnv.d.ts +20 -12
- package/dist/authEnv.js +76 -36
- package/dist/authEnv.js.map +1 -1
- package/dist/backend.d.ts +26 -2
- package/dist/backend.js +79 -9
- package/dist/backend.js.map +1 -1
- package/dist/candidates.d.ts +2 -0
- package/dist/candidates.js +2 -1
- package/dist/candidates.js.map +1 -1
- package/dist/circuit-breaker.d.ts +2 -0
- package/dist/circuit-breaker.js +17 -0
- package/dist/circuit-breaker.js.map +1 -1
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +193 -5
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +4 -3
- package/dist/config.js +87 -7
- package/dist/config.js.map +1 -1
- package/dist/cooldown-clear.d.ts +2 -0
- package/dist/cooldown-clear.js +20 -4
- package/dist/cooldown-clear.js.map +1 -1
- package/dist/credential-fleet.d.ts +5 -4
- package/dist/credential-fleet.js +9 -9
- package/dist/credential-fleet.js.map +1 -1
- package/dist/dialect-stream.d.ts +2 -1
- package/dist/dialect-stream.js +31 -3
- package/dist/dialect-stream.js.map +1 -1
- package/dist/dotenv.d.ts +2 -0
- package/dist/dotenv.js +9 -0
- package/dist/dotenv.js.map +1 -1
- package/dist/key-checker.d.ts +1 -0
- package/dist/key-checker.js +3 -2
- package/dist/key-checker.js.map +1 -1
- package/dist/key-import.d.ts +7 -0
- package/dist/key-import.js +52 -0
- package/dist/key-import.js.map +1 -1
- package/dist/keys-cli.d.ts +48 -0
- package/dist/keys-cli.js +693 -0
- package/dist/keys-cli.js.map +1 -0
- package/dist/keystore.d.ts +130 -0
- package/dist/keystore.js +1195 -0
- package/dist/keystore.js.map +1 -0
- package/dist/openai-dialect.d.ts +9 -2
- package/dist/openai-dialect.js +30 -9
- package/dist/openai-dialect.js.map +1 -1
- package/dist/os-keyring.d.ts +88 -0
- package/dist/os-keyring.js +476 -0
- package/dist/os-keyring.js.map +1 -0
- package/dist/registry.d.ts +2 -1
- package/dist/registry.js +1 -0
- package/dist/registry.js.map +1 -1
- package/dist/resolved-attempt.d.ts +2 -1
- package/dist/resolved-attempt.js +3 -2
- package/dist/resolved-attempt.js.map +1 -1
- package/dist/routes/admin.js +8 -1
- package/dist/routes/admin.js.map +1 -1
- package/dist/secret-file-acl.d.ts +47 -12
- package/dist/secret-file-acl.js +151 -16
- package/dist/secret-file-acl.js.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.js +12 -2
- package/dist/server.js.map +1 -1
- package/dist/stream-commit.d.ts +7 -0
- package/dist/stream-commit.js +36 -21
- package/dist/stream-commit.js.map +1 -1
- package/dist/target-facts.d.ts +5 -0
- package/dist/target-facts.js +10 -3
- package/dist/target-facts.js.map +1 -1
- package/dist/tool-dialects.d.ts +68 -1
- package/dist/tool-dialects.js +41 -1
- package/dist/tool-dialects.js.map +1 -1
- package/package.json +1 -1
package/dist/keys-cli.js
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { Writable } from "node:stream";
|
|
4
|
+
import { curatedEnvNames, resolveCredential, } from "./authEnv.js";
|
|
5
|
+
import { providerCredentialSlots, resolveCredentialSlot, } from "./credential-fleet.js";
|
|
6
|
+
import { CREDENTIAL_LABEL_PATTERN, makeCredentialId, parseCredentialId, } from "./credential-id.js";
|
|
7
|
+
import { addEntry, createEncryptedKeystoreExport, decryptEncryptedKeystoreExport, isEncryptedKeystoreExport, keystoreStatus, keystoreWrapMode, keyIsPresent, listEntries, removeEntry, restoreEntryFromExport, revokeEntry, rotateEntry, setDisabled, verifyKeystoreUnlock, KeystoreEntryExistsError, } from "./keystore.js";
|
|
8
|
+
import { KeyringPassphraseRequiredError, KeyringUnavailableError, } from "./os-keyring.js";
|
|
9
|
+
import { matchCredentialImportName, parseCredentialImport } from "./key-import.js";
|
|
10
|
+
import { defaultEnvPath } from "./dotenv.js";
|
|
11
|
+
import { validateProviderKeys } from "./key-checker.js";
|
|
12
|
+
import { createControlAuthorization, resolveControlAuthorizationConfigDir, } from "./control-authorization.js";
|
|
13
|
+
import { restrictSecretFileOnWindowsSync } from "./secret-file-acl.js";
|
|
14
|
+
export class KeysCliError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "KeysCliError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export const KEY_CUSTODY_THREAT_BOUNDARY = [
|
|
21
|
+
"- **What it stops:** a copy of `keystore.json` alone — committed to a repo, synced to cloud storage, pulled from a partial backup, or moved to another machine — is useless.",
|
|
22
|
+
"- **What it does not stop:** a copy of the **whole user profile together with the account password**. `%APPDATA%\\Microsoft\\Protect` travels in a whole-profile backup, and an offline attacker holding it plus the password (or its hash, or a domain backup key) decrypts CurrentUser blobs. This is routine, not exotic.",
|
|
23
|
+
"- **What nothing user-side stops:** code already running as you.",
|
|
24
|
+
"- An **admin-forced password reset** on a non-domain machine destroys the DPAPI master key and every entry becomes undecryptable. A normal password change is fine.",
|
|
25
|
+
].join("\n");
|
|
26
|
+
class MutedPromptOutput extends Writable {
|
|
27
|
+
target;
|
|
28
|
+
muted = false;
|
|
29
|
+
constructor(target) {
|
|
30
|
+
super();
|
|
31
|
+
this.target = target;
|
|
32
|
+
}
|
|
33
|
+
_write(chunk, _encoding, callback) {
|
|
34
|
+
try {
|
|
35
|
+
if (!this.muted)
|
|
36
|
+
this.target.write(Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk);
|
|
37
|
+
callback();
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
callback(error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const pipedLineReaders = new WeakMap();
|
|
45
|
+
function sharedPipedLineReader(input) {
|
|
46
|
+
const existing = pipedLineReaders.get(input);
|
|
47
|
+
if (existing !== undefined)
|
|
48
|
+
return existing;
|
|
49
|
+
const state = {
|
|
50
|
+
values: [],
|
|
51
|
+
waiters: [],
|
|
52
|
+
ended: input.readableEnded,
|
|
53
|
+
};
|
|
54
|
+
pipedLineReaders.set(input, state);
|
|
55
|
+
if (state.ended)
|
|
56
|
+
return state;
|
|
57
|
+
const line = createInterface({ input, crlfDelay: Infinity, terminal: false });
|
|
58
|
+
const fail = () => {
|
|
59
|
+
if (state.failure !== undefined)
|
|
60
|
+
return;
|
|
61
|
+
const failure = new KeysCliError("secret input failed");
|
|
62
|
+
state.failure = failure;
|
|
63
|
+
state.values.length = 0;
|
|
64
|
+
for (const waiter of state.waiters.splice(0))
|
|
65
|
+
waiter.reject(failure);
|
|
66
|
+
line.close();
|
|
67
|
+
};
|
|
68
|
+
line.on("line", (value) => {
|
|
69
|
+
const waiter = state.waiters.shift();
|
|
70
|
+
if (waiter === undefined)
|
|
71
|
+
state.values.push(value);
|
|
72
|
+
else
|
|
73
|
+
waiter.resolve(value);
|
|
74
|
+
});
|
|
75
|
+
line.once("close", () => {
|
|
76
|
+
state.ended = true;
|
|
77
|
+
input.removeListener("error", fail);
|
|
78
|
+
if (state.failure !== undefined)
|
|
79
|
+
return;
|
|
80
|
+
for (const waiter of state.waiters.splice(0))
|
|
81
|
+
waiter.resolve("");
|
|
82
|
+
});
|
|
83
|
+
line.once("error", fail);
|
|
84
|
+
input.once("error", fail);
|
|
85
|
+
return state;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* One-line secret input. TTY input is handled by readline in terminal/raw mode while its output
|
|
89
|
+
* is muted after the prompt; closing and every failure path restore the caller's raw-mode state.
|
|
90
|
+
* Each piped prompt consumes one logical line from a shared reader and never prints a prompt.
|
|
91
|
+
*/
|
|
92
|
+
export async function readMaskedOrPipedLine(prompt, input = process.stdin, output = process.stderr) {
|
|
93
|
+
if (input.isTTY !== true) {
|
|
94
|
+
const reader = sharedPipedLineReader(input);
|
|
95
|
+
const buffered = reader.values.shift();
|
|
96
|
+
if (buffered !== undefined)
|
|
97
|
+
return buffered;
|
|
98
|
+
if (reader.failure !== undefined)
|
|
99
|
+
throw reader.failure;
|
|
100
|
+
if (reader.ended || input.readableEnded)
|
|
101
|
+
return "";
|
|
102
|
+
return await new Promise((resolve, reject) => {
|
|
103
|
+
reader.waiters.push({ resolve, reject });
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const priorRaw = input.isRaw;
|
|
107
|
+
const masked = new MutedPromptOutput(output);
|
|
108
|
+
const line = createInterface({ input, output: masked, terminal: true });
|
|
109
|
+
return await new Promise((resolve, reject) => {
|
|
110
|
+
let settled = false;
|
|
111
|
+
const finish = (error, value = "") => {
|
|
112
|
+
if (settled)
|
|
113
|
+
return;
|
|
114
|
+
settled = true;
|
|
115
|
+
process.removeListener("SIGINT", onSigint);
|
|
116
|
+
try {
|
|
117
|
+
line.close();
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
try {
|
|
121
|
+
if (typeof input.setRawMode === "function")
|
|
122
|
+
input.setRawMode(priorRaw === true);
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
output.write("\n");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (error !== null)
|
|
129
|
+
reject(error);
|
|
130
|
+
else
|
|
131
|
+
resolve(value);
|
|
132
|
+
};
|
|
133
|
+
const onSigint = () => finish(new KeysCliError("secret input interrupted"));
|
|
134
|
+
process.once("SIGINT", onSigint);
|
|
135
|
+
line.once("SIGINT", onSigint);
|
|
136
|
+
line.once("error", () => finish(new KeysCliError("secret input failed")));
|
|
137
|
+
line.once("close", () => finish(new KeysCliError("secret input ended before a line was read")));
|
|
138
|
+
line.question(prompt, (value) => finish(null, value));
|
|
139
|
+
masked.muted = true;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function output(deps, value) {
|
|
143
|
+
(deps.write ?? ((message) => { process.stdout.write(message); }))(value);
|
|
144
|
+
}
|
|
145
|
+
async function readSecret(deps, prompt, purpose, allowWhitespace = false) {
|
|
146
|
+
let value;
|
|
147
|
+
try {
|
|
148
|
+
value = await (deps.readSecret ?? ((message) => readMaskedOrPipedLine(message, deps.stdin ?? process.stdin, deps.promptOutput ?? process.stderr)))(prompt, purpose);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (error instanceof KeysCliError)
|
|
152
|
+
throw error;
|
|
153
|
+
throw new KeysCliError("secret input failed");
|
|
154
|
+
}
|
|
155
|
+
if (value.endsWith("\r\n"))
|
|
156
|
+
value = value.slice(0, -2);
|
|
157
|
+
else if (value.endsWith("\n"))
|
|
158
|
+
value = value.slice(0, -1);
|
|
159
|
+
if (value.includes("\r") || value.includes("\n")) {
|
|
160
|
+
throw new KeysCliError("secret input must be exactly one line");
|
|
161
|
+
}
|
|
162
|
+
if (value.length === 0 || (!allowWhitespace && !keyIsPresent(value))) {
|
|
163
|
+
throw new KeysCliError("empty secret refused");
|
|
164
|
+
}
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
function selectAddTarget(cfg, provider, requestedLabel, requestedEnvName) {
|
|
168
|
+
const configured = cfg.providers[provider];
|
|
169
|
+
if (configured === undefined) {
|
|
170
|
+
throw new KeysCliError(`unknown provider: no provider "${provider}" exists in the loaded config`);
|
|
171
|
+
}
|
|
172
|
+
// This check precedes the no-auth declaration check because valid passthrough providers
|
|
173
|
+
// necessarily omit provider-owned auth declarations; both named refusals must stay reachable.
|
|
174
|
+
const hasProviderOwnedDeclaration = configured.authEnv !== undefined ||
|
|
175
|
+
(configured.credentials?.some((slot) => slot.authEnv.length > 0) ?? false);
|
|
176
|
+
const resolvesPassthrough = configured.credentialMode === "passthrough" ||
|
|
177
|
+
(configured.kind === "anthropic" && configured.credentialMode !== "contained" &&
|
|
178
|
+
!hasProviderOwnedDeclaration);
|
|
179
|
+
if (resolvesPassthrough) {
|
|
180
|
+
throw new KeysCliError(`passthrough provider: provider "${provider}" resolves credentialMode "passthrough"`);
|
|
181
|
+
}
|
|
182
|
+
const declaredSlots = providerCredentialSlots(provider, configured)
|
|
183
|
+
.filter((slot) => slot.authEnv !== undefined);
|
|
184
|
+
if (declaredSlots.length === 0) {
|
|
185
|
+
throw new KeysCliError(`no auth declaration: provider "${provider}" declares neither authEnv nor a credentials[] slot naming one`);
|
|
186
|
+
}
|
|
187
|
+
if (requestedLabel !== undefined && !CREDENTIAL_LABEL_PATTERN.test(requestedLabel)) {
|
|
188
|
+
throw new KeysCliError("invalid label: --label must match [A-Za-z0-9_.-]{1,32}");
|
|
189
|
+
}
|
|
190
|
+
const declaredNames = declaredSlots.map((slot) => slot.authEnv);
|
|
191
|
+
const allowedNames = new Set([...declaredNames, ...curatedEnvNames(provider)]);
|
|
192
|
+
if (requestedEnvName !== undefined && !allowedNames.has(requestedEnvName)) {
|
|
193
|
+
throw new KeysCliError(`undeclared env name: --env-name "${requestedEnvName}" is outside provider "${provider}" declared names and curated aliases`);
|
|
194
|
+
}
|
|
195
|
+
const envSlot = requestedEnvName === undefined
|
|
196
|
+
? undefined
|
|
197
|
+
: declaredSlots.find((candidate) => candidate.authEnv === requestedEnvName);
|
|
198
|
+
const labelSlot = requestedLabel === undefined
|
|
199
|
+
? undefined
|
|
200
|
+
: declaredSlots.find((candidate) => candidate.label === requestedLabel);
|
|
201
|
+
if (requestedLabel !== undefined && labelSlot === undefined) {
|
|
202
|
+
throw new KeysCliError(`credential identity mismatch: --label "${requestedLabel}" is not a configured slot for provider "${provider}"`);
|
|
203
|
+
}
|
|
204
|
+
if (envSlot !== undefined && labelSlot !== undefined && envSlot !== labelSlot) {
|
|
205
|
+
throw new KeysCliError(`credential identity mismatch: --label "${requestedLabel}" and --env-name "${requestedEnvName}" select different configured slots`);
|
|
206
|
+
}
|
|
207
|
+
let slot = envSlot ?? labelSlot;
|
|
208
|
+
if (configured.authEnv !== undefined) {
|
|
209
|
+
slot = declaredSlots[0];
|
|
210
|
+
if (requestedLabel !== undefined && requestedLabel !== slot.label) {
|
|
211
|
+
throw new KeysCliError(`credential identity mismatch: legacy authEnv uses credential id ${slot.credentialId}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else if (slot === undefined && declaredSlots.length === 1) {
|
|
215
|
+
slot = declaredSlots[0];
|
|
216
|
+
}
|
|
217
|
+
else if (slot === undefined && requestedLabel === undefined && requestedEnvName === undefined) {
|
|
218
|
+
slot = declaredSlots.find((candidate) => candidate.label === "default");
|
|
219
|
+
}
|
|
220
|
+
if (slot === undefined) {
|
|
221
|
+
throw new KeysCliError(`ambiguous credential slot: provider "${provider}" has multiple credentials[] slots; use --env-name or a matching --label`);
|
|
222
|
+
}
|
|
223
|
+
// credentials[] fleet slots resolve declared-only. For those slots, a curated alias is accepted
|
|
224
|
+
// as input vocabulary but normalized to the selected slot's declared authEnv; legacy authEnv
|
|
225
|
+
// providers retain an explicitly requested curated alias.
|
|
226
|
+
const envName = configured.credentials !== undefined
|
|
227
|
+
? slot.authEnv
|
|
228
|
+
: (requestedEnvName ?? slot.authEnv);
|
|
229
|
+
const label = slot.label;
|
|
230
|
+
return {
|
|
231
|
+
provider,
|
|
232
|
+
config: configured,
|
|
233
|
+
slot,
|
|
234
|
+
envName,
|
|
235
|
+
label,
|
|
236
|
+
credentialId: makeCredentialId(provider, label),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function envFilePath(deps) {
|
|
240
|
+
return deps.envFilePath ?? defaultEnvPath();
|
|
241
|
+
}
|
|
242
|
+
function describeShadow(resolution, deps) {
|
|
243
|
+
const envName = resolution.envName ?? "(unknown variable)";
|
|
244
|
+
return resolution.source === "env-file"
|
|
245
|
+
? `$${envName} from env-file ${envFilePath(deps)}`
|
|
246
|
+
: `$${envName} from the process environment`;
|
|
247
|
+
}
|
|
248
|
+
function resolveDescriptor(cfg, entry, deps) {
|
|
249
|
+
const configured = cfg.providers[entry.provider];
|
|
250
|
+
const env = deps.env ?? process.env;
|
|
251
|
+
if (configured === undefined) {
|
|
252
|
+
return resolveCredential(entry.envName, env, entry.provider, deps.keystore);
|
|
253
|
+
}
|
|
254
|
+
const slots = providerCredentialSlots(entry.provider, configured);
|
|
255
|
+
const idSlot = slots.find((slot) => slot.credentialId === entry.id);
|
|
256
|
+
if (idSlot !== undefined)
|
|
257
|
+
return resolveCredentialSlot(idSlot, env, deps.keystore);
|
|
258
|
+
const envSlot = slots.find((slot) => slot.authEnv === entry.envName);
|
|
259
|
+
if (envSlot !== undefined)
|
|
260
|
+
return resolveCredentialSlot(envSlot, env, deps.keystore);
|
|
261
|
+
const legacy = slots.find((slot) => slot.origin === "legacy-authEnv");
|
|
262
|
+
if (legacy !== undefined)
|
|
263
|
+
return resolveCredentialSlot(legacy, env, deps.keystore);
|
|
264
|
+
return resolveCredential(entry.envName, env, entry.provider, deps.keystore);
|
|
265
|
+
}
|
|
266
|
+
async function storeOptionsForWrite(deps, prompt = "Keystore passphrase: ") {
|
|
267
|
+
const base = deps.keystore ?? {};
|
|
268
|
+
const mode = keystoreWrapMode(base);
|
|
269
|
+
if ((mode === "passphrase" || (mode === null && base.mode === "passphrase")) &&
|
|
270
|
+
base.passphrase === undefined) {
|
|
271
|
+
const passphrase = await readSecret(deps, prompt, "keystore-passphrase", true);
|
|
272
|
+
return { ...base, mode: "passphrase", passphrase };
|
|
273
|
+
}
|
|
274
|
+
return base;
|
|
275
|
+
}
|
|
276
|
+
async function withFreshPassphraseFallback(deps, initial, operation) {
|
|
277
|
+
try {
|
|
278
|
+
return { value: operation(initial), opts: initial };
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (!(error instanceof KeyringUnavailableError) &&
|
|
282
|
+
!(error instanceof KeyringPassphraseRequiredError))
|
|
283
|
+
throw error;
|
|
284
|
+
const passphrase = await readSecret(deps, "Create keystore passphrase: ", "keystore-passphrase", true);
|
|
285
|
+
const opts = { ...initial, mode: "passphrase", passphrase };
|
|
286
|
+
return { value: operation(opts), opts };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
export async function runKeysAddLocal(cfg, provider, options = {}, deps = {}) {
|
|
290
|
+
if (provider === undefined || provider.length === 0) {
|
|
291
|
+
throw new KeysCliError("usage: keys add <provider> [--label <label>] [--env-name <NAME>] [--check]");
|
|
292
|
+
}
|
|
293
|
+
const target = selectAddTarget(cfg, provider, options.label, options.envName);
|
|
294
|
+
const secret = await readSecret(deps, "Credential: ", "credential");
|
|
295
|
+
const prepared = await storeOptionsForWrite(deps);
|
|
296
|
+
const stored = await withFreshPassphraseFallback(deps, prepared, (keystore) => addEntry({
|
|
297
|
+
id: target.credentialId,
|
|
298
|
+
provider: target.provider,
|
|
299
|
+
envName: target.envName,
|
|
300
|
+
value: secret,
|
|
301
|
+
}, keystore));
|
|
302
|
+
output(deps, `Stored ${stored.value.id} for $${stored.value.envName}.\n`);
|
|
303
|
+
const winning = resolveCredentialSlot(target.slot, deps.env ?? process.env, stored.opts);
|
|
304
|
+
if (winning.source === "env" || winning.source === "env-file") {
|
|
305
|
+
output(deps, `Warning: the key is stored but shadowed by ${describeShadow(winning, deps)}.\n`);
|
|
306
|
+
}
|
|
307
|
+
else if (winning.source === undefined) {
|
|
308
|
+
output(deps, "Warning: the key is stored but the credential resolver cannot currently select it; inspect the store status with `llm-relay keys list`.\n");
|
|
309
|
+
}
|
|
310
|
+
output(deps, `${KEY_CUSTODY_THREAT_BOUNDARY}\n`);
|
|
311
|
+
if (options.check === true) {
|
|
312
|
+
if (winning.source === undefined) {
|
|
313
|
+
output(deps, `Check skipped: the stored key ${target.credentialId} was NOT probed because the credential resolver selected no source.\n`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const checkCfg = { ...cfg, providers: { [provider]: target.config } };
|
|
317
|
+
const results = await (deps.validateKeys?.(checkCfg, deps.env ?? process.env) ??
|
|
318
|
+
validateProviderKeys(checkCfg, deps.keyCheckFetch ?? fetch, {
|
|
319
|
+
env: deps.env ?? process.env,
|
|
320
|
+
}));
|
|
321
|
+
for (const result of results) {
|
|
322
|
+
const subject = result.credentialId === target.credentialId &&
|
|
323
|
+
(winning.source === "env" || winning.source === "env-file")
|
|
324
|
+
? `Checked ${describeShadow(winning, deps)} — the stored key was NOT probed`
|
|
325
|
+
: `Check ${result.credentialId}`;
|
|
326
|
+
output(deps, `${subject}: ${result.status.toUpperCase()} — ${result.message}\n`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function dateCell(value) {
|
|
331
|
+
return value === null ? "—" : new Date(value).toISOString();
|
|
332
|
+
}
|
|
333
|
+
function entryState(entry, now) {
|
|
334
|
+
const states = [];
|
|
335
|
+
if (entry.disabled)
|
|
336
|
+
states.push("disabled");
|
|
337
|
+
if (entry.revokedAt !== null)
|
|
338
|
+
states.push("revoked");
|
|
339
|
+
if (entry.expiresAt !== null && entry.expiresAt <= now)
|
|
340
|
+
states.push("expired");
|
|
341
|
+
return states.length === 0 ? "active" : states.join(",");
|
|
342
|
+
}
|
|
343
|
+
export function runKeysListLocal(cfg, deps = {}) {
|
|
344
|
+
const status = keystoreStatus(deps.keystore);
|
|
345
|
+
let entries = [];
|
|
346
|
+
try {
|
|
347
|
+
entries = listEntries(deps.keystore);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
// The status line below is the safe, non-secret diagnostic for an unreadable store.
|
|
351
|
+
}
|
|
352
|
+
const rows = [
|
|
353
|
+
["provider", "credential id", "source", "fingerprint", "added", "rotated", "expiry", "state"],
|
|
354
|
+
...entries.map((entry) => {
|
|
355
|
+
const resolution = resolveDescriptor(cfg, entry, deps);
|
|
356
|
+
return [
|
|
357
|
+
entry.provider,
|
|
358
|
+
entry.id,
|
|
359
|
+
resolution.source ?? "—",
|
|
360
|
+
entry.fingerprint,
|
|
361
|
+
dateCell(entry.addedAt),
|
|
362
|
+
dateCell(entry.rotatedAt),
|
|
363
|
+
dateCell(entry.expiresAt),
|
|
364
|
+
entryState(entry, Date.now()),
|
|
365
|
+
];
|
|
366
|
+
}),
|
|
367
|
+
];
|
|
368
|
+
output(deps, `${rows.map((row) => row.join("\t")).join("\n")}\n`);
|
|
369
|
+
const undecryptableCount = status.undecryptableCount;
|
|
370
|
+
const listedCount = entries.length;
|
|
371
|
+
const droppedCount = Math.max(0, status.droppedCount - undecryptableCount);
|
|
372
|
+
output(deps, `Store: ${status.status} — ${listedCount} listed, ${undecryptableCount} undecryptable, ${droppedCount} dropped.\n`);
|
|
373
|
+
output(deps, "Note: this reflects the CLI process's view, not the running relay's.\n");
|
|
374
|
+
}
|
|
375
|
+
function normalizeRotatableId(value) {
|
|
376
|
+
if (value === undefined || value.length === 0) {
|
|
377
|
+
throw new KeysCliError("usage: keys rotate <credentialId>");
|
|
378
|
+
}
|
|
379
|
+
const id = value.includes("#") ? value : makeCredentialId(value);
|
|
380
|
+
const parsed = parseCredentialId(id);
|
|
381
|
+
if (parsed === null)
|
|
382
|
+
throw new KeysCliError("invalid credential id");
|
|
383
|
+
return { id: id, ...parsed };
|
|
384
|
+
}
|
|
385
|
+
function runtimeSlotForEntry(cfg, entry) {
|
|
386
|
+
const configured = cfg.providers[entry.provider];
|
|
387
|
+
if (configured === undefined)
|
|
388
|
+
return undefined;
|
|
389
|
+
const slots = providerCredentialSlots(entry.provider, configured);
|
|
390
|
+
if (configured.credentials !== undefined) {
|
|
391
|
+
return slots.find((slot) => slot.authEnv === entry.envName);
|
|
392
|
+
}
|
|
393
|
+
return slots.find((slot) => slot.origin === "legacy-authEnv" && slot.authEnv !== undefined);
|
|
394
|
+
}
|
|
395
|
+
function proxyUrl(cfg, path) {
|
|
396
|
+
const host = cfg.host.includes(":") ? `[${cfg.host}]` : cfg.host;
|
|
397
|
+
return `http://${host}:${cfg.port}${path}`;
|
|
398
|
+
}
|
|
399
|
+
function isRecord(value) {
|
|
400
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
401
|
+
}
|
|
402
|
+
function hasExactKeys(value, keys) {
|
|
403
|
+
const actual = Object.keys(value);
|
|
404
|
+
return actual.length === keys.length && actual.every((key) => keys.includes(key));
|
|
405
|
+
}
|
|
406
|
+
function validClearedGroup(value, item) {
|
|
407
|
+
if (!isRecord(value) || !hasExactKeys(value, ["count", "items"]) ||
|
|
408
|
+
typeof value.count !== "number" || !Number.isSafeInteger(value.count) ||
|
|
409
|
+
value.count < 0 || !Array.isArray(value.items) || value.count !== value.items.length) {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
return value.items.every(item);
|
|
413
|
+
}
|
|
414
|
+
function validNarrowedClearResponse(value, provider, label) {
|
|
415
|
+
if (!isRecord(value) || !hasExactKeys(value, ["target", "cleared"]) ||
|
|
416
|
+
!isRecord(value.target) || !hasExactKeys(value.target, ["provider", "credential", "kinds"]) ||
|
|
417
|
+
value.target.provider !== provider || value.target.credential !== label ||
|
|
418
|
+
!Array.isArray(value.target.kinds) || value.target.kinds.length !== 1 ||
|
|
419
|
+
value.target.kinds[0] !== "credential-fault" ||
|
|
420
|
+
!isRecord(value.cleared) ||
|
|
421
|
+
!hasExactKeys(value.cleared, ["breakerCells", "credentialFaults", "facts"])) {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
const validCell = (candidate) => isRecord(candidate) &&
|
|
425
|
+
hasExactKeys(candidate, ["provider", "model", "credential"]) &&
|
|
426
|
+
candidate.provider === provider && candidate.credential === label &&
|
|
427
|
+
(candidate.model === null || typeof candidate.model === "string");
|
|
428
|
+
const credentialId = makeCredentialId(provider, label);
|
|
429
|
+
const validScope = (scope) => {
|
|
430
|
+
if (!isRecord(scope) || typeof scope.kind !== "string")
|
|
431
|
+
return false;
|
|
432
|
+
switch (scope.kind) {
|
|
433
|
+
case "attempt":
|
|
434
|
+
return hasExactKeys(scope, ["kind", "provider", "credentialId", "model"]) &&
|
|
435
|
+
scope.provider === provider && scope.credentialId === credentialId &&
|
|
436
|
+
typeof scope.model === "string";
|
|
437
|
+
case "group":
|
|
438
|
+
return hasExactKeys(scope, ["kind", "provider", "credentialId", "members"]) &&
|
|
439
|
+
scope.provider === provider && scope.credentialId === credentialId &&
|
|
440
|
+
Array.isArray(scope.members) && scope.members.length > 0 &&
|
|
441
|
+
scope.members.every((member) => typeof member === "string" && member.length > 0);
|
|
442
|
+
case "credential":
|
|
443
|
+
return hasExactKeys(scope, ["kind", "provider", "credentialId"]) &&
|
|
444
|
+
scope.provider === provider && scope.credentialId === credentialId;
|
|
445
|
+
default:
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
const validFact = (candidate) => isRecord(candidate) &&
|
|
450
|
+
hasExactKeys(candidate, ["kind", "scope"]) &&
|
|
451
|
+
candidate.kind === "credential-invalid" && validScope(candidate.scope);
|
|
452
|
+
return validClearedGroup(value.cleared.breakerCells, () => false) &&
|
|
453
|
+
value.cleared.breakerCells.count === 0 &&
|
|
454
|
+
validClearedGroup(value.cleared.credentialFaults, validCell) &&
|
|
455
|
+
validClearedGroup(value.cleared.facts, validFact);
|
|
456
|
+
}
|
|
457
|
+
async function clearRotatedLiveState(cfg, provider, label, deps) {
|
|
458
|
+
let headers;
|
|
459
|
+
try {
|
|
460
|
+
headers = deps.attachControlHeaders?.(cfg, { "content-type": "application/json" }) ??
|
|
461
|
+
createControlAuthorization(resolveControlAuthorizationConfigDir(cfg.sourcePath))
|
|
462
|
+
.attach({ "content-type": "application/json" });
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
return { status: "failed", reason: "control authorization is unavailable" };
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
const response = await (deps.fetch ?? fetch)(proxyUrl(cfg, "/cooldowns/clear"), {
|
|
469
|
+
method: "POST",
|
|
470
|
+
headers,
|
|
471
|
+
body: JSON.stringify({ provider, credential: label, kinds: ["credential-fault"] }),
|
|
472
|
+
signal: AbortSignal.timeout(5_000),
|
|
473
|
+
});
|
|
474
|
+
if (!response.ok) {
|
|
475
|
+
return { status: "failed", reason: `running relay returned HTTP ${response.status}` };
|
|
476
|
+
}
|
|
477
|
+
let payload;
|
|
478
|
+
try {
|
|
479
|
+
payload = await response.json();
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
return { status: "failed", reason: "running relay returned malformed JSON" };
|
|
483
|
+
}
|
|
484
|
+
return validNarrowedClearResponse(payload, provider, label)
|
|
485
|
+
? { status: "cleared" }
|
|
486
|
+
: { status: "failed", reason: "running relay returned an invalid narrowed-clear response" };
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return { status: "unreachable" };
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
export async function runKeysRotateLocal(cfg, credential, deps = {}) {
|
|
493
|
+
const parsed = normalizeRotatableId(credential);
|
|
494
|
+
const entry = listEntries(deps.keystore).find((candidate) => candidate.id === parsed.id);
|
|
495
|
+
if (entry === undefined)
|
|
496
|
+
throw new KeysCliError(`credential "${parsed.id}" was not found`);
|
|
497
|
+
const runtimeSlot = runtimeSlotForEntry(cfg, entry);
|
|
498
|
+
if (runtimeSlot === undefined || runtimeSlot.credentialId !== entry.id) {
|
|
499
|
+
throw new KeysCliError(`credential identity mismatch: ${entry.id} does not match the configured runtime slot for $${entry.envName}`);
|
|
500
|
+
}
|
|
501
|
+
if (!runtimeSlot.enabled) {
|
|
502
|
+
throw new KeysCliError(`configured slot disabled refusal: enable ${runtimeSlot.credentialId} in config before rotating it`);
|
|
503
|
+
}
|
|
504
|
+
if (entry.disabled) {
|
|
505
|
+
throw new KeysCliError(`disabled credential refusal: enable ${entry.id} before rotating it`);
|
|
506
|
+
}
|
|
507
|
+
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
508
|
+
throw new KeysCliError(`expired credential refusal: ${entry.id} would remain expired after rotation`);
|
|
509
|
+
}
|
|
510
|
+
const winning = resolveDescriptor(cfg, entry, deps);
|
|
511
|
+
if (winning.source === "env" || winning.source === "env-file") {
|
|
512
|
+
throw new KeysCliError(`shadow refusal: ${describeShadow(winning, deps)} wins; rotation would not change the wire credential`);
|
|
513
|
+
}
|
|
514
|
+
if (winning.source === "keystore" && winning.provenance?.entryId !== entry.id) {
|
|
515
|
+
throw new KeysCliError(`shadow refusal: keystore credential ${winning.provenance?.entryId ?? "(unknown)"} wins; rotation would not change the wire credential`);
|
|
516
|
+
}
|
|
517
|
+
const wasRevoked = entry.revokedAt !== null;
|
|
518
|
+
const prepared = await storeOptionsForWrite(deps);
|
|
519
|
+
const storeView = keystoreStatus(prepared);
|
|
520
|
+
if (storeView.status !== "ok") {
|
|
521
|
+
throw new KeysCliError(`rotation proof failed: keystore is ${storeView.status}`);
|
|
522
|
+
}
|
|
523
|
+
const verified = resolveDescriptor(cfg, entry, { ...deps, keystore: prepared });
|
|
524
|
+
if (verified.source === "env" || verified.source === "env-file") {
|
|
525
|
+
throw new KeysCliError(`shadow refusal: ${describeShadow(verified, deps)} wins; rotation would not change the wire credential`);
|
|
526
|
+
}
|
|
527
|
+
if (verified.source === "keystore" && verified.provenance?.entryId !== entry.id) {
|
|
528
|
+
throw new KeysCliError(`shadow refusal: keystore credential ${verified.provenance?.entryId ?? "(unknown)"} wins; rotation would not change the wire credential`);
|
|
529
|
+
}
|
|
530
|
+
if (!wasRevoked &&
|
|
531
|
+
(verified.source !== "keystore" || verified.provenance?.entryId !== entry.id)) {
|
|
532
|
+
throw new KeysCliError(`rotation proof failed: ${entry.id} is not the configured keystore winner`);
|
|
533
|
+
}
|
|
534
|
+
const replacement = await readSecret(deps, "New credential: ", "credential");
|
|
535
|
+
rotateEntry(entry.id, replacement, prepared);
|
|
536
|
+
output(deps, `Rotated ${entry.id}; new ciphertext stored and rotatedAt set.\n`);
|
|
537
|
+
if (wasRevoked)
|
|
538
|
+
output(deps, `Rotation deliberately un-revoked ${entry.id}.\n`);
|
|
539
|
+
// Design §2.6: widening "only on a disproved stated fact" is defensible here because the
|
|
540
|
+
// operator's assertion was verified above against what actually resolves.
|
|
541
|
+
const cleared = await clearRotatedLiveState(cfg, parsed.provider, parsed.label, deps);
|
|
542
|
+
if (cleared.status === "cleared") {
|
|
543
|
+
output(deps, `Cleared live credential-fault state for ${entry.id}; other cooldowns were untouched.\n`);
|
|
544
|
+
}
|
|
545
|
+
else if (cleared.status === "unreachable") {
|
|
546
|
+
output(deps, "No running relay accepted the narrowed clear; live state will converge on the relay's own success or expiry.\n");
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
throw new KeysCliError(`rotation was stored, but live credential-fault clearing failed: ${cleared.reason}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
function requireLifecycleId(value, verb) {
|
|
553
|
+
if (value === undefined || parseCredentialId(value) === null) {
|
|
554
|
+
throw new KeysCliError(`usage: keys ${verb} <credentialId>`);
|
|
555
|
+
}
|
|
556
|
+
return value;
|
|
557
|
+
}
|
|
558
|
+
export function runKeysRevokeLocal(id, deps = {}) {
|
|
559
|
+
const entry = revokeEntry(requireLifecycleId(id, "revoke"), deps.keystore);
|
|
560
|
+
output(deps, `Revoked ${entry.id}; the row remains in the keystore.\n`);
|
|
561
|
+
}
|
|
562
|
+
export function runKeysRemoveLocal(id, purge, deps = {}) {
|
|
563
|
+
const credentialId = requireLifecycleId(id, "remove");
|
|
564
|
+
removeEntry(credentialId, deps.keystore);
|
|
565
|
+
output(deps, `Removed ${credentialId}.\n`);
|
|
566
|
+
if (purge) {
|
|
567
|
+
output(deps, "--purge is logical removal only: overwrite-then-unlink is theatre on a journaling filesystem or SSD; no secure erase is claimed.\n");
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
export function runKeysDisableLocal(id, deps = {}) {
|
|
571
|
+
const entry = setDisabled(requireLifecycleId(id, "disable"), true, deps.keystore);
|
|
572
|
+
output(deps, `Disabled ${entry.id}.\n`);
|
|
573
|
+
}
|
|
574
|
+
export function runKeysEnableLocal(id, deps = {}) {
|
|
575
|
+
const entry = setDisabled(requireLifecycleId(id, "enable"), false, deps.keystore);
|
|
576
|
+
output(deps, `Enabled ${entry.id}.\n`);
|
|
577
|
+
}
|
|
578
|
+
export async function runKeysExportLocal(outPath, deps = {}) {
|
|
579
|
+
if (outPath === undefined || outPath.length === 0) {
|
|
580
|
+
throw new KeysCliError("usage: keys export --out <file>");
|
|
581
|
+
}
|
|
582
|
+
if (existsSync(outPath))
|
|
583
|
+
throw new KeysCliError(`export destination already exists: ${outPath}`);
|
|
584
|
+
const store = await storeOptionsForWrite(deps);
|
|
585
|
+
const passphrase = await readSecret(deps, "Export passphrase: ", "export-passphrase");
|
|
586
|
+
const confirmation = await readSecret(deps, "Confirm export passphrase: ", "export-passphrase");
|
|
587
|
+
if (confirmation !== passphrase) {
|
|
588
|
+
throw new KeysCliError("export passphrase confirmation mismatch: export refused");
|
|
589
|
+
}
|
|
590
|
+
const envelope = createEncryptedKeystoreExport(passphrase, store);
|
|
591
|
+
writeFileSync(outPath, envelope, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
592
|
+
if ((store.acl?.platform ?? process.platform) === "win32") {
|
|
593
|
+
restrictSecretFileOnWindowsSync(outPath, store.acl);
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
chmodSync(outPath, 0o600);
|
|
597
|
+
}
|
|
598
|
+
output(deps, `Encrypted keystore export written to ${outPath}. A plaintext export path does not exist.\n`);
|
|
599
|
+
output(deps, "The export passphrase is the file's entire protection off-machine; keep it separate from the export.\n");
|
|
600
|
+
}
|
|
601
|
+
async function importWriteOptions(deps) {
|
|
602
|
+
return await storeOptionsForWrite(deps, "Destination keystore passphrase: ");
|
|
603
|
+
}
|
|
604
|
+
export async function runKeysImportLocal(cfg, importPath, deps = {}) {
|
|
605
|
+
if (importPath === undefined || importPath.length === 0) {
|
|
606
|
+
throw new KeysCliError("usage: keys import <file>");
|
|
607
|
+
}
|
|
608
|
+
const serialized = readFileSync(importPath, "utf8");
|
|
609
|
+
let store = await importWriteOptions(deps);
|
|
610
|
+
let imported = 0;
|
|
611
|
+
let skipped = 0;
|
|
612
|
+
if (isEncryptedKeystoreExport(serialized)) {
|
|
613
|
+
const passphrase = await readSecret(deps, "Import passphrase: ", "import-passphrase", true);
|
|
614
|
+
const entries = decryptEncryptedKeystoreExport(serialized, passphrase);
|
|
615
|
+
for (const entry of entries) {
|
|
616
|
+
try {
|
|
617
|
+
const id = parseCredentialId(entry.id);
|
|
618
|
+
const target = selectAddTarget(cfg, entry.provider, id?.label, entry.envName);
|
|
619
|
+
if (target.credentialId !== entry.id) {
|
|
620
|
+
throw new KeysCliError("exported credential id does not match the configured slot");
|
|
621
|
+
}
|
|
622
|
+
const result = await withFreshPassphraseFallback(deps, store, (opts) => restoreEntryFromExport(entry, opts));
|
|
623
|
+
store = result.opts;
|
|
624
|
+
imported += 1;
|
|
625
|
+
output(deps, `Imported ${entry.provider} $${entry.envName} into the keystore.\n`);
|
|
626
|
+
}
|
|
627
|
+
catch (error) {
|
|
628
|
+
if (error instanceof KeystoreEntryExistsError || error instanceof KeysCliError) {
|
|
629
|
+
skipped += 1;
|
|
630
|
+
output(deps, `Skipped ${entry.provider} $${entry.envName}: ${error.message}.\n`);
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
throw error;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
output(deps, `Import complete: ${imported} imported, ${skipped} skipped.\n`);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const parsed = parseCredentialImport(serialized);
|
|
640
|
+
for (const entry of parsed.entries) {
|
|
641
|
+
const match = matchCredentialImportName(entry.name, cfg);
|
|
642
|
+
if (match === undefined) {
|
|
643
|
+
skipped += 1;
|
|
644
|
+
// Import names are attacker-controlled file content. Keep the diagnostic useful without
|
|
645
|
+
// reflecting terminal escapes or a value someone placed in the name field.
|
|
646
|
+
output(deps, "Skipped an unrecognized credential name.\n");
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
if (!keyIsPresent(entry.value)) {
|
|
650
|
+
skipped += 1;
|
|
651
|
+
output(deps, `Skipped ${match.provider} $${match.envName}: empty value.\n`);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
try {
|
|
655
|
+
const target = selectAddTarget(cfg, match.provider, undefined, match.envName);
|
|
656
|
+
const result = await withFreshPassphraseFallback(deps, store, (opts) => addEntry({
|
|
657
|
+
id: target.credentialId,
|
|
658
|
+
provider: target.provider,
|
|
659
|
+
envName: target.envName,
|
|
660
|
+
value: entry.value,
|
|
661
|
+
}, opts));
|
|
662
|
+
store = result.opts;
|
|
663
|
+
imported += 1;
|
|
664
|
+
output(deps, `Imported ${target.provider} $${target.envName} into the keystore.\n`);
|
|
665
|
+
}
|
|
666
|
+
catch (error) {
|
|
667
|
+
if (error instanceof KeystoreEntryExistsError || error instanceof KeysCliError) {
|
|
668
|
+
skipped += 1;
|
|
669
|
+
output(deps, `Skipped ${match.provider} $${match.envName}: ${error.message}.\n`);
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
throw error;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
output(deps, `Import complete: ${imported} imported, ${skipped} skipped.\n`);
|
|
676
|
+
output(deps, "The plaintext source was not modified. Shred/delete it yourself; backups and unallocated blocks can retain old contents.\n");
|
|
677
|
+
}
|
|
678
|
+
export async function runKeysUnlockLocal(deps = {}) {
|
|
679
|
+
const base = deps.keystore ?? {};
|
|
680
|
+
const mode = keystoreWrapMode(base);
|
|
681
|
+
if (mode === null) {
|
|
682
|
+
output(deps, "No keystore exists; unlock is a no-op.\n");
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (mode !== "passphrase") {
|
|
686
|
+
output(deps, `Keystore wrap mode is ${mode}; unlock is a no-op there.\n`);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const passphrase = base.passphrase ?? await readSecret(deps, "Keystore passphrase: ", "unlock-passphrase", true);
|
|
690
|
+
verifyKeystoreUnlock({ ...base, mode: "passphrase", passphrase });
|
|
691
|
+
output(deps, "Passphrase verified. No cross-process KEK cache exists; each command prompts as needed.\n");
|
|
692
|
+
}
|
|
693
|
+
//# sourceMappingURL=keys-cli.js.map
|