@devdogsuga/devtools 0.1.12 → 0.1.14

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.
Files changed (56) hide show
  1. package/bin/devtools-ci.mjs +15 -1
  2. package/bin/devtools.mjs +15 -1
  3. package/dist/catalog.d.ts +7 -5
  4. package/dist/catalog.js +1 -25
  5. package/dist/cf/build.d.ts +13 -0
  6. package/dist/cf/build.js +65 -0
  7. package/dist/cf/commands.js +6 -16
  8. package/dist/cf/local-env.d.ts +14 -0
  9. package/dist/cf/local-env.js +29 -0
  10. package/dist/ci.js +8 -1
  11. package/dist/cli.js +134 -138
  12. package/dist/commands.d.ts +10 -4
  13. package/dist/commands.js +40 -30
  14. package/dist/db/generated-env.d.ts +69 -0
  15. package/dist/db/generated-env.js +113 -0
  16. package/dist/db/seed-production.js +12 -6
  17. package/dist/docs/index-pages.js +2 -2
  18. package/dist/emails/commands.js +3 -3
  19. package/dist/env-entry.d.ts +49 -0
  20. package/dist/env-entry.js +204 -0
  21. package/dist/environment-doctor.d.ts +9 -0
  22. package/dist/environment-doctor.js +18 -5
  23. package/dist/environment.d.ts +2 -2
  24. package/dist/environment.js +5 -28
  25. package/dist/images/commands.d.ts +3 -6
  26. package/dist/images/commands.js +26 -30
  27. package/dist/images/events.d.ts +27 -16
  28. package/dist/images/events.js +32 -68
  29. package/dist/images/graphics.js +2 -1
  30. package/dist/images/prompts.js +2 -2
  31. package/dist/instance.d.ts +29 -27
  32. package/dist/instance.js +49 -76
  33. package/dist/launch-ci.js +5 -5
  34. package/dist/launch.d.ts +2 -1
  35. package/dist/launch.js +74 -111
  36. package/dist/menu.d.ts +17 -5
  37. package/dist/menu.js +34 -11
  38. package/dist/moderation.d.ts +48 -40
  39. package/dist/moderation.js +95 -194
  40. package/dist/oauth/wizard.d.ts +2 -2
  41. package/dist/oauth/wizard.js +6 -26
  42. package/dist/persona.d.ts +18 -0
  43. package/dist/persona.js +221 -0
  44. package/dist/repo/peers.d.ts +2 -0
  45. package/dist/repo/peers.js +3 -0
  46. package/dist/repo/supabase-project.d.ts +60 -0
  47. package/dist/repo/supabase-project.js +124 -0
  48. package/dist/setup.js +2 -2
  49. package/dist/stack.js +142 -29
  50. package/dist/telemetry.d.ts +18 -5
  51. package/dist/telemetry.js +26 -5
  52. package/dist/ui.d.ts +15 -0
  53. package/dist/ui.js +20 -0
  54. package/dist/workflows/commands.d.ts +10 -0
  55. package/dist/workflows/commands.js +119 -25
  56. package/package.json +10 -5
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Reads `project_id` out of `<repoRoot>/supabase/config.toml`, or `null`
3
+ * when the file is missing or unparsable.
4
+ *
5
+ * Regex, not a TOML parser: it is one line, the shape has been stable
6
+ * across every CLI version this repo has seen, and a parser on a path some
7
+ * callers pay before their first real subprocess is a cost that stays
8
+ * invisible until it is why the tool feels slow.
9
+ */
10
+ export declare function readProjectId(repoRoot: string): string | null;
11
+ /**
12
+ * The Supabase CLI's container name prefix for a project id, or the bare
13
+ * `supabase_db_` fallback when the id could not be read.
14
+ *
15
+ * Falling back to the bare prefix means a machine whose `config.toml`
16
+ * cannot be read still detects *a* stack, at the cost of over-matching a
17
+ * differently-named project — deliberate, and unchanged from this
18
+ * function's original home in `environment.ts`: over-matching offers
19
+ * `stop`/a foreign-stack hint for a stack that turns out to be this
20
+ * project's own, which is harmless to see and decline, while
21
+ * under-matching hides a real signal entirely.
22
+ */
23
+ export declare function containerPrefix(projectId: string | null): string;
24
+ /**
25
+ * The local stack's API port, published by its Kong container. Mirrors
26
+ * `@devdogsuga/env/load`'s `LOCAL_STACK_PORT`, which this module cannot import
27
+ * statically (that package is an optional peer, loaded on demand).
28
+ */
29
+ export declare const STACK_API_PORT = 54321;
30
+ /**
31
+ * `docker ps --format '{{.Names}}'`, split into trimmed, non-empty names —
32
+ * or `null` when Docker could not be read at all (no daemon, not
33
+ * installed, timed out). With `publish`, only the containers publishing that
34
+ * host port. `execute` is injectable for tests, mirroring `environment.ts`'s
35
+ * own probes.
36
+ */
37
+ export declare function listContainerNames(publish?: number, execute?: (file: string, args: string[]) => string | null): string[] | null;
38
+ /**
39
+ * Finds the `supabase_kong_<id>` container among `names` whose id differs
40
+ * from `projectId` — the signature of a stack started from a DIFFERENT
41
+ * Supabase project (this same repo before/after a rename, or another
42
+ * project entirely) holding the shared ports. Kong is the container that
43
+ * publishes the API port, so callers pass the names `listContainerNames`
44
+ * returns for `STACK_API_PORT`: that names the stack actually on 54321,
45
+ * not just any Supabase stack on the machine.
46
+ *
47
+ * Returns that foreign id, or `null` when every matching container belongs
48
+ * to this project, none match at all, or `projectId` itself could not be
49
+ * read — with no known id of our own, there is nothing to compare against,
50
+ * and guessing would risk telling a contributor their OWN stack is
51
+ * foreign.
52
+ */
53
+ export declare function foreignStackProjectId(names: readonly string[], projectId: string | null): string | null;
54
+ /**
55
+ * The actionable message for a detected foreign stack — shared by
56
+ * `db/generated-env.ts` (a failed `supabase status -o env`) and `stack.ts`
57
+ * (a failed `supabase start`), so both name the same fix in the same
58
+ * words.
59
+ */
60
+ export declare function foreignStackMessage(foreignProjectId: string): string;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * This repo's Supabase project id — read from `supabase/config.toml` — and
3
+ * the container-name conventions the Supabase CLI derives from it.
4
+ *
5
+ * Shared by `environment.ts`'s adaptive-menu probe (does a stack answer for
6
+ * THIS project?) and the foreign-stack detection in `db/generated-env.ts`
7
+ * and `stack.ts` (does a DIFFERENT project's stack hold the shared ports? —
8
+ * the case where `supabase status -o env` fails not because nothing is
9
+ * running, but because what is running on 127.0.0.1:54321 belongs to
10
+ * another workspace, or to this same repo before/after a `project_id`
11
+ * rename). One project-id reader and one container-name parser, so both
12
+ * questions agree on what "this project" means.
13
+ */
14
+ import { execFileSync } from "node:child_process";
15
+ import { readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ /**
18
+ * Reads `project_id` out of `<repoRoot>/supabase/config.toml`, or `null`
19
+ * when the file is missing or unparsable.
20
+ *
21
+ * Regex, not a TOML parser: it is one line, the shape has been stable
22
+ * across every CLI version this repo has seen, and a parser on a path some
23
+ * callers pay before their first real subprocess is a cost that stays
24
+ * invisible until it is why the tool feels slow.
25
+ */
26
+ export function readProjectId(repoRoot) {
27
+ try {
28
+ const path = join(repoRoot, "supabase", "config.toml");
29
+ const match = /^\s*project_id\s*=\s*"([^"]+)"/m.exec(readFileSync(path, "utf8"));
30
+ return match?.[1] ?? null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ /**
37
+ * The Supabase CLI's container name prefix for a project id, or the bare
38
+ * `supabase_db_` fallback when the id could not be read.
39
+ *
40
+ * Falling back to the bare prefix means a machine whose `config.toml`
41
+ * cannot be read still detects *a* stack, at the cost of over-matching a
42
+ * differently-named project — deliberate, and unchanged from this
43
+ * function's original home in `environment.ts`: over-matching offers
44
+ * `stop`/a foreign-stack hint for a stack that turns out to be this
45
+ * project's own, which is harmless to see and decline, while
46
+ * under-matching hides a real signal entirely.
47
+ */
48
+ export function containerPrefix(projectId) {
49
+ return projectId ? `supabase_db_${projectId}` : "supabase_db_";
50
+ }
51
+ /**
52
+ * The local stack's API port, published by its Kong container. Mirrors
53
+ * `@devdogsuga/env/load`'s `LOCAL_STACK_PORT`, which this module cannot import
54
+ * statically (that package is an optional peer, loaded on demand).
55
+ */
56
+ export const STACK_API_PORT = 54321;
57
+ function defaultExec(file, args) {
58
+ try {
59
+ return execFileSync(file, args, {
60
+ encoding: "utf8",
61
+ stdio: ["ignore", "pipe", "ignore"],
62
+ timeout: 3000,
63
+ });
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /**
70
+ * `docker ps --format '{{.Names}}'`, split into trimmed, non-empty names —
71
+ * or `null` when Docker could not be read at all (no daemon, not
72
+ * installed, timed out). With `publish`, only the containers publishing that
73
+ * host port. `execute` is injectable for tests, mirroring `environment.ts`'s
74
+ * own probes.
75
+ */
76
+ export function listContainerNames(publish, execute = defaultExec) {
77
+ const filter = publish === undefined ? [] : ["--filter", `publish=${publish}`];
78
+ const out = execute("docker", ["ps", ...filter, "--format", "{{.Names}}"]);
79
+ if (out === null)
80
+ return null;
81
+ return out
82
+ .split("\n")
83
+ .map((name) => name.trim())
84
+ .filter((name) => name.length > 0);
85
+ }
86
+ /**
87
+ * Finds the `supabase_kong_<id>` container among `names` whose id differs
88
+ * from `projectId` — the signature of a stack started from a DIFFERENT
89
+ * Supabase project (this same repo before/after a rename, or another
90
+ * project entirely) holding the shared ports. Kong is the container that
91
+ * publishes the API port, so callers pass the names `listContainerNames`
92
+ * returns for `STACK_API_PORT`: that names the stack actually on 54321,
93
+ * not just any Supabase stack on the machine.
94
+ *
95
+ * Returns that foreign id, or `null` when every matching container belongs
96
+ * to this project, none match at all, or `projectId` itself could not be
97
+ * read — with no known id of our own, there is nothing to compare against,
98
+ * and guessing would risk telling a contributor their OWN stack is
99
+ * foreign.
100
+ */
101
+ export function foreignStackProjectId(names, projectId) {
102
+ if (projectId === null)
103
+ return null;
104
+ for (const name of names) {
105
+ const match = /^supabase_kong_(.+)$/.exec(name);
106
+ if (!match)
107
+ continue;
108
+ const id = match[1];
109
+ if (id !== projectId)
110
+ return id;
111
+ }
112
+ return null;
113
+ }
114
+ /**
115
+ * The actionable message for a detected foreign stack — shared by
116
+ * `db/generated-env.ts` (a failed `supabase status -o env`) and `stack.ts`
117
+ * (a failed `supabase start`), so both name the same fix in the same
118
+ * words.
119
+ */
120
+ export function foreignStackMessage(foreignProjectId) {
121
+ return (`The stack on port ${STACK_API_PORT} belongs to project "${foreignProjectId}", not ` +
122
+ "this checkout's. Stop it with `supabase stop --project-id " +
123
+ `${foreignProjectId}\` then \`pnpm devtools db start\`.`);
124
+ }
package/dist/setup.js CHANGED
@@ -139,7 +139,7 @@ export async function runSetup() {
139
139
  {
140
140
  value: "local",
141
141
  label: "Local (Docker)",
142
- hint: "needs Docker Desktop, OrbStack, or Docker Engine in WSL2",
142
+ hint: "needs Docker Desktop, or Docker Engine in WSL2",
143
143
  },
144
144
  ],
145
145
  initialValue: "hosted",
@@ -178,7 +178,7 @@ function printLocalNextSteps(chosenApps) {
178
178
  ? [
179
179
  "",
180
180
  " schedule-builder starts with an empty catalog. Populate it by",
181
- " triggering the registrar scrape workflow (starts Wrangler for you):",
181
+ " triggering the registrar scrape workflow (starts vinext dev for you):",
182
182
  " pnpm devtools workflows run --app schedule-builder --tier development",
183
183
  ]
184
184
  : []),
package/dist/stack.js CHANGED
@@ -16,15 +16,19 @@
16
16
  * the session targets staging is odd but harmless, and refusing it would
17
17
  * block the one command that fixes an offline-local session.
18
18
  */
19
- import { rmSync } from "node:fs";
19
+ import { existsSync, rmSync } from "node:fs";
20
20
  import { writeFile } from "node:fs/promises";
21
21
  import { join } from "node:path";
22
22
  import { describeEnvironment, probeEnvironment } from "./environment.js";
23
23
  import { findRepoRoot } from "./repo/root.js";
24
+ import { foreignStackMessage, foreignStackProjectId, listContainerNames, STACK_API_PORT, readProjectId, } from "./repo/supabase-project.js";
24
25
  import { dbPush, generateTypes, seedBuckets, supabase, supabaseCapture, } from "./db/run.js";
25
26
  import { describeDbTarget, isLocalConnection, } from "./db/connection.js";
26
27
  import { refreshSessionEnv } from "./db/session-refresh.js";
28
+ import { runSeedProduction } from "./db/seed-production.js";
27
29
  import { originReachable, resolveBaseUrl } from "./cron/commands.js";
30
+ import { ensureGeneratedEnvFile, realEnsureGeneratedEnvDeps, } from "./db/generated-env.js";
31
+ import { loadEnvLoad } from "./repo/peers.js";
28
32
  // Scope order, matching `db`'s subcommands in `commands.ts`: the four that
29
33
  // act on the Supabase stack (`connect` is handled separately below — it
30
34
  // takes a positional ref), then the two that write the Postgres database
@@ -38,20 +42,58 @@ export const STACK_COMMANDS = [
38
42
  "reset",
39
43
  ];
40
44
  // ── Implementations ──────────────────────────────────────────────────────────
45
+ /**
46
+ * A running Docker container whose name says it belongs to a DIFFERENT
47
+ * Supabase project than this checkout's own `config.toml` — the signature
48
+ * of a foreign stack holding the shared ports (see
49
+ * `repo/supabase-project.ts`'s header). `undefined` when nothing points
50
+ * that way, including when Docker or `config.toml` could not be read at
51
+ * all: with no signal either way, `startLocalStack`'s own failure and the
52
+ * Supabase CLI's own output are still the reader's best explanation.
53
+ */
54
+ function foreignStackHint() {
55
+ const names = listContainerNames(STACK_API_PORT);
56
+ if (names === null)
57
+ return undefined;
58
+ const foreign = foreignStackProjectId(names, readProjectId(findRepoRoot()));
59
+ return foreign === null ? undefined : foreignStackMessage(foreign);
60
+ }
61
+ /**
62
+ * `wroteEnvFile` is split out from `code` on purpose: `.env.generated` is
63
+ * written to disk BEFORE `seedBuckets` runs, so a nonzero `code` from
64
+ * `seedBuckets` alone does not mean the file on disk is unchanged — the
65
+ * fresh connection block is already there, a later `db start` retry or the
66
+ * probe table would already see it, and this process's OWN entered
67
+ * environment is now the stale one. `afterLocalStackChange` below refreshes
68
+ * whenever the file changed, independently of whether the command as a whole
69
+ * succeeded, and still reports the failing code so the caller does not
70
+ * mistake a seed failure for success.
71
+ *
72
+ * `hint` is set when `supabase start` itself fails (before anything here
73
+ * could have written `.env.generated`) AND a foreign project's stack is
74
+ * holding the ports — the same signal `db/generated-env.ts` surfaces for a
75
+ * failed `supabase status -o env`, named here too so `db start` explains
76
+ * itself instead of leaving only the bare Supabase CLI error.
77
+ */
41
78
  async function startLocalStack() {
42
79
  const code = await supabase("start");
43
- if (code !== 0)
44
- return code;
80
+ if (code !== 0) {
81
+ const hint = foreignStackHint();
82
+ return hint === undefined
83
+ ? { code, wroteEnvFile: false }
84
+ : { code, wroteEnvFile: false, hint };
85
+ }
45
86
  // Write the local stack's connection details so with-env can load them.
46
87
  let env;
47
88
  try {
48
89
  env = await supabaseCapture("status", "-o", "env");
49
90
  }
50
91
  catch {
51
- return 1;
92
+ return { code: 1, wroteEnvFile: false };
52
93
  }
53
94
  await writeFile(join(findRepoRoot(), ".env.generated"), env);
54
- return seedBuckets({ kind: "local" });
95
+ const bucketsCode = await seedBuckets({ kind: "local" });
96
+ return { code: bucketsCode, wroteEnvFile: true };
55
97
  }
56
98
  async function stopLocalStack() {
57
99
  const code = await supabase("stop");
@@ -97,17 +139,34 @@ async function pushMigrations(connection) {
97
139
  * (`cli.ts`'s `runStack`) has already confirmed the session target —
98
140
  * including the hard production gate — before this ever runs.
99
141
  *
142
+ * A non-development target gets `--no-seed` on the reset itself, followed by
143
+ * an explicit `runSeedProduction` — the same two steps `db seed production`
144
+ * runs on its own (see `seed-production.ts`) — rather than letting the bare
145
+ * `supabase db reset` apply whatever `config.toml`'s `[db.seed]` happens to
146
+ * list. That is deliberately belt-and-suspenders: `[db.seed]` today lists
147
+ * only `supabase/seed/production`, so the bare reset would already do the
148
+ * right thing, but that is a property of the CURRENT config, not of this
149
+ * function. An older checkout, or a config edited to add
150
+ * `supabase/seed/development` back for some local-only reason, must still be
151
+ * structurally unable to run development's password-login seeds against a
152
+ * hosted, non-dev database — the one seed directory that must never reach
153
+ * staging or production, named in that directory's own header. Development
154
+ * resets are untouched: they keep applying whatever `[db.seed]` lists, same
155
+ * as always.
156
+ *
100
157
  * Meetings and workshops are NOT among the tables `supabase/seed/*.sql`
101
158
  * populates -- they come from `@devdogsuga/events` via
102
159
  * `reconcileFromConfig`, an authenticated platform route rather than a
103
160
  * devtools-side function this CLI can call directly (it needs the app's
104
161
  * Drizzle client, relations and Sentry wiring, none of which belong in this
105
- * package). On a local reset this calls that route itself, over the same
106
- * origin `cron run` would use, once the reset and rebuild finish -- see
162
+ * package). On any DEVELOPMENT reset -- local Docker or the shared remote dev
163
+ * project alike -- this calls that route itself, over the same origin `cron
164
+ * run` would use, once the reset and rebuild finish -- see
107
165
  * `reconcileConfigAfterReset` for what happens when nothing is listening yet.
108
166
  */
109
167
  async function reset(connection) {
110
- const code = await supabase("db", "reset", "--db-url", connection.dbUrl);
168
+ const isDev = connection.tier === "development";
169
+ const code = await supabase("db", "reset", "--db-url", connection.dbUrl, ...(isDev ? [] : ["--no-seed"]));
111
170
  if (code !== 0)
112
171
  return { code, lines: [] };
113
172
  const types = await generateTypes(connection.dbUrl);
@@ -116,11 +175,23 @@ async function reset(connection) {
116
175
  const bucketsCode = await seedBuckets(bucketsShape(connection));
117
176
  if (bucketsCode !== 0)
118
177
  return { code: bucketsCode, lines: [] };
178
+ if (!isDev) {
179
+ const seedCode = await runSeedProduction(connection.dbUrl);
180
+ if (seedCode !== 0)
181
+ return { code: seedCode, lines: [] };
182
+ return { code: 0, lines: [] };
183
+ }
119
184
  return {
120
185
  code: 0,
121
- lines: isLocalConnection(connection)
122
- ? await reconcileConfigAfterReset()
123
- : [],
186
+ // Both development databases — local Docker and the shared remote dev
187
+ // project (`--tier development:remote`) — rather than gated on
188
+ // `isLocalConnection`. The platform dev server this hits is always local
189
+ // (`resolveBaseUrl("platform", "development", …)` inside
190
+ // `reconcileConfigAfterReset`); what varies with `devDatabase` is only
191
+ // which Postgres that local server happens to be pointed at, and a
192
+ // remote-dev reset leaves meetings and workshops just as unseeded as a
193
+ // local one.
194
+ lines: await reconcileConfigAfterReset(),
124
195
  };
125
196
  }
126
197
  /**
@@ -184,10 +255,19 @@ export async function reconcileConfigAfterReset(deps = {
184
255
  * (see `db/session-refresh.ts`) so the rest of the session — including a
185
256
  * `db introspect` run right after this one — sees the stack's CURRENT
186
257
  * connection, not whatever `process.env` held at launch. A failed stack
187
- * command changed nothing on disk, so there is nothing to refresh.
258
+ * command USUALLY changed nothing on disk, so by default there is nothing to
259
+ * refresh.
260
+ *
261
+ * `wroteEnvFile` is the one exception: `startLocalStack` writes
262
+ * `.env.generated` BEFORE the buckets-seed step that can still fail
263
+ * afterwards, so a nonzero `code` from THAT failure does not mean the file on
264
+ * disk is unchanged — see `startLocalStack`'s own doc. Passing `true` here
265
+ * refreshes regardless of `code`, while the failing code itself still reaches
266
+ * the caller untouched, so `db start` is still reported as failed even though
267
+ * the session's environment was brought current.
188
268
  */
189
- async function afterLocalStackChange(code) {
190
- if (code !== 0)
269
+ async function afterLocalStackChange(code, wroteEnvFile = false) {
270
+ if (code !== 0 && !wroteEnvFile)
191
271
  return [];
192
272
  return refreshSessionEnv();
193
273
  }
@@ -215,31 +295,63 @@ async function restartLocal() {
215
295
  ],
216
296
  };
217
297
  }
218
- const code = await startLocalStack();
219
- return { code, lines: await afterLocalStackChange(code) };
298
+ const { code, wroteEnvFile, hint } = await startLocalStack();
299
+ const lines = await afterLocalStackChange(code, wroteEnvFile);
300
+ return { code, lines: hint === undefined ? lines : [hint, ...lines] };
220
301
  }
221
302
  /**
222
303
  * What `status` says for a local session, now that it can answer for itself.
223
304
  *
224
- * `environment.ts` already reads the two facts that question is really asking
225
- * about, so this reports them and names the next step.
305
+ * `environment.ts` already reads the two facts that question is really
306
+ * asking about, so this reports them and names the next step. When the
307
+ * stack's containers are up but this checkout's own `.env.generated` is
308
+ * missing — most often a stack another workspace started, sharing this
309
+ * repo's `project_id` — this also attempts the same regeneration
310
+ * `enterSessionEnvironment` (`env-entry.ts`) runs before every command, so
311
+ * `db status` reports the file's ACTUAL state after trying to fix it,
312
+ * rather than a stale "the stack is up" that leaves the absence a mystery.
226
313
  */
227
- function localStatus() {
314
+ async function localStatus() {
228
315
  const env = probeEnvironment();
229
- let next;
316
+ const lines = [describeEnvironment(env)];
230
317
  if (env.docker === "no") {
231
- next = "Start Docker, then `pnpm devtools db start` to bring the stack up.";
318
+ lines.push("Start Docker, then `pnpm devtools db start` to bring the stack up.");
319
+ return { code: 0, lines };
320
+ }
321
+ if (env.stack === "no") {
322
+ lines.push("Nothing is running. `pnpm devtools db start` starts it.");
323
+ return { code: 0, lines };
232
324
  }
233
- else if (env.stack === "yes") {
234
- next = "The stack is up. `supabase status` prints its URLs and keys.";
325
+ if (env.stack === "unknown") {
326
+ lines.push("Could not read Docker. `supabase status` asks the stack directly.");
327
+ return { code: 0, lines };
235
328
  }
236
- else if (env.stack === "no") {
237
- next = "Nothing is running. `pnpm devtools db start` starts it.";
329
+ // env.stack === "yes"
330
+ const repoRoot = findRepoRoot();
331
+ if (existsSync(join(repoRoot, ".env.generated"))) {
332
+ lines.push("The stack is up. `supabase status` prints its URLs and keys.");
333
+ return { code: 0, lines };
334
+ }
335
+ const envLoad = await loadEnvLoad();
336
+ const generated = await ensureGeneratedEnvFile("development", undefined, realEnsureGeneratedEnvDeps(envLoad.probeLocalStack));
337
+ if (generated.outcome === "wrote") {
338
+ lines.push(generated.line.replace(/^devtools: /, ""), "The stack is up. `supabase status` prints its URLs and keys.");
339
+ }
340
+ else if (generated.outcome === "foreign") {
341
+ lines.push(generated.line.replace(/^devtools: /, ""));
342
+ }
343
+ else if (generated.outcome === "unreachable") {
344
+ lines.push("The stack's containers are up but .env.generated could not be " +
345
+ "regenerated — run `supabase status -o env` yourself to see why.");
238
346
  }
239
347
  else {
240
- next = "Could not read Docker. `supabase status` asks the stack directly.";
348
+ // "skipped": Docker's container list said the stack is up, but nothing
349
+ // answered the port probe — a race right after `db start`/`db restart`,
350
+ // most likely. Nothing to regenerate from yet.
351
+ lines.push("Docker reports the stack's containers running, but nothing is " +
352
+ "answering on 127.0.0.1:54321 yet — try again in a moment.");
241
353
  }
242
- return { code: 0, lines: [describeEnvironment(env), next] };
354
+ return { code: 0, lines };
243
355
  }
244
356
  /**
245
357
  * Runs a stack command, returning its exit code and anything to report.
@@ -259,8 +371,9 @@ export async function runStackCommand(command, connection) {
259
371
  return { code, lines: await afterLocalStackChange(code) };
260
372
  }
261
373
  if (command === "start") {
262
- const code = await startLocalStack();
263
- return { code, lines: await afterLocalStackChange(code) };
374
+ const { code, wroteEnvFile, hint } = await startLocalStack();
375
+ const lines = await afterLocalStackChange(code, wroteEnvFile);
376
+ return { code, lines: hint === undefined ? lines : [hint, ...lines] };
264
377
  }
265
378
  if (command === "status") {
266
379
  if (connection === null || isLocalConnection(connection)) {
@@ -24,13 +24,26 @@ export declare function devtoolsTelemetryEnabled(): boolean;
24
24
  */
25
25
  export declare function initDevtoolsTelemetry(command: string): void;
26
26
  /**
27
- * Reports an uncaught error from the top-level `main().catch` in `cli.ts`
28
- * and `ci.ts` alike, then flushes before the process exits. Node's event
29
- * loop dies with `process.exit`, taking any in-flight request to Sentry's
30
- * ingest endpoint with it, so the flush must be awaited BEFORE that call —
31
- * never after.
27
+ * Reports an error nothing below the entry points caught — `launch.ts`'s
28
+ * `dispatch`, `launch-ci.ts`, and the bins — then flushes before the process
29
+ * exits. Node's event loop dies with `process.exit`, taking any in-flight
30
+ * request to Sentry's ingest endpoint with it, so the flush must be awaited
31
+ * BEFORE that call — never after.
32
32
  *
33
33
  * A short timeout: a CLI exiting on error should not hang perceptibly longer
34
34
  * because Sentry's ingest is slow or unreachable.
35
35
  */
36
36
  export declare function captureDevtoolsError(err: unknown): Promise<void>;
37
+ /**
38
+ * Reports an error a command caught and explained to the reader itself
39
+ * (`explainError` in `ui.ts`, `devtools-ci deploy`'s catch), then carried on
40
+ * to a normal exit. Not flushed: the SDK's in-flight request keeps the event
41
+ * loop alive until it lands, so a natural exit waits for it on its own. A
42
+ * path that ends in `process.exit` instead must use `captureDevtoolsError`.
43
+ */
44
+ export declare function reportDevtoolsError(err: unknown): void;
45
+ /**
46
+ * Reports a failure that has no error object — a subprocess or deploy step
47
+ * that exited non-zero. Same no-flush contract as `reportDevtoolsError`.
48
+ */
49
+ export declare function reportDevtoolsFailure(message: string, extra?: Record<string, unknown>): void;
package/dist/telemetry.js CHANGED
@@ -92,11 +92,11 @@ export function initDevtoolsTelemetry(command) {
92
92
  Sentry.getCurrentScope().setTag("command", command);
93
93
  }
94
94
  /**
95
- * Reports an uncaught error from the top-level `main().catch` in `cli.ts`
96
- * and `ci.ts` alike, then flushes before the process exits. Node's event
97
- * loop dies with `process.exit`, taking any in-flight request to Sentry's
98
- * ingest endpoint with it, so the flush must be awaited BEFORE that call —
99
- * never after.
95
+ * Reports an error nothing below the entry points caught — `launch.ts`'s
96
+ * `dispatch`, `launch-ci.ts`, and the bins — then flushes before the process
97
+ * exits. Node's event loop dies with `process.exit`, taking any in-flight
98
+ * request to Sentry's ingest endpoint with it, so the flush must be awaited
99
+ * BEFORE that call — never after.
100
100
  *
101
101
  * A short timeout: a CLI exiting on error should not hang perceptibly longer
102
102
  * because Sentry's ingest is slow or unreachable.
@@ -107,3 +107,24 @@ export async function captureDevtoolsError(err) {
107
107
  Sentry.captureException(err);
108
108
  await Sentry.flush(2000);
109
109
  }
110
+ /**
111
+ * Reports an error a command caught and explained to the reader itself
112
+ * (`explainError` in `ui.ts`, `devtools-ci deploy`'s catch), then carried on
113
+ * to a normal exit. Not flushed: the SDK's in-flight request keeps the event
114
+ * loop alive until it lands, so a natural exit waits for it on its own. A
115
+ * path that ends in `process.exit` instead must use `captureDevtoolsError`.
116
+ */
117
+ export function reportDevtoolsError(err) {
118
+ if (!devtoolsTelemetryEnabled())
119
+ return;
120
+ Sentry.captureException(err);
121
+ }
122
+ /**
123
+ * Reports a failure that has no error object — a subprocess or deploy step
124
+ * that exited non-zero. Same no-flush contract as `reportDevtoolsError`.
125
+ */
126
+ export function reportDevtoolsFailure(message, extra = {}) {
127
+ if (!devtoolsTelemetryEnabled())
128
+ return;
129
+ Sentry.captureMessage(message, { level: "error", extra });
130
+ }
package/dist/ui.d.ts CHANGED
@@ -9,6 +9,21 @@ export declare function unwrap<T>(value: T | symbol): T;
9
9
  * contributor asking in Discord.
10
10
  */
11
11
  export declare function explain(summary: string, detail: string, hints?: string[]): void;
12
+ /**
13
+ * A refusal the reader can fix themselves — a name that matches nothing, a
14
+ * flag missing where there is no terminal to ask. Thrown from deep enough
15
+ * that the command's catch can't tell it from a real failure any other way;
16
+ * `explainError` prints it like any other error but keeps it out of Sentry.
17
+ */
18
+ export declare class UsageError extends Error {
19
+ name: string;
20
+ }
21
+ /**
22
+ * `explain()` for a caught error rather than a refusal: prints the same way,
23
+ * with `err`'s message as the detail, and reports `err` to Sentry unless it
24
+ * is a {@link UsageError}. Only failures nobody anticipated belong there.
25
+ */
26
+ export declare function explainError(summary: string, err: unknown, hints?: string[]): void;
12
27
  export declare function errorMessage(err: unknown): string;
13
28
  export interface CheckResult {
14
29
  name: string;
package/dist/ui.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * ends at a stack trace.
11
11
  */
12
12
  import { cancel, isCancel, log, note } from "@clack/prompts";
13
+ import { reportDevtoolsError } from "./telemetry.js";
13
14
  export function bail(message = "Cancelled.") {
14
15
  cancel(message);
15
16
  process.exit(1);
@@ -34,6 +35,25 @@ export function explain(summary, detail, hints = []) {
34
35
  if (hints.length > 0)
35
36
  note(hints.join("\n"), "Try this");
36
37
  }
38
+ /**
39
+ * A refusal the reader can fix themselves — a name that matches nothing, a
40
+ * flag missing where there is no terminal to ask. Thrown from deep enough
41
+ * that the command's catch can't tell it from a real failure any other way;
42
+ * `explainError` prints it like any other error but keeps it out of Sentry.
43
+ */
44
+ export class UsageError extends Error {
45
+ name = "UsageError";
46
+ }
47
+ /**
48
+ * `explain()` for a caught error rather than a refusal: prints the same way,
49
+ * with `err`'s message as the detail, and reports `err` to Sentry unless it
50
+ * is a {@link UsageError}. Only failures nobody anticipated belong there.
51
+ */
52
+ export function explainError(summary, err, hints = []) {
53
+ if (!(err instanceof UsageError))
54
+ reportDevtoolsError(err);
55
+ explain(summary, errorMessage(err), hints);
56
+ }
37
57
  export function errorMessage(err) {
38
58
  return err instanceof Error ? err.message : String(err);
39
59
  }
@@ -26,6 +26,16 @@ export declare function waitForLocalWorkflow(workflowName: string, instanceId: s
26
26
  fetcher?: typeof fetch;
27
27
  }): Promise<number>;
28
28
  export declare function wranglerDevArgs(app: string, port: string): string[];
29
+ /** `vinext dev` bound to loopback, where `isWranglerDevRunning` probes. */
30
+ export declare function vinextDevArgs(app: string, port: string): string[];
31
+ /**
32
+ * The port of a `vinext dev` already running for `app`, from the lock file
33
+ * vinext keeps at `<app>/.vinext/dev/lock.json` — or undefined when there is
34
+ * none, or its process is gone. vinext refuses a second dev server in the same
35
+ * app directory, so starting one beside it fails ("Another vinext dev server
36
+ * is already running"); reusing it is the only way through.
37
+ */
38
+ export declare function runningVinextDevPort(appDir: string, isAlive?: (pid: number) => boolean): string | undefined;
29
39
  /** Probe Wrangler's Workflow explorer API, rather than merely checking a port. */
30
40
  export declare function isWranglerDevRunning(port: string, timeoutMs?: number, fetcher?: typeof fetch): Promise<boolean>;
31
41
  /** Whether nothing is bound to `port` on loopback — a real bind test, not a