@devdogsuga/backstage 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -1
- package/dist/{catalog-BOBE4ee7.js → catalog-BuNLUu08.js} +145 -237
- package/dist/{cli-ZF4QgJro.js → cli-BI5Vn9sR.js} +16 -955
- package/dist/client-B7HhuTaA.js +429 -0
- package/dist/client-DzXQHd58.js +2 -0
- package/dist/commands-Dl3UlhBf.js +1118 -0
- package/dist/connection-D3XsOE3S.js +7 -0
- package/dist/launch.js +4 -2
- package/dist/peers-B0KDkI6a.js +229 -0
- package/dist/vault-C8LYJFOQ.js +563 -0
- package/package.json +3 -3
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { o as unwrap } from "./ui-CdKo8mLw.js";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { confirm, log, spinner } from "@clack/prompts";
|
|
5
|
+
import { execFile, spawn } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
//#region ../cli-core/src/env/document.ts
|
|
8
|
+
/**
|
|
9
|
+
* An editable `.env` that survives being edited.
|
|
10
|
+
*
|
|
11
|
+
* The root `.env` is not a data file. It is 150 lines of hard-won commentary
|
|
12
|
+
* about which value breaks the Supabase CLI when empty, which one must stay
|
|
13
|
+
* commented out, and why. A writer that parses to a Map and serializes back
|
|
14
|
+
* destroys all of it on the first save.
|
|
15
|
+
*
|
|
16
|
+
* So this keeps the file as **lines** and edits in place. An untouched line is
|
|
17
|
+
* returned byte-for-byte, including its spacing, quoting style and trailing
|
|
18
|
+
* comment. Only the lines actually being changed are rewritten.
|
|
19
|
+
*
|
|
20
|
+
* Nothing is ever deleted. Removing a key comments it out, so the value stays
|
|
21
|
+
* recoverable from the file itself, and re-adding it later uncomments that line
|
|
22
|
+
* rather than appending a duplicate.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* `KEY=value`, active. Groups: indent, `export ` prefix, key, value.
|
|
26
|
+
*
|
|
27
|
+
* The prefix is CAPTURED rather than skipped so an update can put it back.
|
|
28
|
+
* Dropping it turns `export FOO=` into `FOO=`, which still parses here and
|
|
29
|
+
* stops being exported to child processes. That shows up as a missing variable
|
|
30
|
+
* three tools downstream.
|
|
31
|
+
*/
|
|
32
|
+
const ACTIVE = /^(\s*)((?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;
|
|
33
|
+
/** `# KEY=value`, the commented form this tool writes and reads back. */
|
|
34
|
+
const COMMENTED = /^(\s*)#\s?((?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;
|
|
35
|
+
/**
|
|
36
|
+
* Reads the value half of an assignment.
|
|
37
|
+
*
|
|
38
|
+
* Deliberately NOT a general dotenv parser: it does not expand `$VAR`, because
|
|
39
|
+
* a stored value that means one thing in the file and another in the process
|
|
40
|
+
* cannot be rotated with confidence.
|
|
41
|
+
*/
|
|
42
|
+
function parseValue(rest) {
|
|
43
|
+
const text = rest.trim();
|
|
44
|
+
if (text.startsWith("\"")) {
|
|
45
|
+
const end = findClosing(text, "\"");
|
|
46
|
+
if (end === -1) return unescape(text.slice(1));
|
|
47
|
+
return unescape(text.slice(1, end));
|
|
48
|
+
}
|
|
49
|
+
if (text.startsWith("'")) {
|
|
50
|
+
const end = findClosing(text, "'");
|
|
51
|
+
if (end === -1) return text.slice(1);
|
|
52
|
+
return text.slice(1, end);
|
|
53
|
+
}
|
|
54
|
+
const hash = text.search(/\s#/);
|
|
55
|
+
return (hash === -1 ? text : text.slice(0, hash)).trim();
|
|
56
|
+
}
|
|
57
|
+
function findClosing(text, quote) {
|
|
58
|
+
for (let i = 1; i < text.length; i += 1) {
|
|
59
|
+
if (text[i] === "\\") {
|
|
60
|
+
i += 1;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (text[i] === quote) return i;
|
|
64
|
+
}
|
|
65
|
+
return -1;
|
|
66
|
+
}
|
|
67
|
+
function unescape(text) {
|
|
68
|
+
return text.replace(/\\([nrt\\"'])/g, (_, c) => c === "n" ? "\n" : c === "r" ? "\r" : c === "t" ? " " : c);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Splits everything after `=` into its value and its trailing comment.
|
|
72
|
+
*
|
|
73
|
+
* Quote-aware, because the naive "cut at the first `#`" truncates a generated
|
|
74
|
+
* password into something that still looks like one. A `#` inside quotes is
|
|
75
|
+
* part of the value; outside them it starts a comment only when whitespace
|
|
76
|
+
* precedes it.
|
|
77
|
+
*/
|
|
78
|
+
function splitComment(rest) {
|
|
79
|
+
const text = rest.trimStart();
|
|
80
|
+
let after;
|
|
81
|
+
if (text.startsWith("\"") || text.startsWith("'")) {
|
|
82
|
+
const end = findClosing(text, text[0]);
|
|
83
|
+
after = end === -1 ? text.length : end + 1;
|
|
84
|
+
} else {
|
|
85
|
+
const hash = text.search(/\s#/);
|
|
86
|
+
after = hash === -1 ? text.length : hash;
|
|
87
|
+
}
|
|
88
|
+
const tail = text.slice(after);
|
|
89
|
+
const hash = tail.indexOf("#");
|
|
90
|
+
return { comment: hash === -1 ? "" : tail.slice(hash).trimEnd() };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Recognises a stamp this tool wrote, so it is replaced rather than duplicated.
|
|
94
|
+
*
|
|
95
|
+
* Deliberately narrow: it matches the exact bracketed shape below and nothing
|
|
96
|
+
* else, because the cost of a false positive is deleting somebody's own note.
|
|
97
|
+
*/
|
|
98
|
+
const STAMP = /\s*#\s*\[[a-z][a-z0-9-]* (?:pushed|pulled) \d{4}-\d{2}-\d{2}\]\s*$/;
|
|
99
|
+
function stampText(stamp) {
|
|
100
|
+
return `# [${stamp.environment} ${stamp.action} ${stamp.date}]`;
|
|
101
|
+
}
|
|
102
|
+
function isStamp(comment) {
|
|
103
|
+
return STAMP.test(comment);
|
|
104
|
+
}
|
|
105
|
+
/** Always double-quoted, so a multi-line value round-trips through one line. */
|
|
106
|
+
function quote(value) {
|
|
107
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
|
|
108
|
+
}
|
|
109
|
+
var EnvDocument = class EnvDocument {
|
|
110
|
+
lines;
|
|
111
|
+
/** Whether this session has already appended, so it separates only once. */
|
|
112
|
+
appended = false;
|
|
113
|
+
constructor(lines) {
|
|
114
|
+
this.lines = lines;
|
|
115
|
+
}
|
|
116
|
+
static parse(text) {
|
|
117
|
+
const lines = text.split("\n").map((raw) => {
|
|
118
|
+
const active = ACTIVE.exec(raw);
|
|
119
|
+
if (active) return {
|
|
120
|
+
raw,
|
|
121
|
+
key: active[3],
|
|
122
|
+
value: parseValue(active[4])
|
|
123
|
+
};
|
|
124
|
+
const commented = COMMENTED.exec(raw);
|
|
125
|
+
if (commented) return {
|
|
126
|
+
raw,
|
|
127
|
+
key: commented[3],
|
|
128
|
+
value: parseValue(commented[4]),
|
|
129
|
+
commented: true
|
|
130
|
+
};
|
|
131
|
+
return { raw };
|
|
132
|
+
});
|
|
133
|
+
return new EnvDocument(lines);
|
|
134
|
+
}
|
|
135
|
+
static empty() {
|
|
136
|
+
return new EnvDocument([]);
|
|
137
|
+
}
|
|
138
|
+
find(key, commented) {
|
|
139
|
+
return this.lines.findIndex((l) => l.key === key && Boolean(l.commented) === commented);
|
|
140
|
+
}
|
|
141
|
+
/** The active value, or undefined when absent or commented out. */
|
|
142
|
+
get(key) {
|
|
143
|
+
const i = this.find(key, false);
|
|
144
|
+
return i === -1 ? void 0 : this.lines[i].value;
|
|
145
|
+
}
|
|
146
|
+
/** Present and active. */
|
|
147
|
+
has(key) {
|
|
148
|
+
return this.find(key, false) !== -1;
|
|
149
|
+
}
|
|
150
|
+
/** Present, but commented out. */
|
|
151
|
+
isCommented(key) {
|
|
152
|
+
return this.find(key, false) === -1 && this.find(key, true) !== -1;
|
|
153
|
+
}
|
|
154
|
+
/** Every active assignment, in file order. */
|
|
155
|
+
entries() {
|
|
156
|
+
return this.lines.filter((l) => l.key !== void 0 && !l.commented).map((l) => [l.key, l.value]);
|
|
157
|
+
}
|
|
158
|
+
keys() {
|
|
159
|
+
return this.entries().map(([k]) => k);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Sets a value, preferring to revive a commented line over appending.
|
|
163
|
+
*
|
|
164
|
+
* Appending when a commented form already exists is how a file ends up with
|
|
165
|
+
* the same key twice: one stale, both plausible, and which one looks
|
|
166
|
+
* authoritative depends on where the reader scrolled to.
|
|
167
|
+
*/
|
|
168
|
+
set(key, value, stamp) {
|
|
169
|
+
const active = this.find(key, false);
|
|
170
|
+
if (active !== -1) {
|
|
171
|
+
const line = this.lines[active];
|
|
172
|
+
const match = ACTIVE.exec(line.raw);
|
|
173
|
+
const existing = splitComment(match[4]).comment;
|
|
174
|
+
if (stamp && existing !== "" && !isStamp(existing)) this.lines.splice(this.first(key), 0, { raw: existing });
|
|
175
|
+
const trailing = stamp ? ` ${stampText(stamp)}` : existing === "" ? "" : ` ${existing}`;
|
|
176
|
+
const at = this.find(key, false);
|
|
177
|
+
const target = this.lines[at];
|
|
178
|
+
target.raw = `${match[1]}${match[2]}${key}=${quote(value)}${trailing}`;
|
|
179
|
+
target.value = value;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const commented = this.find(key, true);
|
|
183
|
+
if (commented !== -1) {
|
|
184
|
+
const line = this.lines[commented];
|
|
185
|
+
line.raw = `${key}=${quote(value)}${stamp ? ` ${stampText(stamp)}` : ""}`;
|
|
186
|
+
line.value = value;
|
|
187
|
+
line.commented = false;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!this.appended && this.lines.length > 0 && this.lines.at(-1).raw !== "") this.lines.push({ raw: "" });
|
|
191
|
+
this.appended = true;
|
|
192
|
+
this.lines.push({
|
|
193
|
+
raw: `${key}=${quote(value)}${stamp ? ` ${stampText(stamp)}` : ""}`,
|
|
194
|
+
key,
|
|
195
|
+
value
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
/** Index of the first line mentioning a key, active or commented. */
|
|
199
|
+
first(key) {
|
|
200
|
+
return this.lines.findIndex((l) => l.key === key);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Moves every line for a key next to that key's first appearance.
|
|
204
|
+
*
|
|
205
|
+
* Files drift: a key gets commented out near the bottom, re-added at the top
|
|
206
|
+
* six weeks later, and now two lines claim the same name a hundred lines
|
|
207
|
+
* apart. Whichever one a reader scrolls to first looks authoritative.
|
|
208
|
+
*
|
|
209
|
+
* The FIRST occurrence keeps its position, so the standalone comment block
|
|
210
|
+
* documenting a key stays attached to it. Everything else moves up to join
|
|
211
|
+
* it, in the order it already had.
|
|
212
|
+
*/
|
|
213
|
+
group() {
|
|
214
|
+
const out = [];
|
|
215
|
+
const taken = /* @__PURE__ */ new Set();
|
|
216
|
+
let moved = false;
|
|
217
|
+
for (let i = 0; i < this.lines.length; i += 1) {
|
|
218
|
+
if (taken.has(i)) continue;
|
|
219
|
+
const line = this.lines[i];
|
|
220
|
+
out.push(line);
|
|
221
|
+
taken.add(i);
|
|
222
|
+
if (line.key === void 0) continue;
|
|
223
|
+
for (let j = i + 1; j < this.lines.length; j += 1) {
|
|
224
|
+
if (taken.has(j) || this.lines[j].key !== line.key) continue;
|
|
225
|
+
if (j !== i + 1 || taken.has(i + 1)) moved = true;
|
|
226
|
+
out.push(this.lines[j]);
|
|
227
|
+
taken.add(j);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
this.lines = out;
|
|
231
|
+
return moved;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Blanks every active value without losing one.
|
|
235
|
+
*
|
|
236
|
+
* Each becomes a commented line holding what it was, plus an empty active
|
|
237
|
+
* line under it. The file still declares every key it needs, which is what
|
|
238
|
+
* makes it a usable checklist, while holding nothing.
|
|
239
|
+
*
|
|
240
|
+
* Already-empty keys are skipped. Commenting out `FOO=""` to write `FOO=""`
|
|
241
|
+
* underneath is churn that makes the next diff harder to read.
|
|
242
|
+
*/
|
|
243
|
+
reset() {
|
|
244
|
+
const out = [];
|
|
245
|
+
const cleared = [];
|
|
246
|
+
for (const line of this.lines) {
|
|
247
|
+
if (line.key === void 0 || line.commented || line.value === "") {
|
|
248
|
+
out.push(line);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const exported = ACTIVE.exec(line.raw)?.[2] ?? "";
|
|
252
|
+
line.raw = `# ${line.raw.trimStart()}`;
|
|
253
|
+
line.commented = true;
|
|
254
|
+
out.push(line);
|
|
255
|
+
out.push({
|
|
256
|
+
raw: `${exported}${line.key}=""`,
|
|
257
|
+
key: line.key,
|
|
258
|
+
value: ""
|
|
259
|
+
});
|
|
260
|
+
cleared.push(line.key);
|
|
261
|
+
}
|
|
262
|
+
this.lines = out;
|
|
263
|
+
return cleared;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Comments a key out rather than deleting it.
|
|
267
|
+
*
|
|
268
|
+
* A no-op when it is already commented or absent, so running a sync twice
|
|
269
|
+
* cannot produce `## KEY=`.
|
|
270
|
+
*/
|
|
271
|
+
comment(key) {
|
|
272
|
+
const i = this.find(key, false);
|
|
273
|
+
if (i === -1) return false;
|
|
274
|
+
const line = this.lines[i];
|
|
275
|
+
line.raw = `# ${line.raw.trimStart()}`;
|
|
276
|
+
line.commented = true;
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
/** Restores a commented assignment. Returns its value, or undefined. */
|
|
280
|
+
uncomment(key) {
|
|
281
|
+
const i = this.find(key, true);
|
|
282
|
+
if (i === -1) return void 0;
|
|
283
|
+
const line = this.lines[i];
|
|
284
|
+
line.raw = line.raw.replace(/^(\s*)#\s?/, "$1");
|
|
285
|
+
line.commented = false;
|
|
286
|
+
return line.value;
|
|
287
|
+
}
|
|
288
|
+
toString() {
|
|
289
|
+
return this.lines.map((l) => l.raw).join("\n");
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
//#endregion
|
|
293
|
+
//#region src/bws/bw.ts
|
|
294
|
+
/**
|
|
295
|
+
* Where the bundled Bitwarden CLI (`bw`) is, for the vault code that signs in
|
|
296
|
+
* and reads the Secrets Manager token (`vault.ts`).
|
|
297
|
+
*
|
|
298
|
+
* There is no `bw` command of ours: `env` runs `bw login` and `bw unlock`
|
|
299
|
+
* itself when it needs the token. The binary is still resolved from
|
|
300
|
+
* `@bitwarden/cli`, never looked up on PATH. A dependency's bin is only linked
|
|
301
|
+
* into the `node_modules/.bin` of the package that depends on it, so under
|
|
302
|
+
* `pnpm dlx` nothing puts `bw` on PATH and a bare `spawn("bw")` is ENOENT.
|
|
303
|
+
*/
|
|
304
|
+
/**
|
|
305
|
+
* The `[command, args]` pair that runs the bundled Bitwarden CLI.
|
|
306
|
+
*
|
|
307
|
+
* Runs `build/bw.js` under this Node rather than through a shim, so it works
|
|
308
|
+
* the same wherever backstage is installed. Falls back to `bw` on PATH when the
|
|
309
|
+
* package cannot be resolved, which keeps a missing install surfacing as the
|
|
310
|
+
* ENOENT every caller already handles.
|
|
311
|
+
*/
|
|
312
|
+
function bwCommand(args) {
|
|
313
|
+
try {
|
|
314
|
+
const require_ = createRequire(import.meta.url);
|
|
315
|
+
const pkgPath = require_.resolve("@bitwarden/cli/package.json");
|
|
316
|
+
const pkg = require_(pkgPath);
|
|
317
|
+
return [process.execPath, [join(dirname(pkgPath), pkg.bin.bw), ...args]];
|
|
318
|
+
} catch {
|
|
319
|
+
return ["bw", args];
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
//#endregion
|
|
323
|
+
//#region src/bws/vault.ts
|
|
324
|
+
/**
|
|
325
|
+
* The Bitwarden **Password Manager** vault, via the `bw` CLI.
|
|
326
|
+
*
|
|
327
|
+
* A different product from Secrets Manager and a different CLI, which is the
|
|
328
|
+
* point: `bws` has no user authentication at all, so the access token it needs
|
|
329
|
+
* has to be held somewhere a person can log in to. That somewhere is the
|
|
330
|
+
* Password Manager vault, and this module reads it back rather than making
|
|
331
|
+
* somebody paste a token every session.
|
|
332
|
+
*
|
|
333
|
+
* Everything here degrades to `undefined`. A missing `bw`, a locked vault, a
|
|
334
|
+
* declined unlock, a missing item: none of them are errors, because each has a
|
|
335
|
+
* good fallback, which is to ask. Throwing would turn a convenience into a
|
|
336
|
+
* prerequisite.
|
|
337
|
+
*
|
|
338
|
+
* ⚠️ The token never passes through argv in either direction. Reads use
|
|
339
|
+
* `bw get password <search>`, which puts only the item NAME on the command
|
|
340
|
+
* line, and writes pipe base64 JSON through **stdin**, which `bw create item`
|
|
341
|
+
* documents as an accepted input.
|
|
342
|
+
*/
|
|
343
|
+
const run = promisify(execFile);
|
|
344
|
+
/**
|
|
345
|
+
* The item this looks for, and creates.
|
|
346
|
+
*
|
|
347
|
+
* Named for what it unlocks rather than for this tool, because whoever finds it
|
|
348
|
+
* in the vault six months from now needs to know what it is, not which script
|
|
349
|
+
* wrote it.
|
|
350
|
+
*/
|
|
351
|
+
const VAULT_ITEM_NAME = "DevDogs Secrets Manager access token (admin)";
|
|
352
|
+
/** `bw status`, or `unavailable` when the CLI is not installed. */
|
|
353
|
+
async function vaultStatus() {
|
|
354
|
+
try {
|
|
355
|
+
const { stdout } = await run(...bwCommand(bwArgs(["status", "--response"])), { shell: false });
|
|
356
|
+
const parsed = JSON.parse(stdout);
|
|
357
|
+
const status = ("status" in parsed ? parsed.status : void 0) ?? ("data" in parsed ? parsed.data?.template?.status : void 0);
|
|
358
|
+
if (status === "unlocked") return "unlocked";
|
|
359
|
+
if (status === "locked") return "locked";
|
|
360
|
+
return "unauthenticated";
|
|
361
|
+
} catch (err) {
|
|
362
|
+
if (err.code === "ENOENT") return "unavailable";
|
|
363
|
+
return "unauthenticated";
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Offers to sign in to the vault when it is not, and does it with the bundled
|
|
368
|
+
* `bw` (`bw login`, interactive). There is no `bw` command of ours to run by
|
|
369
|
+
* hand any more: `env` signs in and unlocks for itself, at the moment it needs
|
|
370
|
+
* the Secrets Manager token. Returns the status afterwards.
|
|
371
|
+
*
|
|
372
|
+
* Asks first, like the unlock below: a master-password prompt that appears
|
|
373
|
+
* unannounced is shaped exactly like the thing people are told never to type
|
|
374
|
+
* their master password into. Without a terminal there is nobody to sign in,
|
|
375
|
+
* so the status comes back unchanged.
|
|
376
|
+
*/
|
|
377
|
+
async function signInIfNeeded(status, purpose) {
|
|
378
|
+
if (status !== "unauthenticated" || !process.stdin.isTTY) return status;
|
|
379
|
+
if (!unwrap(await confirm({
|
|
380
|
+
message: `You are not signed in to Bitwarden. Sign in now to ${purpose}?`,
|
|
381
|
+
initialValue: true
|
|
382
|
+
}))) return status;
|
|
383
|
+
if (await new Promise((resolve) => {
|
|
384
|
+
const child = spawn(...bwCommand(["login"]), {
|
|
385
|
+
stdio: "inherit",
|
|
386
|
+
shell: false
|
|
387
|
+
});
|
|
388
|
+
child.on("error", () => resolve(null));
|
|
389
|
+
child.on("close", (exit) => resolve(exit));
|
|
390
|
+
}) !== 0) return status;
|
|
391
|
+
return vaultStatus();
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* A usable session key, unlocking if the person agrees.
|
|
395
|
+
*
|
|
396
|
+
* `BW_SESSION` is preferred and silent. Otherwise this ASKS before unlocking:
|
|
397
|
+
* a tool that pops a master-password prompt unannounced is shaped exactly like
|
|
398
|
+
* the thing people are told never to type their master password into.
|
|
399
|
+
*
|
|
400
|
+
* The password is typed straight into `bw`. stdin is inherited, so it never
|
|
401
|
+
* passes through this process.
|
|
402
|
+
*
|
|
403
|
+
* The key is kept for the rest of the process once unlocked: `creds` makes a
|
|
404
|
+
* dozen `bw` calls in one run, and asking for the master password before each
|
|
405
|
+
* would be both tiresome and a reason to stop reading the prompt.
|
|
406
|
+
*/
|
|
407
|
+
let unlockedKey;
|
|
408
|
+
async function session(status, purpose) {
|
|
409
|
+
if (process.env.BW_SESSION) return process.env.BW_SESSION;
|
|
410
|
+
if (unlockedKey) return unlockedKey;
|
|
411
|
+
if (status !== "locked") return void 0;
|
|
412
|
+
if (!process.stdin.isTTY) return void 0;
|
|
413
|
+
if (!unwrap(await confirm({
|
|
414
|
+
message: `Your Bitwarden vault is locked. Unlock it to ${purpose}?`,
|
|
415
|
+
initialValue: true
|
|
416
|
+
}))) return void 0;
|
|
417
|
+
unlockedKey = await new Promise((resolve) => {
|
|
418
|
+
const child = spawn(...bwCommand(["unlock", "--raw"]), {
|
|
419
|
+
stdio: [
|
|
420
|
+
"inherit",
|
|
421
|
+
"pipe",
|
|
422
|
+
"inherit"
|
|
423
|
+
],
|
|
424
|
+
shell: false
|
|
425
|
+
});
|
|
426
|
+
let out = "";
|
|
427
|
+
child.stdout.on("data", (c) => out += c.toString());
|
|
428
|
+
child.on("error", () => resolve(void 0));
|
|
429
|
+
child.on("close", (code) => resolve(code === 0 && out.trim() !== "" ? out.trim() : void 0));
|
|
430
|
+
});
|
|
431
|
+
return unlockedKey;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* A vault ready for `bw` calls, signing in and unlocking (after asking) as
|
|
435
|
+
* needed, or `undefined` when it cannot be used. `key` is the session to pass
|
|
436
|
+
* to {@link bwArgs}; it is absent only when `bw` itself reports the vault
|
|
437
|
+
* unlocked without one.
|
|
438
|
+
*
|
|
439
|
+
* `purpose` finishes the questions: "Unlock it to <purpose>?".
|
|
440
|
+
*/
|
|
441
|
+
async function openVault(purpose) {
|
|
442
|
+
const status = await signInIfNeeded(await vaultStatus(), purpose);
|
|
443
|
+
if (status === "unavailable" || status === "unauthenticated") return;
|
|
444
|
+
const key = await session(status, purpose);
|
|
445
|
+
if (status === "locked" && !key) return void 0;
|
|
446
|
+
return { key };
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Adds the session key, and `--nointeraction` always.
|
|
450
|
+
*
|
|
451
|
+
* The second one is not belt-and-braces. `bw` prompts for a master password on
|
|
452
|
+
* stdin whenever the vault is locked, as `bw get template item` does against a
|
|
453
|
+
* locked vault, and this module runs `bw` with piped stdio, where that prompt
|
|
454
|
+
* has nobody to answer it and hangs until something gives up. `--nointeraction`
|
|
455
|
+
* turns that into an immediate non-zero exit, which every caller here already
|
|
456
|
+
* treats as "not available, ask instead".
|
|
457
|
+
*/
|
|
458
|
+
function bwArgs(args, key) {
|
|
459
|
+
const withNoInteraction = [...args, "--nointeraction"];
|
|
460
|
+
return key ? [
|
|
461
|
+
...withNoInteraction,
|
|
462
|
+
"--session",
|
|
463
|
+
key
|
|
464
|
+
] : withNoInteraction;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* The stored token, or `undefined` for every reason it might not be there.
|
|
468
|
+
*
|
|
469
|
+
* `bw get password` takes a search term and fails when it matches more than one
|
|
470
|
+
* item, which is the behaviour worth having: two items called something like
|
|
471
|
+
* this means somebody should look, not that this should guess.
|
|
472
|
+
*/
|
|
473
|
+
async function readTokenFromVault() {
|
|
474
|
+
return readPasswordFromVault(VAULT_ITEM_NAME, "the access token", "look for the access token", "BWS_ACCESS_TOKEN");
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* The password of the one vault item named `itemName`, or `undefined` for
|
|
478
|
+
* every reason it might not be there. `what` names it in the spinner; `purpose`
|
|
479
|
+
* finishes the sign-in and unlock questions.
|
|
480
|
+
*/
|
|
481
|
+
async function readPasswordFromVault(itemName, what, purpose, envVar) {
|
|
482
|
+
const status = await signInIfNeeded(await vaultStatus(), purpose);
|
|
483
|
+
if (status === "unavailable" || status === "unauthenticated") {
|
|
484
|
+
log.info(explainVault(status, envVar));
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const key = await session(status, purpose);
|
|
488
|
+
if (status === "locked" && !key) return void 0;
|
|
489
|
+
const s = spinner();
|
|
490
|
+
s.start("Looking in your Bitwarden vault");
|
|
491
|
+
try {
|
|
492
|
+
const { stdout } = await run(...bwCommand(bwArgs([
|
|
493
|
+
"get",
|
|
494
|
+
"password",
|
|
495
|
+
itemName,
|
|
496
|
+
"--raw"
|
|
497
|
+
], key)), { shell: false });
|
|
498
|
+
const token = stdout.trim();
|
|
499
|
+
if (token === "") {
|
|
500
|
+
s.stop("Nothing stored in the vault yet");
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
s.stop(`Read ${what} from your vault ("${itemName}")`);
|
|
504
|
+
return token;
|
|
505
|
+
} catch {
|
|
506
|
+
s.stop("No single matching item in your vault");
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Creates the item, with the token arriving on **stdin** as base64 JSON.
|
|
512
|
+
*
|
|
513
|
+
* `bw create item` documents an encoded-JSON positional AND stdin. Using stdin
|
|
514
|
+
* is the difference between a live credential that is invisible and one that
|
|
515
|
+
* sits in `ps` output for the length of the call.
|
|
516
|
+
*/
|
|
517
|
+
async function saveTokenToVault(token) {
|
|
518
|
+
const purpose = "save the access token";
|
|
519
|
+
const status = await signInIfNeeded(await vaultStatus(), purpose);
|
|
520
|
+
if (status === "unavailable" || status === "unauthenticated") return false;
|
|
521
|
+
const key = await session(status, purpose);
|
|
522
|
+
if (status === "locked" && !key) return false;
|
|
523
|
+
const item = {
|
|
524
|
+
organizationId: null,
|
|
525
|
+
collectionIds: null,
|
|
526
|
+
folderId: null,
|
|
527
|
+
type: 1,
|
|
528
|
+
name: VAULT_ITEM_NAME,
|
|
529
|
+
notes: "Bitwarden Secrets Manager access token for the `admin` machine account, read/write on preflight, staging and production.\n\nRead automatically by `backstage env`. Never put this in a .env file: it unlocks all three projects, and the tool refuses to upload it.",
|
|
530
|
+
favorite: false,
|
|
531
|
+
reprompt: 0,
|
|
532
|
+
login: {
|
|
533
|
+
username: null,
|
|
534
|
+
password: token,
|
|
535
|
+
totp: null
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
return new Promise((resolve) => {
|
|
539
|
+
const child = spawn(...bwCommand(bwArgs(["create", "item"], key)), {
|
|
540
|
+
stdio: [
|
|
541
|
+
"pipe",
|
|
542
|
+
"ignore",
|
|
543
|
+
"pipe"
|
|
544
|
+
],
|
|
545
|
+
shell: false
|
|
546
|
+
});
|
|
547
|
+
let stderr = "";
|
|
548
|
+
child.stderr.on("data", (c) => stderr += c.toString());
|
|
549
|
+
child.on("error", () => resolve(false));
|
|
550
|
+
child.on("close", (code) => {
|
|
551
|
+
if (code !== 0 && stderr.trim() !== "") log.warn(stderr.trim());
|
|
552
|
+
resolve(code === 0);
|
|
553
|
+
});
|
|
554
|
+
child.stdin.end(Buffer.from(JSON.stringify(item)).toString("base64"));
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
/** Why the vault could not be used, phrased as something to do about it. */
|
|
558
|
+
function explainVault(status, envVar = "BWS_ACCESS_TOKEN") {
|
|
559
|
+
if (status === "unavailable") return "The `bw` CLI is not installed, so the vault could not be checked. It ships with backstage, so reinstall it.";
|
|
560
|
+
if (status === "unauthenticated") return `You are not signed in to Bitwarden, and with no terminal there is nobody to sign in. Set ${envVar} instead.`;
|
|
561
|
+
}
|
|
562
|
+
//#endregion
|
|
563
|
+
export { readTokenFromVault as a, EnvDocument as c, readPasswordFromVault as i, bwArgs as n, saveTokenToVault as o, openVault as r, bwCommand as s, VAULT_ITEM_NAME as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devdogsuga/backstage",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Officer and production CLI for DevDogsUGA: deploys, env sync with Bitwarden and GitHub, the migration planner role, club graphics and QR codes, GitHub rulesets and settings, and the newsletter. Ships built JS; run it anywhere with `pnpm dlx @devdogsuga/backstage` (no checkout needed to start; commands that read a checkout say so).",
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"tsx": "^4.23.12",
|
|
41
41
|
"typescript": "^6.0.3",
|
|
42
42
|
"zod": "4.4.3",
|
|
43
|
+
"@devdogsuga/telemetry": "0.1.3",
|
|
43
44
|
"@devdogsuga/brand": "0.1.5",
|
|
44
45
|
"@devdogsuga/events": "0.1.5",
|
|
45
|
-
"@devdogsuga/telemetry": "0.1.3",
|
|
46
46
|
"@devdogsuga/newsletter": "0.1.9"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
"tsdown": "^0.23.0",
|
|
66
66
|
"vitest": "^4.1.11",
|
|
67
67
|
"@devdogsuga/cli-core": "0.0.0",
|
|
68
|
-
"@devdogsuga/config": "0.1.2",
|
|
69
68
|
"@devdogsuga/db": "0.1.3",
|
|
69
|
+
"@devdogsuga/config": "0.1.2",
|
|
70
70
|
"@devdogsuga/env": "0.1.5"
|
|
71
71
|
},
|
|
72
72
|
"scripts": {
|