@devdogsuga/devtools 0.1.49 → 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.
@@ -1,21 +1,19 @@
1
- import { a as cliName, d as positionals, u as flagValue } from "./failure-log-DOt6E3hS.js";
2
- import { C as helpPath, S as catalog, T as renderHelp, _ as bail, a as recordEnteredTier, b as explainError, c as describeEnvironment, d as resolveDbConnection, f as sessionIsLocal, i as beginInvocation, l as probeEnvironment, n as bareGroupStartPath, o as recordResolved, p as sessionLabel, r as runMenu, s as reproducibleCommand, t as bareGroupStartFlags, u as nonEmpty, v as errorMessage, w as renderCommandList, x as unwrap, y as explain } from "./menu-DYQ-Dd-O.js";
3
- import { S as wouldRunLine, _ as runInGroup, c as reportRuns, f as supabase, g as reportRan, l as run$1, m as supabaseRoot, s as dbPush, u as runWithStderr, v as dryRunKind, y as isDryRun } from "./supabase-project-C93xRFkN.js";
4
- import { a as ownVersion, n as discoverRepoRoot, r as findRepoRoot } from "./root-CjPWNkBu.js";
5
- import { c as devdogsugaRootOrNull, d as listApps, f as resolveAppPath, i as loadEnvSession, l as findApp, n as loadEnv, p as resolveLayout, r as loadEnvLoad, s as appWorkspaceRoot, t as getEnvSync, u as layoutAt } from "./peers-DvUH5VxX.js";
6
- import { d as hasYes, f as isNonInteractive, n as captureDevtoolsError, o as initDevtoolsTelemetry, r as devtoolsEnvironment, t as captureDevtoolsDeprecation } from "./telemetry-C07-ioHm.js";
7
- import { n as assertRegistryLoaded, r as loadRegistry, t as scopedProcessEnv } from "./local-env-BuHU5Ckd.js";
8
- import { t as runStackCommand } from "./stack-C_50FafG.js";
9
- import { cancel, confirm, intro, isCancel, log, multiselect, note, outro, password, select, spinner, text } from "@clack/prompts";
10
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
1
+ import { c as ownPackageDir, g as positionals, h as flagValue, l as ownVersion, n as discoverRepoRoot, r as findRepoRoot, u as cliName } from "./root-CsocfxnK.js";
2
+ import { A as renderCommandList, C as currentProduct, E as isProductMode, O as catalog, S as ProductConfigError, T as activeCatalog, _ as bail, a as recordEnteredTier, b as explainError, c as describeEnvironment, d as resolveDbConnection, f as sessionIsLocal, i as beginInvocation, j as renderHelp, k as helpPath, l as probeEnvironment, n as bareGroupStartPath, o as recordResolved, p as sessionLabel, r as runMenu, s as reproducibleCommand, t as bareGroupStartFlags, u as nonEmpty, v as errorMessage, x as unwrap, y as explain } from "./menu-CJC7OElU.js";
3
+ import { A as dryRunKind, C as layoutAt, E as resolveLayout, O as reportRan, P as wouldRunLine, S as findApp, T as resolveAppPath, _ as loadEnvLoad, b as appWorkspaceRoot, c as reportRuns, f as supabase, g as loadEnv, h as getEnvSync, j as isDryRun, k as runInGroup, l as run$1, m as supabaseRoot, s as dbPush, u as runWithStderr, v as loadEnvSession, w as listApps, x as devdogsugaRootOrNull, y as repoPeerUrl } from "./supabase-project-BoJKMCxL.js";
4
+ import { d as hasYes, f as isNonInteractive, n as captureDevtoolsError, o as initDevtoolsTelemetry } from "./telemetry-BKFDtzXI.js";
5
+ import { t as runStackCommand } from "./stack-7Lbym6Wq.js";
6
+ import { register } from "node:module";
7
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
8
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
9
+ import { z } from "zod";
10
+ import { confirm, intro, log, multiselect, note, outro, password, select, spinner, text } from "@clack/prompts";
11
11
  import { chmod, readFile, writeFile } from "node:fs/promises";
12
- import { basename, dirname, join, resolve } from "node:path";
13
- import { execFile, execFileSync, spawn } from "node:child_process";
12
+ import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
14
13
  import { pathToFileURL } from "node:url";
15
14
  import { parse } from "dotenv";
16
15
  import postgres from "postgres";
17
16
  import { flattenDiagnosticMessageText, parseConfigFileTextToJson } from "typescript";
18
- import { z } from "zod";
19
17
  import { createHash, randomBytes, randomUUID } from "node:crypto";
20
18
  import { createServer } from "node:net";
21
19
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
@@ -33,6 +31,296 @@ import { createServer as createServer$1 } from "node:http";
33
31
  /** The `outro()` line for a command that finished. */
34
32
  const DONE = "Done.";
35
33
  //#endregion
34
+ //#region ../cli-core/src/repo/peer-redirect.ts
35
+ /**
36
+ * Points one module's bare import of a peer at the copy the target repo
37
+ * resolved (`repo/peers.ts`).
38
+ *
39
+ * devtools' own `env.ts` manifest imports `@devdogsuga/sdk/env` by bare
40
+ * specifier, and from an installed copy that specifier resolves relative to
41
+ * the manifest's own location: inside the pnpm store, next to devtools,
42
+ * where an optional peer is never installed under `pnpm dlx`. The import
43
+ * failed with ERR_MODULE_NOT_FOUND, and every `env` command with it. Even
44
+ * where the specifier does resolve (devtools installed as a devDependency),
45
+ * the only copy that keeps the registry's module identity is the one the
46
+ * repo's manifests use, so the redirect is applied whenever that copy is
47
+ * known rather than only as a fallback.
48
+ *
49
+ * Node resolve hooks (`module.register`) rather than rewriting the file: the
50
+ * manifest also imports `zod`, which has to keep resolving from devtools'
51
+ * own location, and a copy written anywhere else would lose that.
52
+ */
53
+ /** `redirectPeer` for several at once: the hooks keep one table per
54
+ * registration, so everything that needs redirecting goes in one call. */
55
+ function redirectPeers(redirects) {
56
+ register("./peer-redirect-hooks.js", import.meta.url, { data: redirects });
57
+ }
58
+ //#endregion
59
+ //#region ../cli-core/src/repo/tsx-loader.ts
60
+ /**
61
+ * Loads the repo's own TypeScript at runtime: env manifests
62
+ * (each app's `src/env.ts` and friends), cron contracts
63
+ * (each app's `cloudflare/scheduled.ts`), and — via `source.ts` — the
64
+ * `devdogs-source` condition packages (`open-graph`, `email`, `docs`).
65
+ *
66
+ * Uses `tsx`'s programmatic API (`register()` from `tsx/esm/api`), NOT the
67
+ * `--conditions=devdogs-source` CLI flag the old in-repo devtools used —
68
+ * this package ships built JS and runs from `dist/`, so there is no `tsx`
69
+ * process wrapping it anymore (see the package README / CUTOVER note on
70
+ * "built JS, tsx only for repo TS").
71
+ *
72
+ * Gotcha (FINDINGS.md experiments 3 and 4, gotcha C): do NOT pass a
73
+ * `namespace` to `register()`. It silently breaks tsx's own relative
74
+ * `./foo.js` → `./foo.ts` extension-fallback resolution for files loaded
75
+ * under that registration — exactly the convention `open-graph`, `email`,
76
+ * and any future `devdogs-source` package use for their internal relative
77
+ * imports. `register({})` (no options) is the only form validated.
78
+ */
79
+ let registered = false;
80
+ /** Idempotent: registers tsx's ESM loader hooks once per process. */
81
+ async function ensureRegistered() {
82
+ if (registered) return;
83
+ const { register } = await import("tsx/esm/api");
84
+ register();
85
+ registered = true;
86
+ }
87
+ /**
88
+ * Imports a repo TypeScript file at an absolute path, loaded through tsx.
89
+ * Returns the module namespace, typed as `T` by the caller (there is no way
90
+ * to know the shape of an arbitrary repo file's exports statically here).
91
+ */
92
+ async function importRepoTs(absolutePath) {
93
+ await ensureRegistered();
94
+ return import(pathToFileURL(absolutePath).href);
95
+ }
96
+ //#endregion
97
+ //#region ../cli-core/src/env/discovery.ts
98
+ /**
99
+ * Fills the `@devdogsuga/sdk/env` registry by importing every manifest.
100
+ *
101
+ * The registry populates as a SIDE EFFECT of importing each manifest module.
102
+ * `declare()` runs at import time, so a process that never imports them sees an
103
+ * empty registry and every derived selector returns nothing. That is the
104
+ * fail-open shape this module closes: anything that routes secrets calls
105
+ * `loadRegistry()` first, and `assertRegistryLoaded()` is the guard the
106
+ * selectors' consumers use to refuse an empty answer.
107
+ *
108
+ * Discovery is by filename, per the model doc: `src/env.ts`, then `env.ts`, per
109
+ * workspace package. Both present is a hard error, because two manifests in one
110
+ * package means two places for the next declaration to land and no rule saying
111
+ * which. No `package.json` pointer: every other tool in this repo is found by
112
+ * filename (`tsconfig.json`, `wrangler.jsonc`, `supadart.yaml`), and a pointer
113
+ * would be the only one of its kind.
114
+ *
115
+ * From a Backstage checkout the scan covers BOTH repos (`repo/layout.ts`):
116
+ * Backstage's `apps/*` and `packages/*`, then DevDogsUGA's through
117
+ * `devdogsuga/`, de-duplicated by directory name with Backstage first.
118
+ *
119
+ * One special case: `supabase/env.ts` sits at DevDogsUGA's root, OUTSIDE the
120
+ * workspace globs. Its variables belong to `config.toml` and the Supabase
121
+ * CLI, not to any package, so it lives next to the config that reads them.
122
+ *
123
+ * `@devdogsuga/sdk/env` itself (the package that exports `define()`/`declare()`)
124
+ * no longer needs a special-case exclusion here: since the Backstage
125
+ * cutover it is an installed npm dependency, not a workspace package under
126
+ * `packages/`, so this scan never sees it at all.
127
+ *
128
+ * devtools' OWN operator manifest (this package's root `env.ts` — the keys
129
+ * no app reads at all: `BWS_ACCESS_TOKEN`, `CLOUDFLARE_API_TOKEN`, and
130
+ * friends) has the same problem in reverse: since the Backstage cutover
131
+ * devtools is no longer one of the TARGET repo's workspace packages either,
132
+ * so the scan below would never see it. It is loaded unconditionally,
133
+ * always, regardless of which repo devtools is running against — see
134
+ * `ownManifestPath()`'s own comment for how it stays resolvable both from a
135
+ * workspace checkout and from an installed `pnpm dlx`/`node_modules` copy,
136
+ * and for why loading it through the same `importRepoTs()` tsx path as
137
+ * every other manifest (rather than a plain static `import`) is what keeps
138
+ * its `declare()`/`define()` calls landing in the SAME `@devdogsuga/sdk/env`
139
+ * registry instance the target repo's manifests populate (module identity —
140
+ * see `repo/peers.ts`'s header and the devtools-dlx prototype's
141
+ * FINDINGS.md experiment 3). Its bare `@devdogsuga/sdk/env` import is
142
+ * redirected to the copy `loadEnv()` resolved through the repo, because
143
+ * from a `pnpm dlx` copy the specifier does not resolve at all: devtools
144
+ * ships no copy of its own (`@devdogsuga/sdk/env` is only ever an optional
145
+ * peer), and nothing installs one next to it. See `repo/peer-redirect.ts`.
146
+ *
147
+ * Most workspace packages declare nothing, so "no env.ts found" means
148
+ * not-a-manifest rather than an error.
149
+ *
150
+ * ⚠️ A Worker still needs a manifest, even though its bindings arrive as a
151
+ * function argument rather than `process.env`: a manifest is the only thing
152
+ * that routes a credential to a deployed environment and the only thing that
153
+ * tells `env audit` a Worker secret is supposed to be there. Without one, a
154
+ * minted secret (in no Bitwarden project by design) is reported as an orphan,
155
+ * i.e. as safe for the §3.6 prune path to delete.
156
+ */
157
+ /**
158
+ * The single in-flight (or settled) load.
159
+ *
160
+ * Node's module cache already makes a second `import()` of the same manifest a
161
+ * no-op, but memoizing the whole pass is asserted here rather than inherited: a
162
+ * future bundler or test runner that re-evaluates modules would duplicate every
163
+ * declaration, and duplicated declarations are what the completeness test
164
+ * treats as a bug. A failed load stays failed. Every later call gets the same
165
+ * rejection, because retrying into a half-populated registry would hide the
166
+ * manifest that broke.
167
+ */
168
+ let loaded;
169
+ /**
170
+ * Imports every manifest so the registry populates. Idempotent; concurrent
171
+ * callers share one pass. Await this before touching any derived selector.
172
+ */
173
+ function loadRegistry() {
174
+ loaded ??= importManifests();
175
+ return loaded;
176
+ }
177
+ /**
178
+ * Refuses to proceed on an empty registry.
179
+ *
180
+ * For SYNC consumers of the derived selectors (`selectForPush`, `routeTo`),
181
+ * which cannot await the load themselves. An empty registry makes every
182
+ * selector return `[]`, and `[]` fails open in the worst direction: nothing is
183
+ * never-store, so `BWS_ACCESS_TOKEN` would upload. Better a crash naming the
184
+ * missing call than a push that quietly stores the unstorable.
185
+ */
186
+ function assertRegistryLoaded() {
187
+ if (getEnvSync().variables().size === 0) throw new Error("The env registry is empty — no manifest has been imported, so the derived key sets would all be []. Await loadRegistry() (from devtools' env/discovery.ts) before routing or selecting secrets.");
188
+ }
189
+ async function importManifests() {
190
+ await loadEnv();
191
+ const redirects = [];
192
+ const envUrl = repoPeerUrl("@devdogsuga/sdk/env");
193
+ if (envUrl) {
194
+ redirects.push({
195
+ parentURL: pathToFileURL(ownManifestPath()).href,
196
+ specifier: "@devdogsuga/sdk/env",
197
+ url: envUrl
198
+ });
199
+ redirects.push(...siblingRedirects(envUrl));
200
+ }
201
+ if (redirects.length > 0) redirectPeers(redirects);
202
+ const previous = process.env.SKIP_ENV_VALIDATION;
203
+ process.env.SKIP_ENV_VALIDATION = "1";
204
+ try {
205
+ for (const path of manifestPaths()) try {
206
+ await importRepoTs(path);
207
+ } catch (cause) {
208
+ throw new Error(`The env manifest at ${path} failed to import.`, { cause });
209
+ }
210
+ } finally {
211
+ if (previous === void 0) delete process.env.SKIP_ENV_VALIDATION;
212
+ else process.env.SKIP_ENV_VALIDATION = previous;
213
+ }
214
+ }
215
+ /**
216
+ * From a Backstage checkout, the manifests under `devdogsuga/` import
217
+ * `@devdogsuga/sdk/env` through THAT checkout's `node_modules` (or not at all, when
218
+ * it has none): a second copy of the package, and so a second registry that
219
+ * every `declare()` there would populate instead of the one this process
220
+ * reads. Point their imports at the copy `loadEnv()` resolved. Resolved
221
+ * through `realpath`, because the loader sees the module's real location, not
222
+ * the `devdogsuga` symlink.
223
+ */
224
+ function siblingRedirects(envUrl) {
225
+ const layout = resolveLayout();
226
+ if (layout.kind !== "backstage" || !layout.hasDevdogsuga) return [];
227
+ const redirects = [];
228
+ for (const path of manifestPaths()) {
229
+ if (!path.startsWith(layout.devdogsugaRoot)) continue;
230
+ const parentURL = pathToFileURL(realpathSync(path)).href;
231
+ redirects.push({
232
+ parentURL,
233
+ specifier: "@devdogsuga/sdk/env",
234
+ url: envUrl
235
+ });
236
+ }
237
+ return redirects;
238
+ }
239
+ /**
240
+ * Every manifest file, in a stable order.
241
+ *
242
+ * The workspace members are enumerated by scanning `apps/*` and `packages/*`
243
+ * plus the literal `docs`, a deliberately boring mirror of the three globs in
244
+ * `pnpm-workspace.yaml` rather than a YAML parse: the globs have not changed
245
+ * since the workspace existed, devtools carries no YAML dependency, and a new
246
+ * top-level glob would already mean editing more interesting files than this
247
+ * one.
248
+ */
249
+ function manifestPaths() {
250
+ const paths = [];
251
+ for (const dir of workspaceDirs()) {
252
+ const manifest = manifestIn(dir);
253
+ if (manifest) paths.push(manifest);
254
+ }
255
+ const layout = resolveLayout();
256
+ const supabase = layout.hasDevdogsuga ? manifestIn(join(layout.devdogsugaRoot, "supabase")) : null;
257
+ if (supabase) paths.push(supabase);
258
+ paths.push(ownManifestPath());
259
+ return paths;
260
+ }
261
+ /**
262
+ * The absolute path to devtools' own `env.ts`, resolved relative to THIS
263
+ * module's own file rather than the target repo — it ships alongside
264
+ * `dist/` (see `package.json`'s `files`), not inside the target repo's
265
+ * workspace, so `findRepoRoot()`/`workspaceDirs()` can never find it.
266
+ *
267
+ * The package root is `ownPackageDir()`: `env.ts` sits there, deliberately
268
+ * outside `src/` (see that file's own header, and `tsconfig.typecheck.json`,
269
+ * which includes it as a sibling of `src` for exactly that reason). Same file,
270
+ * same relative shape, in both a workspace checkout
271
+ * (`packages/devtools/env.ts`) and an installed copy
272
+ * (`node_modules/@devdogsuga/devtools/env.ts`).
273
+ */
274
+ function ownManifestPath() {
275
+ return join(ownPackageDir(), "env.ts");
276
+ }
277
+ /**
278
+ * The CLIs' own packages: each loads its own operator manifest through
279
+ * `ownManifestPath()`, so scanning them from a Backstage checkout would
280
+ * declare the same keys twice.
281
+ */
282
+ const CLI_PACKAGES = /* @__PURE__ */ new Set(["devtools", "backstage"]);
283
+ /**
284
+ * `apps/*` and `packages/*` of every repo in the layout (the repo you are in
285
+ * first, so it wins a name clash: before the cutover both repos carry
286
+ * `apps/platform` and `packages/email`), then DevDogsUGA's `docs`.
287
+ */
288
+ function workspaceDirs() {
289
+ const layout = resolveLayout();
290
+ const roots = [layout.root];
291
+ if (layout.kind === "backstage" && layout.hasDevdogsuga) roots.push(layout.devdogsugaRoot);
292
+ const dirs = [];
293
+ for (const parent of ["apps", "packages"]) {
294
+ const found = /* @__PURE__ */ new Map();
295
+ for (const root of roots) {
296
+ let entries;
297
+ try {
298
+ entries = readdirSync(join(root, parent), { withFileTypes: true });
299
+ } catch {
300
+ continue;
301
+ }
302
+ for (const entry of entries) {
303
+ if (!entry.isDirectory()) continue;
304
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
305
+ if (layout.kind === "backstage" && parent === "packages" && CLI_PACKAGES.has(entry.name)) continue;
306
+ if (!found.has(entry.name)) found.set(entry.name, join(root, parent, entry.name));
307
+ }
308
+ }
309
+ for (const name of [...found.keys()].sort()) dirs.push(found.get(name));
310
+ }
311
+ if (layout.hasDevdogsuga) dirs.push(join(layout.devdogsugaRoot, "docs"));
312
+ return dirs;
313
+ }
314
+ /** `src/env.ts`, then `env.ts`; both present in one package is a hard error. */
315
+ function manifestIn(dir) {
316
+ const inSrc = join(dir, "src", "env.ts");
317
+ const atRoot = join(dir, "env.ts");
318
+ const hasSrc = existsSync(inSrc);
319
+ const hasRoot = existsSync(atRoot);
320
+ if (hasSrc && hasRoot) throw new Error(`${dir} has BOTH src/env.ts and env.ts. One package gets one manifest — two of them means two places for the next declaration to land, and no rule saying which. Merge them.`);
321
+ return hasSrc ? inSrc : hasRoot ? atRoot : null;
322
+ }
323
+ //#endregion
36
324
  //#region src/check/env.ts
37
325
  /**
38
326
  * `check env`: every env variable is declared, and the registry is
@@ -243,7 +531,7 @@ function checkMigrationOrder(root, baseRef) {
243
531
  `${baseRef}^{commit}`
244
532
  ]);
245
533
  } catch {
246
- throw new MigrationBaseError(`${baseRef} is not a commit in ${root}. Migrations live in DevDogsUGA, so the base is a ref of that checkout (a Backstage CI checkout has only the pinned commit): pass --base <ref>, e.g. the previous devdogsuga.lock SHA.`);
534
+ throw new MigrationBaseError(`${baseRef} is not a commit in ${root}. The base is a ref of this checkout (a shallow CI checkout may lack it): pass --base <ref>, e.g. the previous commit on main.`);
247
535
  }
248
536
  const baseFiles = basenames(git$1(root, [
249
537
  "ls-tree",
@@ -368,23 +656,28 @@ function expandWorkspacePackages(root, globs) {
368
656
  return packages;
369
657
  }
370
658
  /** The `matrix.include` blocks' `app:` values, in the `strategy: matrix:
371
- * include:` shape `deploy-app.yaml` uses. */
659
+ * include:` shape the deploy workflows use. An entry may carry other keys
660
+ * (`dir:`, `release:`, `mint:`), indented under its `- app:` line. */
372
661
  function deployMatrixApps(yamlText) {
373
662
  const lines = yamlText.split("\n");
374
663
  const blocks = [];
664
+ const indentOf = (line) => line.length - line.trimStart().length;
375
665
  for (let i = 0; i < lines.length; i += 1) {
376
666
  if (!/^\s*matrix:\s*$/.test(lines[i])) continue;
377
667
  let j = i + 1;
378
668
  while (j < lines.length && !/^\s*include:\s*$/.test(lines[j])) j += 1;
379
669
  const apps = [];
670
+ let entryIndent = null;
380
671
  for (let k = j + 1; k < lines.length; k += 1) {
381
672
  const line = lines[k];
382
673
  const appMatch = /^\s*- app:\s*(\S+)\s*$/.exec(line);
383
674
  if (appMatch?.[1]) {
384
675
  apps.push(appMatch[1]);
676
+ entryIndent ??= indentOf(line);
385
677
  continue;
386
678
  }
387
- if (/^\s*mint:/.test(line) || /^\s*#/.test(line) || /^\s*$/.test(line)) continue;
679
+ if (/^\s*#/.test(line) || /^\s*$/.test(line)) continue;
680
+ if (entryIndent !== null && indentOf(line) > entryIndent) continue;
388
681
  break;
389
682
  }
390
683
  if (apps.length > 0) blocks.push(apps);
@@ -392,11 +685,34 @@ function deployMatrixApps(yamlText) {
392
685
  return blocks;
393
686
  }
394
687
  /**
688
+ * The Workers `deploy.yaml` deploys, read from its calls of the shared
689
+ * `deploy-worker.yaml`: each caller job names its `tier` and `worker_dir`
690
+ * under `with:`. Returns the directories' basenames (the app slugs) per tier.
691
+ * A job that does not call deploy-worker.yaml is ignored.
692
+ */
693
+ function deployWorkerCalls(yamlText) {
694
+ const staging = [];
695
+ const production = [];
696
+ for (const job of yamlText.split(/^ {2}(?=[\w-]+:\s*$)/m)) {
697
+ if (!/uses:\s*\.\/\.github\/workflows\/deploy-worker\.yaml/.test(job)) continue;
698
+ const tier = /^ {6}tier:\s*(\w+)\s*$/m.exec(job)?.[1];
699
+ const dir = /^ {6}worker_dir:\s*(\S+)\s*$/m.exec(job)?.[1];
700
+ if (!tier || !dir) continue;
701
+ (tier === "production" ? production : staging).push(basename(dir));
702
+ }
703
+ return {
704
+ staging,
705
+ production
706
+ };
707
+ }
708
+ /**
395
709
  * Apps with a `wrangler.jsonc` that are deliberately NOT in `workers.json`:
396
- * `workers.json` is the managed set (deploy matrix, smoke tests, env registry,
397
- * secret orphan sweep), and these are deployed by hand and have none of that.
398
- * `apps/slides` is the hosted workshop deck, shipped with `pnpm run deploy`
399
- * from its own directory (its `wrangler.jsonc` says so).
710
+ * `workers.json` is the managed set (deploy matrix, env registry, secret orphan
711
+ * sweep), and these have none of that. `apps/slides` is the hosted workshop
712
+ * deck. It deploys through the same shared `deploy-worker.yaml` as the others
713
+ * (called from slides.yaml, behind the `production` approval), with its smoke
714
+ * test in `apps/slides/smoke.json` because it has no staging tier and no env
715
+ * manifest.
400
716
  */
401
717
  const HAND_DEPLOYED_APPS = ["apps/slides"];
402
718
  function sameSet(a, b) {
@@ -438,12 +754,15 @@ function checkWorkers(root, layout = layoutAt(root)) {
438
754
  const slug = path.split("/").pop();
439
755
  if (name !== slug) problems.push(`${path}/package.json is named "${name}"; the directory name, package name and app slug must agree ("${slug}").`);
440
756
  }
441
- const deployApp = join(root, ".github", "workflows", "deploy-app.yaml");
442
- if (!existsSync(deployApp)) {
443
- if (layout.kind === "backstage") return problems;
444
- problems.push(".github/workflows/deploy-app.yaml does not exist.");
445
- return problems;
757
+ const deployWorkflow = join(root, ".github", "workflows", "deploy.yaml");
758
+ if (existsSync(deployWorkflow)) {
759
+ const calls = deployWorkerCalls(readFileSync(deployWorkflow, "utf8"));
760
+ if (calls.staging.length === 0) problems.push("deploy.yaml has no staging Worker deploy.");
761
+ else if (!sameSet(calls.staging, apps)) problems.push(`deploy.yaml deploys [${calls.staging.join(", ")}] to staging; workers.json lists [${apps.join(", ")}].`);
762
+ if (calls.production.length > 0 && !sameSet(calls.production, apps)) problems.push(`deploy.yaml deploys [${calls.production.join(", ")}] to production; workers.json lists [${apps.join(", ")}].`);
446
763
  }
764
+ const deployApp = join(root, ".github", "workflows", "deploy-app.yaml");
765
+ if (!existsSync(deployApp)) return problems;
447
766
  const blocks = deployMatrixApps(readFileSync(deployApp, "utf8"));
448
767
  if (blocks.length !== 1) problems.push(`deploy-app.yaml should define its deploy matrix once; found ${blocks.length}.`);
449
768
  for (const block of blocks) if (!sameSet(block, apps)) problems.push(`deploy-app.yaml's matrix is [${block.join(", ")}]; workers.json lists [${apps.join(", ")}].`);
@@ -758,7 +1077,7 @@ function runCompletions(catalog, argv) {
758
1077
  //#endregion
759
1078
  //#region src/completions/commands.ts
760
1079
  const handleCompletions = async (rest) => {
761
- const code = runCompletions(catalog, rest);
1080
+ const code = runCompletions(activeCatalog(), rest);
762
1081
  process.exitCode = code;
763
1082
  return code === 0 ? DONE : null;
764
1083
  };
@@ -2303,7 +2622,7 @@ const handleEnv = async (rest) => {
2303
2622
  * The shared `--tier` resolver for the handful of commands that pick a
2304
2623
  * deploy tier of their OWN, separate from the session-wide tier
2305
2624
  * `src/launch.ts` already resolved and entered before this CLI ever runs
2306
- * (see that file's header, and `@devdogsuga/env/session`, which is where
2625
+ * (see that file's header, and `env-runtime/session`, which is where
2307
2626
  * `availableTiers` and the session-wide policy now live — this module used
2308
2627
  * to carry its own copy of both, one of two the wizard's tier question also
2309
2628
  * used to keep before it moved into `launch.ts`).
@@ -2655,7 +2974,7 @@ async function runCronRun(argv) {
2655
2974
  }
2656
2975
  return failed ? 1 : 0;
2657
2976
  }
2658
- function readEnvFile(path) {
2977
+ function readEnvFile$1(path) {
2659
2978
  return existsSync(path) ? parse(readFileSync(path, "utf8")) : {};
2660
2979
  }
2661
2980
  /** Resolve the selected app's origin without assuming every app uses port 3000. */
@@ -2663,8 +2982,8 @@ function resolveBaseUrl(app, tier, config, tierEnv) {
2663
2982
  if (tier === "development" && app) {
2664
2983
  const appRoot = findApp(app) ?? join(findRepoRoot(), "apps", app);
2665
2984
  const local = {
2666
- ...readEnvFile(join(appRoot, ".dev.vars.example")),
2667
- ...readEnvFile(join(appRoot, ".dev.vars"))
2985
+ ...readEnvFile$1(join(appRoot, ".dev.vars.example")),
2986
+ ...readEnvFile$1(join(appRoot, ".dev.vars"))
2668
2987
  };
2669
2988
  if (local["BASE_URL"]) return local["BASE_URL"];
2670
2989
  }
@@ -2692,6 +3011,60 @@ function parseCronRunOptions(argv) {
2692
3011
  return opts;
2693
3012
  }
2694
3013
  //#endregion
3014
+ //#region src/cf/local-env.ts
3015
+ /** The app's declared keys, deduped and sorted, from the given entries or the
3016
+ * loaded registry.
3017
+ *
3018
+ * `DEPLOY_ENV` and `NODE_ENV` are excluded BY NAME, not by any registry-meta
3019
+ * heuristic. Their committed source is wrangler.jsonc's per-env `vars` blocks
3020
+ * (and the framework), never an env file — see each key's `define()` doc.
3021
+ * Materializing them into the temp `.dev.vars` this file feeds to `wrangler
3022
+ * dev` lets a stray or empty `.env` value OVERRIDE the tier's wrangler var,
3023
+ * which surfaced as a Workflow isolate reading a blank `DEPLOY_ENV` and
3024
+ * throwing "has no HYPERDRIVE binding".
3025
+ *
3026
+ * A `scope: "default"` + `commented: true` filter was tried first and is
3027
+ * WRONG: that shape also matches `GITHUB_COMPETITION_REPO`, an opt-in
3028
+ * override a contributor sets deliberately in their `.env` (its wrangler
3029
+ * config defines no such var), and the heuristic silently stripped it from
3030
+ * `wrangler dev`'s environment. Wrangler-ownership is a fact about
3031
+ * wrangler.jsonc, not about registry metadata, so the two keys it is true
3032
+ * for are named outright. */
3033
+ const WRANGLER_OWNED_KEYS = /* @__PURE__ */ new Set(["DEPLOY_ENV", "NODE_ENV"]);
3034
+ async function scopedKeys(app, entries) {
3035
+ if (!entries) {
3036
+ await loadRegistry();
3037
+ entries = (await loadEnv()).declarations();
3038
+ }
3039
+ return [...new Set(entries.filter((entry) => entry.source === app).filter((entry) => !WRANGLER_OWNED_KEYS.has(entry.key)).map((entry) => entry.key))].sort();
3040
+ }
3041
+ /**
3042
+ * The process env for a `vinext dev` child, scoped the way the temp
3043
+ * `.dev.vars` is for `wrangler dev`.
3044
+ *
3045
+ * `@cloudflare/vite-plugin` has no `--env-file`: it reads the app dir's own
3046
+ * `.dev.vars`/`.env`, or — with `CLOUDFLARE_INCLUDE_PROCESS_ENV` — its whole
3047
+ * `process.env`, as Worker vars that override wrangler.jsonc's `vars`. So the
3048
+ * scoping happens on the child's env instead: every key some OTHER app
3049
+ * declares (and this app doesn't) is dropped, `NODE_ENV` is left to Vite, and
3050
+ * `DEPLOY_ENV` is pinned to the tier rather than dropped, because the Vite
3051
+ * process's own `next.config.ts` reads it too. Undeclared keys (PATH, HOME,
3052
+ * pnpm's own) stay; the child needs them and they carry no app secrets.
3053
+ */
3054
+ async function scopedProcessEnv(app, environment, tier, entries) {
3055
+ if (!entries) {
3056
+ await loadRegistry();
3057
+ entries = (await loadEnv()).declarations();
3058
+ }
3059
+ const own = new Set(await scopedKeys(app, entries));
3060
+ const scoped = { ...environment };
3061
+ for (const entry of entries) if (entry.source !== app && !own.has(entry.key)) delete scoped[entry.key];
3062
+ delete scoped.NODE_ENV;
3063
+ scoped.DEPLOY_ENV = tier;
3064
+ scoped.CLOUDFLARE_INCLUDE_PROCESS_ENV = "true";
3065
+ return scoped;
3066
+ }
3067
+ //#endregion
2695
3068
  //#region src/cf/build.ts
2696
3069
  /**
2697
3070
  * The build a Worker app needs before `wrangler dev` can boot it locally.
@@ -3581,6 +3954,350 @@ const handleJobs = async (rest) => {
3581
3954
  process.exitCode = code;
3582
3955
  return code === 0 ? DONE : null;
3583
3956
  };
3957
+ const WEBHOOK_ID = "webhook-id";
3958
+ const WEBHOOK_TIMESTAMP = "webhook-timestamp";
3959
+ const WEBHOOK_SIGNATURE = "webhook-signature";
3960
+ const encoder = new TextEncoder();
3961
+ function toBase64(bytes) {
3962
+ let binary = "";
3963
+ for (const byte of bytes) binary += String.fromCharCode(byte);
3964
+ return btoa(binary);
3965
+ }
3966
+ function fromBase64(value) {
3967
+ try {
3968
+ const binary = atob(value);
3969
+ const bytes = new Uint8Array(binary.length);
3970
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
3971
+ return bytes;
3972
+ } catch {
3973
+ return null;
3974
+ }
3975
+ }
3976
+ /** The raw key bytes of a `whsec_` secret (the prefix is optional). */
3977
+ function secretBytes(secret) {
3978
+ const bytes = fromBase64(secret.startsWith("whsec_") ? secret.slice(6) : secret);
3979
+ if (!bytes || bytes.length === 0) throw new Error("The signing secret is not a base64 value");
3980
+ return bytes;
3981
+ }
3982
+ async function hmacKey(secret, usage) {
3983
+ return crypto.subtle.importKey("raw", secretBytes(secret), {
3984
+ name: "HMAC",
3985
+ hash: "SHA-256"
3986
+ }, false, [usage]);
3987
+ }
3988
+ function signedContent(id, timestamp, body) {
3989
+ return `${id}.${timestamp}.${body}`;
3990
+ }
3991
+ /** `v1,<base64>` for one message. */
3992
+ async function computeSignature(secret, id, timestamp, body) {
3993
+ const key = await hmacKey(secret, "sign");
3994
+ const mac = await crypto.subtle.sign("HMAC", key, encoder.encode(signedContent(id, String(timestamp), body)));
3995
+ return `v1,${toBase64(new Uint8Array(mac))}`;
3996
+ }
3997
+ /** The three headers that authenticate `body`. */
3998
+ async function signMessage(secret, body, options = {}) {
3999
+ const id = options.id ?? `msg_${crypto.randomUUID()}`;
4000
+ const timestamp = options.timestamp ?? Math.floor(Date.now() / 1e3);
4001
+ return {
4002
+ [WEBHOOK_ID]: id,
4003
+ [WEBHOOK_TIMESTAMP]: String(timestamp),
4004
+ [WEBHOOK_SIGNATURE]: await computeSignature(secret, id, timestamp, body)
4005
+ };
4006
+ }
4007
+ const reportReasonSchema = z.enum([
4008
+ "harassment",
4009
+ "hate_speech",
4010
+ "spam",
4011
+ "sexual_content",
4012
+ "violence",
4013
+ "impersonation",
4014
+ "off_topic",
4015
+ "other"
4016
+ ]);
4017
+ /**
4018
+ * A content type is the app's own name for a kind of thing a member can write
4019
+ * (`session`, `profile`). Registered per app on the platform; a report naming
4020
+ * an unregistered one is refused.
4021
+ */
4022
+ const contentTypeSchema = z.string().regex(/^[a-z][a-z0-9_]{0,62}$/, "lowercase letters, digits and underscores");
4023
+ z.object({
4024
+ /** The platform user id of the member filing the report. */
4025
+ reporterUserId: z.uuid(),
4026
+ /** The platform user id of the member who wrote the content. */
4027
+ authorUserId: z.uuid(),
4028
+ contentType: contentTypeSchema,
4029
+ /** The app's own id for the row; opaque to the platform. */
4030
+ contentRef: z.string().min(1).max(256),
4031
+ /** What the content said when it was reported. Frozen on the report. */
4032
+ contentSnapshot: z.string().min(1).max(5e3),
4033
+ /** A link a moderator can follow to the live content. https only. */
4034
+ contentUrl: z.url({ protocol: /^https$/ }).max(2e3).nullish(),
4035
+ reason: reportReasonSchema,
4036
+ description: z.string().max(1e3).nullish()
4037
+ });
4038
+ z.object({
4039
+ reportId: z.uuid(),
4040
+ /** True when an open report already existed and this one was added to it. */
4041
+ corroborated: z.boolean(),
4042
+ /** True when the client is a development client: only its owner sees it. */
4043
+ sandbox: z.boolean()
4044
+ });
4045
+ z.object({ error: z.object({
4046
+ code: z.enum([
4047
+ "invalid_json",
4048
+ "invalid_request",
4049
+ "missing_client",
4050
+ "unknown_client",
4051
+ "client_not_registered",
4052
+ "bad_signature",
4053
+ "unknown_content_type",
4054
+ "unknown_user",
4055
+ "reporter_not_allowed",
4056
+ "rate_limited",
4057
+ "not_configured"
4058
+ ]),
4059
+ message: z.string()
4060
+ }) });
4061
+ const CONTENT_MESSAGE_TYPES = [
4062
+ "content.hide",
4063
+ "content.remove",
4064
+ "content.restore"
4065
+ ];
4066
+ const USER_MESSAGE_TYPES = [
4067
+ "user.suspend",
4068
+ "user.ban",
4069
+ "user.restore"
4070
+ ];
4071
+ const MESSAGE_TYPES = [...CONTENT_MESSAGE_TYPES, ...USER_MESSAGE_TYPES];
4072
+ const contentMessageSchema = z.object({
4073
+ type: z.enum(CONTENT_MESSAGE_TYPES),
4074
+ /** When the moderator decided, ISO 8601. The ordering key. */
4075
+ timestamp: z.iso.datetime(),
4076
+ data: z.object({
4077
+ contentType: contentTypeSchema,
4078
+ contentRef: z.string().min(1).max(256),
4079
+ reportId: z.uuid().nullable()
4080
+ })
4081
+ });
4082
+ const userMessageSchema = z.object({
4083
+ type: z.enum(USER_MESSAGE_TYPES),
4084
+ timestamp: z.iso.datetime(),
4085
+ data: z.object({
4086
+ /** The platform user id. Maps to the app's user through the identity. */
4087
+ userId: z.uuid(),
4088
+ reason: z.string().max(1e3).nullable(),
4089
+ reportId: z.uuid().nullable()
4090
+ })
4091
+ });
4092
+ z.union([contentMessageSchema, userMessageSchema]);
4093
+ const SAMPLE_CONTENT = {
4094
+ contentType: "session",
4095
+ contentRef: "00000000-0000-4000-8000-0000000000a1"
4096
+ };
4097
+ /** A message of any type, with sample targets unless told otherwise. */
4098
+ function buildMessage(input) {
4099
+ const timestamp = input.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
4100
+ const reportId = input.reportId ?? null;
4101
+ if (input.type.startsWith("content.")) return {
4102
+ type: input.type,
4103
+ timestamp,
4104
+ data: {
4105
+ contentType: input.contentType ?? SAMPLE_CONTENT.contentType,
4106
+ contentRef: input.contentRef ?? SAMPLE_CONTENT.contentRef,
4107
+ reportId
4108
+ }
4109
+ };
4110
+ return {
4111
+ type: input.type,
4112
+ timestamp,
4113
+ data: {
4114
+ userId: input.userId ?? "00000000-0000-4000-8000-0000000000b1",
4115
+ reason: input.reason ?? null,
4116
+ reportId
4117
+ }
4118
+ };
4119
+ }
4120
+ /**
4121
+ * The conformance sequence. Send it in order to a fresh receiver:
4122
+ *
4123
+ * 1. hide a session: applied
4124
+ * 2. the same message again: duplicate
4125
+ * 3. a restore dated before the hide: stale
4126
+ * 4. a restore dated after it: applied
4127
+ * 5. suspend a user: applied
4128
+ * 6. a user restore dated before the suspension: stale
4129
+ *
4130
+ * Every message is signed for "now", so it is inside the five-minute window
4131
+ * however old its decision timestamp is.
4132
+ */
4133
+ async function signedSamples(options = {}) {
4134
+ const secret = options.secret ?? "whsec_c2FtcGxlLW1vZGVyYXRpb24tc2VjcmV0LW5vdC1yZWFs";
4135
+ const now = options.now ?? Date.now();
4136
+ const at = (offsetSeconds) => new Date(now + offsetSeconds * 1e3).toISOString();
4137
+ const signedAt = Math.floor(now / 1e3);
4138
+ const run = options.runId ?? Math.random().toString(36).slice(2, 10);
4139
+ const messageId = (n) => `msg_sample_${run}_${n}`;
4140
+ const contentRef = options.contentRef ?? crypto.randomUUID();
4141
+ async function sample(name, id, expected, message) {
4142
+ const body = JSON.stringify(message);
4143
+ return {
4144
+ name,
4145
+ expected,
4146
+ body,
4147
+ headers: await signMessage(secret, body, {
4148
+ id,
4149
+ timestamp: signedAt
4150
+ })
4151
+ };
4152
+ }
4153
+ const hide = buildMessage({
4154
+ type: "content.hide",
4155
+ timestamp: at(0),
4156
+ contentRef
4157
+ });
4158
+ return [
4159
+ await sample("hide a session", messageId(1), "applied", hide),
4160
+ await sample("replay of the hide", messageId(1), "duplicate", hide),
4161
+ await sample("a restore dated before the hide", messageId(2), "stale", buildMessage({
4162
+ type: "content.restore",
4163
+ timestamp: at(-60),
4164
+ contentRef
4165
+ })),
4166
+ await sample("a restore dated after the hide", messageId(3), "applied", buildMessage({
4167
+ type: "content.restore",
4168
+ timestamp: at(1),
4169
+ contentRef
4170
+ })),
4171
+ await sample("suspend a user", messageId(4), "applied", buildMessage({
4172
+ type: "user.suspend",
4173
+ timestamp: at(0),
4174
+ reason: "sample suspension"
4175
+ })),
4176
+ await sample("a user restore dated before the suspension", messageId(5), "stale", buildMessage({
4177
+ type: "user.restore",
4178
+ timestamp: at(-60)
4179
+ }))
4180
+ ];
4181
+ }
4182
+ function isMessageType(value) {
4183
+ return MESSAGE_TYPES.includes(value);
4184
+ }
4185
+ function target(rest) {
4186
+ const secret = flagValue(rest, "--secret") ?? process.env.PLATFORM_MODERATION_SECRET;
4187
+ return {
4188
+ url: flagValue(rest, "--to") ?? "http://127.0.0.1:54321/functions/v1/platform-moderation",
4189
+ secret: secret ?? "whsec_c2FtcGxlLW1vZGVyYXRpb24tc2VjcmV0LW5vdC1yZWFs",
4190
+ usingSampleSecret: secret === void 0
4191
+ };
4192
+ }
4193
+ /** POSTs a signed body. A refused connection says what to start, not a stack. */
4194
+ async function post({ url, secret }, body, options = {}, doFetch = fetch) {
4195
+ const response = await doFetch(url, {
4196
+ method: "POST",
4197
+ headers: {
4198
+ ...await signMessage(secret, body, options),
4199
+ "content-type": "application/json"
4200
+ },
4201
+ body
4202
+ });
4203
+ return {
4204
+ status: response.status,
4205
+ body: await response.text()
4206
+ };
4207
+ }
4208
+ /** The outcome word a reference-shaped receiver answers with, if it does. */
4209
+ function outcomeOf(reply) {
4210
+ try {
4211
+ const parsed = JSON.parse(reply.body);
4212
+ return typeof parsed.outcome === "string" ? parsed.outcome : null;
4213
+ } catch {
4214
+ return null;
4215
+ }
4216
+ }
4217
+ function show(label, reply) {
4218
+ const outcome = outcomeOf(reply);
4219
+ process.stdout.write(`${label}: HTTP ${reply.status}${outcome ? ` (${outcome})` : ""}${reply.status >= 400 && reply.body ? ` ${reply.body.slice(0, 200)}` : ""}\n`);
4220
+ }
4221
+ function unreachable(url, error) {
4222
+ explain(`Could not reach ${url}.`, error instanceof Error ? error.message : String(error), ["Start your local stack (`supabase start`) and serve the function (`supabase functions serve`),", "or pass --to <url> for a receiver running somewhere else."]);
4223
+ process.exitCode = 1;
4224
+ }
4225
+ function noteSecret(t) {
4226
+ if (t.usingSampleSecret) process.stderr.write("Signing with the sample secret. Set your receiver's secret to it, or pass --secret / PLATFORM_MODERATION_SECRET.\n");
4227
+ }
4228
+ async function runSend(rest) {
4229
+ const action = positionals(rest)[1];
4230
+ if (!action || !isMessageType(action)) {
4231
+ explain(action ? `"${action}" is not a moderation action.` : "Which action?", "", [`devtools moderation send <${MESSAGE_TYPES.join(" | ")}>`]);
4232
+ process.exitCode = 1;
4233
+ return;
4234
+ }
4235
+ const t = target(rest);
4236
+ noteSecret(t);
4237
+ const stale = rest.includes("--stale");
4238
+ const message = buildMessage({
4239
+ type: action,
4240
+ timestamp: (/* @__PURE__ */ new Date(Date.now() - (stale ? 6e4 : 0))).toISOString(),
4241
+ contentType: flagValue(rest, "--content-type"),
4242
+ contentRef: flagValue(rest, "--content-ref"),
4243
+ userId: flagValue(rest, "--user")
4244
+ });
4245
+ const body = JSON.stringify(message);
4246
+ const id = `msg_${crypto.randomUUID()}`;
4247
+ try {
4248
+ const first = await post(t, body, { id });
4249
+ show(`${action}`, first);
4250
+ if (first.status >= 300) process.exitCode = 1;
4251
+ if (rest.includes("--replay")) {
4252
+ const second = await post(t, body, { id });
4253
+ show(`${action} (replay)`, second);
4254
+ if (second.status >= 300) process.exitCode = 1;
4255
+ }
4256
+ } catch (error) {
4257
+ unreachable(t.url, error);
4258
+ }
4259
+ }
4260
+ async function runSamples(rest, doFetch = fetch) {
4261
+ const t = target(rest);
4262
+ noteSecret(t);
4263
+ let allPassed = true;
4264
+ try {
4265
+ for (const sample of await signedSamples({ secret: t.secret })) {
4266
+ const response = await doFetch(t.url, {
4267
+ method: "POST",
4268
+ headers: {
4269
+ ...sample.headers,
4270
+ "content-type": "application/json"
4271
+ },
4272
+ body: sample.body
4273
+ });
4274
+ const reply = {
4275
+ status: response.status,
4276
+ body: await response.text()
4277
+ };
4278
+ const outcome = outcomeOf(reply);
4279
+ const passed = reply.status < 300 && outcome === sample.expected;
4280
+ const verdict = passed ? "ok" : reply.status < 300 && outcome === null ? "answered 2xx (no outcome to check)" : `expected ${sample.expected}, got HTTP ${reply.status}${outcome ? ` (${outcome})` : ""}`;
4281
+ process.stdout.write(`${passed ? "pass" : "FAIL"} ${sample.name}: ${verdict}\n`);
4282
+ if (!passed && !(reply.status < 300 && outcome === null)) allPassed = false;
4283
+ }
4284
+ } catch (error) {
4285
+ unreachable(t.url, error);
4286
+ return false;
4287
+ }
4288
+ if (!allPassed) process.exitCode = 1;
4289
+ return allPassed;
4290
+ }
4291
+ const handleModeration = async (rest) => {
4292
+ const sub = positionals(rest)[0];
4293
+ if (sub === "send") await runSend(rest);
4294
+ else if (sub === "samples") await runSamples(rest);
4295
+ else {
4296
+ process.stderr.write(`devtools moderation: unknown subcommand "${sub ?? "(none)"}". Expected: send or samples.\n`);
4297
+ process.exitCode = 1;
4298
+ }
4299
+ return process.exitCode ? null : DONE;
4300
+ };
3584
4301
  //#endregion
3585
4302
  //#region ../cli-core/src/instance.ts
3586
4303
  /**
@@ -4002,6 +4719,125 @@ const handleRoles = async (rest) => {
4002
4719
  }
4003
4720
  return process.exitCode ? null : DONE;
4004
4721
  };
4722
+ //#endregion
4723
+ //#region src/product/env.ts
4724
+ /**
4725
+ * The two env files a product keeps, and the manifest they come from.
4726
+ *
4727
+ * * `.env.example` is committed and holds public values only. It is the
4728
+ * manifest: `setup` writes `.env` from it, and the Flutter build reads
4729
+ * `.env` with `--dart-define-from-file=.env`, so everything in it ships in
4730
+ * the app. CI rejects a secret-looking key in it.
4731
+ * * `.env.tooling` is git-ignored and holds what the tooling needs and the
4732
+ * app must never see: the database URL and the secret key.
4733
+ *
4734
+ * Values are written unquoted: Flutter's `--dart-define-from-file` reads
4735
+ * `KEY=value` lines and keeps quotes as part of the value.
4736
+ */
4737
+ function readEnvFile(path) {
4738
+ return existsSync(path) ? parse(readFileSync(path, "utf8")) : {};
4739
+ }
4740
+ /** Sets `values` in an env file, keeping every other line (comments included) as it was. */
4741
+ function upsertEnvFile(path, values, header) {
4742
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
4743
+ const lines = existing.length > 0 ? existing.replace(/\n$/, "").split("\n") : [];
4744
+ if (lines.length === 0 && header) lines.push(header);
4745
+ const remaining = new Map(Object.entries(values));
4746
+ const updated = lines.map((line) => {
4747
+ const key = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
4748
+ if (key === void 0 || !remaining.has(key)) return line;
4749
+ const value = remaining.get(key);
4750
+ remaining.delete(key);
4751
+ return `${key}=${value}`;
4752
+ });
4753
+ for (const [key, value] of remaining) updated.push(`${key}=${value}`);
4754
+ mkdirSync(dirname(path), { recursive: true });
4755
+ writeFileSync(path, `${updated.join("\n")}\n`, { mode: 384 });
4756
+ }
4757
+ /**
4758
+ * Makes sure `relPath` (relative to `root`) is ignored by git, adding it to
4759
+ * the root `.gitignore` when it is not. Returns true when it had to add it.
4760
+ *
4761
+ * Flutter's default `.gitignore` ignores none of `.env`, `.env.tooling` or
4762
+ * `.env.local`, and `.env.tooling` holds the database URL, the secret key and
4763
+ * the OAuth client secret, so devtools does not leave that to chance. Outside a
4764
+ * git work tree (or with no git) there is nothing to commit to, and it does
4765
+ * nothing.
4766
+ */
4767
+ function ensureGitignored(root, relPath) {
4768
+ const check = spawnSync("git", [
4769
+ "check-ignore",
4770
+ "-q",
4771
+ "--",
4772
+ relPath
4773
+ ], {
4774
+ cwd: root,
4775
+ stdio: "ignore"
4776
+ });
4777
+ if (check.error || check.status !== 1) return false;
4778
+ const file = join(root, ".gitignore");
4779
+ const existing = existsSync(file) ? readFileSync(file, "utf8") : "";
4780
+ const lead = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
4781
+ appendFileSync(file, `${lead}/${relPath}\n`);
4782
+ return true;
4783
+ }
4784
+ /** The keys of a manifest, in order. */
4785
+ function manifestKeys(text) {
4786
+ return Object.keys(parse(text));
4787
+ }
4788
+ /**
4789
+ * `.env` for a fresh checkout: the manifest's own lines and comments, with
4790
+ * `overrides` applied. Keys the manifest does not declare are not added; the
4791
+ * file stays public-only by construction.
4792
+ */
4793
+ function renderEnvFromManifest(manifest, overrides) {
4794
+ const declared = new Set(manifestKeys(manifest));
4795
+ const lines = manifest.replace(/\n$/, "").split("\n").map((line) => {
4796
+ const key = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
4797
+ if (key === void 0) return line;
4798
+ const value = overrides[key];
4799
+ return value === void 0 ? line : `${key}=${value}`;
4800
+ });
4801
+ for (const key of Object.keys(overrides)) if (!declared.has(key)) throw new Error(`${key} is not in the manifest; add it to .env.example (public values only) first.`);
4802
+ return `${lines.join("\n")}\n`;
4803
+ }
4804
+ /** Key names that mean a secret. `API_KEY` is not here: a browser Maps key is public by design. */
4805
+ const SECRET_KEY_NAME = /(?:^|_)(?:SECRET|PASSWORD|PASSWD|PRIVATE|TOKEN|SERVICE_ROLE|CREDENTIALS?)(?:_|$)|(?:^|_)(?:DB|DATABASE|POSTGRES|PG)_(?:URL|URI|PASSWORD)(?:_|$)/i;
4806
+ /** Values that look like a secret whatever the key is called. */
4807
+ const SECRET_VALUE = [
4808
+ [/sb_secret_/, "a Supabase secret key"],
4809
+ [/postgres(?:ql)?:\/\/[^:\s/]+:[^@\s]+@/i, "a database URL with a password"],
4810
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "a private key"],
4811
+ [/\bwhsec_[A-Za-z0-9+/=]{16,}/, "a webhook signing secret"],
4812
+ [/\bgh[pousr]_[A-Za-z0-9]{20,}/, "a GitHub token"]
4813
+ ];
4814
+ /** Secret-looking entries in a manifest. The manifest ships in the app, so none may pass. */
4815
+ function findSecrets(manifest) {
4816
+ const findings = [];
4817
+ for (const [key, value] of Object.entries(parse(manifest))) {
4818
+ if (SECRET_KEY_NAME.test(key)) {
4819
+ findings.push({
4820
+ key,
4821
+ reason: "its name says it is a secret"
4822
+ });
4823
+ continue;
4824
+ }
4825
+ const hit = SECRET_VALUE.find(([pattern]) => pattern.test(value));
4826
+ if (hit) findings.push({
4827
+ key,
4828
+ reason: `its value looks like ${hit[1]}`
4829
+ });
4830
+ }
4831
+ return findings;
4832
+ }
4833
+ /**
4834
+ * Every host the platform's auth server has answered on, in the order to try.
4835
+ * The platform's project moves from its custom domain to the vanity name
4836
+ * `devdogsuga.supabase.co` at a deploy gate (Q90), and `api.devdogsuga.org`
4837
+ * then becomes a Worker-routed host with no `/auth/v1`; discovery tries each,
4838
+ * so one build of devtools works on both sides of the move.
4839
+ */
4840
+ const DEFAULT_API_URLS = ["https://api.devdogsuga.org", "https://devdogsuga.supabase.co"];
4005
4841
  /**
4006
4842
  * DevDogs website — human-facing links, and (since TASK-351) the origin
4007
4843
  * that serves the one-click connect flow's `/tools/oauth/connect` and
@@ -4031,12 +4867,13 @@ const ENV_KEYS = {
4031
4867
  //#endregion
4032
4868
  //#region src/oauth/env-file.ts
4033
4869
  /**
4034
- * Writes `values` into `.env.local` in `cwd`, updating any existing
4035
- * `KEY=...` lines in place and appending keys that aren't present yet.
4036
- * Leaves all other lines (comments, unrelated vars) untouched.
4870
+ * Writes `values` into `fileName` (`.env.local` by default) in `cwd`,
4871
+ * updating any existing `KEY=...` lines in place and appending keys that
4872
+ * aren't present yet. Leaves all other lines (comments, unrelated vars)
4873
+ * untouched. A product repo passes its `.env.tooling`, where its secrets live.
4037
4874
  */
4038
- function upsertEnvLocal(cwd, values) {
4039
- const path = join(cwd, ".env.local");
4875
+ function upsertEnvLocal(cwd, values, fileName = ".env.local") {
4876
+ const path = join(cwd, fileName);
4040
4877
  const existing = existsSync(path) ? readFileSync(path, "utf-8") : "";
4041
4878
  const lines = existing.length > 0 ? existing.split("\n") : [];
4042
4879
  const remaining = new Map(Object.entries(values));
@@ -4051,7 +4888,7 @@ function upsertEnvLocal(cwd, values) {
4051
4888
  return `${key}=${formatValue(value)}`;
4052
4889
  });
4053
4890
  for (const [key, value] of remaining) updated.push(`${key}=${formatValue(value)}`);
4054
- writeFileSync(path, `${updated.join("\n").replace(/\n*$/, "")}\n`);
4891
+ writeFileSync(path, `${updated.join("\n").replace(/\n*$/, "")}\n`, { mode: 384 });
4055
4892
  }
4056
4893
  function formatValue(value) {
4057
4894
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
@@ -4224,6 +5061,22 @@ async function fetchIssuer(baseUrl) {
4224
5061
  if (typeof issuer !== "string" || issuer.length === 0) throw new DiscoveryError(`${discoveryUrl} response had no "issuer" field`);
4225
5062
  return issuer;
4226
5063
  }
5064
+ /**
5065
+ * `fetchIssuer` against each base in turn, returning the first that answers.
5066
+ * Only when every base fails does it throw, naming all of them.
5067
+ */
5068
+ async function fetchIssuerFromAny(baseUrls) {
5069
+ const failures = [];
5070
+ for (const baseUrl of baseUrls) try {
5071
+ return {
5072
+ issuer: await fetchIssuer(baseUrl),
5073
+ baseUrl
5074
+ };
5075
+ } catch (err) {
5076
+ failures.push(err instanceof Error ? err.message : String(err));
5077
+ }
5078
+ throw new DiscoveryError(failures.join("\n"));
5079
+ }
4227
5080
  //#endregion
4228
5081
  //#region src/oauth/browser.ts
4229
5082
  /** Opens `url` in the user's default browser, cross-platform. */
@@ -5097,7 +5950,8 @@ async function connectViaOneClick(target, platformUrl, cwd, transportOverride) {
5097
5950
  }
5098
5951
  /** Step 2 (paste branch): the original manual flow — client ID/secret against the DevDogs API. */
5099
5952
  async function connectByPasting(baseUrlOverride) {
5100
- const baseUrl = (baseUrlOverride ?? process.env[ENV_KEYS.baseUrl] ?? "https://api.devdogsuga.org").replace(/\/+$/, "");
5953
+ const explicitBase = baseUrlOverride ?? process.env[ENV_KEYS.baseUrl];
5954
+ const candidates = explicitBase ? [explicitBase.replace(/\/+$/, "")] : DEFAULT_API_URLS;
5101
5955
  let clientId = nonEmptyEnv(ENV_KEYS.clientId);
5102
5956
  let clientSecret = nonEmptyEnv(ENV_KEYS.clientSecret);
5103
5957
  if (clientId && clientSecret) {
@@ -5124,7 +5978,7 @@ async function connectByPasting(baseUrlOverride) {
5124
5978
  discoverySpinner.start("Discovering issuer");
5125
5979
  let issuer;
5126
5980
  try {
5127
- issuer = await fetchIssuer(baseUrl);
5981
+ ({issuer} = await fetchIssuerFromAny(candidates));
5128
5982
  discoverySpinner.stop(`Discovered issuer: ${issuer}`);
5129
5983
  } catch (err) {
5130
5984
  discoverySpinner.stop("Could not discover the issuer");
@@ -5148,7 +6002,7 @@ async function connectByPasting(baseUrlOverride) {
5148
6002
  * Intro and outro are the caller's, so this composes into the devtools menu
5149
6003
  * without drawing a second box inside the first.
5150
6004
  */
5151
- async function runOAuthSetup(baseUrlOverride, platformUrlOverride, transportOverride, presetTarget) {
6005
+ async function runOAuthSetup(baseUrlOverride, platformUrlOverride, transportOverride, presetTarget, credentialsFile = ".env.local") {
5152
6006
  const cwd = process.cwd();
5153
6007
  log.info("Configures a Supabase project to support \"Sign in with DevDogs\".");
5154
6008
  const target = presetTarget ?? await chooseTarget(cwd);
@@ -5219,9 +6073,10 @@ async function runOAuthSetup(baseUrlOverride, platformUrlOverride, transportOver
5219
6073
  [ENV_KEYS.clientId]: clientId,
5220
6074
  [ENV_KEYS.clientSecret]: clientSecret
5221
6075
  };
5222
- if (!usedOneClick) envValues[ENV_KEYS.baseUrl] = baseUrlOverride ?? process.env[ENV_KEYS.baseUrl] ?? "https://api.devdogsuga.org";
5223
- upsertEnvLocal(cwd, envValues);
5224
- log.success("Credentials saved to .env.local");
6076
+ const explicitBase = baseUrlOverride ?? process.env[ENV_KEYS.baseUrl];
6077
+ if (!usedOneClick && explicitBase) envValues[ENV_KEYS.baseUrl] = explicitBase;
6078
+ upsertEnvLocal(cwd, envValues, credentialsFile);
6079
+ log.success(`Credentials saved to ${credentialsFile}`);
5225
6080
  const callbackUri = `${target.apiUrl}/auth/v1/callback`;
5226
6081
  if (!usedOneClick) {
5227
6082
  const alreadyRegistered = unwrap(await confirm({
@@ -5266,7 +6121,10 @@ const handleOAuth = async (rest) => {
5266
6121
  process.exitCode = 1;
5267
6122
  return null;
5268
6123
  }
5269
- await runOAuthSetup(flagValue(rest, "--base-url"), flagValue(rest, "--platform-url"), forceDevice ? "device" : forceLoopback ? "loopback" : void 0);
6124
+ const product = currentProduct();
6125
+ if (product) process.chdir(product.root);
6126
+ if (product && ensureGitignored(product.root, product.config.env.tooling)) process.stderr.write(`devtools: added /${product.config.env.tooling} to .gitignore (it holds secrets).\n`);
6127
+ await runOAuthSetup(flagValue(rest, "--base-url"), flagValue(rest, "--platform-url"), forceDevice ? "device" : forceLoopback ? "loopback" : void 0, void 0, product?.config.env.tooling);
5270
6128
  return "All done! You're ready to \"Sign in with DevDogs\".";
5271
6129
  };
5272
6130
  //#endregion
@@ -5719,677 +6577,53 @@ async function handlePassthrough(tool, args) {
5719
6577
  }
5720
6578
  }
5721
6579
  //#endregion
5722
- //#region src/run/commands.ts
5723
- /**
5724
- * `pnpm devtools run <task>`: which apps a root pnpm task runs against.
5725
- *
5726
- * `pnpm dev` used to start every app in the workspace at once: two Next dev
5727
- * servers on two ports and a Flutter run, when almost nobody is working on
5728
- * more than one. pnpm's own `--filter` has always made scoping possible, so
5729
- * the capability was there. The DEFAULT was wrong, and a default you have to
5730
- * know a flag to escape is one most contributors never escape.
5731
- *
5732
- * So every root package script routes through here. With a TTY and no
5733
- * explicit filter it asks; without either it is a passthrough that costs one
5734
- * or two process spawns (one to build dependencies first, for tasks that need
5735
- * that — see `NEEDS_DEPS_BUILT` below — then one to run the task itself).
5736
- *
5737
- * ## Why this lives in devtools now
5738
- *
5739
- * It began here, moved out to `scripts/pick.mjs` as plain Node, and has come
5740
- * back. The move out rested on two grounds, and re-checking them on 2026-08-27
5741
- * found one of them false:
5742
- *
5743
- * * "CI pays for it." It does not. No workflow invokes the root task
5744
- * aliases: `ci.yaml` and `deploy.yaml` both call `pnpm -r …`/`pnpm --filter
5745
- * …` directly, which never reaches this file. Only a person at a terminal
5746
- * pays it, and there it buys the question this exists to ask. The `CI`
5747
- * guard in `shouldAsk` stays as insurance against a runner that one day
5748
- * does type `pnpm build`.
5749
- * * "It must not need a build." This one holds, and is why the module stays
5750
- * careful. `pnpm build` routes through here, so anything on this path that
5751
- * had to be compiled first would be a cycle. It is safe because devtools
5752
- * runs from source under `--conditions=devdogs-source`, and because the
5753
- * one workspace dependency with no source condition (`@devdogsuga/docs`,
5754
- * which is `dist`-only) is reached through a lazy `await import` in
5755
- * `docs/index-pages.ts` and never loads on this path. ⚠️ A top-level
5756
- * `import` of `@devdogsuga/docs` anywhere in the eager graph would
5757
- * deadlock `pnpm build` on itself.
5758
- *
5759
- * ## Building dependencies first, in pnpm terms
5760
- *
5761
- * A package's workspace dependencies have to be built before a task runs
5762
- * against it, for every task in `NEEDS_DEPS_BUILT` below. pnpm has no single
5763
- * flag that means this, but it has the two pieces that compose into it: `<pkg>^...` selects only the direct and indirect DEPENDENCIES of a
5764
- * package (excluding the package itself), and plain recursive `pnpm -r` runs
5765
- * scripts in dependency order (a package's own dependencies always run before
5766
- * it). So building the deps-of set with `-r --filter '<pkg>^...' run build`
5767
- * and then running the real task against the package itself gives that
5768
- * ordering with two spawns.
5769
- *
5770
- * `@devdogsuga/email` is the one package that needs its OWN build before its
5771
- * OWN typecheck/test, not just its dependencies' — its build step generates
5772
- * gitignored `src/generated/templates.ts`. That case never runs through this
5773
- * picker (it lists `apps/*`, not `packages/*`; see `appsWith` below), but
5774
- * matters for anyone reasoning about the workspace-wide passthrough path
5775
- * (see `passthroughApps` and `allAppNames`), which builds the union of every
5776
- * app's dependencies — email included, since some app depends on it —
5777
- * before the target task, deliberately stopping short of a bare `pnpm -r run
5778
- * build`: that would also run every APP's own build script, which a
5779
- * typecheck/lint/test/dev run has no business doing (see `passthroughApps`'s
5780
- * doc comment for why that is more than just wasted work).
5781
- */
5782
- /**
5783
- * Where the last answer per task is kept.
5784
- *
5785
- * Under `node_modules/.cache` rather than a dotfile at the root: it is a
5786
- * convenience, not configuration, and losing it to a reinstall costs one extra
5787
- * keystroke. Putting it there also means no new `.gitignore` entry and no new
5788
- * file in the listing every contributor sees.
5789
- */
5790
- function memoryPath() {
5791
- return join(findRepoRoot(), "node_modules", ".cache", "devdogs", "tasks.json");
5792
- }
5793
- /**
5794
- * pnpm's own ways of naming packages, plus `--scope` for backward
5795
- * compatibility with anyone who still types the old flag out of habit.
5796
- * Any of them means "already decided" for `shouldAsk`; `--scope`'s value is
5797
- * translated into a `--filter` when a command actually gets built, since pnpm
5798
- * itself has no `--scope` flag.
5799
- */
5800
- const FILTERS = [
5801
- "--filter",
5802
- "-F",
5803
- "--scope"
5804
- ];
5805
- /**
5806
- * Tasks that need the packages a target depends on built first. Everything
5807
- * else forwarded through `run` (`generate-types`, and any package script not
5808
- * in this list) needs no dependency build, and nothing else is routed through
5809
- * the root aliases this picker serves.
5810
- */
5811
- const NEEDS_DEPS_BUILT = /* @__PURE__ */ new Set([
5812
- "build",
5813
- "dev",
5814
- "typecheck",
5815
- "lint",
5816
- "lint:fix",
5817
- "test"
5818
- ]);
5819
- /** The workspace root an app lives in: `<root>/apps/<name>` gives `<root>`. */
5820
- function workspaceRootOfApp(dir) {
5821
- return dirname(dirname(dir));
5822
- }
5823
- /**
5824
- * Splits `filters` by the workspace that holds the app each one names. From a
5825
- * Backstage checkout `schedule-builder` belongs to DevDogsUGA's workspace, and
5826
- * a `pnpm --filter` run from Backstage's root would match nothing. A filter
5827
- * naming no known app (a glob, a path) stays with the repo you are in. No
5828
- * filters means the whole workspace of the repo you are in.
5829
- */
5830
- function groupFiltersByRoot(filters, apps = appsWith(), repoRoot = findRepoRoot()) {
5831
- const groups = /* @__PURE__ */ new Map([[repoRoot, []]]);
5832
- for (const filter of filters) {
5833
- const root = apps.find((app) => app.name === filter)?.root ?? repoRoot;
5834
- const group = groups.get(root) ?? [];
5835
- group.push(filter);
5836
- groups.set(root, group);
5837
- }
5838
- if (filters.length > 0 && groups.get(repoRoot)?.length === 0) groups.delete(repoRoot);
5839
- return groups;
6580
+ //#region src/script/pick.ts
6581
+ /** Packages with a name and at least one script, in workspace order. */
6582
+ function runnablePackages(packages) {
6583
+ return packages.flatMap((pkg) => pkg.name && Object.keys(pkg.scripts).length > 0 ? [{
6584
+ name: pkg.name,
6585
+ dir: pkg.dir,
6586
+ scripts: pkg.scripts
6587
+ }] : []);
5840
6588
  }
5841
- /**
5842
- * Runs one `pnpm` invocation to completion, reporting its own exit the way
5843
- * `passthroughApps` needs to: it returns to keep going, and exits the process
5844
- * on a non-zero status or when it is the final command.
5845
- *
5846
- * `pnpm` resolves from the workspace root's `node_modules/.bin`, which is
5847
- * already on PATH. It runs through the process-group runner, so a Ctrl-C (or a
5848
- * closed terminal) stops the dev server and everything it started rather than
5849
- * orphaning vinext and workerd, and the exit status comes back as a shell would
5850
- * report it (`128 + n` for a signal). The command that ran is printed after it
5851
- * finishes.
5852
- *
5853
- * ⚠️ `cwd` is explicit, and must be. Reached through
5854
- * `pnpm --filter @devdogsuga/devtools run cli`, this process starts in
5855
- * `packages/devtools`, and a `pnpm -r`/`--filter` invoked there would scope
5856
- * itself relative to that one package rather than the workspace root.
5857
- */
5858
- async function runOne({ args, cwd }, extraEnv, { final }) {
5859
- const result = await runInGroup("pnpm", args, {
5860
- cwd,
5861
- env: {
5862
- ...process.env,
5863
- ...extraEnv,
5864
- DEVDOGS_PICK: "0"
5865
- }
5866
- });
5867
- reportRan("pnpm", args, result);
5868
- if (result.code !== 0 || final) process.exit(result.code);
6589
+ /** Every script name and how many packages define it, most common first. */
6590
+ function scriptNames(packages) {
6591
+ const counts = /* @__PURE__ */ new Map();
6592
+ for (const pkg of packages) for (const script of Object.keys(pkg.scripts)) counts.set(script, (counts.get(script) ?? 0) + 1);
6593
+ return [...counts].map(([script, count]) => ({
6594
+ script,
6595
+ count
6596
+ })).sort((a, b) => b.count - a.count || a.script.localeCompare(b.script));
5869
6597
  }
5870
- /**
5871
- * Runs a sequence of `pnpm` invocations — typically "build the dependencies",
5872
- * then "run the actual task" — stopping (and exiting non-zero) at the first
5873
- * one that fails, and exiting with the LAST one's status otherwise. This is
5874
- * what makes a dependency build failure and a task failure look identical
5875
- * from the outside: the second spawn is never reached if the first one failed.
5876
- *
5877
- * Never returns: `runOne` exits on a failure and on the final command.
5878
- */
5879
- async function passthrough(commands, extraEnv) {
5880
- for (const [index, command] of commands.entries()) await runOne(command, extraEnv, { final: index === commands.length - 1 });
5881
- process.exit(1);
6598
+ /** The packages that define `script`. */
6599
+ function packagesWith(packages, script) {
6600
+ return packages.filter((pkg) => script in pkg.scripts);
5882
6601
  }
5883
6602
  /**
5884
- * Whether to ask at all.
5885
- *
5886
- * Every one of these is a case where a prompt is either impossible or wrong:
5887
- *
5888
- * * `CI`, set by GitHub Actions and every other runner. No workflow reaches
5889
- * this today (see the header), but a workflow that blocked on a
5890
- * multiselect would hang until its timeout with no output saying why, and
5891
- * that failure is bad enough to keep guarding against.
5892
- * * No TTY: a pipe, a `pnpm` invoked by a script, an editor task runner.
5893
- * Nobody is there to answer. Every other picker in this repo checks the
5894
- * same thing.
5895
- * * An explicit filter. The caller has already said which packages, and
5896
- * asking again would be asking them to repeat themselves.
5897
- * * `DEVDOGS_PICK=0`, the escape hatch and the recursion guard above.
6603
+ * Finds a package from what somebody typed: its exact name, its directory, or
6604
+ * the last segment of its name (`platform` for `@devdogsuga/platform`).
6605
+ * `null` when nothing matches or when the short form is ambiguous.
5898
6606
  */
5899
- function shouldAsk(args) {
5900
- if (process.env.CI) return false;
5901
- if (process.env.DEVDOGS_PICK === "0") return false;
5902
- if (!process.stdin.isTTY) return false;
5903
- return !args.some((arg) => FILTERS.includes(arg) || FILTERS.some((f) => arg.startsWith(`${f}=`)));
6607
+ function findPackage(packages, typed) {
6608
+ const exact = packages.find((pkg) => pkg.name === typed || pkg.dir === typed);
6609
+ if (exact) return exact;
6610
+ const short = packages.filter((pkg) => pkg.name.split("/").pop() === typed);
6611
+ return short.length === 1 ? short[0] : null;
6612
+ }
6613
+ /** The arguments `pnpm` gets: `-F <package> run <script> [extra…]`. */
6614
+ function scriptArgs(pkg, script, extra = []) {
6615
+ return [
6616
+ "-F",
6617
+ pkg.name,
6618
+ "run",
6619
+ script,
6620
+ ...extra
6621
+ ];
5904
6622
  }
6623
+ //#endregion
6624
+ //#region src/script/commands.ts
5905
6625
  /**
5906
- * Pulls every `--filter`/`-F`/`--scope` value out of `args`, returning the
5907
- * package patterns named and the remaining args with those flags removed.
5908
- *
5909
- * `--scope` is folded into the same bucket as `--filter` here: pnpm has no
5910
- * `--scope` flag of its own, so a caller who still types the old name
5911
- * gets it translated into a real `--filter` value rather than passed through
5912
- * to a `pnpm` invocation that would reject it outright.
5913
- *
5914
- * Exported for the same reason `parseTierArg` is: pure, no `process.exit`, so
5915
- * it is unit-testable without going through `passthrough`.
5916
- */
5917
- function extractFilters(args) {
5918
- const filters = [];
5919
- const rest = [];
5920
- for (let i = 0; i < args.length; i++) {
5921
- const arg = args[i];
5922
- const eqFlag = FILTERS.find((f) => arg.startsWith(`${f}=`));
5923
- if (eqFlag) {
5924
- filters.push(arg.slice(eqFlag.length + 1));
5925
- continue;
5926
- }
5927
- if (FILTERS.includes(arg)) {
5928
- const value = args[i + 1];
5929
- if (value !== void 0) {
5930
- filters.push(value);
5931
- i++;
5932
- continue;
5933
- }
5934
- }
5935
- rest.push(arg);
5936
- }
5937
- return {
5938
- filters,
5939
- rest
5940
- };
5941
- }
5942
- /**
5943
- * Every `apps/*` package name, regardless of whether it defines the task in
5944
- * question — unlike `appsWith`, which is a picker concern (only offer apps
5945
- * that actually have this script). This exists for exactly one thing: naming
5946
- * "every dependency any app has" without hand-listing packages, since apps
5947
- * are the only packages in this workspace nothing else depends on (every
5948
- * `packages/*`/`docs` package is reachable from at least one app's
5949
- * dependency graph, and no app depends on another app).
5950
- */
5951
- function allAppNames(root = findRepoRoot()) {
5952
- const names = [];
5953
- for (const app of appManifests(root)) if (app.pkg.name) names.push(app.pkg.name);
5954
- return names;
5955
- }
5956
- /**
5957
- * The parsed `package.json` of every app in the repo you are in, plus DevDogsUGA's
5958
- * from a Backstage checkout (`repo/layout.ts`). With `root`, only that
5959
- * workspace's. An unparsable manifest is one we cannot use, so it is skipped.
5960
- */
5961
- function appManifests(root) {
5962
- const found = [];
5963
- for (const app of listApps(resolveLayout())) {
5964
- const appRoot = workspaceRootOfApp(app.dir);
5965
- if (root !== void 0 && appRoot !== root) continue;
5966
- const manifest = join(app.dir, "package.json");
5967
- if (!existsSync(manifest)) continue;
5968
- try {
5969
- found.push({
5970
- root: appRoot,
5971
- pkg: JSON.parse(readFileSync(manifest, "utf8"))
5972
- });
5973
- } catch {}
5974
- }
5975
- return found;
5976
- }
5977
- /**
5978
- * Builds the `pnpm run <task>` invocation(s) for a set of package filters (or
5979
- * none, for the whole workspace) and runs them via `passthrough`.
5980
- *
5981
- * Two spawns for a task in `NEEDS_DEPS_BUILT`: one that builds only the
5982
- * dependencies of the target packages, then one that runs the real task
5983
- * against the target packages themselves. A task outside `NEEDS_DEPS_BUILT`
5984
- * skips straight to the second spawn.
5985
- *
5986
- * The first spawn's filter set matters more than it looks: it must build
5987
- * every dependency the target packages need, and NOTHING ELSE — never an
5988
- * app's own `build` script, since a dependency build never includes the target's own
5989
- * (typechecking `platform` built platform's DEPENDENCIES, then ran
5990
- * `platform`'s `typecheck` script; it never ran `platform`'s own `build`).
5991
- * Getting this wrong is not just wasted work: an app's `build` script can
5992
- * need things a `typecheck`/`lint`/`test` run has no reason to have (a
5993
- * database, a chosen deploy tier, a Flutter SDK on PATH), so running one
5994
- * unasked can fail outright.
5995
- *
5996
- * * Filtered (`filters` non-empty): `<pkg>^...` per filter — pnpm's
5997
- * deps-of selector, excluding the package itself.
5998
- * * Whole workspace: the union of `<app>^...` over every app in the
5999
- * workspace (`allAppNames()`), NOT a bare `pnpm -r run build`. A bare
6000
- * recursive build would build every package with a `build` script,
6001
- * apps included, for exactly the reason above. Every non-app package in
6002
- * this workspace is reachable from at least one app's dependency graph
6003
- * (verified against every current `apps/*` package, not assumed), so
6004
- * this covers the same ground a bare `-r` would minus the apps
6005
- * themselves.
6006
- *
6007
- * `dev` is the one persistent task and runs with `--parallel`: pnpm's
6008
- * ordinary recursive mode runs one package at a time in dependency order,
6009
- * which is wrong for two dev servers that are each supposed to keep running
6010
- * — `--parallel` "completely disregard[s] concurrency and topological
6011
- * sorting, running a given script immediately in all matching packages with
6012
- * prefixed streaming output" (pnpm's own words for the flag), which is
6013
- * exactly the live, interleaved multi-app output a dev run wants. Every other
6014
- * task keeps pnpm's default topological/serial recursion: independent tasks
6015
- * (lint, typecheck, test across several apps) run one package at a time.
6016
- * Slower on a multi-app selection, never wrong.
6017
- *
6018
- * `build` with no filter (the whole workspace) is the one case that skips
6019
- * the separate dependency spawn even though it is in `NEEDS_DEPS_BUILT`: a
6020
- * bare `pnpm -r run build` already builds every package — apps included, and
6021
- * rightly so, since the task itself IS `build` — in dependency order in one
6022
- * pass, so a preceding dependency-only spawn would just redo part of it. A
6023
- * FILTERED build still needs the pre-step — naming a package by itself,
6024
- * with no `^...`, selects only that package, not what it depends on.
6025
- */
6026
- async function passthroughApps(task, filters, rest, extraEnv) {
6027
- const commands = [];
6028
- for (const [cwd, groupFilters] of groupFiltersByRoot(filters)) {
6029
- if (NEEDS_DEPS_BUILT.has(task) && (task !== "build" || filters.length > 0)) {
6030
- const depsFilters = (groupFilters.length > 0 ? groupFilters : allAppNames(cwd)).flatMap((f) => ["--filter", `${f}^...`]);
6031
- commands.push({
6032
- args: [
6033
- "-r",
6034
- "--if-present",
6035
- ...depsFilters,
6036
- "run",
6037
- "build"
6038
- ],
6039
- cwd
6040
- });
6041
- }
6042
- const ownFilters = groupFilters.flatMap((f) => ["--filter", f]);
6043
- const parallel = task === "dev" ? ["--parallel"] : [];
6044
- commands.push({
6045
- args: [
6046
- "-r",
6047
- "--if-present",
6048
- ...parallel,
6049
- ...ownFilters,
6050
- "run",
6051
- task,
6052
- ...rest
6053
- ],
6054
- cwd
6055
- });
6056
- }
6057
- return passthrough(commands, extraEnv);
6058
- }
6059
- /** A `dev` script that starts `vinext dev` (platform's `with-env vinext dev`). */
6060
- const VINEXT_DEV = /(^|[\s;&|])vinext\s+dev\b/;
6061
- /**
6062
- * Splits a `dev` run between apps whose dev server is `vinext dev` and the
6063
- * rest.
6064
- *
6065
- * A vinext app's server code runs in workerd, which only sees what
6066
- * `@cloudflare/vite-plugin` hands it: the app dir's own `.dev.vars`/`.env`, or
6067
- * with `CLOUDFLARE_INCLUDE_PROCESS_ENV` the whole `process.env`. The repo's
6068
- * `.env` is at the root, so through the app's own `with-env vinext dev` script
6069
- * the Worker gets nothing and env validation fails. So a vinext app skips its
6070
- * script and runs `pnpm --filter <app> exec vinext dev` with the env
6071
- * `scopedProcessEnv` builds, the same way `jobs run` starts one: the
6072
- * flag set, and every other app's variables left out. Its script's own
6073
- * `with-env` would load the whole `.env` back in, so it is not used.
6074
- *
6075
- * Everything else still runs its own `dev` script under one `pnpm --parallel`.
6076
- * `apps` is every app with a `dev` script; `filters` the selection (empty for
6077
- * all of them). Only apps are named here: no other package has a `dev`
6078
- * script.
6079
- */
6080
- function planDev(apps, filters, rest) {
6081
- const vinext = (filters.length > 0 ? apps.filter((app) => filters.includes(app.name)) : [...apps]).filter((app) => VINEXT_DEV.test(app.script)).map((app) => app.name);
6082
- const otherFilters = filters.length > 0 ? filters.filter((f) => !vinext.includes(f)) : apps.map((app) => app.name).filter((name) => !vinext.includes(name));
6083
- return {
6084
- vinext,
6085
- others: otherFilters.length > 0 ? [
6086
- "-r",
6087
- "--if-present",
6088
- "--parallel",
6089
- ...otherFilters.flatMap((f) => ["--filter", f]),
6090
- "run",
6091
- "dev",
6092
- ...rest
6093
- ] : null
6094
- };
6095
- }
6096
- /**
6097
- * `dev` when at least one selected app runs `vinext dev`: builds every
6098
- * selected app's workspace dependencies, then starts the vinext apps (each
6099
- * with its own scoped env, see `planDev`) and the rest side by side, and
6100
- * exits when the first of them does, stopping the others.
6101
- *
6102
- * The env is the session's: `launch.ts` has already entered the tier into
6103
- * `process.env` (or `--tier` loaded one into `tierEnv`).
6104
- */
6105
- async function runDev(plan, filters, rest, tierEnv, root) {
6106
- await runOne({
6107
- args: [
6108
- "-r",
6109
- "--if-present",
6110
- ...(filters.length > 0 ? filters : allAppNames(root)).flatMap((f) => ["--filter", `${f}^...`]),
6111
- "run",
6112
- "build"
6113
- ],
6114
- cwd: root
6115
- }, tierEnv, { final: false });
6116
- const { scopedProcessEnv } = await import("./local-env-MqH18s-N.js");
6117
- const environment = {
6118
- ...process.env,
6119
- ...tierEnv
6120
- };
6121
- const tier = environment.DEPLOY_ENV ?? "development";
6122
- const children = [...await Promise.all(plan.vinext.map(async (app) => ({
6123
- args: [
6124
- "--filter",
6125
- app,
6126
- "exec",
6127
- "vinext",
6128
- "dev",
6129
- ...rest
6130
- ],
6131
- env: isDryRun() ? environment : await scopedProcessEnv(app, environment, tier)
6132
- }))), ...plan.others ? [{
6133
- args: plan.others,
6134
- env: environment
6135
- }] : []];
6136
- const stop = new AbortController();
6137
- let first;
6138
- const runs = children.map(async ({ args, env }) => {
6139
- const result = await runInGroup("pnpm", args, {
6140
- cwd: root,
6141
- env: {
6142
- ...env,
6143
- DEVDOGS_PICK: "0"
6144
- },
6145
- abort: stop.signal
6146
- });
6147
- reportRan("pnpm", args, result);
6148
- first ??= result.code;
6149
- stop.abort();
6150
- });
6151
- await Promise.all(runs);
6152
- process.exit(first ?? 1);
6153
- }
6154
- /** `passthroughApps`, except that a `dev` selecting a vinext app goes to
6155
- * `runDev`. */
6156
- async function dispatch$1(task, filters, rest, tierEnv) {
6157
- if (task === "dev") {
6158
- const groups = groupFiltersByRoot(filters);
6159
- if (groups.size > 1) {
6160
- process.stderr.write("devtools run: dev servers from DevDogsUGA and Backstage cannot be started together; start them from their own checkouts.\n");
6161
- process.exit(1);
6162
- }
6163
- const root = [...groups.keys()][0];
6164
- const plan = planDev(appsWith("dev", root), filters, rest);
6165
- if (plan.vinext.length > 0) return runDev(plan, filters, rest, tierEnv, root);
6166
- }
6167
- return passthroughApps(task, filters, rest, tierEnv);
6168
- }
6169
- /** The tiers a loaded env can select. `preflight` is not a deploy environment
6170
- * (see `@devdogsuga/env`'s target table) and is refused here for the same
6171
- * reason `loadEnvironment` itself would refuse it. */
6172
- const RUN_TIERS = /* @__PURE__ */ new Set([
6173
- "development",
6174
- "staging",
6175
- "production"
6176
- ]);
6177
- /**
6178
- * Parses and strips `--tier <value>` from pnpm-bound args, mirroring the
6179
- * `--all` strip above.
6180
- *
6181
- * Pure on purpose — no `process.exit` — so this is unit-testable without
6182
- * going through `passthrough`, which every other path in this file ends at
6183
- * and which a test cannot observe return from. `runTask` is the one caller,
6184
- * and it is the one that turns `error` into an exit.
6185
- */
6186
- function parseTierArg(args) {
6187
- const idx = args.indexOf("--tier");
6188
- if (idx === -1) return { rest: [...args] };
6189
- const value = args[idx + 1];
6190
- if (!value || value.startsWith("-")) return {
6191
- rest: [...args],
6192
- error: "devtools run: --tier requires a value."
6193
- };
6194
- if (!RUN_TIERS.has(value)) return {
6195
- rest: [...args],
6196
- error: `devtools run: unknown tier "${value}". Expected: development, staging, production.`
6197
- };
6198
- return {
6199
- tier: value,
6200
- rest: [...args.slice(0, idx), ...args.slice(idx + 2)]
6201
- };
6202
- }
6203
- /**
6204
- * The apps that actually define this task, by package name.
6205
- *
6206
- * `apps/*` only. Packages are libraries an app pulls in, and a filtered
6207
- * `pnpm -r --filter '<app>^...' run build` already builds those first (see
6208
- * `passthroughApps` above), so listing all twelve would ask about things the
6209
- * answer does not change. Reading each `package.json` rather than shelling
6210
- * out to a dry-run equivalent, which costs about a second before the first
6211
- * question.
6212
- */
6213
- function appsWith(task, root) {
6214
- const found = [];
6215
- for (const app of appManifests(root)) {
6216
- const script = task === void 0 ? "" : app.pkg.scripts?.[task];
6217
- if (app.pkg.name && (task === void 0 || script)) found.push({
6218
- name: app.pkg.name,
6219
- script: script ?? "",
6220
- root: app.root
6221
- });
6222
- }
6223
- return found;
6224
- }
6225
- function remembered(task) {
6226
- try {
6227
- return JSON.parse(readFileSync(memoryPath(), "utf8"))[task] ?? [];
6228
- } catch {
6229
- return [];
6230
- }
6231
- }
6232
- function remember(task, apps) {
6233
- try {
6234
- let all = {};
6235
- try {
6236
- all = JSON.parse(readFileSync(memoryPath(), "utf8"));
6237
- } catch {}
6238
- all[task] = apps;
6239
- mkdirSync(dirname(memoryPath()), { recursive: true });
6240
- writeFileSync(memoryPath(), `${JSON.stringify(all, null, 2)}\n`);
6241
- } catch {}
6242
- }
6243
- /** Groups every use of `run` into one Sentry issue, whatever the script. */
6244
- const RUN_ALIAS_FINGERPRINT = "devtools-run-alias";
6245
- /**
6246
- * What replaces `run <task>`, as the line printed before it runs.
6247
- *
6248
- * `run` is a deprecated alias: branches behind main still call it. It says
6249
- * what to type instead (`pnpm -r run <task>` for every package,
6250
- * `pnpm -F <app> <task>` for one) and then does the job as before.
6251
- */
6252
- function runAliasMessage(task) {
6253
- return `devtools run is deprecated. Use \`pnpm -r run ${task}\` for every package, or \`pnpm -F <app> ${task}\` for one app.`;
6254
- }
6255
- /**
6256
- * Prints the replacement and reports the use. Each use sends one
6257
- * warning-level Sentry message with a fixed fingerprint, tagged with the
6258
- * script and local or CI, so the alias can be removed once that issue stops
6259
- * receiving events (TASK-404).
6260
- */
6261
- async function announceRunAlias(task) {
6262
- process.stderr.write(`${runAliasMessage(task)}\n`);
6263
- if (isDryRun()) return;
6264
- await captureDevtoolsDeprecation("devtools run is deprecated", RUN_ALIAS_FINGERPRINT, {
6265
- script: task,
6266
- mode: devtoolsEnvironment()
6267
- });
6268
- }
6269
- /**
6270
- * Runs one root task, asking which apps first where that makes sense.
6271
- *
6272
- * Never returns: every path ends in `passthrough`, which exits with the
6273
- * underlying `pnpm` invocation's own status. That is why `cli.ts` dispatches
6274
- * this before `intro()`. There is no `outro()` to reach, and a banner would
6275
- * land on the stream a dev server is about to take over.
6276
- */
6277
- async function runTask(argv) {
6278
- const [task, ...args] = argv;
6279
- if (!task) {
6280
- console.error("usage: pnpm devtools run <task> [pnpm args…]");
6281
- process.exit(1);
6282
- }
6283
- await announceRunAlias(task);
6284
- const all = args.includes("--all");
6285
- const tierArg = parseTierArg(args.filter((arg) => arg !== "--all"));
6286
- if (tierArg.error) {
6287
- console.error(tierArg.error);
6288
- process.exit(1);
6289
- }
6290
- const { tier, rest } = tierArg;
6291
- if (tier === "production" && !isDryRun()) {
6292
- if (!process.stdin.isTTY) {
6293
- console.error("devtools run: --tier production needs a terminal to confirm — nothing ran.");
6294
- process.exit(1);
6295
- }
6296
- const approved = await confirm({
6297
- message: `Run \`${task}\` against production? This loads .env.production's credentials into the child process.`,
6298
- initialValue: false
6299
- });
6300
- if (isCancel(approved) || !approved) {
6301
- cancel("Nothing ran.");
6302
- process.exit(0);
6303
- }
6304
- }
6305
- let tierEnv;
6306
- if (tier) {
6307
- const { loadEnvironment, MissingEnvFileError } = await loadEnvLoad();
6308
- try {
6309
- tierEnv = {
6310
- ...(await loadEnvironment(tier, { override: true })).env,
6311
- DEPLOY_ENV: tier
6312
- };
6313
- } catch (err) {
6314
- if (err instanceof MissingEnvFileError) {
6315
- console.error(`devtools run: ${err.message}`);
6316
- process.exit(1);
6317
- }
6318
- throw err;
6319
- }
6320
- }
6321
- if (all || !shouldAsk(rest)) {
6322
- const { filters, rest: cleanRest } = extractFilters(rest);
6323
- return dispatch$1(task, filters, cleanRest, tierEnv);
6324
- }
6325
- const apps = appsWith(task);
6326
- if (apps.length < 2) return dispatch$1(task, [], rest, tierEnv);
6327
- const previous = remembered(task);
6328
- const chosen = await multiselect({
6329
- message: `\`${task}\` — which apps? (a selects all; --all runs every package)`,
6330
- options: apps.map((app) => ({
6331
- value: app.name,
6332
- label: app.name,
6333
- hint: app.script.length > 58 ? `${app.script.slice(0, 57)}…` : app.script
6334
- })),
6335
- initialValues: previous.filter((name) => apps.some((app) => app.name === name)),
6336
- required: true
6337
- });
6338
- if (isCancel(chosen)) {
6339
- cancel("Nothing ran.");
6340
- process.exit(0);
6341
- }
6342
- if (!isDryRun()) remember(task, chosen);
6343
- return dispatch$1(task, chosen, rest, tierEnv);
6344
- }
6345
- //#endregion
6346
- //#region src/script/pick.ts
6347
- /** Packages with a name and at least one script, in workspace order. */
6348
- function runnablePackages(packages) {
6349
- return packages.flatMap((pkg) => pkg.name && Object.keys(pkg.scripts).length > 0 ? [{
6350
- name: pkg.name,
6351
- dir: pkg.dir,
6352
- scripts: pkg.scripts
6353
- }] : []);
6354
- }
6355
- /** Every script name and how many packages define it, most common first. */
6356
- function scriptNames(packages) {
6357
- const counts = /* @__PURE__ */ new Map();
6358
- for (const pkg of packages) for (const script of Object.keys(pkg.scripts)) counts.set(script, (counts.get(script) ?? 0) + 1);
6359
- return [...counts].map(([script, count]) => ({
6360
- script,
6361
- count
6362
- })).sort((a, b) => b.count - a.count || a.script.localeCompare(b.script));
6363
- }
6364
- /** The packages that define `script`. */
6365
- function packagesWith(packages, script) {
6366
- return packages.filter((pkg) => script in pkg.scripts);
6367
- }
6368
- /**
6369
- * Finds a package from what somebody typed: its exact name, its directory, or
6370
- * the last segment of its name (`platform` for `@devdogsuga/platform`).
6371
- * `null` when nothing matches or when the short form is ambiguous.
6372
- */
6373
- function findPackage(packages, typed) {
6374
- const exact = packages.find((pkg) => pkg.name === typed || pkg.dir === typed);
6375
- if (exact) return exact;
6376
- const short = packages.filter((pkg) => pkg.name.split("/").pop() === typed);
6377
- return short.length === 1 ? short[0] : null;
6378
- }
6379
- /** The arguments `pnpm` gets: `-F <package> run <script> [extra…]`. */
6380
- function scriptArgs(pkg, script, extra = []) {
6381
- return [
6382
- "-F",
6383
- pkg.name,
6384
- "run",
6385
- script,
6386
- ...extra
6387
- ];
6388
- }
6389
- //#endregion
6390
- //#region src/script/commands.ts
6391
- /**
6392
- * `devtools script [<package> <script> [args…]]`: the package-script picker.
6626
+ * `devtools script [<package> <script> [args…]]`: the package-script picker.
6393
6627
  *
6394
6628
  * Typed with a package and a script it runs them; with nothing, it asks. The
6395
6629
  * choice is made against the real `package.json` scripts, so there is no list
@@ -6528,7 +6762,7 @@ function readPnpmPin(repoRoot) {
6528
6762
  return null;
6529
6763
  }
6530
6764
  }
6531
- async function runSetup() {
6765
+ async function runSetup$1() {
6532
6766
  const checks = [];
6533
6767
  const node = process.versions.node;
6534
6768
  const [major = 0, minor = 0] = node.split(".").map(Number);
@@ -6717,10 +6951,1322 @@ async function promptSecretValue(doc, key, label) {
6717
6951
  })).trim();
6718
6952
  }
6719
6953
  const handleSetup = async () => {
6720
- await runSetup();
6954
+ await runSetup$1();
6721
6955
  return DONE;
6722
6956
  };
6723
6957
  //#endregion
6958
+ //#region ../sdk/dist/constants/generated.js
6959
+ const OAUTH = {
6960
+ "providerId": "custom:devdogsuga",
6961
+ "providerName": "DevDogs",
6962
+ "scopes": [
6963
+ "openid",
6964
+ "email",
6965
+ "profile"
6966
+ ],
6967
+ "tiers": ["development", "production"],
6968
+ "authPath": "/auth/v1",
6969
+ "discoveryPath": "/auth/v1/.well-known/openid-configuration",
6970
+ "callbackPath": "/auth/v1/callback"
6971
+ };
6972
+ //#endregion
6973
+ //#region src/product/exec.ts
6974
+ const spawnRunner = (command, args, options) => new Promise((resolve, reject) => {
6975
+ const child = spawn(command, [...args], {
6976
+ cwd: options.cwd,
6977
+ env: {
6978
+ ...process.env,
6979
+ ...options.env
6980
+ },
6981
+ stdio: options.inherit ? "inherit" : [
6982
+ "ignore",
6983
+ "pipe",
6984
+ "pipe"
6985
+ ]
6986
+ });
6987
+ let stdout = "";
6988
+ let stderr = "";
6989
+ child.stdout?.on("data", (chunk) => {
6990
+ stdout += chunk.toString("utf8");
6991
+ });
6992
+ child.stderr?.on("data", (chunk) => {
6993
+ stderr += chunk.toString("utf8");
6994
+ });
6995
+ child.on("error", reject);
6996
+ child.on("close", (code) => {
6997
+ resolve({
6998
+ code: code ?? 1,
6999
+ stdout,
7000
+ stderr
7001
+ });
7002
+ });
7003
+ });
7004
+ /** `command arg arg`, quoted only where a shell would need it. For messages. */
7005
+ function commandLine(command, args) {
7006
+ return [command, ...args].map((part) => /^[\w@%+=:,./-]+$/.test(part) ? part : JSON.stringify(part)).join(" ");
7007
+ }
7008
+ //#endregion
7009
+ //#region src/product/supabase-cli.ts
7010
+ /**
7011
+ * Finding the Supabase CLI a product is pinned to.
7012
+ *
7013
+ * A product repo may have no `package.json` (DogPack has none), so the CLI
7014
+ * cannot be assumed to be a dependency. In order:
7015
+ *
7016
+ * 1. `node_modules/.bin/supabase` in the repo, when its version is the pin;
7017
+ * 2. a `supabase` on PATH whose version is the pin;
7018
+ * 3. `npx --yes supabase@<pin>`, which downloads and caches the right one.
7019
+ *
7020
+ * The pin is also part of the generated-types hash, so a contributor on a
7021
+ * different CLI would generate different types than CI; matching it is not
7022
+ * cosmetic.
7023
+ */
7024
+ function cliLine(cli, args) {
7025
+ return commandLine(cli.command, [...cli.prefix, ...args]);
7026
+ }
7027
+ function versionOf(output) {
7028
+ return /\b(\d+\.\d+\.\d+)\b/.exec(output)?.[1] ?? null;
7029
+ }
7030
+ async function resolveSupabaseCli(root, pin, run, fileExists = existsSync) {
7031
+ const candidates = [];
7032
+ const local = join(root, "node_modules", ".bin", "supabase");
7033
+ if (fileExists(local)) candidates.push({
7034
+ command: local,
7035
+ prefix: [],
7036
+ source: "repo"
7037
+ });
7038
+ candidates.push({
7039
+ command: "supabase",
7040
+ prefix: [],
7041
+ source: "path"
7042
+ });
7043
+ for (const candidate of candidates) try {
7044
+ const result = await run(candidate.command, ["--version"], { cwd: root });
7045
+ if (result.code === 0 && versionOf(result.stdout) === pin) return candidate;
7046
+ } catch {}
7047
+ return {
7048
+ command: "npx",
7049
+ prefix: ["--yes", `supabase@${pin}`],
7050
+ source: "npx"
7051
+ };
7052
+ }
7053
+ //#endregion
7054
+ //#region src/product/target.ts
7055
+ const TOOLING_KEYS = {
7056
+ /** `local` or `hosted`: what `setup` last chose. Wins over everything else. */
7057
+ target: "DEVTOOLS_TARGET",
7058
+ dbUrl: "DB_URL",
7059
+ secretKey: "SECRET_KEY",
7060
+ accessToken: "SUPABASE_ACCESS_TOKEN"
7061
+ };
7062
+ var TargetError = class extends Error {
7063
+ constructor(message) {
7064
+ super(message);
7065
+ this.name = "TargetError";
7066
+ }
7067
+ };
7068
+ /** The project ref or vanity name in a Supabase URL or pooler URL, if it has one. */
7069
+ function projectNameOf(url) {
7070
+ try {
7071
+ const { hostname, username } = new URL(url);
7072
+ const fromHost = /^([a-z0-9-]+)\.supabase\.co$/.exec(hostname)?.[1];
7073
+ if (fromHost) return fromHost;
7074
+ const fromUser = /^postgres\.([a-z0-9]+)$/.exec(username)?.[1];
7075
+ if (fromUser) return fromUser;
7076
+ return /^db\.([a-z0-9]+)\.supabase\.co$/.exec(hostname)?.[1] ?? null;
7077
+ } catch {
7078
+ return null;
7079
+ }
7080
+ }
7081
+ /** Refuses a URL that names one of the product's production projects. */
7082
+ function assertNotProduction(product, ...urls) {
7083
+ const production = new Set(product.config.production.projects);
7084
+ for (const url of urls) {
7085
+ const name = projectNameOf(url);
7086
+ if (name !== null && production.has(name)) throw new TargetError(`${url} is ${product.config.name}'s production project (${name}). devtools only acts on your own development project or the local Docker stack.`);
7087
+ }
7088
+ }
7089
+ /** Reads `supabase status -o env` for the running local stack. */
7090
+ async function localTarget(product, cli, run) {
7091
+ const args = [
7092
+ ...cli.prefix,
7093
+ "status",
7094
+ "-o",
7095
+ "env"
7096
+ ];
7097
+ const result = await run(cli.command, args, { cwd: product.root });
7098
+ if (result.code !== 0) throw new TargetError(`The local Supabase stack is not running (${cliLine(cli, ["status"])} failed). Start it with \`devtools supabase start\`, or run \`devtools setup\`.`);
7099
+ const env = parse(result.stdout);
7100
+ const apiUrl = env["API_URL"];
7101
+ const dbUrl = env["DB_URL"];
7102
+ const publishableKey = env["PUBLISHABLE_KEY"] ?? env["ANON_KEY"];
7103
+ const secretKey = env["SERVICE_ROLE_KEY"] ?? env["SECRET_KEY"];
7104
+ if (!apiUrl || !dbUrl || !publishableKey || !secretKey) throw new TargetError("`supabase status -o env` did not report the API URL, DB URL and keys. Is the stack fully started?");
7105
+ return {
7106
+ kind: "local",
7107
+ apiUrl,
7108
+ dbUrl,
7109
+ publishableKey,
7110
+ secretKey
7111
+ };
7112
+ }
7113
+ /** The contributor's own hosted project, from `.env` and `.env.tooling`. */
7114
+ function hostedTarget(product) {
7115
+ const pub = readEnvFile(product.paths.envFile);
7116
+ const tooling = readEnvFile(product.paths.toolingFile);
7117
+ const { keys } = product.config.env;
7118
+ const apiUrl = pub[keys.url];
7119
+ const publishableKey = pub[keys.publishableKey];
7120
+ const dbUrl = tooling[TOOLING_KEYS.dbUrl];
7121
+ const secretKey = tooling[TOOLING_KEYS.secretKey];
7122
+ const missing = [
7123
+ [
7124
+ keys.url,
7125
+ apiUrl,
7126
+ product.config.env.file
7127
+ ],
7128
+ [
7129
+ keys.publishableKey,
7130
+ publishableKey,
7131
+ product.config.env.file
7132
+ ],
7133
+ [
7134
+ TOOLING_KEYS.dbUrl,
7135
+ dbUrl,
7136
+ product.config.env.tooling
7137
+ ],
7138
+ [
7139
+ TOOLING_KEYS.secretKey,
7140
+ secretKey,
7141
+ product.config.env.tooling
7142
+ ]
7143
+ ].filter(([, value]) => !value);
7144
+ if (missing.length > 0 || !apiUrl || !publishableKey || !dbUrl || !secretKey) throw new TargetError(`A hosted project needs ${missing.map(([key, , file]) => `${String(key)} (in ${String(file)})`).join(", ")}. Run \`devtools setup --hosted\`.`);
7145
+ assertNotProduction(product, apiUrl, dbUrl);
7146
+ return {
7147
+ kind: "hosted",
7148
+ apiUrl,
7149
+ dbUrl,
7150
+ publishableKey,
7151
+ secretKey
7152
+ };
7153
+ }
7154
+ /**
7155
+ * `--local` or `--hosted` if given; else what `setup` recorded in
7156
+ * `.env.tooling`; else hosted when that file carries a `DB_URL` (the
7157
+ * contributor chose their own project) and the local stack when it does not.
7158
+ */
7159
+ function chooseKind(product, preference) {
7160
+ if (preference) return preference;
7161
+ const tooling = readEnvFile(product.paths.toolingFile);
7162
+ const recorded = tooling[TOOLING_KEYS.target];
7163
+ if (recorded === "local" || recorded === "hosted") return recorded;
7164
+ return tooling[TOOLING_KEYS.dbUrl] ? "hosted" : "local";
7165
+ }
7166
+ async function resolveTarget(product, cli, run, preference) {
7167
+ return chooseKind(product, preference) === "hosted" ? hostedTarget(product) : localTarget(product, cli, run);
7168
+ }
7169
+ //#endregion
7170
+ //#region src/product/dev-data.ts
7171
+ /**
7172
+ * `dev-data`: fill a development database with believable rows (DogPack's fake
7173
+ * hosts, and open, full and past sessions for the member's own courses).
7174
+ *
7175
+ * The product supplies the SQL (`devData.file`); devtools supplies who to
7176
+ * run it for. The file reads the signed-in member from a session setting:
7177
+ *
7178
+ * ```sql
7179
+ * select current_setting('devtools.member_id')::uuid;
7180
+ * ```
7181
+ *
7182
+ * `devtools.member_id` is the Supabase `auth.users` id of the member, found as
7183
+ * the most recent sign-in through "Sign in with DevDogs" unless `--member`
7184
+ * names someone. Nothing is seeded until someone has signed in, which is why
7185
+ * this is a command run afterwards and not part of `setup`.
7186
+ *
7187
+ * It refuses any database that is not the local stack or the contributor's own
7188
+ * project, and the SQL runs in one transaction.
7189
+ */
7190
+ var DevDataError = class extends Error {
7191
+ constructor(message) {
7192
+ super(message);
7193
+ this.name = "DevDataError";
7194
+ }
7195
+ };
7196
+ function postgresSession(dbUrl) {
7197
+ const sql = postgres(dbUrl, {
7198
+ max: 1,
7199
+ onnotice: () => void 0
7200
+ });
7201
+ return {
7202
+ async members(provider) {
7203
+ return (await sql`
7204
+ select u.id, u.email, u.last_sign_in_at
7205
+ from auth.users u
7206
+ join auth.identities i on i.user_id = u.id
7207
+ where i.provider = ${provider}
7208
+ order by u.last_sign_in_at desc nulls last
7209
+ limit 5`).map((r) => ({
7210
+ id: r.id,
7211
+ email: r.email,
7212
+ lastSignInAt: r.last_sign_in_at?.toISOString() ?? null
7213
+ }));
7214
+ },
7215
+ async memberByRef(ref) {
7216
+ const row = (await sql`
7217
+ select id, email, last_sign_in_at from auth.users
7218
+ where id::text = ${ref} or lower(email) = lower(${ref})
7219
+ limit 1`)[0];
7220
+ return row ? {
7221
+ id: row.id,
7222
+ email: row.email,
7223
+ lastSignInAt: row.last_sign_in_at?.toISOString() ?? null
7224
+ } : null;
7225
+ },
7226
+ async runAs(memberId, text) {
7227
+ await sql.begin(async (tx) => {
7228
+ await tx`select set_config('devtools.member_id', ${memberId}, true)`;
7229
+ await tx.unsafe(text);
7230
+ });
7231
+ },
7232
+ close: () => sql.end({ timeout: 5 })
7233
+ };
7234
+ }
7235
+ async function runDevData(product, target, open, options) {
7236
+ const log = options.log ?? (() => void 0);
7237
+ const { devData } = product.config;
7238
+ if (!devData) throw new DevDataError(`${product.config.name} has no dev data: add "devData": { "file": "supabase/dev-data.sql" } to devtools.config.json.`);
7239
+ try {
7240
+ assertNotProduction(product, target.apiUrl, target.dbUrl);
7241
+ } catch (err) {
7242
+ if (err instanceof TargetError) throw new DevDataError(err.message);
7243
+ throw err;
7244
+ }
7245
+ const file = join(product.root, devData.file);
7246
+ const text = readFileSync(file, "utf8");
7247
+ const session = open(target.dbUrl);
7248
+ try {
7249
+ const member = options.member ? await session.memberByRef(options.member) : (await session.members(options.provider))[0] ?? null;
7250
+ if (!member) throw new DevDataError(options.member ? `No user matches ${options.member} in this database.` : `Nobody has signed in with DevDogs on this database yet. Start the app, sign in, then run this again (or name someone with --member).`);
7251
+ log(`Seeding as ${member.email ?? member.id} (${member.id})`);
7252
+ if (options.dryRun) {
7253
+ log(`Would run ${devData.file} (${text.split("\n").length} lines) in one transaction.`);
7254
+ return null;
7255
+ }
7256
+ await session.runAs(member.id, text);
7257
+ return {
7258
+ member,
7259
+ file: devData.file
7260
+ };
7261
+ } finally {
7262
+ await session.close();
7263
+ }
7264
+ }
7265
+ //#endregion
7266
+ //#region src/product/management.ts
7267
+ /**
7268
+ * The two hosted-project auth settings "Sign in with DevDogs" needs and a
7269
+ * contributor would otherwise click through the dashboard for:
7270
+ *
7271
+ * * `custom_oauth_enabled`: off by default, and GoTrue's custom-provider
7272
+ * endpoint answers 404 "Custom OAuth providers are disabled" until it is
7273
+ * on, so registering `custom:devdogsuga` fails without it;
7274
+ * * `uri_allow_list`: the app's return addresses, which the local stack
7275
+ * reads from `supabase/config.toml` instead.
7276
+ *
7277
+ * Both are the Management API's `PATCH /v1/projects/{ref}/config/auth`, with a
7278
+ * personal access token (`SUPABASE_ACCESS_TOKEN`, from the dashboard's Access
7279
+ * Tokens page). Without a token `setup` prints the two dashboard steps.
7280
+ */
7281
+ const API = "https://api.supabase.com";
7282
+ var ManagementError = class extends Error {
7283
+ status;
7284
+ constructor(message, status) {
7285
+ super(message);
7286
+ this.status = status;
7287
+ this.name = "ManagementError";
7288
+ }
7289
+ };
7290
+ /** The project ref in `https://<ref>.supabase.co`, or null for a custom domain or local URL. */
7291
+ function projectRefOf(apiUrl) {
7292
+ try {
7293
+ return /^([a-z0-9]{20})\.supabase\.co$/.exec(new URL(apiUrl).hostname)?.[1] ?? null;
7294
+ } catch {
7295
+ return null;
7296
+ }
7297
+ }
7298
+ /** The allow list with `wanted` added, keeping what was there; comma-separated as GoTrue stores it. */
7299
+ function mergeAllowList(current, wanted) {
7300
+ const entries = (current ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
7301
+ for (const uri of wanted) if (!entries.includes(uri)) entries.push(uri);
7302
+ return entries.join(",");
7303
+ }
7304
+ async function ensureHostedAuthSettings(input) {
7305
+ const doFetch = input.fetch ?? fetch;
7306
+ const headers = {
7307
+ authorization: `Bearer ${input.token}`,
7308
+ "content-type": "application/json"
7309
+ };
7310
+ const url = `${API}/v1/projects/${input.projectRef}/config/auth`;
7311
+ const current = await doFetch(url, { headers });
7312
+ if (!current.ok) throw new ManagementError(current.status === 401 || current.status === 403 ? "The Supabase access token was refused. Create one at https://supabase.com/dashboard/account/tokens." : `Reading the project's auth settings failed (${current.status}).`, current.status);
7313
+ const settings = await current.json();
7314
+ const patch = {};
7315
+ const changed = [];
7316
+ if (settings.custom_oauth_enabled !== true) {
7317
+ patch["custom_oauth_enabled"] = true;
7318
+ changed.push("custom_oauth_enabled");
7319
+ }
7320
+ const allow = mergeAllowList(settings.uri_allow_list, input.redirectUris);
7321
+ if (allow !== (settings.uri_allow_list ?? "")) {
7322
+ patch["uri_allow_list"] = allow;
7323
+ changed.push("uri_allow_list");
7324
+ }
7325
+ if (changed.length === 0) return { changed };
7326
+ const response = await doFetch(url, {
7327
+ method: "PATCH",
7328
+ headers,
7329
+ body: JSON.stringify(patch)
7330
+ });
7331
+ if (!response.ok) throw new ManagementError(`Updating the project's auth settings failed (${response.status}).`, response.status);
7332
+ return { changed };
7333
+ }
7334
+ //#endregion
7335
+ //#region src/product/oauth.ts
7336
+ /**
7337
+ * "Sign in with DevDogs" for a product's development database, without
7338
+ * prompts: the non-interactive halves of `devtools oauth`.
7339
+ *
7340
+ * The wizard (`../oauth/wizard.ts`) is the front door and is what `setup`
7341
+ * runs for a person. These two exist for the cases that have no person:
7342
+ *
7343
+ * * a fresh-machine or CI run that already holds an OAuth client's id and
7344
+ * secret (`DEVDOGS_OAUTH_CLIENT_ID` / `DEVDOGS_OAUTH_CLIENT_SECRET`);
7345
+ * * `db sync --rebuild`, which resets the local database and with it
7346
+ * `auth.custom_oauth_providers`, so the provider is registered again from
7347
+ * the credentials the wizard saved to the product's `.env.tooling`.
7348
+ */
7349
+ var OAuthRegistrationError = class extends Error {
7350
+ constructor(message) {
7351
+ super(message);
7352
+ this.name = "OAuthRegistrationError";
7353
+ }
7354
+ };
7355
+ function credentialsFromEnv(env) {
7356
+ const clientId = env["DEVDOGS_OAUTH_CLIENT_ID"];
7357
+ const clientSecret = env["DEVDOGS_OAUTH_CLIENT_SECRET"];
7358
+ if (!clientId || !clientSecret) return null;
7359
+ return {
7360
+ clientId,
7361
+ clientSecret,
7362
+ baseUrl: env["DEVDOGS_OAUTH_BASE_URL"]
7363
+ };
7364
+ }
7365
+ /** Registers `custom:devdogsuga` on the target with these credentials. */
7366
+ async function registerProvider(credentialsFile, target, credentials) {
7367
+ const { issuer } = await fetchIssuerFromAny(credentials.baseUrl ? [credentials.baseUrl] : DEFAULT_API_URLS);
7368
+ const row = await upsertDevDogsProvider({
7369
+ apiUrl: target.apiUrl,
7370
+ serviceRoleKey: target.secretKey
7371
+ }, {
7372
+ identifier: OAUTH.providerId,
7373
+ name: PROVIDER_NAME,
7374
+ clientId: credentials.clientId,
7375
+ clientSecret: credentials.clientSecret,
7376
+ issuer
7377
+ });
7378
+ upsertEnvFile(credentialsFile, {
7379
+ [ENV_KEYS.clientId]: credentials.clientId,
7380
+ [ENV_KEYS.clientSecret]: credentials.clientSecret
7381
+ });
7382
+ return row;
7383
+ }
7384
+ /** Registers the provider again from what the wizard saved to `.env.tooling`. */
7385
+ async function reRegisterSavedProvider(credentialsFile, target) {
7386
+ const saved = readEnvFile(credentialsFile);
7387
+ const clientId = saved[ENV_KEYS.clientId];
7388
+ const clientSecret = saved[ENV_KEYS.clientSecret];
7389
+ if (!clientId || !clientSecret) throw new OAuthRegistrationError(`No saved OAuth client in ${basename(credentialsFile)}, so "Sign in with DevDogs" cannot be registered again automatically. Run \`devtools oauth\`.`);
7390
+ return registerProvider(credentialsFile, target, {
7391
+ clientId,
7392
+ clientSecret,
7393
+ baseUrl: saved[ENV_KEYS.baseUrl]
7394
+ });
7395
+ }
7396
+ //#endregion
7397
+ //#region src/product/schema-hash.ts
7398
+ /**
7399
+ * The schema hash that names a set of generated types.
7400
+ *
7401
+ * Generated types are never committed (Q82). CI builds them once per schema
7402
+ * change and uploads them to a rolling `generated-types` release; a
7403
+ * contributor who cannot (or would rather not) generate them downloads the
7404
+ * asset whose name carries the hash of the schema they have checked out. Two
7405
+ * checkouts with the same hash have the same types, so the hash must change
7406
+ * exactly when the generated output could.
7407
+ *
7408
+ * It covers:
7409
+ * * every file under `supabase/schemas`, by sorted path (POSIX separators,
7410
+ * relative to the schemas directory) and LF-normalised contents, so a
7411
+ * Windows checkout with CRLF hashes like a Linux one;
7412
+ * * the generator and its version (the pinned Supabase CLI for TypeScript,
7413
+ * supadart for Dart), since a new generator rewrites the output.
7414
+ */
7415
+ function listFiles(dir) {
7416
+ if (!existsSync(dir)) return [];
7417
+ const found = [];
7418
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
7419
+ const path = join(dir, entry.name);
7420
+ if (entry.isDirectory()) found.push(...listFiles(path));
7421
+ else found.push(path);
7422
+ }
7423
+ return found;
7424
+ }
7425
+ /** The files a hash covers, as POSIX paths relative to the schemas directory, sorted. */
7426
+ function schemaFiles(schemasDir) {
7427
+ return listFiles(schemasDir).map((path) => relative(schemasDir, path).split(sep).join("/")).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
7428
+ }
7429
+ function normalise(contents) {
7430
+ return Buffer.from(contents.toString("utf8").replace(/\r\n/g, "\n"), "utf8");
7431
+ }
7432
+ function hashSchemas(schemasDir, generator) {
7433
+ const hash = createHash("sha256");
7434
+ for (const path of schemaFiles(schemasDir)) {
7435
+ hash.update(`file:${path}\0`);
7436
+ hash.update(normalise(readFileSync(join(schemasDir, ...path.split("/")))));
7437
+ hash.update("\0");
7438
+ }
7439
+ hash.update(`generator:${generator}`);
7440
+ return hash.digest("hex");
7441
+ }
7442
+ /**
7443
+ * The generator and version string a product's types come from. For supadart
7444
+ * it is the version `pubspec.lock` resolved, else the constraint in
7445
+ * `pubspec.yaml`, so a Dart SDK bump that changes the models changes the hash.
7446
+ */
7447
+ function generatorFor(product) {
7448
+ const { types, supabase } = product.config;
7449
+ if (types.kind === "typescript") return `supabase@${supabase.version}`;
7450
+ const lock = join(product.root, "pubspec.lock");
7451
+ if (existsSync(lock)) {
7452
+ const match = /^ {2}supadart:\n(?:.*\n)*? {4}version: "([^"]+)"/m.exec(readFileSync(lock, "utf8"));
7453
+ if (match?.[1]) return `supadart@${match[1]}`;
7454
+ }
7455
+ const pubspec = join(product.root, "pubspec.yaml");
7456
+ if (existsSync(pubspec)) {
7457
+ const match = /^\s+supadart:\s*([^\s#]+)/m.exec(readFileSync(pubspec, "utf8"));
7458
+ if (match?.[1]) return `supadart@${match[1]}`;
7459
+ }
7460
+ return "supadart@unknown";
7461
+ }
7462
+ function schemaHashFor(product) {
7463
+ return hashSchemas(product.paths.schemasDir, generatorFor(product));
7464
+ }
7465
+ /** The release asset CI uploads for a hash, and `db types --download` looks for. */
7466
+ function typesAssetName(product, hash) {
7467
+ return `${product}-types-${hash}.tgz`;
7468
+ }
7469
+ //#endregion
7470
+ //#region src/product/sync.ts
7471
+ /**
7472
+ * `db sync`: make the contributor's development database match
7473
+ * `supabase/schemas/`.
7474
+ *
7475
+ * The schema files are the source; migrations are not committed in a product
7476
+ * (Q9), but the Supabase CLI's declarative flow still works in terms of them,
7477
+ * so a local, git-ignored `supabase/migrations/` is the record of what this
7478
+ * database has had applied:
7479
+ *
7480
+ * 1. `supabase db schema declarative sync --no-apply` diffs the schema tree
7481
+ * against that record and writes the difference as a new migration, or
7482
+ * nothing when they already agree. It needs Docker (a shadow database)
7483
+ * but never touches the target.
7484
+ * 2. The new migration is applied: `supabase migration up --local` for the
7485
+ * Docker stack, `supabase db push --db-url …` for a hosted project.
7486
+ *
7487
+ * A hosted project's applied migrations are also in its own history table, so a
7488
+ * machine with no local record (a second laptop, a deleted `supabase/migrations/`)
7489
+ * restores the record from there first (`supabase migration fetch --db-url`).
7490
+ * Without that, the diff would start from an empty database and write the whole
7491
+ * schema as one migration, which `db push` rejects as already applied.
7492
+ *
7493
+ * It never resets the database. A reset wipes `auth.custom_oauth_providers`,
7494
+ * which is where "Sign in with DevDogs" is registered (Q83); `--rebuild` is
7495
+ * the explicit escape hatch for a database that has drifted from its record,
7496
+ * and it registers the provider again afterwards.
7497
+ */
7498
+ var SyncError = class extends Error {
7499
+ output;
7500
+ constructor(message, output) {
7501
+ super(message);
7502
+ this.output = output;
7503
+ this.name = "SyncError";
7504
+ }
7505
+ };
7506
+ /** `DROP TABLE|COLUMN|SCHEMA|TYPE|VIEW`, which loses data or breaks readers; constraints, indexes and policies do not. */
7507
+ const DESTRUCTIVE = /\bDROP\s+(?:TABLE|COLUMN|SCHEMA|TYPE|VIEW|MATERIALIZED\s+VIEW|FUNCTION)\b/i;
7508
+ function destructiveStatements(sql) {
7509
+ return sql.split(/;\s*(?:\n|$)/).map((statement) => statement.trim()).filter((statement) => DESTRUCTIVE.test(statement.replace(/--.*$/gm, "")));
7510
+ }
7511
+ function migrationFiles(dir) {
7512
+ return existsSync(dir) ? readdirSync(dir).filter((name) => name.endsWith(".sql")).sort() : [];
7513
+ }
7514
+ async function dbSync(product, cli, target, run, options) {
7515
+ const log = options.log ?? (() => void 0);
7516
+ const { migrationsDir, schemasDir } = product.paths;
7517
+ if (!existsSync(schemasDir)) throw new SyncError(`${product.config.supabase.dir}/schemas does not exist; there is nothing to sync.`);
7518
+ mkdirSync(migrationsDir, { recursive: true });
7519
+ const generateArgs = [
7520
+ ...cli.prefix,
7521
+ "db",
7522
+ "schema",
7523
+ "declarative",
7524
+ "sync",
7525
+ "--no-apply",
7526
+ "--name",
7527
+ "devtools_sync"
7528
+ ];
7529
+ const fetchArgs = [
7530
+ ...cli.prefix,
7531
+ "migration",
7532
+ "fetch",
7533
+ "--db-url",
7534
+ target.dbUrl
7535
+ ];
7536
+ const needsRecord = target.kind === "hosted" && migrationFiles(migrationsDir).length === 0;
7537
+ if (options.dryRun) {
7538
+ if (needsRecord) log(`Would run: ${cliLine(cli, [
7539
+ "migration",
7540
+ "fetch",
7541
+ "--db-url",
7542
+ "<DB_URL>"
7543
+ ])} (this machine has no record of what the project has had applied)`);
7544
+ log(`Would run: ${cliLine(cli, generateArgs.slice(cli.prefix.length))}`);
7545
+ log(target.kind === "local" ? `Would run: ${cliLine(cli, [
7546
+ "migration",
7547
+ "up",
7548
+ "--local"
7549
+ ])}` : `Would run: ${cliLine(cli, [
7550
+ "db",
7551
+ "push",
7552
+ "--db-url",
7553
+ "<DB_URL>",
7554
+ "--yes"
7555
+ ])}`);
7556
+ return {
7557
+ migration: null,
7558
+ applied: false,
7559
+ destructive: []
7560
+ };
7561
+ }
7562
+ if (needsRecord) {
7563
+ const fetched = await run(cli.command, fetchArgs, { cwd: product.root });
7564
+ const restored = migrationFiles(migrationsDir).length;
7565
+ if (fetched.code === 0 && restored > 0) log(`Restored ${restored} applied migration${restored === 1 ? "" : "s"} from the project's history.`);
7566
+ }
7567
+ const before = new Set(migrationFiles(migrationsDir));
7568
+ let generated = await run(cli.command, generateArgs, { cwd: product.root });
7569
+ if (generated.code !== 0 && isContainerFailure(generated)) {
7570
+ log("The shadow database did not start; trying once more.");
7571
+ generated = await run(cli.command, generateArgs, { cwd: product.root });
7572
+ }
7573
+ if (generated.code !== 0) throw new SyncError(isContainerFailure(generated) ? "Could not diff the schema: Docker is needed for the shadow database. Start Docker and try again." : "Could not diff the schema against what this database has had applied.", generated.stderr || generated.stdout);
7574
+ const created = migrationFiles(migrationsDir).filter((f) => !before.has(f));
7575
+ if (created.length === 0) {
7576
+ log("The database already matches supabase/schemas.");
7577
+ return {
7578
+ migration: null,
7579
+ applied: false,
7580
+ destructive: []
7581
+ };
7582
+ }
7583
+ const migration = created[created.length - 1];
7584
+ const sql = readFileSync(join(migrationsDir, migration), "utf8");
7585
+ const destructive = destructiveStatements(sql);
7586
+ if (destructive.length > 0 && !options.yes) {
7587
+ if (!(options.confirmDestructive ? await options.confirmDestructive(sql, destructive) : false)) {
7588
+ for (const file of created) rmSync(join(migrationsDir, file));
7589
+ throw new SyncError(`The change drops data in your development database (${destructive.length} statement${destructive.length === 1 ? "" : "s"}). Re-run with --yes to allow it.`, destructive.join(";\n"));
7590
+ }
7591
+ }
7592
+ log(`Applying ${migration}`);
7593
+ const applyArgs = target.kind === "local" ? [
7594
+ ...cli.prefix,
7595
+ "migration",
7596
+ "up",
7597
+ "--local"
7598
+ ] : [
7599
+ ...cli.prefix,
7600
+ "db",
7601
+ "push",
7602
+ "--db-url",
7603
+ target.dbUrl,
7604
+ "--yes"
7605
+ ];
7606
+ const applied = await run(cli.command, applyArgs, { cwd: product.root });
7607
+ if (applied.code !== 0) {
7608
+ for (const file of created) rmSync(join(migrationsDir, file));
7609
+ throw new SyncError("The change did not apply. Your schema files may not be valid SQL, or the database has drifted from what devtools recorded (`devtools db sync --rebuild` starts over).", applied.stderr || applied.stdout);
7610
+ }
7611
+ return {
7612
+ migration,
7613
+ applied: true,
7614
+ destructive
7615
+ };
7616
+ }
7617
+ function isContainerFailure(result) {
7618
+ return /docker|container|image/i.test(result.stderr + result.stdout);
7619
+ }
7620
+ /** Forgets what was applied: removes the git-ignored local migrations. */
7621
+ function forgetMigrations(product) {
7622
+ const files = migrationFiles(product.paths.migrationsDir);
7623
+ for (const file of files) rmSync(join(product.paths.migrationsDir, file));
7624
+ return files;
7625
+ }
7626
+ //#endregion
7627
+ //#region src/product/types.ts
7628
+ /**
7629
+ * `db types`: the generated TypeScript types or Dart models for a product's
7630
+ * schema. Never committed (Q82); three ways to have them:
7631
+ *
7632
+ * * generate them from the contributor's own database (`db types`);
7633
+ * * download the asset CI built for the exact schema hash
7634
+ * (`db types --download`), which needs no database at all;
7635
+ * * pack what was generated into that asset (`db types --pack file.tgz`),
7636
+ * which is what CI does on every schema change on `main`.
7637
+ */
7638
+ var TypesError = class extends Error {
7639
+ output;
7640
+ constructor(message, output) {
7641
+ super(message);
7642
+ this.output = output;
7643
+ this.name = "TypesError";
7644
+ }
7645
+ };
7646
+ function readTypesState(product) {
7647
+ try {
7648
+ return JSON.parse(readFileSync(product.paths.stateFile, "utf8"));
7649
+ } catch {
7650
+ return null;
7651
+ }
7652
+ }
7653
+ function writeTypesState(product, state) {
7654
+ mkdirSync(dirname(product.paths.stateFile), { recursive: true });
7655
+ writeFileSync(product.paths.stateFile, `${JSON.stringify(state, null, 2)}\n`);
7656
+ }
7657
+ /** Whether the types on disk were made from the schema as it is now. */
7658
+ function typesAreCurrent(product) {
7659
+ const state = readTypesState(product);
7660
+ return state !== null && existsSync(join(product.root, product.config.types.out)) && state.hash === schemaHashFor(product);
7661
+ }
7662
+ function redact(output, secret) {
7663
+ return secret.length > 0 ? output.split(secret).join("<DB_URL>") : output;
7664
+ }
7665
+ async function generateTypes(product, cli, target, run, log = () => void 0) {
7666
+ const { types, supabase } = product.config;
7667
+ const out = join(product.root, types.out);
7668
+ const hash = schemaHashFor(product);
7669
+ if (types.kind === "typescript") {
7670
+ const args = [
7671
+ ...cli.prefix,
7672
+ "gen",
7673
+ "types",
7674
+ "typescript",
7675
+ ...target.kind === "local" ? ["--local"] : ["--db-url", target.dbUrl],
7676
+ "--schema",
7677
+ supabase.schemas.join(",")
7678
+ ];
7679
+ log(`Running ${cliLine(cli, args.slice(cli.prefix.length).map((arg) => arg === target.dbUrl ? "<DB_URL>" : arg))}`);
7680
+ const result = await run(cli.command, args, { cwd: product.root });
7681
+ if (result.code !== 0 || result.stdout.trim().length === 0) throw new TypesError("The Supabase CLI could not generate types. Is the database up and synced (`devtools db sync`)?", redact(result.stderr || result.stdout, target.dbUrl));
7682
+ mkdirSync(dirname(out), { recursive: true });
7683
+ writeFileSync(out, `// @generated by \`devtools db types\` (${generatorFor(product)}) from supabase/schemas, hash ${hash.slice(0, 12)}. Not committed.\n${result.stdout}`);
7684
+ } else {
7685
+ log("Running dart run supadart");
7686
+ const result = await run("dart", ["run", "supadart"], {
7687
+ cwd: product.root,
7688
+ env: {
7689
+ SUPABASE_URL: target.apiUrl,
7690
+ SUPABASE_API_KEY: target.secretKey
7691
+ }
7692
+ });
7693
+ if (result.code !== 0) throw new TypesError("supadart could not generate the models. It needs Dart on PATH, `dart pub get` run once, and a synced database.", result.stderr || result.stdout);
7694
+ if (!existsSync(out)) throw new TypesError(`supadart ran but ${types.out} does not exist; check that ${types.config} writes its output there.`);
7695
+ }
7696
+ const state = {
7697
+ hash,
7698
+ generator: generatorFor(product),
7699
+ source: "generated",
7700
+ at: (/* @__PURE__ */ new Date()).toISOString()
7701
+ };
7702
+ writeTypesState(product, state);
7703
+ return state;
7704
+ }
7705
+ /** `tar` the generated output into one asset, named for the hash by the caller. */
7706
+ async function packTypes(product, file, run) {
7707
+ const out = join(product.root, product.config.types.out);
7708
+ if (!existsSync(out)) throw new TypesError(`${product.config.types.out} does not exist; run \`devtools db types\` first.`);
7709
+ const result = await run("tar", [
7710
+ "-czf",
7711
+ file,
7712
+ "-C",
7713
+ dirname(out),
7714
+ basename(out)
7715
+ ], { cwd: product.root });
7716
+ if (result.code !== 0) throw new TypesError("tar could not write the asset.", result.stderr);
7717
+ }
7718
+ async function downloadTypes(product, deps, log = () => void 0) {
7719
+ const source = product.config.generatedTypes;
7720
+ if (!source) throw new TypesError("This product publishes no generated types (no `generatedTypes` in devtools.config.json). Run `devtools db types` against a synced database instead.");
7721
+ const hash = schemaHashFor(product);
7722
+ const wanted = typesAssetName(product.config.product, hash);
7723
+ const headers = {
7724
+ accept: "application/vnd.github+json",
7725
+ "user-agent": "devdogsuga-devtools"
7726
+ };
7727
+ if (deps.token) headers["authorization"] = `Bearer ${deps.token}`;
7728
+ log(`Looking for ${wanted} in ${source.repo}@${source.tag}`);
7729
+ const release = await deps.fetch(`https://api.github.com/repos/${source.repo}/releases/tags/${source.tag}`, { headers });
7730
+ if (!release.ok) throw new TypesError(`Could not read the ${source.tag} release of ${source.repo} (${release.status}). Run \`devtools setup\` to generate the types from your own database instead.`);
7731
+ const asset = (await release.json()).assets?.find((a) => a.name === wanted);
7732
+ if (!asset) throw new TypesError(`There are no generated types for your schema yet (hash ${hash.slice(0, 12)}). CI builds them when a schema change lands on main; until then run \`devtools setup\` to generate them from your own database.`);
7733
+ const download = await deps.fetch(asset.browser_download_url, { headers: { "user-agent": "devdogsuga-devtools" } });
7734
+ if (!download.ok) throw new TypesError(`Downloading ${wanted} failed (${download.status}).`);
7735
+ const out = join(product.root, product.config.types.out);
7736
+ const staging = join(product.root, ".devtools", "download");
7737
+ rmSync(staging, {
7738
+ recursive: true,
7739
+ force: true
7740
+ });
7741
+ mkdirSync(staging, { recursive: true });
7742
+ const archive = join(staging, wanted);
7743
+ writeFileSync(archive, Buffer.from(await download.arrayBuffer()));
7744
+ const extracted = await deps.run("tar", [
7745
+ "-xzf",
7746
+ archive,
7747
+ "-C",
7748
+ staging
7749
+ ], { cwd: product.root });
7750
+ if (extracted.code !== 0) throw new TypesError("tar could not read the downloaded asset.", extracted.stderr);
7751
+ const unpacked = join(staging, basename(out));
7752
+ if (!existsSync(unpacked)) throw new TypesError(`The asset does not contain ${basename(out)}.`);
7753
+ rmSync(out, {
7754
+ recursive: true,
7755
+ force: true
7756
+ });
7757
+ mkdirSync(dirname(out), { recursive: true });
7758
+ renameSync(unpacked, out);
7759
+ rmSync(staging, {
7760
+ recursive: true,
7761
+ force: true
7762
+ });
7763
+ const state = {
7764
+ hash,
7765
+ generator: generatorFor(product),
7766
+ source: "downloaded",
7767
+ at: (/* @__PURE__ */ new Date()).toISOString()
7768
+ };
7769
+ writeTypesState(product, state);
7770
+ return state;
7771
+ }
7772
+ //#endregion
7773
+ //#region src/product/commands.ts
7774
+ /**
7775
+ * The product-mode commands: `setup`, `db sync|types|hash`, `dev-data`,
7776
+ * `doctor`, `check env-example` and the pinned `supabase`.
7777
+ *
7778
+ * Each handler is a thin reader of argv over the modules beside this one
7779
+ * (`sync.ts`, `types.ts`, `dev-data.ts`, ...), which take their collaborators
7780
+ * as parameters. The handlers get the real ones from `defaultDeps`; the tests
7781
+ * give them fakes.
7782
+ */
7783
+ const defaultDeps = {
7784
+ run: spawnRunner,
7785
+ fetch: (input, init) => fetch(input, init),
7786
+ openSql: (url) => postgresSession(url),
7787
+ env: process.env,
7788
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
7789
+ interactive: () => !isNonInteractive()
7790
+ };
7791
+ function isKnown(err) {
7792
+ return err instanceof ProductConfigError || err instanceof TargetError || err instanceof SyncError || err instanceof TypesError || err instanceof DevDataError || err instanceof ManagementError || err instanceof OAuthRegistrationError;
7793
+ }
7794
+ /** Runs a handler body; a known failure prints its message and sets the exit code. */
7795
+ async function guarded(body) {
7796
+ try {
7797
+ return await body();
7798
+ } catch (err) {
7799
+ if (!isKnown(err)) throw err;
7800
+ log.error(err.message);
7801
+ const output = "output" in err ? err.output : void 0;
7802
+ if (output) process.stderr.write(`${output.trim()}\n`);
7803
+ process.exitCode = 1;
7804
+ return null;
7805
+ }
7806
+ }
7807
+ function requireProduct() {
7808
+ const product = currentProduct();
7809
+ if (!product) throw new ProductConfigError("No devtools.config.json found in this directory or above it.");
7810
+ return product;
7811
+ }
7812
+ function preferenceOf(rest) {
7813
+ const local = rest.includes("--local");
7814
+ const hosted = rest.includes("--hosted");
7815
+ if (local && hosted) throw new TargetError("--local and --hosted are mutually exclusive.");
7816
+ return local ? "local" : hosted ? "hosted" : void 0;
7817
+ }
7818
+ async function cliFor(product, deps) {
7819
+ return resolveSupabaseCli(product.root, product.config.supabase.version, deps.run);
7820
+ }
7821
+ async function confirmDestructive(_sql, statements) {
7822
+ note(statements.join(";\n"), "This change drops things in your development database");
7823
+ return unwrap(await confirm({
7824
+ message: "Apply it anyway?",
7825
+ initialValue: false
7826
+ }));
7827
+ }
7828
+ async function runSync(product, deps, rest) {
7829
+ const cli = await cliFor(product, deps);
7830
+ const preference = preferenceOf(rest);
7831
+ const kind = chooseKind(product, preference);
7832
+ const dryRun = isDryRun();
7833
+ if (rest.includes("--rebuild")) {
7834
+ if (kind !== "local") throw new SyncError("--rebuild resets the database, which devtools only does to the local Docker stack. Reset a hosted project from its dashboard (Database > Settings), then run `devtools db sync`.");
7835
+ const tracked = await deps.run("git", [
7836
+ "ls-files",
7837
+ "--",
7838
+ `${product.config.supabase.dir}/migrations`
7839
+ ], { cwd: product.root });
7840
+ const committed = tracked.stdout.split("\n").filter((file) => file.endsWith(".sql"));
7841
+ if (tracked.code === 0 && committed.length > 0) throw new SyncError(`${product.config.supabase.dir}/migrations has committed migrations (${committed[0]}, ...); a product keeps its migrations out of git, so --rebuild will not delete them.`);
7842
+ if (!hasYes(rest) && deps.interactive()) {
7843
+ if (!unwrap(await confirm({
7844
+ message: "Reset your local database and rebuild it from supabase/schemas? Its data is lost.",
7845
+ initialValue: false
7846
+ }))) bail("Nothing was changed.");
7847
+ } else if (!hasYes(rest)) throw new SyncError("--rebuild needs --yes when there is no terminal.");
7848
+ if (dryRun) log.info(`Would run: ${cliLine(cli, [
7849
+ "db",
7850
+ "reset",
7851
+ "--local",
7852
+ "--yes"
7853
+ ])} after removing the local migration record.`);
7854
+ else {
7855
+ forgetMigrations(product);
7856
+ if ((await deps.run(cli.command, [
7857
+ ...cli.prefix,
7858
+ "db",
7859
+ "reset",
7860
+ "--local",
7861
+ "--yes"
7862
+ ], {
7863
+ cwd: product.root,
7864
+ inherit: true
7865
+ })).code !== 0) throw new SyncError("`supabase db reset` failed.");
7866
+ }
7867
+ }
7868
+ const target = dryRun ? {
7869
+ kind,
7870
+ apiUrl: "",
7871
+ dbUrl: "<DB_URL>",
7872
+ publishableKey: "",
7873
+ secretKey: ""
7874
+ } : await resolveTarget(product, cli, deps.run, preference);
7875
+ const result = await dbSync(product, cli, target, deps.run, {
7876
+ yes: hasYes(rest),
7877
+ confirmDestructive: deps.interactive() ? confirmDestructive : void 0,
7878
+ dryRun,
7879
+ log: (line) => log.info(line)
7880
+ });
7881
+ if (rest.includes("--rebuild") && !dryRun) try {
7882
+ const row = await reRegisterSavedProvider(product.paths.toolingFile, target);
7883
+ log.success(`Registered ${row.identifier} again.`);
7884
+ } catch (err) {
7885
+ if (!(err instanceof OAuthRegistrationError)) throw err;
7886
+ log.warn(err.message);
7887
+ }
7888
+ return {
7889
+ result,
7890
+ cli,
7891
+ target: dryRun ? null : target
7892
+ };
7893
+ }
7894
+ /**
7895
+ * Q82: `db sync` also regenerates the types, so an edit-schema, sync loop never
7896
+ * leaves them behind. Skipped when nothing changed and the types on disk are
7897
+ * already for this schema. A failure here does not undo the sync.
7898
+ */
7899
+ async function regenerateAfterSync(product, cli, target, result, deps) {
7900
+ if (target === null) {
7901
+ log.info(`Would regenerate ${product.config.types.out}.`);
7902
+ return;
7903
+ }
7904
+ if (!result.migration && typesAreCurrent(product)) return;
7905
+ try {
7906
+ await generateTypes(product, cli, target, deps.run, (line) => log.info(line));
7907
+ log.success(`Regenerated ${product.config.types.out}.`);
7908
+ } catch (err) {
7909
+ if (!(err instanceof TypesError)) throw err;
7910
+ log.warn(`${err.message} The sync itself worked; run \`devtools db types\` once that is fixed.`);
7911
+ }
7912
+ }
7913
+ async function runTypes(product, deps, rest) {
7914
+ if (isDryRun()) {
7915
+ log.info(`Would ${rest.includes("--download") ? "download" : "generate"} ${product.config.types.out}.`);
7916
+ return DONE;
7917
+ }
7918
+ if (rest.includes("--download")) {
7919
+ const state = await downloadTypes(product, {
7920
+ fetch: deps.fetch,
7921
+ run: deps.run,
7922
+ token: deps.env["GITHUB_TOKEN"]
7923
+ }, (line) => log.info(line));
7924
+ log.success(`Downloaded the types for schema ${state.hash.slice(0, 12)} into ${product.config.types.out}.`);
7925
+ return DONE;
7926
+ }
7927
+ const cli = await cliFor(product, deps);
7928
+ const state = await generateTypes(product, cli, await resolveTarget(product, cli, deps.run, preferenceOf(rest)), deps.run, (line) => log.info(line));
7929
+ log.success(`Generated ${product.config.types.out} for schema ${state.hash.slice(0, 12)}.`);
7930
+ const pack = flagValue([...rest], "--pack");
7931
+ if (pack) {
7932
+ await packTypes(product, pack, deps.run);
7933
+ log.success(`Packed it into ${pack}.`);
7934
+ }
7935
+ return DONE;
7936
+ }
7937
+ function runHash(product) {
7938
+ process.stdout.write(`${schemaHashFor(product)}\n`);
7939
+ process.stderr.write(`generator ${generatorFor(product)}\n`);
7940
+ return DONE;
7941
+ }
7942
+ function createProductHandlers(deps = defaultDeps) {
7943
+ const handleDb = (rest) => guarded(async () => {
7944
+ const product = requireProduct();
7945
+ const [sub] = positionals([...rest]);
7946
+ const flags = rest.filter((arg) => arg !== sub);
7947
+ switch (sub) {
7948
+ case "sync": {
7949
+ const { result, cli, target } = await runSync(product, deps, flags);
7950
+ if (result.migration) log.success("Your database now matches supabase/schemas.");
7951
+ await regenerateAfterSync(product, cli, target, result, deps);
7952
+ return DONE;
7953
+ }
7954
+ case "types": return runTypes(product, deps, flags);
7955
+ case "hash": return runHash(product);
7956
+ default: throw new ProductConfigError(`db: expected sync, types or hash, got ${sub === void 0 ? "nothing" : `"${sub}"`}.`);
7957
+ }
7958
+ });
7959
+ const handleDevData = (rest) => guarded(async () => {
7960
+ const product = requireProduct();
7961
+ const done = await runDevData(product, await resolveTarget(product, await cliFor(product, deps), deps.run, preferenceOf(rest)), deps.openSql, {
7962
+ member: flagValue([...rest], "--member"),
7963
+ provider: OAUTH.providerId,
7964
+ dryRun: isDryRun(),
7965
+ log: (line) => log.info(line)
7966
+ });
7967
+ if (done) log.success(`Ran ${done.file} for ${done.member.email ?? done.member.id}.`);
7968
+ return DONE;
7969
+ });
7970
+ const handleCheck = (rest) => guarded(async () => {
7971
+ const product = requireProduct();
7972
+ const [sub] = positionals([...rest]);
7973
+ if (sub !== "env-example") throw new ProductConfigError(`check: expected env-example, got "${sub ?? ""}".`);
7974
+ const { envExample } = product.paths;
7975
+ if (!existsSync(envExample)) throw new ProductConfigError(`${product.config.env.example} does not exist.`);
7976
+ const findings = findSecrets(readFileSync(envExample, "utf8"));
7977
+ if (findings.length > 0) {
7978
+ for (const f of findings) log.error(`${product.config.env.example}: ${f.key} — ${f.reason}. This file ships in the app; keep secrets in ${product.config.env.tooling}.`);
7979
+ process.exitCode = 1;
7980
+ return null;
7981
+ }
7982
+ log.success(`${product.config.env.example} holds public values only.`);
7983
+ return DONE;
7984
+ });
7985
+ const handleSupabase = async (rest) => {
7986
+ const product = requireProduct();
7987
+ const cli = await cliFor(product, deps);
7988
+ const args = [...cli.prefix, ...rest];
7989
+ if (isDryRun()) {
7990
+ log.info(`Would run: ${commandLine(cli.command, args)}`);
7991
+ return DONE;
7992
+ }
7993
+ const result = await deps.run(cli.command, args, {
7994
+ cwd: product.root,
7995
+ inherit: true
7996
+ });
7997
+ if (result.code !== 0) process.exitCode = result.code;
7998
+ return result.code === 0 ? DONE : null;
7999
+ };
8000
+ const handleDoctor = (rest) => guarded(async () => {
8001
+ return runDoctor(requireProduct(), deps);
8002
+ });
8003
+ const handleSetup = (rest) => guarded(async () => runSetup(requireProduct(), deps, rest));
8004
+ return {
8005
+ setup: handleSetup,
8006
+ db: handleDb,
8007
+ "dev-data": handleDevData,
8008
+ doctor: handleDoctor,
8009
+ check: handleCheck,
8010
+ supabase: handleSupabase
8011
+ };
8012
+ }
8013
+ async function runDoctor(product, deps) {
8014
+ const lines = [];
8015
+ const ok = (message) => void lines.push(`OK ${message}`);
8016
+ const warn = (message) => void lines.push(`WARN ${message}`);
8017
+ const info = (message) => void lines.push(`INFO ${message}`);
8018
+ ok(`${product.config.name} (${product.config.product}), Supabase CLI pinned at ${product.config.supabase.version}`);
8019
+ const cli = await cliFor(product, deps);
8020
+ if (cli.source === "npx") info(`The pinned Supabase CLI is fetched with npx on first use (${cliLine(cli, ["--version"])}).`);
8021
+ else ok(`Supabase CLI ${product.config.supabase.version} (${cli.source})`);
8022
+ const docker = await deps.run("docker", [
8023
+ "info",
8024
+ "--format",
8025
+ "{{.ServerVersion}}"
8026
+ ], { cwd: product.root }).catch(() => ({
8027
+ code: 1,
8028
+ stdout: "",
8029
+ stderr: ""
8030
+ }));
8031
+ if (docker.code === 0) ok(`Docker ${docker.stdout.trim()}`);
8032
+ else warn("Docker is not reachable. The local stack and the schema diff need it.");
8033
+ if (product.config.types.kind === "supadart") {
8034
+ if ((await deps.run("dart", ["--version"], { cwd: product.root }).catch(() => ({
8035
+ code: 1,
8036
+ stdout: "",
8037
+ stderr: ""
8038
+ }))).code === 0) ok("Dart on PATH");
8039
+ else warn("Dart is not on PATH; `db types` needs it for supadart.");
8040
+ }
8041
+ const pub = readEnvFile(product.paths.envFile);
8042
+ const missing = Object.values(product.config.env.keys).filter((key) => !pub[key]);
8043
+ if (!existsSync(product.paths.envFile)) warn(`${product.config.env.file} does not exist. Run \`devtools setup\`.`);
8044
+ else if (missing.length > 0) warn(`${product.config.env.file} is missing ${missing.join(", ")}.`);
8045
+ else ok(`${product.config.env.file} has the project URL and publishable key`);
8046
+ const findings = existsSync(product.paths.envExample) ? findSecrets(readFileSync(product.paths.envExample, "utf8")) : [];
8047
+ if (findings.length > 0) warn(`${product.config.env.example} has secret-looking keys: ${findings.map((f) => f.key).join(", ")}.`);
8048
+ info(`Target: ${chooseKind(product, void 0) === "hosted" ? "your own hosted project" : "the local Docker stack"}`);
8049
+ try {
8050
+ ok(`Database reachable at ${(await resolveTarget(product, cli, deps.run, void 0)).apiUrl}`);
8051
+ } catch (err) {
8052
+ if (!(err instanceof TargetError)) throw err;
8053
+ warn(err.message);
8054
+ }
8055
+ if (existsSync(product.paths.schemasDir)) {
8056
+ if (typesAreCurrent(product)) ok(`Generated types match the schema (${readTypesState(product)?.source ?? "?"})`);
8057
+ else warn(`${product.config.types.out} is missing or older than supabase/schemas. Run \`devtools db types\` (or \`--download\`).`);
8058
+ }
8059
+ note(lines.join("\n"), `${product.config.name} environment`);
8060
+ return DONE;
8061
+ }
8062
+ async function chooseSetupKind(deps, preference) {
8063
+ if (preference) return preference;
8064
+ if (!deps.interactive()) return "local";
8065
+ return unwrap(await select({
8066
+ message: "Which Supabase should this checkout use?",
8067
+ options: [{
8068
+ value: "local",
8069
+ label: "Local (Docker)",
8070
+ hint: "needs Docker; nothing to create online"
8071
+ }, {
8072
+ value: "hosted",
8073
+ label: "Your own free Supabase project",
8074
+ hint: "create one at supabase.com first"
8075
+ }],
8076
+ initialValue: "local"
8077
+ }));
8078
+ }
8079
+ async function promptHosted(product, deps) {
8080
+ const tooling = readEnvFile(product.paths.toolingFile);
8081
+ const pub = readEnvFile(product.paths.envFile);
8082
+ const { keys } = product.config.env;
8083
+ const fromEnv = (name) => {
8084
+ const value = deps.env[name];
8085
+ return value === "" ? void 0 : value;
8086
+ };
8087
+ const have = {
8088
+ apiUrl: fromEnv("DEVTOOLS_SUPABASE_URL") ?? pub[keys.url],
8089
+ publishableKey: fromEnv("DEVTOOLS_SUPABASE_PUBLISHABLE_KEY") ?? pub[keys.publishableKey],
8090
+ secretKey: fromEnv("DEVTOOLS_SUPABASE_SECRET_KEY") ?? tooling[TOOLING_KEYS.secretKey],
8091
+ dbUrl: fromEnv("DEVTOOLS_DB_URL") ?? tooling[TOOLING_KEYS.dbUrl]
8092
+ };
8093
+ const complete = have.apiUrl && have.publishableKey && have.secretKey && have.dbUrl;
8094
+ if (!complete && !deps.interactive()) throw new TargetError("A hosted setup with no terminal needs DEVTOOLS_SUPABASE_URL, DEVTOOLS_SUPABASE_PUBLISHABLE_KEY, DEVTOOLS_SUPABASE_SECRET_KEY and DEVTOOLS_DB_URL in the environment.");
8095
+ if (!complete) note([
8096
+ "1. Create a free project at https://supabase.com/dashboard",
8097
+ "2. Project Settings > API: copy the Project URL, the publishable key and the secret key.",
8098
+ "3. Project Settings > Database > Connection string > Session pooler: copy that string (port 5432, not 6543)."
8099
+ ].join("\n"), "Your own Supabase project");
8100
+ const ask = async (label, current, secret) => {
8101
+ if (current) return current;
8102
+ const validate = (v) => v?.trim() ? void 0 : "Required";
8103
+ return unwrap(secret ? await password({
8104
+ message: label,
8105
+ validate
8106
+ }) : await text({
8107
+ message: label,
8108
+ validate
8109
+ })).trim();
8110
+ };
8111
+ const apiUrl = (await ask("Project URL", have.apiUrl, false)).replace(/\/+$/, "");
8112
+ const publishableKey = await ask("Publishable key", have.publishableKey, false);
8113
+ const secretKey = await ask("Secret key", have.secretKey, true);
8114
+ let dbUrl = have.dbUrl;
8115
+ for (;;) {
8116
+ dbUrl = await ask("Session pooler connection string", dbUrl, true);
8117
+ const validation = validateSessionPoolerUrl(dbUrl);
8118
+ if (validation.ok) break;
8119
+ if (!deps.interactive()) throw new TargetError(validation.message ?? "That DB_URL is not usable.");
8120
+ log.error(validation.message ?? "That DB_URL is not usable.");
8121
+ dbUrl = void 0;
8122
+ }
8123
+ assertNotProduction(product, apiUrl, dbUrl);
8124
+ return {
8125
+ kind: "hosted",
8126
+ apiUrl,
8127
+ dbUrl,
8128
+ publishableKey,
8129
+ secretKey
8130
+ };
8131
+ }
8132
+ async function runSetup(product, deps, rest) {
8133
+ const { config } = product;
8134
+ const dryRun = isDryRun();
8135
+ log.info(`Setting up ${config.name}.`);
8136
+ if (!existsSync(product.paths.envExample)) throw new ProductConfigError(`${config.env.example} does not exist; it is the manifest .env is made from.`);
8137
+ const manifest = readFileSync(product.paths.envExample, "utf8");
8138
+ const secrets = findSecrets(manifest);
8139
+ if (secrets.length > 0) throw new ProductConfigError(`${config.env.example} has secret-looking keys (${secrets.map((s) => s.key).join(", ")}); it ships in the app. Move them to ${config.env.tooling} first.`);
8140
+ if (dryRun) {
8141
+ log.info(`Would write ${config.env.file} from ${config.env.example}, start the database, sync the schema, get the types and set up sign in.`);
8142
+ return DONE;
8143
+ }
8144
+ if (!existsSync(product.paths.envFile)) {
8145
+ writeFileSync(product.paths.envFile, renderEnvFromManifest(manifest, {}));
8146
+ log.success(`Created ${config.env.file} from ${config.env.example}.`);
8147
+ }
8148
+ for (const file of [config.env.file, config.env.tooling]) if (ensureGitignored(product.root, file)) log.warn(`${file} was not ignored by version control; added /${file} to the ignore file.`);
8149
+ const cli = await cliFor(product, deps);
8150
+ const kind = await chooseSetupKind(deps, preferenceOf(rest));
8151
+ let target;
8152
+ if (kind === "local") {
8153
+ log.info("Starting the local Supabase stack (the first start downloads its images).");
8154
+ if ((await deps.run(cli.command, [
8155
+ ...cli.prefix,
8156
+ "start",
8157
+ "--ignore-health-check"
8158
+ ], {
8159
+ cwd: product.root,
8160
+ inherit: true
8161
+ })).code !== 0) throw new TargetError("`supabase start` failed. Is Docker running?");
8162
+ target = await localTarget(product, cli, deps.run);
8163
+ upsertEnvFile(product.paths.toolingFile, { [TOOLING_KEYS.target]: "local" }, "# Secrets for devtools. Never committed.");
8164
+ } else {
8165
+ target = await promptHosted(product, deps);
8166
+ upsertEnvFile(product.paths.toolingFile, {
8167
+ [TOOLING_KEYS.target]: "hosted",
8168
+ [TOOLING_KEYS.dbUrl]: target.dbUrl,
8169
+ [TOOLING_KEYS.secretKey]: target.secretKey
8170
+ }, "# Secrets for devtools. Never committed.");
8171
+ }
8172
+ upsertEnvFile(product.paths.envFile, {
8173
+ [config.env.keys.url]: target.apiUrl,
8174
+ [config.env.keys.publishableKey]: target.publishableKey
8175
+ });
8176
+ log.success(`${config.env.file} points at ${target.apiUrl}.`);
8177
+ const result = await dbSync(product, cli, target, deps.run, {
8178
+ yes: hasYes(rest),
8179
+ confirmDestructive: deps.interactive() ? confirmDestructive : void 0,
8180
+ log: (line) => log.info(line)
8181
+ });
8182
+ log.success(result.migration ? "Applied supabase/schemas to your database." : "Your database already matched supabase/schemas.");
8183
+ if (target.kind === "local") await waitForRest(target, deps, product);
8184
+ if (rest.includes("--download-types")) try {
8185
+ await downloadTypes(product, {
8186
+ fetch: deps.fetch,
8187
+ run: deps.run,
8188
+ token: deps.env["GITHUB_TOKEN"]
8189
+ }, (l) => log.info(l));
8190
+ log.success("Downloaded the generated types.");
8191
+ } catch (err) {
8192
+ if (!(err instanceof TypesError)) throw err;
8193
+ log.warn(`${err.message} Generating them from your database instead.`);
8194
+ await generateTypes(product, cli, target, deps.run, (l) => log.info(l));
8195
+ }
8196
+ else {
8197
+ await generateTypes(product, cli, target, deps.run, (l) => log.info(l));
8198
+ log.success(`Generated ${config.types.out}.`);
8199
+ }
8200
+ await setUpSignIn(product, deps, target, rest);
8201
+ note([
8202
+ config.runHint ?? "Start the app, then sign in with DevDogs.",
8203
+ "",
8204
+ "Check the whole setup any time with `devtools doctor`."
8205
+ ].join("\n"), "Next");
8206
+ return DONE;
8207
+ }
8208
+ /**
8209
+ * Waits for the API to serve the product's schema: PostgREST starts unhealthy
8210
+ * on an empty database and loads its schema cache on its next reconnect.
8211
+ */
8212
+ async function waitForRest(target, deps, product) {
8213
+ for (let attempt = 0; attempt < 30; attempt += 1) {
8214
+ try {
8215
+ if ((await deps.fetch(`${target.apiUrl}/rest/v1/`, { headers: {
8216
+ apikey: target.publishableKey,
8217
+ "accept-profile": product.config.supabase.schemas[0] ?? "public"
8218
+ } })).ok) return;
8219
+ } catch {}
8220
+ await deps.sleep(2e3);
8221
+ }
8222
+ log.warn("The local API is not serving your schema yet. `devtools doctor` and `supabase status` will say more.");
8223
+ }
8224
+ async function setUpSignIn(product, deps, target, rest) {
8225
+ if (rest.includes("--skip-oauth")) {
8226
+ log.info("Skipped \"Sign in with DevDogs\" (--skip-oauth). Run `devtools oauth` when you want it.");
8227
+ return;
8228
+ }
8229
+ if (target.kind === "hosted") {
8230
+ const ref = projectRefOf(target.apiUrl);
8231
+ const token = deps.env["SUPABASE_ACCESS_TOKEN"] ?? readEnvFile(product.paths.toolingFile)[TOOLING_KEYS.accessToken];
8232
+ if (ref && token) {
8233
+ const { changed } = await ensureHostedAuthSettings({
8234
+ projectRef: ref,
8235
+ token,
8236
+ redirectUris: product.config.oauth.redirectUris,
8237
+ fetch: deps.fetch
8238
+ });
8239
+ if (changed.length > 0) log.success(`Updated your project's auth settings: ${changed.join(", ")}.`);
8240
+ } else note([
8241
+ "Two settings on your project, in the dashboard:",
8242
+ " Authentication > Providers: turn on custom OAuth/OIDC providers.",
8243
+ ...product.config.oauth.redirectUris.length > 0 ? [` Authentication > URL Configuration > Redirect URLs: add ${product.config.oauth.redirectUris.join(", ")}`] : [],
8244
+ "Or put a personal access token in SUPABASE_ACCESS_TOKEN and run setup again."
8245
+ ].join("\n"), "Before sign in will work");
8246
+ } else {
8247
+ const configToml = join(product.paths.supabaseDir, "config.toml");
8248
+ const text = existsSync(configToml) ? readFileSync(configToml, "utf8") : "";
8249
+ const missing = product.config.oauth.redirectUris.filter((uri) => !text.includes(uri));
8250
+ if (missing.length > 0) log.warn(`Allow ${missing.join(", ")} under [auth] additional_redirect_urls in ${product.config.supabase.dir}/config.toml, or sign in will be refused on this stack.`);
8251
+ }
8252
+ process.chdir(product.root);
8253
+ const credentials = credentialsFromEnv(deps.env);
8254
+ if (credentials) {
8255
+ const row = await registerProvider(product.paths.toolingFile, target, credentials);
8256
+ log.success(`Registered ${row.identifier} from DEVDOGS_OAUTH_CLIENT_ID.`);
8257
+ return;
8258
+ }
8259
+ if (!deps.interactive()) {
8260
+ log.warn("No terminal, so \"Sign in with DevDogs\" was not set up. Set DEVDOGS_OAUTH_CLIENT_ID and DEVDOGS_OAUTH_CLIENT_SECRET, or run `devtools oauth`.");
8261
+ return;
8262
+ }
8263
+ await runOAuthSetup(void 0, void 0, void 0, {
8264
+ kind: target.kind,
8265
+ apiUrl: target.apiUrl,
8266
+ serviceRoleKey: target.secretKey
8267
+ }, product.config.env.tooling);
8268
+ }
8269
+ //#endregion
6724
8270
  //#region src/cli.ts
6725
8271
  /**
6726
8272
  * `pnpm devtools [--tier <development:local|development:remote|staging|production>] [command]`
@@ -6754,7 +8300,6 @@ const CONTRIBUTOR_HANDLERS = {
6754
8300
  setup: handleSetup,
6755
8301
  completions: handleCompletions,
6756
8302
  check: handleCheck,
6757
- run: runTask,
6758
8303
  oauth: handleOAuth,
6759
8304
  script: handleScript,
6760
8305
  jobs: handleJobs,
@@ -6764,7 +8309,8 @@ const CONTRIBUTOR_HANDLERS = {
6764
8309
  "new-migration": handleNewMigration,
6765
8310
  "apply-migrations": handleApplyMigrations,
6766
8311
  "push-config": handlePushConfig,
6767
- roles: handleRoles
8312
+ roles: handleRoles,
8313
+ moderation: handleModeration
6768
8314
  };
6769
8315
  /**
6770
8316
  * The real tools with the session's env and tier. A typed one is handled in
@@ -6789,6 +8335,22 @@ const HANDLERS = {
6789
8335
  ...PASSTHROUGH_HANDLERS
6790
8336
  };
6791
8337
  /**
8338
+ * What a product repository (one with a `devtools.config.json`) runs instead:
8339
+ * its own `setup`, `db`, `dev-data`, `doctor`, `check` and pinned `supabase`,
8340
+ * and the same `oauth` wizard. Built on first use so the platform's startup
8341
+ * does not pay for it.
8342
+ */
8343
+ let productHandlers;
8344
+ function handlersFor() {
8345
+ if (!isProductMode()) return HANDLERS;
8346
+ productHandlers ??= {
8347
+ ...createProductHandlers(),
8348
+ oauth: handleOAuth,
8349
+ moderation: handleModeration
8350
+ };
8351
+ return productHandlers;
8352
+ }
8353
+ /**
6792
8354
  * Names retired in favour of a new one, refused with the new name rather than
6793
8355
  * falling into "Unknown command": they are in this repo's own docs, scripts
6794
8356
  * and shell histories, so a bare "unknown" would leave the rename to be
@@ -6796,6 +8358,10 @@ const HANDLERS = {
6796
8358
  * environment checker, a deliberate reuse rather than a collision.
6797
8359
  */
6798
8360
  const RETIRED = {
8361
+ run: {
8362
+ message: "`run` is gone (TASK-404).",
8363
+ hints: ["every package: pnpm -r run <task>", "one app: pnpm -F <app> <task>"]
8364
+ },
6799
8365
  secrets: {
6800
8366
  message: "`secrets` is now `backstage env`.",
6801
8367
  hints: ["In a Backstage clone: pnpm backstage env <pull|push|audit>"]
@@ -6869,7 +8435,8 @@ const RETIRED = {
6869
8435
  async function dispatch(argv) {
6870
8436
  const [first, ...rest] = argv;
6871
8437
  if (!first) return DONE;
6872
- const handler = HANDLERS[first];
8438
+ const catalog = activeCatalog();
8439
+ const handler = handlersFor()[first];
6873
8440
  if (handler) {
6874
8441
  if (isDryRun() && dryRunKind(catalog, helpPath(argv)) === void 0) {
6875
8442
  process.stderr.write(`${wouldRunLine(argv)}\n`);
@@ -6877,7 +8444,7 @@ async function dispatch(argv) {
6877
8444
  }
6878
8445
  return handler(rest);
6879
8446
  }
6880
- const retired = RETIRED[first];
8447
+ const retired = isProductMode() ? void 0 : RETIRED[first];
6881
8448
  if (retired) {
6882
8449
  const [sub] = rest;
6883
8450
  const hoisted = sub !== void 0 && retired.hoisted?.includes(sub);
@@ -6901,9 +8468,14 @@ async function dispatch(argv) {
6901
8468
  * guard at the bottom of this file, for a direct `tsx src/cli.ts` run.
6902
8469
  */
6903
8470
  async function main(typed) {
8471
+ const catalog = activeCatalog();
6904
8472
  const argv = catalog.canonicalArgv(typed);
6905
8473
  initDevtoolsTelemetry(argv[0] ?? "menu");
6906
- if (isPassthroughTool(argv[0])) {
8474
+ if (isProductMode() && argv[0] === "supabase") {
8475
+ await handlersFor()["supabase"](argv.slice(1));
8476
+ return;
8477
+ }
8478
+ if (!isProductMode() && isPassthroughTool(argv[0])) {
6907
8479
  await handlePassthrough(argv[0], argv.slice(1));
6908
8480
  return;
6909
8481
  }
@@ -6929,10 +8501,6 @@ async function main(typed) {
6929
8501
  return;
6930
8502
  }
6931
8503
  const startPath = process.stdin.isTTY ? bareGroupStartPath(catalog, argv) : null;
6932
- if (!startPath && argv[0] === "run") {
6933
- await runTask(argv.slice(1));
6934
- return;
6935
- }
6936
8504
  const banners = !isNonInteractive();
6937
8505
  if (banners) intro("DevDogs devtools");
6938
8506
  let closing;