@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 +10 -5
- package/dist/{catalog-BLgY4knB.js → catalog-D7eGX7cD.js} +26 -12
- package/dist/{cli-B7W_-WdD.js → cli-CqbqBpaT.js} +1 -1
- package/dist/{cli-Bv5hcXnG.js → cli-xqblG51R.js} +161 -26
- package/dist/{client-Djo8fRdS.js → client-CFhbS3nu.js} +2 -2
- package/dist/client-CJPfmtwW.js +2 -0
- package/dist/{commands-3HvxDq-2.js → commands-BEYZyI4Y.js} +2 -2
- package/dist/{commands-C6w8jm6a.js → commands-CTQjJ45_.js} +30 -9
- package/dist/{commands-DfWNSPra.js → commands-DfQKdPJY.js} +2 -2
- package/dist/{commands-B25Vafxz.js → commands-DsyAE7BO.js} +2 -2
- package/dist/{commands-D-Vvufb0.js → commands-H4DGzVqb.js} +3 -3
- package/dist/launch.js +2 -2
- package/dist/{meetings-BAX4Sils.js → meetings-QgVEoBIV.js} +1 -1
- package/dist/{read-Dd388anq.js → read-DMderLHG.js} +1 -1
- package/dist/{vault-BpYSYcuT.js → vault-DVmaQC_E.js} +54 -30
- package/package.json +5 -5
- package/dist/client-DCRfKvpP.js +0 -2
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). |
|
|
@@ -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
|
|
157
|
-
a prompt
|
|
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
|
|
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.
|
|
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-
|
|
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-
|
|
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-
|
|
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 =
|
|
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
|
}
|
|
@@ -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-
|
|
5393
|
-
import: async (rest) => (await import("./commands-
|
|
5394
|
-
export: async (rest) => (await import("./commands-
|
|
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,
|
|
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
|
|
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();
|
|
@@ -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-
|
|
6
|
-
import { i as parseBound, n as findMeetingsWith, r as pickMeeting, t as describeMeeting } from "./meetings-
|
|
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,
|
|
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-
|
|
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
|
|
699
|
-
*
|
|
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)()
|
|
703
|
-
if (
|
|
704
|
-
|
|
705
|
-
|
|
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-
|
|
6
|
-
if (what === "attendance") return (await import("./commands-
|
|
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-
|
|
5
|
-
import { a as createAccounts, i as createAccountFor, n as accountFinder, o as readAccounts, t as parseCsv } from "./read-
|
|
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-
|
|
5
|
-
import { a as createAccounts, i as createAccountFor, n as accountFinder, r as accountsWith, t as parseCsv } from "./read-
|
|
6
|
-
import { n as findMeetingsWith, r as pickMeeting, t as describeMeeting } from "./meetings-
|
|
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-
|
|
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
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { r as errorMessage } from "./ui-RFZyr6k_.js";
|
|
2
|
-
import { c as ProductionError, f as withConnection } from "./cli-
|
|
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
|
|
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
|
|
192
|
-
* every reason it might not be there. `what` names it in the
|
|
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
|
-
"
|
|
208
|
-
"
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
], key)), {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
}
|
|
220
|
-
|
|
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
|
-
*
|
|
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
|
|
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:
|
|
243
|
-
notes
|
|
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,
|
|
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.
|
|
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.
|
|
44
|
-
"@devdogsuga/
|
|
43
|
+
"@devdogsuga/brand": "0.1.8",
|
|
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,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
|
},
|
package/dist/client-DCRfKvpP.js
DELETED