@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.
- package/README.md +147 -0
- package/bin/backstage.mjs +28 -0
- package/dist/args-Cjr_Iqts.js +66 -0
- package/dist/build-info.json +3 -0
- package/dist/catalog-BOBE4ee7.js +1570 -0
- package/dist/cli-YLCe3Zrx.js +6300 -0
- package/dist/commands-Bk7IbhPD.js +220 -0
- package/dist/commands-D345F-Od.js +800 -0
- package/dist/commands-Dv5TXjvb.js +545 -0
- package/dist/dispatch-D048O65I.js +13 -0
- package/dist/launch.js +329 -0
- package/dist/options-BTjOf5KP.js +183 -0
- package/dist/peer-redirect-hooks.js +15 -0
- package/dist/telemetry-Bjoz29Hl.js +609 -0
- package/dist/telemetry.js +2 -0
- package/dist/ui-CdKo8mLw.js +66 -0
- package/env.ts +163 -0
- package/package.json +82 -0
|
@@ -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 };
|