@devdogsuga/backstage 0.1.3 → 0.1.5

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.
@@ -0,0 +1,429 @@
1
+ import { w as findRepoRoot } from "./telemetry-Bjoz29Hl.js";
2
+ import { n as loadEnv } from "./peers-B0KDkI6a.js";
3
+ import { o as unwrap } from "./ui-CdKo8mLw.js";
4
+ import { a as readTokenFromVault, c as EnvDocument, o as saveTokenToVault, t as VAULT_ITEM_NAME } from "./vault-C8LYJFOQ.js";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { log, password, select, text } from "@clack/prompts";
7
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
8
+ import { homedir } from "node:os";
9
+ //#region src/bws/token.ts
10
+ /**
11
+ * Finding the Secrets Manager access token, in four places, in order.
12
+ *
13
+ * 1. `--access-token` explicit, and explicit beats ambient
14
+ * 2. `BWS_ACCESS_TOKEN` the environment, which includes your `.env`,
15
+ * since `with-env` loads it for every command
16
+ * 3. the Bitwarden Password Manager vault, via the bundled `bw`
17
+ * (`env` signs in and unlocks it for itself)
18
+ * 4. ask, and offer to save it to `.env` (the default) or the vault, so
19
+ * this is the last time
20
+ *
21
+ * The ordering logic is separated from the four implementations because it is
22
+ * the part that can be wrong invisibly. A chain that silently prefers a stale
23
+ * environment variable over a rotated vault entry authenticates as the wrong
24
+ * account and reports nothing, so the order is asserted rather than assumed.
25
+ */
26
+ var NoAccessTokenError = class extends Error {};
27
+ async function resolveToken(sources) {
28
+ const announce = sources.onSource ?? (() => void 0);
29
+ if (sources.explicit) {
30
+ announce("flag");
31
+ return sources.explicit;
32
+ }
33
+ if (sources.env) {
34
+ announce("environment");
35
+ return sources.env;
36
+ }
37
+ const stored = await sources.fromVault();
38
+ if (stored) {
39
+ announce("vault");
40
+ return stored;
41
+ }
42
+ const typed = await sources.prompt();
43
+ if (!typed) throw new NoAccessTokenError("No access token. Put BWS_ACCESS_TOKEN in your .env (an interactive run offers to do this for you), pass --access-token, or store one in your Bitwarden vault. See docs/toolkit/guides/env/commands.md.");
44
+ announce("prompt");
45
+ if (await sources.offerSave()) try {
46
+ await sources.save(typed);
47
+ } catch (err) {
48
+ log.warn(`Could not save to your vault: ${err instanceof Error ? err.message : String(err)}. Continuing with the token you typed.`);
49
+ }
50
+ return typed;
51
+ }
52
+ /**
53
+ * A weak shape check, to turn a paste error into a sentence rather than a 401.
54
+ *
55
+ * Machine account tokens are `0.<uuid>.<secret>:<secret>`. This checks the
56
+ * leading version segment only, and **warns rather than refuses**: the format
57
+ * is Bitwarden's to change, and a tool that rejects a valid token because its
58
+ * prefix moved is worse than one that passes a bad token to `bws` and lets the
59
+ * real error surface.
60
+ */
61
+ function looksLikeAccessToken(value) {
62
+ return /^\d+\.[0-9a-f-]{36}\./i.test(value.trim());
63
+ }
64
+ /** The interactive half. Masked, and never echoed back. */
65
+ async function promptForToken() {
66
+ if (!process.stdin.isTTY) return void 0;
67
+ const typed = unwrap(await password({
68
+ message: "Paste the Secrets Manager access token for the `admin` account",
69
+ mask: "•",
70
+ validate: (v) => (v ?? "").trim() === "" ? "An access token is required." : void 0
71
+ })).trim();
72
+ if (!looksLikeAccessToken(typed)) log.warn("That does not look like a machine account access token (they start `0.<uuid>.`). Continuing anyway — if it is wrong, Bitwarden will say so.");
73
+ return typed;
74
+ }
75
+ //#endregion
76
+ //#region src/bws/retry.ts
77
+ /**
78
+ * Retrying exactly one failure class: HTTP 429, rate limiting.
79
+ *
80
+ * A 429 is the server saying "correct request, wrong minute", the only error
81
+ * where trying the same thing again IS the fix. Everything else (bad token,
82
+ * missing project, malformed input) re-throws untouched on the first attempt,
83
+ * because retrying those just triples the time to the real message.
84
+ *
85
+ * Written for the Secrets Manager calls, where the minute matters: a push is
86
+ * ~45 sequential writes plus a login, several targets get pushed back-to-back,
87
+ * and Bitwarden's identity endpoint rate-limits repeated access-token logins
88
+ * aggressively. The login half is also fixed at the source, since the client
89
+ * now caches its login in a state file, so the retry is the belt on that
90
+ * suspender.
91
+ */
92
+ function isRateLimited(err) {
93
+ const message = err instanceof Error ? err.message : String(err);
94
+ return /\b429\b|too many requests|rate.?limit/i.test(message);
95
+ }
96
+ const wait$1 = (ms) => new Promise((r) => setTimeout(r, ms));
97
+ async function withRateLimitRetry(op, { retries = 3, baseMs = 5e3, sleep = wait$1, doing }) {
98
+ for (let attempt = 0;; attempt++) try {
99
+ return await op();
100
+ } catch (err) {
101
+ if (!isRateLimited(err) || attempt >= retries) throw err;
102
+ const ms = baseMs * 2 ** attempt;
103
+ log.warn(`Bitwarden rate-limited ${doing} (HTTP 429). Waiting ${ms / 1e3}s and retrying (${attempt + 1}/${retries})…`);
104
+ await sleep(ms);
105
+ }
106
+ }
107
+ //#endregion
108
+ //#region src/bws/pace.ts
109
+ const WRITE_GAP_MS = 1100;
110
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
111
+ function makePacer(now = Date.now, sleep = wait) {
112
+ let notBefore = 0;
113
+ let chain = Promise.resolve();
114
+ return (kind) => {
115
+ const gap = kind === "write" ? WRITE_GAP_MS : 350;
116
+ const turn = chain.then(async () => {
117
+ const pause = notBefore - now();
118
+ if (pause > 0) await sleep(pause);
119
+ notBefore = now() + gap;
120
+ });
121
+ chain = turn.catch(() => void 0);
122
+ return turn;
123
+ };
124
+ }
125
+ //#endregion
126
+ //#region src/bws/store.ts
127
+ /**
128
+ * Saving a prompted value into the developer's own `.env`.
129
+ *
130
+ * The development file only, by construction: the path is fixed to
131
+ * `fileFor("development")`, so no caller can point this at a vault target's
132
+ * file. Those are what `env push` uploads, and the values saved here
133
+ * (`BWS_ACCESS_TOKEN`, `BWS_ORG_ID`) exist so they never ride a push. The
134
+ * token is refused there by name anyway; this keeps the write from ever being
135
+ * the thing that needs refusing.
136
+ *
137
+ * `EnvDocument.set` revives the commented line the rendered file already
138
+ * carries (never-store keys ship as `# KEY=""` with their documentation), so a
139
+ * saved value lands under its own doc comment rather than appended bare.
140
+ */
141
+ async function saveToDevEnv(key, value) {
142
+ const { fileFor } = await loadEnv();
143
+ const path = resolve(findRepoRoot(), fileFor("development"));
144
+ let doc;
145
+ try {
146
+ doc = EnvDocument.parse(await readFile(path, "utf8"));
147
+ } catch {
148
+ doc = EnvDocument.empty();
149
+ }
150
+ doc.set(key, value);
151
+ await writeFile(path, doc.toString());
152
+ }
153
+ //#endregion
154
+ //#region src/bws/client.ts
155
+ /**
156
+ * Secrets Manager, through the official SDK. No `bws` binary to install.
157
+ *
158
+ * This wrapped the `bws` CLI until 2026-08-19. The CLI was the right call
159
+ * against the raw REST API: Secrets Manager is end-to-end encrypted, the
160
+ * server stores ciphertext, and the client derives the key that opens it from
161
+ * the access token, so `fetch` returns blobs and reimplementing the crypto is
162
+ * not a trade worth making. `@bitwarden/sdk-napi` is that same client-side
163
+ * crypto (the same Rust core the CLI wraps), loaded in-process, which buys two
164
+ * things the CLI could not:
165
+ *
166
+ * * **Nothing to install.** The SDK is a dependency of this package, so
167
+ * `pnpm install` is the whole setup. No "install bws from the releases
168
+ * page" step, and no version somebody's laptop drifted on.
169
+ * * **No credential in argv.** `bws secret create` took the VALUE as a
170
+ * positional argument, visible to `ps` for the length of the call, a
171
+ * documented property of the tool this wrapper could only apologize for.
172
+ * In-process values never touch a process table.
173
+ *
174
+ * ⚠️ Imported LAZILY, at the first real call. The SDK is a native module, and
175
+ * loading it at import time would tax every `cli:no-env` path, the CI guards
176
+ * included, with a `.node` binary none of them use.
177
+ *
178
+ * The one thing the SDK needs that the CLI did not: the ORGANIZATION ID. The
179
+ * CLI derived it from the access token's login response; the SDK's every list
180
+ * and create takes it as an argument, and nothing in its API discovers it. It
181
+ * is a public identifier (a UUID that confers nothing), read from `BWS_ORG_ID`.
182
+ * See the declaration in `packages/devtools/env.ts`.
183
+ */
184
+ var BwsError = class extends Error {};
185
+ /**
186
+ * One pacer for the process: every Secrets Manager request funnels through
187
+ * it, spaced under the published per-IP limits so the 429 retry stays a
188
+ * backstop instead of the plan. See `pace.ts` for the numbers and sources.
189
+ */
190
+ const pace = makePacer();
191
+ let explicitToken;
192
+ let resolved;
193
+ /** Where a just-typed token goes, picked in `offerSave`, used in `save`. */
194
+ let saveDestination = "no";
195
+ /** Records `--access-token`, before any command runs. */
196
+ function setExplicitAccessToken(token) {
197
+ explicitToken = token;
198
+ resolved = void 0;
199
+ }
200
+ /**
201
+ * The access token, found once per process.
202
+ *
203
+ * Memoized as a **promise**, not a value: resolution can prompt, and a single
204
+ * command makes several Secrets Manager calls. Caching the value alone would
205
+ * still let two concurrent calls open two prompts over each other.
206
+ *
207
+ * Whatever the source, the token reaches the SDK as a function argument in
208
+ * this process, never argv, never a child's environment.
209
+ */
210
+ async function accessToken() {
211
+ resolved ??= resolveToken({
212
+ explicit: explicitToken,
213
+ env: process.env.BWS_ACCESS_TOKEN,
214
+ fromVault: readTokenFromVault,
215
+ prompt: promptForToken,
216
+ offerSave: async () => {
217
+ saveDestination = unwrap(await select({
218
+ message: "Save it, so this is the last time you paste it?",
219
+ options: [
220
+ {
221
+ value: "env",
222
+ label: "yes — into .env",
223
+ hint: "gitignored, this machine only; push refuses it by name"
224
+ },
225
+ {
226
+ value: "vault",
227
+ label: "yes — into my Bitwarden vault",
228
+ hint: "needs `pnpm devtools bw login`; follows you across machines"
229
+ },
230
+ {
231
+ value: "no",
232
+ label: "no — ask me again next time"
233
+ }
234
+ ],
235
+ initialValue: "env"
236
+ }));
237
+ return saveDestination !== "no";
238
+ },
239
+ save: async (token) => {
240
+ if (saveDestination === "env") {
241
+ await saveToDevEnv("BWS_ACCESS_TOKEN", token);
242
+ log.success("Stored in .env — with-env loads it on every run.");
243
+ return;
244
+ }
245
+ if (await saveTokenToVault(token)) log.success(`Stored as "${VAULT_ITEM_NAME}" in your Bitwarden vault.`);
246
+ else log.warn("Could not save it. The command will continue with the token you typed; nothing was written to your vault.");
247
+ },
248
+ onSource: (source) => {
249
+ if (source === "flag") log.warn("--access-token puts a live credential in argv, where `ps` can read it for the length of the call, and in your shell history. Prefer BWS_ACCESS_TOKEN or the vault.");
250
+ }
251
+ }).catch((err) => {
252
+ resolved = void 0;
253
+ throw err instanceof NoAccessTokenError ? new BwsError(err.message) : err;
254
+ });
255
+ return resolved;
256
+ }
257
+ let resolvedOrgId;
258
+ /**
259
+ * The organization the machine account belongs to: the environment, else a
260
+ * prompt with an offer to save.
261
+ *
262
+ * Prompted rather than only refused because it is the one Secrets Manager
263
+ * input with nothing secret about it: a public UUID that identifies without
264
+ * authorizing. Where nobody can answer (a pipe), the named refusal below is
265
+ * the answer, because an SDK error about a malformed UUID would send somebody
266
+ * debugging the token, which is the one thing that is fine.
267
+ */
268
+ function organizationId() {
269
+ resolvedOrgId ??= (async () => {
270
+ const fromEnv = process.env.BWS_ORG_ID;
271
+ if (fromEnv) return fromEnv;
272
+ if (!process.stdin.isTTY) throw new BwsError("BWS_ORG_ID is not set. The Secrets Manager SDK addresses everything by organization id — a public UUID, shown in the Secrets Manager URL (bitwarden.com/#/sm/<org-id>/...) and on the machine-account page. Put it in your .env; it identifies, it does not authorize.");
273
+ const typed = unwrap(await text({
274
+ message: "What is the Bitwarden organization id? (the UUID in the Secrets Manager URL: bitwarden.com/#/sm/<org-id>/...)",
275
+ validate: (v) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test((v ?? "").trim()) ? void 0 : "That is not a UUID."
276
+ })).trim();
277
+ try {
278
+ await saveToDevEnv("BWS_ORG_ID", typed);
279
+ log.success("Stored BWS_ORG_ID in .env.");
280
+ } catch (err) {
281
+ log.warn(`Could not save it to .env: ${err instanceof Error ? err.message : String(err)}. Continuing with the id you typed.`);
282
+ }
283
+ return typed;
284
+ })();
285
+ resolvedOrgId.catch(() => {
286
+ resolvedOrgId = void 0;
287
+ });
288
+ return resolvedOrgId;
289
+ }
290
+ /** The lazily-loaded, logged-in SDK client, one per process. */
291
+ let sdk;
292
+ /**
293
+ * Where the SDK caches its login, so a process is not a fresh authentication.
294
+ *
295
+ * ⚠️ This file is what stops the 429s. Bitwarden's identity endpoint
296
+ * rate-limits repeated access-token logins hard, and "push preflight, audit,
297
+ * push staging, audit, push production, audit" is six devtools processes in
298
+ * two minutes: six logins without this, ONE with it. The `bws` binary kept a
299
+ * state directory for exactly this reason, and the SDK migration dropped it.
300
+ * Per machine account (the token's client-id UUID is in the filename, public
301
+ * by format), under 0700 directories, and outside the repository, because it
302
+ * caches an auth token and must live where nothing syncs or commits it.
303
+ */
304
+ function stateFileFor(accessToken) {
305
+ const clientId = /^\d+\.([0-9a-f-]{36})\./i.exec(accessToken)?.[1] ?? "default";
306
+ return join(homedir(), ".config", "devdogs-devtools", `bws-state-${clientId}.json`);
307
+ }
308
+ async function client() {
309
+ sdk ??= (async () => {
310
+ const token = await accessToken();
311
+ const { BitwardenClient } = await import("@bitwarden/sdk-napi");
312
+ const instance = new BitwardenClient();
313
+ try {
314
+ const stateFile = stateFileFor(token);
315
+ await mkdir(dirname(stateFile), {
316
+ recursive: true,
317
+ mode: 448
318
+ });
319
+ await withRateLimitRetry(async () => {
320
+ await pace("write");
321
+ return instance.auth().loginAccessToken(token, stateFile);
322
+ }, { doing: "the login" });
323
+ } catch (err) {
324
+ throw new BwsError(describeSdkFailure(err, "logging in"));
325
+ }
326
+ return instance;
327
+ })();
328
+ sdk.catch(() => {
329
+ sdk = void 0;
330
+ });
331
+ return sdk;
332
+ }
333
+ /**
334
+ * Turns an SDK failure into something with a next step in it.
335
+ *
336
+ * The two that actually happen are a rejected token and a token that does not
337
+ * cover the project. The latter surfaces as a bare "not found", because a
338
+ * project you cannot see and a project that does not exist are deliberately
339
+ * the same answer.
340
+ */
341
+ function describeSdkFailure(err, doing) {
342
+ const message = err instanceof Error ? err.message : String(err);
343
+ if (/\b429\b|too many requests/i.test(message)) return `${message}\n\nBitwarden rate-limited this even after the automatic retries. Wait a minute and re-run — the login is cached in a state file now, so re-running does not spend another authentication.`;
344
+ if (/404|not.?found/i.test(message)) return `${message}\n\nA 404 here usually means the access token is valid but its machine account is not assigned to this project — Secrets Manager reports 'invisible' and 'absent' identically.`;
345
+ if (/401|unauthoriz|invalid|expired/i.test(message)) return `${message}\n\nThe access token was rejected while ${doing}. It may have been revoked or belong to another organization.`;
346
+ return message || `Secrets Manager failed with no message while ${doing}.`;
347
+ }
348
+ function iso(value) {
349
+ if (value instanceof Date) return value.toISOString();
350
+ if (typeof value === "string" && value !== "") return value;
351
+ }
352
+ /** Resolves a project name to its id. Names are unique within an org. */
353
+ async function projectIdFor(name) {
354
+ const c = await client();
355
+ const org = await organizationId();
356
+ let projects;
357
+ try {
358
+ projects = (await withRateLimitRetry(async () => {
359
+ await pace("read");
360
+ return c.projects().list(org);
361
+ }, { doing: "listing projects" })).data;
362
+ } catch (err) {
363
+ throw new BwsError(describeSdkFailure(err, "listing projects"));
364
+ }
365
+ const match = projects.find((p) => p.name === name);
366
+ if (!match) {
367
+ const visible = projects.map((p) => p.name).sort();
368
+ throw new BwsError(`No project named "${name}".\n` + (visible.length > 0 ? `This token can see: ${visible.join(", ")}` : "This token can see no projects at all, which usually means the machine account has no project assignments yet."));
369
+ }
370
+ return match.id;
371
+ }
372
+ async function listSecrets(projectId) {
373
+ const c = await client();
374
+ const org = await organizationId();
375
+ try {
376
+ const identifiers = (await withRateLimitRetry(async () => {
377
+ await pace("read");
378
+ return c.secrets().list(org);
379
+ }, { doing: "listing secrets" })).data;
380
+ if (identifiers.length === 0) return [];
381
+ return (await withRateLimitRetry(async () => {
382
+ await pace("read");
383
+ return c.secrets().getByIds(identifiers.map((i) => i.id));
384
+ }, { doing: "reading secrets" })).data.filter((s) => s.projectId === projectId).map((s) => ({
385
+ id: s.id,
386
+ key: s.key,
387
+ value: s.value,
388
+ note: s.note,
389
+ projectId,
390
+ revisionDate: iso(s.revisionDate)
391
+ }));
392
+ } catch (err) {
393
+ throw new BwsError(describeSdkFailure(err, "listing secrets"));
394
+ }
395
+ }
396
+ async function createSecret(projectId, key, value, note) {
397
+ const c = await client();
398
+ const org = await organizationId();
399
+ try {
400
+ await withRateLimitRetry(async () => {
401
+ await pace("write");
402
+ return c.secrets().create(org, key, value, note, [projectId]);
403
+ }, { doing: `creating ${key}` });
404
+ } catch (err) {
405
+ throw new BwsError(describeSdkFailure(err, `creating ${key}`));
406
+ }
407
+ }
408
+ /**
409
+ * Takes the whole secret rather than an id: the SDK's update is a full
410
+ * replace (organization, key, project list included), and every caller
411
+ * already holds the listed secret it is updating.
412
+ */
413
+ async function updateSecret(secret, value, note) {
414
+ const c = await client();
415
+ const org = await organizationId();
416
+ try {
417
+ await withRateLimitRetry(async () => {
418
+ await pace("write");
419
+ return c.secrets().update(org, secret.id, secret.key, value, note, [secret.projectId]);
420
+ }, { doing: `updating ${secret.key}` });
421
+ } catch (err) {
422
+ throw new BwsError(describeSdkFailure(err, `updating ${secret.key}`));
423
+ }
424
+ }
425
+ function byKey(secrets) {
426
+ return new Map(secrets.map((s) => [s.key, s]));
427
+ }
428
+ //#endregion
429
+ export { listSecrets as a, updateSecret as c, createSecret as i, accessToken as n, projectIdFor as o, byKey as r, setExplicitAccessToken as s, BwsError as t };
@@ -0,0 +1,2 @@
1
+ import { a as listSecrets, o as projectIdFor } from "./client-B7HhuTaA.js";
2
+ export { listSecrets, projectIdFor };