@devdogsuga/backstage 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,8 @@ match. The two flags matter outside a DevDogsUGA checkout, where the workspace's
14
14
  `pnpm backstage` is the script).
15
15
 
16
16
  It **starts without a checkout**. Help, `version`, `completions`, the tools
17
- below that need no secrets (`graphics`, `qr`, `github`, `newsletter`, `creds`) and
17
+ below that need no secrets (`graphics`, `qr`, `github`, `newsletter`, `creds`),
18
+ `import`, `export` and
18
19
  anything run with `--no-env` never look for one; a command that reads a checkout says
19
20
  "run this from inside a DevDogsUGA clone" and exits 1. The DevDogsUGA libraries
20
21
  it reads (`@devdogsuga/env`, `@devdogsuga/db`) are optional peers, resolved
@@ -33,25 +34,32 @@ pnpm backstage qr https://devdogsuga.org --format svg,png,webp --logo acm
33
34
  pnpm backstage newsletter send 3.0.1 --to a@uga.edu # asks first; --yes with no terminal
34
35
  pnpm backstage creds # share a club login with officers, as a Bitwarden Send
35
36
  pnpm backstage creds renew # extend every Send 30 days
37
+ pnpm backstage import involvement --file OrganizationRoster.csv # verify members; previews, then asks
38
+ pnpm backstage import attendance --meeting 2026-09-09 --file sign-in.csv
39
+ pnpm backstage export stars --from 2026-08-17 # audited under your gh login
36
40
  ```
37
41
 
38
42
  ## Commands
39
43
 
40
- | Command | What it does |
41
- | ---------------------------------------------- | -------------------------------------------------------------------------------------- |
42
- | `deploy <app> --tier <t>` | Checks `CLOUDFLARE_API_TOKEN`, writes the Worker's secrets file, deploys, removes it. |
43
- | `deploy write-env` | Composes `.env.<DEPLOY_ENV>` from the GitHub environment. |
44
- | `deploy preflight` | Classifies the project: paused (skip) or broken (fail). |
45
- | `deploy plan`, `deploy migrate` | Dry-run the migrations into the job summary; apply them to `DB_URL`. |
46
- | `deploy smoke --tier <t> [--app]` | Public routes answer 200, the auth redirect works, this deploy's Sentry release shows. |
47
- | `deploy reconcile --tier <t>` | The platform's config reconcile, after the deploy (`CRON_SECRET`). |
48
- | `env pull\|push\|audit --target <t>` | One env file per target, synced to Bitwarden and GitHub. `audit` lists orphans. |
49
- | `planner status\|create\|reset-password\|drop` | The `migration_planner` role the preflight tier holds. |
50
- | `graphics [graphic…]` | Club images from `@devdogsuga/brand`: `brand/*`, `app/*`, `event/*`. |
51
- | `qr <text>` | QR codes with every option of `/console/qr`. |
52
- | `github rulesets\|settings` | Diff (and with `--apply` write) GitHub config, through `gh`. |
53
- | `newsletter render\|draft\|send <issue…>` | Changelog issues as files, mailbox drafts, or a send. |
54
- | `creds send\|add\|renew\|list\|report` | Club logins from Bitwarden as email-verified Sends, and the Linear report. |
44
+ | Command | What it does |
45
+ | ------------------------------------------------ | -------------------------------------------------------------------------------------- |
46
+ | `deploy <app> --tier <t>` | Checks `CLOUDFLARE_API_TOKEN`, writes the Worker's secrets file, deploys, removes it. |
47
+ | `deploy write-env` | Composes `.env.<DEPLOY_ENV>` from the GitHub environment. |
48
+ | `deploy preflight` | Classifies the project: paused (skip) or broken (fail). |
49
+ | `deploy plan`, `deploy migrate` | Dry-run the migrations into the job summary; apply them to `DB_URL`. |
50
+ | `deploy smoke --tier <t> [--app]` | Public routes answer 200, the auth redirect works, this deploy's Sentry release shows. |
51
+ | `deploy reconcile --tier <t>` | The platform's config reconcile, after the deploy (`CRON_SECRET`). |
52
+ | `deploy prune-monitors --tier <t> [--app]` | Deletes the app's Sentry Crons monitors it no longer declares (production only). |
53
+ | `env pull\|push\|audit --target <t>` | One env file per target, synced to Bitwarden and GitHub. `audit` lists orphans. |
54
+ | `planner status\|create\|reset-password\|drop` | The `migration_planner` role the preflight tier holds. |
55
+ | `graphics [graphic…]` | Club images from `@devdogsuga/brand`: `brand/*`, `app/*`, `event/*`. |
56
+ | `qr <text>` | QR codes with every option of `/console/qr`. |
57
+ | `github rulesets\|settings` | Diff (and with `--apply` write) GitHub config, through `gh`. |
58
+ | `newsletter render\|draft\|send <issue…>` | Changelog issues as files, mailbox drafts, or a send. |
59
+ | `creds send\|add\|renew\|list\|report` | Club logins from Bitwarden as email-verified Sends, and the Linear report. |
60
+ | `import involvement --file <csv>` | Verifies the members on the Involvement Network roster, unverifies everyone else. |
61
+ | `import attendance --meeting <day> --file <csv>` | Records a meeting's attendance from a sign-in sheet (method `import`). |
62
+ | `export stars\|attendance\|reflections` | Member data as CSV, each export audited under the officer's gh login. |
55
63
 
56
64
  `smoke` and `reconcile` replace DevDogsUGA's `packages/deploy-checks`. The
57
65
  per-app data (hosts, public paths, the protected path and its redirect) stays in
@@ -148,6 +156,62 @@ checkout, else Secrets Manager. The Linear key: `--linear-token`, then
148
156
  `LINEAR_API_KEY`, then the vault item "DevDogs Linear API key (backstage)", then
149
157
  a prompt; it is never saved.
150
158
 
159
+ ## Member data: `import` and `export`
160
+
161
+ These replace the platform's `/console/verification` upload and
162
+ `/console/exports` downloads. They read and write production only:
163
+ `DB_URL`, `API_URL` and `SECRET_KEY` come from `.env.production` in a checkout,
164
+ else Secrets Manager. There is no `--db-url`, since a database override would
165
+ leave account creation pointed at production. Days (`--meeting 2026-09-09`,
166
+ `--from`, `--to`) are Eastern, and `--to` includes the whole day.
167
+
168
+ ### `import involvement`
169
+
170
+ Replaces the platform's `/console/verification` upload. Export the roster from
171
+ the UGA Involvement Network (Roster → Export → Organization Roster) and pass the
172
+ file as it downloads: the title lines above the header, the `(Hidden)` columns
173
+ and the one-row-per-position layout are all expected. A roster for another
174
+ organization is refused.
175
+
176
+ The roster is the whole truth: everyone on it is verified, and everyone
177
+ verified who is not on it loses that status. A roster email finds its account
178
+ by the profile's UGA email first, then the sign-in address; with no account,
179
+ one is created (confirmed, no invite email) so the member is verified the first
180
+ time they sign in. Every run previews the counts, who is newly verified, who
181
+ loses verification, and whose preferred name differs from the roster, then
182
+ asks; `--dry-run` stops there, `--yes` answers with no terminal. Preferred
183
+ names are never changed. Rerunning the same export changes nothing.
184
+
185
+ ### `import attendance`
186
+
187
+ Records a meeting's attendance from a sign-in sheet, for members who could not
188
+ check in on the platform. The sheet is a Google or Microsoft Forms response
189
+ export or a hand-made spreadsheet, saved as CSV; one sheet is one meeting,
190
+ named with `--meeting` (its day, slug, config id or id; a day with two meetings
191
+ asks for the slug). The header and columns are found, not assumed: the email
192
+ column is the one holding UGA addresses, the name is "First/Last Name" or a
193
+ column mentioning "name", and `--email-column`/`--name-column` override
194
+ either. A sheet with no header is read as every address in it. Only UGA
195
+ addresses count; anything else is listed and skipped.
196
+
197
+ Check-ins are written with method `import`, stamped with the meeting's start,
198
+ and count for stars like any other. A member's own check-in is never touched.
199
+ Someone with no account gets one (confirmed, no invite), named from the sheet;
200
+ with no name on the sheet they are listed and skipped. Cancelled and future
201
+ meetings are refused. Rerunning a sheet records nothing new; `--replace`
202
+ makes the sheet the meeting's whole imported set, removing earlier imported
203
+ rows it no longer lists. Every run previews, then asks (`--dry-run`, `--yes`).
204
+
205
+ ### `export stars|attendance|reflections`
206
+
207
+ The platform's three CSVs, columns unchanged: `stars` (one row per earned
208
+ star), `attendance` (one row per check-in; `--meeting` for one night) and
209
+ `reflections` (each member's current text). Each export writes a
210
+ `platform.exportAudit` row before reading anything, attributed to the
211
+ platform account linked to the GitHub login `gh` is signed in as, and refuses
212
+ to run when there is none. The file goes to `./<kind>….csv` (owner-readable
213
+ only, never over an existing file without `--yes`) or stdout with `--out -`.
214
+
151
215
  No secret value reaches stdout, stderr, the failure log, Sentry, an error
152
216
  message or argv: values go to `bw` as base64 JSON on stdin, every error is
153
217
  scrubbed of every value read so far (`src/creds/secrets.ts`), and the clipboard
@@ -171,7 +235,8 @@ stderr, and every confirmation needs `--yes`. Against staging or production a
171
235
  command that is not read-only asks once first (`--yes` answers it).
172
236
 
173
237
  Each command checks for the secrets it uses up front: `deploy <app>` for
174
- `CLOUDFLARE_API_TOKEN`, `deploy reconcile` for `CRON_SECRET`, `env` for the
238
+ `CLOUDFLARE_API_TOKEN`, `deploy reconcile` for `CRON_SECRET`, `deploy
239
+ prune-monitors` for `SENTRY_MONITORS_TOKEN` (skipped without it), `env` for the
175
240
  Secrets Manager token (flag, environment, then the Bitwarden vault, which `env`
176
241
  signs in to and unlocks itself, then asking).
177
242
 
@@ -1,7 +1,9 @@
1
1
  import { A as YES, D as DRY_RUN, O as JSON_FLAG, j as createCatalog, k as SCOPES, v as noteRan, w as findRepoRoot } from "./telemetry-Bjoz29Hl.js";
2
- import { o as isDryRun, r as qrCatalogOptions } from "./options-BTjOf5KP.js";
2
+ import { n as isDryRun } from "./dry-run-3IxPmCtW.js";
3
3
  import { o as unwrap } from "./ui-CdKo8mLw.js";
4
4
  import { n as positionals } from "./args-Cjr_Iqts.js";
5
+ import { t as EXPORTS } from "./queries-DeEwfe2c.js";
6
+ import { r as qrCatalogOptions } from "./options-DCaqm8BI.js";
5
7
  import { existsSync, readFileSync } from "node:fs";
6
8
  import { join } from "node:path";
7
9
  import { confirm, note, select, text } from "@clack/prompts";
@@ -930,7 +932,7 @@ const ITEM = {
930
932
  value: "<name>",
931
933
  summary: "A DevDogs organization item, by name or id. Repeat for several."
932
934
  };
933
- const TO = {
935
+ const TO$1 = {
934
936
  flag: "--to",
935
937
  value: "<a@uga.edu,…>",
936
938
  summary: "Recipients, comma-separated. Replaces the item's current list."
@@ -970,7 +972,7 @@ const credsCommand = {
970
972
  hint: "pick accounts, then officers",
971
973
  options: [
972
974
  ITEM,
973
- TO,
975
+ TO$1,
974
976
  ROLE,
975
977
  ALLOW_EMAIL,
976
978
  DB_URL$1,
@@ -1013,7 +1015,7 @@ const credsCommand = {
1013
1015
  flag: "--password-stdin",
1014
1016
  summary: "Read the password from stdin, for scripts."
1015
1017
  },
1016
- TO,
1018
+ TO$1,
1017
1019
  ROLE,
1018
1020
  ALLOW_EMAIL,
1019
1021
  DB_URL$1,
@@ -1141,6 +1143,16 @@ const deployCommand = {
1141
1143
  summary: "Run the platform's config reconcile after a deploy.",
1142
1144
  hint: "needs CRON_SECRET",
1143
1145
  options: [DEPLOY_TIER, SMOKE_APP]
1146
+ },
1147
+ {
1148
+ name: "prune-monitors",
1149
+ summary: "Delete the Sentry Crons monitors an app no longer declares.",
1150
+ hint: "needs SENTRY_MONITORS_TOKEN; production only",
1151
+ options: [
1152
+ DEPLOY_TIER,
1153
+ SMOKE_APP,
1154
+ DRY_RUN
1155
+ ]
1144
1156
  }
1145
1157
  ]
1146
1158
  };
@@ -1210,6 +1222,61 @@ const envCommand = {
1210
1222
  ]
1211
1223
  };
1212
1224
  //#endregion
1225
+ //#region src/export/catalog.ts
1226
+ /**
1227
+ * `export`'s place in the command tree: declaration only, nothing here runs.
1228
+ * The handler lives in `commands.ts` beside it.
1229
+ *
1230
+ * `envFree` like `involvement`: production's `DB_URL` comes from the
1231
+ * checkout's `.env.production` or Secrets Manager, and the audit identity from
1232
+ * `gh`, so it runs under `pnpm dlx`.
1233
+ */
1234
+ const FROM = {
1235
+ flag: "--from",
1236
+ value: "<day>",
1237
+ summary: "From this Eastern day (or ISO timestamp), inclusive."
1238
+ };
1239
+ const TO = {
1240
+ flag: "--to",
1241
+ value: "<day>",
1242
+ summary: "Through this Eastern day (or before this ISO timestamp)."
1243
+ };
1244
+ const OUT = {
1245
+ flag: "--out",
1246
+ value: "<path|->",
1247
+ summary: "Where to write. Defaults to ./<kind>….csv; - for stdout."
1248
+ };
1249
+ function exportOf(kind, extra = []) {
1250
+ return {
1251
+ name: kind,
1252
+ envFree: true,
1253
+ dryRun: "handled",
1254
+ summary: EXPORTS[kind].summary,
1255
+ options: [
1256
+ FROM,
1257
+ TO,
1258
+ ...extra,
1259
+ OUT,
1260
+ DRY_RUN,
1261
+ YES
1262
+ ]
1263
+ };
1264
+ }
1265
+ const exportCommand = {
1266
+ name: "export",
1267
+ summary: "Member data as CSV: stars, attendance, reflections. Audited.",
1268
+ hint: "production database; your gh login is recorded",
1269
+ subcommands: [
1270
+ exportOf("stars"),
1271
+ exportOf("attendance", [{
1272
+ flag: "--meeting",
1273
+ value: "<meeting>",
1274
+ summary: "One meeting: its day (2026-09-09), slug, config id or id."
1275
+ }]),
1276
+ exportOf("reflections")
1277
+ ]
1278
+ };
1279
+ //#endregion
1213
1280
  //#region src/github/catalog.ts
1214
1281
  /**
1215
1282
  * `github`'s place in the command tree: declaration only, nothing here runs.
@@ -1303,6 +1370,63 @@ const graphicsCommand = {
1303
1370
  ]
1304
1371
  };
1305
1372
  //#endregion
1373
+ //#region src/import/catalog.ts
1374
+ const importCommand = {
1375
+ name: "import",
1376
+ summary: "Member data in: the Involvement roster, a meeting's sign-in sheet.",
1377
+ hint: "production database; previews, then asks",
1378
+ subcommands: [{
1379
+ name: "involvement",
1380
+ envFree: true,
1381
+ dryRun: "handled",
1382
+ summary: "Import an Organization Roster export: verify everyone on it, unverify everyone else.",
1383
+ hint: "previews, then asks",
1384
+ options: [
1385
+ {
1386
+ flag: "--file",
1387
+ value: "<csv>",
1388
+ summary: "The roster export (Involvement Network → Roster → Export → Organization Roster)."
1389
+ },
1390
+ DRY_RUN,
1391
+ YES
1392
+ ]
1393
+ }, {
1394
+ name: "attendance",
1395
+ envFree: true,
1396
+ dryRun: "handled",
1397
+ summary: "Import a sign-in sheet (form responses or a spreadsheet, as CSV) for one meeting.",
1398
+ hint: "previews, then asks",
1399
+ options: [
1400
+ {
1401
+ flag: "--meeting",
1402
+ value: "<meeting>",
1403
+ summary: "The meeting: its day (2026-09-09), slug, config id or id."
1404
+ },
1405
+ {
1406
+ flag: "--file",
1407
+ value: "<csv>",
1408
+ summary: "The sign-in sheet, saved as CSV."
1409
+ },
1410
+ {
1411
+ flag: "--email-column",
1412
+ value: "<header>",
1413
+ summary: "The column of UGA addresses, when there are several."
1414
+ },
1415
+ {
1416
+ flag: "--name-column",
1417
+ value: "<header>",
1418
+ summary: "The column of names, used for new accounts."
1419
+ },
1420
+ {
1421
+ flag: "--replace",
1422
+ summary: "Make this sheet the meeting's whole imported set: remove earlier imported rows it lacks."
1423
+ },
1424
+ DRY_RUN,
1425
+ YES
1426
+ ]
1427
+ }]
1428
+ };
1429
+ //#endregion
1306
1430
  //#region src/newsletter/catalog.ts
1307
1431
  /**
1308
1432
  * `newsletter`'s place in the command tree: declaration only, nothing here
@@ -1458,8 +1582,12 @@ const catalog = createCatalog({
1458
1582
  commands: [envCommand]
1459
1583
  },
1460
1584
  {
1461
- title: "Database roles",
1462
- commands: [plannerCommand]
1585
+ title: "Production database",
1586
+ commands: [
1587
+ plannerCommand,
1588
+ importCommand,
1589
+ exportCommand
1590
+ ]
1463
1591
  },
1464
1592
  {
1465
1593
  title: "Graphics & QR codes (no credentials)",
@@ -1,13 +1,13 @@
1
1
  import { C as discoverRepoRoot, 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-Bjoz29Hl.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-BUOwCd2Q.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-BiXI0wyi.js";
3
3
  import { a as repoPeerUrl, n as loadEnv, r as loadEnvLoad, t as getEnvSync } from "./peers-B0KDkI6a.js";
4
4
  import { t as nonEmpty } from "./connection-D3XsOE3S.js";
5
- import { a as dryRunKind, l as wouldRunLine, o as isDryRun } from "./options-BTjOf5KP.js";
5
+ import { a as wouldRunLine, n as isDryRun, t as dryRunKind } from "./dry-run-3IxPmCtW.js";
6
6
  import { a as explainError, i as explain, n as bail, o as unwrap, r as errorMessage } from "./ui-CdKo8mLw.js";
7
7
  import { n as positionals, t as flagValue } from "./args-Cjr_Iqts.js";
8
- import { a as listSecrets$1, c as updateSecret, i as createSecret, o as projectIdFor, r as byKey, s as setExplicitAccessToken } from "./client-B7HhuTaA.js";
9
- import { c as EnvDocument } from "./vault-C8LYJFOQ.js";
10
8
  import { t as DONE } from "./dispatch-D048O65I.js";
9
+ import { t as EnvDocument } from "./document-DumoZXoO.js";
10
+ import { a as listSecrets$1, c as updateSecret, i as createSecret, o as projectIdFor, r as byKey, s as setExplicitAccessToken } from "./client-B_MY9kVI.js";
11
11
  import { register } from "node:module";
12
12
  import { pathToFileURL } from "node:url";
13
13
  import { appendFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
@@ -17,9 +17,9 @@ import { chmod, readFile, writeFile } from "node:fs/promises";
17
17
  import { execFile, execFileSync, spawn } from "node:child_process";
18
18
  import { promisify } from "node:util";
19
19
  import { tmpdir } from "node:os";
20
+ import postgres from "postgres";
20
21
  import { z } from "zod";
21
22
  import { randomBytes } from "node:crypto";
22
- import postgres from "postgres";
23
23
  //#region ../cli-core/src/completions.ts
24
24
  /**
25
25
  * Groups all paths by their parent prefix to build per-parent completion lists.
@@ -553,6 +553,116 @@ async function runDeployMigrate(env = process.env, push = (url) => dbPush(url, {
553
553
  say(["deploy migrate: migrations applied."]);
554
554
  }
555
555
  //#endregion
556
+ //#region src/deploy/monitors.ts
557
+ /**
558
+ * `deploy prune-monitors`: deletes the Sentry Crons monitors an app no longer
559
+ * declares.
560
+ *
561
+ * Sentry creates a monitor the first time a job checks in, and never removes
562
+ * one. Rename or drop a `monitorSlug` and the old monitor keeps expecting
563
+ * check-ins that will never come, opening a "missed check-in" issue every
564
+ * period until someone deletes it by hand (PLATFORM-2: the Discord role sync
565
+ * folded into the fifteen-minute cron, and its ten-minute monitor alerted
566
+ * every ten minutes after the deploy).
567
+ *
568
+ * The declared set is every `monitorSlug` on the entries of the app's
569
+ * `cloudflare/scheduled.ts` exports `CRON_ROUTES` and `WORKFLOW_CRONS`. Every
570
+ * monitor in the app's Sentry project that is not in it, and whose slug
571
+ * starts with `<app>-`, is deleted. The prefix keeps a monitor someone made
572
+ * by hand in the Sentry UI out of reach.
573
+ *
574
+ * Delete, not disable. Sentry drops every check-in sent to a disabled monitor
575
+ * and never re-enables one from a check-in, so a slug that came back would
576
+ * report nowhere. A deleted one is recreated by the next check-in that
577
+ * carries its config (each `withMonitor`/`captureCheckIn` here does), and the
578
+ * delete renames the old slug first, so the name is free at once.
579
+ *
580
+ * Production only. Monitors belong to a project, not a tier, and staging
581
+ * deploys `main`, which can be ahead of production: a monitor `main` dropped
582
+ * still has a production job checking in to it.
583
+ *
584
+ * `SENTRY_MONITORS_TOKEN`, not `SENTRY_AUTH_TOKEN`: the deploy token is an
585
+ * organization token (`org:ci`), which Sentry lets neither list nor delete
586
+ * monitors. This one needs `alerts:write` (`alerts:read` to list), and is
587
+ * held in this step's `env:` alone. Absent, the step is skipped with a
588
+ * notice, the same as the Sentry release check.
589
+ */
590
+ const SENTRY_API$1 = "https://sentry.io/api/0";
591
+ /** Only the field read here; the exports' own shapes are devtools' to check. */
592
+ const Declarations = z.record(z.string(), z.object({ monitorSlug: z.string().min(1).optional() }));
593
+ const MonitorList = z.array(z.object({ slug: z.string() }));
594
+ /**
595
+ * Every `monitorSlug` the app's `cloudflare/scheduled.ts` declares, or
596
+ * `undefined` when the app has no such file (no crons, nothing to prune).
597
+ */
598
+ async function declaredMonitorSlugs(app, root = findRepoRoot()) {
599
+ const path = join(root, "apps", app, "cloudflare", "scheduled.ts");
600
+ if (!existsSync(path)) return void 0;
601
+ const mod = await import(pathToFileURL(path).href);
602
+ const slugs = /* @__PURE__ */ new Set();
603
+ for (const name of ["CRON_ROUTES", "WORKFLOW_CRONS"]) {
604
+ const parsed = Declarations.safeParse(mod[name] ?? {});
605
+ if (!parsed.success) throw new DeployError(`${path}: ${name} is not a map of entries.`);
606
+ for (const entry of Object.values(parsed.data)) if (entry.monitorSlug) slugs.add(entry.monitorSlug);
607
+ }
608
+ return slugs;
609
+ }
610
+ /** The `rel="next"` URL of a Sentry `Link` header, when it has results. */
611
+ function nextPage(link) {
612
+ for (const part of link?.split(",") ?? []) if (part.includes("rel=\"next\"") && part.includes("results=\"true\"")) return /<([^>]+)>/.exec(part)?.[1];
613
+ }
614
+ async function listMonitorSlugs(org, project, headers, fetchImpl) {
615
+ const slugs = [];
616
+ let url = `${SENTRY_API$1}/organizations/${org}/monitors/?project=${encodeURIComponent(project)}`;
617
+ while (url) {
618
+ const response = await fetchImpl(url, { headers });
619
+ if (!response.ok) throw new DeployError(`Listing Sentry monitors: HTTP ${response.status}.`, ["SENTRY_MONITORS_TOKEN needs alerts:read and alerts:write."]);
620
+ slugs.push(...MonitorList.parse(await response.json()).map((m) => m.slug));
621
+ url = nextPage(response.headers.get("link"));
622
+ }
623
+ return slugs;
624
+ }
625
+ /** Returns the slugs it deleted (or would have, on a dry run). */
626
+ async function runPruneMonitors(options) {
627
+ const env = options.env ?? process.env;
628
+ const { app } = options;
629
+ if (options.tier !== "production") {
630
+ say([`prune-monitors: skipped on ${options.tier}; monitors are pruned against production only.`]);
631
+ return [];
632
+ }
633
+ const token = env.SENTRY_MONITORS_TOKEN;
634
+ if (!token) {
635
+ say(["::notice::Sentry monitor pruning skipped -- SENTRY_MONITORS_TOKEN is not configured."]);
636
+ return [];
637
+ }
638
+ if (!env.SENTRY_ORG) throw new DeployError("SENTRY_ORG is not set.");
639
+ const declared = options.declared ?? await declaredMonitorSlugs(app);
640
+ if (!declared) {
641
+ say([`prune-monitors: ${app} has no cloudflare/scheduled.ts; nothing to prune.`]);
642
+ return [];
643
+ }
644
+ const headers = { authorization: `Bearer ${token}` };
645
+ const fetchImpl = options.fetchImpl ?? fetch;
646
+ const orphans = (await listMonitorSlugs(env.SENTRY_ORG, app, headers, fetchImpl)).filter((slug) => slug.startsWith(`${app}-`) && !declared.has(slug));
647
+ if (orphans.length === 0) {
648
+ say([`prune-monitors: every ${app} monitor is declared.`]);
649
+ return [];
650
+ }
651
+ for (const slug of orphans) {
652
+ if (options.dryRun) {
653
+ say([`Would delete Sentry monitor ${slug}`]);
654
+ continue;
655
+ }
656
+ const response = await fetchImpl(`${SENTRY_API$1}/projects/${env.SENTRY_ORG}/${app}/monitors/${encodeURIComponent(slug)}/`, {
657
+ method: "DELETE",
658
+ headers
659
+ });
660
+ if (!response.ok && response.status !== 404) throw new DeployError(`Deleting Sentry monitor ${slug}: HTTP ${response.status}.`);
661
+ say([`Deleted Sentry monitor ${slug}`]);
662
+ }
663
+ return orphans;
664
+ }
665
+ //#endregion
556
666
  //#region src/deploy/preflight.ts
557
667
  /**
558
668
  * `pnpm devtools deploy preflight`
@@ -1554,10 +1664,11 @@ function renderWriteEnvReport(result) {
1554
1664
  * Sandbox (plain Worker) always used this shape, so all three apps share one
1555
1665
  * deploy step.
1556
1666
  *
1557
- * Steps (`write-env`, `preflight`, `plan`, `migrate`, `smoke`, `reconcile`) are
1558
- * individually addressable for jobs that run only one. The ones that hold a
1559
- * single credential in the job's own `env:` block and compose no env file
1560
- * (`preflight`, `plan`, `migrate`, `smoke`, `reconcile`), and `write-env`,
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`,
1561
1672
  * which CREATES the file tier resolution would otherwise insist on reading,
1562
1673
  * run with `--no-env`.
1563
1674
  */
@@ -1707,6 +1818,14 @@ async function runStep(sub, rest) {
1707
1818
  });
1708
1819
  return true;
1709
1820
  }
1821
+ if (sub === "prune-monitors") {
1822
+ await runPruneMonitors({
1823
+ app: flagValue(rest, "--app") ?? "platform",
1824
+ tier: requireTier(rest),
1825
+ dryRun: rest.includes("--dry-run")
1826
+ });
1827
+ return true;
1828
+ }
1710
1829
  if (sub === "write-env") {
1711
1830
  await loadRegistry();
1712
1831
  const index = rest.indexOf("--source");
@@ -5268,9 +5387,11 @@ const HANDLERS = {
5268
5387
  completions: handleCompletions,
5269
5388
  github: handleGithub,
5270
5389
  graphics: async (rest) => (await import("./commands-Dv5TXjvb.js")).handleGraphics(rest),
5271
- qr: async (rest) => (await import("./commands-Bk7IbhPD.js")).handleQr(rest),
5390
+ qr: async (rest) => (await import("./commands-Sx1fDNNn.js")).handleQr(rest),
5272
5391
  newsletter: async (rest) => (await import("./commands-D345F-Od.js")).handleNewsletter(rest),
5273
- creds: async (rest) => (await import("./commands-BMQ1OZIr.js")).handleCreds(rest)
5392
+ creds: async (rest) => (await import("./commands-zPSnOGzH.js")).handleCreds(rest),
5393
+ import: async (rest) => (await import("./commands-DzALVX5g.js")).handleImport(rest),
5394
+ export: async (rest) => (await import("./commands-5Ou8xJw5.js")).handleExport(rest)
5274
5395
  };
5275
5396
  /**
5276
5397
  * Names that were commands of the contributor CLI, refused with where they
@@ -0,0 +1,136 @@
1
+ import { C as discoverRepoRoot, p as isNonInteractive } from "./telemetry-Bjoz29Hl.js";
2
+ import { t as nonEmpty } from "./connection-D3XsOE3S.js";
3
+ import { a as explainError, i as explain, o as unwrap, t as UsageError } from "./ui-CdKo8mLw.js";
4
+ import { t as EnvDocument } from "./document-DumoZXoO.js";
5
+ import { resolve } from "node:path";
6
+ import { confirm, log, text } from "@clack/prompts";
7
+ import { readFile } from "node:fs/promises";
8
+ import postgres from "postgres";
9
+ //#region src/production/access.ts
10
+ /**
11
+ * Production access for the commands that read or write member data
12
+ * (`involvement`, `attendance`, `export`).
13
+ *
14
+ * Every key comes from one place, so a run never splits across two projects:
15
+ * the checkout's `.env.production`, else the production Secrets Manager
16
+ * project. There is deliberately no `--db-url`; a database override would
17
+ * leave account creation (which goes through Auth with `API_URL`) pointed at
18
+ * production.
19
+ */
20
+ /** A failure already worded for the officer. Safe to print. */
21
+ var ProductionError = class extends Error {
22
+ name = "ProductionError";
23
+ };
24
+ /** The production project in Secrets Manager (see `creds/roster.ts`). */
25
+ const PRODUCTION_PROJECT = "production";
26
+ /** `.env.production` in a checkout, else the production Secrets Manager project. */
27
+ async function readProductionKey(key) {
28
+ const root = discoverRepoRoot();
29
+ if (root) try {
30
+ const text = await readFile(resolve(root, ".env.production"), "utf8");
31
+ const value = nonEmpty(EnvDocument.parse(text).get(key));
32
+ if (value) return value;
33
+ } catch {}
34
+ const { listSecrets, projectIdFor } = await import("./client-DwDGjalU.js");
35
+ const secrets = await listSecrets(await projectIdFor(PRODUCTION_PROJECT));
36
+ return nonEmpty(secrets.find((s) => s.key === key)?.value);
37
+ }
38
+ async function requireProductionKey(key) {
39
+ const value = await readProductionKey(key);
40
+ if (!value) throw new ProductionError(`Could not find production's ${key}. Run \`backstage env pull --target production\` in a checkout, or sign in to Secrets Manager.`);
41
+ return value;
42
+ }
43
+ /**
44
+ * A database failure, described without the connection string: postgres.js
45
+ * can quote the host, and the URL carries the password. A server error
46
+ * (a five-character SQLSTATE) is the server's own message, which never does.
47
+ */
48
+ function dbError(what, err) {
49
+ const e = err;
50
+ return new ProductionError(`${what}: ${(e.code && /^[0-9A-Z]{5}$/.test(e.code) ? e.message : e.code) ?? "connection failed"}.`);
51
+ }
52
+ /** One short-lived connection; callers `end()` it. */
53
+ function connect(url) {
54
+ return postgres(url, {
55
+ max: 1,
56
+ prepare: false,
57
+ connect_timeout: 15
58
+ });
59
+ }
60
+ /** Runs `fn` on a fresh connection and closes it, rewording failures. */
61
+ async function withConnection(url, what, fn) {
62
+ const sql = connect(url);
63
+ try {
64
+ return await fn(sql);
65
+ } catch (err) {
66
+ if (err instanceof ProductionError) throw err;
67
+ throw dbError(what, err);
68
+ } finally {
69
+ await sql.end({ timeout: 5 });
70
+ }
71
+ }
72
+ //#endregion
73
+ //#region src/production/cli.ts
74
+ /**
75
+ * The terminal half shared by the member-data commands: progress lines, the
76
+ * input file, and the one confirmation before production is written.
77
+ */
78
+ /** Progress for a person; plain stderr lines when nobody is watching a terminal. */
79
+ function say(message, level = "info") {
80
+ if (isNonInteractive()) process.stderr.write(`${message}\n`);
81
+ else log[level](message);
82
+ }
83
+ function plural(n, one, many = `${one}s`) {
84
+ return `${n} ${n === 1 ? one : many}`;
85
+ }
86
+ /** Whether a person can answer a prompt. */
87
+ function canPrompt() {
88
+ return !isNonInteractive() && process.stdin.isTTY === true;
89
+ }
90
+ function expandHome(path) {
91
+ return path.startsWith("~/") ? `${process.env.HOME ?? "~"}${path.slice(1)}` : path;
92
+ }
93
+ /** `--file`, else asked for at a terminal; then read. */
94
+ async function readInputFile(file, options) {
95
+ let path = file;
96
+ if (!path) {
97
+ if (!options.interactive) throw new UsageError(`Pass the file with --file <${options.placeholder}>.`);
98
+ path = unwrap(await text({
99
+ message: options.message,
100
+ placeholder: options.placeholder,
101
+ validate: (v) => v?.trim() ? void 0 : "A path, please."
102
+ })).trim();
103
+ }
104
+ const full = expandHome(path);
105
+ try {
106
+ return await (options.read ?? ((p) => readFile(p, "utf8")))(full);
107
+ } catch {
108
+ throw new UsageError(`Could not read ${full}.`);
109
+ }
110
+ }
111
+ /**
112
+ * The one question before production is written. `--yes` answers it; with no
113
+ * terminal and no `--yes`, the command stops rather than guess. Defaults to no.
114
+ */
115
+ async function confirmWrite(question, options) {
116
+ if (options.yes) return true;
117
+ if (!options.interactive) throw new UsageError(`${question} Pass --yes to answer yes without a terminal.`);
118
+ return unwrap(await confirm({
119
+ message: question,
120
+ initialValue: false
121
+ }));
122
+ }
123
+ /**
124
+ * The shared `catch`: a worded failure is explained and the run exits 1; any
125
+ * other error is unexpected and goes to Sentry with the command's name.
126
+ */
127
+ function reportFailure(command, err, worded = []) {
128
+ process.exitCode = 1;
129
+ if (err instanceof UsageError || err instanceof ProductionError || worded.some((kind) => err instanceof kind)) {
130
+ explain(err.message, "");
131
+ return;
132
+ }
133
+ explainError(`${command} failed.`, err);
134
+ }
135
+ //#endregion
136
+ export { reportFailure as a, connect as c, withConnection as d, readInputFile as i, dbError as l, confirmWrite as n, say as o, plural as r, ProductionError as s, canPrompt as t, requireProductionKey as u };
@@ -1,7 +1,8 @@
1
1
  import { w as findRepoRoot } from "./telemetry-Bjoz29Hl.js";
2
2
  import { n as loadEnv } from "./peers-B0KDkI6a.js";
3
3
  import { o as unwrap } from "./ui-CdKo8mLw.js";
4
- import { a as readTokenFromVault, c as EnvDocument, o as saveTokenToVault, t as VAULT_ITEM_NAME } from "./vault-C8LYJFOQ.js";
4
+ import { t as EnvDocument } from "./document-DumoZXoO.js";
5
+ import { a as readTokenFromVault, o as saveTokenToVault, t as VAULT_ITEM_NAME } from "./vault-CsBC-1Mq.js";
5
6
  import { dirname, join, resolve } from "node:path";
6
7
  import { log, password, select, text } from "@clack/prompts";
7
8
  import { mkdir, readFile, writeFile } from "node:fs/promises";
@@ -0,0 +1,2 @@
1
+ import { a as listSecrets, o as projectIdFor } from "./client-B_MY9kVI.js";
2
+ export { listSecrets, projectIdFor };