@devdogsuga/backstage 0.1.13 → 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). |
@@ -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
  {
@@ -1,5 +1,5 @@
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";
@@ -12,7 +12,7 @@ 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
  }
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-DIXhcRjn.js");
325
+ const { main } = await import("./cli-xqblG51R.js");
326
326
  await main(args);
327
327
  } });
328
328
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devdogsuga/backstage",
3
- "version": "0.1.13",
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).",
@@ -41,9 +41,9 @@
41
41
  "typescript": "^6.0.3",
42
42
  "zod": "4.4.3",
43
43
  "@devdogsuga/brand": "0.1.8",
44
- "@devdogsuga/events": "0.1.7",
44
+ "@devdogsuga/newsletter": "0.1.16",
45
45
  "@devdogsuga/telemetry": "0.1.4",
46
- "@devdogsuga/newsletter": "0.1.15"
46
+ "@devdogsuga/events": "0.1.7"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@devdogsuga/db": "*",
@@ -64,9 +64,9 @@
64
64
  "eslint": "^9.39.5",
65
65
  "tsdown": "^0.23.0",
66
66
  "vitest": "^4.1.11",
67
+ "@devdogsuga/cli-core": "0.0.0",
67
68
  "@devdogsuga/config": "0.1.2",
68
69
  "@devdogsuga/db": "0.1.3",
69
- "@devdogsuga/cli-core": "0.0.0",
70
70
  "@devdogsuga/env": "0.1.5"
71
71
  },
72
72
  "scripts": {