@devdogsuga/devtools 0.1.50 → 0.1.51-next.527
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -7
- package/bin/with-env.mjs +17 -0
- package/dist/app-registry-BoAnenqC.js +38 -0
- package/dist/{cli-t3gWyy3w.js → cli-D4LUUxUR.js} +2286 -718
- package/dist/launch.js +23 -11
- package/dist/load-Cp8AMERM.js +640 -0
- package/dist/load-Czhayrsk.js +2 -0
- package/dist/{menu-DYQ-Dd-O.js → menu-DjE4EZKO.js} +475 -178
- package/dist/{failure-log-DOt6E3hS.js → root-CsocfxnK.js} +104 -5
- package/dist/session-CmDxHEMn.js +2 -0
- package/dist/session-rbONwFvY.js +431 -0
- package/dist/{stack-C_50FafG.js → stack-7Lbym6Wq.js} +2 -3
- package/dist/stack-7qA2UP9g.js +2 -0
- package/dist/supabase-project-BoJKMCxL.js +766 -0
- package/dist/{telemetry-C07-ioHm.js → telemetry-BKFDtzXI.js} +8 -9
- package/dist/telemetry.js +1 -1
- package/dist/with-env.js +370 -0
- package/dist/worker-env-CAFmquhW.js +36 -0
- package/dist/wrangler-env-BrlNe4pm.js +34 -0
- package/env.ts +1 -1
- package/package.json +11 -20
- package/dist/local-env-BuHU5Ckd.js +0 -357
- package/dist/local-env-MqH18s-N.js +0 -2
- package/dist/peers-DvUH5VxX.js +0 -354
- package/dist/root-CjPWNkBu.js +0 -99
- package/dist/stack-o1Kml3V0.js +0 -2
- package/dist/supabase-project-C93xRFkN.js +0 -436
|
@@ -1,21 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { C as
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
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-DjE4EZKO.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 {
|
|
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}.
|
|
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
|
|
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
|
|
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,
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
* from
|
|
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
|
|
442
|
-
if (
|
|
443
|
-
|
|
444
|
-
problems.push("
|
|
445
|
-
|
|
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(
|
|
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
|
|
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`,
|
|
4035
|
-
* `KEY=...` lines in place and appending keys that
|
|
4036
|
-
* Leaves all other lines (comments, unrelated vars)
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
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
|
-
|
|
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/
|
|
5723
|
-
/**
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
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
|
-
|
|
5843
|
-
|
|
5844
|
-
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
|
|
5848
|
-
|
|
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
|
-
|
|
5872
|
-
|
|
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
|
-
*
|
|
5885
|
-
*
|
|
5886
|
-
*
|
|
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
|
|
5900
|
-
|
|
5901
|
-
if (
|
|
5902
|
-
|
|
5903
|
-
return
|
|
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
|
-
*
|
|
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
|
|
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 (
|
|
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;
|