@devdogsuga/backstage 0.1.0

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.
@@ -0,0 +1,1570 @@
1
+ import { A as YES, D as DRY_RUN, O as JSON_FLAG, S as RepoNotFoundError, 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";
3
+ import { o as unwrap } from "./ui-CdKo8mLw.js";
4
+ import { n as positionals } from "./args-Cjr_Iqts.js";
5
+ import { createRequire } from "node:module";
6
+ import { pathToFileURL } from "node:url";
7
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { confirm, note, select, text } from "@clack/prompts";
10
+ import "node:fs/promises";
11
+ import { execFile, execFileSync, spawn } from "node:child_process";
12
+ import { promisify } from "node:util";
13
+ //#region ../cli-core/src/help.ts
14
+ /**
15
+ * `--help`, one level at a time.
16
+ *
17
+ * ## Why this is small
18
+ *
19
+ * The help this replaced was ~200 lines and printed the whole tree: every
20
+ * subcommand of every group, the Bitwarden target table, the four places an
21
+ * access token is looked for, the grants `migration_planner` holds, and which
22
+ * deploy steps must avoid the `with-env` wrapper. A contributor running
23
+ * `pnpm devtools --help` to find out how to start a database read all of it.
24
+ *
25
+ * Two rules now:
26
+ *
27
+ * 1. **A level prints its own children and stops.** `--help` lists the
28
+ * top-level commands; `env --help` lists env's subcommands; `env pull
29
+ * --help` lists that command's options. Depth is reached by asking for
30
+ * it.
31
+ * 2. **No more than the caller needs to choose.** Operator internals are
32
+ * `docs/`'s job: which project a target maps to, how a credential is
33
+ * resolved, what a deploy job's environment holds. Help says what a
34
+ * command does and what it takes.
35
+ *
36
+ * Both fall out of rendering the catalog rather than hand-writing prose, so
37
+ * neither can rot back into a wall of text one paragraph at a time.
38
+ */
39
+ const INDENT = " ";
40
+ /** `--target <t>`, or `--prune` for a boolean. */
41
+ function optionLabel(option) {
42
+ return option.value ? `${option.flag} ${option.value}` : option.flag;
43
+ }
44
+ /**
45
+ * Two columns, with the gutter sized to the widest label in THIS block.
46
+ *
47
+ * Per-block rather than one width for the whole file: `deploy`'s labels are
48
+ * long and `setup`'s are not, and a shared width would indent the short list
49
+ * halfway across the terminal to accommodate a group the reader is not
50
+ * looking at.
51
+ */
52
+ function columns(rows) {
53
+ const width = Math.max(...rows.map(([label]) => label.length));
54
+ return rows.map(([label, text]) => `${INDENT}${label.padEnd(width)} ${text}`);
55
+ }
56
+ /**
57
+ * A list of commands, split into scope blocks when any of them declares one.
58
+ *
59
+ * Used at two levels: a GROUP's own commands (no group has scoped commands
60
+ * today — `db` folded the old "Supabase" group's scopes into itself), and a
61
+ * command's own SUBCOMMANDS (`db`'s: `start`/`connect`/`stop`/`restart` on
62
+ * this machine, `migration` in the repo, `status`/`migrate`/`reset`/… on an
63
+ * endpoint, `planner`/`signing-key` naming their own connection). Either way,
64
+ * a flat list leaves the reader to work out which of `restart` and `reset` is
65
+ * which, the distinction that costs people an afternoon. A list with no
66
+ * scopes renders as it always did: one block, one indent, no headings.
67
+ *
68
+ * The blocks follow declaration order rather than `SCOPES` order, so the tree
69
+ * stays the one place that decides how the list reads. The gutter is sized
70
+ * across the whole list, not per block, so every block lines up with the
71
+ * others rather than each finding its own column.
72
+ */
73
+ function scopedBody(commands) {
74
+ if (!commands.some((command) => command.scope)) return columns(childRows(commands));
75
+ const width = Math.max(...commands.map((command) => command.name.length));
76
+ const lines = [];
77
+ let open;
78
+ for (const command of commands) {
79
+ if (command.scope && command.scope !== open) {
80
+ open = command.scope;
81
+ lines.push(`${INDENT}${SCOPES[open].help}:`);
82
+ }
83
+ lines.push(`${INDENT.repeat(2)}${command.name.padEnd(width)} ${command.summary}`);
84
+ }
85
+ return lines;
86
+ }
87
+ function optionRows(options) {
88
+ return options.map((option) => [optionLabel(option), option.summary]);
89
+ }
90
+ function childRows(children) {
91
+ return children.map((child) => [child.name, child.deprecated ? `${child.summary} (deprecated)` : child.summary]);
92
+ }
93
+ /** `pnpm devtools --help`: the groups, and nothing below them. */
94
+ function renderRoot(catalog) {
95
+ const lines = [
96
+ `${catalog.usage} [command] [options]`,
97
+ "",
98
+ "Run with no command to choose from a menu.",
99
+ "",
100
+ "Common tasks:",
101
+ ...columns(catalog.commonTasks.map(([command, what]) => [command, what]))
102
+ ];
103
+ for (const group of catalog.groups) lines.push("", `${group.title}:`, ...scopedBody(group.commands));
104
+ lines.push("", "Options:", ...columns([
105
+ ["--help, -h", "Show this message"],
106
+ ["--tier <t>", "Deploy tier for this whole invocation (development, staging, production)"],
107
+ ["--no-env", "Load no env files; the environment you pass is the environment"]
108
+ ]), "", `\`${catalog.usage} <command> --help\` shows what that command takes.`);
109
+ return lines.join("\n");
110
+ }
111
+ /** `pnpm devtools <path…> --help`: one command's own children and options. */
112
+ function renderCommand(catalog, path, node) {
113
+ const children = node.subcommands ?? [];
114
+ const options = node.options ?? [];
115
+ const trail = path.join(" ");
116
+ const lines = [
117
+ [
118
+ catalog.usage,
119
+ trail,
120
+ children.length > 0 ? "<subcommand>" : "",
121
+ options.length > 0 ? "[options]" : ""
122
+ ].filter(Boolean).join(" "),
123
+ "",
124
+ node.summary
125
+ ];
126
+ if (node.deprecated) lines.push("", `Deprecated. ${node.deprecated}`);
127
+ if (children.length > 0) lines.push("", "Subcommands:", ...scopedBody(children));
128
+ if (options.length > 0) lines.push("", "Options:", ...columns(optionRows(options)));
129
+ if (children.length > 0) lines.push("", `\`${catalog.usage} ${trail} <subcommand> --help\` shows what that one takes.`);
130
+ return lines.join("\n");
131
+ }
132
+ /**
133
+ * Renders help for a path, falling back to the root.
134
+ *
135
+ * An unknown path renders the root rather than an error: this is only reached
136
+ * when `--help` was asked for, and answering a mistyped command with the list
137
+ * it was mistyped from is more use than a refusal.
138
+ */
139
+ function renderHelp(catalog, path = []) {
140
+ if (path.length === 0) return renderRoot(catalog);
141
+ const node = catalog.findCommand(path);
142
+ if (!node) return renderRoot(catalog);
143
+ return renderCommand(catalog, path, node);
144
+ }
145
+ /**
146
+ * The command names in `argv` that precede any flag.
147
+ *
148
+ * `env pull --help` asks about `["env", "pull"]`; `--help env` asks about
149
+ * nothing, because a flag's value is not a command path. Kept separate from
150
+ * `positionals()`: that one strips a known set of value-taking flags to find a
151
+ * subcommand, and here anything after the first flag is not part of the path at
152
+ * all.
153
+ */
154
+ function helpPath(argv) {
155
+ const path = [];
156
+ for (const arg of argv) {
157
+ if (arg.startsWith("-")) break;
158
+ path.push(arg);
159
+ }
160
+ return path;
161
+ }
162
+ /**
163
+ * Every command path the CLI accepts, for tools that need the supported list
164
+ * (the docs build refuses a page that shows a command that no longer exists).
165
+ *
166
+ * A declared export rather than a scrape of `--help`: same tree, parsed once.
167
+ * Only this CLI's own tree, so a doc cannot pass by showing a command that
168
+ * belongs to the other CLI (`pnpm devtools deploy`).
169
+ */
170
+ function commandList(catalog) {
171
+ const entries = [];
172
+ const visit = (nodes, prefix, inheritedCliOnly) => {
173
+ for (const node of nodes) {
174
+ const path = [...prefix, node.name];
175
+ const cliOnly = inheritedCliOnly || node.surface === "cli-only";
176
+ entries.push({
177
+ path: path.join(" "),
178
+ summary: node.summary,
179
+ surface: cliOnly ? "cli-only" : "interactive",
180
+ ...node.deprecated ? { deprecated: node.deprecated } : {}
181
+ });
182
+ visit(node.subcommands ?? [], path, cliOnly);
183
+ }
184
+ };
185
+ visit(catalog.topLevel, [], false);
186
+ return entries;
187
+ }
188
+ /** `--help --json`'s document: the version and every command path. */
189
+ function renderCommandList(catalog, version) {
190
+ return JSON.stringify({
191
+ version,
192
+ commands: commandList(catalog)
193
+ }, null, 2);
194
+ }
195
+ //#endregion
196
+ //#region ../cli-core/src/repo/resolve.ts
197
+ /**
198
+ * Resolves an `@devdogsuga/*` package FROM the target repo, not from
199
+ * devtools' own (dlx-isolated) `node_modules`.
200
+ *
201
+ * Ported from the `devtools-dlx` prototype
202
+ * (`/home/sloan/scratchpad/devdogs/prototypes/devtools-dlx/FINDINGS.md`,
203
+ * experiments 2 and 4 — read that file for the two gotchas this module
204
+ * exists to avoid). The short version:
205
+ *
206
+ * - `require.resolve(\`${specifier}/package.json\`)` throws
207
+ * `ERR_PACKAGE_PATH_NOT_EXPORTED` on packages (like `@devdogsuga/env`,
208
+ * `@devdogsuga/open-graph`) whose `exports` map does not list
209
+ * `"./package.json"`, even though the file is on disk.
210
+ * - A resolved path cannot be assumed to contain a literal
211
+ * `node_modules/<specifier>` segment — pnpm workspace-linked packages
212
+ * resolve to their REALPATH (e.g. `packages/open-graph/dist/index.js`),
213
+ * which has no such segment at all.
214
+ * - `require.resolve` ignores whatever `conditions` a caller has set on
215
+ * `tsx`'s `register()` — condition-based resolution (the
216
+ * `devdogs-source` condition) has to be done by hand, reading the
217
+ * target package's own `exports["."]` map.
218
+ *
219
+ * Both problems are solved the same way: walk up from the resolved file's
220
+ * directory to the nearest `package.json` whose own `"name"` field matches
221
+ * the specifier. That works for a real installed dependency AND a
222
+ * workspace symlink, uniformly.
223
+ */
224
+ /**
225
+ * The installable package name a (possibly subpath) specifier belongs to:
226
+ * `@devdogsuga/env/load` → `@devdogsuga/env`, `foo/bar` → `foo`. A repo's
227
+ * package.json declares the PACKAGE as a dependency, never a subpath, so
228
+ * `findDependent` has to search on this rather than the literal specifier a
229
+ * caller wants to import.
230
+ */
231
+ function packageNameOf(specifier) {
232
+ const parts = specifier.split("/");
233
+ return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
234
+ }
235
+ /**
236
+ * Scans `apps/*` and `packages/*` under `repoRoot` for the first
237
+ * `package.json` whose `dependencies`/`devDependencies`/`peerDependencies`
238
+ * names `specifier`'s package (see `packageNameOf`). Returns its path
239
+ * relative to `repoRoot`, or `null` if nothing in the repo depends on it.
240
+ *
241
+ * Re-scans the filesystem on every call — fine for a short-lived CLI
242
+ * invocation calling this a handful of times; see FINDINGS item 5 if this
243
+ * is ever called in a hot loop.
244
+ */
245
+ function findDependent(repoRoot, specifier) {
246
+ const packageName = packageNameOf(specifier);
247
+ for (const group of ["apps", "packages"]) {
248
+ const groupDir = join(repoRoot, group);
249
+ let entries;
250
+ try {
251
+ entries = readdirSync(groupDir);
252
+ } catch {
253
+ continue;
254
+ }
255
+ for (const entry of entries) {
256
+ const pkgJsonPath = join(groupDir, entry, "package.json");
257
+ if (!existsSync(pkgJsonPath)) continue;
258
+ let pkg;
259
+ try {
260
+ pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
261
+ } catch {
262
+ continue;
263
+ }
264
+ if (packageName in {
265
+ ...pkg.dependencies,
266
+ ...pkg.devDependencies,
267
+ ...pkg.peerDependencies
268
+ }) return join(group, entry, "package.json");
269
+ }
270
+ }
271
+ return null;
272
+ }
273
+ /** Walks up from `startDir` to the nearest `package.json` whose `"name"` matches `specifier`. */
274
+ function findOwningPackageJson(startDir, specifier) {
275
+ const packageName = packageNameOf(specifier);
276
+ let dir = startDir;
277
+ for (;;) {
278
+ const candidate = join(dir, "package.json");
279
+ if (existsSync(candidate)) try {
280
+ if (JSON.parse(readFileSync(candidate, "utf8")).name === packageName) return candidate;
281
+ } catch {}
282
+ const parent = dirname(dir);
283
+ if (parent === dir) throw new Error(`resolveFromRepo: could not find ${specifier}'s own package.json by walking up from ${startDir}`);
284
+ dir = parent;
285
+ }
286
+ }
287
+ /**
288
+ * Resolves `specifier` as `resolutionBase` (a workspace-relative
289
+ * `package.json` path known to depend on it, from `findDependent()`) would
290
+ * see it.
291
+ *
292
+ * `opts.condition`, when given, is NOT passed through to Node's resolver —
293
+ * `require.resolve` ignores tsx's `register({ conditions })` entirely (see
294
+ * this module's header). Instead the target package's own
295
+ * `exports["."]` map is read directly and `exports["."][condition]` is
296
+ * picked, falling back to `.default`.
297
+ */
298
+ function resolveFromRepo(repoRoot, resolutionBase, specifier, opts) {
299
+ const baseFile = join(repoRoot, resolutionBase);
300
+ const require = createRequire(baseFile);
301
+ let resolvedPath;
302
+ if (opts?.condition) {
303
+ const defaultEntry = require.resolve(specifier);
304
+ const pkgJsonPath = findOwningPackageJson(dirname(defaultEntry), specifier);
305
+ const rootExport = JSON.parse(readFileSync(pkgJsonPath, "utf8")).exports?.["."];
306
+ const conditioned = rootExport && typeof rootExport === "object" ? rootExport[opts.condition] ?? rootExport.default : void 0;
307
+ if (typeof conditioned !== "string") throw new Error(`resolveFromRepo: ${specifier} has no "${opts.condition}" (or "default") export in its exports["."] map`);
308
+ resolvedPath = join(dirname(pkgJsonPath), conditioned);
309
+ } else resolvedPath = require.resolve(specifier);
310
+ const pkgJsonPath = findOwningPackageJson(dirname(resolvedPath), specifier);
311
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
312
+ return {
313
+ resolvedPath,
314
+ version: pkg.version ?? "0.0.0",
315
+ pkgJsonPath
316
+ };
317
+ }
318
+ //#endregion
319
+ //#region ../cli-core/src/repo/peers.ts
320
+ /**
321
+ * Loads devtools' optional-peer `@devdogsuga/*` libraries — the ones
322
+ * published to npm and consumed by the TARGET repo, which devtools reads at
323
+ * runtime rather than bundling its own copy of (see this package's
324
+ * `package.json`: `peerDependencies` + `peerDependenciesMeta.optional`, and
325
+ * `FINDINGS.md` experiment 2).
326
+ *
327
+ * Every export here is memoized per module (one dynamic `import()` per
328
+ * process, not per call) — this is what makes the module-identity guarantee
329
+ * in FINDINGS experiment 3 hold: every devtools call site that needs
330
+ * `@devdogsuga/env`'s registry gets the exact same module instance the
331
+ * repo's own manifests populated via `declare()`/`define()`, because both
332
+ * resolve to the identical absolute file path and Node's ESM cache is keyed
333
+ * by resolved URL.
334
+ */
335
+ const moduleCache = /* @__PURE__ */ new Map();
336
+ /** The file URL each peer resolved to through the target repo, set only on
337
+ * that path (not on either plain bare-specifier fallback). See
338
+ * `repoPeerUrl()`. */
339
+ const resolvedUrls = /* @__PURE__ */ new Map();
340
+ /**
341
+ * Dynamically imports `specifier` as the repo would resolve it, memoized so
342
+ * repeated calls in one process return the SAME module instance (required
343
+ * for the env-registry identity guarantee above).
344
+ *
345
+ * Falls back to a plain bare-specifier `import()` when devtools is not
346
+ * running inside a DevDogsUGA checkout at all (`findRepoRoot()` throws
347
+ * `RepoNotFoundError`). That is not a production affordance — under real
348
+ * `pnpm dlx` use, `specifier` is an optional peer devtools' own package
349
+ * never installs, so the fallback import fails with the ordinary
350
+ * `MODULE_NOT_FOUND` either way. It exists so devtools' OWN test suite
351
+ * (which has no target repo to discover, but does have every one of these
352
+ * peers as a real Backstage workspace package) exercises this code against
353
+ * the genuine package rather than needing every test to mock this module.
354
+ */
355
+ function loadPeer(specifier) {
356
+ const cached = moduleCache.get(specifier);
357
+ if (cached) return cached;
358
+ const promise = (async () => {
359
+ if (process.env.DEVTOOLS_TEST_REPO_ROOT) return import(specifier);
360
+ let repoRoot;
361
+ try {
362
+ repoRoot = findRepoRoot();
363
+ } catch (err) {
364
+ if (err instanceof RepoNotFoundError) return import(specifier);
365
+ throw err;
366
+ }
367
+ const resolutionBase = findDependent(repoRoot, specifier);
368
+ if (!resolutionBase) throw new Error(`Nothing in this repo depends on ${specifier} — expected an app or package under apps/* or packages/* to declare it (dependencies/devDependencies/peerDependencies).`);
369
+ const { resolvedPath } = resolveFromRepo(repoRoot, resolutionBase, specifier);
370
+ const url = pathToFileURL(resolvedPath).href;
371
+ resolvedUrls.set(specifier, url);
372
+ return import(url);
373
+ })();
374
+ moduleCache.set(specifier, promise);
375
+ return promise;
376
+ }
377
+ /**
378
+ * The file URL `specifier` was loaded from through the target repo, or
379
+ * undefined if it has not been loaded yet or came from a plain bare-specifier
380
+ * `import()` (the test-suite and not-in-a-repo fallbacks above).
381
+ *
382
+ * For code devtools imports that itself names a peer by bare specifier --
383
+ * today only devtools' own `env.ts` manifest. From an installed copy that
384
+ * specifier cannot resolve at all (an optional peer is never installed next
385
+ * to devtools under `pnpm dlx`), and even where it can, the only copy that
386
+ * keeps module identity is this one. `repo/peer-redirect.ts` uses it to
387
+ * point the import here.
388
+ */
389
+ function repoPeerUrl(specifier) {
390
+ return resolvedUrls.get(specifier);
391
+ }
392
+ let resolvedEnvModule;
393
+ function loadEnv() {
394
+ return loadPeer("@devdogsuga/env").then((mod) => {
395
+ resolvedEnvModule = mod;
396
+ return mod;
397
+ });
398
+ }
399
+ /**
400
+ * The synchronous companion to `loadEnv()`, for the handful of call sites
401
+ * (`gh/environments.ts`'s `GITHUB_ENVIRONMENT_SPECS` getters) that cannot
402
+ * become async without changing their own callers' contract — they read a
403
+ * plain array off a getter, not a Promise. Safe ONLY after `loadEnv()` has
404
+ * resolved at least once; every caller of these getters already requires
405
+ * `env/discovery.ts`'s `loadRegistry()` (which calls `loadEnv()` itself) to
406
+ * have run first, via `assertRegistryLoaded()`'s own guard.
407
+ */
408
+ function getEnvSync() {
409
+ if (!resolvedEnvModule) throw new Error("getEnvSync() called before loadEnv() ever resolved — call loadRegistry() (env/discovery.ts) first.");
410
+ return resolvedEnvModule;
411
+ }
412
+ function loadEnvLoad() {
413
+ return loadPeer("@devdogsuga/env/load");
414
+ }
415
+ function loadEnvSession() {
416
+ return loadPeer("@devdogsuga/env/session");
417
+ }
418
+ //#endregion
419
+ //#region ../cli-core/src/db/connection.ts
420
+ /** An env value, with unset and empty both meaning "not given". */
421
+ function nonEmpty(value) {
422
+ return value === void 0 || value === "" ? void 0 : value;
423
+ }
424
+ //#endregion
425
+ //#region ../cli-core/src/process-group.ts
426
+ /**
427
+ * Running a child tool so that stopping it stops everything it started.
428
+ *
429
+ * `pnpm run dev` forks a shell that forks vinext that forks workerd. Killing
430
+ * only the pnpm process (what a plain `child.kill()` does, and what a closed
431
+ * terminal tab or a `kill` from another window delivers) orphans the rest,
432
+ * and the orphan keeps its port. So the child gets its own process group, and
433
+ * every stop signal this process receives is sent to the whole group.
434
+ *
435
+ * Ctrl-C needs the same care in reverse: a child in its own group is not in
436
+ * the terminal's foreground group, so the terminal no longer delivers ^C to
437
+ * it. The signal handlers below forward it.
438
+ *
439
+ * The group is only swept after the direct child exits when that exit was a
440
+ * signal we forwarded; a child that exits on its own is left to have cleaned
441
+ * up after itself, so a tool that deliberately leaves a daemon running keeps it.
442
+ */
443
+ const FORWARDED = [
444
+ "SIGINT",
445
+ "SIGTERM",
446
+ "SIGHUP"
447
+ ];
448
+ const SIGNAL_NUMBERS = {
449
+ SIGHUP: 1,
450
+ SIGINT: 2,
451
+ SIGTERM: 15,
452
+ SIGKILL: 9
453
+ };
454
+ /** How long a group gets to leave after SIGTERM before SIGKILL. */
455
+ const GRACE_MS = 5e3;
456
+ /** Sends `signal` to every process in `pid`'s group. False if it is already gone. */
457
+ function signalGroup(pid, signal) {
458
+ try {
459
+ process.kill(process.platform === "win32" ? pid : -pid, signal);
460
+ return true;
461
+ } catch {
462
+ return false;
463
+ }
464
+ }
465
+ function runInGroup(command, args, opts = {}) {
466
+ if (isDryRun()) {
467
+ process.stderr.write(`Would run: ${formatCommand(command, args)}\n`);
468
+ return Promise.resolve({
469
+ code: 0,
470
+ signal: null
471
+ });
472
+ }
473
+ return new Promise((resolve) => {
474
+ const child = spawn(command, [...args], {
475
+ stdio: opts.stdio ?? "inherit",
476
+ cwd: opts.cwd,
477
+ env: opts.env,
478
+ detached: process.platform !== "win32"
479
+ });
480
+ const pid = child.pid;
481
+ let forwarded = null;
482
+ let escalation;
483
+ const forward = (signal) => {
484
+ if (pid === void 0) return;
485
+ forwarded ??= signal;
486
+ signalGroup(pid, signal);
487
+ escalation ??= setTimeout(() => signalGroup(pid, "SIGKILL"), GRACE_MS);
488
+ escalation.unref();
489
+ };
490
+ if (opts.abort?.aborted) forward("SIGTERM");
491
+ else opts.abort?.addEventListener("abort", () => forward("SIGTERM"), { once: true });
492
+ const handlers = FORWARDED.map((signal) => {
493
+ const handler = () => forward(signal);
494
+ process.on(signal, handler);
495
+ return [signal, handler];
496
+ });
497
+ const onExit = () => {
498
+ if (pid !== void 0) signalGroup(pid, "SIGKILL");
499
+ };
500
+ process.on("exit", onExit);
501
+ const finish = (result) => {
502
+ noteRan(`${formatCommand(command, args)} (exit ${result.code})`);
503
+ for (const [signal, handler] of handlers) process.off(signal, handler);
504
+ process.off("exit", onExit);
505
+ if (escalation) clearTimeout(escalation);
506
+ if (forwarded !== null && pid !== void 0) signalGroup(pid, "SIGKILL");
507
+ resolve(result);
508
+ };
509
+ child.on("error", (error) => {
510
+ process.stderr.write(`${error.message}\n`);
511
+ finish({
512
+ code: 1,
513
+ signal: null
514
+ });
515
+ });
516
+ child.on("exit", (code, signal) => {
517
+ const bySignal = signal ? 128 + (SIGNAL_NUMBERS[signal] ?? 0) : 1;
518
+ finish({
519
+ code: code ?? bySignal,
520
+ signal
521
+ });
522
+ });
523
+ });
524
+ }
525
+ const URL_CREDENTIALS = /\/\/[^/@\s]*@/;
526
+ /**
527
+ * The command as a person would type it, for printing. A database URL carries
528
+ * its password, so the value after `--db-url` (and any URL with credentials)
529
+ * is replaced rather than echoed.
530
+ */
531
+ function formatCommand(command, args) {
532
+ const shown = [];
533
+ for (let i = 0; i < args.length; i += 1) {
534
+ const arg = args[i];
535
+ if (arg === "--db-url" && i + 1 < args.length) {
536
+ shown.push(arg, "<DB_URL>");
537
+ i += 1;
538
+ } else if (arg.startsWith("--db-url=")) shown.push("--db-url=<DB_URL>");
539
+ else shown.push(arg.replace(URL_CREDENTIALS, "//***@"));
540
+ }
541
+ return [command, ...shown].map((part) => /^[\w@%+=:,./<>*-]+$/.test(part) ? part : JSON.stringify(part)).join(" ");
542
+ }
543
+ /**
544
+ * Prints the exact command that ran, AFTER it ran so the tool's own output
545
+ * cannot bury it. On stderr, so a tool whose stdout is a pipe stays clean.
546
+ */
547
+ function reportRan(command, args, result) {
548
+ if (isDryRun()) return;
549
+ const status = result.code === 0 ? "" : ` (exit ${result.code})`;
550
+ process.stderr.write(`Ran: ${formatCommand(command, args)}${status}\n`);
551
+ }
552
+ //#endregion
553
+ //#region ../cli-core/src/db/run.ts
554
+ /**
555
+ * Shared helpers for running supabase CLI and pnpm commands from the repo root.
556
+ *
557
+ * The supabase CLI is a workspace devDependency (not a global install), so
558
+ * every invocation goes through `pnpm exec supabase`. The cwd is always
559
+ * findRepoRoot(), where `supabase/config.toml` lives.
560
+ */
561
+ const runFile = promisify(execFile);
562
+ /** Spawn a pnpm command with inherited stdio; resolves to the exit code. Pass
563
+ * `env` to run the child against a loaded tier rather than process.env. The
564
+ * child runs in its own process group, so stopping this process stops
565
+ * whatever it started (see `process-group.ts`). */
566
+ async function run$1(args, env) {
567
+ const result = await runInGroup("pnpm", args, {
568
+ cwd: findRepoRoot(),
569
+ ...env ? { env } : {}
570
+ });
571
+ if (reporting) reportRan("pnpm", args, result);
572
+ return result.code;
573
+ }
574
+ let reporting = false;
575
+ /** `pnpm exec supabase …` with inherited stdio. */
576
+ const supabase = (...args) => run$1([
577
+ "exec",
578
+ "supabase",
579
+ ...args
580
+ ]);
581
+ /**
582
+ * `supabase db push` over `dbUrl`, inherited stdio, resolving to the exit code.
583
+ *
584
+ * The one spelling of the migration push, shared by the contributor `db
585
+ * migrate` path (`stack.ts`'s `pushMigrations`) and CI's `deploy migrate`.
586
+ * `--yes` is the unattended apply CI wants; the contributor path omits it so a
587
+ * human still confirms. Neither variant regenerates types — that is
588
+ * `pushMigrations`' own second step, layered on top only where a checkout is
589
+ * meant to be rewritten. A CI apply must NOT write back into the repo, which is
590
+ * exactly why the bare push is factored out here rather than reused whole.
591
+ */
592
+ function dbPush(dbUrl, opts = {}) {
593
+ const args = [
594
+ "db",
595
+ "push",
596
+ "--db-url",
597
+ dbUrl
598
+ ];
599
+ if (opts.yes) args.push("--yes");
600
+ return supabase(...args);
601
+ }
602
+ /**
603
+ * `supabase db push --dry-run` over `dbUrl`, returning the plan text (stdout
604
+ * and stderr combined, as the workflow's old `2>&1` did).
605
+ *
606
+ * Throws on a non-zero exit — which for a dry run means the CONNECTION failed,
607
+ * not that the plan came back empty. That throw is the invariant `deploy plan`
608
+ * leans on, and the thing the old shell needed `set -o pipefail` to preserve: a
609
+ * dead connection must fail the step rather than read as "no migrations to
610
+ * apply". The child's own output is surfaced in the thrown message, because for
611
+ * a dry run that output is precisely the reason the operator needs.
612
+ */
613
+ async function dbPushDryRun(dbUrl) {
614
+ try {
615
+ const { stdout, stderr } = await runFile("pnpm", [
616
+ "exec",
617
+ "supabase",
618
+ "db",
619
+ "push",
620
+ "--db-url",
621
+ dbUrl,
622
+ "--dry-run"
623
+ ], {
624
+ cwd: findRepoRoot(),
625
+ encoding: "utf8",
626
+ maxBuffer: 33554432
627
+ });
628
+ return `${stdout}${stderr}`;
629
+ } catch (err) {
630
+ const e = err;
631
+ const detail = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
632
+ throw new Error(detail ? `supabase db push --dry-run failed:\n${detail}` : e.message ?? "supabase db push --dry-run failed");
633
+ }
634
+ }
635
+ //#endregion
636
+ //#region ../cli-core/src/repo/supabase-project.ts
637
+ /**
638
+ * Reads `project_id` out of `<repoRoot>/supabase/config.toml`, or `null`
639
+ * when the file is missing or unparsable.
640
+ *
641
+ * Regex, not a TOML parser: it is one line, the shape has been stable
642
+ * across every CLI version this repo has seen, and a parser on a path some
643
+ * callers pay before their first real subprocess is a cost that stays
644
+ * invisible until it is why the tool feels slow.
645
+ */
646
+ function readProjectId(repoRoot) {
647
+ try {
648
+ const path = join(repoRoot, "supabase", "config.toml");
649
+ return /^\s*project_id\s*=\s*"([^"]+)"/m.exec(readFileSync(path, "utf8"))?.[1] ?? null;
650
+ } catch {
651
+ return null;
652
+ }
653
+ }
654
+ /**
655
+ * The Supabase CLI's container name prefix for a project id, or the bare
656
+ * `supabase_db_` fallback when the id could not be read.
657
+ *
658
+ * Falling back to the bare prefix means a machine whose `config.toml`
659
+ * cannot be read still detects *a* stack, at the cost of over-matching a
660
+ * differently-named project — deliberate, and unchanged from this
661
+ * function's original home in `environment.ts`: over-matching offers
662
+ * `stop`/a foreign-stack hint for a stack that turns out to be this
663
+ * project's own, which is harmless to see and decline, while
664
+ * under-matching hides a real signal entirely.
665
+ */
666
+ function containerPrefix(projectId) {
667
+ return projectId ? `supabase_db_${projectId}` : "supabase_db_";
668
+ }
669
+ //#endregion
670
+ //#region ../cli-core/src/env-entry.ts
671
+ let menuEnvHook;
672
+ /** Set by `launch.ts` for a menu invocation; `undefined` for a typed command
673
+ * (which entered already) and for any test that drives `runMenu` directly
674
+ * without going through `launch.ts` at all. */
675
+ function setMenuEnvHook(hook) {
676
+ menuEnvHook = hook;
677
+ }
678
+ /** Read by `runMenu`, then cleared — see this module's header on why a
679
+ * nested launcher must not inherit a stale hook. */
680
+ function takeMenuEnvHook() {
681
+ const hook = menuEnvHook;
682
+ menuEnvHook = void 0;
683
+ return hook;
684
+ }
685
+ //#endregion
686
+ //#region ../cli-core/src/environment.ts
687
+ /**
688
+ * What this machine currently is, read once before the menu draws itself.
689
+ *
690
+ * The wizard used to offer the same commands to everyone: someone whose Docker
691
+ * daemon was not running was invited to reset a database that could not
692
+ * answer, and someone whose stack was already up had no way to stop it at all,
693
+ * because "stop" was not in the tree. Both are the same bug. The menu
694
+ * described the CLI rather than the situation.
695
+ *
696
+ * So this reads three facts and `menu.ts` adapts to them. The facts are few
697
+ * and cheap on purpose; see `probeEnvironment` for the budget and why.
698
+ *
699
+ * ## Unknown is not "no"
700
+ *
701
+ * Every probe can come back `"unknown"`, and that is what a failed probe
702
+ * reports rather than guessing. Nothing is hidden or flagged on `"unknown"`: a
703
+ * machine this cannot read gets the full menu, exactly as before. A tool that
704
+ * hides a command because a `docker ps` timed out is worse than one that never
705
+ * adapted at all, because the command it hides is the one you were looking
706
+ * for.
707
+ */
708
+ /**
709
+ * Every probe is bounded, silent, and total.
710
+ *
711
+ * `timeout` because a Docker daemon that is starting up (or a VM that has just
712
+ * been resumed) accepts the connection and then never answers, and the failure
713
+ * that produces is a menu that hangs before printing anything, with no output
714
+ * to explain what it is waiting for.
715
+ */
716
+ function run(file, args) {
717
+ try {
718
+ return execFileSync(file, args, {
719
+ cwd: findRepoRoot(),
720
+ encoding: "utf8",
721
+ stdio: [
722
+ "ignore",
723
+ "pipe",
724
+ "ignore"
725
+ ],
726
+ timeout: 3e3
727
+ });
728
+ } catch {
729
+ return null;
730
+ }
731
+ }
732
+ /**
733
+ * Reads the machine.
734
+ *
735
+ * Two subprocesses at worst, one when Docker is down, plus an `existsSync`.
736
+ * The budget is "cheaper than the first keystroke", because this runs before
737
+ * the wizard's first screen and anything slower would be paid on every
738
+ * invocation to save a few of them later.
739
+ *
740
+ * It does NOT call `supabase status`, which is the authoritative answer but
741
+ * takes the better part of a second: it shells out through `pnpm exec` and
742
+ * then talks to every service in the stack. `docker ps` answers the only
743
+ * question the menu has (is it up?) in a fraction of that, and the commands
744
+ * that need real credentials go through `resolveInstance` (`instance.ts`),
745
+ * which reads the session's already-entered env at the moment it matters.
746
+ */
747
+ function probeEnvironment(execute = run) {
748
+ const envFile = existsSync(join(findRepoRoot(), ".env")) ? "yes" : "no";
749
+ const docker = execute("docker", ["info"]) === null ? "no" : "yes";
750
+ if (docker === "no") return {
751
+ docker,
752
+ stack: "no",
753
+ envFile
754
+ };
755
+ const names = execute("docker", [
756
+ "ps",
757
+ "--format",
758
+ "{{.Names}}"
759
+ ]);
760
+ if (names === null) return {
761
+ docker,
762
+ stack: "unknown",
763
+ envFile
764
+ };
765
+ const prefix = containerPrefix(readProjectId(findRepoRoot()));
766
+ return {
767
+ docker,
768
+ stack: names.split("\n").some((name) => name.trim().startsWith(prefix)) ? "yes" : "no",
769
+ envFile
770
+ };
771
+ }
772
+ /**
773
+ * Whether a `Condition` from the command tree currently holds.
774
+ *
775
+ * The tree declares conditions as strings so that it stays inert data the docs
776
+ * build can render (see `catalog.ts`); this is the one place that knows what
777
+ * they mean. `"unknown"` propagates rather than collapsing to false, and both
778
+ * callers below read it as "do nothing".
779
+ */
780
+ function holds(condition, env) {
781
+ switch (condition) {
782
+ case "docker": return env.docker;
783
+ case "instance-running": return env.stack;
784
+ case "instance-stopped":
785
+ if (env.stack === "unknown") return "unknown";
786
+ return env.stack === "no" ? "yes" : "no";
787
+ }
788
+ }
789
+ /**
790
+ * Whether the wizard should offer this command at all.
791
+ *
792
+ * False only for a command whose `when` is definitively unmet: `stop`
793
+ * against a stack that is already stopped. This is reserved for commands that
794
+ * would be *meaningless*, never merely inconvenient: one that would fail with
795
+ * a good error message stays in the menu, because a contributor who cannot
796
+ * find a command they know exists is worse off than one who runs it and is
797
+ * told why it did not work.
798
+ *
799
+ * Hiding is a wizard-only decision. `--help` still lists these, the dispatcher
800
+ * still accepts them, and the generated reference still documents them. The
801
+ * menu is a guide to right now, and those three are the reference.
802
+ */
803
+ function isOffered(node, env) {
804
+ return node.when === void 0 || holds(node.when, env) !== "no";
805
+ }
806
+ const LABELS = {
807
+ yes: "yes",
808
+ no: "no",
809
+ unknown: "could not tell"
810
+ };
811
+ /**
812
+ * The three facts, as the wizard's opening note and as `status --local`.
813
+ *
814
+ * Printed before the first question rather than discovered through failures:
815
+ * "Docker running no" at the top of the screen is the whole explanation for
816
+ * why the database commands below it are flagged, and it costs one glance.
817
+ */
818
+ function describeEnvironment(env) {
819
+ return [
820
+ `Docker running ${LABELS[env.docker]}`,
821
+ `Local stack up ${LABELS[env.stack]}`,
822
+ `.env present ${LABELS[env.envFile]}`
823
+ ].join("\n");
824
+ }
825
+ //#endregion
826
+ //#region ../cli-core/src/invocation.ts
827
+ /**
828
+ * Records the fully-resolved argv of the command being run, so the CLI can
829
+ * close by printing a copy-pasteable line — the same command with every prompt
830
+ * already answered — for anyone who reached it through the wizard or let a
831
+ * missing flag fall to a prompt.
832
+ *
833
+ * ## Why a module-level recorder rather than a return value
834
+ *
835
+ * A command's inputs arrive from two places that never meet: the wizard walks
836
+ * `commands.ts` and gathers the *declared* options into the argv it dispatches
837
+ * (see `menu.ts`), while a runner resolves anything it prompts for *itself* —
838
+ * `workflows run` picking a tier, `env init` picking projects — deep inside its
839
+ * own call stack, where the dispatched argv is long out of reach. Threading a
840
+ * return value back up through every runner would touch each one's signature;
841
+ * a recorder every layer can reach touches only the layers that actually
842
+ * prompt.
843
+ *
844
+ * The rule for a runner: whenever a prompt (not a flag the caller already
845
+ * passed) decides a value, `recordResolved` the flag form of that decision. A
846
+ * value that came in as a flag is already in the base argv, so recording it
847
+ * again would double it — only the prompt branch records.
848
+ */
849
+ /** The base argv plus whatever prompts resolved; `null` until an invocation begins. */
850
+ let argv = null;
851
+ /** Did the wizard, or an in-command prompt, actually decide anything here? */
852
+ let interactive = false;
853
+ /**
854
+ * The deploy tier `src/launch.ts` resolved and entered for this session, or
855
+ * `null` for the development default. Reproduced as a leading `--tier <tier>`
856
+ * flag, the same global flag `launch.ts` strips off ANY invocation before
857
+ * `cli.ts` ever runs — see its header — so this reproduces the tier for
858
+ * EVERY command, `db` and `env` included, exactly as typing `--tier <tier>`
859
+ * up front would.
860
+ */
861
+ let enteredTier = null;
862
+ /**
863
+ * Start recording for one invocation.
864
+ *
865
+ * `base` is what the dispatcher received: the wizard's built argv, or the argv
866
+ * the user typed. `fromMenu` marks the wizard path, where every step was a
867
+ * prompt, so the rerun line is worth printing even if no runner records a
868
+ * thing. A typed command starts non-interactive and only earns the line if a
869
+ * runner resolves a missing flag from a prompt.
870
+ */
871
+ function beginInvocation(base, fromMenu) {
872
+ argv = [...base];
873
+ interactive = fromMenu;
874
+ enteredTier = null;
875
+ }
876
+ /**
877
+ * Record the deploy tier the wizard entered up front, so the rerun line carries
878
+ * it. development is the ambient default, so a prefix for it would be noise and
879
+ * is dropped.
880
+ */
881
+ function recordEnteredTier(tier) {
882
+ enteredTier = tier === "development" ? null : tier;
883
+ }
884
+ /**
885
+ * Append the flag form of a value a prompt just resolved, e.g.
886
+ * `recordResolved("--tier", "development")`.
887
+ */
888
+ function recordResolved(...fragment) {
889
+ if (argv === null || fragment.length === 0) return;
890
+ argv.push(...fragment);
891
+ interactive = true;
892
+ }
893
+ /**
894
+ * The command to print, or `null` when there is nothing worth printing —
895
+ * either no invocation ran, or the user typed a complete command and answered
896
+ * no prompts, so echoing it back would be noise.
897
+ */
898
+ function reproducibleCommand() {
899
+ if (argv === null || !interactive || argv.length === 0) return null;
900
+ return `pnpm devtools ${[...enteredTier ? ["--tier", enteredTier] : [], ...argv].join(" ")}`;
901
+ }
902
+ //#endregion
903
+ //#region ../cli-core/src/menu.ts
904
+ /**
905
+ * The wizard: `pnpm devtools` with no arguments.
906
+ *
907
+ * ## The one property worth protecting
908
+ *
909
+ * **It builds an argv and hands it to the CLI's own dispatcher.** It does not
910
+ * call command functions directly. That is what makes "the menu covers every
911
+ * interactive command" structural instead of aspirational. The menu it replaced held a
912
+ * hand-written list of ten entries beside a CLI that had grown to sixteen
913
+ * top-level commands and thirty-one subcommands, so `env`, `planner` and
914
+ * `docs index` were reachable only by someone who already knew their names.
915
+ * A contributor who does not know a command name is the entire audience for
916
+ * this file.
917
+ *
918
+ * Walking the catalog means an interactive command added there is in the
919
+ * menu the same day, with its options. Commands marked `cli-only` do not appear here.
920
+ */
921
+ /** Chosen when a submenu should return to the screen above it. */
922
+ const BACK = Symbol("back");
923
+ const BACK_OPTION = {
924
+ value: BACK,
925
+ label: "← Back"
926
+ };
927
+ /**
928
+ * The commands at one level that are worth showing right now.
929
+ *
930
+ * `when` is the only thing that removes an entry, and it removes very few;
931
+ * see `isOffered`. Everything else stays on screen and explains itself
932
+ * through `hintFor` below, because the menu's job is to help someone who does
933
+ * not know a command's name, and it cannot do that for a command it declined
934
+ * to draw.
935
+ */
936
+ function offered(nodes, env) {
937
+ return nodes.filter((node) => node.surface !== "cli-only" && isOffered(node, env));
938
+ }
939
+ /** The line beside a name. */
940
+ function hintFor(node) {
941
+ const said = node.hint ?? node.summary;
942
+ return node.scope ? `${SCOPES[node.scope].menu} · ${said}` : said;
943
+ }
944
+ async function pickGroup(catalog, env) {
945
+ const groups = catalog.groups.filter((group) => offered(group.commands, env).length > 0);
946
+ return unwrap(await select({
947
+ message: "What would you like to do?",
948
+ options: [...groups.map((group) => ({
949
+ value: group,
950
+ label: group.title,
951
+ hint: offered(group.commands, env).map((command) => command.name).join(", ")
952
+ })), {
953
+ value: null,
954
+ label: "Quit"
955
+ }]
956
+ }));
957
+ }
958
+ async function pickCommand(group, env) {
959
+ const commands = offered(group.commands, env);
960
+ if (commands.length === 1) return commands[0];
961
+ return unwrap(await select({
962
+ message: `${group.title}:`,
963
+ options: [...commands.map((command) => ({
964
+ value: command,
965
+ label: command.name,
966
+ hint: hintFor(command)
967
+ })), BACK_OPTION]
968
+ }));
969
+ }
970
+ async function pickSubcommand(node, env) {
971
+ return unwrap(await select({
972
+ message: `${node.name}:`,
973
+ options: [...offered(node.subcommands ?? [], env).map((child) => ({
974
+ value: child,
975
+ label: child.name,
976
+ hint: hintFor(child)
977
+ })), BACK_OPTION]
978
+ }));
979
+ }
980
+ /**
981
+ * Asks for one option, returning the argv fragment it contributes.
982
+ *
983
+ * An empty array is a real answer, not a failure: a declined confirm adds no
984
+ * flag, and a blank optional text means "let the command decide", which is
985
+ * how `--db-url` falls back to `.env.production`.
986
+ */
987
+ async function askOption(option) {
988
+ const prompt = option.prompt;
989
+ if (!prompt) return [];
990
+ if (prompt.kind === "confirm") return unwrap(await confirm({
991
+ message: prompt.message,
992
+ initialValue: prompt.initial
993
+ })) ? [option.flag] : [];
994
+ if (prompt.kind === "text") {
995
+ const answer = unwrap(await text({
996
+ message: prompt.message,
997
+ placeholder: prompt.placeholder,
998
+ defaultValue: ""
999
+ })).trim();
1000
+ return answer ? [option.flag, answer] : [];
1001
+ }
1002
+ const choice = unwrap(await select({
1003
+ message: prompt.message,
1004
+ options: prompt.choices.map((c) => ({
1005
+ value: c.value,
1006
+ label: c.label ?? c.value,
1007
+ hint: c.hint
1008
+ }))
1009
+ }));
1010
+ return [option.flag, choice];
1011
+ }
1012
+ async function askOptions(node) {
1013
+ const argv = [];
1014
+ for (const option of node.options ?? []) argv.push(...await askOption(option));
1015
+ return argv;
1016
+ }
1017
+ /**
1018
+ * Descends from `start` through its subcommand screens to a leaf, then asks
1019
+ * that leaf's options.
1020
+ *
1021
+ * Shared by both entries into a walk: the top-of-tree loop below, which calls
1022
+ * this once a group and its first command are chosen, and a resumed walk
1023
+ * (`walk`'s `startPath` branch), which calls it directly on a node the caller
1024
+ * already picked by argv. `while` rather than a single step because the tree
1025
+ * is two deep today and this does not care. The condition counts the OFFERED
1026
+ * children rather than all of them, so a node whose every subcommand is
1027
+ * hidden is treated as the leaf it has become instead of opening a screen
1028
+ * holding only "Back".
1029
+ */
1030
+ async function descendFrom(start, pathNames, env) {
1031
+ let node = start;
1032
+ const path = [...pathNames];
1033
+ while (offered(node.subcommands ?? [], env).length > 0) {
1034
+ const child = await pickSubcommand(node, env);
1035
+ if (child === BACK) return BACK;
1036
+ node = child;
1037
+ path.push(node.name);
1038
+ }
1039
+ return {
1040
+ node,
1041
+ argv: [...path, ...await askOptions(node)]
1042
+ };
1043
+ }
1044
+ /**
1045
+ * Walks the tree to a leaf and its options, either from the top or resumed at
1046
+ * a known group.
1047
+ *
1048
+ * `startPath`, when given, is a path to a group node from `bareGroupStartPath`
1049
+ * — `["db"]` for `devtools db` — and lands here instead of at `pickGroup`.
1050
+ * There is no group screen above a resumed node (the caller already named it
1051
+ * by typing `db`), so BACK on its first subcommand screen has nowhere to
1052
+ * return to but out, unlike BACK from the top of the tree, which returns to
1053
+ * `pickGroup`.
1054
+ */
1055
+ async function walk(catalog, env, startPath) {
1056
+ if (startPath) {
1057
+ const start = catalog.findCommand(startPath);
1058
+ if (!start) return null;
1059
+ const chosen = await descendFrom(start, [...startPath], env);
1060
+ return chosen === BACK ? null : chosen;
1061
+ }
1062
+ for (;;) {
1063
+ const group = await pickGroup(catalog, env);
1064
+ if (!group) return null;
1065
+ const first = await pickCommand(group, env);
1066
+ if (first === BACK) continue;
1067
+ const chosen = await descendFrom(first, [first.name], env);
1068
+ if (chosen === BACK) continue;
1069
+ return chosen;
1070
+ }
1071
+ }
1072
+ /**
1073
+ * The command path when argv names a group with subcommands but no subcommand
1074
+ * token — what `devtools db` and `devtools db seed` are. Returns null for a
1075
+ * leaf command, an unknown token, or a bare invocation (which the no-argument
1076
+ * wizard already covers). The caller resumes the wizard at this node when
1077
+ * stdin is a TTY; a non-TTY caller keeps the dispatcher's "which of …?" exit.
1078
+ */
1079
+ function bareGroupStartPath(catalog, argv) {
1080
+ const path = positionals(argv);
1081
+ if (path.length === 0) return null;
1082
+ const node = catalog.findCommand(path);
1083
+ if (!node || (node.subcommands ?? []).length === 0) return null;
1084
+ return path;
1085
+ }
1086
+ /**
1087
+ * Runs the wizard, returning the `outro()` line its command earned.
1088
+ *
1089
+ * `dispatch` is the CLI's own argv handler, injected rather than imported so
1090
+ * that the CLI keeps a single definition of what each command does and the
1091
+ * tests can watch what a walk produces without running it. Its return value
1092
+ * passes straight through, the closing line or `null` for a failure already
1093
+ * explained, so a command reached from the menu signs off exactly as it does
1094
+ * from the command line.
1095
+ *
1096
+ * `options.startPath`, from `bareGroupStartPath`, skips straight to that
1097
+ * node's subcommand screen — see `walk`'s `startPath` branch.
1098
+ *
1099
+ * The deploy tier is NOT asked here any more. `src/launch.ts` resolves it
1100
+ * before `cli.ts` — and therefore this module — is even imported, and
1101
+ * exports `DEPLOY_ENV`/`DEV_DB` right away, so by the time the wizard opens
1102
+ * `process.env` already names the session exactly as if `--tier` had been
1103
+ * typed. Recording it for the "run it directly next time" line only needs to
1104
+ * read `process.env.DEPLOY_ENV` back.
1105
+ *
1106
+ * Entering that tier — loading its `.env.<tier>` file onto `process.env` —
1107
+ * is what's deferred: `launch.ts` registers a hook (`env-entry.ts`'s
1108
+ * `setMenuEnvHook`) instead of entering up front, so whatever changed while
1109
+ * the reader was still walking the menu (the local stack coming up in
1110
+ * another terminal, `.env.generated` being rewritten) is picked up rather
1111
+ * than missed. `takeMenuEnvHook()` below runs it right before the chosen
1112
+ * command dispatches — the ONE place in a menu walk an env value is ever
1113
+ * actually read. `undefined` (no hook registered — a typed command entered
1114
+ * already, or a test drives `runMenu` directly) just dispatches straight
1115
+ * through, unchanged from before this existed.
1116
+ */
1117
+ async function runMenu(catalog, dispatch, env = probeEnvironment(), options = {}) {
1118
+ note(describeEnvironment(env), "This machine");
1119
+ const chosen = await walk(catalog, env, options.startPath);
1120
+ if (!chosen) return null;
1121
+ beginInvocation(chosen.argv, true);
1122
+ const enteredTier = process.env.DEPLOY_ENV ?? "development";
1123
+ const devDb = process.env.DEV_DB;
1124
+ recordEnteredTier(enteredTier === "development" && (devDb === "local" || devDb === "remote") ? `development:${devDb}` : enteredTier);
1125
+ const enterEnvironment = takeMenuEnvHook();
1126
+ if (enterEnvironment) return enterEnvironment(chosen.argv, () => dispatch(chosen.argv));
1127
+ return dispatch(chosen.argv);
1128
+ }
1129
+ //#endregion
1130
+ //#region src/completions/catalog.ts
1131
+ const completionsCommand = {
1132
+ name: "completions",
1133
+ dryRun: "read-only",
1134
+ summary: "Output a shell completion script for backstage.",
1135
+ hint: "pipe to source or write to a file",
1136
+ surface: "cli-only",
1137
+ options: [{
1138
+ flag: "--shell",
1139
+ value: "<bash|zsh>",
1140
+ summary: "Target shell. Asked for when absent.",
1141
+ prompt: {
1142
+ kind: "select",
1143
+ message: "Which shell?",
1144
+ choices: [{ value: "bash" }, { value: "zsh" }]
1145
+ }
1146
+ }]
1147
+ };
1148
+ //#endregion
1149
+ //#region src/deploy/catalog.ts
1150
+ /**
1151
+ * `deploy`'s place in the command tree: declaration only, nothing here runs.
1152
+ * The handler lives in `commands.ts` beside it.
1153
+ */
1154
+ const DEPLOY_TIER = {
1155
+ flag: "--tier",
1156
+ value: "<t>",
1157
+ summary: "staging or production. Or set DEPLOY_ENV.",
1158
+ prompt: {
1159
+ kind: "select",
1160
+ message: "Which environment?",
1161
+ choices: [{ value: "staging" }, {
1162
+ value: "production",
1163
+ hint: "⚠️ live"
1164
+ }]
1165
+ }
1166
+ };
1167
+ const SMOKE_APP = {
1168
+ flag: "--app",
1169
+ value: "<app>",
1170
+ summary: "Which app to check. Defaults to platform."
1171
+ };
1172
+ function app(name) {
1173
+ return {
1174
+ name,
1175
+ summary: `Deploy the ${name} app.`,
1176
+ options: [
1177
+ DEPLOY_TIER,
1178
+ DRY_RUN,
1179
+ YES
1180
+ ]
1181
+ };
1182
+ }
1183
+ const deployCommand = {
1184
+ name: "deploy",
1185
+ dryRun: "handled",
1186
+ summary: "Deploy an app, or run one step of a deploy.",
1187
+ hint: "needs CLOUDFLARE_API_TOKEN and the tier's env",
1188
+ subcommands: [
1189
+ app("platform"),
1190
+ app("schedule-builder"),
1191
+ app("sandbox"),
1192
+ {
1193
+ name: "write-env",
1194
+ summary: "Compose .env.<DEPLOY_ENV> from the GitHub environment.",
1195
+ hint: "run it with --no-env: it creates the file the tier needs",
1196
+ options: [{
1197
+ flag: "--source",
1198
+ value: "<manifest>",
1199
+ summary: "Compose one manifest's slice instead of all."
1200
+ }, DRY_RUN]
1201
+ },
1202
+ {
1203
+ name: "preflight",
1204
+ dryRun: "read-only",
1205
+ summary: "Classify the project: paused (skip) vs broken (fail)."
1206
+ },
1207
+ {
1208
+ name: "plan",
1209
+ dryRun: "read-only",
1210
+ summary: "Dry-run the migrations into the job summary.",
1211
+ options: [{
1212
+ flag: "--label",
1213
+ value: "<title>",
1214
+ summary: "Heading for the summary section."
1215
+ }]
1216
+ },
1217
+ {
1218
+ name: "migrate",
1219
+ summary: "Apply the migrations to DB_URL.",
1220
+ options: [DRY_RUN]
1221
+ },
1222
+ {
1223
+ name: "smoke",
1224
+ dryRun: "read-only",
1225
+ summary: "Check a deployed app: public routes, auth redirect, Sentry.",
1226
+ hint: "per-app data comes from workers.json",
1227
+ options: [DEPLOY_TIER, SMOKE_APP]
1228
+ },
1229
+ {
1230
+ name: "reconcile",
1231
+ dryRun: "read-only",
1232
+ summary: "Run the platform's config reconcile after a deploy.",
1233
+ hint: "needs CRON_SECRET",
1234
+ options: [DEPLOY_TIER, SMOKE_APP]
1235
+ }
1236
+ ]
1237
+ };
1238
+ //#endregion
1239
+ //#region src/env/catalog.ts
1240
+ /**
1241
+ * `env`'s place in the command tree: declaration only, nothing here runs.
1242
+ * The handler lives in `commands.ts` beside it.
1243
+ */
1244
+ const VAULT_TARGET = {
1245
+ flag: "--target",
1246
+ value: "<t>",
1247
+ summary: "preflight, staging or production. Asked for when absent."
1248
+ };
1249
+ const ENV_FILE = {
1250
+ flag: "--file",
1251
+ value: "<path>",
1252
+ summary: "Read and write this file instead of the target's own."
1253
+ };
1254
+ const ACCESS_TOKEN = {
1255
+ flag: "--access-token",
1256
+ value: "<token>",
1257
+ summary: "Bitwarden Secrets Manager token. Prefer the vault or the env var."
1258
+ };
1259
+ const envCommand = {
1260
+ name: "env",
1261
+ summary: "One env file per target, synced to Bitwarden and GitHub.",
1262
+ hint: "needs the Secrets Manager token",
1263
+ subcommands: [
1264
+ {
1265
+ name: "pull",
1266
+ summary: "Bitwarden → the target's file, in place.",
1267
+ options: [
1268
+ VAULT_TARGET,
1269
+ ENV_FILE,
1270
+ YES,
1271
+ ACCESS_TOKEN
1272
+ ]
1273
+ },
1274
+ {
1275
+ name: "push",
1276
+ summary: "The target's file → Bitwarden and GitHub.",
1277
+ options: [
1278
+ VAULT_TARGET,
1279
+ ENV_FILE,
1280
+ YES,
1281
+ ACCESS_TOKEN
1282
+ ]
1283
+ },
1284
+ {
1285
+ name: "audit",
1286
+ dryRun: "read-only",
1287
+ summary: "Compare the file, Bitwarden, GitHub and Cloudflare; list orphaned Worker secrets.",
1288
+ hint: "reads only, unless --prune",
1289
+ options: [
1290
+ VAULT_TARGET,
1291
+ ENV_FILE,
1292
+ YES,
1293
+ ACCESS_TOKEN,
1294
+ JSON_FLAG,
1295
+ {
1296
+ flag: "--prune",
1297
+ summary: "Delete the Worker secrets no app declares. Asks first without --yes."
1298
+ }
1299
+ ]
1300
+ }
1301
+ ]
1302
+ };
1303
+ //#endregion
1304
+ //#region src/github/catalog.ts
1305
+ /**
1306
+ * `github`'s place in the command tree: declaration only, nothing here runs.
1307
+ * The handler lives in `commands.ts` beside it.
1308
+ */
1309
+ const githubCommand = {
1310
+ name: "github",
1311
+ dryRun: "handled",
1312
+ summary: "Reconcile the repository's rulesets and settings.",
1313
+ hint: "rulesets: main, production, ~ALL, team/**, tags; settings: security, Actions, environments",
1314
+ subcommands: [{
1315
+ name: "rulesets",
1316
+ summary: "Diff the fixed rulesets against live GitHub; --apply to write.",
1317
+ hint: "dry-run plan by default",
1318
+ envFree: true,
1319
+ options: [
1320
+ {
1321
+ flag: "--org",
1322
+ value: "<org>",
1323
+ summary: "GitHub org. Defaults to DevDogsUGA."
1324
+ },
1325
+ {
1326
+ flag: "--repo",
1327
+ value: "<repo>",
1328
+ summary: "Repository name. Defaults to DevDogsUGA."
1329
+ },
1330
+ {
1331
+ flag: "--app-slug",
1332
+ value: "<slug>",
1333
+ summary: "The GitHub App whose id bypasses team/** creation. Defaults to devdogs-platform."
1334
+ },
1335
+ {
1336
+ flag: "--apply",
1337
+ summary: "Write the plan instead of only printing it."
1338
+ },
1339
+ YES,
1340
+ JSON_FLAG
1341
+ ]
1342
+ }, {
1343
+ name: "settings",
1344
+ summary: "Diff security/Actions/environment settings vs live GitHub.",
1345
+ hint: "dry-run plan by default",
1346
+ envFree: true,
1347
+ options: [
1348
+ {
1349
+ flag: "--org",
1350
+ value: "<org>",
1351
+ summary: "GitHub org. Defaults to DevDogsUGA."
1352
+ },
1353
+ {
1354
+ flag: "--repo",
1355
+ value: "<repo>",
1356
+ summary: "Repository name. Defaults to DevDogsUGA."
1357
+ },
1358
+ {
1359
+ flag: "--apply",
1360
+ summary: "Write fixable drift instead of only printing it."
1361
+ },
1362
+ YES,
1363
+ JSON_FLAG
1364
+ ]
1365
+ }]
1366
+ };
1367
+ //#endregion
1368
+ //#region src/graphics/catalog.ts
1369
+ const graphicsCommand = {
1370
+ name: "graphics",
1371
+ dryRun: "handled",
1372
+ envFree: true,
1373
+ summary: "Render a club image at one or more sizes.",
1374
+ hint: "brand/*, app/*, event/*, or * for all",
1375
+ options: [
1376
+ {
1377
+ flag: "--format",
1378
+ value: "<a,b,…>",
1379
+ summary: "Sizes to render: og, gdgc-wide, gdgc-square, savvycal, email-*, icon-*."
1380
+ },
1381
+ {
1382
+ flag: "--all-formats",
1383
+ summary: "Every size the named graphics support."
1384
+ },
1385
+ {
1386
+ flag: "--out",
1387
+ value: "<dir>",
1388
+ summary: "Write everything into one directory, flat. Defaults to the current directory."
1389
+ },
1390
+ {
1391
+ flag: "--dry-run",
1392
+ summary: "List what would be written, and what each size is for."
1393
+ }
1394
+ ]
1395
+ };
1396
+ //#endregion
1397
+ //#region src/newsletter/catalog.ts
1398
+ /**
1399
+ * `newsletter`'s place in the command tree: declaration only, nothing here
1400
+ * runs. The handler lives in `commands.ts` beside it.
1401
+ *
1402
+ * Every subcommand is `envFree`: the club mailbox is reached with the
1403
+ * officer's own Microsoft sign-in, not an env file or a deploy tier.
1404
+ */
1405
+ const newsletterCommand = {
1406
+ name: "newsletter",
1407
+ summary: "Render, draft or send a DevDogs Changelog issue.",
1408
+ hint: "needs the club mailbox sign-in for draft and send",
1409
+ subcommands: [
1410
+ {
1411
+ name: "render",
1412
+ envFree: true,
1413
+ summary: "Write an issue as .eml and .html files.",
1414
+ hint: "writes files only",
1415
+ options: [{
1416
+ flag: "--format",
1417
+ value: "<eml,html>",
1418
+ summary: "Which files. Defaults to both.",
1419
+ prompt: {
1420
+ kind: "select",
1421
+ message: "Which files?",
1422
+ choices: [
1423
+ {
1424
+ value: "eml,html",
1425
+ label: "Both"
1426
+ },
1427
+ {
1428
+ value: "eml",
1429
+ label: "Outlook draft (.eml)"
1430
+ },
1431
+ {
1432
+ value: "html",
1433
+ label: "HTML preview"
1434
+ }
1435
+ ]
1436
+ }
1437
+ }, {
1438
+ flag: "--out",
1439
+ value: "<dir>",
1440
+ summary: "Directory for the files. Defaults to ./changelog-exports.",
1441
+ prompt: {
1442
+ kind: "text",
1443
+ message: "Where should the files go?",
1444
+ placeholder: "./changelog-exports",
1445
+ optional: true
1446
+ }
1447
+ }]
1448
+ },
1449
+ {
1450
+ name: "draft",
1451
+ envFree: true,
1452
+ summary: "Append an issue to the club mailbox's Drafts.",
1453
+ hint: "review it in any Outlook"
1454
+ },
1455
+ {
1456
+ name: "send",
1457
+ envFree: true,
1458
+ summary: "Send an issue from the club mailbox, as authored.",
1459
+ hint: "asks first, naming the issue and every recipient",
1460
+ options: [{
1461
+ flag: "--to",
1462
+ value: "<a@…,b@…>",
1463
+ summary: "Recipients, comma-separated. Required; there is no default.",
1464
+ prompt: {
1465
+ kind: "text",
1466
+ message: "Who should receive it? (comma-separated addresses)"
1467
+ }
1468
+ }, YES]
1469
+ }
1470
+ ]
1471
+ };
1472
+ //#endregion
1473
+ //#region src/planner/catalog.ts
1474
+ /**
1475
+ * `planner`'s place in the command tree: declaration only, nothing here
1476
+ * runs. The handler lives in `commands.ts` beside it. Production-only: the
1477
+ * role it manages exists for the deploy pipeline's preflight tier.
1478
+ */
1479
+ const DB_URL = {
1480
+ flag: "--db-url",
1481
+ value: "<url>",
1482
+ summary: "Privileged connection. Defaults to .env.production's DB_URL.",
1483
+ prompt: {
1484
+ kind: "text",
1485
+ message: "Connection URL? (blank uses .env.production's DB_URL)",
1486
+ optional: true
1487
+ }
1488
+ };
1489
+ const plannerCommand = {
1490
+ name: "planner",
1491
+ summary: "The migration_planner role the preflight tier may hold.",
1492
+ hint: "needs the production database",
1493
+ subcommands: [
1494
+ {
1495
+ name: "status",
1496
+ dryRun: "read-only",
1497
+ summary: "Can the preflight credential reach more than it should; the role's shape.",
1498
+ hint: "reads only — start here; CI runs it with --no-env",
1499
+ options: [DB_URL, JSON_FLAG]
1500
+ },
1501
+ {
1502
+ name: "create",
1503
+ summary: "Mint the role, verify it live, write .env.preflight.",
1504
+ options: [DB_URL]
1505
+ },
1506
+ {
1507
+ name: "reset-password",
1508
+ summary: "Rotate the password. There is no retrieve.",
1509
+ options: [DB_URL, YES]
1510
+ },
1511
+ {
1512
+ name: "drop",
1513
+ summary: "Remove the role and blank the dead URL.",
1514
+ hint: "the recovery path",
1515
+ options: [DB_URL, YES]
1516
+ }
1517
+ ]
1518
+ };
1519
+ //#endregion
1520
+ //#region src/qr/catalog.ts
1521
+ const qrCommand = {
1522
+ name: "qr",
1523
+ dryRun: "handled",
1524
+ envFree: true,
1525
+ summary: "Make a QR code: any size, colour, logo and format.",
1526
+ hint: "same options as /console/qr; svg, png, jpg, webp, avif, tiff",
1527
+ options: [...qrCatalogOptions(), {
1528
+ flag: "--dry-run",
1529
+ summary: "List the files that would be written, and write nothing."
1530
+ }]
1531
+ };
1532
+ const catalog = createCatalog({
1533
+ usage: "pnpm backstage",
1534
+ commonTasks: [
1535
+ ["env pull --target production", "Fill .env.production from the vault"],
1536
+ ["env audit --target production", "Compare every store, list orphans"],
1537
+ ["deploy platform --tier staging", "Deploy an app"],
1538
+ ["planner status", "Check the preflight credential"],
1539
+ ["graphics 'event/*' --out ~/images", "Render event images"],
1540
+ ["qr https://devdogsuga.org --format svg,png", "Make a QR code"]
1541
+ ],
1542
+ groups: [
1543
+ {
1544
+ title: "Production deploys",
1545
+ commands: [deployCommand]
1546
+ },
1547
+ {
1548
+ title: "Secrets & environments",
1549
+ commands: [envCommand]
1550
+ },
1551
+ {
1552
+ title: "Database roles",
1553
+ commands: [plannerCommand]
1554
+ },
1555
+ {
1556
+ title: "Graphics & QR codes (no credentials)",
1557
+ commands: [graphicsCommand, qrCommand]
1558
+ },
1559
+ {
1560
+ title: "Your own sign-in (gh login, club mailbox)",
1561
+ commands: [githubCommand, newsletterCommand]
1562
+ },
1563
+ {
1564
+ title: "CLI utilities",
1565
+ commands: [completionsCommand]
1566
+ }
1567
+ ]
1568
+ });
1569
+ //#endregion
1570
+ export { repoPeerUrl as _, recordEnteredTier as a, renderHelp as b, setMenuEnvHook as c, formatCommand as d, nonEmpty as f, loadEnvSession as g, loadEnvLoad as h, beginInvocation as i, dbPush as l, loadEnv as m, bareGroupStartPath as n, recordResolved as o, getEnvSync as p, runMenu as r, reproducibleCommand as s, catalog as t, dbPushDryRun as u, helpPath as v, renderCommandList as y };