@devdogsuga/backstage 0.1.12 → 0.1.14

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
@@ -47,7 +47,8 @@ pnpm backstage export attendance --meeting 2026-09-09 --format bevy,involvement
47
47
  | `deploy <app> --tier <t>` | Checks `CLOUDFLARE_API_TOKEN`, writes the Worker's secrets file, deploys, removes it. |
48
48
  | `deploy write-env` | Composes `.env.<DEPLOY_ENV>` from the GitHub environment. |
49
49
  | `deploy preflight` | Classifies the project: paused (skip) or broken (fail). |
50
- | `deploy plan`, `deploy migrate` | Dry-run the migrations into the job summary; apply them to `DB_URL`. |
50
+ | `deploy plan`, `deploy migrate [--include-seed]` | Dry-run the migrations (and new seed files) into the job summary; apply them. |
51
+ | `deploy avatars` | Uploads seeded headshots the bucket lacks; never replaces one (`SECRET_KEY`). |
51
52
  | `deploy smoke --tier <t> [--app]` | Public routes answer 200, the auth redirect works, this deploy's Sentry release shows. |
52
53
  | `deploy reconcile --tier <t>` | The platform's config reconcile, after the deploy (`CRON_SECRET`). |
53
54
  | `deploy prune-monitors --tier <t> [--app]` | Deletes the app's Sentry Crons monitors it no longer declares (production only). |
@@ -153,8 +154,10 @@ a Send another officer made creates a new one, with a new link, under yours.
153
154
 
154
155
  The roster needs production's `DB_URL`: `--db-url`, else `.env.production` in a
155
156
  checkout, else Secrets Manager. The Linear key: `--linear-token`, then
156
- `LINEAR_API_KEY`, then the vault item "DevDogs Linear API key (backstage)", then
157
- a prompt; it is never saved.
157
+ `LINEAR_API_KEY`, then the item "DevDogs Linear API key (backstage)" in your
158
+ personal vault, then a prompt, which offers to save it there. Use your own key,
159
+ never one shared through the DevDogs organization: Linear records every edit
160
+ under the key's owner, and creds ignores organization items of that name.
158
161
 
159
162
  ## Member data: `import` and `export`
160
163
 
@@ -254,8 +257,10 @@ command that is not read-only asks once first (`--yes` answers it).
254
257
  Each command checks for the secrets it uses up front: `deploy <app>` for
255
258
  `CLOUDFLARE_API_TOKEN`, `deploy reconcile` for `CRON_SECRET`, `deploy
256
259
  prune-monitors` for `SENTRY_MONITORS_TOKEN` (skipped without it), `env` for the
257
- Secrets Manager token (flag, environment, then the Bitwarden vault, which `env`
258
- signs in to and unlocks itself, then asking).
260
+ Secrets Manager token (flag, environment, then your personal Bitwarden vault,
261
+ which `env` signs in to and unlocks itself, then asking). The token is each
262
+ person's own: the vault read skips organization items, and a save sets no
263
+ organization, so one person's token can be revoked without the rest.
259
264
 
260
265
  ## Layout
261
266
 
@@ -371,7 +371,8 @@ const supabase = (...args) => run$1([
371
371
  * The one spelling of the migration push, shared by the contributor `db
372
372
  * migrate` path (`stack.ts`'s `pushMigrations`) and CI's `deploy migrate`.
373
373
  * `--yes` is the unattended apply CI wants; the contributor path omits it so a
374
- * human still confirms. Neither variant regenerates types — that is
374
+ * human still confirms. `includeSeed` adds the `[db.seed]` files, which the
375
+ * CLI runs once each and records in `supabase_migrations.seed_files`. Neither variant regenerates types — that is
375
376
  * `pushMigrations`' own second step, layered on top only where a checkout is
376
377
  * meant to be rewritten. A CI apply must NOT write back into the repo, which is
377
378
  * exactly why the bare push is factored out here rather than reused whole.
@@ -383,6 +384,7 @@ function dbPush(dbUrl, opts = {}) {
383
384
  "--db-url",
384
385
  dbUrl
385
386
  ];
387
+ if (opts.includeSeed) args.push("--include-seed");
386
388
  if (opts.yes) args.push("--yes");
387
389
  return supabase(...args);
388
390
  }
@@ -397,17 +399,19 @@ function dbPush(dbUrl, opts = {}) {
397
399
  * apply". The child's own output is surfaced in the thrown message, because for
398
400
  * a dry run that output is precisely the reason the operator needs.
399
401
  */
400
- async function dbPushDryRun(dbUrl) {
402
+ async function dbPushDryRun(dbUrl, opts = {}) {
403
+ const args = [
404
+ "exec",
405
+ "supabase",
406
+ "db",
407
+ "push",
408
+ "--db-url",
409
+ dbUrl
410
+ ];
411
+ if (opts.includeSeed) args.push("--include-seed");
412
+ args.push("--dry-run");
401
413
  try {
402
- const { stdout, stderr } = await runFile("pnpm", [
403
- "exec",
404
- "supabase",
405
- "db",
406
- "push",
407
- "--db-url",
408
- dbUrl,
409
- "--dry-run"
410
- ], {
414
+ const { stdout, stderr } = await runFile("pnpm", args, {
411
415
  cwd: findRepoRoot(),
412
416
  encoding: "utf8",
413
417
  maxBuffer: 33554432
@@ -1130,6 +1134,10 @@ function app(name) {
1130
1134
  ]
1131
1135
  };
1132
1136
  }
1137
+ const INCLUDE_SEED = {
1138
+ flag: "--include-seed",
1139
+ summary: "Also run the seed files the database has not recorded."
1140
+ };
1133
1141
  const deployCommand = {
1134
1142
  name: "deploy",
1135
1143
  dryRun: "handled",
@@ -1161,11 +1169,17 @@ const deployCommand = {
1161
1169
  flag: "--label",
1162
1170
  value: "<title>",
1163
1171
  summary: "Heading for the summary section."
1164
- }]
1172
+ }, INCLUDE_SEED]
1165
1173
  },
1166
1174
  {
1167
1175
  name: "migrate",
1168
1176
  summary: "Apply the migrations to DB_URL.",
1177
+ options: [INCLUDE_SEED, DRY_RUN]
1178
+ },
1179
+ {
1180
+ name: "avatars",
1181
+ summary: "Upload seeded headshots the avatars bucket does not have yet.",
1182
+ hint: "never replaces an existing photo",
1169
1183
  options: [DRY_RUN]
1170
1184
  },
1171
1185
  {
@@ -31,7 +31,7 @@ async function readProductionKey(key) {
31
31
  const value = nonEmpty(EnvDocument.parse(text).get(key));
32
32
  if (value) return value;
33
33
  } catch {}
34
- const { listSecrets, projectIdFor } = await import("./client-DCRfKvpP.js");
34
+ const { listSecrets, projectIdFor } = await import("./client-CJPfmtwW.js");
35
35
  const secrets = await listSecrets(await projectIdFor(PRODUCTION_PROJECT));
36
36
  return nonEmpty(secrets.find((s) => s.key === key)?.value);
37
37
  }
@@ -1,18 +1,18 @@
1
1
  import { C as discoverRepoRoot, M as flagValue, N as positionals, T as cliName, b as ownPackageDir, c as reportDevtoolsError, f as isNoEnv, l as reportDevtoolsFailure, n as captureDevtoolsError, o as initDevtoolsTelemetry, p as isNonInteractive, w as findRepoRoot, x as ownVersion } from "./telemetry-RNK5Wu74.js";
2
- import { a as recordEnteredTier, f as helpPath, i as beginInvocation, l as dbPush, m as renderHelp, n as bareGroupStartPath, o as recordResolved, p as renderCommandList, r as runMenu, s as reproducibleCommand, t as catalog, u as dbPushDryRun } from "./catalog-BLgY4knB.js";
2
+ import { a as recordEnteredTier, f as helpPath, i as beginInvocation, l as dbPush, m as renderHelp, n as bareGroupStartPath, o as recordResolved, p as renderCommandList, r as runMenu, s as reproducibleCommand, t as catalog, u as dbPushDryRun } from "./catalog-D7eGX7cD.js";
3
3
  import { a as repoPeerUrl, n as loadEnv, r as loadEnvLoad, t as getEnvSync } from "./peers-DE07w6nT.js";
4
4
  import { t as nonEmpty } from "./connection-D3XsOE3S.js";
5
5
  import { a as wouldRunLine, n as isDryRun, t as dryRunKind } from "./dry-run-OzO0jUhK.js";
6
6
  import { a as explainError, i as explain, n as bail, o as unwrap, r as errorMessage } from "./ui-RFZyr6k_.js";
7
7
  import { t as DONE } from "./dispatch-D048O65I.js";
8
8
  import { t as EnvDocument } from "./document-DumoZXoO.js";
9
- import { a as listSecrets$1, c as updateSecret, i as createSecret, o as projectIdFor, r as byKey, s as setExplicitAccessToken } from "./client-Djo8fRdS.js";
9
+ import { a as listSecrets$1, c as updateSecret, i as createSecret, o as projectIdFor, r as byKey, s as setExplicitAccessToken } from "./client-CFhbS3nu.js";
10
10
  import { register } from "node:module";
11
11
  import { pathToFileURL } from "node:url";
12
12
  import { appendFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
13
13
  import { basename, join, resolve } from "node:path";
14
14
  import { confirm, intro, log, note, outro, select } from "@clack/prompts";
15
- import { chmod, readFile, writeFile } from "node:fs/promises";
15
+ import { chmod, readFile, readdir, writeFile } from "node:fs/promises";
16
16
  import { execFile, execFileSync, spawn } from "node:child_process";
17
17
  import { promisify } from "node:util";
18
18
  import { tmpdir } from "node:os";
@@ -491,6 +491,103 @@ function summary(lines, env = process.env) {
491
491
  appendFileSync(path, `${lines.join("\n")}\n`);
492
492
  }
493
493
  //#endregion
494
+ //#region src/deploy/avatars.ts
495
+ /**
496
+ * `backstage deploy avatars`: the seeded headshots, uploaded where missing.
497
+ *
498
+ * `supabase db push --include-seed` runs the seed SQL but never touches
499
+ * Storage, so a new officer's profile reached a hosted tier without their
500
+ * photo. `supabase seed buckets` would upload it, but it sends every file with
501
+ * `x-upsert: true`, and the app stores an officer's own upload at the same key
502
+ * (`avatars/<user id>`, also upserted). Run on every deploy, it would put the
503
+ * seed photo back over whatever the officer chose since.
504
+ *
505
+ * So this uploads each file under a bucket's `objects_path` WITHOUT upsert,
506
+ * and Storage's duplicate refusal is the "already there" answer. That makes it
507
+ * insert-only, like the seed SQL beside it: a photo is added once and never
508
+ * replaced, and there is no check-then-write race to lose.
509
+ *
510
+ * Reads `API_URL` (else `https://<PROJECT_REF>.supabase.co`) and `SECRET_KEY`
511
+ * from the step's own `env:`, like the other credential-holding steps.
512
+ */
513
+ /**
514
+ * Every `[storage.buckets.<name>]` in `supabase/config.toml` that declares an
515
+ * `objects_path`. A line scan rather than a TOML parser: the two keys are
516
+ * flat, quoted and always written this way by the Supabase template, and a
517
+ * table header ends the previous section.
518
+ */
519
+ function seededBuckets(configToml, supabaseDir) {
520
+ const found = [];
521
+ let bucket;
522
+ for (const line of configToml.split("\n")) {
523
+ const header = /^\s*\[([^\]]+)\]\s*$/.exec(line);
524
+ if (header) {
525
+ bucket = /^storage\.buckets\.([A-Za-z0-9_-]+)$/.exec(header[1].trim())?.[1];
526
+ continue;
527
+ }
528
+ const path = /^\s*objects_path\s*=\s*"([^"]+)"/.exec(line);
529
+ if (bucket && path) found.push({
530
+ bucket,
531
+ dir: resolve(supabaseDir, path[1])
532
+ });
533
+ }
534
+ return found;
535
+ }
536
+ /** The content type Storage needs, from the bytes; the seeds have no extension. */
537
+ function sniffImageType(bytes) {
538
+ const ascii = (from, to) => String.fromCharCode(...bytes.subarray(from, to));
539
+ if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP") return "image/webp";
540
+ if (bytes[0] === 137 && ascii(1, 4) === "PNG") return "image/png";
541
+ if (bytes[0] === 255 && bytes[1] === 216) return "image/jpeg";
542
+ }
543
+ /**
544
+ * POST without `x-upsert`. Storage refuses a duplicate key with 409, or (older
545
+ * Storage) a 400 whose body says `"statusCode":"409"` / `"Duplicate"`.
546
+ */
547
+ async function uploadIfMissing(apiUrl, secretKey, bucket, name, bytes, contentType, fetchImpl = fetch) {
548
+ const res = await fetchImpl(`${apiUrl.replace(/\/+$/, "")}/storage/v1/object/${encodeURIComponent(bucket)}/${encodeURIComponent(name)}`, {
549
+ method: "POST",
550
+ headers: {
551
+ apikey: secretKey,
552
+ Authorization: `Bearer ${secretKey}`,
553
+ "Content-Type": contentType,
554
+ "x-upsert": "false"
555
+ },
556
+ body: bytes
557
+ });
558
+ if (res.ok) return "uploaded";
559
+ const body = await res.text().catch(() => "");
560
+ if (res.status === 409 || /"statusCode"\s*:\s*"409"|Duplicate/i.test(body)) return "exists";
561
+ throw new DeployError(`Uploading ${bucket}/${name} failed (HTTP ${res.status}).`, [body.slice(0, 300) || "Storage gave no reason."]);
562
+ }
563
+ async function runDeployAvatars(env = process.env, deps = {}) {
564
+ const ref = nonEmpty(env.PROJECT_REF);
565
+ const apiUrl = nonEmpty(env.API_URL) ?? (ref ? `https://${ref}.supabase.co` : void 0);
566
+ const secretKey = nonEmpty(env.SECRET_KEY);
567
+ if (!apiUrl || !secretKey) throw new DeployError(`${apiUrl ? "SECRET_KEY" : "API_URL (or PROJECT_REF)"} is not set — refusing to upload.`, [
568
+ "This step reads the Storage endpoint and the tier's secret key from",
569
+ "its own env: block. An empty value usually means the workflow step",
570
+ "lost it, or the environment never had it."
571
+ ]);
572
+ const supabaseDir = join(deps.root ?? findRepoRoot(), "supabase");
573
+ const config = await readFile(join(supabaseDir, "config.toml"), "utf8");
574
+ let uploaded = 0;
575
+ let existing = 0;
576
+ for (const { bucket, dir } of seededBuckets(config, supabaseDir)) {
577
+ const names = (await readdir(dir, { withFileTypes: true })).filter((e) => e.isFile() && !e.name.startsWith(".")).map((e) => e.name).sort();
578
+ for (const name of names) {
579
+ const bytes = new Uint8Array(await readFile(join(dir, name)));
580
+ const type = sniffImageType(bytes);
581
+ if (!type) throw new DeployError(`${bucket}/${name} is not a WebP, PNG or JPEG.`);
582
+ if (await uploadIfMissing(apiUrl, secretKey, bucket, name, bytes, type, deps.fetch) === "uploaded") {
583
+ uploaded++;
584
+ say([`deploy avatars: uploaded ${bucket}/${name}`]);
585
+ } else existing++;
586
+ }
587
+ }
588
+ say([`deploy avatars: ${uploaded} uploaded, ${existing} already there (left alone).`]);
589
+ }
590
+ //#endregion
494
591
  //#region src/deploy/migrations.ts
495
592
  /**
496
593
  * `backstage deploy plan` and `deploy migrate`.
@@ -525,10 +622,10 @@ const MISSING_DB_URL = [
525
622
  * the caller distinguish the two stages (`main`'s early plan vs the one taken
526
623
  * seconds before the apply); it is the only thing that varies between them.
527
624
  */
528
- async function runDeployPlan(label = "Migration plan", env = process.env, plan = dbPushDryRun) {
625
+ async function runDeployPlan(label = "Migration plan", env = process.env, plan, { includeSeed = false } = {}) {
529
626
  const url = env.DB_URL;
530
627
  if (!url) throw new DeployError("DB_URL is not set — refusing to plan.", MISSING_DB_URL);
531
- const text = await plan(url);
628
+ const text = await (plan ?? ((u) => dbPushDryRun(u, { includeSeed })))(url);
532
629
  say([text]);
533
630
  summary([
534
631
  `### ${label}`,
@@ -541,16 +638,19 @@ async function runDeployPlan(label = "Migration plan", env = process.env, plan =
541
638
  /**
542
639
  * Apply the migrations to DB_URL.
543
640
  *
544
- * `dbPush(url, { yes: true })` — the bare push, WITHOUT the type regeneration
641
+ * `dbPush(url, { yes: true, includeSeed })` — the bare push, WITHOUT the type regeneration
545
642
  * the contributor path layers on, so a production apply never writes back into
546
643
  * the checkout. A non-zero exit becomes a DeployError so the job fails with a
547
644
  * line a person can read; the supabase CLI's own output (inherited stdio) says
548
645
  * why above it.
549
646
  */
550
- async function runDeployMigrate(env = process.env, push = (url) => dbPush(url, { yes: true })) {
647
+ async function runDeployMigrate(env = process.env, push, { includeSeed = false } = {}) {
551
648
  const url = env.DB_URL;
552
649
  if (!url) throw new DeployError("DB_URL is not set — refusing to migrate.", MISSING_DB_URL);
553
- const code = await push(url);
650
+ const code = await (push ?? ((u) => dbPush(u, {
651
+ yes: true,
652
+ includeSeed
653
+ })))(url);
554
654
  if (code !== 0) throw new DeployError(`\`supabase db push\` failed (exit ${code}).`, ["The migration was not applied. The supabase CLI output above says why."]);
555
655
  say(["deploy migrate: migrations applied."]);
556
656
  }
@@ -1664,11 +1764,11 @@ function renderWriteEnvReport(result) {
1664
1764
  * deploy` from the app dir resolves through it with no `--config` needed.
1665
1765
  * Both apps share one deploy step.
1666
1766
  *
1667
- * Steps (`write-env`, `preflight`, `plan`, `migrate`, `smoke`, `reconcile`,
1668
- * `prune-monitors`) are individually addressable for jobs that run only one.
1669
- * The ones that hold a single credential in the job's own `env:` block and
1670
- * compose no env file (`preflight`, `plan`, `migrate`, `smoke`, `reconcile`,
1671
- * `prune-monitors`), and `write-env`,
1767
+ * Steps (`write-env`, `preflight`, `plan`, `migrate`, `avatars`, `smoke`,
1768
+ * `reconcile`, `prune-monitors`) are individually addressable for jobs that run
1769
+ * only one. The ones that hold their credentials in the job's own `env:` block
1770
+ * and compose no env file (`preflight`, `plan`, `migrate`, `avatars`, `smoke`,
1771
+ * `reconcile`, `prune-monitors`), and `write-env`,
1672
1772
  * which CREATES the file tier resolution would otherwise insist on reading,
1673
1773
  * run with `--no-env`.
1674
1774
  */
@@ -1786,18 +1886,22 @@ function renderDeployFailure(label, err) {
1786
1886
  }
1787
1887
  /** Whether a dry run stops before this step, because the step writes. */
1788
1888
  function writesWhenLive(sub) {
1789
- return sub === "write-env" || sub === "migrate";
1889
+ return sub === "write-env" || sub === "migrate" || sub === "avatars";
1790
1890
  }
1791
1891
  /**
1792
1892
  * The steps, by name.
1793
1893
  */
1794
1894
  async function runStep(sub, rest) {
1795
1895
  if (sub === "plan") {
1796
- await runDeployPlan(flagValue(rest, "--label"));
1896
+ await runDeployPlan(flagValue(rest, "--label"), process.env, void 0, { includeSeed: rest.includes("--include-seed") });
1797
1897
  return true;
1798
1898
  }
1799
1899
  if (sub === "migrate") {
1800
- await runDeployMigrate();
1900
+ await runDeployMigrate(process.env, void 0, { includeSeed: rest.includes("--include-seed") });
1901
+ return true;
1902
+ }
1903
+ if (sub === "avatars") {
1904
+ await runDeployAvatars();
1801
1905
  return true;
1802
1906
  }
1803
1907
  if (sub === "preflight") {
@@ -4835,11 +4939,17 @@ const handleGithub = async (rest) => {
4835
4939
  * production" from `main`, so whatever it authenticates with is reachable
4836
4940
  * from the `main` trust tier. The plan refuses even a general read-only role
4837
4941
  * there, because that puts every row of production data behind that boundary.
4838
- * What is left is a role that can read the migrations-history table and
4839
- * nothing else. Two grants are the whole of it:
4942
+ * What is left is a role that can read the CLI's two ledgers and nothing
4943
+ * else. Three grants are the whole of it:
4840
4944
  *
4841
4945
  * grant usage on schema supabase_migrations to migration_planner;
4842
4946
  * grant select on supabase_migrations.schema_migrations to migration_planner;
4947
+ * grant select on supabase_migrations.seed_files to migration_planner;
4948
+ *
4949
+ * The third lets the dry run plan seeds too (`deploy plan --include-seed`).
4950
+ * `seed_files` holds a path and a content hash per seed file, no member data.
4951
+ * The CLI creates it on the first seeded push, so the grant is skipped while
4952
+ * it does not exist, and `planner status` says when it is missing.
4843
4953
  *
4844
4954
  * This module is the pure half: names, SQL text, password generation, URL
4845
4955
  * derivation. Everything that talks to a database lives beside it.
@@ -4860,9 +4970,15 @@ function createRoleSql(password) {
4860
4970
  return [
4861
4971
  `create role ${PLANNER_ROLE} login password '${password}'`,
4862
4972
  `grant usage on schema supabase_migrations to ${PLANNER_ROLE}`,
4863
- `grant select on supabase_migrations.schema_migrations to ${PLANNER_ROLE}`
4973
+ `grant select on supabase_migrations.schema_migrations to ${PLANNER_ROLE}`,
4974
+ GRANT_SEED_FILES
4864
4975
  ];
4865
4976
  }
4977
+ /**
4978
+ * The seed-ledger grant, applied only when the table exists. Also what
4979
+ * `planner status` tells you to run when it is missing.
4980
+ */
4981
+ const GRANT_SEED_FILES = `do \$\$ begin if to_regclass('supabase_migrations.seed_files') is not null then grant select on supabase_migrations.seed_files to ${PLANNER_ROLE}; end if; end \$\$`;
4866
4982
  /** ALTER ROLE, same interpolation contract as `createRoleSql`. */
4867
4983
  function resetPasswordSql(password) {
4868
4984
  return `alter role ${PLANNER_ROLE} password '${password}'`;
@@ -4901,6 +5017,8 @@ function plannerUrlFrom(adminUrl, password) {
4901
5017
  * 1. Who did Postgres authenticate? (`current_user`)
4902
5018
  * 2. Can it read an application schema? (it must not)
4903
5019
  * 3. Can it read the migrations table? (it must, the dry run is next)
5020
+ * 4. Can it read the seed ledger? (it must, once the table exists, for
5021
+ * `deploy plan --include-seed`)
4904
5022
  *
4905
5023
  * Shared between `deploy require-planner` (which turns a bad answer into a
4906
5024
  * red CI job) and `planner create` / `planner reset-password` (which run the
@@ -4923,6 +5041,7 @@ const CHECK_IDENTITY = "select current_user as who";
4923
5041
  */
4924
5042
  const CHECK_OVERREACH = "select * from platform.profile limit 1";
4925
5043
  const CHECK_MIGRATIONS = "select count(*)::int as n from supabase_migrations.schema_migrations";
5044
+ const CHECK_SEEDS = "select count(*)::int as n from supabase_migrations.seed_files";
4926
5045
  /**
4927
5046
  * Runs the three checks over an OPEN connection; never closes it.
4928
5047
  *
@@ -4967,11 +5086,27 @@ async function checkPlanner(db) {
4967
5086
  problem: `${PLANNER_ROLE} cannot read supabase_migrations.schema_migrations, so the migration dry run would fail right after this. Re-apply the two grants (see planner/role.ts).`
4968
5087
  };
4969
5088
  }
5089
+ try {
5090
+ const [row] = await db.run(CHECK_SEEDS);
5091
+ lines.push(`can read supabase_migrations.seed_files (${String(row?.n)} rows)`);
5092
+ } catch (error) {
5093
+ if (isMissingTable(error)) lines.push("no supabase_migrations.seed_files yet (no seeded push)");
5094
+ else return {
5095
+ ok: false,
5096
+ lines,
5097
+ problem: `${PLANNER_ROLE} cannot read supabase_migrations.seed_files, so \`deploy plan --include-seed\` would fail. As an admin, run: grant select on supabase_migrations.seed_files to ${PLANNER_ROLE};`
5098
+ };
5099
+ }
4970
5100
  return {
4971
5101
  ok: true,
4972
5102
  lines
4973
5103
  };
4974
5104
  }
5105
+ /** Postgres 42P01 (undefined_table), with a text fallback. */
5106
+ function isMissingTable(error) {
5107
+ const e = error;
5108
+ return e.code === "42P01" || /relation "?[\w.]*seed_files"? does not exist/i.test(e.message ?? "");
5109
+ }
4975
5110
  /** Postgres 3F000 (invalid_schema_name), with a text fallback. */
4976
5111
  function isMissingSchema(error) {
4977
5112
  const e = error;
@@ -5064,7 +5199,7 @@ const QUERY_ROLE = `select rolcanlogin from pg_roles where rolname = '${PLANNER_
5064
5199
  * it strands a grant-less role behind a refusal to re-run.
5065
5200
  */
5066
5201
  const QUERY_SCHEMA = "select 1 from pg_namespace where nspname = 'supabase_migrations'";
5067
- const QUERY_GRANTS = `select has_schema_privilege('${PLANNER_ROLE}', 'supabase_migrations', 'usage') as schema_usage, has_table_privilege('${PLANNER_ROLE}', 'supabase_migrations.schema_migrations', 'select') as table_select`;
5202
+ const QUERY_GRANTS = `select has_schema_privilege('${PLANNER_ROLE}', 'supabase_migrations', 'usage') as schema_usage, has_table_privilege('${PLANNER_ROLE}', 'supabase_migrations.schema_migrations', 'select') as table_select, to_regclass('supabase_migrations.seed_files') is not null as seed_table, case when to_regclass('supabase_migrations.seed_files') is null then false else has_table_privilege('${PLANNER_ROLE}', 'supabase_migrations.seed_files', 'select') end as seed_select`;
5068
5203
  /**
5069
5204
  * Membership is the quiet way a "narrow" role widens: `grant postgres to
5070
5205
  * migration_planner` leaves both privilege queries above looking correct.
@@ -5147,7 +5282,7 @@ async function inspectRole(connect, admin) {
5147
5282
  if (!schemaReady) lines.push("⚠️ the supabase_migrations schema DOES NOT EXIST on this database — the grants below cannot hold and the dry run cannot plan. Initialize the migration history, then `planner reset-password` will re-verify (or `planner drop` and re-run `planner create`).");
5148
5283
  if (schemaReady) {
5149
5284
  const [grants] = await db.run(QUERY_GRANTS);
5150
- lines.push(grants?.schema_usage ? "has usage on supabase_migrations" : "⚠️ MISSING usage on supabase_migrations", grants?.table_select ? "has select on supabase_migrations.schema_migrations" : "⚠️ MISSING select on supabase_migrations.schema_migrations");
5285
+ lines.push(grants?.schema_usage ? "has usage on supabase_migrations" : "⚠️ MISSING usage on supabase_migrations", grants?.table_select ? "has select on supabase_migrations.schema_migrations" : "⚠️ MISSING select on supabase_migrations.schema_migrations", !grants?.seed_table ? "no supabase_migrations.seed_files yet (granted on the next create once it exists)" : grants.seed_select ? "has select on supabase_migrations.seed_files" : `⚠️ MISSING select on supabase_migrations.seed_files — grant select on supabase_migrations.seed_files to ${PLANNER_ROLE};`);
5151
5286
  }
5152
5287
  const memberships = await db.run(QUERY_MEMBERSHIPS);
5153
5288
  lines.push(memberships.length === 0 ? "member of no other role" : `⚠️ MEMBER OF: ${memberships.map((m) => String(m.rolname)).join(", ")} — inherited privileges reach past the validated pair`);
@@ -5250,7 +5385,7 @@ async function runPlannerCreate(options = {}) {
5250
5385
  const connect = options.connect ?? connectDb;
5251
5386
  const admin = await adminUrl(options);
5252
5387
  if (!unwrap(await confirm({
5253
- message: `Create role migration_planner on the PRODUCTION database, with its two grants and a generated password?`,
5388
+ message: `Create role migration_planner on the PRODUCTION database, with its three grants and a generated password?`,
5254
5389
  initialValue: false
5255
5390
  }))) bail();
5256
5391
  await withDb(connect, admin, async (db) => {
@@ -5265,7 +5400,7 @@ async function runPlannerCreate(options = {}) {
5265
5400
  await db.run("rollback").catch(() => void 0);
5266
5401
  throw error;
5267
5402
  }
5268
- log.success(`Created ${PLANNER_ROLE} with the two validated grants.`);
5403
+ log.success(`Created ${PLANNER_ROLE} with the validated grants.`);
5269
5404
  await verifyAndStore(connect, admin, password);
5270
5405
  });
5271
5406
  }
@@ -5389,9 +5524,9 @@ const HANDLERS = {
5389
5524
  graphics: async (rest) => (await import("./commands-BAh2_Nds.js")).handleGraphics(rest),
5390
5525
  qr: async (rest) => (await import("./commands-DILvdMc2.js")).handleQr(rest),
5391
5526
  newsletter: async (rest) => (await import("./commands-C3_hAcZS.js")).handleNewsletter(rest),
5392
- creds: async (rest) => (await import("./commands-C6w8jm6a.js")).handleCreds(rest),
5393
- import: async (rest) => (await import("./commands-DfWNSPra.js")).handleImport(rest),
5394
- export: async (rest) => (await import("./commands-3HvxDq-2.js")).handleExport(rest)
5527
+ creds: async (rest) => (await import("./commands-CTQjJ45_.js")).handleCreds(rest),
5528
+ import: async (rest) => (await import("./commands-DfQKdPJY.js")).handleImport(rest),
5529
+ export: async (rest) => (await import("./commands-BEYZyI4Y.js")).handleExport(rest)
5395
5530
  };
5396
5531
  /**
5397
5532
  * Names that were commands of the contributor CLI, refused with where they
@@ -2,7 +2,7 @@ import { w as findRepoRoot } from "./telemetry-RNK5Wu74.js";
2
2
  import { n as loadEnv } from "./peers-DE07w6nT.js";
3
3
  import { o as unwrap } from "./ui-RFZyr6k_.js";
4
4
  import { t as EnvDocument } from "./document-DumoZXoO.js";
5
- import { a as readTokenFromVault, o as saveTokenToVault, t as VAULT_ITEM_NAME } from "./vault-BpYSYcuT.js";
5
+ import { a as readTokenFromVault, s as saveTokenToVault, t as VAULT_ITEM_NAME } from "./vault-DVmaQC_E.js";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { log, password, select, text } from "@clack/prompts";
8
8
  import { mkdir, readFile, writeFile } from "node:fs/promises";
@@ -66,7 +66,7 @@ function looksLikeAccessToken(value) {
66
66
  async function promptForToken() {
67
67
  if (!process.stdin.isTTY) return void 0;
68
68
  const typed = unwrap(await password({
69
- message: "Paste the Secrets Manager access token for the `admin` account",
69
+ message: "Paste your own Secrets Manager access token (`admin` machine account)",
70
70
  mask: "•",
71
71
  validate: (v) => (v ?? "").trim() === "" ? "An access token is required." : void 0
72
72
  })).trim();
@@ -0,0 +1,2 @@
1
+ import { a as listSecrets, o as projectIdFor } from "./client-CFhbS3nu.js";
2
+ export { listSecrets, projectIdFor };
@@ -2,8 +2,8 @@ import { n as isDryRun } from "./dry-run-OzO0jUhK.js";
2
2
  import { o as unwrap, r as errorMessage, t as UsageError } from "./ui-RFZyr6k_.js";
3
3
  import { i as exportParams, n as EXPORT_KINDS, r as auditFilters, t as EXPORTS } from "./queries-D3mTchag.js";
4
4
  import { t as DONE } from "./dispatch-D048O65I.js";
5
- import { c as ProductionError, d as requireProductionKey, i as plural, l as connect, o as reportFailure, r as expandHome, s as say, t as canPrompt, u as dbError } from "./cli-B7W_-WdD.js";
6
- import { i as parseBound, n as findMeetingsWith, r as pickMeeting, t as describeMeeting } from "./meetings-BAX4Sils.js";
5
+ import { c as ProductionError, d as requireProductionKey, i as plural, l as connect, o as reportFailure, r as expandHome, s as say, t as canPrompt, u as dbError } from "./cli-CqbqBpaT.js";
6
+ import { i as parseBound, n as findMeetingsWith, r as pickMeeting, t as describeMeeting } from "./meetings-QgVEoBIV.js";
7
7
  import { existsSync, readdirSync, statSync } from "node:fs";
8
8
  import { basename, dirname, join } from "node:path";
9
9
  import { autocomplete, confirm, multiselect, text } from "@clack/prompts";
@@ -3,7 +3,7 @@ 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-RFZyr6k_.js";
4
4
  import { t as DONE } from "./dispatch-D048O65I.js";
5
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-BpYSYcuT.js";
6
+ import { c as bwCommand, i as readPasswordFromVault, n as bwArgs, o as savePasswordToVault, r as openVault } from "./vault-DVmaQC_E.js";
7
7
  import { resolve } from "node:path";
8
8
  import { confirm, log, multiselect, note, password, select, text } from "@clack/prompts";
9
9
  import { readFile } from "node:fs/promises";
@@ -632,7 +632,7 @@ async function readCheckoutDbUrl() {
632
632
  }
633
633
  }
634
634
  async function readSecretsManagerDbUrl() {
635
- const { listSecrets, projectIdFor } = await import("./client-DCRfKvpP.js");
635
+ const { listSecrets, projectIdFor } = await import("./client-CJPfmtwW.js");
636
636
  const secrets = await listSecrets(await projectIdFor(PRODUCTION_PROJECT));
637
637
  return nonEmpty(secrets.find((s) => s.key === "DB_URL")?.value);
638
638
  }
@@ -660,6 +660,12 @@ function rosterQueryFor(url) {
660
660
  }
661
661
  };
662
662
  }
663
+ /**
664
+ * In the officer's PERSONAL vault, never the DevDogs organization: a Linear
665
+ * key acts as the person who made it, so a shared one would put every edit to
666
+ * the document under one name. It would also be an organization login that
667
+ * `creds send` could hand out.
668
+ */
663
669
  const LINEAR_VAULT_ITEM = "DevDogs Linear API key (backstage)";
664
670
  const LINEAR_API = "https://api.linear.app/graphql";
665
671
  function cell(text) {
@@ -695,21 +701,36 @@ function renderReport(items, now, roster) {
695
701
  return `${lines.join("\n")}\n`;
696
702
  }
697
703
  /**
698
- * `--linear-token`, then `LINEAR_API_KEY`, then the vault item, then a prompt.
699
- * Never saved anywhere: the vault is where it lives.
704
+ * `--linear-token`, then `LINEAR_API_KEY`, then your personal vault item, then
705
+ * a prompt, which offers to save what was typed to your personal vault.
700
706
  */
701
707
  async function resolveLinearToken(sources) {
702
- const token = nonEmpty(sources.explicit) ?? nonEmpty(sources.env) ?? await (sources.fromVault ?? readLinearTokenFromVault)() ?? await (sources.prompt ?? promptForLinearToken)();
703
- if (!token) throw new CredsError(`No Linear API key. Set LINEAR_API_KEY, or store one in your vault as "${LINEAR_VAULT_ITEM}".`);
704
- registerSecret(token);
705
- return token;
708
+ const token = nonEmpty(sources.explicit) ?? nonEmpty(sources.env) ?? await (sources.fromVault ?? readLinearTokenFromVault)();
709
+ if (token) {
710
+ registerSecret(token);
711
+ return token;
712
+ }
713
+ const typed = await (sources.prompt ?? promptForLinearToken)();
714
+ if (!typed) throw new CredsError(`No Linear API key. Set LINEAR_API_KEY, or store your own in your personal vault as "${LINEAR_VAULT_ITEM}".`);
715
+ registerSecret(typed);
716
+ await (sources.offerSave ?? offerToSaveLinearToken)(typed);
717
+ return typed;
706
718
  }
707
719
  function readLinearTokenFromVault() {
708
720
  return readPasswordFromVault(LINEAR_VAULT_ITEM, "the Linear API key", "read the Linear API key", "LINEAR_API_KEY");
709
721
  }
722
+ async function offerToSaveLinearToken(token) {
723
+ if (!process.stdin.isTTY) return;
724
+ if (!unwrap(await confirm({
725
+ message: `Save it to your personal Bitwarden vault as "DevDogs Linear API key (backstage)"?`,
726
+ initialValue: true
727
+ }))) return;
728
+ if (await savePasswordToVault("DevDogs Linear API key (backstage)", "Your own Linear API key, read by `backstage creds` to update the Shared Accounts document. Keep it in your personal vault, never the DevDogs organization: Linear records every edit under the key's owner.", token, "save the Linear API key")) log.success(`Stored as "${LINEAR_VAULT_ITEM}" in your vault.`);
729
+ else log.warn("Could not save it; continuing with the key you typed.");
730
+ }
710
731
  async function promptForLinearToken() {
711
732
  if (!process.stdin.isTTY) return void 0;
712
- const value = unwrap(await password({ message: "Linear API key? (Settings → Security & access → API keys)" }));
733
+ const value = unwrap(await password({ message: "Your Linear API key? (Settings → Security & access → API keys)" }));
713
734
  return nonEmpty(value.trim());
714
735
  }
715
736
  /** Replaces the document's content. Returns its URL. */
@@ -2,8 +2,8 @@ import { i as explain } from "./ui-RFZyr6k_.js";
2
2
  //#region src/import/commands.ts
3
3
  const handleImport = async (rest) => {
4
4
  const [what, ...args] = rest;
5
- if (what === "involvement") return (await import("./commands-B25Vafxz.js")).handleInvolvement(args);
6
- if (what === "attendance") return (await import("./commands-D-Vvufb0.js")).handleAttendance(args);
5
+ if (what === "involvement") return (await import("./commands-DsyAE7BO.js")).handleInvolvement(args);
6
+ if (what === "attendance") return (await import("./commands-H4DGzVqb.js")).handleAttendance(args);
7
7
  explain(what ? `Unknown import "${what}". Try involvement or attendance.` : "Name the import: involvement or attendance.", "");
8
8
  process.exitCode = 1;
9
9
  return null;
@@ -1,8 +1,8 @@
1
1
  import { n as isDryRun } from "./dry-run-OzO0jUhK.js";
2
2
  import { r as errorMessage, t as UsageError } from "./ui-RFZyr6k_.js";
3
3
  import { t as DONE } from "./dispatch-D048O65I.js";
4
- import { a as readInputFile, d as requireProductionKey, f as withConnection, i as plural, n as confirmWrite, o as reportFailure, s as say, t as canPrompt } from "./cli-B7W_-WdD.js";
5
- import { a as createAccounts, i as createAccountFor, n as accountFinder, o as readAccounts, t as parseCsv } from "./read-Dd388anq.js";
4
+ import { a as readInputFile, d as requireProductionKey, f as withConnection, i as plural, n as confirmWrite, o as reportFailure, s as say, t as canPrompt } from "./cli-CqbqBpaT.js";
5
+ import { a as createAccounts, i as createAccountFor, n as accountFinder, o as readAccounts, t as parseCsv } from "./read-DMderLHG.js";
6
6
  import { parseArgs } from "node:util";
7
7
  //#region src/involvement/csv.ts
8
8
  /**
@@ -1,9 +1,9 @@
1
1
  import { n as isDryRun } from "./dry-run-OzO0jUhK.js";
2
2
  import { o as unwrap, r as errorMessage, t as UsageError } from "./ui-RFZyr6k_.js";
3
3
  import { t as DONE } from "./dispatch-D048O65I.js";
4
- import { a as readInputFile, c as ProductionError, d as requireProductionKey, f as withConnection, i as plural, l as connect, n as confirmWrite, o as reportFailure, s as say, t as canPrompt } from "./cli-B7W_-WdD.js";
5
- import { a as createAccounts, i as createAccountFor, n as accountFinder, r as accountsWith, t as parseCsv } from "./read-Dd388anq.js";
6
- import { n as findMeetingsWith, r as pickMeeting, t as describeMeeting } from "./meetings-BAX4Sils.js";
4
+ import { a as readInputFile, c as ProductionError, d as requireProductionKey, f as withConnection, i as plural, l as connect, n as confirmWrite, o as reportFailure, s as say, t as canPrompt } from "./cli-CqbqBpaT.js";
5
+ import { a as createAccounts, i as createAccountFor, n as accountFinder, r as accountsWith, t as parseCsv } from "./read-DMderLHG.js";
6
+ import { n as findMeetingsWith, r as pickMeeting, t as describeMeeting } from "./meetings-QgVEoBIV.js";
7
7
  import { text } from "@clack/prompts";
8
8
  import { parseArgs } from "node:util";
9
9
  //#region src/attendance/plan.ts
package/dist/launch.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { C as discoverRepoRoot, E as setCliName, S as RepoNotFoundError, T as cliName, _ as installFailureLog, d as hasYes, g as stripTierFlag, h as stripNoEnvFlag, m as setNoEnv, n as captureDevtoolsError, o as initDevtoolsTelemetry, p as isNonInteractive, s as lastSentryEventId } from "./telemetry-RNK5Wu74.js";
2
- import { c as setMenuEnvHook, d as formatCommand, f as helpPath, n as bareGroupStartPath, t as catalog } from "./catalog-BLgY4knB.js";
2
+ import { c as setMenuEnvHook, d as formatCommand, f as helpPath, n as bareGroupStartPath, t as catalog } from "./catalog-D7eGX7cD.js";
3
3
  import { i as loadEnvSession, r as loadEnvLoad } from "./peers-DE07w6nT.js";
4
4
  import { t as nonEmpty } from "./connection-D3XsOE3S.js";
5
5
  import { i as setDryRun, n as isDryRun, r as resolveDryRun } from "./dry-run-OzO0jUhK.js";
@@ -322,7 +322,7 @@ async function launchWith(argv, options) {
322
322
  */
323
323
  async function launch(argv) {
324
324
  await launchWith(argv, { dispatch: async (args) => {
325
- const { main } = await import("./cli-Bv5hcXnG.js");
325
+ const { main } = await import("./cli-xqblG51R.js");
326
326
  await main(args);
327
327
  } });
328
328
  }
@@ -1,5 +1,5 @@
1
1
  import { t as UsageError } from "./ui-RFZyr6k_.js";
2
- import "./cli-B7W_-WdD.js";
2
+ import "./cli-CqbqBpaT.js";
3
3
  //#region src/production/time.ts
4
4
  /**
5
5
  * Dates as officers type them. Meetings happen in Athens, so a bare
@@ -1,5 +1,5 @@
1
1
  import { r as errorMessage } from "./ui-RFZyr6k_.js";
2
- import { c as ProductionError, f as withConnection } from "./cli-B7W_-WdD.js";
2
+ import { c as ProductionError, f as withConnection } from "./cli-CqbqBpaT.js";
3
3
  //#region src/production/accounts.ts
4
4
  /**
5
5
  * Production accounts: reading them, finding one by email, and creating the
@@ -50,9 +50,14 @@ function bwCommand(args) {
50
50
  * prerequisite.
51
51
  *
52
52
  * ⚠️ The token never passes through argv in either direction. Reads use
53
- * `bw get password <search>`, which puts only the item NAME on the command
53
+ * `bw list items --search <name>`, which puts only the item NAME on the command
54
54
  * line, and writes pipe base64 JSON through **stdin**, which `bw create item`
55
55
  * documents as an accepted input.
56
+ *
57
+ * ⚠️ Only the PERSONAL vault. A token read from, or saved to, an organization
58
+ * item would be one token for everybody who can see that item: every call made
59
+ * with it looks the same, and revoking one person means revoking everyone.
60
+ * Reads skip organization items and say so; writes set no organization.
56
61
  */
57
62
  const run = promisify(execFile);
58
63
  /**
@@ -177,20 +182,20 @@ function bwArgs(args, key) {
177
182
  key
178
183
  ] : withNoInteraction;
179
184
  }
180
- /**
181
- * The stored token, or `undefined` for every reason it might not be there.
182
- *
183
- * `bw get password` takes a search term and fails when it matches more than one
184
- * item, which is the behaviour worth having: two items called something like
185
- * this means somebody should look, not that this should guess.
186
- */
185
+ /** The stored token, or `undefined` for every reason it might not be there. */
187
186
  async function readTokenFromVault() {
188
187
  return readPasswordFromVault(VAULT_ITEM_NAME, "the access token", "look for the access token", "BWS_ACCESS_TOKEN");
189
188
  }
190
189
  /**
191
- * The password of the one vault item named `itemName`, or `undefined` for
192
- * every reason it might not be there. `what` names it in the spinner; `purpose`
193
- * finishes the sign-in and unlock questions.
190
+ * The password of the one PERSONAL vault item named exactly `itemName`, or
191
+ * `undefined` for every reason it might not be there. `what` names it in the
192
+ * spinner; `purpose` finishes the sign-in and unlock questions.
193
+ *
194
+ * `bw list items --search` rather than `bw get password`: `get` takes the
195
+ * first item it can see, organization items included, and an item of that
196
+ * name in an organization is precisely the shared token this must not use.
197
+ * Two personal items of the same name mean somebody should look, not that
198
+ * this should guess, so that is "not found" too.
194
199
  */
195
200
  async function readPasswordFromVault(itemName, what, purpose, envVar) {
196
201
  const status = await signInIfNeeded(await vaultStatus(), purpose);
@@ -202,34 +207,49 @@ async function readPasswordFromVault(itemName, what, purpose, envVar) {
202
207
  if (status === "locked" && !key) return void 0;
203
208
  const s = spinner();
204
209
  s.start("Looking in your Bitwarden vault");
210
+ let items;
205
211
  try {
206
212
  const { stdout } = await run(...bwCommand(bwArgs([
207
- "get",
208
- "password",
209
- itemName,
210
- "--raw"
211
- ], key)), { shell: false });
212
- const token = stdout.trim();
213
- if (token === "") {
214
- s.stop("Nothing stored in the vault yet");
215
- return;
216
- }
213
+ "list",
214
+ "items",
215
+ "--search",
216
+ itemName
217
+ ], key)), {
218
+ shell: false,
219
+ maxBuffer: 16777216
220
+ });
221
+ items = JSON.parse(stdout).filter((item) => item.name === itemName);
222
+ } catch {
223
+ s.stop("Could not read your Bitwarden vault");
224
+ return;
225
+ }
226
+ const personal = items.filter((item) => !item.organizationId);
227
+ if (personal.length > 1) {
228
+ s.stop(`More than one "${itemName}" in your vault; delete the extras`);
229
+ return;
230
+ }
231
+ const token = personal[0]?.login?.password?.trim();
232
+ if (token) {
217
233
  s.stop(`Read ${what} from your vault ("${itemName}")`);
218
234
  return token;
219
- } catch {
220
- s.stop("No single matching item in your vault");
235
+ }
236
+ if (items.length > personal.length) {
237
+ s.stop(`"${itemName}" is in an organization, not your personal vault`);
238
+ log.warn(`Not using it: an organization item is one ${what} for everyone who can see it, so nothing it does can be told apart. Use your own, saved in your personal vault (no organization).`);
221
239
  return;
222
240
  }
241
+ s.stop("Nothing stored in the vault yet");
223
242
  }
224
243
  /**
225
- * Creates the item, with the token arriving on **stdin** as base64 JSON.
244
+ * Saves `token` as a login item named `itemName` in the PERSONAL vault, the
245
+ * token arriving on **stdin** as base64 JSON.
226
246
  *
227
247
  * `bw create item` documents an encoded-JSON positional AND stdin. Using stdin
228
248
  * is the difference between a live credential that is invisible and one that
229
- * sits in `ps` output for the length of the call.
249
+ * sits in `ps` output for the length of the call. `organizationId: null` is
250
+ * what makes it personal, and it is not a parameter.
230
251
  */
231
- async function saveTokenToVault(token) {
232
- const purpose = "save the access token";
252
+ async function savePasswordToVault(itemName, notes, token, purpose) {
233
253
  const status = await signInIfNeeded(await vaultStatus(), purpose);
234
254
  if (status === "unavailable" || status === "unauthenticated") return false;
235
255
  const key = await session(status, purpose);
@@ -239,8 +259,8 @@ async function saveTokenToVault(token) {
239
259
  collectionIds: null,
240
260
  folderId: null,
241
261
  type: 1,
242
- name: VAULT_ITEM_NAME,
243
- notes: "Bitwarden Secrets Manager access token for the `admin` machine account, read/write on preflight, staging and production.\n\nRead automatically by `backstage env`. Never put this in a .env file: it unlocks all three projects, and the tool refuses to upload it.",
262
+ name: itemName,
263
+ notes,
244
264
  favorite: false,
245
265
  reprompt: 0,
246
266
  login: {
@@ -268,10 +288,14 @@ async function saveTokenToVault(token) {
268
288
  child.stdin.end(Buffer.from(JSON.stringify(item)).toString("base64"));
269
289
  });
270
290
  }
291
+ /** Saves the Secrets Manager access token. See {@link savePasswordToVault}. */
292
+ function saveTokenToVault(token) {
293
+ return savePasswordToVault(VAULT_ITEM_NAME, "Your own Bitwarden Secrets Manager access token for the `admin` machine account, read/write on preflight, staging and production.\n\nRead automatically by `backstage env`. Keep it in your personal vault, never an organization: one token per person is what keeps calls attributable and lets one person's be revoked alone. Never put this in a .env that leaves this machine: it unlocks all three projects, and the tool refuses to upload it.", token, "save the access token");
294
+ }
271
295
  /** Why the vault could not be used, phrased as something to do about it. */
272
296
  function explainVault(status, envVar = "BWS_ACCESS_TOKEN") {
273
297
  if (status === "unavailable") return "The `bw` CLI is not installed, so the vault could not be checked. It ships with backstage, so reinstall it.";
274
298
  if (status === "unauthenticated") return `You are not signed in to Bitwarden, and with no terminal there is nobody to sign in. Set ${envVar} instead.`;
275
299
  }
276
300
  //#endregion
277
- export { readTokenFromVault as a, readPasswordFromVault as i, bwArgs as n, saveTokenToVault as o, openVault as r, bwCommand as s, VAULT_ITEM_NAME as t };
301
+ export { readTokenFromVault as a, bwCommand as c, readPasswordFromVault as i, bwArgs as n, savePasswordToVault as o, openVault as r, saveTokenToVault as s, VAULT_ITEM_NAME as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devdogsuga/backstage",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Officer and production CLI for DevDogsUGA: deploys, env sync with Bitwarden and GitHub, the migration planner role, club graphics and QR codes, GitHub rulesets and settings, and the newsletter. Ships built JS; run it anywhere with `pnpm dlx @devdogsuga/backstage` (no checkout needed to start; commands that read a checkout say so).",
@@ -40,10 +40,10 @@
40
40
  "tsx": "^4.23.12",
41
41
  "typescript": "^6.0.3",
42
42
  "zod": "4.4.3",
43
- "@devdogsuga/brand": "0.1.7",
44
- "@devdogsuga/events": "0.1.7",
43
+ "@devdogsuga/brand": "0.1.8",
44
+ "@devdogsuga/newsletter": "0.1.16",
45
45
  "@devdogsuga/telemetry": "0.1.4",
46
- "@devdogsuga/newsletter": "0.1.14"
46
+ "@devdogsuga/events": "0.1.7"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@devdogsuga/db": "*",
@@ -64,8 +64,8 @@
64
64
  "eslint": "^9.39.5",
65
65
  "tsdown": "^0.23.0",
66
66
  "vitest": "^4.1.11",
67
- "@devdogsuga/config": "0.1.2",
68
67
  "@devdogsuga/cli-core": "0.0.0",
68
+ "@devdogsuga/config": "0.1.2",
69
69
  "@devdogsuga/db": "0.1.3",
70
70
  "@devdogsuga/env": "0.1.5"
71
71
  },
@@ -1,2 +0,0 @@
1
- import { a as listSecrets, o as projectIdFor } from "./client-Djo8fRdS.js";
2
- export { listSecrets, projectIdFor };