@devdogsuga/backstage 0.1.6 → 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
@@ -49,6 +49,7 @@ pnpm backstage export stars --from 2026-08-17 # audited under your gh login
49
49
  | `deploy plan`, `deploy migrate` | Dry-run the migrations into the job summary; apply them to `DB_URL`. |
50
50
  | `deploy smoke --tier <t> [--app]` | Public routes answer 200, the auth redirect works, this deploy's Sentry release shows. |
51
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). |
52
53
  | `env pull\|push\|audit --target <t>` | One env file per target, synced to Bitwarden and GitHub. `audit` lists orphans. |
53
54
  | `planner status\|create\|reset-password\|drop` | The `migration_planner` role the preflight tier holds. |
54
55
  | `graphics [graphic…]` | Club images from `@devdogsuga/brand`: `brand/*`, `app/*`, `event/*`. |
@@ -234,7 +235,8 @@ stderr, and every confirmation needs `--yes`. Against staging or production a
234
235
  command that is not read-only asks once first (`--yes` answers it).
235
236
 
236
237
  Each command checks for the secrets it uses up front: `deploy <app>` for
237
- `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
238
240
  Secrets Manager token (flag, environment, then the Bitwarden vault, which `env`
239
241
  signs in to and unlocks itself, then asking).
240
242
 
@@ -1143,6 +1143,16 @@ const deployCommand = {
1143
1143
  summary: "Run the platform's config reconcile after a deploy.",
1144
1144
  hint: "needs CRON_SECRET",
1145
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
+ ]
1146
1156
  }
1147
1157
  ]
1148
1158
  };
@@ -1,5 +1,5 @@
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-Dke7bQVz.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
5
  import { a as wouldRunLine, n as isDryRun, t as dryRunKind } from "./dry-run-3IxPmCtW.js";
@@ -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");
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-Bjoz29Hl.js";
2
- import { c as setMenuEnvHook, d as formatCommand, f as helpPath, n as bareGroupStartPath, t as catalog } from "./catalog-Dke7bQVz.js";
2
+ import { c as setMenuEnvHook, d as formatCommand, f as helpPath, n as bareGroupStartPath, t as catalog } from "./catalog-BiXI0wyi.js";
3
3
  import { i as loadEnvSession, r as loadEnvLoad } from "./peers-B0KDkI6a.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-3IxPmCtW.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-BhZ4je2D.js");
325
+ const { main } = await import("./cli-Cz8faASC.js");
326
326
  await main(args);
327
327
  } });
328
328
  }
package/env.ts CHANGED
@@ -96,7 +96,22 @@ declare({
96
96
  doc:
97
97
  "A Sentry organization auth token. Deploys use it to create each " +
98
98
  "release and upload its source maps, and the smoke test uses it to " +
99
- "read cron check-ins. One token serves every environment.",
99
+ "confirm the release landed. One token serves every environment.",
100
+ scope: "environment",
101
+ secrecy: "secret",
102
+ commented: true,
103
+ }),
104
+ // Read only by `deploy prune-monitors`, in its own step's `env:`: an
105
+ // organization token (above) can neither list nor delete Crons monitors,
106
+ // and one that can delete monitors and alert rules org-wide should not
107
+ // sit in every step of the deploy job. Without it, pruning is skipped
108
+ // with a notice.
109
+ SENTRY_MONITORS_TOKEN: define(z.string().min(1).optional(), {
110
+ doc:
111
+ "A Sentry internal integration token with Alerts: Read & Write and " +
112
+ "nothing else. The production deploy uses it to delete the Crons " +
113
+ "monitors an app no longer declares, which would otherwise alert " +
114
+ "on missed check-ins forever.",
100
115
  scope: "environment",
101
116
  secrecy: "secret",
102
117
  commented: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devdogsuga/backstage",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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.5",
44
+ "@devdogsuga/events": "0.1.5",
44
45
  "@devdogsuga/newsletter": "0.1.10",
45
- "@devdogsuga/telemetry": "0.1.3",
46
- "@devdogsuga/events": "0.1.5"
46
+ "@devdogsuga/telemetry": "0.1.3"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@devdogsuga/db": "*",
@@ -64,10 +64,10 @@
64
64
  "eslint": "^9.39.5",
65
65
  "tsdown": "^0.23.0",
66
66
  "vitest": "^4.1.11",
67
- "@devdogsuga/config": "0.1.2",
67
+ "@devdogsuga/cli-core": "0.0.0",
68
68
  "@devdogsuga/db": "0.1.3",
69
- "@devdogsuga/env": "0.1.5",
70
- "@devdogsuga/cli-core": "0.0.0"
69
+ "@devdogsuga/config": "0.1.2",
70
+ "@devdogsuga/env": "0.1.5"
71
71
  },
72
72
  "scripts": {
73
73
  "build": "tsdown && node scripts/write-build-info.mjs && node ../../scripts/check-bundle-imports.mjs",