@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,1157 @@
1
+ import { C as discoverRepoRoot, p as isNonInteractive } from "./telemetry-Bjoz29Hl.js";
2
+ import { t as nonEmpty } from "./connection-D3XsOE3S.js";
3
+ import { a as explainError, i as explain, o as unwrap, r as errorMessage, t as UsageError } from "./ui-CdKo8mLw.js";
4
+ import { c as EnvDocument, i as readPasswordFromVault, n as bwArgs, r as openVault, s as bwCommand } from "./vault-C8LYJFOQ.js";
5
+ import { t as DONE } from "./dispatch-D048O65I.js";
6
+ import { resolve } from "node:path";
7
+ import { confirm, log, multiselect, note, password, select, text } from "@clack/prompts";
8
+ import { readFile } from "node:fs/promises";
9
+ import { spawn } from "node:child_process";
10
+ import { parseArgs } from "node:util";
11
+ import postgres from "postgres";
12
+ //#region src/creds/secrets.ts
13
+ /**
14
+ * The secret values this process has seen, so nothing it prints can carry one.
15
+ *
16
+ * `creds` holds live passwords in memory: it reads them from Bitwarden items
17
+ * and writes them into Send bodies. They must never reach stdout, stderr, the
18
+ * failure log, Sentry or an error message. The code never prints one on
19
+ * purpose, but `bw` writes its own errors, and an error message that quotes its
20
+ * input is one refactor away. So every value is registered the moment it is
21
+ * read, and every string that leaves through an error passes {@link scrub}.
22
+ *
23
+ * Masking in the preview is a separate thing: it never has the value to begin
24
+ * with (see `item.ts`'s `MASK`).
25
+ */
26
+ const seen = /* @__PURE__ */ new Set();
27
+ /**
28
+ * Values shorter than this are not registered. Scrubbing a two-character
29
+ * password out of every message would shred the messages and protect nothing
30
+ * a two-character password had left to protect.
31
+ */
32
+ const MIN_LENGTH = 4;
33
+ /** Records a value that must never be printed. */
34
+ function registerSecret(value) {
35
+ if (value && value.length >= MIN_LENGTH) seen.add(value);
36
+ }
37
+ const REDACTED = "<redacted>";
38
+ /** `text` with every registered value replaced. Longest first, so a value that contains another is removed whole. */
39
+ function scrub(text) {
40
+ let out = text;
41
+ for (const value of [...seen].sort((a, b) => b.length - a.length)) out = out.split(value).join(REDACTED);
42
+ return out;
43
+ }
44
+ /** An error whose message has already been scrubbed. Safe to print and report. */
45
+ var CredsError = class extends Error {
46
+ name = "CredsError";
47
+ constructor(message) {
48
+ super(scrub(message));
49
+ }
50
+ };
51
+ //#endregion
52
+ //#region src/creds/item.ts
53
+ /**
54
+ * A shared account as `creds` sees it: a login or secure note in the DevDogs
55
+ * Bitwarden organization, plus the custom fields that record who has it.
56
+ *
57
+ * Those fields are the only access record. The Send, the Linear report and
58
+ * every "who has Canva?" answer are generated from them, so they are plain
59
+ * text fields anyone with the item can read and correct in Bitwarden
60
+ * itself:
61
+ *
62
+ * Recipients comma-separated UGA emails, the Send's `--emails` list
63
+ * Owner the officer responsible for the account
64
+ * Send ID the Send's id, for `bw send edit`
65
+ * Send link its access URL, for the report (Sends belong to whoever
66
+ * created them, so another officer cannot look it up)
67
+ * Send expires its deletion date, ISO 8601, for the report and `renew`
68
+ * Send account the Bitwarden account that owns the Send
69
+ *
70
+ * The password, the TOTP seed and the notes of a secure note are registered
71
+ * as secrets the moment an item is parsed (see `secrets.ts`).
72
+ */
73
+ const FIELD = {
74
+ recipients: "Recipients",
75
+ owner: "Owner",
76
+ sendId: "Send ID",
77
+ sendLink: "Send link",
78
+ sendExpires: "Send expires",
79
+ sendAccount: "Send account"
80
+ };
81
+ /** Bitwarden's item types, as `bw` numbers them. */
82
+ const ITEM_TYPE = {
83
+ login: 1,
84
+ secureNote: 2
85
+ };
86
+ /** What the preview shows for a password: fixed, so not even its length leaks. */
87
+ const MASK = "••••••••";
88
+ /** `value`, trimmed, or `undefined` when there is nothing in it. */
89
+ function present(value) {
90
+ const trimmed = value?.trim();
91
+ return trimmed === void 0 || trimmed === "" ? void 0 : trimmed;
92
+ }
93
+ function field(raw, name) {
94
+ return present(raw.fields?.find((f) => f.name === name)?.value);
95
+ }
96
+ function splitEmails(value) {
97
+ return [...new Set((value ?? "").split(/[,\s]+/).map((e) => e.trim().toLowerCase()).filter(Boolean))];
98
+ }
99
+ /** Reads an item, registering its secret values first. */
100
+ function parseItem(raw) {
101
+ registerSecret(raw.login?.password);
102
+ registerSecret(raw.login?.totp);
103
+ if (raw.type === ITEM_TYPE.secureNote) registerSecret(raw.notes);
104
+ for (const f of raw.fields ?? []) if (f.type === 1) registerSecret(f.value);
105
+ const sendId = field(raw, FIELD.sendId);
106
+ const expiresText = field(raw, FIELD.sendExpires);
107
+ const expires = expiresText ? new Date(expiresText) : void 0;
108
+ return {
109
+ raw,
110
+ id: raw.id,
111
+ name: raw.name,
112
+ username: present(raw.login?.username),
113
+ url: raw.login?.uris?.map((u) => present(u.uri)).find((u) => u !== void 0),
114
+ recipients: splitEmails(field(raw, FIELD.recipients)),
115
+ owner: field(raw, FIELD.owner),
116
+ send: sendId ? {
117
+ id: sendId,
118
+ link: field(raw, FIELD.sendLink),
119
+ expires: expires && !Number.isNaN(expires.getTime()) ? expires : void 0,
120
+ account: field(raw, FIELD.sendAccount)
121
+ } : void 0
122
+ };
123
+ }
124
+ /**
125
+ * `raw` with the named text fields set (or removed, for `undefined`), every
126
+ * other field and property left exactly as it was. Our fields are always
127
+ * plain text: none of them is secret, and a hidden field would hide the
128
+ * access record from the people meant to read it.
129
+ */
130
+ function withFields(raw, updates) {
131
+ let fields = [...raw.fields ?? []];
132
+ for (const [key, value] of Object.entries(updates)) {
133
+ const name = FIELD[key];
134
+ const index = fields.findIndex((f) => f.name === name);
135
+ if (value === void 0 || value === "") {
136
+ if (index >= 0) fields = fields.filter((_, i) => i !== index);
137
+ } else if (index >= 0) fields[index] = {
138
+ ...fields[index],
139
+ value,
140
+ type: 0
141
+ };
142
+ else fields.push({
143
+ name,
144
+ value,
145
+ type: 0
146
+ });
147
+ }
148
+ return {
149
+ ...raw,
150
+ fields
151
+ };
152
+ }
153
+ /** What the recipient reads once Bitwarden has verified their email. Holds the password. */
154
+ function sendBody(item) {
155
+ return body(item, item.raw.login?.password ?? void 0, item.raw.notes);
156
+ }
157
+ /**
158
+ * {@link sendBody} with the password replaced by {@link MASK} and the notes by
159
+ * a line count. Built from the item, never from the body, so there is no
160
+ * string here that ever held the value.
161
+ */
162
+ function previewBody(item) {
163
+ const notes = item.raw.notes?.trim();
164
+ const lines = notes ? notes.split("\n").length : 0;
165
+ return body(item, item.raw.login?.password ? MASK : void 0, notes ? `(notes: ${lines} line${lines === 1 ? "" : "s"}, not shown)` : null);
166
+ }
167
+ function body(item, password, notes) {
168
+ const lines = [item.name];
169
+ if (item.url) lines.push(`Login: ${item.url}`);
170
+ if (item.username) lines.push(`Username: ${item.username}`);
171
+ if (password) lines.push(`Password: ${password}`);
172
+ if (notes?.trim()) lines.push("", notes.trim());
173
+ lines.push("", "Shared by DevDogs officers. Please don't forward this link or the password; ask an officer to add you instead.");
174
+ return lines.join("\n");
175
+ }
176
+ const DAY = 864e5;
177
+ function sendStatus(item, now) {
178
+ if (!item.send) return "No Send";
179
+ if (!item.send.expires) return "Active";
180
+ const left = item.send.expires.getTime() - now.getTime();
181
+ if (left <= 0) return "Expired";
182
+ return left <= 7 * DAY ? "Expires Soon" : "Active";
183
+ }
184
+ /** Whole days until the Send is deleted, rounded down; negative once it has been. */
185
+ function daysLeft(item, now) {
186
+ const expires = item.send?.expires;
187
+ if (!expires) return void 0;
188
+ return Math.floor((expires.getTime() - now.getTime()) / DAY);
189
+ }
190
+ /** `YYYY-MM-DD`. */
191
+ function isoDay(date) {
192
+ return date ? date.toISOString().slice(0, 10) : "—";
193
+ }
194
+ //#endregion
195
+ //#region src/creds/bitwarden.ts
196
+ /**
197
+ * The DevDogs Bitwarden organization's items and the Sends made from them,
198
+ * through the bundled `bw` on the officer's personal session (`bws/vault.ts`).
199
+ *
200
+ * Sends belong to the account that creates them, not to the organization, so
201
+ * this always runs as a person and never on CI.
202
+ *
203
+ * ## Secret handling
204
+ *
205
+ * * Every value travels to `bw` on **stdin**, base64 JSON as `bw create`,
206
+ * `bw edit` and `bw send create|edit` document. argv carries only ids and
207
+ * the session key `bwArgs` already passes.
208
+ * * `bw`'s stdout is parsed and never printed: an item or a Send read back
209
+ * holds the password.
210
+ * * `bw`'s stderr is only shown through `CredsError`, which scrubs every value
211
+ * registered so far.
212
+ *
213
+ * ## The organization fence
214
+ *
215
+ * `creds` reads and writes the logins and secure notes of ONE organization,
216
+ * found by name. Anything else the account can see (a personal login, another
217
+ * organization's item) is never listed, never edited and never sent;
218
+ * `updateItem` re-checks before every write. Production secrets are not
219
+ * vault items at all: they live in Secrets Manager, which `bw` cannot read.
220
+ */
221
+ /** Matched case-insensitively against the organizations the account belongs to. */
222
+ const ORGANIZATION_NAME = "DevDogs";
223
+ function encode(value) {
224
+ return Buffer.from(JSON.stringify(value)).toString("base64");
225
+ }
226
+ /** The real runner: the bundled `bw`, every stream piped, stdin always closed. */
227
+ function spawnRunner(key) {
228
+ return (args, stdin) => new Promise((resolve) => {
229
+ const child = spawn(...bwCommand(bwArgs(args, key)), {
230
+ stdio: [
231
+ "pipe",
232
+ "pipe",
233
+ "pipe"
234
+ ],
235
+ shell: false
236
+ });
237
+ let stdout = "";
238
+ let stderr = "";
239
+ child.stdout.on("data", (c) => stdout += c.toString());
240
+ child.stderr.on("data", (c) => stderr += c.toString());
241
+ child.on("error", (err) => resolve({
242
+ code: null,
243
+ stdout: "",
244
+ stderr: err.message
245
+ }));
246
+ child.on("close", (code) => resolve({
247
+ code,
248
+ stdout,
249
+ stderr
250
+ }));
251
+ child.stdin.end(stdin ?? "");
252
+ });
253
+ }
254
+ let runnerOverride;
255
+ /** Opens the vault (asking first) and finds the DevDogs organization in it. */
256
+ async function connectSharedVault() {
257
+ let runner = runnerOverride;
258
+ if (!runner) {
259
+ const vault = await openVault("share DevDogs logins");
260
+ if (!vault) throw new CredsError("Could not use your Bitwarden vault. `creds` needs your own session: run it at a terminal so it can sign in and unlock, or export BW_SESSION.");
261
+ runner = spawnRunner(vault.key);
262
+ }
263
+ const shared = new SharedVault(runner);
264
+ await shared.sync();
265
+ await shared.organization();
266
+ return shared;
267
+ }
268
+ var SharedVault = class {
269
+ run;
270
+ found;
271
+ constructor(run) {
272
+ this.run = run;
273
+ }
274
+ /** `bw <args>`, parsed as JSON, or a scrubbed `CredsError`. */
275
+ async json(args, stdin) {
276
+ const result = await this.run(args, stdin);
277
+ const label = `bw ${args.slice(0, args[0] === "send" ? 2 : 1).join(" ")}`;
278
+ if (result.code !== 0) throw new CredsError(`${label} failed: ${result.stderr.trim() || `exit ${result.code}`}`);
279
+ try {
280
+ return JSON.parse(result.stdout);
281
+ } catch {
282
+ throw new CredsError(`${label} printed something that is not JSON.`);
283
+ }
284
+ }
285
+ async sync() {
286
+ const result = await this.run(["sync"]);
287
+ if (result.code !== 0) throw new CredsError(`bw sync failed: ${result.stderr.trim() || `exit ${result.code}`}`);
288
+ }
289
+ /** The signed-in account's email, recorded as the Send's owner. */
290
+ async account() {
291
+ return (await this.json(["status"])).userEmail ?? "unknown";
292
+ }
293
+ /** The one organization named like {@link ORGANIZATION_NAME} this account belongs to. */
294
+ async organization() {
295
+ if (this.found) return this.found;
296
+ const matches = (await this.json(["list", "organizations"])).filter((o) => o.name.toLowerCase().includes(ORGANIZATION_NAME.toLowerCase()));
297
+ if (matches.length !== 1) throw new CredsError(matches.length === 0 ? `Your Bitwarden account is not in the ${ORGANIZATION_NAME} organization. Ask an organization admin to invite you.` : `Your Bitwarden account is in ${matches.length} organizations named like "${ORGANIZATION_NAME}" (${matches.map((o) => o.name).join(", ")}); creds cannot tell which one to use.`);
298
+ this.found = matches[0];
299
+ return this.found;
300
+ }
301
+ shareable(raw, org) {
302
+ return raw.organizationId === org.id && (raw.type === ITEM_TYPE.login || raw.type === ITEM_TYPE.secureNote);
303
+ }
304
+ /** Every login and secure note in the organization, by name. */
305
+ async listItems() {
306
+ const org = await this.organization();
307
+ return (await this.json([
308
+ "list",
309
+ "items",
310
+ "--organizationid",
311
+ org.id
312
+ ])).filter((item) => this.shareable(item, org)).map(parseItem).sort((a, b) => a.name.localeCompare(b.name));
313
+ }
314
+ /** The organization's collections this account can see, by name. */
315
+ async collections() {
316
+ const org = await this.organization();
317
+ return (await this.json([
318
+ "list",
319
+ "collections",
320
+ "--organizationid",
321
+ org.id
322
+ ])).filter((c) => c.organizationId === org.id).sort((a, b) => a.name.localeCompare(b.name));
323
+ }
324
+ /** A new login, owned by the organization and placed in `collection`. */
325
+ async createLogin(input) {
326
+ registerSecret(input.password);
327
+ const org = await this.organization();
328
+ if (input.collection.organizationId !== org.id) throw new CredsError(`The "${input.collection.name}" collection is not in the ${org.name} organization.`);
329
+ const item = {
330
+ organizationId: org.id,
331
+ collectionIds: [input.collection.id],
332
+ folderId: null,
333
+ type: 1,
334
+ name: input.name,
335
+ notes: input.notes ?? null,
336
+ favorite: false,
337
+ reprompt: 0,
338
+ fields: input.owner ? [{
339
+ name: FIELD.owner,
340
+ value: input.owner,
341
+ type: 0
342
+ }] : [],
343
+ login: {
344
+ username: input.username ?? null,
345
+ password: input.password,
346
+ totp: null,
347
+ uris: input.url ? [{
348
+ match: null,
349
+ uri: input.url
350
+ }] : []
351
+ }
352
+ };
353
+ return parseItem(await this.json(["create", "item"], encode(item)));
354
+ }
355
+ /** Saves `raw`, which must still be a login or secure note of the organization. */
356
+ async updateItem(raw) {
357
+ const org = await this.organization();
358
+ if (!this.shareable(raw, org)) throw new CredsError(`"${raw.name}" is not a login or secure note of the ${org.name} organization; creds will not change it.`);
359
+ return parseItem(await this.json([
360
+ "edit",
361
+ "item",
362
+ raw.id
363
+ ], encode(raw)));
364
+ }
365
+ /** The Send, if this account owns one with that id. Others' Sends are invisible to it. */
366
+ async getSend(id) {
367
+ const result = await this.run([
368
+ "send",
369
+ "get",
370
+ id
371
+ ]);
372
+ if (result.code !== 0) {
373
+ if (/not found/i.test(result.stderr)) return void 0;
374
+ throw new CredsError(`bw send get failed: ${result.stderr.trim() || `exit ${result.code}`}`);
375
+ }
376
+ try {
377
+ const send = JSON.parse(result.stdout);
378
+ registerSecret(send.text?.text);
379
+ return send;
380
+ } catch {
381
+ throw new CredsError("bw send get printed something that is not JSON.");
382
+ }
383
+ }
384
+ /** A new email-verified text Send for `item`. */
385
+ async createSend(item, body, emails, now) {
386
+ registerSecret(body);
387
+ const send = {
388
+ object: "send",
389
+ name: sendName(item),
390
+ notes: sendNotes(item),
391
+ type: 0,
392
+ text: {
393
+ text: body,
394
+ hidden: true
395
+ },
396
+ file: null,
397
+ maxAccessCount: null,
398
+ deletionDate: deletionDate(now),
399
+ expirationDate: null,
400
+ password: null,
401
+ emails: [...emails],
402
+ disabled: false,
403
+ hideEmail: false
404
+ };
405
+ return this.json(["send", "create"], encode(send));
406
+ }
407
+ /**
408
+ * Extends `existing` in place, so its link never changes, and brings its
409
+ * body and recipients up to date with the item.
410
+ */
411
+ async editSend(existing, item, body, emails, now) {
412
+ registerSecret(body);
413
+ const send = {
414
+ ...existing,
415
+ name: sendName(item),
416
+ notes: sendNotes(item),
417
+ text: {
418
+ text: body,
419
+ hidden: true
420
+ },
421
+ deletionDate: deletionDate(now),
422
+ expirationDate: null,
423
+ maxAccessCount: null,
424
+ password: null,
425
+ emails: [...emails],
426
+ disabled: false,
427
+ hideEmail: false
428
+ };
429
+ return this.json(["send", "edit"], encode(send));
430
+ }
431
+ };
432
+ function sendName(item) {
433
+ return `DevDogs: ${item.name}`;
434
+ }
435
+ /** Private to the sender: how to find the item this Send came from. */
436
+ function sendNotes(item) {
437
+ return `Made by backstage creds from the ${ORGANIZATION_NAME} item "${item.name}" (${item.id}).`;
438
+ }
439
+ function deletionDate(now) {
440
+ return new Date(now.getTime() + 2592e6).toISOString();
441
+ }
442
+ //#endregion
443
+ //#region src/creds/clipboard.ts
444
+ /**
445
+ * Puts a Send link on the clipboard. Only ever a link: the one thing `creds`
446
+ * hands the officer to paste into a message.
447
+ *
448
+ * Best effort. No clipboard tool is a missing convenience, not a failure, and
449
+ * the link is printed anyway.
450
+ */
451
+ function candidates() {
452
+ if (process.platform === "darwin") return [["pbcopy", []]];
453
+ if (process.platform === "win32") return [["clip", []]];
454
+ return [
455
+ ...process.env.WSL_DISTRO_NAME ? [["clip.exe", []]] : [],
456
+ ["wl-copy", []],
457
+ ["xclip", ["-selection", "clipboard"]],
458
+ ["xsel", ["--clipboard", "--input"]]
459
+ ];
460
+ }
461
+ function tryCopy(command, args, text) {
462
+ return new Promise((resolve) => {
463
+ const child = spawn(command, args, {
464
+ stdio: [
465
+ "pipe",
466
+ "ignore",
467
+ "ignore"
468
+ ],
469
+ shell: false
470
+ });
471
+ child.on("error", () => resolve(false));
472
+ child.on("close", (code) => resolve(code === 0));
473
+ child.stdin.on("error", () => void 0);
474
+ child.stdin.end(text);
475
+ });
476
+ }
477
+ async function copyLink(link) {
478
+ for (const [command, args] of candidates()) if (await tryCopy(command, args, link)) return true;
479
+ return false;
480
+ }
481
+ //#endregion
482
+ //#region src/creds/roster.ts
483
+ /**
484
+ * The officer roster: who a shared login may be sent to.
485
+ *
486
+ * Read live from production every run, never cached and never committed: the
487
+ * club's roles change at elections and mid-semester handoffs, and a stale list
488
+ * is how a Send ends up with somebody who has left. An officer is anyone
489
+ * holding a role other than the default `Member` one, addressed by their UGA
490
+ * MyID email, the address Bitwarden verifies before it opens a Send.
491
+ *
492
+ * Picking is by person or by role, and choosing a role chooses everyone in
493
+ * it. An email that is not on the roster is refused unless `--allow-email` is
494
+ * passed, which is for the rare account shared outside the officer team.
495
+ */
496
+ /**
497
+ * Everyone holding a non-default role, one row per person.
498
+ *
499
+ * The UGA address comes from the profile first: an officer may sign in with a
500
+ * personal address, and the seeds record the MyID one as `ugaEmail`. The
501
+ * sign-in address is the fallback, used only when it is itself a UGA one.
502
+ */
503
+ const ROSTER_QUERY = `
504
+ select
505
+ ur."userId" as "userId",
506
+ coalesce(
507
+ nullif(p."preferredName", ''),
508
+ nullif(concat_ws(' ', p."legalFirstName", p."legalLastName"), '')
509
+ ) as "name",
510
+ p."ugaEmail" as "ugaEmail",
511
+ u."email" as "authEmail",
512
+ array_agg(r."title" order by r."rank" nulls last, r."title") as "roles",
513
+ array_agg(r."rank" order by r."rank" nulls last, r."title") as "ranks"
514
+ from "platform"."userRoles" ur
515
+ join "platform"."roles" r on r."id" = ur."roleId"
516
+ join "auth"."users" u on u."id" = ur."userId"
517
+ left join "platform"."profile" p on p."userId" = ur."userId"
518
+ where r."roleType" <> 'default'
519
+ group by ur."userId", p."preferredName", p."legalFirstName", p."legalLastName", p."ugaEmail", u."email"
520
+ `;
521
+ function isUgaEmail(email) {
522
+ return /@uga\.edu$/i.test(email);
523
+ }
524
+ /**
525
+ * Builds the roster from query rows. People with no UGA address cannot be
526
+ * sent to (Bitwarden would verify the wrong inbox), so they are set aside
527
+ * and named in `skipped` for the caller to mention.
528
+ */
529
+ function rosterFromRows(rows) {
530
+ const officers = [];
531
+ const skipped = [];
532
+ const rank = /* @__PURE__ */ new Map();
533
+ for (const row of rows) {
534
+ const email = [row.ugaEmail, row.authEmail].map((e) => e?.trim().toLowerCase()).find((e) => !!e && isUgaEmail(e));
535
+ const name = [row.name?.trim(), row.authEmail].find((v) => !!v) ?? row.userId;
536
+ if (!email) {
537
+ skipped.push(name);
538
+ continue;
539
+ }
540
+ officers.push({
541
+ name,
542
+ email,
543
+ roles: [...row.roles]
544
+ });
545
+ row.roles.forEach((title, i) => {
546
+ const r = row.ranks[i] ?? Number.MAX_SAFE_INTEGER;
547
+ rank.set(title, Math.min(rank.get(title) ?? r, r));
548
+ });
549
+ }
550
+ officers.sort((a, b) => a.name.localeCompare(b.name));
551
+ return {
552
+ roster: {
553
+ officers,
554
+ roles: [...rank.keys()].sort((a, b) => rank.get(a) - rank.get(b) || a.localeCompare(b)).map((title) => ({
555
+ title,
556
+ emails: officers.filter((o) => o.roles.includes(title)).map((o) => o.email)
557
+ }))
558
+ },
559
+ skipped
560
+ };
561
+ }
562
+ async function loadRoster(query) {
563
+ return rosterFromRows(await query());
564
+ }
565
+ /**
566
+ * The role a typed name means: an exact title, else the one title it is a
567
+ * prefix of, case-insensitively (`devops` is `DevOps Director`). Ambiguous or
568
+ * unknown names throw, listing what there is.
569
+ */
570
+ function findRole(roster, typed) {
571
+ const needle = typed.trim().toLowerCase();
572
+ const exact = roster.roles.find((r) => r.title.toLowerCase() === needle);
573
+ if (exact) return exact;
574
+ const prefixed = roster.roles.filter((r) => r.title.toLowerCase().startsWith(needle));
575
+ if (prefixed.length === 1) return prefixed[0];
576
+ const titles = roster.roles.map((r) => r.title).join(", ");
577
+ throw new CredsError(prefixed.length === 0 ? `No officer role called "${typed}". Roles: ${titles}.` : `"${typed}" could be ${prefixed.map((r) => r.title).join(" or ")}.`);
578
+ }
579
+ /** Everyone in the named roles, once each, in roster order. */
580
+ function emailsForRoles(roster, typed) {
581
+ const wanted = new Set(typed.flatMap((t) => findRole(roster, t).emails));
582
+ return roster.officers.map((o) => o.email).filter((e) => wanted.has(e));
583
+ }
584
+ /**
585
+ * Splits `emails` into the ones on the roster and the ones that are not.
586
+ * With `allowEmail`, nothing is refused; addresses are lowercased either way,
587
+ * because Bitwarden compares them and a capital letter is a different person
588
+ * to a string comparison.
589
+ */
590
+ function checkRecipients(emails, roster, allowEmail) {
591
+ const known = new Set(roster.officers.map((o) => o.email));
592
+ const accepted = [];
593
+ const refused = [];
594
+ for (const raw of emails) {
595
+ const email = raw.trim().toLowerCase();
596
+ if (!email || accepted.includes(email)) continue;
597
+ if (allowEmail || known.has(email)) accepted.push(email);
598
+ else refused.push(email);
599
+ }
600
+ return {
601
+ accepted,
602
+ refused
603
+ };
604
+ }
605
+ /** "Name <email>" for an address on the roster, the bare address otherwise. */
606
+ function describeRecipient(roster, email) {
607
+ const officer = roster?.officers.find((o) => o.email === email);
608
+ return officer ? `${officer.name} <${email}>` : email;
609
+ }
610
+ /**
611
+ * The production project in Secrets Manager. The same name the target table in
612
+ * `@devdogsuga/env` gives it, spelled out here because `creds` runs with no
613
+ * checkout, where that package is not installed.
614
+ */
615
+ const PRODUCTION_PROJECT = "production";
616
+ /** `--db-url`, else the checkout's `.env.production`, else Secrets Manager. */
617
+ async function resolveRosterDbUrl(sources = {}) {
618
+ const url = nonEmpty(sources.explicit) ?? await (sources.fromCheckout ?? readCheckoutDbUrl)() ?? await (sources.fromSecretsManager ?? readSecretsManagerDbUrl)();
619
+ registerSecret(url);
620
+ if (!url) throw new CredsError("Could not find the production DB_URL to read the officer roster. Pass --db-url, or run `backstage env pull --target production` in a checkout.");
621
+ return url;
622
+ }
623
+ async function readCheckoutDbUrl() {
624
+ const root = discoverRepoRoot();
625
+ if (!root) return void 0;
626
+ try {
627
+ const text = await readFile(resolve(root, ".env.production"), "utf8");
628
+ return nonEmpty(EnvDocument.parse(text).get("DB_URL"));
629
+ } catch {
630
+ return;
631
+ }
632
+ }
633
+ async function readSecretsManagerDbUrl() {
634
+ const { listSecrets, projectIdFor } = await import("./client-DzXQHd58.js");
635
+ const secrets = await listSecrets(await projectIdFor(PRODUCTION_PROJECT));
636
+ return nonEmpty(secrets.find((s) => s.key === "DB_URL")?.value);
637
+ }
638
+ /** {@link ROSTER_QUERY} against `url`, on one short-lived connection. */
639
+ function rosterQueryFor(url) {
640
+ return async () => {
641
+ const sql = postgres(url, {
642
+ max: 1,
643
+ prepare: false,
644
+ connect_timeout: 15
645
+ });
646
+ try {
647
+ return (await sql.unsafe(ROSTER_QUERY)).map((row) => ({
648
+ userId: String(row.userId),
649
+ name: row.name ?? null,
650
+ ugaEmail: row.ugaEmail ?? null,
651
+ authEmail: row.authEmail ?? null,
652
+ roles: row.roles,
653
+ ranks: row.ranks
654
+ }));
655
+ } catch (err) {
656
+ throw new CredsError(`Could not read the officer roster from production: ${err.code ?? "connection failed"}.`);
657
+ } finally {
658
+ await sql.end({ timeout: 5 });
659
+ }
660
+ };
661
+ }
662
+ const LINEAR_VAULT_ITEM = "DevDogs Linear API key (backstage)";
663
+ const LINEAR_API = "https://api.linear.app/graphql";
664
+ function cell(text) {
665
+ return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
666
+ }
667
+ /** The document's markdown. Pure: `now` decides the statuses. */
668
+ function renderReport(items, now, roster) {
669
+ const lines = [
670
+ "> Generated by `backstage creds` from the DevDogs Bitwarden organization: every item shared as a Send. Manual edits are overwritten on the next run; change the item's fields in Bitwarden, or run `backstage creds send`.",
671
+ "",
672
+ `Last generated ${now.toISOString().slice(0, 16).replace("T", " ")} UTC. Sends are deleted 30 days after they were last renewed; **Expires Soon** means within 7 days, so run \`backstage creds renew\`.`,
673
+ ""
674
+ ];
675
+ const shared = items.filter((i) => i.recipients.length > 0 || i.send !== void 0);
676
+ if (shared.length === 0) {
677
+ lines.push("Nothing has been shared yet.");
678
+ return `${lines.join("\n")}\n`;
679
+ }
680
+ lines.push("| Credential | Account | Recipients | Owner | Status | Expires | Send |", "| --- | --- | --- | --- | --- | --- | --- |");
681
+ for (const item of shared) {
682
+ const recipients = item.recipients.length > 0 ? item.recipients.map((e) => describeRecipient(roster, e)).join(", ") : "—";
683
+ const link = item.send?.link ? `[Open](${item.send.link})` : "—";
684
+ lines.push(`| ${[
685
+ cell(item.name),
686
+ cell(item.username ?? "—"),
687
+ cell(recipients),
688
+ cell(item.owner ?? "—"),
689
+ sendStatus(item, now),
690
+ isoDay(item.send?.expires),
691
+ link
692
+ ].join(" | ")} |`);
693
+ }
694
+ return `${lines.join("\n")}\n`;
695
+ }
696
+ /**
697
+ * `--linear-token`, then `LINEAR_API_KEY`, then the vault item, then a prompt.
698
+ * Never saved anywhere: the vault is where it lives.
699
+ */
700
+ async function resolveLinearToken(sources) {
701
+ const token = nonEmpty(sources.explicit) ?? nonEmpty(sources.env) ?? await (sources.fromVault ?? readLinearTokenFromVault)() ?? await (sources.prompt ?? promptForLinearToken)();
702
+ if (!token) throw new CredsError(`No Linear API key. Set LINEAR_API_KEY, or store one in your vault as "${LINEAR_VAULT_ITEM}".`);
703
+ registerSecret(token);
704
+ return token;
705
+ }
706
+ function readLinearTokenFromVault() {
707
+ return readPasswordFromVault(LINEAR_VAULT_ITEM, "the Linear API key", "read the Linear API key", "LINEAR_API_KEY");
708
+ }
709
+ async function promptForLinearToken() {
710
+ if (!process.stdin.isTTY) return void 0;
711
+ const value = unwrap(await password({ message: "Linear API key? (Settings → Security & access → API keys)" }));
712
+ return nonEmpty(value.trim());
713
+ }
714
+ /** Replaces the document's content. Returns its URL. */
715
+ async function writeReport(token, documentId, content, fetchImpl = fetch) {
716
+ let response;
717
+ try {
718
+ response = await fetchImpl(LINEAR_API, {
719
+ method: "POST",
720
+ headers: {
721
+ Authorization: token,
722
+ "Content-Type": "application/json"
723
+ },
724
+ body: JSON.stringify({
725
+ query: "mutation($id: String!, $input: DocumentUpdateInput!) { documentUpdate(id: $id, input: $input) { success document { url } } }",
726
+ variables: {
727
+ id: documentId,
728
+ input: { content }
729
+ }
730
+ })
731
+ });
732
+ } catch (err) {
733
+ throw new CredsError(`Could not reach Linear: ${err instanceof Error ? err.message : String(err)}`);
734
+ }
735
+ const payload = await response.json().catch(() => ({}));
736
+ const result = payload.data?.documentUpdate;
737
+ if (!response.ok || !result?.success) throw new CredsError(`Linear refused the Shared Accounts update: ${payload.errors?.map((e) => e.message).join("; ") ?? `HTTP ${response.status}`}`);
738
+ return result.document?.url ?? documentId;
739
+ }
740
+ //#endregion
741
+ //#region src/creds/commands.ts
742
+ /**
743
+ * `backstage creds <send|add|renew|list|report>`: the club's shared logins,
744
+ * shared as Bitwarden Sends that only the officers they name can open.
745
+ *
746
+ * send pick shared accounts and officers; create or update one Send each
747
+ * add save a new login to the DevDogs organization, then send it
748
+ * renew push every Send's deletion 30 days out and re-sync its recipients
749
+ * list what is shared with whom, until when
750
+ * report regenerate the Shared Accounts Linear document
751
+ *
752
+ * Bare `creds` at a terminal opens this group's menu.
753
+ *
754
+ * ## Where things live
755
+ *
756
+ * The values live only in the DevDogs Bitwarden organization: any login or
757
+ * secure note in it can be shared. Each item's custom fields record its recipients and its
758
+ * Send (`item.ts`); the Linear document is generated from them (`report.ts`);
759
+ * the roster of who may receive one is read live from production
760
+ * (`roster.ts`).
761
+ *
762
+ * ## One Send per account
763
+ *
764
+ * Restricted by email verification to the item's recipients, deleted 30 days
765
+ * out, extended in place so the link never changes. Revoking someone is
766
+ * removing their address. A Send belongs to the officer who made it, so when
767
+ * another officer renews it they get a new one (and a new link) under their
768
+ * own account, and the item records that.
769
+ *
770
+ * ## Secrets
771
+ *
772
+ * No secret value reaches stdout, stderr, the failure log, Sentry, an error
773
+ * message or argv. The preview masks the password as a fixed `••••••••` and
774
+ * never shows the notes; values go to `bw` on stdin; errors are scrubbed
775
+ * (`secrets.ts`); the clipboard only ever gets a link. `commands.test.ts`
776
+ * runs every path with a sentinel password and looks for it everywhere.
777
+ */
778
+ const SUBCOMMANDS = [
779
+ "send",
780
+ "add",
781
+ "renew",
782
+ "list",
783
+ "report"
784
+ ];
785
+ function parse(argv) {
786
+ const [sub, ...rest] = argv;
787
+ if (!sub || !SUBCOMMANDS.includes(sub)) throw new UsageError(sub ? `Unknown creds command "${sub}". Try ${SUBCOMMANDS.join(", ")}.` : `Name what to do: ${SUBCOMMANDS.join(", ")}.`);
788
+ try {
789
+ const { values } = parseArgs({
790
+ args: rest,
791
+ options: {
792
+ item: {
793
+ type: "string",
794
+ multiple: true
795
+ },
796
+ to: { type: "string" },
797
+ role: { type: "string" },
798
+ "allow-email": { type: "boolean" },
799
+ "db-url": { type: "string" },
800
+ "linear-token": { type: "string" },
801
+ "no-report": { type: "boolean" },
802
+ yes: { type: "boolean" },
803
+ json: { type: "boolean" },
804
+ name: { type: "string" },
805
+ url: { type: "string" },
806
+ username: { type: "string" },
807
+ owner: { type: "string" },
808
+ collection: { type: "string" },
809
+ "password-stdin": { type: "boolean" },
810
+ document: { type: "string" },
811
+ "dry-run": { type: "boolean" }
812
+ },
813
+ allowPositionals: false,
814
+ strict: true
815
+ });
816
+ if (values["db-url"]) registerSecret(values["db-url"]);
817
+ if (values["linear-token"]) registerSecret(values["linear-token"]);
818
+ return {
819
+ sub,
820
+ values
821
+ };
822
+ } catch (err) {
823
+ throw new UsageError(scrub(errorMessage(err)));
824
+ }
825
+ }
826
+ /** Progress for a person; plain stderr lines when nobody is watching a terminal. */
827
+ function say(message, level = "info") {
828
+ if (isNonInteractive()) process.stderr.write(`${message}\n`);
829
+ else log[level](message);
830
+ }
831
+ function interactive() {
832
+ return !isNonInteractive() && process.stdin.isTTY === true;
833
+ }
834
+ async function confirmOrRefuse(message, yes) {
835
+ if (yes) return true;
836
+ if (!interactive()) throw new UsageError(`${message} Pass --yes to answer yes without a terminal.`);
837
+ return unwrap(await confirm({
838
+ message,
839
+ initialValue: true
840
+ }));
841
+ }
842
+ function findItem(items, typed) {
843
+ const needle = typed.trim().toLowerCase();
844
+ const match = items.find((i) => i.id === typed.trim()) ?? items.find((i) => i.name.toLowerCase() === needle);
845
+ if (!match) throw new UsageError(`No shared account called "${typed}". There are: ${items.map((i) => i.name).join(", ") || "none yet (try creds add)"}.`);
846
+ return match;
847
+ }
848
+ async function pickItems(items, typed, message) {
849
+ if (typed && typed.length > 0) return typed.map((t) => findItem(items, t));
850
+ if (items.length === 0) throw new UsageError("The DevDogs organization has no logins you can see. Add one with `backstage creds add`.");
851
+ if (!interactive()) throw new UsageError("Name the accounts with --item <name>.");
852
+ const ids = unwrap(await multiselect({
853
+ message,
854
+ options: items.map((i) => ({
855
+ value: i.id,
856
+ label: i.name,
857
+ hint: i.recipients.length > 0 ? `${i.recipients.length} recipient${i.recipients.length === 1 ? "" : "s"}` : "not shared yet"
858
+ })),
859
+ required: true
860
+ }));
861
+ return items.filter((i) => ids.includes(i.id));
862
+ }
863
+ let rosterCache;
864
+ async function roster(values, deps) {
865
+ rosterCache ??= (async () => {
866
+ const { roster: loaded, skipped } = await loadRoster(deps.rosterQuery ?? rosterQueryFor(await resolveRosterDbUrl({ explicit: values["db-url"] })));
867
+ if (skipped.length > 0) say(`Left off the roster for having no UGA address on record: ${skipped.join(", ")}.`, "warn");
868
+ return loaded;
869
+ })();
870
+ try {
871
+ return await rosterCache;
872
+ } catch (err) {
873
+ rosterCache = void 0;
874
+ throw err;
875
+ }
876
+ }
877
+ /**
878
+ * The recipients for one item: `--to` and `--role` when given, else a
879
+ * multiselect at a terminal (people and roles, the current list preselected),
880
+ * else the item's current list. Addresses off the roster need --allow-email,
881
+ * except ones the item already has.
882
+ */
883
+ async function pickRecipients(item, values, deps) {
884
+ const typed = splitEmails(values.to);
885
+ const roles = (values.role ?? "").split(",").map((r) => r.trim()).filter(Boolean);
886
+ let chosen;
887
+ if (typed.length > 0 || roles.length > 0) {
888
+ const r = roles.length > 0 ? await roster(values, deps) : void 0;
889
+ chosen = [...typed, ...r ? emailsForRoles(r, roles) : []];
890
+ } else if (interactive()) {
891
+ const r = await roster(values, deps);
892
+ const offRoster = item.recipients.filter((e) => !r.officers.some((o) => o.email === e));
893
+ chosen = unwrap(await multiselect({
894
+ message: `Who should be able to open ${item.name}? (roles select everyone in them)`,
895
+ options: [
896
+ ...r.roles.map((role) => ({
897
+ value: `role:${role.title}`,
898
+ label: `Everyone: ${role.title}`,
899
+ hint: `${role.emails.length}`
900
+ })),
901
+ ...r.officers.map((o) => ({
902
+ value: `email:${o.email}`,
903
+ label: o.name,
904
+ hint: `${o.email} · ${o.roles.join(", ")}`
905
+ })),
906
+ ...offRoster.map((e) => ({
907
+ value: `email:${e}`,
908
+ label: e,
909
+ hint: "not on the officer roster"
910
+ }))
911
+ ],
912
+ initialValues: item.recipients.map((e) => `email:${e}`),
913
+ required: true
914
+ })).flatMap((value) => value.startsWith("role:") ? emailsForRoles(r, [value.slice(5)]) : [value.slice(6)]);
915
+ if (values["allow-email"]) {
916
+ const extra = unwrap(await text({
917
+ message: "Anyone else? (comma-separated addresses, blank for nobody)",
918
+ placeholder: ""
919
+ }));
920
+ chosen.push(...splitEmails(extra));
921
+ }
922
+ } else chosen = item.recipients;
923
+ const unique = [...new Set(chosen.map((e) => e.toLowerCase()))];
924
+ const added = unique.filter((e) => !item.recipients.includes(e));
925
+ if (added.length > 0 && !values["allow-email"]) {
926
+ const { refused } = checkRecipients(added, await roster(values, deps), false);
927
+ if (refused.length > 0) throw new UsageError(`Not on the officer roster: ${refused.join(", ")}. Check the address, or pass --allow-email to share outside the officer team.`);
928
+ }
929
+ return unique;
930
+ }
931
+ async function plan(vault, item, recipients) {
932
+ if (recipients.length === 0) throw new CredsError(`${item.name} has no recipients. A Send needs at least one address.`);
933
+ return {
934
+ item,
935
+ recipients,
936
+ existing: item.send ? await vault.getSend(item.send.id) : void 0
937
+ };
938
+ }
939
+ /** The confirmation text for a plan: the masked body, the recipients, and what happens to the link. */
940
+ function describePlan(p, rosterForNames) {
941
+ const added = p.recipients.filter((e) => !p.item.recipients.includes(e));
942
+ const removed = p.item.recipients.filter((e) => !p.recipients.includes(e));
943
+ const lines = [
944
+ previewBody(p.item),
945
+ "",
946
+ "Recipients (Bitwarden verifies each address before showing anything):",
947
+ ...p.recipients.map((e) => ` ${describeRecipient(rosterForNames, e)}`)
948
+ ];
949
+ if (added.length > 0) lines.push(`Adding: ${added.join(", ")}`);
950
+ if (removed.length > 0) lines.push(`Removing: ${removed.join(", ")}`);
951
+ if (p.existing) lines.push("", "Updates the existing Send in place: the link stays the same.");
952
+ else if (p.item.send) lines.push("", `The recorded Send belongs to ${p.item.send.account ?? "another account"} or was deleted, so this makes a new one under your account. The link changes; the Linear document carries the new one.`);
953
+ else lines.push("", "Makes a new Send.");
954
+ return lines.join("\n");
955
+ }
956
+ async function apply(vault, p, account, now) {
957
+ const body = sendBody(p.item);
958
+ const send = p.existing ? await vault.editSend(p.existing, p.item, body, p.recipients, now) : await vault.createSend(p.item, body, p.recipients, now);
959
+ return vault.updateItem(withFields(p.item.raw, {
960
+ recipients: p.recipients.join(", "),
961
+ sendId: send.id,
962
+ sendLink: send.accessUrl,
963
+ sendExpires: new Date(send.deletionDate).toISOString(),
964
+ sendAccount: account
965
+ }));
966
+ }
967
+ async function sendAll(vault, plans, values, deps, rosterForNames) {
968
+ const now = (deps.now ?? (() => /* @__PURE__ */ new Date()))();
969
+ const account = await vault.account();
970
+ const done = [];
971
+ for (const p of plans) {
972
+ if (interactive()) note(describePlan(p, rosterForNames), p.item.name);
973
+ else process.stderr.write(`${p.item.name}\n${describePlan(p, rosterForNames)}\n\n`);
974
+ if (!await confirmOrRefuse(`Share ${p.item.name} with ${p.recipients.length} recipient${p.recipients.length === 1 ? "" : "s"}?`, values.yes === true)) {
975
+ say(`Skipped ${p.item.name}.`, "warn");
976
+ continue;
977
+ }
978
+ const saved = await apply(vault, p, account, now);
979
+ done.push(saved);
980
+ say(`${saved.name}: ${saved.send?.link ?? "(no link)"} (until ${isoDay(saved.send?.expires)})`, "success");
981
+ }
982
+ if (done.length === 1 && done[0].send?.link) {
983
+ if (await (deps.copy ?? copyLink)(done[0].send.link)) say("Copied the link to your clipboard.");
984
+ }
985
+ return done;
986
+ }
987
+ async function report(vault, values, deps, rosterForNames) {
988
+ const token = await resolveLinearToken({
989
+ explicit: values["linear-token"],
990
+ env: process.env.LINEAR_API_KEY
991
+ });
992
+ const items = await vault.listItems();
993
+ const now = (deps.now ?? (() => /* @__PURE__ */ new Date()))();
994
+ say(`Updated the Shared Accounts document: ${await writeReport(token, values.document ?? "209799db-5981-4796-a41e-2bb433020d06", renderReport(items, now, rosterForNames), deps.fetch)}`, "success");
995
+ }
996
+ /** The report after a change. A failure here leaves the Sends done, so it warns rather than fails. */
997
+ async function reportAfter(vault, values, deps, rosterForNames) {
998
+ if (values["no-report"]) return;
999
+ try {
1000
+ await report(vault, values, deps, rosterForNames);
1001
+ } catch (err) {
1002
+ say(`The Sends are done, but the Linear document was not updated: ${scrub(errorMessage(err))} Run \`backstage creds report\` to retry.`, "warn");
1003
+ }
1004
+ }
1005
+ async function runSend(vault, values, deps) {
1006
+ await sendItems(vault, await pickItems(await vault.listItems(), values.item, "Which shared accounts?"), values, deps);
1007
+ }
1008
+ async function sendItems(vault, items, values, deps) {
1009
+ const plans = [];
1010
+ for (const item of items) plans.push(await plan(vault, item, await pickRecipients(item, values, deps)));
1011
+ const names = rosterCache ? await rosterCache.catch(() => void 0) : void 0;
1012
+ if ((await sendAll(vault, plans, values, deps, names)).length > 0) await reportAfter(vault, values, deps, names);
1013
+ }
1014
+ async function readPassword(values, deps) {
1015
+ if (values["password-stdin"]) {
1016
+ const value = (await (deps.readStdin ?? readAllStdin)()).replace(/\r?\n$/, "");
1017
+ if (!value) throw new UsageError("--password-stdin read nothing.");
1018
+ return value;
1019
+ }
1020
+ if (!interactive()) throw new UsageError("Pass the password on stdin with --password-stdin; it never goes in argv.");
1021
+ return unwrap(await password({
1022
+ message: "Password? (hidden; never printed)",
1023
+ validate: (v) => v ? void 0 : "A shared login needs a password."
1024
+ }));
1025
+ }
1026
+ async function readAllStdin() {
1027
+ let data = "";
1028
+ for await (const chunk of process.stdin) data += String(chunk);
1029
+ return data;
1030
+ }
1031
+ async function ask(given, message, required) {
1032
+ if (given !== void 0) return given.trim() || void 0;
1033
+ if (!interactive()) {
1034
+ if (required) throw new UsageError(`Missing ${message}`);
1035
+ return;
1036
+ }
1037
+ return unwrap(await text({
1038
+ message,
1039
+ validate: required ? (v) => v?.trim() ? void 0 : "Required." : void 0
1040
+ }))?.trim() || void 0;
1041
+ }
1042
+ /** `--collection`, the only one there is, or a choice. Every organization item needs one. */
1043
+ async function pickCollection(vault, typed) {
1044
+ const all = await vault.collections();
1045
+ if (typed !== void 0) {
1046
+ const needle = typed.trim().toLowerCase();
1047
+ const match = all.find((c) => c.name.toLowerCase() === needle);
1048
+ if (!match) throw new UsageError(`No collection called "${typed}" that you can see. There are: ${all.map((c) => c.name).join(", ") || "none"}.`);
1049
+ return match;
1050
+ }
1051
+ if (all.length === 0) throw new UsageError("You cannot see any collection in the DevDogs organization to save to. Ask an organization admin for access to one.");
1052
+ if (all.length === 1) return all[0];
1053
+ if (!interactive()) throw new UsageError(`Name the collection with --collection: ${all.map((c) => c.name).join(", ")}.`);
1054
+ const id = unwrap(await select({
1055
+ message: "Which collection?",
1056
+ options: all.map((c) => ({
1057
+ value: c.id,
1058
+ label: c.name
1059
+ }))
1060
+ }));
1061
+ return all.find((c) => c.id === id);
1062
+ }
1063
+ async function runAdd(vault, values, deps) {
1064
+ const existing = await vault.listItems();
1065
+ const name = await ask(values.name, "Name? (e.g. Instagram)", true);
1066
+ if (existing.some((i) => i.name.toLowerCase() === name.toLowerCase())) throw new UsageError(`"${name}" is already in the organization. Use \`backstage creds send --item "${name}"\`.`);
1067
+ const url = await ask(values.url, "Login page URL? (optional)", false);
1068
+ const username = await ask(values.username, "Username or email? (optional)", false);
1069
+ const password = await readPassword(values, deps);
1070
+ registerSecret(password);
1071
+ const notes = interactive() ? await ask(void 0, "Notes for recipients? (optional, never printed)", false) : void 0;
1072
+ const owner = await ask(values.owner, "Owner? (the officer responsible)", false);
1073
+ const collection = await pickCollection(vault, values.collection);
1074
+ const item = await vault.createLogin({
1075
+ collection,
1076
+ name,
1077
+ url,
1078
+ username,
1079
+ password,
1080
+ notes,
1081
+ owner
1082
+ });
1083
+ say(`Saved ${item.name} to the ${collection.name} collection.`, "success");
1084
+ await sendItems(vault, [item], values, deps);
1085
+ }
1086
+ async function runRenew(vault, values, deps) {
1087
+ const now = (deps.now ?? (() => /* @__PURE__ */ new Date()))();
1088
+ const all = await vault.listItems();
1089
+ const ready = (values.item && values.item.length > 0 ? values.item.map((t) => findItem(all, t)) : all.filter((i) => i.send !== void 0 || i.recipients.length > 0)).filter((i) => {
1090
+ if (i.recipients.length > 0) return true;
1091
+ say(`${i.name} has no recipients; share it with \`creds send\` first.`, "warn");
1092
+ return false;
1093
+ });
1094
+ const early = ready.filter((i) => (daysLeft(i, now) ?? 0) > 7);
1095
+ let renew = ready;
1096
+ if (early.length > 0) {
1097
+ if (!await confirmOrRefuse(`More than 7 days left on: ${early.map((i) => `${i.name} (${daysLeft(i, now)} days left)`).join(", ")}. Renew ${early.length === 1 ? "it" : "them"} anyway?`, values.yes === true)) renew = ready.filter((i) => !early.includes(i));
1098
+ }
1099
+ if (renew.length === 0) {
1100
+ say("Nothing to renew.");
1101
+ return;
1102
+ }
1103
+ const plans = [];
1104
+ for (const item of renew) plans.push(await plan(vault, item, item.recipients));
1105
+ if ((await sendAll(vault, plans, {
1106
+ ...values,
1107
+ yes: true
1108
+ }, deps, void 0)).length > 0) await reportAfter(vault, values, deps, void 0);
1109
+ }
1110
+ async function runList(vault, values, deps) {
1111
+ const now = (deps.now ?? (() => /* @__PURE__ */ new Date()))();
1112
+ const rows = (await vault.listItems()).map((i) => ({
1113
+ name: i.name,
1114
+ account: i.username ?? null,
1115
+ url: i.url ?? null,
1116
+ recipients: i.recipients,
1117
+ owner: i.owner ?? null,
1118
+ status: sendStatus(i, now),
1119
+ expires: i.send?.expires?.toISOString() ?? null,
1120
+ sendLink: i.send?.link ?? null
1121
+ }));
1122
+ if (values.json) {
1123
+ process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
1124
+ return;
1125
+ }
1126
+ if (rows.length === 0) {
1127
+ process.stdout.write("No shared accounts yet. Add one with `backstage creds add`.\n");
1128
+ return;
1129
+ }
1130
+ for (const r of rows) process.stdout.write(`${r.name}${r.account ? ` (${r.account})` : ""}: ${r.status}, until ${isoDay(r.expires ? new Date(r.expires) : void 0)}\n owner: ${r.owner ?? "—"}\n recipients: ${r.recipients.join(", ") || "—"}\n`);
1131
+ }
1132
+ async function runCreds(argv, deps = {}) {
1133
+ try {
1134
+ const { sub, values } = parse(argv);
1135
+ const vault = await (deps.vault ?? connectSharedVault)();
1136
+ if (sub === "send") await runSend(vault, values, deps);
1137
+ else if (sub === "add") await runAdd(vault, values, deps);
1138
+ else if (sub === "renew") await runRenew(vault, values, deps);
1139
+ else if (sub === "list") await runList(vault, values, deps);
1140
+ else await report(vault, values, deps, void 0);
1141
+ } catch (err) {
1142
+ process.exitCode = 1;
1143
+ if (err instanceof UsageError || err instanceof CredsError) {
1144
+ explain(scrub(err.message), "");
1145
+ return;
1146
+ }
1147
+ const safe = new Error(scrub(errorMessage(err)));
1148
+ safe.stack = scrub(err?.stack ?? safe.message);
1149
+ explainError("creds failed.", safe);
1150
+ }
1151
+ }
1152
+ const handleCreds = async (rest) => {
1153
+ await runCreds(rest);
1154
+ return process.exitCode ? null : DONE;
1155
+ };
1156
+ //#endregion
1157
+ export { handleCreds };