@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`
|
|
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.
|
|
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-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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`, `
|
|
1668
|
-
* `prune-monitors`) are individually addressable for jobs that run
|
|
1669
|
-
* The ones that hold
|
|
1670
|
-
* compose no env file (`preflight`, `plan`, `migrate`, `
|
|
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
|
|
4839
|
-
*
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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.
|
|
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/
|
|
44
|
+
"@devdogsuga/newsletter": "0.1.16",
|
|
45
45
|
"@devdogsuga/telemetry": "0.1.4",
|
|
46
|
-
"@devdogsuga/
|
|
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": {
|