@devdogsuga/backstage 0.1.4 → 0.1.6

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,352 @@
1
+ import { n as isDryRun } from "./dry-run-3IxPmCtW.js";
2
+ import { r as errorMessage, t as UsageError } from "./ui-CdKo8mLw.js";
3
+ import { t as DONE } from "./dispatch-D048O65I.js";
4
+ import { a as reportFailure, d as withConnection, i as readInputFile, n as confirmWrite, o as say, r as plural, t as canPrompt, u as requireProductionKey } from "./cli-Do2nWD7W.js";
5
+ import { a as createAccounts, i as createAccountFor, n as accountFinder, o as readAccounts, t as parseCsv } from "./read-B-aJJewb.js";
6
+ import { parseArgs } from "node:util";
7
+ //#region src/involvement/csv.ts
8
+ /**
9
+ * Reading the UGA Involvement Network's "Organization Roster" export.
10
+ *
11
+ * The export is not a plain CSV. It opens with a title line, a blank line and
12
+ * an `Organization,date` line before the header, hides most columns as
13
+ * `(Hidden)`, and lists a person once per position they hold, so an officer
14
+ * appears as both `Member` and `President`. The header is found rather than
15
+ * assumed to be the first line, which is what broke the platform's upload: it
16
+ * took `Organization Roster` for the header and found no rows.
17
+ */
18
+ var RosterFormatError = class extends Error {
19
+ name = "RosterFormatError";
20
+ };
21
+ /** Header names for each field, the first one present wins. */
22
+ const COLUMNS = {
23
+ firstName: ["first name"],
24
+ lastName: ["last name"],
25
+ email: ["campus email", "email"],
26
+ organization: ["organization name"],
27
+ position: ["position name"]
28
+ };
29
+ function columnIndex(header, names) {
30
+ const normalized = header.map((h) => h.trim().toLowerCase());
31
+ for (const name of names) {
32
+ const i = normalized.indexOf(name);
33
+ if (i >= 0) return i;
34
+ }
35
+ return -1;
36
+ }
37
+ function clean(value) {
38
+ const v = (value ?? "").trim();
39
+ return v === "(Hidden)" ? "" : v;
40
+ }
41
+ function parseRoster(text) {
42
+ const rows = parseCsv(text);
43
+ const headerAt = rows.findIndex((r) => columnIndex(r, COLUMNS.firstName) >= 0 && columnIndex(r, COLUMNS.lastName) >= 0 && columnIndex(r, COLUMNS.email) >= 0);
44
+ if (headerAt < 0) throw new RosterFormatError("No header row with \"First Name\", \"Last Name\" and \"Campus Email\". Export the roster from the Involvement Network's Roster page (Export → Organization Roster) and pass that file.");
45
+ const header = rows[headerAt];
46
+ const at = {
47
+ firstName: columnIndex(header, COLUMNS.firstName),
48
+ lastName: columnIndex(header, COLUMNS.lastName),
49
+ email: columnIndex(header, COLUMNS.email),
50
+ organization: columnIndex(header, COLUMNS.organization),
51
+ position: columnIndex(header, COLUMNS.position)
52
+ };
53
+ const byEmail = /* @__PURE__ */ new Map();
54
+ const organizations = /* @__PURE__ */ new Set();
55
+ const skippedLines = [];
56
+ const conflicting = /* @__PURE__ */ new Set();
57
+ rows.slice(headerAt + 1).forEach((row, i) => {
58
+ if (row.every((cell) => cell.trim() === "")) return;
59
+ const email = clean(row[at.email]).toLowerCase();
60
+ const firstName = clean(row[at.firstName]);
61
+ const lastName = clean(row[at.lastName]);
62
+ if (!email.includes("@") || !firstName || !lastName) {
63
+ skippedLines.push(headerAt + i + 2);
64
+ return;
65
+ }
66
+ if (at.organization >= 0) {
67
+ const org = clean(row[at.organization]);
68
+ if (org) organizations.add(org);
69
+ }
70
+ const position = at.position >= 0 ? clean(row[at.position]) : "";
71
+ const existing = byEmail.get(email);
72
+ if (existing) {
73
+ if (existing.firstName !== firstName || existing.lastName !== lastName) conflicting.add(email);
74
+ if (position && !existing.positions.includes(position)) existing.positions.push(position);
75
+ return;
76
+ }
77
+ byEmail.set(email, {
78
+ email,
79
+ firstName,
80
+ lastName,
81
+ positions: position ? [position] : []
82
+ });
83
+ });
84
+ return {
85
+ members: [...byEmail.values()],
86
+ organizations: [...organizations],
87
+ skippedLines,
88
+ conflictingNames: [...conflicting]
89
+ };
90
+ }
91
+ //#endregion
92
+ //#region src/involvement/plan.ts
93
+ /**
94
+ * What an import would do, worked out before anything is written.
95
+ *
96
+ * The roster is the whole truth about who is involved: everyone on it is
97
+ * verified, and everyone verified who is not on it loses that status. So the
98
+ * plan is four lists, and the preview prints them before the confirmation.
99
+ *
100
+ * Preferred names are never changed: the roster's spelling may not be the
101
+ * name a member goes by, and the preferred name is what the site shows. A
102
+ * difference is listed for an officer to follow up on.
103
+ *
104
+ * A roster email finds its account the way `production/accounts.ts` says:
105
+ * the profile's `ugaEmail` first, then the sign-in address.
106
+ */
107
+ function nonBlank(value) {
108
+ const v = value?.trim();
109
+ if (!v) return void 0;
110
+ return v;
111
+ }
112
+ function planImport(members, accounts) {
113
+ const find = accountFinder(accounts);
114
+ const plan = {
115
+ create: [],
116
+ verify: [],
117
+ keep: [],
118
+ drop: [],
119
+ sharedAccounts: [],
120
+ nameDiffers: []
121
+ };
122
+ const claimed = /* @__PURE__ */ new Set();
123
+ for (const member of members) {
124
+ const account = find(member.email);
125
+ if (!account) {
126
+ plan.create.push(member);
127
+ continue;
128
+ }
129
+ if (claimed.has(account.userId)) {
130
+ plan.sharedAccounts.push({
131
+ email: member.email,
132
+ userId: account.userId
133
+ });
134
+ continue;
135
+ }
136
+ claimed.add(account.userId);
137
+ const match = {
138
+ member,
139
+ userId: account.userId,
140
+ hasProfile: account.hasProfile,
141
+ preferredName: account.preferredName
142
+ };
143
+ const rosterName = `${member.firstName} ${member.lastName}`.toLowerCase();
144
+ if (account.hasProfile && account.preferredName?.trim().toLowerCase() !== rosterName) plan.nameDiffers.push(match);
145
+ if (account.involvementFirstName != null) plan.keep.push(match);
146
+ else plan.verify.push(match);
147
+ }
148
+ for (const account of accounts) {
149
+ if (account.involvementFirstName == null || claimed.has(account.userId)) continue;
150
+ plan.drop.push({
151
+ userId: account.userId,
152
+ name: nonBlank(account.preferredName) ?? `${account.involvementFirstName} ${account.involvementLastName ?? ""}`.trim()
153
+ });
154
+ }
155
+ plan.drop.sort((a, b) => a.name.localeCompare(b.name));
156
+ return plan;
157
+ }
158
+ /** Everyone the import writes a profile for, created accounts excluded. */
159
+ function matches(plan) {
160
+ return [...plan.verify, ...plan.keep];
161
+ }
162
+ //#endregion
163
+ //#region src/involvement/store.ts
164
+ /**
165
+ * The write half of `import involvement`, after `plan.ts` has matched the
166
+ * roster against production's accounts (`production/accounts.ts`) and any
167
+ * missing accounts have been created.
168
+ *
169
+ * Accounts are created before this transaction, because Auth is a separate
170
+ * service. If the transaction then fails, those accounts exist without
171
+ * profiles; the next run finds them by sign-in address and inserts the
172
+ * profiles, so a rerun is the recovery.
173
+ */
174
+ /**
175
+ * Upserts every roster member's profile, then clears involvement from every
176
+ * profile not among them, in one transaction.
177
+ *
178
+ * A new profile takes the roster's name as its preferred name; an existing
179
+ * one keeps its own (see `plan.ts`). The durable identity columns (`ugaEmail`, `legal*`,
180
+ * `identitySourcedAt`) are written for everyone on the roster and never
181
+ * cleared, so missing one export does not erase who someone is.
182
+ */
183
+ const APPLY_UPSERT = `
184
+ insert into "platform"."profile" as p (
185
+ "userId", "preferredName",
186
+ "involvementFirstName", "involvementLastName", "involvementImportedAt",
187
+ "ugaEmail", "legalFirstName", "legalLastName", "identitySourcedAt"
188
+ )
189
+ select t.u, t.f || ' ' || t.l, t.f, t.l, now(), t.e, t.f, t.l, now()
190
+ from unnest($1::uuid[], $2::text[], $3::text[], $4::text[]) as t(u, f, l, e)
191
+ on conflict ("userId") do update set
192
+ "involvementFirstName" = excluded."involvementFirstName",
193
+ "involvementLastName" = excluded."involvementLastName",
194
+ "involvementImportedAt" = excluded."involvementImportedAt",
195
+ "ugaEmail" = excluded."ugaEmail",
196
+ "legalFirstName" = excluded."legalFirstName",
197
+ "legalLastName" = excluded."legalLastName",
198
+ "identitySourcedAt" = excluded."identitySourcedAt"
199
+ `;
200
+ const APPLY_CLEAR = `
201
+ update "platform"."profile"
202
+ set "involvementFirstName" = null,
203
+ "involvementLastName" = null,
204
+ "involvementImportedAt" = null
205
+ where not ("userId" = any($1::uuid[]))
206
+ and ("involvementFirstName" is not null or "involvementImportedAt" is not null)
207
+ `;
208
+ function applyImportFor(url) {
209
+ return (writes) => {
210
+ const ids = writes.map((w) => w.userId);
211
+ return withConnection(url, "The import was rolled back", (sql) => sql.begin(async (tx) => {
212
+ const upserted = await tx.unsafe(APPLY_UPSERT, [
213
+ ids,
214
+ writes.map((w) => w.firstName),
215
+ writes.map((w) => w.lastName),
216
+ writes.map((w) => w.email)
217
+ ]);
218
+ const cleared = await tx.unsafe(APPLY_CLEAR, [ids]);
219
+ return {
220
+ written: upserted.count,
221
+ cleared: cleared.count
222
+ };
223
+ }));
224
+ };
225
+ }
226
+ //#endregion
227
+ //#region src/involvement/commands.ts
228
+ /**
229
+ * `backstage import involvement --file <OrganizationRoster.csv>`: verify the
230
+ * members on the UGA Involvement Network roster.
231
+ *
232
+ * Replaces the platform's /console/verification upload. Officers export the
233
+ * roster from the Involvement Network (Roster → Export → Organization Roster)
234
+ * and run this against production:
235
+ *
236
+ * 1. read and check the export (`csv.ts`): its organization must be DevDogs;
237
+ * 2. read every account and plan the import (`plan.ts`);
238
+ * 3. print who is newly verified, who loses verification, and how many
239
+ * accounts would be created, then ask;
240
+ * 4. create the missing accounts and write the profiles (`store.ts`).
241
+ *
242
+ * `--dry-run` stops after the preview. Production's `DB_URL`, `API_URL` and
243
+ * `SECRET_KEY` come from the checkout's `.env.production`, else Secrets
244
+ * Manager, so it runs under `pnpm dlx` with no checkout.
245
+ *
246
+ * Preferred names are never changed, so the homepage's officer board (which
247
+ * caches them) needs no invalidation.
248
+ */
249
+ /** The organization a roster must be for. Compared case-insensitively. */
250
+ const ORGANIZATION = "DevDogs";
251
+ function parse(argv) {
252
+ try {
253
+ return parseArgs({
254
+ args: [...argv],
255
+ options: {
256
+ file: { type: "string" },
257
+ yes: { type: "boolean" },
258
+ "dry-run": { type: "boolean" }
259
+ },
260
+ allowPositionals: false,
261
+ strict: true
262
+ }).values;
263
+ } catch (err) {
264
+ throw new UsageError(errorMessage(err));
265
+ }
266
+ }
267
+ /** The preview: what changes, by whom. Never lists every kept member. */
268
+ function describePlan(plan, members) {
269
+ const lines = [
270
+ `${plural(members, "member")} on the roster:`,
271
+ ` ${plan.keep.length} already verified`,
272
+ ` ${plan.verify.length} newly verified (have an account)`,
273
+ ` ${plan.create.length} without an account, to be created and verified`,
274
+ ` ${plan.drop.length} verified now but not on the roster, to lose verification`
275
+ ];
276
+ if (plan.verify.length > 0) lines.push("", "Newly verified:", ...plan.verify.map((m) => ` ${m.member.firstName} ${m.member.lastName} <${m.member.email}>`));
277
+ if (plan.drop.length > 0) lines.push("", "Losing verification:", ...plan.drop.map((d) => ` ${d.name}`));
278
+ if (plan.nameDiffers.length > 0) lines.push("", "Preferred name differs from the roster (left as is):", ...plan.nameDiffers.map((m) => ` ${m.preferredName ?? "(none)"}: roster says ${m.member.firstName} ${m.member.lastName}`));
279
+ if (plan.sharedAccounts.length > 0) lines.push("", "Skipped, their account already matched another roster email:", ...plan.sharedAccounts.map((s) => ` ${s.email}`));
280
+ return lines.join("\n");
281
+ }
282
+ async function runInvolvement(argv, deps = {}) {
283
+ const interactive = deps.interactive ?? canPrompt();
284
+ try {
285
+ const values = parse(argv);
286
+ const dryRun = values["dry-run"] === true || isDryRun();
287
+ const roster = parseRoster(await readInputFile(values.file, {
288
+ interactive,
289
+ message: "Path to the Involvement Network roster export",
290
+ placeholder: "OrganizationRoster.csv",
291
+ read: deps.readFile
292
+ }));
293
+ const wrongOrg = roster.organizations.filter((o) => o.toLowerCase() !== ORGANIZATION.toLowerCase());
294
+ if (wrongOrg.length > 0) throw new UsageError(`This roster is for ${wrongOrg.join(", ")}, not ${ORGANIZATION}.`);
295
+ if (roster.members.length === 0) throw new UsageError("The roster has no members with a name and email.");
296
+ if (roster.skippedLines.length > 0) say(`Skipped ${plural(roster.skippedLines.length, "row")} missing a name or email (lines ${roster.skippedLines.join(", ")}).`, "warn");
297
+ if (roster.conflictingNames.length > 0) say(`Listed under two names, kept the first: ${roster.conflictingNames.join(", ")}.`, "warn");
298
+ const dbUrl = deps.accounts ? void 0 : await requireProductionKey("DB_URL");
299
+ const accounts = await (deps.accounts ?? (() => readAccounts(dbUrl)))();
300
+ const plan = planImport(roster.members, accounts);
301
+ process.stderr.write(`${describePlan(plan, roster.members.length)}\n`);
302
+ if (dryRun) {
303
+ say("Dry run: nothing written.");
304
+ return;
305
+ }
306
+ if (plan.create.length === 0 && plan.verify.length === 0 && plan.drop.length === 0 && plan.keep.length === 0) {
307
+ say("Nothing to import.");
308
+ return;
309
+ }
310
+ const question = `Import into production: create ${plural(plan.create.length, "account")}, verify ${plan.verify.length}, unverify ${plan.drop.length}?`;
311
+ if (!await confirmWrite(question, {
312
+ yes: values.yes === true,
313
+ interactive
314
+ })) {
315
+ say("Nothing written.");
316
+ return;
317
+ }
318
+ let create = deps.createAccount;
319
+ if (!create && plan.create.length > 0) create = createAccountFor(await requireProductionKey("API_URL"), await requireProductionKey("SECRET_KEY"));
320
+ const { created: createdIds, failed } = create ? await createAccounts(plan.create.map((m) => m.email), create) : {
321
+ created: /* @__PURE__ */ new Map(),
322
+ failed: []
323
+ };
324
+ const created = plan.create.flatMap((m) => {
325
+ const userId = createdIds.get(m.email);
326
+ return userId ? [{
327
+ userId,
328
+ email: m.email,
329
+ firstName: m.firstName,
330
+ lastName: m.lastName
331
+ }] : [];
332
+ });
333
+ if (failed.length > 0) say(`Could not create ${plural(failed.length, "account")}; they stay unverified until the next import:\n${failed.map((f) => ` ${f.email}: ${f.reason}`).join("\n")}`, "warn");
334
+ const writes = [...matches(plan).map((m) => ({
335
+ userId: m.userId,
336
+ email: m.member.email,
337
+ firstName: m.member.firstName,
338
+ lastName: m.member.lastName
339
+ })), ...created];
340
+ const { written, cleared } = await (deps.apply ?? applyImportFor(dbUrl))(writes);
341
+ say(`Imported: ${plural(written, "profile")} verified (${plural(created.length, "new account")}), ${cleared} unverified.`, "success");
342
+ if (failed.length > 0) process.exitCode = 1;
343
+ } catch (err) {
344
+ reportFailure("import involvement", err, [RosterFormatError]);
345
+ }
346
+ }
347
+ const handleInvolvement = async (rest) => {
348
+ await runInvolvement(rest);
349
+ return process.exitCode ? null : DONE;
350
+ };
351
+ //#endregion
352
+ export { handleInvolvement };
@@ -0,0 +1,12 @@
1
+ import { i as explain } from "./ui-CdKo8mLw.js";
2
+ //#region src/import/commands.ts
3
+ const handleImport = async (rest) => {
4
+ const [what, ...args] = rest;
5
+ if (what === "involvement") return (await import("./commands-CLSxKz-l.js")).handleInvolvement(args);
6
+ if (what === "attendance") return (await import("./commands-BQ2NIqub.js")).handleAttendance(args);
7
+ explain(what ? `Unknown import "${what}". Try involvement or attendance.` : "Name the import: involvement or attendance.", "");
8
+ process.exitCode = 1;
9
+ return null;
10
+ };
11
+ //#endregion
12
+ export { handleImport };
@@ -1,6 +1,7 @@
1
1
  import { p as isNonInteractive } from "./telemetry-Bjoz29Hl.js";
2
- import { i as qrParseOptions, n as QR_FLAGS, o as isDryRun, t as QR_EXTRA_FLAGS } from "./options-BTjOf5KP.js";
2
+ import { n as isDryRun } from "./dry-run-3IxPmCtW.js";
3
3
  import { a as explainError, o as unwrap, t as UsageError } from "./ui-CdKo8mLw.js";
4
+ import { i as qrParseOptions, n as QR_FLAGS, t as QR_EXTRA_FLAGS } from "./options-DCaqm8BI.js";
4
5
  import { t as DONE } from "./dispatch-D048O65I.js";
5
6
  import { dirname, extname, relative, resolve } from "node:path";
6
7
  import { log, text } from "@clack/prompts";
@@ -1,10 +1,11 @@
1
1
  import { C as discoverRepoRoot, p as isNonInteractive } from "./telemetry-Bjoz29Hl.js";
2
2
  import { t as nonEmpty } from "./connection-D3XsOE3S.js";
3
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
4
  import { t as DONE } from "./dispatch-D048O65I.js";
5
+ import { t as EnvDocument } from "./document-DumoZXoO.js";
6
+ import { i as readPasswordFromVault, n as bwArgs, r as openVault, s as bwCommand } from "./vault-CsBC-1Mq.js";
6
7
  import { resolve } from "node:path";
7
- import { confirm, log, multiselect, note, password, text } from "@clack/prompts";
8
+ import { confirm, log, multiselect, note, password, select, text } from "@clack/prompts";
8
9
  import { readFile } from "node:fs/promises";
9
10
  import { spawn } from "node:child_process";
10
11
  import { parseArgs } from "node:util";
@@ -51,12 +52,12 @@ var CredsError = class extends Error {
51
52
  //#endregion
52
53
  //#region src/creds/item.ts
53
54
  /**
54
- * A shared account as `creds` sees it: a Bitwarden item in the Shared Accounts
55
- * collection, plus the custom fields that record who has it.
55
+ * A shared account as `creds` sees it: a login or secure note in the DevDogs
56
+ * Bitwarden organization, plus the custom fields that record who has it.
56
57
  *
57
58
  * Those fields are the only access record. The Send, the Linear report and
58
59
  * every "who has Canva?" answer are generated from them, so they are plain
59
- * text fields anyone in the collection can read and correct in Bitwarden
60
+ * text fields anyone with the item can read and correct in Bitwarden
60
61
  * itself:
61
62
  *
62
63
  * Recipients comma-separated UGA emails, the Send's `--emails` list
@@ -194,8 +195,8 @@ function isoDay(date) {
194
195
  //#endregion
195
196
  //#region src/creds/bitwarden.ts
196
197
  /**
197
- * The Shared Accounts collection and the Sends made from it, through the
198
- * bundled `bw` on the officer's personal session (`bws/vault.ts`).
198
+ * The DevDogs Bitwarden organization's items and the Sends made from them,
199
+ * through the bundled `bw` on the officer's personal session (`bws/vault.ts`).
199
200
  *
200
201
  * Sends belong to the account that creates them, not to the organization, so
201
202
  * this always runs as a person and never on CI.
@@ -203,20 +204,23 @@ function isoDay(date) {
203
204
  * ## Secret handling
204
205
  *
205
206
  * * 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,
207
- * the collection id and the session key `bwArgs` already passes.
207
+ * `bw edit` and `bw send create|edit` document. argv carries only ids and
208
+ * the session key `bwArgs` already passes.
208
209
  * * `bw`'s stdout is parsed and never printed: an item or a Send read back
209
210
  * holds the password.
210
211
  * * `bw`'s stderr is only shown through `CredsError`, which scrubs every value
211
212
  * registered so far.
212
213
  *
213
- * ## The collection fence
214
+ * ## The organization fence
214
215
  *
215
- * `creds` reads and writes items in ONE collection, found by name. An item
216
- * that is not in it (a personal login, a production secret) is never listed,
217
- * never edited and never sent; `updateItem` re-checks before every write.
216
+ * `creds` reads and writes the logins and secure notes of ONE organization,
217
+ * found by name. Anything else the account can see (a personal login, another
218
+ * organization's item) is never listed, never edited and never sent;
219
+ * `updateItem` re-checks before every write. Production secrets are not
220
+ * vault items at all: they live in Secrets Manager, which `bw` cannot read.
218
221
  */
219
- const COLLECTION_NAME = "Shared Accounts";
222
+ /** Matched case-insensitively against the organizations the account belongs to. */
223
+ const ORGANIZATION_NAME = "DevDogs";
220
224
  function encode(value) {
221
225
  return Buffer.from(JSON.stringify(value)).toString("base64");
222
226
  }
@@ -249,7 +253,7 @@ function spawnRunner(key) {
249
253
  });
250
254
  }
251
255
  let runnerOverride;
252
- /** Opens the vault (asking first) and the Shared Accounts collection in it. */
256
+ /** Opens the vault (asking first) and finds the DevDogs organization in it. */
253
257
  async function connectSharedVault() {
254
258
  let runner = runnerOverride;
255
259
  if (!runner) {
@@ -259,7 +263,7 @@ async function connectSharedVault() {
259
263
  }
260
264
  const shared = new SharedVault(runner);
261
265
  await shared.sync();
262
- await shared.collection();
266
+ await shared.organization();
263
267
  return shared;
264
268
  }
265
269
  var SharedVault = class {
@@ -287,34 +291,45 @@ var SharedVault = class {
287
291
  async account() {
288
292
  return (await this.json(["status"])).userEmail ?? "unknown";
289
293
  }
290
- /** The one collection named {@link COLLECTION_NAME} this account can see. */
291
- async collection() {
294
+ /** The one organization named like {@link ORGANIZATION_NAME} this account belongs to. */
295
+ async organization() {
292
296
  if (this.found) return this.found;
293
- const matches = (await this.json(["list", "collections"])).filter((c) => c.name === COLLECTION_NAME);
294
- if (matches.length !== 1) throw new CredsError(matches.length === 0 ? `No "${COLLECTION_NAME}" collection is visible to your Bitwarden account. Ask a DevDogs organization admin to create it or give you access.` : `${matches.length} collections are called "${COLLECTION_NAME}"; rename the extras so \`creds\` cannot pick the wrong one.`);
297
+ const matches = (await this.json(["list", "organizations"])).filter((o) => o.name.toLowerCase().includes(ORGANIZATION_NAME.toLowerCase()));
298
+ 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.`);
295
299
  this.found = matches[0];
296
300
  return this.found;
297
301
  }
298
- inCollection(raw, collection) {
299
- return raw.organizationId === collection.organizationId && (raw.collectionIds ?? []).includes(collection.id);
302
+ shareable(raw, org) {
303
+ return raw.organizationId === org.id && (raw.type === ITEM_TYPE.login || raw.type === ITEM_TYPE.secureNote);
300
304
  }
301
- /** Every item in the collection, by name. */
305
+ /** Every login and secure note in the organization, by name. */
302
306
  async listItems() {
303
- const collection = await this.collection();
307
+ const org = await this.organization();
304
308
  return (await this.json([
305
309
  "list",
306
310
  "items",
307
- "--collectionid",
308
- collection.id
309
- ])).filter((item) => this.inCollection(item, collection)).map(parseItem).sort((a, b) => a.name.localeCompare(b.name));
311
+ "--organizationid",
312
+ org.id
313
+ ])).filter((item) => this.shareable(item, org)).map(parseItem).sort((a, b) => a.name.localeCompare(b.name));
310
314
  }
311
- /** A new login, owned by the organization and placed in the collection. */
315
+ /** The organization's collections this account can see, by name. */
316
+ async collections() {
317
+ const org = await this.organization();
318
+ return (await this.json([
319
+ "list",
320
+ "collections",
321
+ "--organizationid",
322
+ org.id
323
+ ])).filter((c) => c.organizationId === org.id).sort((a, b) => a.name.localeCompare(b.name));
324
+ }
325
+ /** A new login, owned by the organization and placed in `collection`. */
312
326
  async createLogin(input) {
313
327
  registerSecret(input.password);
314
- const collection = await this.collection();
328
+ const org = await this.organization();
329
+ if (input.collection.organizationId !== org.id) throw new CredsError(`The "${input.collection.name}" collection is not in the ${org.name} organization.`);
315
330
  const item = {
316
- organizationId: collection.organizationId,
317
- collectionIds: [collection.id],
331
+ organizationId: org.id,
332
+ collectionIds: [input.collection.id],
318
333
  folderId: null,
319
334
  type: 1,
320
335
  name: input.name,
@@ -338,10 +353,10 @@ var SharedVault = class {
338
353
  };
339
354
  return parseItem(await this.json(["create", "item"], encode(item)));
340
355
  }
341
- /** Saves `raw`, which must still be an item of the collection. */
356
+ /** Saves `raw`, which must still be a login or secure note of the organization. */
342
357
  async updateItem(raw) {
343
- const collection = await this.collection();
344
- if (!this.inCollection(raw, collection)) throw new CredsError(`"${raw.name}" is not in the ${COLLECTION_NAME} collection; creds will not change it.`);
358
+ const org = await this.organization();
359
+ 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.`);
345
360
  return parseItem(await this.json([
346
361
  "edit",
347
362
  "item",
@@ -420,7 +435,7 @@ function sendName(item) {
420
435
  }
421
436
  /** Private to the sender: how to find the item this Send came from. */
422
437
  function sendNotes(item) {
423
- return `Made by backstage creds from the ${COLLECTION_NAME} item "${item.name}" (${item.id}).`;
438
+ return `Made by backstage creds from the ${ORGANIZATION_NAME} item "${item.name}" (${item.id}).`;
424
439
  }
425
440
  function deletionDate(now) {
426
441
  return new Date(now.getTime() + 2592e6).toISOString();
@@ -617,7 +632,7 @@ async function readCheckoutDbUrl() {
617
632
  }
618
633
  }
619
634
  async function readSecretsManagerDbUrl() {
620
- const { listSecrets, projectIdFor } = await import("./client-DzXQHd58.js");
635
+ const { listSecrets, projectIdFor } = await import("./client-DwDGjalU.js");
621
636
  const secrets = await listSecrets(await projectIdFor(PRODUCTION_PROJECT));
622
637
  return nonEmpty(secrets.find((s) => s.key === "DB_URL")?.value);
623
638
  }
@@ -653,17 +668,18 @@ function cell(text) {
653
668
  /** The document's markdown. Pure: `now` decides the statuses. */
654
669
  function renderReport(items, now, roster) {
655
670
  const lines = [
656
- "> Generated by `backstage creds` from the Shared Accounts collection in the DevDogs Bitwarden organization. Manual edits are overwritten on the next run; change the item's fields in Bitwarden, or run `backstage creds send`.",
671
+ "> 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`.",
657
672
  "",
658
673
  `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\`.`,
659
674
  ""
660
675
  ];
661
- if (items.length === 0) {
662
- lines.push("No shared accounts in the collection yet.");
676
+ const shared = items.filter((i) => i.recipients.length > 0 || i.send !== void 0);
677
+ if (shared.length === 0) {
678
+ lines.push("Nothing has been shared yet.");
663
679
  return `${lines.join("\n")}\n`;
664
680
  }
665
681
  lines.push("| Credential | Account | Recipients | Owner | Status | Expires | Send |", "| --- | --- | --- | --- | --- | --- | --- |");
666
- for (const item of items) {
682
+ for (const item of shared) {
667
683
  const recipients = item.recipients.length > 0 ? item.recipients.map((e) => describeRecipient(roster, e)).join(", ") : "—";
668
684
  const link = item.send?.link ? `[Open](${item.send.link})` : "—";
669
685
  lines.push(`| ${[
@@ -729,7 +745,7 @@ async function writeReport(token, documentId, content, fetchImpl = fetch) {
729
745
  * shared as Bitwarden Sends that only the officers they name can open.
730
746
  *
731
747
  * send pick shared accounts and officers; create or update one Send each
732
- * add save a new login into the Shared Accounts collection, then send it
748
+ * add save a new login to the DevDogs organization, then send it
733
749
  * renew push every Send's deletion 30 days out and re-sync its recipients
734
750
  * list what is shared with whom, until when
735
751
  * report regenerate the Shared Accounts Linear document
@@ -738,8 +754,8 @@ async function writeReport(token, documentId, content, fetchImpl = fetch) {
738
754
  *
739
755
  * ## Where things live
740
756
  *
741
- * The values live only in the DevDogs Bitwarden organization's Shared
742
- * Accounts collection. Each item's custom fields record its recipients and its
757
+ * The values live only in the DevDogs Bitwarden organization: any login or
758
+ * secure note in it can be shared. Each item's custom fields record its recipients and its
743
759
  * Send (`item.ts`); the Linear document is generated from them (`report.ts`);
744
760
  * the roster of who may receive one is read live from production
745
761
  * (`roster.ts`).
@@ -790,6 +806,7 @@ function parse(argv) {
790
806
  url: { type: "string" },
791
807
  username: { type: "string" },
792
808
  owner: { type: "string" },
809
+ collection: { type: "string" },
793
810
  "password-stdin": { type: "boolean" },
794
811
  document: { type: "string" },
795
812
  "dry-run": { type: "boolean" }
@@ -831,7 +848,7 @@ function findItem(items, typed) {
831
848
  }
832
849
  async function pickItems(items, typed, message) {
833
850
  if (typed && typed.length > 0) return typed.map((t) => findItem(items, t));
834
- if (items.length === 0) throw new UsageError("The Shared Accounts collection is empty. Add one with `backstage creds add`.");
851
+ if (items.length === 0) throw new UsageError("The DevDogs organization has no logins you can see. Add one with `backstage creds add`.");
835
852
  if (!interactive()) throw new UsageError("Name the accounts with --item <name>.");
836
853
  const ids = unwrap(await multiselect({
837
854
  message,
@@ -1023,17 +1040,40 @@ async function ask(given, message, required) {
1023
1040
  validate: required ? (v) => v?.trim() ? void 0 : "Required." : void 0
1024
1041
  }))?.trim() || void 0;
1025
1042
  }
1043
+ /** `--collection`, the only one there is, or a choice. Every organization item needs one. */
1044
+ async function pickCollection(vault, typed) {
1045
+ const all = await vault.collections();
1046
+ if (typed !== void 0) {
1047
+ const needle = typed.trim().toLowerCase();
1048
+ const match = all.find((c) => c.name.toLowerCase() === needle);
1049
+ if (!match) throw new UsageError(`No collection called "${typed}" that you can see. There are: ${all.map((c) => c.name).join(", ") || "none"}.`);
1050
+ return match;
1051
+ }
1052
+ 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.");
1053
+ if (all.length === 1) return all[0];
1054
+ if (!interactive()) throw new UsageError(`Name the collection with --collection: ${all.map((c) => c.name).join(", ")}.`);
1055
+ const id = unwrap(await select({
1056
+ message: "Which collection?",
1057
+ options: all.map((c) => ({
1058
+ value: c.id,
1059
+ label: c.name
1060
+ }))
1061
+ }));
1062
+ return all.find((c) => c.id === id);
1063
+ }
1026
1064
  async function runAdd(vault, values, deps) {
1027
1065
  const existing = await vault.listItems();
1028
1066
  const name = await ask(values.name, "Name? (e.g. Instagram)", true);
1029
- if (existing.some((i) => i.name.toLowerCase() === name.toLowerCase())) throw new UsageError(`"${name}" is already in the collection. Use \`backstage creds send --item "${name}"\`.`);
1067
+ if (existing.some((i) => i.name.toLowerCase() === name.toLowerCase())) throw new UsageError(`"${name}" is already in the organization. Use \`backstage creds send --item "${name}"\`.`);
1030
1068
  const url = await ask(values.url, "Login page URL? (optional)", false);
1031
1069
  const username = await ask(values.username, "Username or email? (optional)", false);
1032
1070
  const password = await readPassword(values, deps);
1033
1071
  registerSecret(password);
1034
1072
  const notes = interactive() ? await ask(void 0, "Notes for recipients? (optional, never printed)", false) : void 0;
1035
1073
  const owner = await ask(values.owner, "Owner? (the officer responsible)", false);
1074
+ const collection = await pickCollection(vault, values.collection);
1036
1075
  const item = await vault.createLogin({
1076
+ collection,
1037
1077
  name,
1038
1078
  url,
1039
1079
  username,
@@ -1041,7 +1081,7 @@ async function runAdd(vault, values, deps) {
1041
1081
  notes,
1042
1082
  owner
1043
1083
  });
1044
- say(`Saved ${item.name} to the Shared Accounts collection.`, "success");
1084
+ say(`Saved ${item.name} to the ${collection.name} collection.`, "success");
1045
1085
  await sendItems(vault, [item], values, deps);
1046
1086
  }
1047
1087
  async function runRenew(vault, values, deps) {