@coffre/cli 0.0.0 → 0.1.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 +21 -0
- package/README.md +13 -3
- package/dist/main.js +1647 -0
- package/dist/templates/node/README.md +93 -0
- package/dist/templates/node/gitignore +5 -0
- package/dist/templates/node/package.json +24 -0
- package/dist/templates/node/pnpm-workspace.yaml +7 -0
- package/dist/templates/node/server.env.example +18 -0
- package/dist/templates/node/src/server.ts +37 -0
- package/dist/templates/node/src/vault.ts +27 -0
- package/dist/templates/node/tsconfig.json +15 -0
- package/dist/templates/node/vault.env.example +16 -0
- package/dist/templates/workers/README.md +124 -0
- package/dist/templates/workers/app/src/worker.ts +33 -0
- package/dist/templates/workers/app/wrangler.jsonc +34 -0
- package/dist/templates/workers/gitignore +3 -0
- package/dist/templates/workers/package.json +27 -0
- package/dist/templates/workers/pnpm-workspace.yaml +14 -0
- package/dist/templates/workers/tsconfig.json +14 -0
- package/dist/templates/workers/vault/src/worker.ts +21 -0
- package/dist/templates/workers/vault/wrangler.jsonc +29 -0
- package/package.json +33 -5
package/dist/main.js
ADDED
|
@@ -0,0 +1,1647 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs, promisify } from "node:util";
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
4
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir, hostname } from "node:os";
|
|
6
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
//#region src/init.ts
|
|
9
|
+
const KINDS = ["workers", "node"];
|
|
10
|
+
/** Where a template lives: in the package when published, else in the repository. */
|
|
11
|
+
function templateDir(kind) {
|
|
12
|
+
const packed = fileURLToPath(new URL(`./templates/${kind}/`, import.meta.url));
|
|
13
|
+
if (existsSync(packed)) return packed;
|
|
14
|
+
return fileURLToPath(new URL(`../../../examples/${kind}/`, import.meta.url));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A template's files, relative and sorted: everything but what its own
|
|
18
|
+
* `.gitignore` names, such as installed packages, local state and secrets.
|
|
19
|
+
* Patterns are file or directory names, `*` standing for any run of
|
|
20
|
+
* characters.
|
|
21
|
+
*/
|
|
22
|
+
function templateFiles(dir) {
|
|
23
|
+
const ignoreFile = [join(dir, ".gitignore"), join(dir, "gitignore")].find((path) => existsSync(path));
|
|
24
|
+
const ignored = (ignoreFile === void 0 ? "" : readFileSync(ignoreFile, "utf8")).split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#")).map((glob) => new RegExp(`^${glob.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`));
|
|
25
|
+
const files = [];
|
|
26
|
+
const walk = (relative) => {
|
|
27
|
+
for (const entry of readdirSync(join(dir, relative), { withFileTypes: true })) {
|
|
28
|
+
if (ignored.some((pattern) => pattern.test(entry.name))) continue;
|
|
29
|
+
const path = relative === "" ? entry.name : `${relative}/${entry.name}`;
|
|
30
|
+
if (entry.isDirectory()) walk(path);
|
|
31
|
+
else files.push(path);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
walk("");
|
|
35
|
+
return files.sort();
|
|
36
|
+
}
|
|
37
|
+
/** The name a template file has in a project. */
|
|
38
|
+
function projectPath(templatePath) {
|
|
39
|
+
return templatePath === "gitignore" ? ".gitignore" : templatePath;
|
|
40
|
+
}
|
|
41
|
+
/** A package name from a directory's, e.g. `Acme Secrets` → `acme-secrets`. */
|
|
42
|
+
function packageName(dir) {
|
|
43
|
+
const name = basename(dir).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[-._]+|-+$/g, "");
|
|
44
|
+
return name === "" ? "coffre" : name;
|
|
45
|
+
}
|
|
46
|
+
/** The template's package.json, for this project and this version of coffre. */
|
|
47
|
+
function manifest(source, name, version) {
|
|
48
|
+
const pkg = JSON.parse(source);
|
|
49
|
+
pkg.name = name;
|
|
50
|
+
for (const field of ["dependencies", "devDependencies"]) {
|
|
51
|
+
const deps = pkg[field];
|
|
52
|
+
for (const dep of Object.keys(deps ?? {})) if (dep.startsWith("@coffre/")) deps[dep] = version;
|
|
53
|
+
}
|
|
54
|
+
return `${JSON.stringify(pkg, null, 2)}\n`;
|
|
55
|
+
}
|
|
56
|
+
/** Write a new deployment into `target`, which must be empty or absent. Returns the files written. */
|
|
57
|
+
function init(kind, target, version) {
|
|
58
|
+
if (existsSync(target) && readdirSync(target).length > 0) throw new Error(`${target} is not empty; give \`coffre init\` a new directory`);
|
|
59
|
+
const source = templateDir(kind);
|
|
60
|
+
const written = [];
|
|
61
|
+
for (const file of templateFiles(source)) {
|
|
62
|
+
const path = projectPath(file);
|
|
63
|
+
const content = readFileSync(join(source, file), "utf8");
|
|
64
|
+
mkdirSync(dirname(join(target, path)), { recursive: true });
|
|
65
|
+
writeFileSync(join(target, path), path === "package.json" ? manifest(content, packageName(target), version) : content);
|
|
66
|
+
written.push(path);
|
|
67
|
+
}
|
|
68
|
+
return written;
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/instance.ts
|
|
72
|
+
const MODES = ["signin", "cloudflare"];
|
|
73
|
+
function emptyStore() {
|
|
74
|
+
return {
|
|
75
|
+
version: 2,
|
|
76
|
+
current: null,
|
|
77
|
+
instances: {}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Read the credentials file. Anything unrecognised, including the single-token
|
|
82
|
+
* file older CLIs wrote, reads as empty: the worst case is one extra login,
|
|
83
|
+
* never a token sent to the wrong place.
|
|
84
|
+
*/
|
|
85
|
+
function parseStore(text) {
|
|
86
|
+
let raw;
|
|
87
|
+
try {
|
|
88
|
+
raw = JSON.parse(text);
|
|
89
|
+
} catch {
|
|
90
|
+
return emptyStore();
|
|
91
|
+
}
|
|
92
|
+
if (typeof raw !== "object" || raw === null) return emptyStore();
|
|
93
|
+
const candidate = raw;
|
|
94
|
+
if (candidate.version !== 2 || typeof candidate.instances !== "object" || candidate.instances === null) return emptyStore();
|
|
95
|
+
const instances = {};
|
|
96
|
+
for (const [origin, session] of Object.entries(candidate.instances)) if (typeof session === "object" && session !== null && MODES.includes(session.mode)) instances[origin] = session;
|
|
97
|
+
return {
|
|
98
|
+
version: 2,
|
|
99
|
+
current: typeof candidate.current === "string" && candidate.current in instances ? candidate.current : null,
|
|
100
|
+
instances
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function parseMode(raw) {
|
|
104
|
+
const value = raw?.trim();
|
|
105
|
+
if (value === void 0 || value === "") return void 0;
|
|
106
|
+
if (!MODES.includes(value)) throw new Error(`COFFRE_AUTH_MODE must be one of ${MODES.join(", ")}, not "${value}"`);
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
function isLoopback(hostname) {
|
|
110
|
+
return hostname === "localhost" || hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(hostname);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Normalise what someone typed into an origin the CLI will send credentials
|
|
114
|
+
* to. Plain HTTP is refused except on this machine, where there is no wire to
|
|
115
|
+
* listen on. A bare host name means HTTPS, or HTTP for this machine:
|
|
116
|
+
* `coffre.example.com` and `127.0.0.1:3000` both do what they look like.
|
|
117
|
+
*/
|
|
118
|
+
function instanceOrigin(raw) {
|
|
119
|
+
const value = raw.trim();
|
|
120
|
+
const invalid = () => /* @__PURE__ */ new Error(`"${raw}" is not a coffre address; expected something like https://coffre.example.com`);
|
|
121
|
+
let url;
|
|
122
|
+
try {
|
|
123
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) url = new URL(value);
|
|
124
|
+
else {
|
|
125
|
+
url = new URL(`https://${value}`);
|
|
126
|
+
if (isLoopback(url.hostname)) url.protocol = "http:";
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
throw invalid();
|
|
130
|
+
}
|
|
131
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw invalid();
|
|
132
|
+
if (url.username !== "" || url.password !== "") throw new Error("the coffre address must not contain a user name or password");
|
|
133
|
+
if (url.pathname !== "/" && url.pathname !== "" || url.search !== "" || url.hash !== "") throw new Error(`the coffre address must be an origin, like ${url.origin}, without a path`);
|
|
134
|
+
if (url.protocol === "http:" && !isLoopback(url.hostname)) throw new Error(`refusing to send credentials over plain HTTP; use https://${url.host}`);
|
|
135
|
+
return url.origin;
|
|
136
|
+
}
|
|
137
|
+
function isJsonContentType(value) {
|
|
138
|
+
if (value === null) return false;
|
|
139
|
+
const mediaType = value.split(";", 1)[0].trim().toLowerCase();
|
|
140
|
+
return mediaType === "application/json" || mediaType.endsWith("+json");
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* How to sign in somewhere, from its answer to `GET /api/auth` without a
|
|
144
|
+
* credential. coffre says how it signs people in. Cloudflare Access, in front
|
|
145
|
+
* of it, turns the CLI away first, with a redirect to its own login page:
|
|
146
|
+
*
|
|
147
|
+
* 200 { signin: { providers: […] }, access: null } → a device login
|
|
148
|
+
* 200 { signin: null, access: { … } } → cloudflared
|
|
149
|
+
* 302 Location: https://acme.cloudflareaccess.com/… → cloudflared
|
|
150
|
+
*/
|
|
151
|
+
function loginMode(origin, status, body) {
|
|
152
|
+
if (status >= 300 && status < 400) return "cloudflare";
|
|
153
|
+
const auth = status === 200 && typeof body === "object" && body !== null ? body : null;
|
|
154
|
+
if (auth?.signin) return "signin";
|
|
155
|
+
if (auth?.access) return "cloudflare";
|
|
156
|
+
if (status === 401 || status === 403) throw new Error(`${origin} turned the CLI away (status ${status}). If it is behind Cloudflare Access,\n run \`COFFRE_AUTH_MODE=cloudflare coffre login ${origin}\``);
|
|
157
|
+
throw new Error(`${origin} does not look like coffre: GET /api/auth answered ${status}`);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Decide where the next request goes and what it carries, or explain why it
|
|
161
|
+
* cannot be sent. Pure, so every precedence rule is testable.
|
|
162
|
+
*/
|
|
163
|
+
function resolveTarget(env, store, now = /* @__PURE__ */ new Date()) {
|
|
164
|
+
const explicitMode = parseMode(env.COFFRE_AUTH_MODE);
|
|
165
|
+
const requested = env.COFFRE_API_URL?.trim() || store.current;
|
|
166
|
+
if (!requested) throw new Error("not signed in anywhere yet: run `coffre login <url>`");
|
|
167
|
+
const session = lookupSession(store, requested);
|
|
168
|
+
const accessClientId = env.COFFRE_ACCESS_CLIENT_ID?.trim();
|
|
169
|
+
const mode = explicitMode ?? session?.mode ?? (accessClientId ? "cloudflare" : "signin");
|
|
170
|
+
const origin = instanceOrigin(requested);
|
|
171
|
+
const token = env.COFFRE_TOKEN?.trim();
|
|
172
|
+
if (token) return {
|
|
173
|
+
origin,
|
|
174
|
+
mode,
|
|
175
|
+
credential: {
|
|
176
|
+
kind: "token",
|
|
177
|
+
token
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
if (mode === "cloudflare" && accessClientId) {
|
|
181
|
+
const clientSecret = env.COFFRE_ACCESS_CLIENT_SECRET?.trim();
|
|
182
|
+
if (!clientSecret) throw new Error("COFFRE_ACCESS_CLIENT_ID is set but COFFRE_ACCESS_CLIENT_SECRET is not");
|
|
183
|
+
return {
|
|
184
|
+
origin,
|
|
185
|
+
mode,
|
|
186
|
+
credential: {
|
|
187
|
+
kind: "access-service-token",
|
|
188
|
+
clientId: accessClientId,
|
|
189
|
+
clientSecret
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const stored = store.instances[origin];
|
|
194
|
+
if (stored === void 0 || stored.mode !== mode) throw new Error(`not signed in to ${origin}: run \`coffre login ${origin}\``);
|
|
195
|
+
if (mode === "cloudflare") return {
|
|
196
|
+
origin,
|
|
197
|
+
mode,
|
|
198
|
+
credential: { kind: "cloudflared" }
|
|
199
|
+
};
|
|
200
|
+
if (!stored.token) throw new Error(`not signed in to ${origin}: run \`coffre login ${origin}\``);
|
|
201
|
+
if (stored.expiresAt && Date.parse(stored.expiresAt) <= now.getTime()) throw new Error(`your session on ${origin} ended on ${stored.expiresAt.slice(0, 10)}: run \`coffre login ${origin}\``);
|
|
202
|
+
return {
|
|
203
|
+
origin,
|
|
204
|
+
mode,
|
|
205
|
+
credential: {
|
|
206
|
+
kind: "token",
|
|
207
|
+
token: stored.token
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/** The stored session for an address, however it was typed. */
|
|
212
|
+
function lookupSession(store, requested) {
|
|
213
|
+
try {
|
|
214
|
+
return store.instances[instanceOrigin(requested)];
|
|
215
|
+
} catch {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The headers that carry a credential, per mode. coffre's own sign-in takes a
|
|
221
|
+
* standard bearer token. Cloudflare Access takes its user token, or a service
|
|
222
|
+
* token's id and secret, at the edge, and hands coffre its own assertion.
|
|
223
|
+
*/
|
|
224
|
+
function credentialHeaders(mode, credential, cloudflaredToken) {
|
|
225
|
+
switch (credential.kind) {
|
|
226
|
+
case "access-service-token": return {
|
|
227
|
+
"cf-access-client-id": credential.clientId,
|
|
228
|
+
"cf-access-client-secret": credential.clientSecret
|
|
229
|
+
};
|
|
230
|
+
case "cloudflared":
|
|
231
|
+
if (!cloudflaredToken) throw new Error("cloudflared did not return a token");
|
|
232
|
+
return { "cf-access-token": cloudflaredToken };
|
|
233
|
+
case "token": return mode === "cloudflare" ? { "cf-access-token": credential.token } : { authorization: `Bearer ${credential.token}` };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function withSession(store, origin, session) {
|
|
237
|
+
return {
|
|
238
|
+
version: 2,
|
|
239
|
+
current: origin,
|
|
240
|
+
instances: {
|
|
241
|
+
...store.instances,
|
|
242
|
+
[origin]: session
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
function withoutSession(store, origin) {
|
|
247
|
+
const { [origin]: _removed, ...instances } = store.instances;
|
|
248
|
+
return {
|
|
249
|
+
version: 2,
|
|
250
|
+
current: store.current === origin ? Object.keys(instances)[0] ?? null : store.current,
|
|
251
|
+
instances
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
//#endregion
|
|
255
|
+
//#region ../client/dist/index.js
|
|
256
|
+
/**
|
|
257
|
+
* The config from `name=value` arguments, as the CLI takes them. A field with
|
|
258
|
+
* several options takes them comma-separated, and one left out gets what the
|
|
259
|
+
* form preselects. Text goes as typed, for the server to check.
|
|
260
|
+
*/
|
|
261
|
+
function configFromArguments(provider, assignments) {
|
|
262
|
+
const config = {};
|
|
263
|
+
for (const assignment of assignments) {
|
|
264
|
+
const equals = assignment.indexOf("=");
|
|
265
|
+
const name = equals === -1 ? assignment : assignment.slice(0, equals);
|
|
266
|
+
const field = provider.fields.find((candidate) => candidate.name === name);
|
|
267
|
+
if (equals === -1 || field === void 0) {
|
|
268
|
+
const names = provider.fields.map((candidate) => candidate.name).join(", ");
|
|
269
|
+
throw new Error(`${provider.label} takes ${names} as name=value; got ${JSON.stringify(assignment)}`);
|
|
270
|
+
}
|
|
271
|
+
if (Object.hasOwn(config, name)) throw new Error(`${name} is given twice`);
|
|
272
|
+
const value = assignment.slice(equals + 1);
|
|
273
|
+
if (field.type === "text") config[name] = value;
|
|
274
|
+
else if (field.multiple) config[name] = value.split(",").map((item) => item.trim()).filter((item) => item !== "");
|
|
275
|
+
else config[name] = value;
|
|
276
|
+
}
|
|
277
|
+
for (const field of provider.fields) if (field.type === "options" && !Object.hasOwn(config, field.name)) config[field.name] = field.multiple ? field.initial : field.initial[0];
|
|
278
|
+
return config;
|
|
279
|
+
}
|
|
280
|
+
/** The API's error shape, `{ error, message }`, with the HTTP status. */
|
|
281
|
+
const COFFRE_ERROR = Symbol.for("@coffre/client:CoffreError");
|
|
282
|
+
var CoffreError = class extends Error {
|
|
283
|
+
/**
|
|
284
|
+
* `@coffre/ui`'s prebuilt pages bundle a copy of this class, and a page is
|
|
285
|
+
* handed a client the server made with this package's. So `instanceof`
|
|
286
|
+
* asks for the mark every copy leaves, rather than for this copy's prototype.
|
|
287
|
+
*/
|
|
288
|
+
static [Symbol.hasInstance](value) {
|
|
289
|
+
return typeof value === "object" && value !== null && COFFRE_ERROR in value;
|
|
290
|
+
}
|
|
291
|
+
[COFFRE_ERROR] = true;
|
|
292
|
+
status;
|
|
293
|
+
code;
|
|
294
|
+
/** The vault's own code when it refused: `no_grant`, `removed`, `bulk_limit`, ... */
|
|
295
|
+
reason;
|
|
296
|
+
constructor(status, code, message, reason) {
|
|
297
|
+
super(message);
|
|
298
|
+
this.name = "CoffreError";
|
|
299
|
+
this.status = status;
|
|
300
|
+
this.code = code;
|
|
301
|
+
this.reason = reason;
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
/** `market/prod/KEY` as route parameters. */
|
|
305
|
+
function place(path) {
|
|
306
|
+
const [project = "", environment = "", key = ""] = path.replace(/^\/+|\/+$/g, "").split("/");
|
|
307
|
+
return {
|
|
308
|
+
project,
|
|
309
|
+
environment,
|
|
310
|
+
key
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function createClient(options) {
|
|
314
|
+
const origin = options.url.replace(/\/+$/, "");
|
|
315
|
+
const transport = options.transport ?? ((request) => fetch(request));
|
|
316
|
+
async function send(method, path, input) {
|
|
317
|
+
const url = new URL(`${origin}/api${path}`);
|
|
318
|
+
const headers = new Headers(await options.headers?.());
|
|
319
|
+
let body;
|
|
320
|
+
if (method === "GET") {
|
|
321
|
+
for (const [name, value] of Object.entries(input ?? {})) if (value !== void 0 && value !== null) url.searchParams.set(name, String(value));
|
|
322
|
+
} else if (input !== void 0) {
|
|
323
|
+
headers.set("content-type", "application/json");
|
|
324
|
+
body = JSON.stringify(input);
|
|
325
|
+
}
|
|
326
|
+
const response = await transport(new Request(url, {
|
|
327
|
+
method,
|
|
328
|
+
headers,
|
|
329
|
+
body
|
|
330
|
+
}));
|
|
331
|
+
const payload = await response.json().catch(() => null);
|
|
332
|
+
if (!response.ok) {
|
|
333
|
+
const error = payload;
|
|
334
|
+
throw new CoffreError(response.status, typeof error?.error === "string" ? error.error : "http_error", typeof error?.message === "string" ? error.message : `request failed with status ${response.status}`, typeof error?.reason === "string" ? error.reason : void 0);
|
|
335
|
+
}
|
|
336
|
+
return payload;
|
|
337
|
+
}
|
|
338
|
+
/** A route key and its parameters as a method and a path. */
|
|
339
|
+
function address(key, params) {
|
|
340
|
+
const [method, pattern] = key.split(" ");
|
|
341
|
+
return [method, pattern.replace(/:(\w+)/g, (_, name) => encodeURIComponent(params[name]))];
|
|
342
|
+
}
|
|
343
|
+
/** Any route by its key: `call('GET /secrets/:project/:environment', { project, environment })`. */
|
|
344
|
+
async function call(key, ...[params, input]) {
|
|
345
|
+
const [method, path] = address(key, params);
|
|
346
|
+
return await send(method, path, input);
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
call,
|
|
350
|
+
/** How this instance signs people in. Answers anyone, signed in or not. */
|
|
351
|
+
auth: () => send("GET", "/auth", void 0),
|
|
352
|
+
/** Who I am, and every place I can reach. */
|
|
353
|
+
me: () => call("GET /me", {}),
|
|
354
|
+
projects: {
|
|
355
|
+
/** With their environments. */
|
|
356
|
+
list: () => call("GET /projects", {}),
|
|
357
|
+
create: (project, input) => call("PUT /projects/:project", { project }, input),
|
|
358
|
+
update: (project, patch) => call("PATCH /projects/:project", { project }, patch)
|
|
359
|
+
},
|
|
360
|
+
environments: {
|
|
361
|
+
create: (path, input) => call("PUT /projects/:project/:environment", place(path), input),
|
|
362
|
+
update: (path, patch) => call("PATCH /projects/:project/:environment", place(path), patch)
|
|
363
|
+
},
|
|
364
|
+
secrets: {
|
|
365
|
+
/** Keys, versions and who changed what; never values. */
|
|
366
|
+
list: (path) => call("GET /secrets/:project/:environment", place(path)),
|
|
367
|
+
/** Decrypts one secret, or every secret in an environment. Logged in your name. */
|
|
368
|
+
reveal: (path) => call("POST /reveals", {}, { path }),
|
|
369
|
+
/** One transaction, a version and an audit entry per key; `null` archives. */
|
|
370
|
+
set: (path, values) => call("PATCH /secrets/:project/:environment", place(path), values),
|
|
371
|
+
/**
|
|
372
|
+
* What `set` would do to each key, `added`, `changed`, `unchanged` or
|
|
373
|
+
* `archived`, and nothing else: no value comes back and nothing is
|
|
374
|
+
* written. Comparing opens the current values, logged as reads.
|
|
375
|
+
*/
|
|
376
|
+
dryRun: (path, values) => {
|
|
377
|
+
const [method, route] = address("PATCH /secrets/:project/:environment", place(path));
|
|
378
|
+
return send(method, `${route}?dryRun=1`, values);
|
|
379
|
+
},
|
|
380
|
+
history: (path) => call("GET /secrets/:project/:environment/:key/versions", place(path)),
|
|
381
|
+
/** A new version holding the old one's value. */
|
|
382
|
+
restore: (path, version) => call("POST /secrets/:project/:environment/:key/restore", place(path), { version }),
|
|
383
|
+
rename: (path, key) => call("PATCH /secrets/:project/:environment/:key", place(path), { key }),
|
|
384
|
+
update: (path, patch) => call("PATCH /secrets/:project/:environment/:key", place(path), patch)
|
|
385
|
+
},
|
|
386
|
+
members: {
|
|
387
|
+
/** People and tokens, their role and their access; at a place, those who reach it. */
|
|
388
|
+
list: (path) => call("GET /members", {}, { path }),
|
|
389
|
+
/** What they hold, and what to rotate if they leave. */
|
|
390
|
+
get: (member) => call("GET /members/:member", { member }),
|
|
391
|
+
add: (member, input = {}) => call("PUT /members/:member", { member }, input),
|
|
392
|
+
/** Offboards; returns what to rotate. */
|
|
393
|
+
remove: (member) => call("DELETE /members/:member", { member })
|
|
394
|
+
},
|
|
395
|
+
tokens: {
|
|
396
|
+
list: (member) => call("GET /members/:member/tokens", { member }),
|
|
397
|
+
/** The value is in the answer, and only there. */
|
|
398
|
+
issue: (member, input) => call("POST /members/:member/tokens", { member }, input),
|
|
399
|
+
revoke: (member, id) => call("DELETE /members/:member/tokens/:id", {
|
|
400
|
+
member,
|
|
401
|
+
id
|
|
402
|
+
})
|
|
403
|
+
},
|
|
404
|
+
access: {
|
|
405
|
+
/** What they should hold at each place; the server applies the difference. `null` revokes. */
|
|
406
|
+
set: (member, access) => call("PATCH /access/:member", { member }, access) },
|
|
407
|
+
/** Where I am signed in. Only where coffre runs its own sign-in. */
|
|
408
|
+
sessions: {
|
|
409
|
+
list: () => call("GET /sessions", {}),
|
|
410
|
+
revoke: (id) => call("DELETE /sessions/:id", { id })
|
|
411
|
+
},
|
|
412
|
+
/** The accounts I sign in with. */
|
|
413
|
+
identities: {
|
|
414
|
+
list: () => call("GET /identities", {}),
|
|
415
|
+
unlink: (id) => call("DELETE /identities/:id", { id })
|
|
416
|
+
},
|
|
417
|
+
/** A `coffre login` waiting for someone to approve it, by the code it shows. */
|
|
418
|
+
deviceLogins: {
|
|
419
|
+
get: (code) => call("GET /device-logins/:code", { code }),
|
|
420
|
+
decide: (code, approve) => call("POST /device-logins/:code", { code }, { approve })
|
|
421
|
+
},
|
|
422
|
+
syncs: {
|
|
423
|
+
/** Where a sync can push on this instance, and what each asks for. */
|
|
424
|
+
providers: () => call("GET /syncs/providers", {}),
|
|
425
|
+
list: (path) => call("GET /syncs/:project/:environment", place(path)),
|
|
426
|
+
add: (path, input) => call("POST /syncs/:project/:environment", place(path), input),
|
|
427
|
+
update: (id, patch) => call("PATCH /syncs/by-id/:id", { id }, patch),
|
|
428
|
+
remove: (id) => call("DELETE /syncs/by-id/:id", { id }),
|
|
429
|
+
run: (id) => call("POST /syncs/by-id/:id/runs", { id })
|
|
430
|
+
},
|
|
431
|
+
audit: {
|
|
432
|
+
list: (query = {}) => call("GET /audit", {}, query),
|
|
433
|
+
verify: () => call("GET /audit/verification", {})
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* What writing these entries (a parsed `.env` file) would do, from a dry run
|
|
439
|
+
* of the write: the server compares, so no value leaves it, and the values it
|
|
440
|
+
* opens to compare are logged as reads. `changes` is what to pass to
|
|
441
|
+
* `secrets.set`: the keys that differ, so an unchanged value does not become
|
|
442
|
+
* a new version.
|
|
443
|
+
*/
|
|
444
|
+
async function planImport(coffre, path, entries) {
|
|
445
|
+
const values = Object.create(null);
|
|
446
|
+
for (const { key, value } of entries) values[key] = value;
|
|
447
|
+
const [listed, compared] = await Promise.allSettled([coffre.secrets.list(path), coffre.secrets.dryRun(path, { ...values })]);
|
|
448
|
+
if (listed.status === "rejected") throw listed.reason;
|
|
449
|
+
if (compared.status === "rejected") throw compared.reason;
|
|
450
|
+
const [{ keys }, dryRun] = [listed.value, compared.value];
|
|
451
|
+
const versions = new Map(keys.map((entry) => [entry.key, entry.version]));
|
|
452
|
+
const plan = [];
|
|
453
|
+
const changes = Object.create(null);
|
|
454
|
+
for (const [key, value] of Object.entries(values)) {
|
|
455
|
+
const action = dryRun.keys[key];
|
|
456
|
+
plan.push({
|
|
457
|
+
key,
|
|
458
|
+
action,
|
|
459
|
+
version: versions.get(key) ?? null
|
|
460
|
+
});
|
|
461
|
+
if (action !== "unchanged") changes[key] = value;
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
plan,
|
|
465
|
+
changes: { ...changes }
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
//#endregion
|
|
469
|
+
//#region ../core/dist/access.js
|
|
470
|
+
/**
|
|
471
|
+
* Who may do what: seven permissions and the six roles that bundle them.
|
|
472
|
+
*
|
|
473
|
+
* Deliberately constants and not tables or a policy language. A grant names
|
|
474
|
+
* one role for one member on one place, and a check is a lookup in this file,
|
|
475
|
+
* so authorisation stays something you can read end to end.
|
|
476
|
+
*/
|
|
477
|
+
const PERMISSIONS = [
|
|
478
|
+
"secret.read",
|
|
479
|
+
"secret.write",
|
|
480
|
+
"secret.archive",
|
|
481
|
+
"audit.read",
|
|
482
|
+
"environment.manage",
|
|
483
|
+
"grant.manage",
|
|
484
|
+
"project.manage"
|
|
485
|
+
];
|
|
486
|
+
/**
|
|
487
|
+
* Permissions that only make sense on a project. Granting
|
|
488
|
+
* `environment.manage` on one environment would be incoherent: what it
|
|
489
|
+
* authorises is creating that environment's siblings.
|
|
490
|
+
*/
|
|
491
|
+
const PROJECT_ONLY_PERMISSIONS = [
|
|
492
|
+
"environment.manage",
|
|
493
|
+
"grant.manage",
|
|
494
|
+
"project.manage"
|
|
495
|
+
];
|
|
496
|
+
const ROLES = {
|
|
497
|
+
viewer: {
|
|
498
|
+
name: "Viewer",
|
|
499
|
+
description: "Read secret values.",
|
|
500
|
+
permissions: ["secret.read"]
|
|
501
|
+
},
|
|
502
|
+
developer: {
|
|
503
|
+
name: "Developer",
|
|
504
|
+
description: "Read and write secrets.",
|
|
505
|
+
permissions: ["secret.read", "secret.write"]
|
|
506
|
+
},
|
|
507
|
+
maintainer: {
|
|
508
|
+
name: "Maintainer",
|
|
509
|
+
description: "Read, write and retire secrets, and manage environments.",
|
|
510
|
+
permissions: [
|
|
511
|
+
"secret.read",
|
|
512
|
+
"secret.write",
|
|
513
|
+
"secret.archive",
|
|
514
|
+
"environment.manage"
|
|
515
|
+
]
|
|
516
|
+
},
|
|
517
|
+
"access-manager": {
|
|
518
|
+
name: "Access manager",
|
|
519
|
+
description: "Manage who has access. Cannot read secret values.",
|
|
520
|
+
permissions: ["grant.manage"]
|
|
521
|
+
},
|
|
522
|
+
auditor: {
|
|
523
|
+
name: "Auditor",
|
|
524
|
+
description: "Read the audit log. Cannot read secret values.",
|
|
525
|
+
permissions: ["audit.read"]
|
|
526
|
+
},
|
|
527
|
+
owner: {
|
|
528
|
+
name: "Owner",
|
|
529
|
+
description: "Everything, including reading secret values.",
|
|
530
|
+
permissions: [...PERMISSIONS]
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
Object.keys(ROLES);
|
|
534
|
+
function isRole(value) {
|
|
535
|
+
return Object.hasOwn(ROLES, value);
|
|
536
|
+
}
|
|
537
|
+
/** Roles that hold a project-only permission can only be granted on a project. */
|
|
538
|
+
function assignableToEnvironment(role) {
|
|
539
|
+
return !ROLES[role].permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission));
|
|
540
|
+
}
|
|
541
|
+
//#endregion
|
|
542
|
+
//#region ../core/dist/dotenv.js
|
|
543
|
+
const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
544
|
+
function parseDotenv(input) {
|
|
545
|
+
const entries = [];
|
|
546
|
+
const problems = [];
|
|
547
|
+
const seen = /* @__PURE__ */ new Set();
|
|
548
|
+
const lines = input.split(/\r\n|\n|\r/);
|
|
549
|
+
for (let index = 0; index < lines.length; index++) {
|
|
550
|
+
const lineNumber = index + 1;
|
|
551
|
+
const trimmed = lines[index].trim();
|
|
552
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
553
|
+
const withoutExport = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed;
|
|
554
|
+
const equals = withoutExport.indexOf("=");
|
|
555
|
+
if (equals === -1) {
|
|
556
|
+
problems.push({
|
|
557
|
+
line: lineNumber,
|
|
558
|
+
text: trimmed,
|
|
559
|
+
reason: "no \"=\" on this line"
|
|
560
|
+
});
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
564
|
+
if (!KEY_RE.test(key)) {
|
|
565
|
+
problems.push({
|
|
566
|
+
line: lineNumber,
|
|
567
|
+
text: key,
|
|
568
|
+
reason: "key must match ^[A-Za-z_][A-Za-z0-9_]*$"
|
|
569
|
+
});
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
let rest = withoutExport.slice(equals + 1).trim();
|
|
573
|
+
let value;
|
|
574
|
+
if (rest.startsWith("\"")) {
|
|
575
|
+
const closing = findClosingQuote(rest, "\"");
|
|
576
|
+
if (closing === -1) {
|
|
577
|
+
problems.push({
|
|
578
|
+
line: lineNumber,
|
|
579
|
+
text: key,
|
|
580
|
+
reason: "unterminated double quote (multi-line values are not supported)"
|
|
581
|
+
});
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
value = unescapeDoubleQuoted(rest.slice(1, closing));
|
|
585
|
+
if (trailingIsSignificant(rest.slice(closing + 1))) {
|
|
586
|
+
problems.push({
|
|
587
|
+
line: lineNumber,
|
|
588
|
+
text: key,
|
|
589
|
+
reason: "unexpected text after the closing quote"
|
|
590
|
+
});
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
} else if (rest.startsWith("'")) {
|
|
594
|
+
const closing = rest.indexOf("'", 1);
|
|
595
|
+
if (closing === -1) {
|
|
596
|
+
problems.push({
|
|
597
|
+
line: lineNumber,
|
|
598
|
+
text: key,
|
|
599
|
+
reason: "unterminated single quote (multi-line values are not supported)"
|
|
600
|
+
});
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
value = rest.slice(1, closing);
|
|
604
|
+
if (trailingIsSignificant(rest.slice(closing + 1))) {
|
|
605
|
+
problems.push({
|
|
606
|
+
line: lineNumber,
|
|
607
|
+
text: key,
|
|
608
|
+
reason: "unexpected text after the closing quote"
|
|
609
|
+
});
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
} else {
|
|
613
|
+
const comment = rest.indexOf(" #");
|
|
614
|
+
if (comment !== -1) rest = rest.slice(0, comment);
|
|
615
|
+
value = rest.trim();
|
|
616
|
+
}
|
|
617
|
+
if (value.includes("\0")) {
|
|
618
|
+
problems.push({
|
|
619
|
+
line: lineNumber,
|
|
620
|
+
text: key,
|
|
621
|
+
reason: "value contains a NUL byte, which cannot be passed in an environment"
|
|
622
|
+
});
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
if (seen.has(key)) {
|
|
626
|
+
problems.push({
|
|
627
|
+
line: lineNumber,
|
|
628
|
+
text: key,
|
|
629
|
+
reason: "duplicate key in this file"
|
|
630
|
+
});
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
seen.add(key);
|
|
634
|
+
entries.push({
|
|
635
|
+
key,
|
|
636
|
+
value,
|
|
637
|
+
line: lineNumber
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
return {
|
|
641
|
+
entries,
|
|
642
|
+
problems
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
/** True when what follows a closing quote is neither whitespace nor a comment. */
|
|
646
|
+
function trailingIsSignificant(rest) {
|
|
647
|
+
const trimmed = rest.trim();
|
|
648
|
+
return trimmed !== "" && !trimmed.startsWith("#");
|
|
649
|
+
}
|
|
650
|
+
function findClosingQuote(text, quote) {
|
|
651
|
+
for (let index = 1; index < text.length; index++) {
|
|
652
|
+
if (text[index] === "\\") {
|
|
653
|
+
index++;
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (text[index] === quote) return index;
|
|
657
|
+
}
|
|
658
|
+
return -1;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Expand escapes inside a double-quoted value.
|
|
662
|
+
*
|
|
663
|
+
* Deliberately minimal: only the escapes that are necessary. `\n` and `\r`
|
|
664
|
+
* because multi-line values (PEM keys) cannot otherwise be expressed on one
|
|
665
|
+
* line, and `\\` `\"` `\'` because quoting needs them.
|
|
666
|
+
*
|
|
667
|
+
* EVERYTHING ELSE IS PRESERVED VERBATIM, backslash included. The obvious
|
|
668
|
+
* implementation -- returning the character and dropping the backslash for
|
|
669
|
+
* unknown escapes -- silently mangles credentials: `"C:\path\to\file"` came
|
|
670
|
+
* out as `C:path<TAB>ofile`, and `"\d+\w"` as `d+w`. A secrets store that
|
|
671
|
+
* quietly rewrites what you gave it is worse than one that refuses.
|
|
672
|
+
*
|
|
673
|
+
* A value that should contain no interpretation at all belongs in single
|
|
674
|
+
* quotes, which are literal.
|
|
675
|
+
*/
|
|
676
|
+
function unescapeDoubleQuoted(text) {
|
|
677
|
+
return text.replace(/\\(.)/g, (match, character) => {
|
|
678
|
+
switch (character) {
|
|
679
|
+
case "n": return "\n";
|
|
680
|
+
case "r": return "\r";
|
|
681
|
+
case "\\":
|
|
682
|
+
case "\"":
|
|
683
|
+
case "'": return character;
|
|
684
|
+
default: return match;
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
/** Values that read the same unquoted: no spaces, quotes, `#`, `$` or backslashes. */
|
|
689
|
+
const PLAIN_VALUE = /^[A-Za-z0-9_./:@+,=%?-]*$/;
|
|
690
|
+
/**
|
|
691
|
+
* Write entries as a .env file that `parseDotenv` reads back exactly.
|
|
692
|
+
*
|
|
693
|
+
* Plain values stay bare; anything else is single-quoted, which is literal;
|
|
694
|
+
* values holding a quote or a line break are double-quoted with the four
|
|
695
|
+
* escapes the parser knows (`\\`, `\"`, `\n`, `\r`).
|
|
696
|
+
*/
|
|
697
|
+
function formatDotenv(entries) {
|
|
698
|
+
let out = "";
|
|
699
|
+
for (const [key, value] of entries) {
|
|
700
|
+
assertWritable(key, value);
|
|
701
|
+
out += `${key}=${quoteDotenv(value)}\n`;
|
|
702
|
+
}
|
|
703
|
+
return out;
|
|
704
|
+
}
|
|
705
|
+
function assertWritable(key, value) {
|
|
706
|
+
if (!KEY_RE.test(key)) throw new Error(`${JSON.stringify(key)} is not a valid variable name`);
|
|
707
|
+
if (value.includes("\0")) throw new Error(`${key} contains a NUL byte`);
|
|
708
|
+
}
|
|
709
|
+
function quoteDotenv(value) {
|
|
710
|
+
if (PLAIN_VALUE.test(value)) return value;
|
|
711
|
+
if (!/['\n\r]/.test(value)) return `'${value}'`;
|
|
712
|
+
return `"${value.replace(/[\\"\n\r]/g, (character) => {
|
|
713
|
+
switch (character) {
|
|
714
|
+
case "\n": return "\\n";
|
|
715
|
+
case "\r": return "\\r";
|
|
716
|
+
default: return `\\${character}`;
|
|
717
|
+
}
|
|
718
|
+
})}"`;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Write entries as POSIX shell `export` statements, for
|
|
722
|
+
* `eval "$(coffre export … --format shell)"`. Single quotes are the only
|
|
723
|
+
* shell quoting with no expansion inside; a single quote itself is written
|
|
724
|
+
* as `'\''`.
|
|
725
|
+
*/
|
|
726
|
+
function formatShellExports(entries) {
|
|
727
|
+
let out = "";
|
|
728
|
+
for (const [key, value] of entries) {
|
|
729
|
+
assertWritable(key, value);
|
|
730
|
+
out += `export ${key}='${value.replaceAll("'", "'\\''")}'\n`;
|
|
731
|
+
}
|
|
732
|
+
return out;
|
|
733
|
+
}
|
|
734
|
+
//#endregion
|
|
735
|
+
//#region src/main.ts
|
|
736
|
+
/**
|
|
737
|
+
* coffre CLI.
|
|
738
|
+
*
|
|
739
|
+
* Deliberately dependency-free: node:util's parseArgs and node:child_process
|
|
740
|
+
* are enough. A tool that handles every credential we own is a poor place to
|
|
741
|
+
* add a transitive dependency tree for argument parsing.
|
|
742
|
+
*/
|
|
743
|
+
const CREDENTIALS_PATH = join(homedir(), ".coffre", "credentials.json");
|
|
744
|
+
process.stdout.on("error", (error) => {
|
|
745
|
+
if (error.code === "EPIPE") process.exit(0);
|
|
746
|
+
throw error;
|
|
747
|
+
});
|
|
748
|
+
function readStore() {
|
|
749
|
+
return existsSync(CREDENTIALS_PATH) ? parseStore(readFileSync(CREDENTIALS_PATH, "utf8")) : emptyStore();
|
|
750
|
+
}
|
|
751
|
+
function writeStore(store) {
|
|
752
|
+
const directory = dirname(CREDENTIALS_PATH);
|
|
753
|
+
try {
|
|
754
|
+
mkdirSync(directory, {
|
|
755
|
+
recursive: true,
|
|
756
|
+
mode: 448
|
|
757
|
+
});
|
|
758
|
+
if (lstatSync(directory).isSymbolicLink() || existsSync(CREDENTIALS_PATH) && lstatSync(CREDENTIALS_PATH).isSymbolicLink()) throw new Error("a symbolic link is not a credentials path");
|
|
759
|
+
chmodSync(directory, 448);
|
|
760
|
+
if ((statSync(directory).mode & 511) !== 448) throw new Error("directory permissions must be 0700");
|
|
761
|
+
const temporary = join(directory, `.credentials-${crypto.randomUUID()}`);
|
|
762
|
+
try {
|
|
763
|
+
writeFileSync(temporary, `${JSON.stringify(store, null, 2)}\n`, {
|
|
764
|
+
mode: 384,
|
|
765
|
+
flag: "wx"
|
|
766
|
+
});
|
|
767
|
+
chmodSync(temporary, 384);
|
|
768
|
+
if ((statSync(temporary).mode & 511) !== 384) throw new Error("file permissions must be 0600");
|
|
769
|
+
renameSync(temporary, CREDENTIALS_PATH);
|
|
770
|
+
} finally {
|
|
771
|
+
rmSync(temporary, { force: true });
|
|
772
|
+
}
|
|
773
|
+
} catch (error) {
|
|
774
|
+
fail(`cannot secure credentials at ${CREDENTIALS_PATH}: ${error instanceof Error ? error.message : String(error)}`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
function fail(message) {
|
|
778
|
+
process.stderr.write(`coffre: ${message}\n`);
|
|
779
|
+
process.exit(1);
|
|
780
|
+
}
|
|
781
|
+
function attempt(step) {
|
|
782
|
+
try {
|
|
783
|
+
return step();
|
|
784
|
+
} catch (error) {
|
|
785
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
function target() {
|
|
789
|
+
return attempt(() => resolveTarget(process.env, readStore()));
|
|
790
|
+
}
|
|
791
|
+
/** cloudflared keeps and refreshes the Access token; ask it each time. */
|
|
792
|
+
async function cloudflaredToken(origin) {
|
|
793
|
+
try {
|
|
794
|
+
const { stdout } = await promisify(execFile)("cloudflared", [
|
|
795
|
+
"access",
|
|
796
|
+
"token",
|
|
797
|
+
`-app=${origin}`
|
|
798
|
+
]);
|
|
799
|
+
const token = stdout.trim();
|
|
800
|
+
if (token === "" || token.includes(" ")) throw new Error(stdout);
|
|
801
|
+
return token;
|
|
802
|
+
} catch {
|
|
803
|
+
fail(`could not get a Cloudflare Access token for ${origin}: run \`coffre login ${origin}\``);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
async function headersFor(to) {
|
|
807
|
+
const access = to.credential.kind === "cloudflared" ? await cloudflaredToken(to.origin) : void 0;
|
|
808
|
+
return credentialHeaders(to.mode, to.credential, access);
|
|
809
|
+
}
|
|
810
|
+
/** The API, for one instance. */
|
|
811
|
+
function client(to = target()) {
|
|
812
|
+
return createClient({
|
|
813
|
+
url: to.origin,
|
|
814
|
+
headers: () => headersFor(to),
|
|
815
|
+
transport: (request) => send(request, to)
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
/** One request; every way it can fail is explained in terms of what to do next. */
|
|
819
|
+
async function send(request, to) {
|
|
820
|
+
let response;
|
|
821
|
+
try {
|
|
822
|
+
response = await fetch(request, { redirect: "manual" });
|
|
823
|
+
} catch (error) {
|
|
824
|
+
fail(`could not reach ${to.origin}: ${error instanceof Error ? error.message : String(error)}`);
|
|
825
|
+
}
|
|
826
|
+
const relogin = `run \`coffre login ${to.origin}\``;
|
|
827
|
+
if (response.status >= 300 && response.status < 400) {
|
|
828
|
+
if (to.mode === "cloudflare") fail(`Cloudflare Access did not accept your token: ${relogin}`);
|
|
829
|
+
fail(`${to.origin} redirected to ${response.headers.get("location") ?? "elsewhere"}; is that the right address?`);
|
|
830
|
+
}
|
|
831
|
+
if (response.status === 401) fail(`your session on ${to.origin} is missing, expired or revoked: ${relogin}`);
|
|
832
|
+
const json = isJsonContentType(response.headers.get("content-type"));
|
|
833
|
+
if (!response.ok) {
|
|
834
|
+
const body = json ? await response.json().catch(() => ({})) : {};
|
|
835
|
+
const detail = typeof body.message === "string" && body.message.length > 0 ? body.message : null;
|
|
836
|
+
if (response.status === 403) fail(`forbidden: ${detail ?? "you do not have a grant for that environment"}`);
|
|
837
|
+
if (response.status === 404) fail(detail === null ? "not found" : `not found: ${detail}`);
|
|
838
|
+
const status = `request failed with status ${response.status}`;
|
|
839
|
+
if (detail !== null) fail(`${status}: ${detail}`);
|
|
840
|
+
if (typeof body.error === "string" && body.error.length > 0) fail(`${status}: ${body.error}`);
|
|
841
|
+
fail(status);
|
|
842
|
+
}
|
|
843
|
+
if (!json) fail("request returned a non-JSON response");
|
|
844
|
+
return response;
|
|
845
|
+
}
|
|
846
|
+
/** Parse `project/environment/KEY` or `project/environment`. */
|
|
847
|
+
function parsePath(raw) {
|
|
848
|
+
const parts = raw.split("/");
|
|
849
|
+
if (parts.length < 2 || parts.length > 3 || parts.some((part) => part.length === 0)) fail(`expected project/environment[/KEY], got: ${raw}`);
|
|
850
|
+
return {
|
|
851
|
+
project: parts[0],
|
|
852
|
+
environment: parts[1],
|
|
853
|
+
key: parts[2]
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
function printMe(me) {
|
|
857
|
+
if (me.environments.length === 0) process.stdout.write(" no environments granted yet\n");
|
|
858
|
+
for (const entry of me.environments) process.stdout.write(` ${`${entry.project}/${entry.environment}`.padEnd(24)} ${entry.permissions.join(", ")}\n`);
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Sign in to an instance and make it the current one. How depends on who
|
|
862
|
+
* vouches for people there, which the CLI finds out by asking (`loginMode`):
|
|
863
|
+
*
|
|
864
|
+
* - coffre's own sign-in: the CLI shows a code, you approve it in a browser
|
|
865
|
+
* where you are signed in, and the CLI receives a session token of its own.
|
|
866
|
+
* - Cloudflare Access: the CLI hands over to `cloudflared`, which keeps the
|
|
867
|
+
* Access token from then on.
|
|
868
|
+
*/
|
|
869
|
+
async function login(args) {
|
|
870
|
+
const { values, positionals } = parseArgs({
|
|
871
|
+
args,
|
|
872
|
+
options: { "no-browser": {
|
|
873
|
+
type: "boolean",
|
|
874
|
+
default: false
|
|
875
|
+
} },
|
|
876
|
+
allowPositionals: true
|
|
877
|
+
});
|
|
878
|
+
const requested = positionals[0] ?? process.env.COFFRE_API_URL ?? readStore().current;
|
|
879
|
+
if (!requested) fail("usage: coffre login <url>, for example `coffre login https://coffre.example.com`");
|
|
880
|
+
const origin = attempt(() => instanceOrigin(requested));
|
|
881
|
+
if ((attempt(() => parseMode(process.env.COFFRE_AUTH_MODE)) ?? await askMode(origin)) === "cloudflare") return accessLogin(origin);
|
|
882
|
+
let started;
|
|
883
|
+
try {
|
|
884
|
+
started = await fetch(`${origin}/api/auth/device`, {
|
|
885
|
+
method: "POST",
|
|
886
|
+
redirect: "manual",
|
|
887
|
+
headers: { "content-type": "application/json" },
|
|
888
|
+
body: JSON.stringify({ client_label: `coffre CLI on ${hostname()}` })
|
|
889
|
+
});
|
|
890
|
+
} catch (error) {
|
|
891
|
+
fail(`could not reach ${origin}: ${error instanceof Error ? error.message : String(error)}`);
|
|
892
|
+
}
|
|
893
|
+
if (started.status === 429) fail("too many sign-in attempts from this address; wait a minute and retry");
|
|
894
|
+
if (!started.ok || !isJsonContentType(started.headers.get("content-type"))) fail(`${origin} did not start a sign-in (status ${started.status})`);
|
|
895
|
+
const device = await started.json();
|
|
896
|
+
process.stderr.write(`\nTo sign in, open\n\n ${device.verification_uri_complete}\n\nand check that it shows the code ${device.user_code}\n\n`);
|
|
897
|
+
if (!values["no-browser"] && process.stderr.isTTY) openBrowser(device.verification_uri_complete);
|
|
898
|
+
process.stderr.write("Waiting for you to approve it…\n");
|
|
899
|
+
const session = await pollDevice(origin, device.device_code, device.interval, device.expires_in);
|
|
900
|
+
const me = await client({
|
|
901
|
+
origin,
|
|
902
|
+
mode: "signin",
|
|
903
|
+
credential: {
|
|
904
|
+
kind: "token",
|
|
905
|
+
token: session.access_token
|
|
906
|
+
}
|
|
907
|
+
}).me();
|
|
908
|
+
writeStore(withSession(readStore(), origin, {
|
|
909
|
+
mode: "signin",
|
|
910
|
+
token: session.access_token,
|
|
911
|
+
principal: me.principal,
|
|
912
|
+
expiresAt: session.expires_at,
|
|
913
|
+
obtainedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
914
|
+
}));
|
|
915
|
+
process.stdout.write(`Signed in to ${origin} as ${me.principal.id}\n`);
|
|
916
|
+
printMe(me);
|
|
917
|
+
}
|
|
918
|
+
/** Ask the instance how it signs people in. */
|
|
919
|
+
async function askMode(origin) {
|
|
920
|
+
let response;
|
|
921
|
+
try {
|
|
922
|
+
response = await fetch(`${origin}/api/auth`, {
|
|
923
|
+
redirect: "manual",
|
|
924
|
+
headers: { accept: "application/json" }
|
|
925
|
+
});
|
|
926
|
+
} catch (error) {
|
|
927
|
+
fail(`could not reach ${origin}: ${error instanceof Error ? error.message : String(error)}`);
|
|
928
|
+
}
|
|
929
|
+
const body = isJsonContentType(response.headers.get("content-type")) ? await response.json().catch(() => void 0) : void 0;
|
|
930
|
+
return attempt(() => loginMode(origin, response.status, body));
|
|
931
|
+
}
|
|
932
|
+
async function pollDevice(origin, deviceCode, interval, expiresIn) {
|
|
933
|
+
const deadline = Date.now() + expiresIn * 1e3;
|
|
934
|
+
let wait = Math.max(interval, 1) * 1e3;
|
|
935
|
+
while (Date.now() < deadline) {
|
|
936
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
937
|
+
let response;
|
|
938
|
+
try {
|
|
939
|
+
response = await fetch(`${origin}/api/auth/device/token`, {
|
|
940
|
+
method: "POST",
|
|
941
|
+
redirect: "manual",
|
|
942
|
+
headers: { "content-type": "application/json" },
|
|
943
|
+
body: JSON.stringify({ device_code: deviceCode })
|
|
944
|
+
});
|
|
945
|
+
} catch {
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
if (response.ok) return await response.json();
|
|
949
|
+
const { error } = await response.json().catch(() => ({}));
|
|
950
|
+
if (response.status === 429 || error === "slow_down") {
|
|
951
|
+
wait += 5e3;
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
if (error === "authorization_pending") continue;
|
|
955
|
+
if (error === "access_denied") fail("the sign-in was declined in the browser");
|
|
956
|
+
if (error === "expired_token") fail("the code expired before it was approved; run `coffre login` again");
|
|
957
|
+
fail(`sign-in failed: ${error ?? `status ${response.status}`}`);
|
|
958
|
+
}
|
|
959
|
+
fail("the code expired before it was approved; run `coffre login` again");
|
|
960
|
+
}
|
|
961
|
+
/** Best effort: on a machine without a desktop the printed link is enough. */
|
|
962
|
+
function openBrowser(url) {
|
|
963
|
+
const [command, ...args] = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? [
|
|
964
|
+
"cmd",
|
|
965
|
+
"/c",
|
|
966
|
+
"start",
|
|
967
|
+
"\"\"",
|
|
968
|
+
url
|
|
969
|
+
] : ["xdg-open", url];
|
|
970
|
+
const child = spawn(command, args, {
|
|
971
|
+
stdio: "ignore",
|
|
972
|
+
detached: true
|
|
973
|
+
});
|
|
974
|
+
child.on("error", () => {});
|
|
975
|
+
child.unref();
|
|
976
|
+
}
|
|
977
|
+
async function accessLogin(origin) {
|
|
978
|
+
process.stderr.write(`${origin} is behind Cloudflare Access; signing in with cloudflared.\n`);
|
|
979
|
+
const code = await new Promise((resolve) => {
|
|
980
|
+
const child = spawn("cloudflared", [
|
|
981
|
+
"access",
|
|
982
|
+
"login",
|
|
983
|
+
origin
|
|
984
|
+
], { stdio: "inherit" });
|
|
985
|
+
child.on("error", () => resolve(null));
|
|
986
|
+
child.on("exit", (status) => resolve(status));
|
|
987
|
+
});
|
|
988
|
+
if (code === null) fail("this instance uses Cloudflare Access, which needs cloudflared: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n In CI, set COFFRE_ACCESS_CLIENT_ID and COFFRE_ACCESS_CLIENT_SECRET to an Access service token instead.");
|
|
989
|
+
if (code !== 0) fail("cloudflared could not sign you in");
|
|
990
|
+
const me = await client({
|
|
991
|
+
origin,
|
|
992
|
+
mode: "cloudflare",
|
|
993
|
+
credential: { kind: "cloudflared" }
|
|
994
|
+
}).me();
|
|
995
|
+
writeStore(withSession(readStore(), origin, {
|
|
996
|
+
mode: "cloudflare",
|
|
997
|
+
principal: me.principal,
|
|
998
|
+
obtainedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
999
|
+
}));
|
|
1000
|
+
process.stdout.write(`Signed in to ${origin} as ${me.principal.id}\n`);
|
|
1001
|
+
printMe(me);
|
|
1002
|
+
}
|
|
1003
|
+
/** End the session on the server, then forget it here. */
|
|
1004
|
+
async function logout(args) {
|
|
1005
|
+
const store = readStore();
|
|
1006
|
+
const requested = args[0] ?? process.env.COFFRE_API_URL ?? store.current;
|
|
1007
|
+
if (!requested) fail("not signed in anywhere");
|
|
1008
|
+
const origin = attempt(() => instanceOrigin(requested));
|
|
1009
|
+
const session = store.instances[origin];
|
|
1010
|
+
if (session === void 0) fail(`not signed in to ${origin}`);
|
|
1011
|
+
if (session.mode === "signin" && session.token) {
|
|
1012
|
+
const response = await fetch(`${origin}/api/auth/logout`, {
|
|
1013
|
+
method: "POST",
|
|
1014
|
+
redirect: "manual",
|
|
1015
|
+
headers: {
|
|
1016
|
+
authorization: `Bearer ${session.token}`,
|
|
1017
|
+
"content-type": "application/json"
|
|
1018
|
+
}
|
|
1019
|
+
}).catch(() => null);
|
|
1020
|
+
if (response === null) fail(`could not reach ${origin} to end the session; nothing was changed`);
|
|
1021
|
+
if (!response.ok && response.status !== 401) fail(`${origin} refused to end the session (status ${response.status}); nothing was changed`);
|
|
1022
|
+
}
|
|
1023
|
+
writeStore(withoutSession(store, origin));
|
|
1024
|
+
process.stdout.write(`Signed out of ${origin}\n`);
|
|
1025
|
+
if (session.mode === "cloudflare") process.stdout.write(" cloudflared still holds its Access token until it expires.\n");
|
|
1026
|
+
}
|
|
1027
|
+
async function whoami() {
|
|
1028
|
+
const to = target();
|
|
1029
|
+
const me = await client(to).me();
|
|
1030
|
+
const session = readStore().instances[to.origin];
|
|
1031
|
+
const via = to.credential.kind === "access-service-token" ? "an Access service token" : process.env.COFFRE_TOKEN ? "COFFRE_TOKEN" : {
|
|
1032
|
+
signin: "coffre sign-in",
|
|
1033
|
+
cloudflare: "Cloudflare Access"
|
|
1034
|
+
}[to.mode];
|
|
1035
|
+
process.stdout.write(`${me.principal.id} (${me.principal.type}) on ${to.origin}, via ${via}\n`);
|
|
1036
|
+
if (!process.env.COFFRE_TOKEN && session?.expiresAt) {
|
|
1037
|
+
const days = Math.round((Date.parse(session.expiresAt) - Date.now()) / 864e5);
|
|
1038
|
+
process.stdout.write(` session ends ${session.expiresAt.slice(0, 10)} (in ${days} day${days === 1 ? "" : "s"})\n`);
|
|
1039
|
+
}
|
|
1040
|
+
printMe(me);
|
|
1041
|
+
}
|
|
1042
|
+
/** Switch the current instance, or list them. */
|
|
1043
|
+
function use(args) {
|
|
1044
|
+
const store = readStore();
|
|
1045
|
+
if (args[0] === void 0) {
|
|
1046
|
+
const origins = Object.keys(store.instances);
|
|
1047
|
+
if (origins.length === 0) fail("not signed in anywhere yet: run `coffre login <url>`");
|
|
1048
|
+
for (const origin of origins) {
|
|
1049
|
+
const session = store.instances[origin];
|
|
1050
|
+
const marker = origin === store.current ? "*" : " ";
|
|
1051
|
+
process.stdout.write(`${marker} ${origin.padEnd(36)} ${session.principal?.id ?? ""}\n`);
|
|
1052
|
+
}
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
const origin = attempt(() => instanceOrigin(args[0]));
|
|
1056
|
+
if (!(origin in store.instances)) fail(`not signed in to ${origin}: run \`coffre login ${origin}\``);
|
|
1057
|
+
writeStore({
|
|
1058
|
+
...store,
|
|
1059
|
+
current: origin
|
|
1060
|
+
});
|
|
1061
|
+
process.stdout.write(`Now using ${origin}\n`);
|
|
1062
|
+
}
|
|
1063
|
+
async function get(args) {
|
|
1064
|
+
const target = args[0];
|
|
1065
|
+
if (!target) fail("usage: coffre get <project>/<environment>/<KEY>");
|
|
1066
|
+
const { project, environment, key } = parsePath(target);
|
|
1067
|
+
if (!key) fail("usage: coffre get <project>/<environment>/<KEY>");
|
|
1068
|
+
const { values } = await client().secrets.reveal(`${project}/${environment}/${key}`);
|
|
1069
|
+
process.stdout.write(`${values[key]}\n`);
|
|
1070
|
+
}
|
|
1071
|
+
async function list(args) {
|
|
1072
|
+
const target = args[0];
|
|
1073
|
+
if (!target) fail("usage: coffre list <project>/<environment>");
|
|
1074
|
+
const { project, environment } = parsePath(target);
|
|
1075
|
+
const result = await client().secrets.list(`${project}/${environment}`);
|
|
1076
|
+
for (const entry of result.keys) {
|
|
1077
|
+
const archived = entry.archived ? " (archived)" : "";
|
|
1078
|
+
process.stdout.write(`${entry.key}\tv${entry.version ?? "-"}\t${entry.updatedBy ?? "-"}${archived}\n`);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
async function set(args) {
|
|
1082
|
+
const [target, value] = args;
|
|
1083
|
+
if (!target) fail("usage: coffre set <project>/<environment>/<KEY> [value]");
|
|
1084
|
+
const { project, environment, key } = parsePath(target);
|
|
1085
|
+
if (!key) fail("usage: coffre set <project>/<environment>/<KEY> [value]");
|
|
1086
|
+
const resolved = value ?? readFileSync(0, "utf8").replace(/\n$/, "");
|
|
1087
|
+
const outcome = (await client().secrets.set(`${project}/${environment}`, { [key]: resolved })).keys[key];
|
|
1088
|
+
process.stdout.write(`${key} written as version ${"version" in outcome ? outcome.version : "?"}\n`);
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Fetch every secret for an environment and exec a command with them in the
|
|
1092
|
+
* environment. One round trip, and one audit row per secret injected.
|
|
1093
|
+
*/
|
|
1094
|
+
async function run(args) {
|
|
1095
|
+
const separator = args.indexOf("--");
|
|
1096
|
+
if (separator === -1 || separator === args.length - 1) fail("usage: coffre run <project>/<environment> -- <command> [args...]");
|
|
1097
|
+
const target = args.slice(0, separator)[0];
|
|
1098
|
+
if (!target) fail("usage: coffre run <project>/<environment> -- <command> [args...]");
|
|
1099
|
+
const { project, environment } = parsePath(target);
|
|
1100
|
+
const command = args.slice(separator + 1);
|
|
1101
|
+
const { values } = await client().secrets.reveal(`${project}/${environment}`);
|
|
1102
|
+
const child = spawn(command[0], command.slice(1), {
|
|
1103
|
+
env: {
|
|
1104
|
+
...process.env,
|
|
1105
|
+
...values
|
|
1106
|
+
},
|
|
1107
|
+
stdio: "inherit"
|
|
1108
|
+
});
|
|
1109
|
+
child.on("error", (error) => fail(`failed to run ${command[0]}: ${error.message}`));
|
|
1110
|
+
child.on("exit", (code, signal) => {
|
|
1111
|
+
if (signal) process.kill(process.pid, signal);
|
|
1112
|
+
process.exit(code ?? 0);
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Print every secret in an environment, for tools that want a file or a
|
|
1117
|
+
* shell rather than a child process. Each value read is one audit row in
|
|
1118
|
+
* your name, exactly as with `run`.
|
|
1119
|
+
*/
|
|
1120
|
+
async function exportEnv(args) {
|
|
1121
|
+
const { values, positionals } = parseArgs({
|
|
1122
|
+
args,
|
|
1123
|
+
options: { format: {
|
|
1124
|
+
type: "string",
|
|
1125
|
+
default: "dotenv"
|
|
1126
|
+
} },
|
|
1127
|
+
allowPositionals: true
|
|
1128
|
+
});
|
|
1129
|
+
const usage = "usage: coffre export <project>/<environment> [--format dotenv|json|shell]";
|
|
1130
|
+
if (!positionals[0]) fail(usage);
|
|
1131
|
+
const format = values.format;
|
|
1132
|
+
if (format !== "dotenv" && format !== "json" && format !== "shell") fail(usage);
|
|
1133
|
+
const { project, environment } = parsePath(positionals[0]);
|
|
1134
|
+
const revealed = await client().secrets.reveal(`${project}/${environment}`);
|
|
1135
|
+
const entries = Object.entries(revealed.values).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
1136
|
+
process.stdout.write(format === "json" ? `${JSON.stringify(Object.fromEntries(entries), null, 2)}\n` : attempt(() => format === "shell" ? formatShellExports(entries) : formatDotenv(entries)));
|
|
1137
|
+
if (process.stderr.isTTY) {
|
|
1138
|
+
const count = entries.length;
|
|
1139
|
+
process.stderr.write(`coffre: read ${count} secret${count === 1 ? "" : "s"} from ${project}/${environment}; each read is in the audit log under your name\n`);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
async function history(args) {
|
|
1143
|
+
const target = args[0];
|
|
1144
|
+
if (!target) fail("usage: coffre history <project>/<environment>/<KEY>");
|
|
1145
|
+
const { project, environment, key } = parsePath(target);
|
|
1146
|
+
if (!key) fail("usage: coffre history <project>/<environment>/<KEY>");
|
|
1147
|
+
const result = await client().secrets.history(`${project}/${environment}/${key}`);
|
|
1148
|
+
for (const version of result.versions) process.stdout.write(`v${String(version.version).padEnd(4)} ${version.createdAt.slice(0, 19).replace("T", " ")} ${version.createdBy.padEnd(24)}${version.current ? " (current)" : ""}\n`);
|
|
1149
|
+
}
|
|
1150
|
+
async function rollback(args) {
|
|
1151
|
+
const [target, version] = args;
|
|
1152
|
+
if (!target || !version) fail("usage: coffre rollback <project>/<environment>/<KEY> <version>");
|
|
1153
|
+
const { project, environment, key } = parsePath(target);
|
|
1154
|
+
if (!key) fail("usage: coffre rollback <project>/<environment>/<KEY> <version>");
|
|
1155
|
+
const wanted = Number(version);
|
|
1156
|
+
if (!Number.isInteger(wanted) || wanted < 1) fail(`"${version}" is not a version number`);
|
|
1157
|
+
await client().secrets.restore(`${project}/${environment}/${key}`, wanted);
|
|
1158
|
+
process.stdout.write(`${key} rolled back to version ${version}\n`);
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Import a .env file. Previews by default; --apply writes.
|
|
1162
|
+
*
|
|
1163
|
+
* The preview is a dry run of the write: the server compares against the
|
|
1164
|
+
* current values, logs each one it opens as a read, and sends none back.
|
|
1165
|
+
* --apply then writes the keys that differ in one transaction, a new version
|
|
1166
|
+
* each.
|
|
1167
|
+
*/
|
|
1168
|
+
async function importEnv(args) {
|
|
1169
|
+
const { values, positionals } = parseArgs({
|
|
1170
|
+
args,
|
|
1171
|
+
options: {
|
|
1172
|
+
file: { type: "string" },
|
|
1173
|
+
apply: {
|
|
1174
|
+
type: "boolean",
|
|
1175
|
+
default: false
|
|
1176
|
+
}
|
|
1177
|
+
},
|
|
1178
|
+
allowPositionals: true
|
|
1179
|
+
});
|
|
1180
|
+
const target = positionals[0];
|
|
1181
|
+
if (!target) fail("usage: coffre import <project>/<environment> [--file .env] [--apply]");
|
|
1182
|
+
const { project, environment } = parsePath(target);
|
|
1183
|
+
const parsed = parseDotenv(values.file ? readFileSync(values.file, "utf8") : readFileSync(0, "utf8"));
|
|
1184
|
+
for (const problem of parsed.problems) process.stderr.write(` line ${problem.line}: ${problem.reason} (${problem.text})\n`);
|
|
1185
|
+
if (parsed.entries.length === 0) return;
|
|
1186
|
+
const coffre = client();
|
|
1187
|
+
const path = `${project}/${environment}`;
|
|
1188
|
+
const { plan, changes } = await planImport(coffre, path, parsed.entries);
|
|
1189
|
+
if (values.apply && Object.keys(changes).length > 0) await coffre.secrets.set(path, changes);
|
|
1190
|
+
for (const entry of plan) process.stdout.write(`${entry.action.padEnd(10)} ${entry.key}\n`);
|
|
1191
|
+
if (!values.apply) {
|
|
1192
|
+
const pending = Object.keys(changes).length;
|
|
1193
|
+
process.stdout.write(`\n${pending} change${pending === 1 ? "" : "s"} pending. Re-run with --apply to write.\n`);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
async function projects() {
|
|
1197
|
+
const result = await client().projects.list();
|
|
1198
|
+
for (const project of result.projects) {
|
|
1199
|
+
const archived = project.archivedAt === null ? "" : " (archived)";
|
|
1200
|
+
process.stdout.write(`${project.slug}${archived} ${project.name}\n`);
|
|
1201
|
+
for (const environment of project.environments) {
|
|
1202
|
+
if (environment.details?.archivedAt) continue;
|
|
1203
|
+
const count = environment.details?.secretCount;
|
|
1204
|
+
process.stdout.write(` ${environment.slug.padEnd(16)} ${count === null || count === void 0 ? "" : `${count} secrets`}\n`);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
async function whoHasAccess() {
|
|
1209
|
+
const result = await client().members.list();
|
|
1210
|
+
for (const member of result.members) {
|
|
1211
|
+
const root = member.isRootAdmin ? " [root admin]" : "";
|
|
1212
|
+
const tampered = member.tampered ? " [record failed its integrity check: remove to start over]" : "";
|
|
1213
|
+
process.stdout.write(`${member.principalId} (${member.principalType})${root}${tampered}\n`);
|
|
1214
|
+
for (const g of member.grants) {
|
|
1215
|
+
const place = g.environment === null ? g.project : `${g.project}/${g.environment}`;
|
|
1216
|
+
const until = g.expiresAt === null ? "" : ` until ${g.expiresAt.slice(0, 10)}`;
|
|
1217
|
+
process.stdout.write(` ${place.padEnd(24)} ${g.role}${until}\n`);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
async function grantAccess(args) {
|
|
1222
|
+
const { values, positionals } = parseArgs({
|
|
1223
|
+
args,
|
|
1224
|
+
options: {
|
|
1225
|
+
role: { type: "string" },
|
|
1226
|
+
env: { type: "string" },
|
|
1227
|
+
service: {
|
|
1228
|
+
type: "boolean",
|
|
1229
|
+
default: false
|
|
1230
|
+
},
|
|
1231
|
+
expires: { type: "string" }
|
|
1232
|
+
},
|
|
1233
|
+
allowPositionals: true
|
|
1234
|
+
});
|
|
1235
|
+
const [project, principalId] = positionals;
|
|
1236
|
+
if (!project || !principalId || !values.role) fail("usage: coffre grant <project> <principal> --role <role> [--env <env>] [--service] [--expires YYYY-MM-DD]");
|
|
1237
|
+
const role = values.role;
|
|
1238
|
+
if (!isRole(role)) fail(`no role "${role}": \`coffre roles\` lists them`);
|
|
1239
|
+
const scope = values.env ? `${project}/${values.env}` : project;
|
|
1240
|
+
await client().access.set(`${values.service ? "token" : "user"}:${principalId}`, { [scope]: values.expires ? {
|
|
1241
|
+
role,
|
|
1242
|
+
until: values.expires
|
|
1243
|
+
} : role });
|
|
1244
|
+
process.stdout.write(`granted ${values.role} on ${scope} to ${principalId}\n`);
|
|
1245
|
+
}
|
|
1246
|
+
function plural(count, word) {
|
|
1247
|
+
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
1248
|
+
}
|
|
1249
|
+
/** "2 grants, 1 session, 0 linked accounts": what a removal ends. */
|
|
1250
|
+
function waysIn(principalType, counts) {
|
|
1251
|
+
return principalType === "user" ? [
|
|
1252
|
+
plural(counts.grants, "grant"),
|
|
1253
|
+
plural(counts.sessions, "session"),
|
|
1254
|
+
plural(counts.identities, "linked account")
|
|
1255
|
+
].join(", ") : [plural(counts.grants, "grant"), plural(counts.tokens, "token")].join(", ");
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Offboard someone. Previews by default, like import: what removing them
|
|
1259
|
+
* would revoke, and what they leave behind. --apply removes them.
|
|
1260
|
+
*
|
|
1261
|
+
* Removal takes every way in away at once. What it cannot take back is what
|
|
1262
|
+
* they saw, so the report after it is the list of values to rotate.
|
|
1263
|
+
*/
|
|
1264
|
+
async function offboard(args) {
|
|
1265
|
+
const { values, positionals } = parseArgs({
|
|
1266
|
+
args,
|
|
1267
|
+
options: {
|
|
1268
|
+
service: {
|
|
1269
|
+
type: "boolean",
|
|
1270
|
+
default: false
|
|
1271
|
+
},
|
|
1272
|
+
apply: {
|
|
1273
|
+
type: "boolean",
|
|
1274
|
+
default: false
|
|
1275
|
+
}
|
|
1276
|
+
},
|
|
1277
|
+
allowPositionals: true
|
|
1278
|
+
});
|
|
1279
|
+
const principalId = positionals[0];
|
|
1280
|
+
if (!principalId) fail("usage: coffre offboard <principal> [--service] [--apply]");
|
|
1281
|
+
const member = `${values.service ? "token" : "user"}:${principalId}`;
|
|
1282
|
+
const coffre = client();
|
|
1283
|
+
let report = await coffre.members.get(member);
|
|
1284
|
+
const they = report.principalType === "user" ? "they" : "it";
|
|
1285
|
+
if (report.status === "active" && values.apply) {
|
|
1286
|
+
const removed = await coffre.members.remove(member);
|
|
1287
|
+
process.stdout.write(`removed ${principalId}: revoked ${waysIn(report.principalType, removed.revoked)}\n`);
|
|
1288
|
+
report = removed.report;
|
|
1289
|
+
} else if (report.status === "active") process.stdout.write(`${principalId} is active; removing would revoke ${waysIn(report.principalType, report.live)}\n`);
|
|
1290
|
+
else {
|
|
1291
|
+
const by = report.removedBy === null ? "" : ` by ${report.removedBy}`;
|
|
1292
|
+
const at = report.removedAt === null ? "" : ` on ${report.removedAt.slice(0, 16).replace("T", " ")}`;
|
|
1293
|
+
process.stdout.write(`${principalId} was removed${by}${at}\n`);
|
|
1294
|
+
}
|
|
1295
|
+
const rotated = report.rotated > 0 ? `, ${report.rotated} already rotated` : "";
|
|
1296
|
+
const when = report.status === "active" ? ` once ${they} ${they === "they" ? "leave" : "is retired"}` : "";
|
|
1297
|
+
process.stdout.write(`\nValues ${they} saw that nobody has changed since, to rotate${when} (${report.exposed.length}${rotated})\n`);
|
|
1298
|
+
if (report.exposed.length === 0) process.stdout.write(" none\n");
|
|
1299
|
+
const names = report.exposed.map((entry) => `${entry.project}/${entry.environment}/${entry.key}`);
|
|
1300
|
+
const width = Math.max(0, ...names.map((name) => name.length));
|
|
1301
|
+
report.exposed.forEach((entry, index) => {
|
|
1302
|
+
process.stdout.write(` ${names[index].padEnd(width)} v${String(entry.version).padEnd(4)} ${entry.how} ${entry.at.slice(0, 10)}\n`);
|
|
1303
|
+
});
|
|
1304
|
+
if (report.syncs.length > 0) {
|
|
1305
|
+
process.stdout.write(`\nSyncs ${they} set up, which keep pushing\n`);
|
|
1306
|
+
for (const entry of report.syncs) {
|
|
1307
|
+
const paused = entry.paused ? " (paused)" : "";
|
|
1308
|
+
process.stdout.write(` ${entry.project}/${entry.environment} -> ${entry.providerLabel} ${entry.destination}${paused}\n`);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
if (report.issuedTokens.length > 0) {
|
|
1312
|
+
process.stdout.write(`\nService tokens ${they} issued, which still work\n`);
|
|
1313
|
+
for (const token of report.issuedTokens) {
|
|
1314
|
+
const label = token.label === null ? "" : ` "${token.label}"`;
|
|
1315
|
+
process.stdout.write(` ${token.service}${label} ${token.hint}, expires ${token.expiresAt.slice(0, 10)}\n`);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
if (report.status === "active" && !values.apply) process.stdout.write(`\nNothing changed. Re-run with --apply to remove ${principalId}.\n`);
|
|
1319
|
+
}
|
|
1320
|
+
function roles() {
|
|
1321
|
+
for (const [slug, role] of Object.entries(ROLES)) {
|
|
1322
|
+
const scope = assignableToEnvironment(slug) ? "project or env" : "project only";
|
|
1323
|
+
process.stdout.write(`${slug.padEnd(16)} [${scope}] ${role.permissions.join(", ")}\n`);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
async function audit(args) {
|
|
1327
|
+
const { values } = parseArgs({
|
|
1328
|
+
args,
|
|
1329
|
+
options: {
|
|
1330
|
+
limit: {
|
|
1331
|
+
type: "string",
|
|
1332
|
+
default: "20"
|
|
1333
|
+
},
|
|
1334
|
+
actor: { type: "string" },
|
|
1335
|
+
denied: {
|
|
1336
|
+
type: "boolean",
|
|
1337
|
+
default: false
|
|
1338
|
+
},
|
|
1339
|
+
detail: {
|
|
1340
|
+
type: "boolean",
|
|
1341
|
+
default: false
|
|
1342
|
+
}
|
|
1343
|
+
},
|
|
1344
|
+
allowPositionals: false
|
|
1345
|
+
});
|
|
1346
|
+
const limit = Number(values.limit);
|
|
1347
|
+
if (!Number.isInteger(limit) || limit < 1) fail(`--limit takes a positive number, not "${values.limit}"`);
|
|
1348
|
+
const result = await client().audit.list({
|
|
1349
|
+
limit,
|
|
1350
|
+
actor: values.actor,
|
|
1351
|
+
decision: values.denied ? "deny" : void 0,
|
|
1352
|
+
detail: values.detail ? "1" : void 0
|
|
1353
|
+
});
|
|
1354
|
+
for (const entry of result.entries.reverse()) {
|
|
1355
|
+
const place = [
|
|
1356
|
+
entry.project,
|
|
1357
|
+
entry.environment,
|
|
1358
|
+
entry.key
|
|
1359
|
+
].filter((part) => part !== null).join("/") || "-";
|
|
1360
|
+
process.stdout.write(`${entry.occurredAt} ${entry.decision.padEnd(5)} ${entry.actorId.padEnd(28)} ${entry.action.padEnd(16)} ${place}\n`);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
async function verify() {
|
|
1364
|
+
const result = await client().audit.verify();
|
|
1365
|
+
if (result.ok) {
|
|
1366
|
+
const through = result.through === null ? "empty" : `verified through entry ${result.through}`;
|
|
1367
|
+
const signed = result.checkpoint === null ? "no checkpoint signed yet" : `last checkpoint: entry ${result.checkpoint.seq}, signed ${result.checkpoint.signedAt}`;
|
|
1368
|
+
const pending = result.pending === void 0 ? "" : `${result.pending} key operations still under way\n`;
|
|
1369
|
+
process.stdout.write(`audit log OK: ${through}, ${result.entries} entries; members and grants replayed\n${signed}\n${pending}`);
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
const at = result.failedAtSeq === null ? "" : ` at entry ${result.failedAtSeq}`;
|
|
1373
|
+
const through = result.through === null ? "nothing verified" : `verified through entry ${result.through}`;
|
|
1374
|
+
process.stderr.write(`audit log BROKEN${at}, found by the ${result.author}'s check (${through}): ${result.reason}\n`);
|
|
1375
|
+
process.exit(2);
|
|
1376
|
+
}
|
|
1377
|
+
const SYNC_USAGE = `usage:
|
|
1378
|
+
coffre sync providers
|
|
1379
|
+
coffre sync list <project>/<environment>
|
|
1380
|
+
coffre sync add <project>/<environment> <provider> name=value… --credential <project>/<environment>/<KEY>
|
|
1381
|
+
coffre sync run <project>/<environment> <sync>
|
|
1382
|
+
coffre sync pause <project>/<environment> <sync>
|
|
1383
|
+
coffre sync resume <project>/<environment> <sync>
|
|
1384
|
+
coffre sync remove <project>/<environment> <sync>
|
|
1385
|
+
|
|
1386
|
+
\`coffre sync providers\` lists where this instance can sync to, and what each
|
|
1387
|
+
provider takes. <sync> is the start of an id from \`coffre sync list\`, or the
|
|
1388
|
+
provider's id when the environment syncs there only once.
|
|
1389
|
+
`;
|
|
1390
|
+
/** Where this instance can sync to, one provider to a paragraph. */
|
|
1391
|
+
function printProviders(providers) {
|
|
1392
|
+
if (providers.length === 0) {
|
|
1393
|
+
process.stdout.write("This instance offers no sync providers.\n");
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
process.stdout.write("What is in brackets can be left out. A choice left out is what comes\nbefore the first |, and several choices go comma-separated.\n");
|
|
1397
|
+
for (const provider of providers) process.stdout.write(`\n${provider.id} ${provider.label}\n ${[...provider.fields.map(fieldUsage), `--credential ${provider.credential.placeholder}`].join(" ")}\n${indented(provider.credential.hint)}\n`);
|
|
1398
|
+
}
|
|
1399
|
+
/** Prose under a usage line, indented and wrapped to a terminal's 80 columns. */
|
|
1400
|
+
function indented(text) {
|
|
1401
|
+
const lines = [];
|
|
1402
|
+
for (const word of text.split(/\s+/)) {
|
|
1403
|
+
const last = lines.length - 1;
|
|
1404
|
+
if (last >= 0 && lines[last].length + 1 + word.length <= 76) lines[last] += ` ${word}`;
|
|
1405
|
+
else lines.push(word);
|
|
1406
|
+
}
|
|
1407
|
+
return lines.map((line) => ` ${line}`).join("\n");
|
|
1408
|
+
}
|
|
1409
|
+
function fieldUsage(field) {
|
|
1410
|
+
if (field.type === "text") return field.optional ? `[${field.name}=…]` : `${field.name}=…`;
|
|
1411
|
+
const others = field.options.map((option) => option.value).filter((value) => !field.initial.includes(value));
|
|
1412
|
+
return `[${field.name}=${[field.initial.join(","), ...others].join("|")}]`;
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* Push an environment somewhere else and keep it current. The server does
|
|
1416
|
+
* the work and checks every field; this names things and reports back.
|
|
1417
|
+
*/
|
|
1418
|
+
async function sync(args) {
|
|
1419
|
+
const [verb, path, ...rest] = args;
|
|
1420
|
+
if (verb === "providers") {
|
|
1421
|
+
const { providers } = await client().syncs.providers();
|
|
1422
|
+
printProviders(providers);
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
if (verb === void 0 || verb === "--help" || path === void 0) {
|
|
1426
|
+
process.stdout.write(SYNC_USAGE);
|
|
1427
|
+
process.exit(verb === void 0 || verb === "--help" ? 0 : 1);
|
|
1428
|
+
}
|
|
1429
|
+
const { project, environment, key } = parsePath(path);
|
|
1430
|
+
if (key !== void 0) fail(`syncs belong to an environment: use ${project}/${environment}`);
|
|
1431
|
+
const place = `${project}/${environment}`;
|
|
1432
|
+
const coffre = client();
|
|
1433
|
+
if (verb === "list") {
|
|
1434
|
+
const { syncs } = await coffre.syncs.list(place);
|
|
1435
|
+
if (syncs.length === 0) process.stdout.write(`${project}/${environment} is not synced anywhere\n`);
|
|
1436
|
+
for (const entry of syncs) printSync(entry);
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
if (verb === "add") {
|
|
1440
|
+
const { values, positionals } = parseArgs({
|
|
1441
|
+
args: rest,
|
|
1442
|
+
options: { credential: { type: "string" } },
|
|
1443
|
+
allowPositionals: true
|
|
1444
|
+
});
|
|
1445
|
+
const [id, ...assignments] = positionals;
|
|
1446
|
+
const { providers } = await coffre.syncs.providers();
|
|
1447
|
+
const provider = providers.find((candidate) => candidate.id === id);
|
|
1448
|
+
if (provider === void 0) fail(`name a provider this instance offers (${providers.length === 0 ? "none" : providers.map((candidate) => candidate.id).join(", ")}); \`coffre sync providers\` shows what each takes`);
|
|
1449
|
+
if (values.credential === void 0) fail(`--credential names the secret holding the ${provider.label} token, such as ${provider.credential.placeholder}`);
|
|
1450
|
+
const config = attempt(() => configFromArguments(provider, assignments));
|
|
1451
|
+
const created = await coffre.syncs.add(place, {
|
|
1452
|
+
provider: provider.id,
|
|
1453
|
+
config,
|
|
1454
|
+
credential: values.credential
|
|
1455
|
+
});
|
|
1456
|
+
process.stdout.write(`Syncing ${project}/${environment} to ${created.destination} (${created.providerLabel}), id ${created.id.slice(0, 8)}.\nThe first push has started; \`coffre sync list ${project}/${environment}\` shows how it went.\n`);
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
if (![
|
|
1460
|
+
"run",
|
|
1461
|
+
"pause",
|
|
1462
|
+
"resume",
|
|
1463
|
+
"remove"
|
|
1464
|
+
].includes(verb)) fail(`unknown sync command "${verb}"; see \`coffre sync --help\``);
|
|
1465
|
+
const reference = rest[0];
|
|
1466
|
+
if (reference === void 0) fail(`usage: coffre sync ${verb} ${project}/${environment} <sync>`);
|
|
1467
|
+
const { syncs } = await coffre.syncs.list(place);
|
|
1468
|
+
const chosen = attempt(() => pickSync(syncs, reference));
|
|
1469
|
+
if (verb === "run") {
|
|
1470
|
+
if (chosen.paused) fail(`syncing to ${chosen.destination} is paused; resume it first`);
|
|
1471
|
+
const { sync: after, outcome } = await coffre.syncs.run(chosen.id);
|
|
1472
|
+
if (outcome.status === "busy") fail(`a run to ${chosen.destination} is already under way`);
|
|
1473
|
+
for (const failure of outcome.failed) process.stderr.write(` could not ${failure.operation === "upsert" ? "push" : "remove"} ${failure.key}: ${failure.message}\n`);
|
|
1474
|
+
if (outcome.status === "failed") fail(`could not sync to ${after.destination}: ${outcome.error ?? "the run failed"}`);
|
|
1475
|
+
const changed = outcome.upserted.length + outcome.deleted.length;
|
|
1476
|
+
process.stdout.write(changed === 0 && outcome.failed.length === 0 ? `${after.destination} was already current\n` : `Pushed ${outcome.upserted.length}, removed ${outcome.deleted.length} at ${after.destination}\n`);
|
|
1477
|
+
if (outcome.status === "partial") process.exit(1);
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
if (verb === "remove") {
|
|
1481
|
+
await coffre.syncs.remove(chosen.id);
|
|
1482
|
+
process.stdout.write(`No longer syncing to ${chosen.destination}. Keys coffre pushed there stay; remove them at ${chosen.providerLabel} if they should go too.\n`);
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
const paused = verb === "pause";
|
|
1486
|
+
await coffre.syncs.update(chosen.id, { paused });
|
|
1487
|
+
process.stdout.write(paused ? `Paused syncing to ${chosen.destination}\n` : `Resumed syncing to ${chosen.destination}; changes since the pause go out on the next run\n`);
|
|
1488
|
+
}
|
|
1489
|
+
/** By id prefix, or by provider when only one sync here goes there. */
|
|
1490
|
+
function pickSync(syncs, reference) {
|
|
1491
|
+
const byProvider = syncs.filter((entry) => entry.provider === reference);
|
|
1492
|
+
if (byProvider.length === 1) return byProvider[0];
|
|
1493
|
+
if (byProvider.length > 1) throw new Error(`more than one ${reference} sync here; name it by id (coffre sync list)`);
|
|
1494
|
+
if (reference.length < 4) throw new Error("give at least 4 characters of a sync id, or a provider id");
|
|
1495
|
+
const byId = syncs.filter((entry) => entry.id.startsWith(reference.toLowerCase()));
|
|
1496
|
+
if (byId.length === 1) return byId[0];
|
|
1497
|
+
if (byId.length > 1) throw new Error(`"${reference}" starts more than one sync id; give more of it`);
|
|
1498
|
+
throw new Error(`no sync "${reference}" here; \`coffre sync list\` shows them`);
|
|
1499
|
+
}
|
|
1500
|
+
function printSync(entry) {
|
|
1501
|
+
const state = entry.running ? "pushing" : entry.paused ? "paused" : entry.lastStatus === "failed" ? "failed" : entry.lastStatus === "partial" ? "partial" : entry.lastRunAt === null ? "not run yet" : entry.pending > 0 ? "behind" : "in sync";
|
|
1502
|
+
const counts = [`${entry.synced} synced`, ...entry.pending > 0 ? [`${entry.pending} pending`] : []];
|
|
1503
|
+
if (entry.lastRunAt !== null) counts.push(`last run ${entry.lastRunAt.slice(0, 16).replace("T", " ")}`);
|
|
1504
|
+
process.stdout.write(`${entry.id.slice(0, 8)} ${entry.providerLabel.padEnd(18)} ${entry.destination}\n`);
|
|
1505
|
+
process.stdout.write(` ${state}: ${counts.join(", ")}; token from ${entry.credential}\n`);
|
|
1506
|
+
if (entry.lastError !== null && !entry.running) process.stdout.write(` ${entry.lastError}\n`);
|
|
1507
|
+
for (const skipped of entry.skipped) process.stdout.write(` skips ${skipped.key}: ${skipped.reason}\n`);
|
|
1508
|
+
}
|
|
1509
|
+
/** `coffre init --workers|--node [<dir>]`: a new deployment of coffre. */
|
|
1510
|
+
function initProject(args) {
|
|
1511
|
+
const { values, positionals } = parseArgs({
|
|
1512
|
+
args,
|
|
1513
|
+
options: {
|
|
1514
|
+
workers: { type: "boolean" },
|
|
1515
|
+
node: { type: "boolean" }
|
|
1516
|
+
},
|
|
1517
|
+
allowPositionals: true
|
|
1518
|
+
});
|
|
1519
|
+
const kinds = KINDS.filter((kind) => values[kind]);
|
|
1520
|
+
if (kinds.length !== 1 || positionals.length > 1) fail("usage: coffre init --workers|--node [<dir>]");
|
|
1521
|
+
const kind = kinds[0];
|
|
1522
|
+
const dir = resolve(positionals[0] ?? ".");
|
|
1523
|
+
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
1524
|
+
const files = attempt(() => init(kind, dir, version));
|
|
1525
|
+
for (const file of files) process.stdout.write(` ${file}\n`);
|
|
1526
|
+
const at = dir === process.cwd() ? null : positionals[0] ?? null;
|
|
1527
|
+
process.stdout.write(`\nA ${kind === "workers" ? "Cloudflare Workers" : "Node"} deployment of coffre ${version}${at === null ? " here" : ` in ${at}`}. README.md takes it from there:\n\n${at === null ? "" : ` cd ${/\s/.test(at) ? `'${at}'` : at}\n`} pnpm install\n`);
|
|
1528
|
+
}
|
|
1529
|
+
const USAGE = `coffre - secrets, with an audit log
|
|
1530
|
+
|
|
1531
|
+
New deployment
|
|
1532
|
+
coffre init --workers [<dir>] two Cloudflare Workers: the app and its vault
|
|
1533
|
+
coffre init --node [<dir>] a Node server, and its vault beside it
|
|
1534
|
+
|
|
1535
|
+
Session
|
|
1536
|
+
coffre login [<url>] [--no-browser] sign in, and make <url> the current instance
|
|
1537
|
+
coffre logout [<url>]
|
|
1538
|
+
coffre whoami
|
|
1539
|
+
coffre use [<url>] list instances, or switch the current one
|
|
1540
|
+
|
|
1541
|
+
Secrets
|
|
1542
|
+
coffre list <project>/<environment>
|
|
1543
|
+
coffre get <project>/<environment>/<KEY>
|
|
1544
|
+
coffre set <project>/<environment>/<KEY> [value] (reads stdin if omitted)
|
|
1545
|
+
coffre run <project>/<environment> -- <command>
|
|
1546
|
+
coffre export <project>/<environment> [--format dotenv|json|shell]
|
|
1547
|
+
coffre history <project>/<environment>/<KEY>
|
|
1548
|
+
coffre rollback <project>/<environment>/<KEY> <version>
|
|
1549
|
+
coffre import <project>/<environment> [--file .env] [--apply]
|
|
1550
|
+
|
|
1551
|
+
Access
|
|
1552
|
+
coffre projects
|
|
1553
|
+
coffre roles
|
|
1554
|
+
coffre access
|
|
1555
|
+
coffre grant <project> <principal> --role <role> [--env <env>] [--service]
|
|
1556
|
+
[--expires YYYY-MM-DD]
|
|
1557
|
+
coffre offboard <principal> [--service] [--apply]
|
|
1558
|
+
what removing them revokes, and what to rotate
|
|
1559
|
+
|
|
1560
|
+
Syncs (coffre sync providers for where to, and what each takes)
|
|
1561
|
+
coffre sync list <project>/<environment>
|
|
1562
|
+
coffre sync add <project>/<environment> <provider> name=value…
|
|
1563
|
+
--credential <project>/<environment>/<KEY>
|
|
1564
|
+
coffre sync run|pause|resume|remove <project>/<environment> <sync>
|
|
1565
|
+
|
|
1566
|
+
Audit
|
|
1567
|
+
coffre audit [--limit N] [--actor <id>] [--denied] [--detail]
|
|
1568
|
+
coffre verify
|
|
1569
|
+
|
|
1570
|
+
Environment (each overrides the saved session for one command)
|
|
1571
|
+
COFFRE_API_URL which instance to talk to
|
|
1572
|
+
COFFRE_TOKEN a service token (coffre_svc_…), for CI
|
|
1573
|
+
COFFRE_ACCESS_CLIENT_ID, COFFRE_ACCESS_CLIENT_SECRET
|
|
1574
|
+
a Cloudflare Access service token, for CI
|
|
1575
|
+
COFFRE_AUTH_MODE signin or cloudflare; normally detected at login
|
|
1576
|
+
`;
|
|
1577
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
1578
|
+
switch (command) {
|
|
1579
|
+
case "init":
|
|
1580
|
+
initProject(rest);
|
|
1581
|
+
break;
|
|
1582
|
+
case "login":
|
|
1583
|
+
await login(rest);
|
|
1584
|
+
break;
|
|
1585
|
+
case "logout":
|
|
1586
|
+
await logout(rest);
|
|
1587
|
+
break;
|
|
1588
|
+
case "whoami":
|
|
1589
|
+
await whoami();
|
|
1590
|
+
break;
|
|
1591
|
+
case "use":
|
|
1592
|
+
use(rest);
|
|
1593
|
+
break;
|
|
1594
|
+
case "export":
|
|
1595
|
+
await exportEnv(rest);
|
|
1596
|
+
break;
|
|
1597
|
+
case "list":
|
|
1598
|
+
await list(rest);
|
|
1599
|
+
break;
|
|
1600
|
+
case "get":
|
|
1601
|
+
await get(rest);
|
|
1602
|
+
break;
|
|
1603
|
+
case "set":
|
|
1604
|
+
await set(rest);
|
|
1605
|
+
break;
|
|
1606
|
+
case "run":
|
|
1607
|
+
await run(rest);
|
|
1608
|
+
break;
|
|
1609
|
+
case "history":
|
|
1610
|
+
await history(rest);
|
|
1611
|
+
break;
|
|
1612
|
+
case "rollback":
|
|
1613
|
+
await rollback(rest);
|
|
1614
|
+
break;
|
|
1615
|
+
case "import":
|
|
1616
|
+
await importEnv(rest);
|
|
1617
|
+
break;
|
|
1618
|
+
case "projects":
|
|
1619
|
+
await projects();
|
|
1620
|
+
break;
|
|
1621
|
+
case "roles":
|
|
1622
|
+
roles();
|
|
1623
|
+
break;
|
|
1624
|
+
case "access":
|
|
1625
|
+
await whoHasAccess();
|
|
1626
|
+
break;
|
|
1627
|
+
case "grant":
|
|
1628
|
+
await grantAccess(rest);
|
|
1629
|
+
break;
|
|
1630
|
+
case "offboard":
|
|
1631
|
+
await offboard(rest);
|
|
1632
|
+
break;
|
|
1633
|
+
case "sync":
|
|
1634
|
+
await sync(rest);
|
|
1635
|
+
break;
|
|
1636
|
+
case "audit":
|
|
1637
|
+
await audit(rest);
|
|
1638
|
+
break;
|
|
1639
|
+
case "verify":
|
|
1640
|
+
await verify();
|
|
1641
|
+
break;
|
|
1642
|
+
default:
|
|
1643
|
+
process.stdout.write(USAGE);
|
|
1644
|
+
process.exit(command === void 0 || command === "--help" ? 0 : 1);
|
|
1645
|
+
}
|
|
1646
|
+
//#endregion
|
|
1647
|
+
export {};
|