@devdogsuga/devtools 0.1.50 → 0.1.51-next.526

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,766 @@
1
+ import { o as noteRan, r as findRepoRoot, t as RepoNotFoundError, u as cliName } from "./root-CsocfxnK.js";
2
+ import { createRequire } from "node:module";
3
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { execFile, execFileSync, spawn } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { pathToFileURL } from "node:url";
8
+ //#region ../cli-core/src/dry-run.ts
9
+ const DRY_RUN_FLAG = "--dry-run";
10
+ /** Commands that forward everything after their name to another tool. */
11
+ const FORWARDING = /* @__PURE__ */ new Set([
12
+ "supabase",
13
+ "wrangler",
14
+ "drizzle-kit",
15
+ "psql",
16
+ "bw",
17
+ "run"
18
+ ]);
19
+ let active = false;
20
+ function setDryRun(on) {
21
+ active = on;
22
+ }
23
+ function isDryRun() {
24
+ return active;
25
+ }
26
+ /**
27
+ * Decides whether `argv` asks for a dry run and returns the argv to dispatch.
28
+ *
29
+ * For a forwarding command a leading `--dry-run` is consumed (the tool never
30
+ * sees it) and a later one is the tool's. For any other command the argv is
31
+ * returned as it came, because several commands read `--dry-run` themselves.
32
+ */
33
+ function resolveDryRun(argv) {
34
+ const firstPositional = argv.findIndex((arg) => !arg.startsWith("-"));
35
+ const lead = firstPositional === -1 ? argv.length : firstPositional;
36
+ const command = argv[firstPositional];
37
+ if (command !== void 0 && FORWARDING.has(command)) {
38
+ const rest = argv.filter((arg, i) => i >= lead || arg !== "--dry-run");
39
+ return {
40
+ dryRun: rest.length !== argv.length,
41
+ rest
42
+ };
43
+ }
44
+ return {
45
+ dryRun: argv.includes(DRY_RUN_FLAG),
46
+ rest: [...argv]
47
+ };
48
+ }
49
+ /**
50
+ * How a command treats `--dry-run`, from the nearest node on its path that
51
+ * says. `undefined` means it did not say, and the dispatcher stops it.
52
+ */
53
+ function dryRunKind(catalog, path) {
54
+ for (let end = path.length; end > 0; end -= 1) {
55
+ const kind = catalog.findCommand(path.slice(0, end))?.dryRun;
56
+ if (kind !== void 0) return kind;
57
+ }
58
+ }
59
+ /** The command line a stopped command would have run. */
60
+ function wouldRunLine(argv) {
61
+ return `Would run: ${cliName()} ${argv.join(" ")}`;
62
+ }
63
+ //#endregion
64
+ //#region ../cli-core/src/process-group.ts
65
+ /**
66
+ * Running a child tool so that stopping it stops everything it started.
67
+ *
68
+ * `pnpm run dev` forks a shell that forks vinext that forks workerd. Killing
69
+ * only the pnpm process (what a plain `child.kill()` does, and what a closed
70
+ * terminal tab or a `kill` from another window delivers) orphans the rest,
71
+ * and the orphan keeps its port. So the child gets its own process group, and
72
+ * every stop signal this process receives is sent to the whole group.
73
+ *
74
+ * Ctrl-C needs the same care in reverse: a child in its own group is not in
75
+ * the terminal's foreground group, so the terminal no longer delivers ^C to
76
+ * it. The signal handlers below forward it.
77
+ *
78
+ * The group is only swept after the direct child exits when that exit was a
79
+ * signal we forwarded; a child that exits on its own is left to have cleaned
80
+ * up after itself, so a tool that deliberately leaves a daemon running keeps it.
81
+ */
82
+ const FORWARDED = [
83
+ "SIGINT",
84
+ "SIGTERM",
85
+ "SIGHUP"
86
+ ];
87
+ const SIGNAL_NUMBERS = {
88
+ SIGHUP: 1,
89
+ SIGINT: 2,
90
+ SIGTERM: 15,
91
+ SIGKILL: 9
92
+ };
93
+ /** How long a group gets to leave after SIGTERM before SIGKILL. */
94
+ const GRACE_MS = 5e3;
95
+ /** Sends `signal` to every process in `pid`'s group. False if it is already gone. */
96
+ function signalGroup(pid, signal) {
97
+ try {
98
+ process.kill(process.platform === "win32" ? pid : -pid, signal);
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+ function runInGroup(command, args, opts = {}) {
105
+ if (isDryRun()) {
106
+ process.stderr.write(`Would run: ${formatCommand(command, args)}\n`);
107
+ return Promise.resolve({
108
+ code: 0,
109
+ signal: null
110
+ });
111
+ }
112
+ return new Promise((resolve) => {
113
+ const child = spawn(command, [...args], {
114
+ stdio: opts.stdio ?? "inherit",
115
+ cwd: opts.cwd,
116
+ env: opts.env,
117
+ detached: process.platform !== "win32"
118
+ });
119
+ const pid = child.pid;
120
+ let forwarded = null;
121
+ let escalation;
122
+ const forward = (signal) => {
123
+ if (pid === void 0) return;
124
+ forwarded ??= signal;
125
+ signalGroup(pid, signal);
126
+ escalation ??= setTimeout(() => signalGroup(pid, "SIGKILL"), GRACE_MS);
127
+ escalation.unref();
128
+ };
129
+ if (opts.abort?.aborted) forward("SIGTERM");
130
+ else opts.abort?.addEventListener("abort", () => forward("SIGTERM"), { once: true });
131
+ const handlers = FORWARDED.map((signal) => {
132
+ const handler = () => forward(signal);
133
+ process.on(signal, handler);
134
+ return [signal, handler];
135
+ });
136
+ const onExit = () => {
137
+ if (pid !== void 0) signalGroup(pid, "SIGKILL");
138
+ };
139
+ process.on("exit", onExit);
140
+ const finish = (result) => {
141
+ noteRan(`${formatCommand(command, args)} (exit ${result.code})`);
142
+ for (const [signal, handler] of handlers) process.off(signal, handler);
143
+ process.off("exit", onExit);
144
+ if (escalation) clearTimeout(escalation);
145
+ if (forwarded !== null && pid !== void 0) signalGroup(pid, "SIGKILL");
146
+ resolve(result);
147
+ };
148
+ child.on("error", (error) => {
149
+ process.stderr.write(`${error.message}\n`);
150
+ finish({
151
+ code: 1,
152
+ signal: null
153
+ });
154
+ });
155
+ child.on("exit", (code, signal) => {
156
+ const bySignal = signal ? 128 + (SIGNAL_NUMBERS[signal] ?? 0) : 1;
157
+ finish({
158
+ code: code ?? bySignal,
159
+ signal
160
+ });
161
+ });
162
+ });
163
+ }
164
+ const URL_CREDENTIALS = /\/\/[^/@\s]*@/;
165
+ /**
166
+ * The command as a person would type it, for printing. A database URL carries
167
+ * its password, so the value after `--db-url` (and any URL with credentials)
168
+ * is replaced rather than echoed.
169
+ */
170
+ function formatCommand(command, args) {
171
+ const shown = [];
172
+ for (let i = 0; i < args.length; i += 1) {
173
+ const arg = args[i];
174
+ if (arg === "--db-url" && i + 1 < args.length) {
175
+ shown.push(arg, "<DB_URL>");
176
+ i += 1;
177
+ } else if (arg.startsWith("--db-url=")) shown.push("--db-url=<DB_URL>");
178
+ else shown.push(arg.replace(URL_CREDENTIALS, "//***@"));
179
+ }
180
+ return [command, ...shown].map((part) => /^[\w@%+=:,./<>*-]+$/.test(part) ? part : JSON.stringify(part)).join(" ");
181
+ }
182
+ /**
183
+ * Prints the exact command that ran, AFTER it ran so the tool's own output
184
+ * cannot bury it. On stderr, so a tool whose stdout is a pipe stays clean.
185
+ */
186
+ function reportRan(command, args, result) {
187
+ if (isDryRun()) return;
188
+ const status = result.code === 0 ? "" : ` (exit ${result.code})`;
189
+ process.stderr.write(`Ran: ${formatCommand(command, args)}${status}\n`);
190
+ }
191
+ /** The directory (relative to a Backstage root) holding the DevDogsUGA checkout. */
192
+ const DEVDOGSUGA_LINK = "devdogsuga";
193
+ var DevdogsugaMissingError = class extends Error {
194
+ constructor(path) {
195
+ super(`The DevDogsUGA checkout is missing at ${path}. Run \`pnpm devdogsuga\` (links the sibling clone, or \$DEVDOGSUGA_DIR) or \`pnpm install\` in the Backstage root first.`);
196
+ this.name = "DevdogsugaMissingError";
197
+ }
198
+ };
199
+ /** The kind of repo `dir` is the root of, by its `package.json` name; null if neither. */
200
+ function repoKindOf(dir) {
201
+ try {
202
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
203
+ if (pkg.name === "devdogs-monorepo") return "devdogsuga";
204
+ if (pkg.name === "backstage") return "backstage";
205
+ } catch {}
206
+ return null;
207
+ }
208
+ function isDir(path) {
209
+ try {
210
+ return statSync(path).isDirectory();
211
+ } catch {
212
+ return false;
213
+ }
214
+ }
215
+ /**
216
+ * The layout of the repo rooted at `root`. Pure: nothing is memoized, so tests
217
+ * can build fixtures freely. An unrecognised package name is treated as
218
+ * DevDogsUGA (the contract fixtures and every pre-Backstage caller).
219
+ */
220
+ function layoutAt(root) {
221
+ const kind = repoKindOf(root) === "backstage" ? "backstage" : "devdogsuga";
222
+ const sibling = join(root, DEVDOGSUGA_LINK);
223
+ const hasDevdogsuga = kind === "devdogsuga" || isDir(sibling);
224
+ const devdogsugaRoot = kind === "devdogsuga" ? root : sibling;
225
+ const appsDirs = [join(root, "apps")];
226
+ if (kind === "backstage" && hasDevdogsuga) appsDirs.push(join(devdogsugaRoot, "apps"));
227
+ return {
228
+ kind,
229
+ root,
230
+ get devdogsugaRoot() {
231
+ if (!hasDevdogsuga) throw new DevdogsugaMissingError(sibling);
232
+ return devdogsugaRoot;
233
+ },
234
+ ...kind === "backstage" ? { backstageRoot: root } : {},
235
+ hasDevdogsuga,
236
+ appsDirs: appsDirs.filter(isDir),
237
+ envMirrors: kind === "backstage" && hasDevdogsuga ? [devdogsugaRoot] : []
238
+ };
239
+ }
240
+ /** `layout.devdogsugaRoot`, or null instead of a throw when the checkout is missing. */
241
+ function devdogsugaRootOrNull(layout) {
242
+ return layout.hasDevdogsuga ? layout.devdogsugaRoot : null;
243
+ }
244
+ let cached;
245
+ let cachedFor;
246
+ /** The layout of the repo the process is in (`findRepoRoot()`), memoized per root. */
247
+ function resolveLayout() {
248
+ const root = findRepoRoot();
249
+ if (cached === void 0 || cachedFor !== root) {
250
+ cached = layoutAt(root);
251
+ cachedFor = root;
252
+ }
253
+ return cached;
254
+ }
255
+ /** Every directory under `appsDirs` (one app each), de-duplicated by name, sorted by name. */
256
+ function listApps(layout = resolveLayout()) {
257
+ const seen = /* @__PURE__ */ new Map();
258
+ for (const parent of layout.appsDirs) for (const entry of readdirSync(parent, { withFileTypes: true })) {
259
+ if (!entry.isDirectory()) continue;
260
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
261
+ if (!seen.has(entry.name)) seen.set(entry.name, {
262
+ name: entry.name,
263
+ dir: join(parent, entry.name)
264
+ });
265
+ }
266
+ return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
267
+ }
268
+ /** The directory of the app called `name`, or undefined when no repo has it. */
269
+ function findApp(name, layout = resolveLayout()) {
270
+ return listApps(layout).find((app) => app.name === name)?.dir;
271
+ }
272
+ /**
273
+ * Resolves a workspace-relative path such as `workers.json`'s
274
+ * `apps/schedule-builder` to a directory: the repo you are in first, then
275
+ * DevDogsUGA's. When neither has it the first candidate is returned, so
276
+ * callers get an ordinary ENOENT naming the path rather than a second error.
277
+ */
278
+ function resolveAppPath(relative, layout = resolveLayout()) {
279
+ const first = join(layout.root, relative);
280
+ if (existsSync(first)) return first;
281
+ if (layout.kind === "backstage" && layout.hasDevdogsuga) {
282
+ const second = join(layout.devdogsugaRoot, relative);
283
+ if (existsSync(second)) return second;
284
+ }
285
+ return first;
286
+ }
287
+ /**
288
+ * The pnpm workspace root holding the app called `name`: where
289
+ * `pnpm --filter <name>` has to run. DevDogsUGA's root for its apps seen from a
290
+ * Backstage checkout, `layout.root` otherwise.
291
+ */
292
+ function appWorkspaceRoot(name, layout = resolveLayout()) {
293
+ const dir = findApp(name, layout);
294
+ return dir === void 0 ? layout.root : dirname(dirname(dir));
295
+ }
296
+ //#endregion
297
+ //#region ../cli-core/src/repo/resolve.ts
298
+ /**
299
+ * Resolves an `@devdogsuga/*` package FROM the target repo, not from
300
+ * devtools' own (dlx-isolated) `node_modules`.
301
+ *
302
+ * Ported from the `devtools-dlx` prototype
303
+ * (`/home/sloan/scratchpad/devdogs/prototypes/devtools-dlx/FINDINGS.md`,
304
+ * experiments 2 and 4 — read that file for the two gotchas this module
305
+ * exists to avoid). The short version:
306
+ *
307
+ * - `require.resolve(\`${specifier}/package.json\`)` throws
308
+ * `ERR_PACKAGE_PATH_NOT_EXPORTED` on packages (like `@devdogsuga/env`,
309
+ * `@devdogsuga/open-graph`) whose `exports` map does not list
310
+ * `"./package.json"`, even though the file is on disk.
311
+ * - A resolved path cannot be assumed to contain a literal
312
+ * `node_modules/<specifier>` segment — pnpm workspace-linked packages
313
+ * resolve to their REALPATH (e.g. `packages/open-graph/dist/index.js`),
314
+ * which has no such segment at all.
315
+ * - `require.resolve` ignores whatever `conditions` a caller has set on
316
+ * `tsx`'s `register()` — condition-based resolution (the
317
+ * `devdogs-source` condition) has to be done by hand, reading the
318
+ * target package's own `exports["."]` map.
319
+ *
320
+ * Both problems are solved the same way: walk up from the resolved file's
321
+ * directory to the nearest `package.json` whose own `"name"` field matches
322
+ * the specifier. That works for a real installed dependency AND a
323
+ * workspace symlink, uniformly.
324
+ */
325
+ /**
326
+ * The installable package name a (possibly subpath) specifier belongs to:
327
+ * `@devdogsuga/env/load` → `@devdogsuga/env`, `foo/bar` → `foo`. A repo's
328
+ * package.json declares the PACKAGE as a dependency, never a subpath, so
329
+ * `findDependent` has to search on this rather than the literal specifier a
330
+ * caller wants to import.
331
+ */
332
+ function packageNameOf(specifier) {
333
+ const parts = specifier.split("/");
334
+ return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
335
+ }
336
+ /**
337
+ * Scans `apps/*` and `packages/*` under `repoRoot` for the first
338
+ * `package.json` whose `dependencies`/`devDependencies`/`peerDependencies`
339
+ * names `specifier`'s package (see `packageNameOf`). Returns its path
340
+ * relative to `repoRoot`, or `null` if nothing in the repo depends on it.
341
+ *
342
+ * Re-scans the filesystem on every call — fine for a short-lived CLI
343
+ * invocation calling this a handful of times; see FINDINGS item 5 if this
344
+ * is ever called in a hot loop.
345
+ */
346
+ function findDependent(repoRoot, specifier) {
347
+ const packageName = packageNameOf(specifier);
348
+ for (const group of ["apps", "packages"]) {
349
+ const groupDir = join(repoRoot, group);
350
+ let entries;
351
+ try {
352
+ entries = readdirSync(groupDir);
353
+ } catch {
354
+ continue;
355
+ }
356
+ for (const entry of entries) {
357
+ const pkgJsonPath = join(groupDir, entry, "package.json");
358
+ if (!existsSync(pkgJsonPath)) continue;
359
+ let pkg;
360
+ try {
361
+ pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
362
+ } catch {
363
+ continue;
364
+ }
365
+ if (packageName in {
366
+ ...pkg.dependencies,
367
+ ...pkg.devDependencies,
368
+ ...pkg.peerDependencies
369
+ }) return join(group, entry, "package.json");
370
+ }
371
+ }
372
+ return null;
373
+ }
374
+ /** Walks up from `startDir` to the nearest `package.json` whose `"name"` matches `specifier`. */
375
+ function findOwningPackageJson(startDir, specifier) {
376
+ const packageName = packageNameOf(specifier);
377
+ let dir = startDir;
378
+ for (;;) {
379
+ const candidate = join(dir, "package.json");
380
+ if (existsSync(candidate)) try {
381
+ if (JSON.parse(readFileSync(candidate, "utf8")).name === packageName) return candidate;
382
+ } catch {}
383
+ const parent = dirname(dir);
384
+ if (parent === dir) throw new Error(`resolveFromRepo: could not find ${specifier}'s own package.json by walking up from ${startDir}`);
385
+ dir = parent;
386
+ }
387
+ }
388
+ /**
389
+ * Resolves `specifier` as `resolutionBase` (a workspace-relative
390
+ * `package.json` path known to depend on it, from `findDependent()`) would
391
+ * see it.
392
+ *
393
+ * `opts.condition`, when given, is NOT passed through to Node's resolver —
394
+ * `require.resolve` ignores tsx's `register({ conditions })` entirely (see
395
+ * this module's header). Instead the target package's own
396
+ * `exports["."]` map is read directly and `exports["."][condition]` is
397
+ * picked, falling back to `.default`.
398
+ */
399
+ function resolveFromRepo(repoRoot, resolutionBase, specifier, opts) {
400
+ const baseFile = join(repoRoot, resolutionBase);
401
+ const require = createRequire(baseFile);
402
+ let resolvedPath;
403
+ if (opts?.condition) {
404
+ const defaultEntry = require.resolve(specifier);
405
+ const pkgJsonPath = findOwningPackageJson(dirname(defaultEntry), specifier);
406
+ const rootExport = JSON.parse(readFileSync(pkgJsonPath, "utf8")).exports?.["."];
407
+ const conditioned = rootExport && typeof rootExport === "object" ? rootExport[opts.condition] ?? rootExport.default : void 0;
408
+ if (typeof conditioned !== "string") throw new Error(`resolveFromRepo: ${specifier} has no "${opts.condition}" (or "default") export in its exports["."] map`);
409
+ resolvedPath = join(dirname(pkgJsonPath), conditioned);
410
+ } else resolvedPath = require.resolve(specifier);
411
+ const pkgJsonPath = findOwningPackageJson(dirname(resolvedPath), specifier);
412
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
413
+ return {
414
+ resolvedPath,
415
+ version: pkg.version ?? "0.0.0",
416
+ pkgJsonPath
417
+ };
418
+ }
419
+ //#endregion
420
+ //#region ../cli-core/src/repo/peers.ts
421
+ /**
422
+ * Loads devtools' optional-peer `@devdogsuga/*` libraries — the ones
423
+ * published to npm and consumed by the TARGET repo, which devtools reads at
424
+ * runtime rather than bundling its own copy of (see this package's
425
+ * `package.json`: `peerDependencies` + `peerDependenciesMeta.optional`, and
426
+ * `FINDINGS.md` experiment 2).
427
+ *
428
+ * Every export here is memoized per module (one dynamic `import()` per
429
+ * process, not per call) — this is what makes the module-identity guarantee
430
+ * in FINDINGS experiment 3 hold: every devtools call site that needs
431
+ * `@devdogsuga/sdk/env`'s registry gets the exact same module instance the
432
+ * repo's own manifests populated via `declare()`/`define()`, because both
433
+ * resolve to the identical absolute file path and Node's ESM cache is keyed
434
+ * by resolved URL.
435
+ */
436
+ const moduleCache = /* @__PURE__ */ new Map();
437
+ /** The file URL each peer resolved to through the target repo, set only on
438
+ * that path (not on either plain bare-specifier fallback). See
439
+ * `repoPeerUrl()`. */
440
+ const resolvedUrls = /* @__PURE__ */ new Map();
441
+ /**
442
+ * Dynamically imports `specifier` as the repo would resolve it, memoized so
443
+ * repeated calls in one process return the SAME module instance (required
444
+ * for the env-registry identity guarantee above).
445
+ *
446
+ * Falls back to a plain bare-specifier `import()` when devtools is not
447
+ * running inside a DevDogsUGA checkout at all (`findRepoRoot()` throws
448
+ * `RepoNotFoundError`). That is not a production affordance — under real
449
+ * `pnpm dlx` use, `specifier` is an optional peer devtools' own package
450
+ * never installs, so the fallback import fails with the ordinary
451
+ * `MODULE_NOT_FOUND` either way. It exists so devtools' OWN test suite
452
+ * (which has no target repo to discover, but does have every one of these
453
+ * peers as a real Backstage workspace package) exercises this code against
454
+ * the genuine package rather than needing every test to mock this module.
455
+ */
456
+ function loadPeer(specifier) {
457
+ const cached = moduleCache.get(specifier);
458
+ if (cached) return cached;
459
+ const promise = (async () => {
460
+ if (process.env.DEVTOOLS_TEST_REPO_ROOT) return import(specifier);
461
+ let repoRoot;
462
+ try {
463
+ repoRoot = findRepoRoot();
464
+ } catch (err) {
465
+ if (err instanceof RepoNotFoundError) return import(specifier);
466
+ throw err;
467
+ }
468
+ const resolutionBase = findDependent(repoRoot, specifier);
469
+ 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).`);
470
+ const { resolvedPath } = resolveFromRepo(repoRoot, resolutionBase, specifier);
471
+ const url = pathToFileURL(resolvedPath).href;
472
+ resolvedUrls.set(specifier, url);
473
+ return import(url);
474
+ })();
475
+ moduleCache.set(specifier, promise);
476
+ return promise;
477
+ }
478
+ /**
479
+ * The file URL `specifier` was loaded from through the target repo, or
480
+ * undefined if it has not been loaded yet or came from a plain bare-specifier
481
+ * `import()` (the test-suite and not-in-a-repo fallbacks above).
482
+ *
483
+ * For code devtools imports that itself names a peer by bare specifier --
484
+ * today only devtools' own `env.ts` manifest. From an installed copy that
485
+ * specifier cannot resolve at all (an optional peer is never installed next
486
+ * to devtools under `pnpm dlx`), and even where it can, the only copy that
487
+ * keeps module identity is this one. `repo/peer-redirect.ts` uses it to
488
+ * point the import here.
489
+ */
490
+ function repoPeerUrl(specifier) {
491
+ return resolvedUrls.get(specifier);
492
+ }
493
+ let resolvedEnvModule;
494
+ function loadEnv() {
495
+ return loadPeer("@devdogsuga/sdk/env").then((mod) => {
496
+ resolvedEnvModule = mod;
497
+ return mod;
498
+ });
499
+ }
500
+ /**
501
+ * The synchronous companion to `loadEnv()`, for the handful of call sites
502
+ * (`gh/environments.ts`'s `GITHUB_ENVIRONMENT_SPECS` getters) that cannot
503
+ * become async without changing their own callers' contract — they read a
504
+ * plain array off a getter, not a Promise. Safe ONLY after `loadEnv()` has
505
+ * resolved at least once; every caller of these getters already requires
506
+ * `env/discovery.ts`'s `loadRegistry()` (which calls `loadEnv()` itself) to
507
+ * have run first, via `assertRegistryLoaded()`'s own guard.
508
+ */
509
+ function getEnvSync() {
510
+ if (!resolvedEnvModule) throw new Error("getEnvSync() called before loadEnv() ever resolved — call loadRegistry() (env/discovery.ts) first.");
511
+ return resolvedEnvModule;
512
+ }
513
+ /** The env loader (`loadEnvironment` and the tier/file selection). Bundled. */
514
+ function loadEnvLoad() {
515
+ return import("./load-Czhayrsk.js");
516
+ }
517
+ /** The session policy (tier resolution, `enterEnvironment`). Bundled. */
518
+ function loadEnvSession() {
519
+ return import("./session-CmDxHEMn.js");
520
+ }
521
+ //#endregion
522
+ //#region ../cli-core/src/db/run.ts
523
+ /**
524
+ * Shared helpers for running supabase CLI and pnpm commands.
525
+ *
526
+ * The supabase CLI is a workspace devDependency (not a global install), so
527
+ * every invocation goes through `pnpm exec supabase`. Plain pnpm commands run
528
+ * in `findRepoRoot()`; the supabase ones run in DevDogsUGA's root
529
+ * (`supabaseRoot()`), where `supabase/config.toml` lives and where the CLI is
530
+ * installed. From a Backstage checkout that is `<root>/devdogsuga`, so it needs
531
+ * its own `pnpm install` there before any of these work.
532
+ */
533
+ const runFile = promisify(execFile);
534
+ /** Where `supabase/` lives, and so the cwd of every supabase CLI call. */
535
+ function supabaseRoot() {
536
+ return resolveLayout().devdogsugaRoot;
537
+ }
538
+ /** Spawn a pnpm command with inherited stdio; resolves to the exit code. Pass
539
+ * `env` to run the child against a loaded tier rather than process.env. The
540
+ * child runs in its own process group, so stopping this process stops
541
+ * whatever it started (see `process-group.ts`). */
542
+ async function run(args, env, cwd = findRepoRoot()) {
543
+ const result = await runInGroup("pnpm", args, {
544
+ cwd,
545
+ ...env ? { env } : {}
546
+ });
547
+ if (reporting) reportRan("pnpm", args, result);
548
+ return result.code;
549
+ }
550
+ let reporting = false;
551
+ /**
552
+ * Makes every `run()` after this print the exact command it ran (on stderr,
553
+ * after it finishes). Off by default, so a deploy log or a piped command is
554
+ * not changed under its caller; the presets turn it on because showing the
555
+ * tool calls behind a button is their whole point.
556
+ */
557
+ function reportRuns(enabled) {
558
+ reporting = enabled;
559
+ }
560
+ /** Spawn a pnpm command while echoing and retaining stderr for diagnostics.
561
+ * Pass `env` to run the child against a loaded tier rather than process.env. */
562
+ function runWithStderr(args, env, cwd = findRepoRoot()) {
563
+ return new Promise((resolve) => {
564
+ const child = spawn("pnpm", args, {
565
+ stdio: [
566
+ "inherit",
567
+ "inherit",
568
+ "pipe"
569
+ ],
570
+ cwd,
571
+ ...env ? { env } : {}
572
+ });
573
+ let stderr = "";
574
+ child.stderr.setEncoding("utf8");
575
+ child.stderr.on("data", (chunk) => {
576
+ stderr += chunk;
577
+ process.stderr.write(chunk);
578
+ });
579
+ child.on("error", (error) => {
580
+ const message = `${error.message}\n`;
581
+ stderr += message;
582
+ process.stderr.write(message);
583
+ resolve({
584
+ code: 1,
585
+ stderr
586
+ });
587
+ });
588
+ child.on("exit", (code) => resolve({
589
+ code: code ?? 1,
590
+ stderr
591
+ }));
592
+ });
593
+ }
594
+ /** Spawn a pnpm command, capture stdout, throw on non-zero exit. */
595
+ async function capture(args, cwd = findRepoRoot()) {
596
+ const { stdout } = await runFile("pnpm", args, {
597
+ cwd,
598
+ encoding: "utf8"
599
+ });
600
+ return stdout;
601
+ }
602
+ /** `pnpm exec supabase …` with inherited stdio. */
603
+ const supabase = (...args) => run([
604
+ "exec",
605
+ "supabase",
606
+ ...args
607
+ ], void 0, supabaseRoot());
608
+ /** `pnpm exec supabase …` with captured stdout. */
609
+ const supabaseCapture = (...args) => capture([
610
+ "exec",
611
+ "supabase",
612
+ ...args
613
+ ], supabaseRoot());
614
+ /**
615
+ * `supabase db push` over `dbUrl`, inherited stdio, resolving to the exit code.
616
+ *
617
+ * The one spelling of the migration push, shared by the contributor `db
618
+ * migrate` path (`stack.ts`'s `pushMigrations`) and CI's `deploy migrate`.
619
+ * `--yes` is the unattended apply CI wants; the contributor path omits it so a
620
+ * human still confirms. `includeSeed` adds the `[db.seed]` files, which the
621
+ * CLI runs once each and records in `supabase_migrations.seed_files`. Neither variant regenerates types — that is
622
+ * `pushMigrations`' own second step, layered on top only where a checkout is
623
+ * meant to be rewritten. A CI apply must NOT write back into the repo, which is
624
+ * exactly why the bare push is factored out here rather than reused whole.
625
+ */
626
+ function dbPush(dbUrl, opts = {}) {
627
+ const args = [
628
+ "db",
629
+ "push",
630
+ "--db-url",
631
+ dbUrl
632
+ ];
633
+ if (opts.includeSeed) args.push("--include-seed");
634
+ if (opts.yes) args.push("--yes");
635
+ return supabase(...args);
636
+ }
637
+ /** Seed storage buckets on the local stack or a resolved tier's project. */
638
+ async function seedBuckets(conn) {
639
+ if (conn.kind === "local") return supabase("seed", "buckets", "--local", "--yes");
640
+ if (!conn.projectRef) {
641
+ process.stderr.write("devtools db: seed buckets against a hosted project needs PROJECT_REF in the session's env file.\n");
642
+ return 1;
643
+ }
644
+ return supabase("seed", "buckets", "--project-ref", conn.projectRef, "--linked");
645
+ }
646
+ //#endregion
647
+ //#region ../cli-core/src/repo/supabase-project.ts
648
+ /**
649
+ * This repo's Supabase project id — read from `supabase/config.toml` — and
650
+ * the container-name conventions the Supabase CLI derives from it.
651
+ *
652
+ * Shared by `environment.ts`'s adaptive-menu probe (does a stack answer for
653
+ * THIS project?) and the foreign-stack detection in `db/generated-env.ts`
654
+ * and `stack.ts` (does a DIFFERENT project's stack hold the shared ports? —
655
+ * the case where `supabase status -o env` fails not because nothing is
656
+ * running, but because what is running on 127.0.0.1:54321 belongs to
657
+ * another workspace, or to this same repo before/after a `project_id`
658
+ * rename). One project-id reader and one container-name parser, so both
659
+ * questions agree on what "this project" means.
660
+ */
661
+ /**
662
+ * Reads `project_id` out of `<repoRoot>/supabase/config.toml`, or `null`
663
+ * when the file is missing or unparsable.
664
+ *
665
+ * Regex, not a TOML parser: it is one line, the shape has been stable
666
+ * across every CLI version this repo has seen, and a parser on a path some
667
+ * callers pay before their first real subprocess is a cost that stays
668
+ * invisible until it is why the tool feels slow.
669
+ */
670
+ function readProjectId(repoRoot) {
671
+ try {
672
+ const path = join(repoRoot, "supabase", "config.toml");
673
+ return /^\s*project_id\s*=\s*"([^"]+)"/m.exec(readFileSync(path, "utf8"))?.[1] ?? null;
674
+ } catch {
675
+ return null;
676
+ }
677
+ }
678
+ /**
679
+ * The Supabase CLI's container name prefix for a project id, or the bare
680
+ * `supabase_db_` fallback when the id could not be read.
681
+ *
682
+ * Falling back to the bare prefix means a machine whose `config.toml`
683
+ * cannot be read still detects *a* stack, at the cost of over-matching a
684
+ * differently-named project — deliberate, and unchanged from this
685
+ * function's original home in `environment.ts`: over-matching offers
686
+ * `stop`/a foreign-stack hint for a stack that turns out to be this
687
+ * project's own, which is harmless to see and decline, while
688
+ * under-matching hides a real signal entirely.
689
+ */
690
+ function containerPrefix(projectId) {
691
+ return projectId ? `supabase_db_${projectId}` : "supabase_db_";
692
+ }
693
+ /**
694
+ * The local stack's API port, published by its Kong container. Mirrors
695
+ * `env-runtime/load`'s `LOCAL_STACK_PORT`, which this module cannot import
696
+ * statically (that package is an optional peer, loaded on demand).
697
+ */
698
+ const STACK_API_PORT = 54321;
699
+ function defaultExec(file, args) {
700
+ try {
701
+ return execFileSync(file, args, {
702
+ encoding: "utf8",
703
+ stdio: [
704
+ "ignore",
705
+ "pipe",
706
+ "ignore"
707
+ ],
708
+ timeout: 3e3
709
+ });
710
+ } catch {
711
+ return null;
712
+ }
713
+ }
714
+ /**
715
+ * `docker ps --format '{{.Names}}'`, split into trimmed, non-empty names —
716
+ * or `null` when Docker could not be read at all (no daemon, not
717
+ * installed, timed out). With `publish`, only the containers publishing that
718
+ * host port. `execute` is injectable for tests, mirroring `environment.ts`'s
719
+ * own probes.
720
+ */
721
+ function listContainerNames(publish, execute = defaultExec) {
722
+ const out = execute("docker", [
723
+ "ps",
724
+ ...publish === void 0 ? [] : ["--filter", `publish=${publish}`],
725
+ "--format",
726
+ "{{.Names}}"
727
+ ]);
728
+ if (out === null) return null;
729
+ return out.split("\n").map((name) => name.trim()).filter((name) => name.length > 0);
730
+ }
731
+ /**
732
+ * Finds the `supabase_kong_<id>` container among `names` whose id differs
733
+ * from `projectId` — the signature of a stack started from a DIFFERENT
734
+ * Supabase project (this same repo before/after a rename, or another
735
+ * project entirely) holding the shared ports. Kong is the container that
736
+ * publishes the API port, so callers pass the names `listContainerNames`
737
+ * returns for `STACK_API_PORT`: that names the stack actually on 54321,
738
+ * not just any Supabase stack on the machine.
739
+ *
740
+ * Returns that foreign id, or `null` when every matching container belongs
741
+ * to this project, none match at all, or `projectId` itself could not be
742
+ * read — with no known id of our own, there is nothing to compare against,
743
+ * and guessing would risk telling a contributor their OWN stack is
744
+ * foreign.
745
+ */
746
+ function foreignStackProjectId(names, projectId) {
747
+ if (projectId === null) return null;
748
+ for (const name of names) {
749
+ const match = /^supabase_kong_(.+)$/.exec(name);
750
+ if (!match) continue;
751
+ const id = match[1];
752
+ if (id !== projectId) return id;
753
+ }
754
+ return null;
755
+ }
756
+ /**
757
+ * The actionable message for a detected foreign stack — shared by
758
+ * `db/generated-env.ts` (a failed `supabase status -o env`) and `stack.ts`
759
+ * (a failed `supabase start`), so both name the same fix in the same
760
+ * words.
761
+ */
762
+ function foreignStackMessage(foreignProjectId) {
763
+ return `The stack on port ${STACK_API_PORT} belongs to project "${foreignProjectId}", not this checkout's. Stop it with \`supabase stop --project-id ${foreignProjectId}\` then \`pnpm devtools supabase start\`.`;
764
+ }
765
+ //#endregion
766
+ export { dryRunKind as A, layoutAt as C, formatCommand as D, resolveLayout as E, resolveDryRun as M, setDryRun as N, reportRan as O, wouldRunLine as P, findApp as S, resolveAppPath as T, loadEnvLoad as _, listContainerNames as a, appWorkspaceRoot as b, reportRuns as c, seedBuckets as d, supabase as f, loadEnv as g, getEnvSync as h, foreignStackProjectId as i, isDryRun as j, runInGroup as k, run as l, supabaseRoot as m, containerPrefix as n, readProjectId as o, supabaseCapture as p, foreignStackMessage as r, dbPush as s, STACK_API_PORT as t, runWithStderr as u, loadEnvSession as v, listApps as w, devdogsugaRootOrNull as x, repoPeerUrl as y };