@devdogsuga/backstage 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ match. The two flags matter outside a DevDogsUGA checkout, where the workspace's
14
14
  `pnpm backstage` is the script).
15
15
 
16
16
  It **starts without a checkout**. Help, `version`, `completions`, the tools
17
- below that need no secrets (`graphics`, `qr`, `github`, `newsletter`) and
17
+ below that need no secrets (`graphics`, `qr`, `github`, `newsletter`, `creds`) and
18
18
  anything run with `--no-env` never look for one; a command that reads a checkout says
19
19
  "run this from inside a DevDogsUGA clone" and exits 1. The DevDogsUGA libraries
20
20
  it reads (`@devdogsuga/env`, `@devdogsuga/db`) are optional peers, resolved
@@ -31,6 +31,8 @@ pnpm backstage --no-env planner status # the preflight credential, fro
31
31
  pnpm backstage graphics 'event/*' --out ~/images # club images, no checkout
32
32
  pnpm backstage qr https://devdogsuga.org --format svg,png,webp --logo acm
33
33
  pnpm backstage newsletter send 3.0.1 --to a@uga.edu # asks first; --yes with no terminal
34
+ pnpm backstage creds # share a club login with officers, as a Bitwarden Send
35
+ pnpm backstage creds renew # extend every Send 30 days
34
36
  ```
35
37
 
36
38
  ## Commands
@@ -49,6 +51,7 @@ pnpm backstage newsletter send 3.0.1 --to a@uga.edu # asks first; --yes with n
49
51
  | `qr <text>` | QR codes with every option of `/console/qr`. |
50
52
  | `github rulesets\|settings` | Diff (and with `--apply` write) GitHub config, through `gh`. |
51
53
  | `newsletter render\|draft\|send <issue…>` | Changelog issues as files, mailbox drafts, or a send. |
54
+ | `creds send\|add\|renew\|list\|report` | Club logins from Bitwarden as email-verified Sends, and the Linear report. |
52
55
 
53
56
  `smoke` and `reconcile` replace DevDogsUGA's `packages/deploy-checks`. The
54
57
  per-app data (hosts, public paths, the protected path and its redirect) stays in
@@ -106,6 +109,47 @@ entry for them (`envFree` in the command tree).
106
109
  Microsoft or UGA's tenant blocks it, sending needs a club-owned app
107
110
  registration.
108
111
 
112
+ ## Shared logins: `creds`
113
+
114
+ The club's shared logins (Instagram, Canva, ArchPass, Linktree, later project
115
+ API keys) live only in the **Shared Accounts** collection of the DevDogs
116
+ Bitwarden organization. `creds` hands them to officers as Bitwarden Sends:
117
+
118
+ - **`send`** picks items and recipients (officers by name or by role, read live
119
+ from production: everyone holding a role other than `Member`, by UGA MyID
120
+ email), shows a preview with the password masked as `••••••••`, then creates
121
+ or updates **one Send per item**, restricted by email verification to its
122
+ recipients and deleted 30 days out. Removing an address revokes it. Addresses
123
+ off the roster need `--allow-email`. Non-interactive: `--item`, `--to`,
124
+ `--role`, `--yes`.
125
+ - **`add`** saves a new login into the collection (org-owned), then sends it.
126
+ The password is typed at a hidden prompt, or read with `--password-stdin`;
127
+ never argv.
128
+ - **`renew`** pushes each Send's deletion 30 days out in place (the link stays)
129
+ and re-syncs its recipients and body from the item. It asks first for any Send
130
+ with more than 7 days left.
131
+ - **`list`** prints items, recipients and expiry (`--json` for scripts).
132
+ - **`report`** regenerates the **Shared Accounts** Linear document (Platform &
133
+ DevOps initiative), which `send`, `add` and `renew` also do. It is generated
134
+ whole; edits there are overwritten.
135
+
136
+ Each item's custom fields are the only access record: `Recipients`, `Owner`,
137
+ `Send ID`, `Send link`, `Send expires`, `Send account`. Sends belong to the
138
+ Bitwarden account that creates them, so `creds` runs on your own `bw` session
139
+ (signing in and unlocking after asking, or `BW_SESSION`), never on CI; renewing
140
+ a Send another officer made creates a new one, with a new link, under yours.
141
+
142
+ The roster needs production's `DB_URL`: `--db-url`, else `.env.production` in a
143
+ checkout, else Secrets Manager. The Linear key: `--linear-token`, then
144
+ `LINEAR_API_KEY`, then the vault item "DevDogs Linear API key (backstage)", then
145
+ a prompt; it is never saved.
146
+
147
+ No secret value reaches stdout, stderr, the failure log, Sentry, an error
148
+ message or argv: values go to `bw` as base64 JSON on stdin, every error is
149
+ scrubbed of every value read so far (`src/creds/secrets.ts`), and the clipboard
150
+ only ever gets the link. `src/creds/commands.test.ts` runs every path against a
151
+ fake `bw` with a sentinel password and looks for it in all of those places.
152
+
109
153
  ## Tiers, `--no-env`, CI
110
154
 
111
155
  Nothing is ever asked at launch. `--tier <t>` (anywhere in argv) or
@@ -1,11 +1,9 @@
1
- import { A as YES, D as DRY_RUN, O as JSON_FLAG, S as RepoNotFoundError, j as createCatalog, k as SCOPES, v as noteRan, w as findRepoRoot } from "./telemetry-Bjoz29Hl.js";
1
+ import { A as YES, D as DRY_RUN, O as JSON_FLAG, j as createCatalog, k as SCOPES, v as noteRan, w as findRepoRoot } from "./telemetry-Bjoz29Hl.js";
2
2
  import { o as isDryRun, r as qrCatalogOptions } from "./options-BTjOf5KP.js";
3
3
  import { o as unwrap } from "./ui-CdKo8mLw.js";
4
4
  import { n as positionals } from "./args-Cjr_Iqts.js";
5
- import { createRequire } from "node:module";
6
- import { pathToFileURL } from "node:url";
7
- import { existsSync, readFileSync, readdirSync } from "node:fs";
8
- import { dirname, join } from "node:path";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
9
7
  import { confirm, note, select, text } from "@clack/prompts";
10
8
  import "node:fs/promises";
11
9
  import { execFile, execFileSync, spawn } from "node:child_process";
@@ -193,235 +191,6 @@ function renderCommandList(catalog, version) {
193
191
  }, null, 2);
194
192
  }
195
193
  //#endregion
196
- //#region ../cli-core/src/repo/resolve.ts
197
- /**
198
- * Resolves an `@devdogsuga/*` package FROM the target repo, not from
199
- * devtools' own (dlx-isolated) `node_modules`.
200
- *
201
- * Ported from the `devtools-dlx` prototype
202
- * (`/home/sloan/scratchpad/devdogs/prototypes/devtools-dlx/FINDINGS.md`,
203
- * experiments 2 and 4 — read that file for the two gotchas this module
204
- * exists to avoid). The short version:
205
- *
206
- * - `require.resolve(\`${specifier}/package.json\`)` throws
207
- * `ERR_PACKAGE_PATH_NOT_EXPORTED` on packages (like `@devdogsuga/env`,
208
- * `@devdogsuga/open-graph`) whose `exports` map does not list
209
- * `"./package.json"`, even though the file is on disk.
210
- * - A resolved path cannot be assumed to contain a literal
211
- * `node_modules/<specifier>` segment — pnpm workspace-linked packages
212
- * resolve to their REALPATH (e.g. `packages/open-graph/dist/index.js`),
213
- * which has no such segment at all.
214
- * - `require.resolve` ignores whatever `conditions` a caller has set on
215
- * `tsx`'s `register()` — condition-based resolution (the
216
- * `devdogs-source` condition) has to be done by hand, reading the
217
- * target package's own `exports["."]` map.
218
- *
219
- * Both problems are solved the same way: walk up from the resolved file's
220
- * directory to the nearest `package.json` whose own `"name"` field matches
221
- * the specifier. That works for a real installed dependency AND a
222
- * workspace symlink, uniformly.
223
- */
224
- /**
225
- * The installable package name a (possibly subpath) specifier belongs to:
226
- * `@devdogsuga/env/load` → `@devdogsuga/env`, `foo/bar` → `foo`. A repo's
227
- * package.json declares the PACKAGE as a dependency, never a subpath, so
228
- * `findDependent` has to search on this rather than the literal specifier a
229
- * caller wants to import.
230
- */
231
- function packageNameOf(specifier) {
232
- const parts = specifier.split("/");
233
- return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
234
- }
235
- /**
236
- * Scans `apps/*` and `packages/*` under `repoRoot` for the first
237
- * `package.json` whose `dependencies`/`devDependencies`/`peerDependencies`
238
- * names `specifier`'s package (see `packageNameOf`). Returns its path
239
- * relative to `repoRoot`, or `null` if nothing in the repo depends on it.
240
- *
241
- * Re-scans the filesystem on every call — fine for a short-lived CLI
242
- * invocation calling this a handful of times; see FINDINGS item 5 if this
243
- * is ever called in a hot loop.
244
- */
245
- function findDependent(repoRoot, specifier) {
246
- const packageName = packageNameOf(specifier);
247
- for (const group of ["apps", "packages"]) {
248
- const groupDir = join(repoRoot, group);
249
- let entries;
250
- try {
251
- entries = readdirSync(groupDir);
252
- } catch {
253
- continue;
254
- }
255
- for (const entry of entries) {
256
- const pkgJsonPath = join(groupDir, entry, "package.json");
257
- if (!existsSync(pkgJsonPath)) continue;
258
- let pkg;
259
- try {
260
- pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
261
- } catch {
262
- continue;
263
- }
264
- if (packageName in {
265
- ...pkg.dependencies,
266
- ...pkg.devDependencies,
267
- ...pkg.peerDependencies
268
- }) return join(group, entry, "package.json");
269
- }
270
- }
271
- return null;
272
- }
273
- /** Walks up from `startDir` to the nearest `package.json` whose `"name"` matches `specifier`. */
274
- function findOwningPackageJson(startDir, specifier) {
275
- const packageName = packageNameOf(specifier);
276
- let dir = startDir;
277
- for (;;) {
278
- const candidate = join(dir, "package.json");
279
- if (existsSync(candidate)) try {
280
- if (JSON.parse(readFileSync(candidate, "utf8")).name === packageName) return candidate;
281
- } catch {}
282
- const parent = dirname(dir);
283
- if (parent === dir) throw new Error(`resolveFromRepo: could not find ${specifier}'s own package.json by walking up from ${startDir}`);
284
- dir = parent;
285
- }
286
- }
287
- /**
288
- * Resolves `specifier` as `resolutionBase` (a workspace-relative
289
- * `package.json` path known to depend on it, from `findDependent()`) would
290
- * see it.
291
- *
292
- * `opts.condition`, when given, is NOT passed through to Node's resolver —
293
- * `require.resolve` ignores tsx's `register({ conditions })` entirely (see
294
- * this module's header). Instead the target package's own
295
- * `exports["."]` map is read directly and `exports["."][condition]` is
296
- * picked, falling back to `.default`.
297
- */
298
- function resolveFromRepo(repoRoot, resolutionBase, specifier, opts) {
299
- const baseFile = join(repoRoot, resolutionBase);
300
- const require = createRequire(baseFile);
301
- let resolvedPath;
302
- if (opts?.condition) {
303
- const defaultEntry = require.resolve(specifier);
304
- const pkgJsonPath = findOwningPackageJson(dirname(defaultEntry), specifier);
305
- const rootExport = JSON.parse(readFileSync(pkgJsonPath, "utf8")).exports?.["."];
306
- const conditioned = rootExport && typeof rootExport === "object" ? rootExport[opts.condition] ?? rootExport.default : void 0;
307
- if (typeof conditioned !== "string") throw new Error(`resolveFromRepo: ${specifier} has no "${opts.condition}" (or "default") export in its exports["."] map`);
308
- resolvedPath = join(dirname(pkgJsonPath), conditioned);
309
- } else resolvedPath = require.resolve(specifier);
310
- const pkgJsonPath = findOwningPackageJson(dirname(resolvedPath), specifier);
311
- const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
312
- return {
313
- resolvedPath,
314
- version: pkg.version ?? "0.0.0",
315
- pkgJsonPath
316
- };
317
- }
318
- //#endregion
319
- //#region ../cli-core/src/repo/peers.ts
320
- /**
321
- * Loads devtools' optional-peer `@devdogsuga/*` libraries — the ones
322
- * published to npm and consumed by the TARGET repo, which devtools reads at
323
- * runtime rather than bundling its own copy of (see this package's
324
- * `package.json`: `peerDependencies` + `peerDependenciesMeta.optional`, and
325
- * `FINDINGS.md` experiment 2).
326
- *
327
- * Every export here is memoized per module (one dynamic `import()` per
328
- * process, not per call) — this is what makes the module-identity guarantee
329
- * in FINDINGS experiment 3 hold: every devtools call site that needs
330
- * `@devdogsuga/env`'s registry gets the exact same module instance the
331
- * repo's own manifests populated via `declare()`/`define()`, because both
332
- * resolve to the identical absolute file path and Node's ESM cache is keyed
333
- * by resolved URL.
334
- */
335
- const moduleCache = /* @__PURE__ */ new Map();
336
- /** The file URL each peer resolved to through the target repo, set only on
337
- * that path (not on either plain bare-specifier fallback). See
338
- * `repoPeerUrl()`. */
339
- const resolvedUrls = /* @__PURE__ */ new Map();
340
- /**
341
- * Dynamically imports `specifier` as the repo would resolve it, memoized so
342
- * repeated calls in one process return the SAME module instance (required
343
- * for the env-registry identity guarantee above).
344
- *
345
- * Falls back to a plain bare-specifier `import()` when devtools is not
346
- * running inside a DevDogsUGA checkout at all (`findRepoRoot()` throws
347
- * `RepoNotFoundError`). That is not a production affordance — under real
348
- * `pnpm dlx` use, `specifier` is an optional peer devtools' own package
349
- * never installs, so the fallback import fails with the ordinary
350
- * `MODULE_NOT_FOUND` either way. It exists so devtools' OWN test suite
351
- * (which has no target repo to discover, but does have every one of these
352
- * peers as a real Backstage workspace package) exercises this code against
353
- * the genuine package rather than needing every test to mock this module.
354
- */
355
- function loadPeer(specifier) {
356
- const cached = moduleCache.get(specifier);
357
- if (cached) return cached;
358
- const promise = (async () => {
359
- if (process.env.DEVTOOLS_TEST_REPO_ROOT) return import(specifier);
360
- let repoRoot;
361
- try {
362
- repoRoot = findRepoRoot();
363
- } catch (err) {
364
- if (err instanceof RepoNotFoundError) return import(specifier);
365
- throw err;
366
- }
367
- const resolutionBase = findDependent(repoRoot, specifier);
368
- if (!resolutionBase) throw new Error(`Nothing in this repo depends on ${specifier} — expected an app or package under apps/* or packages/* to declare it (dependencies/devDependencies/peerDependencies).`);
369
- const { resolvedPath } = resolveFromRepo(repoRoot, resolutionBase, specifier);
370
- const url = pathToFileURL(resolvedPath).href;
371
- resolvedUrls.set(specifier, url);
372
- return import(url);
373
- })();
374
- moduleCache.set(specifier, promise);
375
- return promise;
376
- }
377
- /**
378
- * The file URL `specifier` was loaded from through the target repo, or
379
- * undefined if it has not been loaded yet or came from a plain bare-specifier
380
- * `import()` (the test-suite and not-in-a-repo fallbacks above).
381
- *
382
- * For code devtools imports that itself names a peer by bare specifier --
383
- * today only devtools' own `env.ts` manifest. From an installed copy that
384
- * specifier cannot resolve at all (an optional peer is never installed next
385
- * to devtools under `pnpm dlx`), and even where it can, the only copy that
386
- * keeps module identity is this one. `repo/peer-redirect.ts` uses it to
387
- * point the import here.
388
- */
389
- function repoPeerUrl(specifier) {
390
- return resolvedUrls.get(specifier);
391
- }
392
- let resolvedEnvModule;
393
- function loadEnv() {
394
- return loadPeer("@devdogsuga/env").then((mod) => {
395
- resolvedEnvModule = mod;
396
- return mod;
397
- });
398
- }
399
- /**
400
- * The synchronous companion to `loadEnv()`, for the handful of call sites
401
- * (`gh/environments.ts`'s `GITHUB_ENVIRONMENT_SPECS` getters) that cannot
402
- * become async without changing their own callers' contract — they read a
403
- * plain array off a getter, not a Promise. Safe ONLY after `loadEnv()` has
404
- * resolved at least once; every caller of these getters already requires
405
- * `env/discovery.ts`'s `loadRegistry()` (which calls `loadEnv()` itself) to
406
- * have run first, via `assertRegistryLoaded()`'s own guard.
407
- */
408
- function getEnvSync() {
409
- if (!resolvedEnvModule) throw new Error("getEnvSync() called before loadEnv() ever resolved — call loadRegistry() (env/discovery.ts) first.");
410
- return resolvedEnvModule;
411
- }
412
- function loadEnvLoad() {
413
- return loadPeer("@devdogsuga/env/load");
414
- }
415
- function loadEnvSession() {
416
- return loadPeer("@devdogsuga/env/session");
417
- }
418
- //#endregion
419
- //#region ../cli-core/src/db/connection.ts
420
- /** An env value, with unset and empty both meaning "not given". */
421
- function nonEmpty(value) {
422
- return value === void 0 || value === "" ? void 0 : value;
423
- }
424
- //#endregion
425
194
  //#region ../cli-core/src/process-group.ts
426
195
  /**
427
196
  * Running a child tool so that stopping it stops everything it started.
@@ -1146,6 +915,141 @@ const completionsCommand = {
1146
915
  }]
1147
916
  };
1148
917
  //#endregion
918
+ //#region src/creds/catalog.ts
919
+ /**
920
+ * `creds`'s place in the command tree: declaration only, nothing here runs.
921
+ * The handler lives in `commands.ts` beside it.
922
+ *
923
+ * Every subcommand is `envFree`: it runs on the officer's own Bitwarden
924
+ * session and reads production only for the roster, through `--db-url` or
925
+ * Secrets Manager, so no env file or tier is entered. Bare `creds` at a
926
+ * terminal opens this group's menu.
927
+ */
928
+ const ITEM = {
929
+ flag: "--item",
930
+ value: "<name>",
931
+ summary: "A Shared Accounts item, by name or id. Repeat for several."
932
+ };
933
+ const TO = {
934
+ flag: "--to",
935
+ value: "<a@uga.edu,…>",
936
+ summary: "Recipients, comma-separated. Replaces the item's current list."
937
+ };
938
+ const ROLE = {
939
+ flag: "--role",
940
+ value: "<role,…>",
941
+ summary: "Everyone holding these officer roles, added to --to."
942
+ };
943
+ const ALLOW_EMAIL = {
944
+ flag: "--allow-email",
945
+ summary: "Accept addresses that are not on the officer roster."
946
+ };
947
+ const DB_URL$1 = {
948
+ flag: "--db-url",
949
+ value: "<url>",
950
+ summary: "Production connection for the roster. Defaults to .env.production, then Secrets Manager."
951
+ };
952
+ const LINEAR_TOKEN = {
953
+ flag: "--linear-token",
954
+ value: "<key>",
955
+ summary: "Linear API key. Prefer LINEAR_API_KEY or the vault item."
956
+ };
957
+ const NO_REPORT = {
958
+ flag: "--no-report",
959
+ summary: "Skip regenerating the Shared Accounts Linear document."
960
+ };
961
+ const credsCommand = {
962
+ name: "creds",
963
+ summary: "Share club logins from Bitwarden as email-verified Sends.",
964
+ hint: "your own Bitwarden session; officers only",
965
+ subcommands: [
966
+ {
967
+ name: "send",
968
+ envFree: true,
969
+ summary: "Create or update the Sends for existing shared accounts.",
970
+ hint: "pick accounts, then officers",
971
+ options: [
972
+ ITEM,
973
+ TO,
974
+ ROLE,
975
+ ALLOW_EMAIL,
976
+ DB_URL$1,
977
+ LINEAR_TOKEN,
978
+ NO_REPORT,
979
+ YES
980
+ ]
981
+ },
982
+ {
983
+ name: "add",
984
+ envFree: true,
985
+ summary: "Save a new shared login to the collection, then send it.",
986
+ options: [
987
+ {
988
+ flag: "--name",
989
+ value: "<name>",
990
+ summary: "The item's name, e.g. Instagram."
991
+ },
992
+ {
993
+ flag: "--url",
994
+ value: "<url>",
995
+ summary: "The login page."
996
+ },
997
+ {
998
+ flag: "--username",
999
+ value: "<username>",
1000
+ summary: "The login."
1001
+ },
1002
+ {
1003
+ flag: "--owner",
1004
+ value: "<name>",
1005
+ summary: "The officer responsible for the account."
1006
+ },
1007
+ {
1008
+ flag: "--password-stdin",
1009
+ summary: "Read the password from stdin, for scripts."
1010
+ },
1011
+ TO,
1012
+ ROLE,
1013
+ ALLOW_EMAIL,
1014
+ DB_URL$1,
1015
+ LINEAR_TOKEN,
1016
+ NO_REPORT,
1017
+ YES
1018
+ ]
1019
+ },
1020
+ {
1021
+ name: "renew",
1022
+ envFree: true,
1023
+ summary: "Extend Sends 30 days and re-sync their recipients.",
1024
+ hint: "asks first for any with more than 7 days left",
1025
+ options: [
1026
+ ITEM,
1027
+ LINEAR_TOKEN,
1028
+ NO_REPORT,
1029
+ YES
1030
+ ]
1031
+ },
1032
+ {
1033
+ name: "list",
1034
+ envFree: true,
1035
+ dryRun: "read-only",
1036
+ summary: "Shared accounts, their recipients and when each Send expires.",
1037
+ hint: "reads only",
1038
+ options: [JSON_FLAG]
1039
+ },
1040
+ {
1041
+ name: "report",
1042
+ envFree: true,
1043
+ summary: "Regenerate the Shared Accounts Linear document.",
1044
+ options: [LINEAR_TOKEN, {
1045
+ flag: "--document",
1046
+ value: "<id>",
1047
+ summary: "A different Linear document id, for testing."
1048
+ }]
1049
+ }
1050
+ ]
1051
+ };
1052
+ //#endregion
1149
1053
  //#region src/deploy/catalog.ts
1150
1054
  /**
1151
1055
  * `deploy`'s place in the command tree: declaration only, nothing here runs.
@@ -1557,8 +1461,12 @@ const catalog = createCatalog({
1557
1461
  commands: [graphicsCommand, qrCommand]
1558
1462
  },
1559
1463
  {
1560
- title: "Your own sign-in (gh login, club mailbox)",
1561
- commands: [githubCommand, newsletterCommand]
1464
+ title: "Your own sign-in (gh login, club mailbox, Bitwarden)",
1465
+ commands: [
1466
+ githubCommand,
1467
+ newsletterCommand,
1468
+ credsCommand
1469
+ ]
1562
1470
  },
1563
1471
  {
1564
1472
  title: "CLI utilities",
@@ -1567,4 +1475,4 @@ const catalog = createCatalog({
1567
1475
  ]
1568
1476
  });
1569
1477
  //#endregion
1570
- export { repoPeerUrl as _, recordEnteredTier as a, renderHelp as b, setMenuEnvHook as c, formatCommand as d, nonEmpty as f, loadEnvSession as g, loadEnvLoad as h, beginInvocation as i, dbPush as l, loadEnv as m, bareGroupStartPath as n, recordResolved as o, getEnvSync as p, runMenu as r, reproducibleCommand as s, catalog as t, dbPushDryRun as u, helpPath as v, renderCommandList as y };
1478
+ export { recordEnteredTier as a, setMenuEnvHook as c, formatCommand as d, helpPath as f, beginInvocation as i, dbPush as l, renderHelp as m, bareGroupStartPath as n, recordResolved as o, renderCommandList as p, runMenu as r, reproducibleCommand as s, catalog as t, dbPushDryRun as u };