@secretstore/cli 0.0.0-stage → 0.7.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/LICENSE +661 -0
- package/README.md +12 -2
- package/dist/secretstore.js +2015 -0
- package/launcher.cjs +82 -0
- package/package.json +30 -4
|
@@ -0,0 +1,2015 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// secretstore 0.7.0: readable bundle of apps/cli for Node.js 20+. Source: https://github.com/CodingDiego/secret-store (apps/cli/src). AGPL-3.0-only.
|
|
3
|
+
// apps/cli/src/runtime.ts
|
|
4
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { open } from "node:fs/promises";
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { text as streamText } from "node:stream/consumers";
|
|
9
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
10
|
+
var isBun = typeof Bun !== "undefined";
|
|
11
|
+
var secrets = isBun ? Bun.secrets : undefined;
|
|
12
|
+
var sleep = (ms) => isBun ? Bun.sleep(ms) : delay(ms);
|
|
13
|
+
var readAllStdin = () => isBun ? Bun.stdin.text() : streamText(process.stdin);
|
|
14
|
+
function spawn(argv, { env, cwd, stdio }) {
|
|
15
|
+
if (isBun)
|
|
16
|
+
return Bun.spawn(argv, { env, cwd, stdio: [stdio, stdio, stdio] });
|
|
17
|
+
const [file = "", ...args] = argv;
|
|
18
|
+
const child = nodeSpawn(file, args, { env, cwd, stdio });
|
|
19
|
+
const exited = new Promise((resolve, reject) => {
|
|
20
|
+
child.once("error", reject);
|
|
21
|
+
child.once("close", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
exited,
|
|
25
|
+
kill: (signal) => void child.kill(signal),
|
|
26
|
+
get exitCode() {
|
|
27
|
+
return child.exitCode;
|
|
28
|
+
},
|
|
29
|
+
get signalCode() {
|
|
30
|
+
return child.signalCode;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
async function capture(argv, timeoutMs) {
|
|
35
|
+
if (isBun) {
|
|
36
|
+
const child = Bun.spawn(argv, { stdin: "ignore", stdout: "pipe", stderr: "ignore", timeout: timeoutMs });
|
|
37
|
+
const [out, code] = await Promise.all([new Response(child.stdout).text(), child.exited]);
|
|
38
|
+
return { text: out, code };
|
|
39
|
+
}
|
|
40
|
+
const [file = "", ...args] = argv;
|
|
41
|
+
const child = nodeSpawn(file, args, { stdio: ["ignore", "pipe", "ignore"], timeout: timeoutMs });
|
|
42
|
+
const closed = new Promise((resolve, reject) => {
|
|
43
|
+
child.once("error", reject);
|
|
44
|
+
child.once("close", (code) => resolve(code ?? 1));
|
|
45
|
+
});
|
|
46
|
+
const [out, code] = await Promise.all([streamText(child.stdout), closed]);
|
|
47
|
+
return { text: out, code };
|
|
48
|
+
}
|
|
49
|
+
async function serveLoopback(handler) {
|
|
50
|
+
if (isBun) {
|
|
51
|
+
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler });
|
|
52
|
+
return { port: server.port ?? 0, stop: (force) => void server.stop(force) };
|
|
53
|
+
}
|
|
54
|
+
const server = createServer((req, res) => {
|
|
55
|
+
let response;
|
|
56
|
+
try {
|
|
57
|
+
response = handler(new Request(`http://127.0.0.1${req.url ?? "/"}`, { method: req.method }));
|
|
58
|
+
} catch {
|
|
59
|
+
response = new Response("Internal Server Error", { status: 500 });
|
|
60
|
+
}
|
|
61
|
+
res.writeHead(response.status, { ...Object.fromEntries(response.headers), connection: "close" });
|
|
62
|
+
response.text().then((body) => res.end(body));
|
|
63
|
+
});
|
|
64
|
+
await new Promise((resolve, reject) => {
|
|
65
|
+
server.once("error", reject);
|
|
66
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
67
|
+
});
|
|
68
|
+
const address = server.address();
|
|
69
|
+
return {
|
|
70
|
+
port: typeof address === "object" && address ? address.port : 0,
|
|
71
|
+
stop(force) {
|
|
72
|
+
server.close();
|
|
73
|
+
if (force)
|
|
74
|
+
server.closeAllConnections();
|
|
75
|
+
else
|
|
76
|
+
server.closeIdleConnections();
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function sha256() {
|
|
81
|
+
if (isBun) {
|
|
82
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
83
|
+
return { update: (chunk) => void hasher.update(chunk), digest: () => new Uint8Array(hasher.digest()) };
|
|
84
|
+
}
|
|
85
|
+
const hash = createHash("sha256");
|
|
86
|
+
return { update: (chunk) => void hash.update(chunk), digest: () => new Uint8Array(hash.digest()) };
|
|
87
|
+
}
|
|
88
|
+
async function fileWriter(path) {
|
|
89
|
+
if (isBun) {
|
|
90
|
+
const sink = Bun.file(path).writer();
|
|
91
|
+
return { write: async (chunk) => sink.write(chunk), end: async () => sink.end() };
|
|
92
|
+
}
|
|
93
|
+
const handle = await open(path, "w");
|
|
94
|
+
return { write: (chunk) => handle.write(chunk), end: () => handle.close() };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// apps/cli/src/browser.ts
|
|
98
|
+
async function openInBrowser(url) {
|
|
99
|
+
if (!/^https?:\/\//.test(url))
|
|
100
|
+
throw new Error("not an http(s) URL");
|
|
101
|
+
const argv = process.platform === "win32" ? ["rundll32", "url.dll,FileProtocolHandler", url] : [process.platform === "darwin" ? "open" : "xdg-open", url];
|
|
102
|
+
const code = await spawn(argv, { stdio: "ignore" }).exited;
|
|
103
|
+
if (code !== 0)
|
|
104
|
+
throw new Error(`${argv[0]} exited with ${code}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// apps/cli/src/config.ts
|
|
108
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
109
|
+
import { dirname, join } from "node:path";
|
|
110
|
+
|
|
111
|
+
// apps/cli/src/errors.ts
|
|
112
|
+
var EXIT = { error: 1, usage: 2, auth: 3, notFound: 4, denied: 4, approval: 5, cannotRun: 127 };
|
|
113
|
+
|
|
114
|
+
class CliError extends Error {
|
|
115
|
+
code;
|
|
116
|
+
constructor(message, code = EXIT.error) {
|
|
117
|
+
super(message);
|
|
118
|
+
this.code = code;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function classify(message) {
|
|
122
|
+
if (/not found|There is no secret/i.test(message))
|
|
123
|
+
return EXIT.notFound;
|
|
124
|
+
if (/request_access|is protected|sealed in the browser/i.test(message))
|
|
125
|
+
return EXIT.approval;
|
|
126
|
+
if (/does not have the|not allowed to access|cannot (read|modify)|can only read/i.test(message))
|
|
127
|
+
return EXIT.auth;
|
|
128
|
+
return EXIT.error;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// apps/cli/src/is-record.ts
|
|
132
|
+
function isRecord(value) {
|
|
133
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// apps/cli/src/mcp.ts
|
|
137
|
+
var TOOLS = {
|
|
138
|
+
whoami: "whoami",
|
|
139
|
+
listProjects: "list_projects",
|
|
140
|
+
getSecret: "get_secret",
|
|
141
|
+
setSecret: "set_secret",
|
|
142
|
+
deleteSecret: "delete_secret",
|
|
143
|
+
getEnvironment: "get_environment",
|
|
144
|
+
listEnvironments: "list_environments",
|
|
145
|
+
importEnv: "import_env",
|
|
146
|
+
requestAccess: "request_access",
|
|
147
|
+
checkAccessRequest: "check_access_request"
|
|
148
|
+
};
|
|
149
|
+
var TIMEOUT_MS = 30000;
|
|
150
|
+
var notRpc = () => new CliError("The server answered with something that is not a JSON-RPC response");
|
|
151
|
+
function parseRpc(contentType, body) {
|
|
152
|
+
try {
|
|
153
|
+
if (!contentType.includes("text/event-stream"))
|
|
154
|
+
return JSON.parse(body);
|
|
155
|
+
for (const event of body.split(/\r?\n\r?\n/)) {
|
|
156
|
+
const data = event.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).replace(/^ /, "")).join(`
|
|
157
|
+
`);
|
|
158
|
+
if (!data)
|
|
159
|
+
continue;
|
|
160
|
+
const message = JSON.parse(data);
|
|
161
|
+
if (message.result || message.error)
|
|
162
|
+
return message;
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
throw notRpc();
|
|
166
|
+
}
|
|
167
|
+
throw notRpc();
|
|
168
|
+
}
|
|
169
|
+
function httpMessage(status, body) {
|
|
170
|
+
try {
|
|
171
|
+
const parsed = JSON.parse(body);
|
|
172
|
+
const error = isRecord(parsed) ? parsed.error : undefined;
|
|
173
|
+
const message = isRecord(error) && typeof error.message === "string" ? error.message : undefined;
|
|
174
|
+
const detail = isRecord(parsed) && parsed.error_description ? parsed.error_description : typeof error === "string" ? error : message;
|
|
175
|
+
if (typeof detail === "string" && detail)
|
|
176
|
+
return `${detail} (HTTP ${status})`;
|
|
177
|
+
} catch {}
|
|
178
|
+
return `The server answered HTTP ${status}`;
|
|
179
|
+
}
|
|
180
|
+
var DEAD_TOKEN = /invalid_token|expired/i;
|
|
181
|
+
async function post(session, body) {
|
|
182
|
+
try {
|
|
183
|
+
const response = await fetch(session.endpoint, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: { ...await session.headers(), "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
186
|
+
body,
|
|
187
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
188
|
+
});
|
|
189
|
+
return { response, text: await response.text() };
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (error instanceof CliError)
|
|
192
|
+
throw error;
|
|
193
|
+
throw new CliError(`Cannot reach ${new URL(session.endpoint).origin}: ${error instanceof Error ? error.message : String(error)}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function createClient(open) {
|
|
197
|
+
let opened;
|
|
198
|
+
const session = () => opened ??= open();
|
|
199
|
+
let id = 0;
|
|
200
|
+
async function invoke(tool, args) {
|
|
201
|
+
const current = await session();
|
|
202
|
+
const request = JSON.stringify({ jsonrpc: "2.0", id: ++id, method: "tools/call", params: { name: tool, arguments: args } });
|
|
203
|
+
let { response, text: body } = await post(current, request);
|
|
204
|
+
if (response.status === 401 && current.renew && DEAD_TOKEN.test(`${response.headers.get("www-authenticate")} ${body}`)) {
|
|
205
|
+
await current.renew();
|
|
206
|
+
({ response, text: body } = await post(current, request));
|
|
207
|
+
}
|
|
208
|
+
if (!response.ok)
|
|
209
|
+
throw new CliError(httpMessage(response.status, body), response.status === 401 || response.status === 403 ? EXIT.auth : EXIT.error);
|
|
210
|
+
const rpc = parseRpc(response.headers.get("content-type") ?? "", body);
|
|
211
|
+
if (rpc.error)
|
|
212
|
+
throw new CliError(rpc.error.message ?? "The server rejected the request");
|
|
213
|
+
const texts = (rpc.result?.content ?? []).filter((block) => block.type === "text").map((block) => block.text ?? "");
|
|
214
|
+
if (rpc.result?.isError) {
|
|
215
|
+
const message = texts.join(`
|
|
216
|
+
`) || "The server reported an error";
|
|
217
|
+
throw new CliError(message, classify(message));
|
|
218
|
+
}
|
|
219
|
+
return { texts, structured: rpc.result?.structuredContent };
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
via: async () => (await session()).via,
|
|
223
|
+
call: async (tool, args) => (await invoke(tool, args)).texts,
|
|
224
|
+
async callStructured(tool, args) {
|
|
225
|
+
const { structured } = await invoke(tool, args);
|
|
226
|
+
if (!structured)
|
|
227
|
+
throw new CliError("The server sent a result this version of the CLI cannot read");
|
|
228
|
+
return structured;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function firstJson(texts) {
|
|
233
|
+
try {
|
|
234
|
+
return JSON.parse(texts[0] ?? "");
|
|
235
|
+
} catch {
|
|
236
|
+
throw new CliError("The server sent a result this version of the CLI cannot read");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async function allProjects(client) {
|
|
240
|
+
const rows = [];
|
|
241
|
+
const seen = new Set;
|
|
242
|
+
let cursor;
|
|
243
|
+
do {
|
|
244
|
+
const page = await client.callStructured(TOOLS.listProjects, cursor ? { cursor } : {});
|
|
245
|
+
if (!Array.isArray(page.projects))
|
|
246
|
+
throw new CliError("The server sent an invalid project page");
|
|
247
|
+
if (cursor === undefined && !Object.hasOwn(page, "nextCursor"))
|
|
248
|
+
return [...rows, ...page.projects];
|
|
249
|
+
const next = page.nextCursor;
|
|
250
|
+
if (next !== null && (typeof next !== "string" || !/^[A-Za-z0-9_-]{1,2048}$/.test(next)))
|
|
251
|
+
throw new CliError("The server sent an invalid project cursor");
|
|
252
|
+
if (typeof next === "string" && seen.has(next))
|
|
253
|
+
throw new CliError("The server repeated a project cursor");
|
|
254
|
+
rows.push(...page.projects);
|
|
255
|
+
if (next === null)
|
|
256
|
+
return rows;
|
|
257
|
+
seen.add(next);
|
|
258
|
+
cursor = next;
|
|
259
|
+
} while (cursor);
|
|
260
|
+
return rows;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// apps/cli/src/config.ts
|
|
264
|
+
var DEFAULT_URL = "https://secretstore.sh";
|
|
265
|
+
var PIN_FILE = ".secret-store.json";
|
|
266
|
+
var LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
267
|
+
function resolveUrl(flag, env) {
|
|
268
|
+
return secureOrigin(flag || env.SECRET_STORE_URL || DEFAULT_URL);
|
|
269
|
+
}
|
|
270
|
+
function secureOrigin(raw) {
|
|
271
|
+
let url;
|
|
272
|
+
try {
|
|
273
|
+
url = new URL(raw);
|
|
274
|
+
} catch {
|
|
275
|
+
throw new CliError(`"${raw}" is not a URL`, EXIT.usage);
|
|
276
|
+
}
|
|
277
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && LOOPBACK.has(url.hostname))) {
|
|
278
|
+
throw new CliError("The server URL must be https (http is only accepted for localhost)", EXIT.usage);
|
|
279
|
+
}
|
|
280
|
+
return url.origin;
|
|
281
|
+
}
|
|
282
|
+
var mcpEndpoint = (origin) => `${origin}/api/mcp`;
|
|
283
|
+
function resolveToken(flag, env) {
|
|
284
|
+
if (flag)
|
|
285
|
+
return { token: flag, via: "--token" };
|
|
286
|
+
return env.SECRET_STORE_TOKEN ? { token: env.SECRET_STORE_TOKEN, via: "SECRET_STORE_TOKEN" } : undefined;
|
|
287
|
+
}
|
|
288
|
+
function colorEnabled(env, tty) {
|
|
289
|
+
return tty && !env.NO_COLOR;
|
|
290
|
+
}
|
|
291
|
+
function readPin(cwd) {
|
|
292
|
+
for (let dir = cwd;; dir = dirname(dir)) {
|
|
293
|
+
const file = join(dir, PIN_FILE);
|
|
294
|
+
let text;
|
|
295
|
+
try {
|
|
296
|
+
text = readFileSync(file, "utf8");
|
|
297
|
+
} catch {}
|
|
298
|
+
if (text !== undefined) {
|
|
299
|
+
try {
|
|
300
|
+
const pin = JSON.parse(text);
|
|
301
|
+
if (!isRecord(pin))
|
|
302
|
+
throw new TypeError("not a JSON object");
|
|
303
|
+
return { project: typeof pin.project === "string" ? pin.project : undefined, environment: typeof pin.environment === "string" ? pin.environment : undefined, file };
|
|
304
|
+
} catch {
|
|
305
|
+
throw new CliError(`${file} is not valid JSON`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (existsSync(join(dir, ".git")) || dirname(dir) === dir)
|
|
309
|
+
return {};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function resolveTarget(args, cwd, shape) {
|
|
313
|
+
const lead = shape.environment ? 2 : 1;
|
|
314
|
+
if (args.length >= lead + shape.need)
|
|
315
|
+
return { project: args[0] ?? "", environment: shape.environment ? args[1] ?? "" : "", rest: args.slice(lead) };
|
|
316
|
+
const pin = readPin(cwd);
|
|
317
|
+
if (!pin.project || shape.environment && !pin.environment) {
|
|
318
|
+
throw new CliError(`Name the project${shape.environment ? " and environment" : ""}, or add ${PIN_FILE} with {"project"${shape.environment ? ', "environment"' : ""}} to the repo.`, EXIT.usage);
|
|
319
|
+
}
|
|
320
|
+
return { project: pin.project, environment: pin.environment ?? "", rest: args, source: pin.file };
|
|
321
|
+
}
|
|
322
|
+
function confirmPinned({ io }, source, name, verb) {
|
|
323
|
+
if (!io.confirm)
|
|
324
|
+
throw new CliError(`${source} chose ${name}; there is no terminal to ask, so name the target on the command line or pass --yes`, EXIT.usage);
|
|
325
|
+
if (!io.confirm(`${verb} ${name}, chosen by ${source}?`))
|
|
326
|
+
throw new CliError("Cancelled; nothing was done");
|
|
327
|
+
}
|
|
328
|
+
function targetFor({ io, flags }, args, shape) {
|
|
329
|
+
const target = resolveTarget(args, io.cwd, shape);
|
|
330
|
+
if (!target.source)
|
|
331
|
+
return target;
|
|
332
|
+
const name = shape.environment ? `${target.project}/${target.environment}` : target.project;
|
|
333
|
+
if (!flags.json)
|
|
334
|
+
io.stderr(`using ${name} from ${target.source}
|
|
335
|
+
`);
|
|
336
|
+
if (!flags.yes && shape.write)
|
|
337
|
+
confirmPinned({ io }, target.source, name, "Change");
|
|
338
|
+
return target;
|
|
339
|
+
}
|
|
340
|
+
async function readTarget(ctx, args, need) {
|
|
341
|
+
const target = targetFor(ctx, args, { environment: true, need });
|
|
342
|
+
if (!target.source || ctx.flags.yes)
|
|
343
|
+
return target;
|
|
344
|
+
let production = true;
|
|
345
|
+
try {
|
|
346
|
+
const { environments } = firstJson(await ctx.client.call(TOOLS.listEnvironments, { project: target.project }));
|
|
347
|
+
production = environments.some((e) => e.kind === "production" && (e.slug === target.environment || e.id === target.environment));
|
|
348
|
+
} catch {}
|
|
349
|
+
if (production)
|
|
350
|
+
confirmPinned(ctx, target.source, `${target.project}/${target.environment}`, "Read");
|
|
351
|
+
return target;
|
|
352
|
+
}
|
|
353
|
+
// apps/cli/package.json
|
|
354
|
+
var package_default = {
|
|
355
|
+
name: "cli",
|
|
356
|
+
version: "0.7.0",
|
|
357
|
+
private: true,
|
|
358
|
+
license: "AGPL-3.0-only",
|
|
359
|
+
type: "module",
|
|
360
|
+
description: "secretstore: command-line client for Secret Store (MCP over an OAuth login or a personal token).",
|
|
361
|
+
scripts: {
|
|
362
|
+
typecheck: "tsc --noEmit",
|
|
363
|
+
lint: "oxlint -c ../../.oxlintrc.json --tsconfig ./tsconfig.json --type-aware .",
|
|
364
|
+
test: "bun test",
|
|
365
|
+
compile: "bun build --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig --outfile dist/secretstore src/index.ts"
|
|
366
|
+
},
|
|
367
|
+
devDependencies: {
|
|
368
|
+
"@secret-store/auth": "*",
|
|
369
|
+
"@secret-store/typescript-config": "*",
|
|
370
|
+
"@types/bun": "^1.4.3",
|
|
371
|
+
oxlint: "^1.88.0",
|
|
372
|
+
"oxlint-tsgolint": "^7.0.2004",
|
|
373
|
+
typescript: "^7.0.2"
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// apps/cli/src/args.ts
|
|
378
|
+
var VALUE_FLAGS = ["url", "token", "out", "format", "kind", "scopes", "keys", "reason", "duration", "status"];
|
|
379
|
+
var SWITCHES = {
|
|
380
|
+
json: "json",
|
|
381
|
+
help: "help",
|
|
382
|
+
write: "write",
|
|
383
|
+
"no-browser": "noBrowser",
|
|
384
|
+
overwrite: "overwrite",
|
|
385
|
+
wait: "wait",
|
|
386
|
+
force: "force",
|
|
387
|
+
"allow-non-tty": "allowNonTty",
|
|
388
|
+
"request-access": "requestAccess",
|
|
389
|
+
check: "check",
|
|
390
|
+
yes: "yes",
|
|
391
|
+
"allow-env-override": "allowEnvOverride"
|
|
392
|
+
};
|
|
393
|
+
var isSwitch = (name) => Object.hasOwn(SWITCHES, name);
|
|
394
|
+
var isValueFlag = (name) => VALUE_FLAGS.includes(name);
|
|
395
|
+
function parseArgs(argv) {
|
|
396
|
+
const flags = { json: false, help: false };
|
|
397
|
+
const positionals = [];
|
|
398
|
+
let rest;
|
|
399
|
+
for (let i = 0;i < argv.length; i++) {
|
|
400
|
+
const arg = argv[i] ?? "";
|
|
401
|
+
if (arg === "--") {
|
|
402
|
+
rest = argv.slice(i + 1);
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
if (arg === "-h") {
|
|
406
|
+
flags.help = true;
|
|
407
|
+
} else if (!arg.startsWith("-") || arg === "-") {
|
|
408
|
+
positionals.push(arg);
|
|
409
|
+
} else if (arg.startsWith("--")) {
|
|
410
|
+
const [name = "", inline] = arg.slice(2).split(/=(.*)/s);
|
|
411
|
+
if (isSwitch(name)) {
|
|
412
|
+
if (inline !== undefined)
|
|
413
|
+
throw new CliError(`--${name} does not take a value`, EXIT.usage);
|
|
414
|
+
flags[SWITCHES[name]] = true;
|
|
415
|
+
} else if (isValueFlag(name)) {
|
|
416
|
+
const value = inline ?? argv[++i];
|
|
417
|
+
if (value === undefined)
|
|
418
|
+
throw new CliError(`--${name} needs a value`, EXIT.usage);
|
|
419
|
+
flags[name] = value;
|
|
420
|
+
} else {
|
|
421
|
+
throw new CliError(`Unknown option --${name}`, EXIT.usage);
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
throw new CliError(`Unknown option ${arg}`, EXIT.usage);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const [command, ...args] = positionals;
|
|
428
|
+
return { command, args, rest, flags };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// apps/cli/src/help.ts
|
|
432
|
+
var USAGE = {
|
|
433
|
+
login: "secretstore login [--url <origin>] [--write] [--no-browser]",
|
|
434
|
+
logout: "secretstore logout",
|
|
435
|
+
whoami: "secretstore whoami",
|
|
436
|
+
projects: "secretstore projects",
|
|
437
|
+
envs: "secretstore envs [project]",
|
|
438
|
+
init: "secretstore init [project environment] [--force]",
|
|
439
|
+
get: "secretstore get [project env] <KEY> [--allow-non-tty] [--request-access]",
|
|
440
|
+
set: "secretstore set [project env] <KEY> [value|-]",
|
|
441
|
+
delete: "secretstore delete [project env] <KEY>",
|
|
442
|
+
push: "secretstore push [project env] [file|-] [--overwrite] [--kind secret|plain]",
|
|
443
|
+
pull: "secretstore pull [project env] [--out .env] [--request-access]",
|
|
444
|
+
export: "secretstore export [project env] [--format dotenv|json|shell] [--allow-non-tty] [--request-access]",
|
|
445
|
+
run: "secretstore run [project env] [--request-access] [--allow-env-override] -- <command...>",
|
|
446
|
+
"request-access": "secretstore request-access [project] [environment] [--scopes <a,b>] [--keys A,B] [--reason <text>] [--duration once|15m|1h|8h] [--wait] | --status <id> [--wait]",
|
|
447
|
+
doctor: "secretstore doctor",
|
|
448
|
+
completion: "secretstore completion bash|zsh|fish|powershell",
|
|
449
|
+
update: "secretstore update [--check] [--yes]",
|
|
450
|
+
version: "secretstore version",
|
|
451
|
+
help: "secretstore help"
|
|
452
|
+
};
|
|
453
|
+
var GLOBAL_FLAGS = ["--json", "--url", "--token", "--help"];
|
|
454
|
+
function usage(command) {
|
|
455
|
+
return new CliError(`usage: ${USAGE[command] ?? "secretstore help"}`, EXIT.usage);
|
|
456
|
+
}
|
|
457
|
+
var HELP = `secretstore: command-line client for Secret Store
|
|
458
|
+
|
|
459
|
+
Usage: secretstore [options] <command> [arguments]
|
|
460
|
+
|
|
461
|
+
Commands:
|
|
462
|
+
${Object.values(USAGE).map((line) => ` ${line.replace("secretstore ", "")}`).join(`
|
|
463
|
+
`)}
|
|
464
|
+
|
|
465
|
+
Options:
|
|
466
|
+
--json machine-readable output
|
|
467
|
+
--url <url> server (default: $SECRET_STORE_URL, then https://secretstore.sh)
|
|
468
|
+
--token <tok> personal token (discouraged: shows in process lists; prefer $SECRET_STORE_TOKEN)
|
|
469
|
+
--write login: also ask for secrets:write (set and delete)
|
|
470
|
+
--no-browser login: print the URL instead of opening it
|
|
471
|
+
--check update: only report whether a newer version is published
|
|
472
|
+
--yes update: install without asking; set, delete, push and reads of an environment whose kind is production: accept the target a .secret-store.json chose
|
|
473
|
+
-h, --help this text, or the usage line when a command is given
|
|
474
|
+
|
|
475
|
+
Command options:
|
|
476
|
+
--allow-non-tty get, export: print values although stdout is a pipe or a file (an agent or a log can read it)
|
|
477
|
+
--request-access get, pull, export, run: when the token lacks the environment or key, ask for it, wait, retry once
|
|
478
|
+
--allow-env-override run: also pass secrets named PATH, NODE_OPTIONS, LD_*, GIT_* and similar to the command (refused by default)
|
|
479
|
+
--overwrite push: replace values that already exist
|
|
480
|
+
--kind secret|plain push: how new entries are stored (default secret)
|
|
481
|
+
--scopes <a,b> request-access: scopes to ask for (default secrets:read)
|
|
482
|
+
--keys A,B request-access: only these keys (default every key)
|
|
483
|
+
--reason <text> request-access and --request-access: what the person approving reads
|
|
484
|
+
--duration <d> request-access and --request-access: once (default), 15m, 1h or 8h
|
|
485
|
+
--wait request-access: wait for the answer
|
|
486
|
+
--status <id> request-access: the state of a request made earlier
|
|
487
|
+
--force init: replace an existing .secret-store.json
|
|
488
|
+
--out <file> pull: where to write the dotenv file (default .env)
|
|
489
|
+
--format <f> export: dotenv, json or shell (default dotenv, or json with --json)
|
|
490
|
+
|
|
491
|
+
Credentials: --token, then $SECRET_STORE_TOKEN, then the login that "secretstore login" stored for the server.
|
|
492
|
+
|
|
493
|
+
[project env] may be left out when a .secret-store.json with {"project", "environment"} is in the
|
|
494
|
+
current directory or above. A value on the command line ends up in your shell history: use "-" to
|
|
495
|
+
read it from stdin.
|
|
496
|
+
|
|
497
|
+
Exit codes: 0 ok, 1 error, 2 usage, 3 auth, 4 not found (or sign-in denied), 5 needs approval (protected or sealed item), 127 run could not start the command
|
|
498
|
+
`;
|
|
499
|
+
|
|
500
|
+
// apps/cli/src/commands/access.ts
|
|
501
|
+
var POLL_MS = 2000;
|
|
502
|
+
var DURATIONS = ["once", "15m", "1h", "8h"];
|
|
503
|
+
var DEFAULT_REASON = "Requested from the secretstore CLI";
|
|
504
|
+
var list = (value) => value.split(/[\s,]+/).filter(Boolean);
|
|
505
|
+
async function ask(ctx, target) {
|
|
506
|
+
const { duration, reason } = ctx.flags;
|
|
507
|
+
if (duration !== undefined && !DURATIONS.includes(duration))
|
|
508
|
+
throw new CliError(`--duration is ${DURATIONS.join(", ")}, not "${duration}"`, EXIT.usage);
|
|
509
|
+
return ctx.client.callStructured(TOOLS.requestAccess, {
|
|
510
|
+
project: target.project,
|
|
511
|
+
...target.environment ? { environment: target.environment } : {},
|
|
512
|
+
scopes: target.scopes,
|
|
513
|
+
...target.keys?.length ? { keys: target.keys } : {},
|
|
514
|
+
reason: reason ?? DEFAULT_REASON,
|
|
515
|
+
...duration ? { duration } : {}
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
var check = (ctx, request) => ctx.client.callStructured(TOOLS.checkAccessRequest, { request });
|
|
519
|
+
async function poll(ctx, request) {
|
|
520
|
+
for (;; ) {
|
|
521
|
+
const state = await check(ctx, request);
|
|
522
|
+
if (state.status !== "pending" || !(ctx.io.now() < Date.parse(state.answerBy ?? "")))
|
|
523
|
+
return state;
|
|
524
|
+
await ctx.io.sleep(POLL_MS);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
function verdict(state) {
|
|
528
|
+
if (state.status === "active")
|
|
529
|
+
return;
|
|
530
|
+
if (state.status === "pending")
|
|
531
|
+
return new CliError(`Access request ${state.request} is still pending. Approve it at ${state.approveUrl} before ${state.answerBy}.`, EXIT.approval);
|
|
532
|
+
return new CliError(`Access request ${state.request} is ${state.status}.${state.note ? ` ${state.note}` : ""}`, state.status === "denied" ? EXIT.denied : EXIT.error);
|
|
533
|
+
}
|
|
534
|
+
function approved(state) {
|
|
535
|
+
const keys = Array.isArray(state.keys) ? `keys ${state.keys.join(", ")}` : state.keys;
|
|
536
|
+
const when = state.singleUse ? "one use" : state.until ? `until ${state.until}` : "";
|
|
537
|
+
return `Approved: ${[state.scopes?.join(", "), keys, when].filter(Boolean).join("; ")}.`;
|
|
538
|
+
}
|
|
539
|
+
function announce(target, asked) {
|
|
540
|
+
const where = target.environment ? `${target.project}/${target.environment}` : target.project;
|
|
541
|
+
return `Access request ${asked.request} for ${where} (${target.scopes.join(", ")}).
|
|
542
|
+
Approve it at ${asked.approveUrl} (it needs your second factor); answer by ${asked.answerBy}.
|
|
543
|
+
`;
|
|
544
|
+
}
|
|
545
|
+
function report({ io, flags }, state) {
|
|
546
|
+
const failure = verdict(state);
|
|
547
|
+
if (flags.json) {
|
|
548
|
+
io.stdout(JSON.stringify(state, null, 2) + `
|
|
549
|
+
`);
|
|
550
|
+
return failure?.code ?? 0;
|
|
551
|
+
}
|
|
552
|
+
if (failure)
|
|
553
|
+
throw failure;
|
|
554
|
+
io.stdout(approved(state) + `
|
|
555
|
+
`);
|
|
556
|
+
return 0;
|
|
557
|
+
}
|
|
558
|
+
var requestAccess = async (ctx) => {
|
|
559
|
+
const { io, flags, args } = ctx;
|
|
560
|
+
if (flags.status !== undefined) {
|
|
561
|
+
if (args.length > 0)
|
|
562
|
+
throw usage("request-access");
|
|
563
|
+
return report(ctx, flags.wait ? await poll(ctx, flags.status) : await check(ctx, flags.status));
|
|
564
|
+
}
|
|
565
|
+
const { project, environment, rest } = targetFor(ctx, args, { environment: false, need: 0 });
|
|
566
|
+
if (rest.length > 1)
|
|
567
|
+
throw usage("request-access");
|
|
568
|
+
const target = { project, environment: rest[0] ?? environment, scopes: list(flags.scopes ?? "secrets:read"), keys: flags.keys ? list(flags.keys) : undefined };
|
|
569
|
+
const asked = await ask(ctx, target);
|
|
570
|
+
if (flags.wait) {
|
|
571
|
+
if (!flags.json)
|
|
572
|
+
io.stderr(announce(target, asked));
|
|
573
|
+
return report(ctx, await poll(ctx, asked.request));
|
|
574
|
+
}
|
|
575
|
+
io.stdout(flags.json ? JSON.stringify({ request: asked.request, status: "pending", approveUrl: asked.approveUrl, answerBy: asked.answerBy }, null, 2) + `
|
|
576
|
+
` : `${announce(target, asked)}Check it with: secretstore request-access --status ${asked.request}
|
|
577
|
+
`);
|
|
578
|
+
return 0;
|
|
579
|
+
};
|
|
580
|
+
var askable = (message) => /request_access/.test(message) && !/include_protected/.test(message);
|
|
581
|
+
async function withAccess(ctx, target, read) {
|
|
582
|
+
try {
|
|
583
|
+
return await read();
|
|
584
|
+
} catch (error) {
|
|
585
|
+
if (!(error instanceof CliError) || error.code !== EXIT.approval || !askable(error.message))
|
|
586
|
+
throw error;
|
|
587
|
+
if (!ctx.flags.requestAccess)
|
|
588
|
+
throw new CliError(`${error.message}
|
|
589
|
+
Run again with --request-access to ask for it and wait for the answer.`, error.code);
|
|
590
|
+
const wanted = { ...target, scopes: ["secrets:read"] };
|
|
591
|
+
const asked = await ask(ctx, wanted);
|
|
592
|
+
ctx.io.stderr(announce(wanted, asked));
|
|
593
|
+
const failure = verdict(await poll(ctx, asked.request));
|
|
594
|
+
if (failure)
|
|
595
|
+
throw failure;
|
|
596
|
+
ctx.io.stderr(`Approved; reading again.
|
|
597
|
+
`);
|
|
598
|
+
return await read();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// packages/auth/src/first-party.ts
|
|
603
|
+
var CLI_CLIENT_PATH = "/.well-known/oauth-client/cli.json";
|
|
604
|
+
function cliClientId(origin) {
|
|
605
|
+
return origin + CLI_CLIENT_PATH;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// apps/cli/src/dpop.ts
|
|
609
|
+
var ES256 = { name: "ECDSA", namedCurve: "P-256" };
|
|
610
|
+
var base64url = (data) => Buffer.from(typeof data === "string" ? data : new Uint8Array(data)).toString("base64url");
|
|
611
|
+
var sha2562 = async (text) => new Uint8Array(await crypto.subtle.digest("SHA-256", Buffer.from(text)));
|
|
612
|
+
async function generateDpopKey() {
|
|
613
|
+
const pair = await crypto.subtle.generateKey(ES256, true, ["sign", "verify"]);
|
|
614
|
+
const { kty, crv, x, y, d } = await crypto.subtle.exportKey("jwk", pair.privateKey);
|
|
615
|
+
return { kty, crv, x, y, d };
|
|
616
|
+
}
|
|
617
|
+
async function dpopProof(key, request) {
|
|
618
|
+
const { d: _private, ...publicJwk } = key;
|
|
619
|
+
const header = { typ: "dpop+jwt", alg: "ES256", jwk: publicJwk };
|
|
620
|
+
const claims = {
|
|
621
|
+
htm: request.htm,
|
|
622
|
+
htu: request.htu,
|
|
623
|
+
jti: base64url(crypto.getRandomValues(new Uint8Array(16))),
|
|
624
|
+
iat: Math.floor(request.nowMs / 1000),
|
|
625
|
+
...request.accessToken === undefined ? {} : { ath: base64url(await sha2562(request.accessToken)) }
|
|
626
|
+
};
|
|
627
|
+
const input = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(claims))}`;
|
|
628
|
+
const signingKey = await crypto.subtle.importKey("jwk", key, ES256, false, ["sign"]);
|
|
629
|
+
const signature = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, signingKey, Buffer.from(input));
|
|
630
|
+
return `${input}.${base64url(signature)}`;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// apps/cli/src/oauth.ts
|
|
634
|
+
class OAuthError extends CliError {
|
|
635
|
+
oauthCode;
|
|
636
|
+
constructor(oauthCode, message, code) {
|
|
637
|
+
super(message, code);
|
|
638
|
+
this.oauthCode = oauthCode;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
var TIMEOUT_MS2 = 30000;
|
|
642
|
+
var unreachable = (origin, error) => new CliError(`Cannot reach ${origin}: ${error instanceof Error ? error.message : String(error)}`);
|
|
643
|
+
function endpoint(body, name) {
|
|
644
|
+
const value = body[name];
|
|
645
|
+
if (typeof value !== "string")
|
|
646
|
+
throw new CliError(`The server's OAuth metadata has no ${name}`);
|
|
647
|
+
try {
|
|
648
|
+
secureOrigin(value);
|
|
649
|
+
} catch {
|
|
650
|
+
throw new CliError(`The server's OAuth metadata names an insecure ${name}`);
|
|
651
|
+
}
|
|
652
|
+
return value;
|
|
653
|
+
}
|
|
654
|
+
async function discover(origin, fetcher = fetch) {
|
|
655
|
+
let body;
|
|
656
|
+
try {
|
|
657
|
+
const response = await fetcher(`${origin}/.well-known/oauth-authorization-server`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(TIMEOUT_MS2) });
|
|
658
|
+
if (!response.ok)
|
|
659
|
+
throw new CliError(`${origin} does not look like a Secret Store server (HTTP ${response.status} for its OAuth metadata)`);
|
|
660
|
+
const json = await response.json();
|
|
661
|
+
body = isRecord(json) ? json : {};
|
|
662
|
+
} catch (error) {
|
|
663
|
+
throw error instanceof CliError ? error : unreachable(origin, error);
|
|
664
|
+
}
|
|
665
|
+
const raw = endpoint(body, "issuer");
|
|
666
|
+
const issuer = secureOrigin(raw);
|
|
667
|
+
if (raw !== issuer && raw !== `${issuer}/`)
|
|
668
|
+
throw new CliError("The server's issuer is not a plain origin");
|
|
669
|
+
return { issuer, authorizationEndpoint: endpoint(body, "authorization_endpoint"), tokenEndpoint: endpoint(body, "token_endpoint"), revocationEndpoint: endpoint(body, "revocation_endpoint") };
|
|
670
|
+
}
|
|
671
|
+
async function pkce() {
|
|
672
|
+
const verifier = base64url(crypto.getRandomValues(new Uint8Array(48)));
|
|
673
|
+
return { verifier, challenge: base64url(await sha2562(verifier)) };
|
|
674
|
+
}
|
|
675
|
+
var randomState = () => base64url(crypto.getRandomValues(new Uint8Array(24)));
|
|
676
|
+
function authorizeUrl(meta, request) {
|
|
677
|
+
const url = new URL(meta.authorizationEndpoint);
|
|
678
|
+
const params = {
|
|
679
|
+
client_id: cliClientId(meta.issuer),
|
|
680
|
+
redirect_uri: request.redirectUri,
|
|
681
|
+
response_type: "code",
|
|
682
|
+
code_challenge: request.challenge,
|
|
683
|
+
code_challenge_method: "S256",
|
|
684
|
+
scope: request.scopes.join(" "),
|
|
685
|
+
state: request.state,
|
|
686
|
+
resource: mcpEndpoint(meta.issuer)
|
|
687
|
+
};
|
|
688
|
+
for (const [name, value] of Object.entries(params))
|
|
689
|
+
url.searchParams.set(name, value);
|
|
690
|
+
return url.href;
|
|
691
|
+
}
|
|
692
|
+
function refusal(status, body) {
|
|
693
|
+
let error;
|
|
694
|
+
let description = `The server answered HTTP ${status}`;
|
|
695
|
+
try {
|
|
696
|
+
const parsed = JSON.parse(body);
|
|
697
|
+
if (isRecord(parsed) && typeof parsed.error === "string")
|
|
698
|
+
error = parsed.error;
|
|
699
|
+
if (isRecord(parsed) && typeof parsed.error_description === "string")
|
|
700
|
+
description = parsed.error_description;
|
|
701
|
+
} catch {}
|
|
702
|
+
return new OAuthError(error, error ? `${description} (${error})` : description);
|
|
703
|
+
}
|
|
704
|
+
async function tokenRequest(tokenEndpoint, key, form, nowMs, fetcher) {
|
|
705
|
+
let response;
|
|
706
|
+
try {
|
|
707
|
+
response = await fetcher(tokenEndpoint, {
|
|
708
|
+
method: "POST",
|
|
709
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json", dpop: await dpopProof(key, { htm: "POST", htu: tokenEndpoint, nowMs }) },
|
|
710
|
+
body: new URLSearchParams(form),
|
|
711
|
+
signal: AbortSignal.timeout(TIMEOUT_MS2)
|
|
712
|
+
});
|
|
713
|
+
} catch (error) {
|
|
714
|
+
throw unreachable(new URL(tokenEndpoint).origin, error);
|
|
715
|
+
}
|
|
716
|
+
const text = await response.text();
|
|
717
|
+
if (!response.ok)
|
|
718
|
+
throw refusal(response.status, text);
|
|
719
|
+
let body = {};
|
|
720
|
+
try {
|
|
721
|
+
const parsed = JSON.parse(text);
|
|
722
|
+
if (isRecord(parsed))
|
|
723
|
+
body = parsed;
|
|
724
|
+
} catch {}
|
|
725
|
+
const { access_token: accessToken, refresh_token: refreshToken, expires_in: expiresIn } = body;
|
|
726
|
+
if (typeof accessToken !== "string" || typeof refreshToken !== "string" || typeof expiresIn !== "number" || String(body.token_type).toLowerCase() !== "dpop") {
|
|
727
|
+
throw new CliError("The server did not answer with a DPoP-bound token pair");
|
|
728
|
+
}
|
|
729
|
+
return { accessToken, refreshToken, expiresAt: nowMs + expiresIn * 1000, scope: typeof body.scope === "string" ? body.scope : "" };
|
|
730
|
+
}
|
|
731
|
+
function exchangeCode(meta, key, request, nowMs, fetcher = fetch) {
|
|
732
|
+
const form = {
|
|
733
|
+
grant_type: "authorization_code",
|
|
734
|
+
code: request.code,
|
|
735
|
+
client_id: cliClientId(meta.issuer),
|
|
736
|
+
redirect_uri: request.redirectUri,
|
|
737
|
+
code_verifier: request.verifier,
|
|
738
|
+
resource: mcpEndpoint(meta.issuer)
|
|
739
|
+
};
|
|
740
|
+
return tokenRequest(meta.tokenEndpoint, key, form, nowMs, fetcher);
|
|
741
|
+
}
|
|
742
|
+
function refreshGrant(login, nowMs, fetcher = fetch) {
|
|
743
|
+
return tokenRequest(login.tokenEndpoint, login.key, { grant_type: "refresh_token", refresh_token: login.refreshToken, client_id: login.clientId }, nowMs, fetcher);
|
|
744
|
+
}
|
|
745
|
+
async function revokeToken(login, fetcher = fetch) {
|
|
746
|
+
let response;
|
|
747
|
+
try {
|
|
748
|
+
response = await fetcher(login.revocationEndpoint, {
|
|
749
|
+
method: "POST",
|
|
750
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
751
|
+
body: new URLSearchParams({ token: login.refreshToken, token_type_hint: "refresh_token", client_id: login.clientId }),
|
|
752
|
+
signal: AbortSignal.timeout(TIMEOUT_MS2)
|
|
753
|
+
});
|
|
754
|
+
} catch (error) {
|
|
755
|
+
throw unreachable(login.issuer, error);
|
|
756
|
+
}
|
|
757
|
+
if (!response.ok)
|
|
758
|
+
throw refusal(response.status, await response.text());
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// apps/cli/src/callback.ts
|
|
762
|
+
var PAGE = '<!doctype html><meta charset="utf-8"><title>Secret Store CLI</title><p>You can close this tab and return to the terminal.</p>';
|
|
763
|
+
function refusal2(error, description) {
|
|
764
|
+
if (error === "access_denied")
|
|
765
|
+
return new OAuthError(error, "Sign-in was denied in the browser.", EXIT.denied);
|
|
766
|
+
return new OAuthError(error, `The server refused the sign-in: ${description ?? error} (${error})`);
|
|
767
|
+
}
|
|
768
|
+
async function listenForCallback(state, timeoutMs) {
|
|
769
|
+
let resolveCode;
|
|
770
|
+
let rejectCode;
|
|
771
|
+
const code = new Promise((resolve, reject) => {
|
|
772
|
+
resolveCode = resolve;
|
|
773
|
+
rejectCode = reject;
|
|
774
|
+
});
|
|
775
|
+
code.catch(() => {
|
|
776
|
+
return;
|
|
777
|
+
});
|
|
778
|
+
const stop = (force) => {
|
|
779
|
+
clearTimeout(timer);
|
|
780
|
+
server.stop(force);
|
|
781
|
+
};
|
|
782
|
+
const server = await serveLoopback((request) => {
|
|
783
|
+
const url = new URL(request.url);
|
|
784
|
+
if (url.pathname !== "/callback")
|
|
785
|
+
return new Response("Not found", { status: 404 });
|
|
786
|
+
if (url.searchParams.get("state") !== state)
|
|
787
|
+
return new Response("This link does not belong to the sign-in that is waiting.", { status: 400 });
|
|
788
|
+
const error = url.searchParams.get("error");
|
|
789
|
+
const granted = url.searchParams.get("code");
|
|
790
|
+
if (error)
|
|
791
|
+
rejectCode(refusal2(error, url.searchParams.get("error_description")));
|
|
792
|
+
else if (granted)
|
|
793
|
+
resolveCode(granted);
|
|
794
|
+
else
|
|
795
|
+
return new Response("The redirect carried no code.", { status: 400 });
|
|
796
|
+
stop(false);
|
|
797
|
+
return new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" } });
|
|
798
|
+
});
|
|
799
|
+
const timer = setTimeout(() => {
|
|
800
|
+
rejectCode(new CliError('Timed out waiting for the browser. Run "secretstore login" again.'));
|
|
801
|
+
stop(true);
|
|
802
|
+
}, timeoutMs);
|
|
803
|
+
return { port: server.port, code, close: () => stop(true) };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// apps/cli/src/keystore.ts
|
|
807
|
+
import { mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
|
|
808
|
+
import { homedir } from "node:os";
|
|
809
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
810
|
+
var SERVICE = "secret-store-cli";
|
|
811
|
+
function parseLogin(value) {
|
|
812
|
+
const login = isRecord(value) ? value : undefined;
|
|
813
|
+
const strings = [login?.issuer, login?.clientId, login?.tokenEndpoint, login?.revocationEndpoint, login?.accessToken, login?.refreshToken, login?.scope];
|
|
814
|
+
const jwk = login?.key;
|
|
815
|
+
const valid = strings.every((s) => typeof s === "string") && typeof login?.expiresAt === "number" && typeof login.createdAt === "number" && isRecord(jwk) && typeof jwk.d === "string";
|
|
816
|
+
return valid ? login : undefined;
|
|
817
|
+
}
|
|
818
|
+
var at = (name) => ({ service: SERVICE, name });
|
|
819
|
+
var noOsKeystore = () => new CliError("Node.js has no OS keystore; the native secretstore binary does", EXIT.usage);
|
|
820
|
+
function osKeystore(secrets2 = secrets ?? null) {
|
|
821
|
+
if (!secrets2) {
|
|
822
|
+
return {
|
|
823
|
+
get: () => Promise.reject(noOsKeystore()),
|
|
824
|
+
set: () => Promise.reject(noOsKeystore()),
|
|
825
|
+
remove: () => Promise.reject(noOsKeystore())
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
return {
|
|
829
|
+
async get(name) {
|
|
830
|
+
const text = await secrets2.get(at(name));
|
|
831
|
+
return text ? parseLogin(JSON.parse(text)) : undefined;
|
|
832
|
+
},
|
|
833
|
+
async set(name, login) {
|
|
834
|
+
await secrets2.set({ ...at(name), value: JSON.stringify(login) });
|
|
835
|
+
},
|
|
836
|
+
async remove(name) {
|
|
837
|
+
await secrets2.delete(at(name));
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
function credentialsPath(env, platform = process.platform) {
|
|
842
|
+
const base = platform === "win32" ? env.APPDATA ?? join2(homedir(), "AppData", "Roaming") : env.XDG_CONFIG_HOME || join2(env.HOME || homedir(), ".config");
|
|
843
|
+
return join2(base, "secret-store", "credentials.json");
|
|
844
|
+
}
|
|
845
|
+
function fileKeystore(path) {
|
|
846
|
+
const read = () => {
|
|
847
|
+
let text;
|
|
848
|
+
try {
|
|
849
|
+
text = readFileSync2(path, "utf8");
|
|
850
|
+
} catch (error) {
|
|
851
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
852
|
+
return {};
|
|
853
|
+
throw error;
|
|
854
|
+
}
|
|
855
|
+
try {
|
|
856
|
+
const parsed = JSON.parse(text);
|
|
857
|
+
if (!isRecord(parsed))
|
|
858
|
+
throw new TypeError("not a JSON object");
|
|
859
|
+
return parsed;
|
|
860
|
+
} catch {
|
|
861
|
+
throw new CliError(`${path} is not valid JSON; fix or delete it`);
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
const write = (all) => {
|
|
865
|
+
mkdirSync(dirname2(path), { recursive: true, mode: 448 });
|
|
866
|
+
const next = `${path}.${process.pid}.tmp`;
|
|
867
|
+
writeFileSync(next, JSON.stringify(all, null, 2) + `
|
|
868
|
+
`, { mode: 384 });
|
|
869
|
+
renameSync(next, path);
|
|
870
|
+
};
|
|
871
|
+
return {
|
|
872
|
+
async get(name) {
|
|
873
|
+
return parseLogin(read()[name]);
|
|
874
|
+
},
|
|
875
|
+
async set(name, login) {
|
|
876
|
+
write({ ...read(), [name]: login });
|
|
877
|
+
},
|
|
878
|
+
async remove(name) {
|
|
879
|
+
const { [name]: gone, ...rest } = read();
|
|
880
|
+
if (gone !== undefined)
|
|
881
|
+
write(rest);
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
var quietly = (lookup) => lookup.catch(() => {
|
|
886
|
+
return;
|
|
887
|
+
});
|
|
888
|
+
function forcedBackend(env) {
|
|
889
|
+
const choice = env.SECRET_STORE_KEYSTORE;
|
|
890
|
+
if (choice === "os" || choice === "file")
|
|
891
|
+
return choice;
|
|
892
|
+
if (choice)
|
|
893
|
+
throw new CliError(`SECRET_STORE_KEYSTORE is "os" or "file", not "${choice}"`, EXIT.usage);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
async function activeBackend(env, secrets2 = secrets ?? null) {
|
|
897
|
+
const forced = forcedBackend(env);
|
|
898
|
+
if (forced === "os" && !secrets2)
|
|
899
|
+
throw noOsKeystore();
|
|
900
|
+
return forced ?? await secrets2?.get(at("doctor")).then(() => "os", () => "file") ?? "file";
|
|
901
|
+
}
|
|
902
|
+
function openKeystore(env, warn, secrets2 = secrets ?? null) {
|
|
903
|
+
const os = osKeystore(secrets2);
|
|
904
|
+
const path = credentialsPath(env);
|
|
905
|
+
const file = fileKeystore(path);
|
|
906
|
+
const choice = forcedBackend(env);
|
|
907
|
+
if (choice === "os")
|
|
908
|
+
return os;
|
|
909
|
+
if (choice === "file")
|
|
910
|
+
return file;
|
|
911
|
+
return {
|
|
912
|
+
async get(name) {
|
|
913
|
+
return await quietly(os.get(name)) ?? await quietly(file.get(name));
|
|
914
|
+
},
|
|
915
|
+
async set(name, login) {
|
|
916
|
+
try {
|
|
917
|
+
await os.set(name, login);
|
|
918
|
+
} catch (error) {
|
|
919
|
+
if (!await quietly(file.get(name)))
|
|
920
|
+
warn(`note: no OS keystore (${error instanceof Error ? error.message : String(error)}); the login is kept in ${path} (mode 0600)
|
|
921
|
+
`);
|
|
922
|
+
await file.set(name, login);
|
|
923
|
+
}
|
|
924
|
+
},
|
|
925
|
+
async remove(name) {
|
|
926
|
+
const results = await Promise.allSettled([os.remove(name), file.remove(name)]);
|
|
927
|
+
const failed = results.filter((r) => r.status === "rejected");
|
|
928
|
+
if (failed.length === results.length && failed[0])
|
|
929
|
+
throw failed[0].reason;
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// apps/cli/src/commands/auth.ts
|
|
935
|
+
var LOGIN_TIMEOUT_MS = 5 * 60000;
|
|
936
|
+
var READ_SCOPES = ["projects:read", "secrets:read"];
|
|
937
|
+
var reason = (error) => error instanceof Error ? error.message : String(error);
|
|
938
|
+
var login = async ({ args, flags, io }) => {
|
|
939
|
+
if (args.length > 0)
|
|
940
|
+
throw usage("login");
|
|
941
|
+
const store = openKeystore(io.env, io.stderr);
|
|
942
|
+
const name = resolveUrl(flags.url, io.env);
|
|
943
|
+
const meta = await discover(name);
|
|
944
|
+
const { verifier, challenge } = await pkce();
|
|
945
|
+
const state = randomState();
|
|
946
|
+
const key = await generateDpopKey();
|
|
947
|
+
const listener = await listenForCallback(state, LOGIN_TIMEOUT_MS);
|
|
948
|
+
let grant;
|
|
949
|
+
try {
|
|
950
|
+
const redirectUri = `http://127.0.0.1:${listener.port}/callback`;
|
|
951
|
+
const url = authorizeUrl(meta, { redirectUri, challenge, state, scopes: flags.write ? [...READ_SCOPES, "secrets:write"] : READ_SCOPES });
|
|
952
|
+
io.stdout(`Open this URL to sign in to ${meta.issuer} (waiting up to 5 minutes):
|
|
953
|
+
${url}
|
|
954
|
+
`);
|
|
955
|
+
if (!flags.noBrowser) {
|
|
956
|
+
try {
|
|
957
|
+
await io.openUrl(url);
|
|
958
|
+
} catch {
|
|
959
|
+
io.stderr(`note: could not open a browser; open the URL above yourself
|
|
960
|
+
`);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
grant = await exchangeCode(meta, key, { code: await listener.code, verifier, redirectUri }, io.now());
|
|
964
|
+
} catch (error) {
|
|
965
|
+
if (flags.write && error instanceof OAuthError && error.oauthCode === "invalid_scope") {
|
|
966
|
+
throw new CliError(`${error.message}. secrets:write is offered only while the server's agent-write-scope flag is on; sign in without --write for read access.`, error.code);
|
|
967
|
+
}
|
|
968
|
+
throw error;
|
|
969
|
+
} finally {
|
|
970
|
+
listener.close();
|
|
971
|
+
}
|
|
972
|
+
const saved = {
|
|
973
|
+
issuer: meta.issuer,
|
|
974
|
+
clientId: cliClientId(meta.issuer),
|
|
975
|
+
tokenEndpoint: meta.tokenEndpoint,
|
|
976
|
+
revocationEndpoint: meta.revocationEndpoint,
|
|
977
|
+
accessToken: grant.accessToken,
|
|
978
|
+
expiresAt: grant.expiresAt,
|
|
979
|
+
refreshToken: grant.refreshToken,
|
|
980
|
+
key,
|
|
981
|
+
scope: grant.scope,
|
|
982
|
+
createdAt: io.now()
|
|
983
|
+
};
|
|
984
|
+
const previous = await store.get(name).catch(() => {
|
|
985
|
+
return;
|
|
986
|
+
});
|
|
987
|
+
if (previous)
|
|
988
|
+
await revokeToken(previous).catch(() => {
|
|
989
|
+
return;
|
|
990
|
+
});
|
|
991
|
+
try {
|
|
992
|
+
await store.set(name, saved);
|
|
993
|
+
} catch (error) {
|
|
994
|
+
throw new CliError(`Signed in, but the login could not be saved (${reason(error)}). Revoke "Secret Store CLI" on ${meta.issuer}/settings/agents and try again.`);
|
|
995
|
+
}
|
|
996
|
+
if (flags.write && !grant.scope.split(" ").includes("secrets:write")) {
|
|
997
|
+
io.stderr(`note: secrets:write was not granted (you have to tick it on the consent page); set and delete will be refused
|
|
998
|
+
`);
|
|
999
|
+
}
|
|
1000
|
+
io.stdout(`Signed in to ${meta.issuer} with ${grant.scope}.
|
|
1001
|
+
The access token lasts an hour and renews itself. Revoke this login any time on ${meta.issuer}/settings/agents.
|
|
1002
|
+
`);
|
|
1003
|
+
};
|
|
1004
|
+
var logout = async ({ args, flags, io }) => {
|
|
1005
|
+
if (args.length > 0)
|
|
1006
|
+
throw usage("logout");
|
|
1007
|
+
const store = openKeystore(io.env, io.stderr);
|
|
1008
|
+
const name = resolveUrl(flags.url, io.env);
|
|
1009
|
+
const stored = await store.get(name);
|
|
1010
|
+
if (!stored) {
|
|
1011
|
+
io.stdout(`Not signed in to ${name}.
|
|
1012
|
+
`);
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
let unrevoked = "";
|
|
1016
|
+
try {
|
|
1017
|
+
await revokeToken(stored);
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
unrevoked = ` The server could not be told (${reason(error)}), so revoke "Secret Store CLI" on ${stored.issuer}/settings/agents.`;
|
|
1020
|
+
}
|
|
1021
|
+
await store.remove(name);
|
|
1022
|
+
io.stdout(`Signed out of ${name}.${unrevoked}
|
|
1023
|
+
`);
|
|
1024
|
+
};
|
|
1025
|
+
var keystoreSelftest = async ({ io }) => {
|
|
1026
|
+
const store = openKeystore(io.env, io.stderr);
|
|
1027
|
+
const name = `selftest-${crypto.randomUUID()}`;
|
|
1028
|
+
const probe = {
|
|
1029
|
+
issuer: "https://selftest.invalid",
|
|
1030
|
+
clientId: "selftest",
|
|
1031
|
+
tokenEndpoint: "https://selftest.invalid/token",
|
|
1032
|
+
revocationEndpoint: "https://selftest.invalid/revoke",
|
|
1033
|
+
accessToken: base64url(crypto.getRandomValues(new Uint8Array(400))),
|
|
1034
|
+
expiresAt: 0,
|
|
1035
|
+
refreshToken: base64url(crypto.getRandomValues(new Uint8Array(100))),
|
|
1036
|
+
key: await generateDpopKey(),
|
|
1037
|
+
scope: "",
|
|
1038
|
+
createdAt: 0
|
|
1039
|
+
};
|
|
1040
|
+
try {
|
|
1041
|
+
await store.set(name, probe);
|
|
1042
|
+
if ((await store.get(name))?.accessToken !== probe.accessToken)
|
|
1043
|
+
throw new CliError("The keystore returned a different value than it was given");
|
|
1044
|
+
} finally {
|
|
1045
|
+
await store.remove(name).catch(() => {
|
|
1046
|
+
return;
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
io.stdout(`OK
|
|
1050
|
+
`);
|
|
1051
|
+
};
|
|
1052
|
+
|
|
1053
|
+
// apps/cli/src/commands/completion.ts
|
|
1054
|
+
var byCommand = () => Object.entries(USAGE).map(([name, line]) => [name, [...new Set([...line.match(/--[a-z][a-z-]*/g) ?? [], ...GLOBAL_FLAGS])]]);
|
|
1055
|
+
var bash = (commands) => `# bash completion for secretstore. In ~/.bashrc: source <(secretstore completion bash)
|
|
1056
|
+
_secretstore() {
|
|
1057
|
+
local cur=\${COMP_WORDS[COMP_CWORD]}
|
|
1058
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
1059
|
+
COMPREPLY=($(compgen -W "${commands.map(([name]) => name).join(" ")}" -- "$cur"))
|
|
1060
|
+
elif [[ $cur == -* ]]; then
|
|
1061
|
+
case \${COMP_WORDS[1]} in
|
|
1062
|
+
${commands.map(([name, flags]) => ` ${name}) COMPREPLY=($(compgen -W "${flags.join(" ")}" -- "$cur")) ;;`).join(`
|
|
1063
|
+
`)}
|
|
1064
|
+
esac
|
|
1065
|
+
fi
|
|
1066
|
+
}
|
|
1067
|
+
complete -o default -F _secretstore secretstore
|
|
1068
|
+
`;
|
|
1069
|
+
var zsh = (commands) => `#compdef secretstore
|
|
1070
|
+
# zsh completion for secretstore. In ~/.zshrc, after compinit: source <(secretstore completion zsh)
|
|
1071
|
+
_secretstore() {
|
|
1072
|
+
if (( CURRENT == 2 )); then
|
|
1073
|
+
compadd -- ${commands.map(([name]) => name).join(" ")}
|
|
1074
|
+
elif [[ $PREFIX == -* ]]; then
|
|
1075
|
+
case $words[2] in
|
|
1076
|
+
${commands.map(([name, flags]) => ` ${name}) compadd -- ${flags.join(" ")} ;;`).join(`
|
|
1077
|
+
`)}
|
|
1078
|
+
esac
|
|
1079
|
+
else
|
|
1080
|
+
_files
|
|
1081
|
+
fi
|
|
1082
|
+
}
|
|
1083
|
+
compdef _secretstore secretstore
|
|
1084
|
+
`;
|
|
1085
|
+
var fish = (commands) => `# fish completion for secretstore. Save it: secretstore completion fish > ~/.config/fish/completions/secretstore.fish
|
|
1086
|
+
complete -c secretstore -f -n __fish_use_subcommand -a "${commands.map(([name]) => name).join(" ")}"
|
|
1087
|
+
${commands.flatMap(([name, flags]) => flags.map((flag) => `complete -c secretstore -n "__fish_seen_subcommand_from ${name}" -l ${flag.slice(2)}`)).join(`
|
|
1088
|
+
`)}
|
|
1089
|
+
`;
|
|
1090
|
+
var powershell = (commands) => `# PowerShell completion for secretstore. In your profile ($PROFILE): secretstore completion powershell | Out-String | Invoke-Expression
|
|
1091
|
+
Register-ArgumentCompleter -Native -CommandName secretstore -ScriptBlock {
|
|
1092
|
+
param($wordToComplete, $commandAst, $cursorPosition)
|
|
1093
|
+
$flags = [ordered]@{
|
|
1094
|
+
${commands.map(([name, list]) => ` '${name}' = @(${list.map((flag) => `'${flag}'`).join(", ")})`).join(`
|
|
1095
|
+
`)}
|
|
1096
|
+
}
|
|
1097
|
+
$words = @($commandAst.CommandElements | Select-Object -Skip 1 | ForEach-Object { $_.ToString() })
|
|
1098
|
+
if ($wordToComplete) { $words = @($words | Select-Object -SkipLast 1) }
|
|
1099
|
+
$candidates = if ($words.Count -eq 0) { @($flags.Keys) } elseif ($flags.Contains($words[0])) { @($flags[$words[0]]) } else { @() }
|
|
1100
|
+
$candidates | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
|
|
1101
|
+
}
|
|
1102
|
+
`;
|
|
1103
|
+
var SCRIPTS = { bash, zsh, fish, powershell };
|
|
1104
|
+
var completion = async ({ args, io }) => {
|
|
1105
|
+
const [shell, ...extra] = args;
|
|
1106
|
+
const script = shell !== undefined && Object.hasOwn(SCRIPTS, shell) ? SCRIPTS[shell] : undefined;
|
|
1107
|
+
if (!script || extra.length > 0)
|
|
1108
|
+
throw usage("completion");
|
|
1109
|
+
io.stdout(script(byCommand()));
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
// apps/cli/src/commands/doctor.ts
|
|
1113
|
+
var SKEW_WARN_MS = 30000;
|
|
1114
|
+
var LOGIN_HINT = 'Run "secretstore login", or export SECRET_STORE_TOKEN for a personal token.';
|
|
1115
|
+
var doctor = async ({ io, flags, client }) => {
|
|
1116
|
+
const origin = resolveUrl(flags.url, io.env);
|
|
1117
|
+
const results = [];
|
|
1118
|
+
const check = async (name, hint, test) => {
|
|
1119
|
+
try {
|
|
1120
|
+
const [status, detail, own] = await test();
|
|
1121
|
+
results.push({ check: name, status, detail, ...own ? { hint: own } : {} });
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
results.push({ check: name, status: "fail", detail: error instanceof Error ? error.message : String(error), ...hint ? { hint } : {} });
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
const seen = { serverDate: null, reachable: false, credential: false };
|
|
1127
|
+
await check("version", "", async () => ["ok", package_default.version]);
|
|
1128
|
+
await check("server", "Check the address (--url or SECRET_STORE_URL) and your network.", async () => {
|
|
1129
|
+
const meta = await discover(origin, async (url, init) => {
|
|
1130
|
+
const response = await fetch(url, init);
|
|
1131
|
+
seen.serverDate = response.headers.get("date");
|
|
1132
|
+
return response;
|
|
1133
|
+
});
|
|
1134
|
+
seen.reachable = true;
|
|
1135
|
+
return ["ok", `${origin} (issuer ${meta.issuer})`];
|
|
1136
|
+
});
|
|
1137
|
+
await check("auth", LOGIN_HINT, async () => {
|
|
1138
|
+
const given = resolveToken(flags.token, io.env);
|
|
1139
|
+
if (given) {
|
|
1140
|
+
seen.credential = true;
|
|
1141
|
+
return ["ok", `${given.via}, a personal token sent as a bearer`];
|
|
1142
|
+
}
|
|
1143
|
+
const login = await openKeystore(io.env, io.stderr).get(origin);
|
|
1144
|
+
if (!login)
|
|
1145
|
+
return ["fail", "no credential for this server", LOGIN_HINT];
|
|
1146
|
+
seen.credential = true;
|
|
1147
|
+
const left = login.expiresAt - io.now();
|
|
1148
|
+
return ["ok", `login, scopes ${login.scope}; the access token ${left > 0 ? `lasts ${Math.ceil(left / 60000)} more minutes` : "has expired and renews on the next call"}`];
|
|
1149
|
+
});
|
|
1150
|
+
await check("keystore", "Set SECRET_STORE_KEYSTORE to os or file.", async () => {
|
|
1151
|
+
const backend = await activeBackend(io.env);
|
|
1152
|
+
if (backend === "os")
|
|
1153
|
+
return ["ok", "the operating system's keystore"];
|
|
1154
|
+
const where = credentialsPath(io.env);
|
|
1155
|
+
return io.env.SECRET_STORE_KEYSTORE === "file" ? ["ok", `the credentials file ${where}`] : ["warn", `no OS keystore here, so logins go to ${where} (mode 0600)`, "Set SECRET_STORE_KEYSTORE=file to say so on purpose."];
|
|
1156
|
+
});
|
|
1157
|
+
await check("token", `Run "secretstore login" again, or create a new token on ${origin}/settings/agents.`, async () => {
|
|
1158
|
+
if (!seen.reachable || !seen.credential)
|
|
1159
|
+
return ["warn", "not checked: needs a reachable server and a credential"];
|
|
1160
|
+
const who = firstJson(await client.call(TOOLS.whoami, {}));
|
|
1161
|
+
return ["ok", `accepted by the server${who.scopes ? `, scopes ${who.scopes.join(" ")}` : ""}`];
|
|
1162
|
+
});
|
|
1163
|
+
await check("pin", `Fix the JSON, or replace the file with "secretstore init <project> <environment> --force".`, async () => {
|
|
1164
|
+
const pin = readPin(io.cwd);
|
|
1165
|
+
if (!pin.project || !pin.environment) {
|
|
1166
|
+
return ["warn", `no ${PIN_FILE} with a project and an environment in this directory or above`, 'Pin them with "secretstore init <project> <environment>".'];
|
|
1167
|
+
}
|
|
1168
|
+
return ["ok", `${PIN_FILE}: ${pin.project}/${pin.environment}`];
|
|
1169
|
+
});
|
|
1170
|
+
await check("clock", "", async () => {
|
|
1171
|
+
const server = Date.parse(seen.serverDate ?? "");
|
|
1172
|
+
if (Number.isNaN(server))
|
|
1173
|
+
return ["warn", `not checked: ${seen.reachable ? "the server's answer had no Date header" : "the server was not reached"}`];
|
|
1174
|
+
const skew = io.now() - server;
|
|
1175
|
+
const detail = `${Math.round(skew / 1000)} s from the server`;
|
|
1176
|
+
return Math.abs(skew) > SKEW_WARN_MS ? ["warn", detail, "DPoP proofs need a clock within about 30 s of the server's: sync your system clock."] : ["ok", detail];
|
|
1177
|
+
});
|
|
1178
|
+
if (flags.json)
|
|
1179
|
+
io.stdout(JSON.stringify(results, null, 2) + `
|
|
1180
|
+
`);
|
|
1181
|
+
else
|
|
1182
|
+
io.stdout(results.map((r) => `${r.status.padEnd(4)} ${r.check.padEnd(8)} ${r.detail}${r.hint ? ` Hint: ${r.hint}` : ""}`).join(`
|
|
1183
|
+
`) + `
|
|
1184
|
+
`);
|
|
1185
|
+
return results.some((r) => r.status === "fail") ? EXIT.error : 0;
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
// apps/cli/src/commands/env.ts
|
|
1189
|
+
import { randomUUID } from "node:crypto";
|
|
1190
|
+
import { chmodSync, lstatSync, renameSync as renameSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1191
|
+
import { constants } from "node:os";
|
|
1192
|
+
import { resolve } from "node:path";
|
|
1193
|
+
|
|
1194
|
+
// apps/cli/src/format.ts
|
|
1195
|
+
var KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1196
|
+
var BARE = /^[A-Za-z0-9_./:@+=,-]+$/;
|
|
1197
|
+
function assertKeys(env) {
|
|
1198
|
+
for (const key of Object.keys(env)) {
|
|
1199
|
+
if (!KEY.test(key))
|
|
1200
|
+
throw new CliError(`Refusing to write the key ${JSON.stringify(key)}: names are letters, digits and underscores`);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
function entries(env) {
|
|
1204
|
+
assertKeys(env);
|
|
1205
|
+
return Object.entries(env);
|
|
1206
|
+
}
|
|
1207
|
+
var lines = (rows) => rows.length ? rows.join(`
|
|
1208
|
+
`) + `
|
|
1209
|
+
` : "";
|
|
1210
|
+
function toDotenv(env) {
|
|
1211
|
+
return lines(entries(env).map(([key, value]) => BARE.test(value) ? `${key}=${value}` : `${key}="${value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"").replaceAll(`
|
|
1212
|
+
`, "\\n").replaceAll("\r", "\\r")}"`));
|
|
1213
|
+
}
|
|
1214
|
+
function toShell(env) {
|
|
1215
|
+
return lines(entries(env).map(([key, value]) => `export ${key}='${value.replaceAll("'", "'\\''")}'`));
|
|
1216
|
+
}
|
|
1217
|
+
function toJson(env) {
|
|
1218
|
+
return JSON.stringify(env, null, 2) + `
|
|
1219
|
+
`;
|
|
1220
|
+
}
|
|
1221
|
+
function table(header, rows) {
|
|
1222
|
+
const all = [header, ...rows];
|
|
1223
|
+
const widths = header.map((_, i) => Math.max(...all.map((row) => (row[i] ?? "").length)));
|
|
1224
|
+
return all.map((row) => row.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(" ").trimEnd()).join(`
|
|
1225
|
+
`) + `
|
|
1226
|
+
`;
|
|
1227
|
+
}
|
|
1228
|
+
var red = (text, on) => on ? `\x1B[31m${text}\x1B[0m` : text;
|
|
1229
|
+
|
|
1230
|
+
// apps/cli/src/commands/env.ts
|
|
1231
|
+
var signalNumber = (signal) => {
|
|
1232
|
+
const signals = constants.signals;
|
|
1233
|
+
return (typeof signal === "string" ? signals[signal] : undefined) ?? 0;
|
|
1234
|
+
};
|
|
1235
|
+
var FORMATS = { dotenv: toDotenv, json: toJson, shell: toShell };
|
|
1236
|
+
var isFormat = (name) => Object.hasOwn(FORMATS, name);
|
|
1237
|
+
function requireTty({ io, flags }) {
|
|
1238
|
+
if (!io.isTty && !flags.allowNonTty) {
|
|
1239
|
+
throw new CliError('Refusing to print secrets to a pipe or file because an agent or a log can read it; pass --allow-non-tty if you mean it (or use "run" or "pull").', EXIT.usage);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
var OVERRIDES = /^(PATH|PATHEXT|COMSPEC|SYSTEMROOT|NODE_OPTIONS|NODE_PATH|SHELLOPTS|BASH_ENV|ENV|PERL5OPT|PERL5LIB|RUBYOPT|RUBYLIB|PROMPT_COMMAND|NODE_EXTRA_CA_CERTS|JAVA_TOOL_OPTIONS|_JAVA_OPTIONS|DOTNET_STARTUP_HOOKS|PYTHON.*|(LD|DYLD|BUN|GIT|SSH|NPM_CONFIG)_.*|.*_PRELOAD)$/i;
|
|
1243
|
+
var overridingKeys = (keys) => keys.filter((key) => OVERRIDES.test(key));
|
|
1244
|
+
function fetchEnvironment(ctx, project, environment) {
|
|
1245
|
+
return withAccess(ctx, { project, environment }, async () => {
|
|
1246
|
+
const [body, ...notes] = await ctx.client.call(TOOLS.getEnvironment, { project, environment, format: "json" });
|
|
1247
|
+
for (const note of notes)
|
|
1248
|
+
ctx.io.stderr(`note: ${note}
|
|
1249
|
+
`);
|
|
1250
|
+
return firstJson([body ?? ""]);
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
var pull = async (ctx) => {
|
|
1254
|
+
const { io, flags } = ctx;
|
|
1255
|
+
const { project, environment, rest } = await readTarget(ctx, ctx.args, 0);
|
|
1256
|
+
if (rest.length > 0)
|
|
1257
|
+
throw usage("pull");
|
|
1258
|
+
const out = flags.out ?? ".env";
|
|
1259
|
+
const path = resolve(io.cwd, out);
|
|
1260
|
+
if (lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink())
|
|
1261
|
+
throw new CliError(`${out} is a symbolic link; remove it and run pull again`, EXIT.usage);
|
|
1262
|
+
const env = await fetchEnvironment(ctx, project, environment);
|
|
1263
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
1264
|
+
try {
|
|
1265
|
+
writeFileSync2(temp, toDotenv(env), { mode: 384, flag: "wx" });
|
|
1266
|
+
chmodSync(temp, 384);
|
|
1267
|
+
renameSync2(temp, path);
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
rmSync(temp, { force: true });
|
|
1270
|
+
throw error;
|
|
1271
|
+
}
|
|
1272
|
+
const keys = Object.keys(env);
|
|
1273
|
+
if (flags.json)
|
|
1274
|
+
io.stdout(JSON.stringify({ file: out, keys }, null, 2) + `
|
|
1275
|
+
`);
|
|
1276
|
+
else
|
|
1277
|
+
io.stdout(`Wrote ${keys.length} ${keys.length === 1 ? "key" : "keys"} to ${out} (mode 0600).
|
|
1278
|
+
Add ${out} to .gitignore so it is never committed.
|
|
1279
|
+
`);
|
|
1280
|
+
};
|
|
1281
|
+
var exportEnv = async (ctx) => {
|
|
1282
|
+
const { io, flags } = ctx;
|
|
1283
|
+
const { project, environment, rest } = await readTarget(ctx, ctx.args, 0);
|
|
1284
|
+
if (rest.length > 0)
|
|
1285
|
+
throw usage("export");
|
|
1286
|
+
const format = flags.format ?? (flags.json ? "json" : "dotenv");
|
|
1287
|
+
if (!isFormat(format))
|
|
1288
|
+
throw new CliError(`--format is dotenv, json or shell, not "${format}"`, EXIT.usage);
|
|
1289
|
+
requireTty(ctx);
|
|
1290
|
+
io.stdout(FORMATS[format](await fetchEnvironment(ctx, project, environment)));
|
|
1291
|
+
};
|
|
1292
|
+
var run = async (ctx) => {
|
|
1293
|
+
const { io, flags, rest: command } = ctx;
|
|
1294
|
+
const { project, environment, rest } = await readTarget(ctx, ctx.args, 0);
|
|
1295
|
+
if (rest.length > 0 || !command?.length)
|
|
1296
|
+
throw usage("run");
|
|
1297
|
+
const secrets = await fetchEnvironment(ctx, project, environment);
|
|
1298
|
+
assertKeys(secrets);
|
|
1299
|
+
const refused = overridingKeys(Object.keys(secrets));
|
|
1300
|
+
if (refused.length > 0 && !flags.allowEnvOverride) {
|
|
1301
|
+
throw new CliError(`Not running: ${refused.join(", ")} would override the command's own environment (loader, path or tool settings). Remove the secret${refused.length === 1 ? "" : "s"} or pass --allow-env-override if you trust everyone who can edit this environment.`, EXIT.usage);
|
|
1302
|
+
}
|
|
1303
|
+
const { SECRET_STORE_TOKEN: _token, SECRET_STORE_PACKAGE_MANAGED: _managed, ...inherited } = io.env;
|
|
1304
|
+
let child;
|
|
1305
|
+
try {
|
|
1306
|
+
child = spawn(command, { env: { ...inherited, ...secrets }, cwd: io.cwd, stdio: "inherit" });
|
|
1307
|
+
} catch {
|
|
1308
|
+
throw new CliError(`Cannot start "${command[0]}"`, EXIT.cannotRun);
|
|
1309
|
+
}
|
|
1310
|
+
const forward = (signal) => () => child.kill(signal);
|
|
1311
|
+
const handlers = ["SIGINT", "SIGTERM"].map((signal) => [signal, forward(signal)]);
|
|
1312
|
+
for (const [signal, handler] of handlers)
|
|
1313
|
+
process.on(signal, handler);
|
|
1314
|
+
try {
|
|
1315
|
+
await child.exited;
|
|
1316
|
+
} catch {
|
|
1317
|
+
throw new CliError(`Cannot start "${command[0]}"`, EXIT.cannotRun);
|
|
1318
|
+
} finally {
|
|
1319
|
+
for (const [signal, handler] of handlers)
|
|
1320
|
+
process.off(signal, handler);
|
|
1321
|
+
}
|
|
1322
|
+
return child.exitCode ?? 128 + signalNumber(child.signalCode);
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1325
|
+
// apps/cli/src/commands/init.ts
|
|
1326
|
+
import { writeFileSync as writeFileSync3 } from "node:fs";
|
|
1327
|
+
import { join as join3 } from "node:path";
|
|
1328
|
+
var init = async ({ args, client, flags, io }) => {
|
|
1329
|
+
if (args.length === 1 || args.length > 2)
|
|
1330
|
+
throw usage("init");
|
|
1331
|
+
const environmentsOf = async (project) => firstJson(await client.call(TOOLS.listEnvironments, { project })).environments;
|
|
1332
|
+
const [project, environment] = args;
|
|
1333
|
+
if (project === undefined || environment === undefined) {
|
|
1334
|
+
const projects = await allProjects(client);
|
|
1335
|
+
const listed = [];
|
|
1336
|
+
for (const p of projects) {
|
|
1337
|
+
listed.push({ ...p, environments: await environmentsOf(p.slug) });
|
|
1338
|
+
}
|
|
1339
|
+
if (flags.json)
|
|
1340
|
+
io.stdout(JSON.stringify(listed, null, 2) + `
|
|
1341
|
+
`);
|
|
1342
|
+
else if (listed.length === 0)
|
|
1343
|
+
io.stdout(`No projects.
|
|
1344
|
+
`);
|
|
1345
|
+
else {
|
|
1346
|
+
const rows = listed.flatMap((p) => p.environments.map((e) => [p.slug + (p.archived ? " (archived)" : ""), e.slug, e.kind, e.restricted ? "restricted for this token" : ""]));
|
|
1347
|
+
io.stdout(table(["PROJECT", "ENVIRONMENT", "KIND", ""], rows) + `Pin one to this directory with: secretstore init <project> <environment>
|
|
1348
|
+
`);
|
|
1349
|
+
}
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
const environments = await environmentsOf(project);
|
|
1353
|
+
if (!environments.some((e) => e.slug === environment)) {
|
|
1354
|
+
throw new CliError(`The project "${project}" has no environment "${environment}". It has: ${environments.map((e) => e.slug).join(", ") || "none"}.`, EXIT.notFound);
|
|
1355
|
+
}
|
|
1356
|
+
try {
|
|
1357
|
+
writeFileSync3(join3(io.cwd, PIN_FILE), JSON.stringify({ project, environment }, null, 2) + `
|
|
1358
|
+
`, { flag: flags.force ? "w" : "wx" });
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST")
|
|
1361
|
+
throw new CliError(`${PIN_FILE} already exists here; pass --force to replace it`, EXIT.usage);
|
|
1362
|
+
throw error;
|
|
1363
|
+
}
|
|
1364
|
+
if (flags.json)
|
|
1365
|
+
io.stdout(JSON.stringify({ file: PIN_FILE, project, environment }, null, 2) + `
|
|
1366
|
+
`);
|
|
1367
|
+
else
|
|
1368
|
+
io.stdout(`Wrote ${PIN_FILE}: ${project}/${environment}. It holds names only, so it is safe to commit.
|
|
1369
|
+
If you use "secretstore pull", add .env to your .gitignore (this command never edits it).
|
|
1370
|
+
`);
|
|
1371
|
+
};
|
|
1372
|
+
|
|
1373
|
+
// apps/cli/src/commands/push.ts
|
|
1374
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
1375
|
+
import { resolve as resolve2 } from "node:path";
|
|
1376
|
+
|
|
1377
|
+
// apps/cli/src/dotenv.ts
|
|
1378
|
+
var CHUNK_BYTES = 200 * 1024;
|
|
1379
|
+
var ENTRY = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
|
|
1380
|
+
function closingQuote(text, from, q) {
|
|
1381
|
+
for (let i = from;i < text.length; i++) {
|
|
1382
|
+
if (text[i] === "\\")
|
|
1383
|
+
i++;
|
|
1384
|
+
else if (text[i] === q)
|
|
1385
|
+
return i;
|
|
1386
|
+
}
|
|
1387
|
+
return -1;
|
|
1388
|
+
}
|
|
1389
|
+
function entries2(text) {
|
|
1390
|
+
const found = [];
|
|
1391
|
+
let preamble = "";
|
|
1392
|
+
let open;
|
|
1393
|
+
for (const line of text.split(/(?<=\n)/)) {
|
|
1394
|
+
if (open !== undefined) {
|
|
1395
|
+
if (closingQuote(line, 0, open) !== -1)
|
|
1396
|
+
open = undefined;
|
|
1397
|
+
} else {
|
|
1398
|
+
const start = ENTRY.exec(line);
|
|
1399
|
+
if (start?.[1] !== undefined) {
|
|
1400
|
+
found.push({ key: start[1], text: preamble });
|
|
1401
|
+
preamble = "";
|
|
1402
|
+
const value = line.slice(start[0].length).trimStart();
|
|
1403
|
+
const quote = value[0];
|
|
1404
|
+
if ((quote === '"' || quote === "'" || quote === "`") && closingQuote(value, 1, quote) === -1)
|
|
1405
|
+
open = quote;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
const last = found.at(-1);
|
|
1409
|
+
if (last)
|
|
1410
|
+
last.text += line;
|
|
1411
|
+
else
|
|
1412
|
+
preamble += line;
|
|
1413
|
+
}
|
|
1414
|
+
return found;
|
|
1415
|
+
}
|
|
1416
|
+
function chunkDotenv(text, max = CHUNK_BYTES) {
|
|
1417
|
+
const chunks = [];
|
|
1418
|
+
let current = "";
|
|
1419
|
+
let size = 0;
|
|
1420
|
+
for (const entry of entries2(text)) {
|
|
1421
|
+
const bytes = Buffer.byteLength(entry.text);
|
|
1422
|
+
if (bytes > max)
|
|
1423
|
+
throw new CliError(`${entry.key} alone is ${Math.ceil(bytes / 1024)} KiB, more than the ${Math.floor(max / 1024)} KiB one import takes`, EXIT.usage);
|
|
1424
|
+
if (size + bytes > max) {
|
|
1425
|
+
chunks.push(current);
|
|
1426
|
+
current = "";
|
|
1427
|
+
size = 0;
|
|
1428
|
+
}
|
|
1429
|
+
current += entry.text;
|
|
1430
|
+
size += bytes;
|
|
1431
|
+
}
|
|
1432
|
+
if (current)
|
|
1433
|
+
chunks.push(current);
|
|
1434
|
+
return chunks;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// apps/cli/src/commands/push.ts
|
|
1438
|
+
var NOT_DONE = [
|
|
1439
|
+
["protected", "protected"],
|
|
1440
|
+
["sealed", "sealed in the browser"],
|
|
1441
|
+
["restricted", "outside this token's limits"],
|
|
1442
|
+
["invalid", "not valid keys"]
|
|
1443
|
+
];
|
|
1444
|
+
function summary(total, where) {
|
|
1445
|
+
const lines = [`Imported into ${where}: ${total.created.length} created, ${total.updated.length} updated, ${total.skipped.length} skipped (already set).`];
|
|
1446
|
+
for (const [list, why] of NOT_DONE)
|
|
1447
|
+
if (total[list].length > 0)
|
|
1448
|
+
lines.push(`Skipped, ${why}: ${total[list].join(", ")}`);
|
|
1449
|
+
if (total.skipped.length > 0)
|
|
1450
|
+
lines.push("Pass --overwrite to replace values that are already set.");
|
|
1451
|
+
return lines.join(`
|
|
1452
|
+
`) + `
|
|
1453
|
+
`;
|
|
1454
|
+
}
|
|
1455
|
+
var push = async (ctx) => {
|
|
1456
|
+
const { io, flags, client } = ctx;
|
|
1457
|
+
const { project, environment, rest } = targetFor(ctx, ctx.args, { environment: true, need: 0, write: true });
|
|
1458
|
+
if (rest.length > 1)
|
|
1459
|
+
throw usage("push");
|
|
1460
|
+
if (flags.kind !== undefined && flags.kind !== "secret" && flags.kind !== "plain")
|
|
1461
|
+
throw new CliError(`--kind is secret or plain, not "${flags.kind}"`, EXIT.usage);
|
|
1462
|
+
const label = rest[0] ?? ".env";
|
|
1463
|
+
let text;
|
|
1464
|
+
if (label === "-") {
|
|
1465
|
+
text = await io.readStdin();
|
|
1466
|
+
} else {
|
|
1467
|
+
try {
|
|
1468
|
+
text = readFileSync3(resolve2(io.cwd, label), "utf8");
|
|
1469
|
+
} catch {
|
|
1470
|
+
throw new CliError(`Cannot read ${label}`, EXIT.usage);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
const chunks = chunkDotenv(text);
|
|
1474
|
+
if (chunks.length === 0)
|
|
1475
|
+
throw new CliError(`No KEY=value lines in ${label === "-" ? "the input" : label}`, EXIT.usage);
|
|
1476
|
+
const total = { created: [], updated: [], skipped: [], invalid: [], sealed: [], protected: [], restricted: [] };
|
|
1477
|
+
const refused = [];
|
|
1478
|
+
let failure;
|
|
1479
|
+
let landed = 0;
|
|
1480
|
+
for (const chunk of chunks) {
|
|
1481
|
+
try {
|
|
1482
|
+
const result = firstJson(await client.call(TOOLS.importEnv, { project, environment, text: chunk, ...flags.overwrite ? { overwrite: true } : {}, ...flags.kind ? { kind: flags.kind } : {} }));
|
|
1483
|
+
for (const list of Object.keys(total))
|
|
1484
|
+
total[list].push(...result[list] ?? []);
|
|
1485
|
+
landed++;
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
if (!(error instanceof CliError && error.code === EXIT.approval)) {
|
|
1488
|
+
failure = error;
|
|
1489
|
+
break;
|
|
1490
|
+
}
|
|
1491
|
+
refused.push(error);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
if (landed === 0)
|
|
1495
|
+
throw failure ?? refused[0];
|
|
1496
|
+
if (flags.json)
|
|
1497
|
+
io.stdout(JSON.stringify({ project, environment, ...total }, null, 2) + `
|
|
1498
|
+
`);
|
|
1499
|
+
else
|
|
1500
|
+
io.stdout(summary(total, `${project}/${environment}`));
|
|
1501
|
+
for (const error of refused)
|
|
1502
|
+
io.stderr(`note: ${error.message}
|
|
1503
|
+
`);
|
|
1504
|
+
if (failure)
|
|
1505
|
+
throw failure;
|
|
1506
|
+
const done = total.created.length + total.updated.length + total.skipped.length;
|
|
1507
|
+
return done === 0 && total.protected.length + total.sealed.length > 0 ? EXIT.approval : 0;
|
|
1508
|
+
};
|
|
1509
|
+
|
|
1510
|
+
// apps/cli/src/commands/read.ts
|
|
1511
|
+
var pretty = (value) => JSON.stringify(value, null, 2) + `
|
|
1512
|
+
`;
|
|
1513
|
+
var whoami = async ({ args, client, io }) => {
|
|
1514
|
+
if (args.length > 0)
|
|
1515
|
+
throw usage("whoami");
|
|
1516
|
+
const token = firstJson(await client.call(TOOLS.whoami, {}));
|
|
1517
|
+
io.stdout(pretty({ ...token, authenticatedWith: await client.via() }));
|
|
1518
|
+
};
|
|
1519
|
+
var projects = async ({ args, client, flags, io }) => {
|
|
1520
|
+
if (args.length > 0)
|
|
1521
|
+
throw usage("projects");
|
|
1522
|
+
const rows = await allProjects(client);
|
|
1523
|
+
if (flags.json)
|
|
1524
|
+
io.stdout(pretty(rows));
|
|
1525
|
+
else if (rows.length === 0)
|
|
1526
|
+
io.stdout(`No projects.
|
|
1527
|
+
`);
|
|
1528
|
+
else
|
|
1529
|
+
io.stdout(table(["SLUG", "NAME", "ROLE", "ENVS", "SECRETS"], rows.map((p) => [p.slug + (p.archived ? " (archived)" : ""), p.name, p.role, String(p.environments), String(p.secrets)])));
|
|
1530
|
+
};
|
|
1531
|
+
var envs = async ({ args, client, flags, io }) => {
|
|
1532
|
+
const { project, rest } = targetFor({ io, flags }, args, { environment: false, need: 0 });
|
|
1533
|
+
if (rest.length > 0)
|
|
1534
|
+
throw usage("envs");
|
|
1535
|
+
const listed = firstJson(await client.call(TOOLS.listEnvironments, { project }));
|
|
1536
|
+
const environments = listed.environments.map(({ id, slug, name, kind, restricted }) => ({ ...id === undefined ? {} : { id }, slug, name, kind, ...restricted ? { restricted } : {} }));
|
|
1537
|
+
if (flags.json)
|
|
1538
|
+
io.stdout(pretty(environments));
|
|
1539
|
+
else
|
|
1540
|
+
io.stdout(table(["SLUG", "NAME", "KIND", ""], environments.map((e) => [e.slug, e.name, e.kind, e.restricted ? "restricted for this token" : ""])));
|
|
1541
|
+
};
|
|
1542
|
+
var get = async (ctx) => {
|
|
1543
|
+
const { args, client, flags, io } = ctx;
|
|
1544
|
+
const { project, environment, rest } = await readTarget(ctx, args, 1);
|
|
1545
|
+
const [key] = rest;
|
|
1546
|
+
if (key === undefined || rest.length > 1)
|
|
1547
|
+
throw usage("get");
|
|
1548
|
+
requireTty(ctx);
|
|
1549
|
+
const result = await withAccess(ctx, { project, environment, keys: [key] }, async () => firstJson(await client.call(TOOLS.getSecret, { project, environment, key })));
|
|
1550
|
+
io.stdout(flags.json ? pretty({ key: result.key, value: result.value }) : result.value + `
|
|
1551
|
+
`);
|
|
1552
|
+
};
|
|
1553
|
+
|
|
1554
|
+
// apps/cli/update-public-key.txt
|
|
1555
|
+
var update_public_key_default = "";
|
|
1556
|
+
|
|
1557
|
+
// apps/cli/src/prompt.ts
|
|
1558
|
+
import { readSync } from "node:fs";
|
|
1559
|
+
function confirmOnStderr(question) {
|
|
1560
|
+
process.stderr.write(`${question} [y/N] `);
|
|
1561
|
+
const byte = Buffer.alloc(1);
|
|
1562
|
+
let line = "";
|
|
1563
|
+
try {
|
|
1564
|
+
while (readSync(0, byte, 0, 1, null) === 1 && byte.readUInt8(0) !== 10)
|
|
1565
|
+
line += String.fromCharCode(byte.readUInt8(0));
|
|
1566
|
+
} catch {
|
|
1567
|
+
return false;
|
|
1568
|
+
}
|
|
1569
|
+
return /^y(es)?$/i.test(line.trim());
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// apps/cli/src/update.ts
|
|
1573
|
+
import { chmodSync as chmodSync2, readFileSync as readFileSync4, realpathSync, renameSync as renameSync3, rmSync as rmSync2 } from "node:fs";
|
|
1574
|
+
import { basename, dirname as dirname3, join as join4 } from "node:path";
|
|
1575
|
+
|
|
1576
|
+
// apps/web/src/lib/desktop-version.ts
|
|
1577
|
+
var VERSION = /^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
1578
|
+
function isVersion(value) {
|
|
1579
|
+
return VERSION.test(value);
|
|
1580
|
+
}
|
|
1581
|
+
function compareVersions(a, b) {
|
|
1582
|
+
const pa = VERSION.exec(a);
|
|
1583
|
+
const pb = VERSION.exec(b);
|
|
1584
|
+
if (!pa || !pb)
|
|
1585
|
+
throw new Error("Versions look like 1.2.3 or 1.2.3-beta.1");
|
|
1586
|
+
for (let i = 1;i <= 3; i++) {
|
|
1587
|
+
const diff = Number(pa[i]) - Number(pb[i]);
|
|
1588
|
+
if (diff !== 0)
|
|
1589
|
+
return diff;
|
|
1590
|
+
}
|
|
1591
|
+
const [preA, preB] = [pa[4], pb[4]];
|
|
1592
|
+
if (preA === preB)
|
|
1593
|
+
return 0;
|
|
1594
|
+
if (preA === undefined)
|
|
1595
|
+
return 1;
|
|
1596
|
+
if (preB === undefined)
|
|
1597
|
+
return -1;
|
|
1598
|
+
const idsA = preA.split(".");
|
|
1599
|
+
const idsB = preB.split(".");
|
|
1600
|
+
for (let i = 0;i < Math.max(idsA.length, idsB.length); i++) {
|
|
1601
|
+
const [x, y] = [idsA[i], idsB[i]];
|
|
1602
|
+
if (x === undefined)
|
|
1603
|
+
return -1;
|
|
1604
|
+
if (y === undefined)
|
|
1605
|
+
return 1;
|
|
1606
|
+
const [nx, ny] = [/^\d+$/.test(x), /^\d+$/.test(y)];
|
|
1607
|
+
if (nx && ny && Number(x) !== Number(y))
|
|
1608
|
+
return Number(x) - Number(y);
|
|
1609
|
+
if (nx !== ny)
|
|
1610
|
+
return nx ? -1 : 1;
|
|
1611
|
+
if (x !== y)
|
|
1612
|
+
return x < y ? -1 : 1;
|
|
1613
|
+
}
|
|
1614
|
+
return 0;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// apps/cli/src/update-verify.ts
|
|
1618
|
+
var toHex = (bytes) => Buffer.from(bytes).toString("hex");
|
|
1619
|
+
async function verifyDigest(publicKey, digest, signature) {
|
|
1620
|
+
const key = await crypto.subtle.importKey("raw", Buffer.from(publicKey, "base64"), "Ed25519", false, ["verify"]);
|
|
1621
|
+
return crypto.subtle.verify("Ed25519", key, Buffer.from(signature, "base64"), digest);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// apps/cli/src/update.ts
|
|
1625
|
+
var FEED_TIMEOUT_MS = 30000;
|
|
1626
|
+
var DOWNLOAD_TIMEOUT_MS = 10 * 60000;
|
|
1627
|
+
var VERSION_TIMEOUT_MS = 1e4;
|
|
1628
|
+
var TARGETS = new Set(["windows-x64", "linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64"]);
|
|
1629
|
+
var OS = new Map([
|
|
1630
|
+
["win32", "windows"],
|
|
1631
|
+
["linux", "linux"],
|
|
1632
|
+
["darwin", "darwin"]
|
|
1633
|
+
]);
|
|
1634
|
+
function platformKey(platform, arch) {
|
|
1635
|
+
const key = `${OS.get(platform)}-${arch}`;
|
|
1636
|
+
return TARGETS.has(key) ? key : undefined;
|
|
1637
|
+
}
|
|
1638
|
+
var isDevelopmentRun = (execPath) => /^bun(\.exe)?$/i.test(basename(execPath));
|
|
1639
|
+
var installedPath = () => realpathSync(process.execPath);
|
|
1640
|
+
function isPackageManaged(execPath, env) {
|
|
1641
|
+
if (!isBun || env.SECRET_STORE_PACKAGE_MANAGED === "1")
|
|
1642
|
+
return true;
|
|
1643
|
+
try {
|
|
1644
|
+
const manifest = JSON.parse(readFileSync4(join4(dirname3(realpathSync(execPath)), "..", "package.json"), "utf8"));
|
|
1645
|
+
return isRecord(manifest) && typeof manifest.name === "string" && /^@secretstore\/cli-(?:windows-x64|linux-(?:x64|arm64)|darwin-(?:x64|arm64))$/.test(manifest.name);
|
|
1646
|
+
} catch {
|
|
1647
|
+
return false;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
var aside = (execPath) => `${execPath}.old`;
|
|
1651
|
+
function removeStale(execPath) {
|
|
1652
|
+
if (isDevelopmentRun(execPath) || isPackageManaged(execPath, process.env))
|
|
1653
|
+
return;
|
|
1654
|
+
try {
|
|
1655
|
+
rmSync2(aside(realpathSync(execPath)), { force: true });
|
|
1656
|
+
} catch {}
|
|
1657
|
+
}
|
|
1658
|
+
var plain = (text) => text.replaceAll(/(?![\n\t])\p{Cc}/gu, "");
|
|
1659
|
+
var unreachable2 = (origin, error) => new CliError(`Cannot reach ${origin}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1660
|
+
async function fetchRelease(origin, fetcher) {
|
|
1661
|
+
let response;
|
|
1662
|
+
try {
|
|
1663
|
+
response = await fetcher(`${origin}/api/cli/latest`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(FEED_TIMEOUT_MS) });
|
|
1664
|
+
} catch (error) {
|
|
1665
|
+
throw unreachable2(origin, error);
|
|
1666
|
+
}
|
|
1667
|
+
const body = await response.json().catch(() => {
|
|
1668
|
+
return;
|
|
1669
|
+
});
|
|
1670
|
+
if (response.status === 404 && isRecord(body) && body.code === "not_found")
|
|
1671
|
+
return;
|
|
1672
|
+
if (!response.ok)
|
|
1673
|
+
throw new CliError(`The update feed at ${origin} answered HTTP ${response.status}`);
|
|
1674
|
+
if (!isRecord(body) || typeof body.version !== "string" || !isVersion(body.version) || !isRecord(body.platforms)) {
|
|
1675
|
+
throw new CliError("The update feed is not in the format this version of the CLI reads");
|
|
1676
|
+
}
|
|
1677
|
+
return { version: body.version, notes: typeof body.notes === "string" ? plain(body.notes) : "", platforms: body.platforms };
|
|
1678
|
+
}
|
|
1679
|
+
function assetFor(release, key) {
|
|
1680
|
+
const asset = Object.hasOwn(release.platforms, key) ? release.platforms[key] : undefined;
|
|
1681
|
+
if (asset === undefined)
|
|
1682
|
+
throw new CliError(`Version ${release.version} has no build for ${key}`);
|
|
1683
|
+
if (!isRecord(asset) || typeof asset.size !== "number" || !Number.isSafeInteger(asset.size) || asset.size <= 0)
|
|
1684
|
+
throw new CliError("The update feed is not in the format this version of the CLI reads");
|
|
1685
|
+
if (typeof asset.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(asset.sha256) || typeof asset.signature !== "string")
|
|
1686
|
+
throw new CliError("The update feed is not in the format this version of the CLI reads");
|
|
1687
|
+
return { size: asset.size, sha256: asset.sha256, signature: asset.signature };
|
|
1688
|
+
}
|
|
1689
|
+
async function download(url, path, size, fetcher) {
|
|
1690
|
+
let response;
|
|
1691
|
+
try {
|
|
1692
|
+
response = await fetcher(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
1693
|
+
} catch (error) {
|
|
1694
|
+
throw unreachable2(new URL(url).origin, error);
|
|
1695
|
+
}
|
|
1696
|
+
if (!response.ok || !response.body)
|
|
1697
|
+
throw new CliError(`The download answered HTTP ${response.status}`);
|
|
1698
|
+
const hasher = sha256();
|
|
1699
|
+
const sink = await fileWriter(path);
|
|
1700
|
+
let received = 0;
|
|
1701
|
+
try {
|
|
1702
|
+
for await (const chunk of response.body) {
|
|
1703
|
+
received += chunk.byteLength;
|
|
1704
|
+
if (received > size)
|
|
1705
|
+
throw new CliError("The download is larger than the server announced; nothing was installed");
|
|
1706
|
+
hasher.update(chunk);
|
|
1707
|
+
await sink.write(chunk);
|
|
1708
|
+
}
|
|
1709
|
+
} finally {
|
|
1710
|
+
await sink.end();
|
|
1711
|
+
}
|
|
1712
|
+
if (received !== size)
|
|
1713
|
+
throw new CliError(`The download is ${received} bytes, not the ${size} the server announced; nothing was installed`);
|
|
1714
|
+
return hasher.digest();
|
|
1715
|
+
}
|
|
1716
|
+
async function replaceExecutable(execPath, next, expected, versionOf) {
|
|
1717
|
+
const old = aside(execPath);
|
|
1718
|
+
chmodSync2(next, 493);
|
|
1719
|
+
rmSync2(old, { force: true });
|
|
1720
|
+
renameSync3(execPath, old);
|
|
1721
|
+
try {
|
|
1722
|
+
renameSync3(next, execPath);
|
|
1723
|
+
const reported = await versionOf(execPath);
|
|
1724
|
+
if (reported !== expected)
|
|
1725
|
+
throw new CliError(`it reports ${JSON.stringify(reported)}, not ${expected}`);
|
|
1726
|
+
} catch (error) {
|
|
1727
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
1728
|
+
try {
|
|
1729
|
+
rmSync2(execPath, { force: true });
|
|
1730
|
+
renameSync3(old, execPath);
|
|
1731
|
+
} catch {
|
|
1732
|
+
throw new CliError(`The new binary failed its version check (${why}) and the previous one could not be put back: it is at ${old}`);
|
|
1733
|
+
}
|
|
1734
|
+
throw new CliError(`The new binary failed its version check (${why}); the previous version was put back`);
|
|
1735
|
+
}
|
|
1736
|
+
try {
|
|
1737
|
+
rmSync2(old, { force: true });
|
|
1738
|
+
} catch {}
|
|
1739
|
+
}
|
|
1740
|
+
async function spawnVersion(path) {
|
|
1741
|
+
const { text, code } = await capture([path, "version"], VERSION_TIMEOUT_MS);
|
|
1742
|
+
if (code !== 0)
|
|
1743
|
+
throw new CliError(`it exited with ${code}`);
|
|
1744
|
+
return text.trim();
|
|
1745
|
+
}
|
|
1746
|
+
async function runUpdate({ io, flags, args }, deps) {
|
|
1747
|
+
if (args.length > 0)
|
|
1748
|
+
throw usage("update");
|
|
1749
|
+
if (isPackageManaged(deps.execPath, io.env)) {
|
|
1750
|
+
const tag = deps.version.includes("-") ? "next" : "latest";
|
|
1751
|
+
const message = `Package-managed installation. Update with bun add --global @secretstore/cli@${tag} or npm install --global @secretstore/cli@${tag}; for a local dependency, omit --global.`;
|
|
1752
|
+
io.stdout(flags.json ? JSON.stringify({ updated: false, managed: true, message }) + `
|
|
1753
|
+
` : `${message}
|
|
1754
|
+
`);
|
|
1755
|
+
return 0;
|
|
1756
|
+
}
|
|
1757
|
+
if (isDevelopmentRun(deps.execPath))
|
|
1758
|
+
throw new CliError("update replaces the installed binary; this is a development run", EXIT.usage);
|
|
1759
|
+
if (!flags.check && !deps.publicKey)
|
|
1760
|
+
throw new CliError("this build has no update key; build with apps/cli/update-public-key.txt set");
|
|
1761
|
+
const origin = resolveUrl(flags.url, io.env);
|
|
1762
|
+
const say = (text) => {
|
|
1763
|
+
if (!flags.json)
|
|
1764
|
+
io.stdout(`${text}
|
|
1765
|
+
`);
|
|
1766
|
+
};
|
|
1767
|
+
const release = await fetchRelease(origin, deps.fetcher);
|
|
1768
|
+
const order = release ? compareVersions(release.version, deps.version) : 0;
|
|
1769
|
+
if (flags.check) {
|
|
1770
|
+
if (flags.json) {
|
|
1771
|
+
io.stdout(JSON.stringify({ current: deps.version, available: release?.version ?? null, updateAvailable: order > 0, notes: order > 0 ? release?.notes : undefined }) + `
|
|
1772
|
+
`);
|
|
1773
|
+
} else {
|
|
1774
|
+
io.stdout(`current: ${deps.version}
|
|
1775
|
+
available: ${release?.version ?? "none"}
|
|
1776
|
+
`);
|
|
1777
|
+
io.stdout(order > 0 ? `${release?.notes ? `
|
|
1778
|
+
${release.notes}
|
|
1779
|
+
|
|
1780
|
+
` : ""}Run "secretstore update" to install it.
|
|
1781
|
+
` : `up to date
|
|
1782
|
+
`);
|
|
1783
|
+
}
|
|
1784
|
+
return 0;
|
|
1785
|
+
}
|
|
1786
|
+
const unchanged = (text) => {
|
|
1787
|
+
io.stdout(flags.json ? JSON.stringify({ updated: false, current: deps.version, available: release?.version ?? null }) + `
|
|
1788
|
+
` : `${text}
|
|
1789
|
+
`);
|
|
1790
|
+
return 0;
|
|
1791
|
+
};
|
|
1792
|
+
if (!release)
|
|
1793
|
+
return unchanged(`No release is published on ${origin}; ${deps.version} is up to date.`);
|
|
1794
|
+
if (order < 0)
|
|
1795
|
+
throw new CliError(`${origin} offers ${release.version}, which is older than ${deps.version}; refusing to downgrade`);
|
|
1796
|
+
if (order === 0)
|
|
1797
|
+
return unchanged(`${deps.version} is up to date.`);
|
|
1798
|
+
const key = platformKey(deps.platform, deps.arch);
|
|
1799
|
+
if (!key)
|
|
1800
|
+
throw new CliError(`There is no build of secretstore for ${deps.platform} ${deps.arch}`);
|
|
1801
|
+
const asset = assetFor(release, key);
|
|
1802
|
+
say(`Update available: ${deps.version} -> ${release.version} (${key}, ${(asset.size / 1e6).toFixed(1)} MB)`);
|
|
1803
|
+
if (release.notes)
|
|
1804
|
+
say(`
|
|
1805
|
+
${release.notes}
|
|
1806
|
+
`);
|
|
1807
|
+
if (!flags.yes) {
|
|
1808
|
+
if (!deps.interactive)
|
|
1809
|
+
throw new CliError("Not a terminal, so there is nobody to ask: pass --yes to update without asking", EXIT.usage);
|
|
1810
|
+
if (!deps.confirm(`Install ${release.version}?`)) {
|
|
1811
|
+
say("Cancelled.");
|
|
1812
|
+
return EXIT.error;
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
const next = `${deps.execPath}.new`;
|
|
1816
|
+
try {
|
|
1817
|
+
const digest = await download(`${origin}/api/cli/download/${encodeURIComponent(key)}/${encodeURIComponent(release.version)}`, next, asset.size, deps.fetcher);
|
|
1818
|
+
if (toHex(digest) !== asset.sha256)
|
|
1819
|
+
throw new CliError("The download does not match the checksum the server published; nothing was installed");
|
|
1820
|
+
if (!await verifyDigest(deps.publicKey, digest, asset.signature))
|
|
1821
|
+
throw new CliError("the update is not signed by the key this build trusts");
|
|
1822
|
+
await replaceExecutable(deps.execPath, next, release.version, deps.versionOf);
|
|
1823
|
+
} finally {
|
|
1824
|
+
rmSync2(next, { force: true });
|
|
1825
|
+
}
|
|
1826
|
+
if (flags.json)
|
|
1827
|
+
io.stdout(JSON.stringify({ updated: true, from: deps.version, to: release.version }) + `
|
|
1828
|
+
`);
|
|
1829
|
+
else
|
|
1830
|
+
io.stdout(`Updated secretstore ${deps.version} -> ${release.version}.
|
|
1831
|
+
`);
|
|
1832
|
+
return 0;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// apps/cli/src/commands/update.ts
|
|
1836
|
+
var update = (ctx) => runUpdate(ctx, {
|
|
1837
|
+
version: package_default.version,
|
|
1838
|
+
execPath: installedPath(),
|
|
1839
|
+
platform: process.platform,
|
|
1840
|
+
arch: process.arch,
|
|
1841
|
+
publicKey: update_public_key_default.trim(),
|
|
1842
|
+
fetcher: fetch,
|
|
1843
|
+
versionOf: spawnVersion,
|
|
1844
|
+
interactive: Boolean(process.stdin.isTTY),
|
|
1845
|
+
confirm: confirmOnStderr
|
|
1846
|
+
});
|
|
1847
|
+
|
|
1848
|
+
// apps/cli/src/commands/write.ts
|
|
1849
|
+
var set = async ({ args, client, flags, io }) => {
|
|
1850
|
+
const { project, environment, rest } = targetFor({ io, flags }, args, { environment: true, need: 1, write: true });
|
|
1851
|
+
const [key, given] = rest;
|
|
1852
|
+
if (key === undefined || rest.length > 2)
|
|
1853
|
+
throw usage("set");
|
|
1854
|
+
let value;
|
|
1855
|
+
if (given === undefined || given === "-") {
|
|
1856
|
+
value = (await io.readStdin()).replace(/\r?\n$/, "");
|
|
1857
|
+
} else {
|
|
1858
|
+
io.stderr(`warning: a value on the command line stays in your shell history and is visible to other processes; pass - to read it from stdin
|
|
1859
|
+
`);
|
|
1860
|
+
value = given;
|
|
1861
|
+
}
|
|
1862
|
+
const result = firstJson(await client.call(TOOLS.setSecret, { project, environment, key, value }));
|
|
1863
|
+
io.stdout(flags.json ? JSON.stringify(result, null, 2) + `
|
|
1864
|
+
` : `${result.created ? "Created" : "Updated"} ${result.key} in ${project}/${environment} (version ${result.version})
|
|
1865
|
+
`);
|
|
1866
|
+
};
|
|
1867
|
+
var del = async ({ args, client, flags, io }) => {
|
|
1868
|
+
const { project, environment, rest } = targetFor({ io, flags }, args, { environment: true, need: 1, write: true });
|
|
1869
|
+
const [key] = rest;
|
|
1870
|
+
if (key === undefined || rest.length > 1)
|
|
1871
|
+
throw usage("delete");
|
|
1872
|
+
const result = firstJson(await client.call(TOOLS.deleteSecret, { project, environment, key }));
|
|
1873
|
+
io.stdout(flags.json ? JSON.stringify(result, null, 2) + `
|
|
1874
|
+
` : `Deleted ${result.key} from ${project}/${environment}
|
|
1875
|
+
`);
|
|
1876
|
+
};
|
|
1877
|
+
|
|
1878
|
+
// apps/cli/src/session.ts
|
|
1879
|
+
var REFRESH_MARGIN_MS = 60000;
|
|
1880
|
+
var signIn = (reason) => new CliError(`${reason} Run "secretstore login" to sign in.`, EXIT.auth);
|
|
1881
|
+
async function refreshLogin(store, name, stale, now, fetcher = fetch) {
|
|
1882
|
+
const stored = await store.get(name);
|
|
1883
|
+
if (!stored)
|
|
1884
|
+
throw signIn("Not signed in any more.");
|
|
1885
|
+
if (stored.accessToken !== stale.accessToken && stored.expiresAt - now() > REFRESH_MARGIN_MS)
|
|
1886
|
+
return stored;
|
|
1887
|
+
let grant;
|
|
1888
|
+
try {
|
|
1889
|
+
grant = await refreshGrant(stored, now(), fetcher);
|
|
1890
|
+
} catch (error) {
|
|
1891
|
+
if (!(error instanceof OAuthError && error.oauthCode === "invalid_grant"))
|
|
1892
|
+
throw error;
|
|
1893
|
+
const latest = await store.get(name);
|
|
1894
|
+
if (latest && latest.refreshToken !== stored.refreshToken)
|
|
1895
|
+
return latest;
|
|
1896
|
+
await store.remove(name);
|
|
1897
|
+
throw signIn("Your login expired or was revoked.");
|
|
1898
|
+
}
|
|
1899
|
+
const next = { ...stored, accessToken: grant.accessToken, refreshToken: grant.refreshToken, expiresAt: grant.expiresAt, scope: grant.scope || stored.scope };
|
|
1900
|
+
await store.set(name, next);
|
|
1901
|
+
return next;
|
|
1902
|
+
}
|
|
1903
|
+
function loginSession(store, name, login, now, fetcher) {
|
|
1904
|
+
let current = login;
|
|
1905
|
+
const endpoint = mcpEndpoint(login.issuer);
|
|
1906
|
+
const renew = async () => {
|
|
1907
|
+
current = await refreshLogin(store, name, current, now, fetcher);
|
|
1908
|
+
};
|
|
1909
|
+
return {
|
|
1910
|
+
via: "login",
|
|
1911
|
+
endpoint,
|
|
1912
|
+
async headers() {
|
|
1913
|
+
if (current.expiresAt - now() < REFRESH_MARGIN_MS)
|
|
1914
|
+
await renew();
|
|
1915
|
+
const proof = await dpopProof(current.key, { htm: "POST", htu: endpoint, accessToken: current.accessToken, nowMs: now() });
|
|
1916
|
+
return { authorization: `DPoP ${current.accessToken}`, dpop: proof };
|
|
1917
|
+
},
|
|
1918
|
+
renew
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
async function openSession(io, flags) {
|
|
1922
|
+
const origin = resolveUrl(flags.url, io.env);
|
|
1923
|
+
const given = resolveToken(flags.token, io.env);
|
|
1924
|
+
if (given)
|
|
1925
|
+
return { via: given.via, endpoint: mcpEndpoint(origin), headers: async () => ({ authorization: `Bearer ${given.token}` }) };
|
|
1926
|
+
const store = openKeystore(io.env, io.stderr);
|
|
1927
|
+
const login = await store.get(origin);
|
|
1928
|
+
if (!login)
|
|
1929
|
+
throw new CliError('Not signed in. Run "secretstore login", or export SECRET_STORE_TOKEN for a personal token.', EXIT.auth);
|
|
1930
|
+
return loginSession(store, origin, login, io.now);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// apps/cli/src/main.ts
|
|
1934
|
+
var COMMANDS = {
|
|
1935
|
+
login,
|
|
1936
|
+
logout,
|
|
1937
|
+
whoami,
|
|
1938
|
+
projects,
|
|
1939
|
+
envs,
|
|
1940
|
+
init,
|
|
1941
|
+
get,
|
|
1942
|
+
set,
|
|
1943
|
+
delete: del,
|
|
1944
|
+
push,
|
|
1945
|
+
pull,
|
|
1946
|
+
export: exportEnv,
|
|
1947
|
+
run,
|
|
1948
|
+
"request-access": requestAccess,
|
|
1949
|
+
doctor,
|
|
1950
|
+
completion,
|
|
1951
|
+
update,
|
|
1952
|
+
"keystore-selftest": keystoreSelftest
|
|
1953
|
+
};
|
|
1954
|
+
async function main(argv, io) {
|
|
1955
|
+
let json = false;
|
|
1956
|
+
try {
|
|
1957
|
+
const { command, args, rest, flags } = parseArgs(argv);
|
|
1958
|
+
json = flags.json;
|
|
1959
|
+
if (command === "version") {
|
|
1960
|
+
io.stdout(json ? JSON.stringify({ version: package_default.version }) + `
|
|
1961
|
+
` : package_default.version + `
|
|
1962
|
+
`);
|
|
1963
|
+
return 0;
|
|
1964
|
+
}
|
|
1965
|
+
if (command === "help" || !command && flags.help) {
|
|
1966
|
+
io.stdout(HELP);
|
|
1967
|
+
return 0;
|
|
1968
|
+
}
|
|
1969
|
+
if (!command) {
|
|
1970
|
+
io.stderr(HELP);
|
|
1971
|
+
return EXIT.usage;
|
|
1972
|
+
}
|
|
1973
|
+
if (!Object.hasOwn(COMMANDS, command))
|
|
1974
|
+
throw new CliError(`Unknown command "${command}". Run "secretstore help".`, EXIT.usage);
|
|
1975
|
+
if (flags.help) {
|
|
1976
|
+
io.stdout(`usage: ${USAGE[command] ?? `secretstore ${command}`}
|
|
1977
|
+
`);
|
|
1978
|
+
return 0;
|
|
1979
|
+
}
|
|
1980
|
+
let client;
|
|
1981
|
+
const ctx = {
|
|
1982
|
+
io,
|
|
1983
|
+
flags,
|
|
1984
|
+
args,
|
|
1985
|
+
rest,
|
|
1986
|
+
get client() {
|
|
1987
|
+
client ??= createClient(() => openSession(io, flags));
|
|
1988
|
+
return client;
|
|
1989
|
+
}
|
|
1990
|
+
};
|
|
1991
|
+
return await COMMANDS[command]?.(ctx) ?? 0;
|
|
1992
|
+
} catch (error) {
|
|
1993
|
+
const failure = error instanceof CliError ? error : new CliError(error instanceof Error ? error.message : String(error));
|
|
1994
|
+
io.stderr(json ? JSON.stringify({ error: failure.message }) + `
|
|
1995
|
+
` : `${red("error:", io.color)} ${failure.message}
|
|
1996
|
+
`);
|
|
1997
|
+
return failure.code;
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// apps/cli/src/index.ts
|
|
2002
|
+
removeStale(process.execPath);
|
|
2003
|
+
process.exitCode = await main(process.argv.slice(2), {
|
|
2004
|
+
env: process.env,
|
|
2005
|
+
cwd: process.cwd(),
|
|
2006
|
+
stdout: (text) => void process.stdout.write(text),
|
|
2007
|
+
stderr: (text) => void process.stderr.write(text),
|
|
2008
|
+
readStdin: readAllStdin,
|
|
2009
|
+
color: colorEnabled(process.env, process.stderr.isTTY),
|
|
2010
|
+
openUrl: openInBrowser,
|
|
2011
|
+
now: Date.now,
|
|
2012
|
+
sleep,
|
|
2013
|
+
isTty: process.stdout.isTTY === true,
|
|
2014
|
+
confirm: process.stdin.isTTY ? confirmOnStderr : undefined
|
|
2015
|
+
});
|