@devdogsuga/devtools 0.1.11 → 0.1.13

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 (61) 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/discovery.js +19 -6
  20. package/dist/env-entry.d.ts +49 -0
  21. package/dist/env-entry.js +204 -0
  22. package/dist/environment-doctor.d.ts +9 -0
  23. package/dist/environment-doctor.js +18 -5
  24. package/dist/environment.d.ts +2 -2
  25. package/dist/environment.js +5 -28
  26. package/dist/images/commands.d.ts +3 -6
  27. package/dist/images/commands.js +26 -30
  28. package/dist/images/events.d.ts +27 -16
  29. package/dist/images/events.js +32 -68
  30. package/dist/images/graphics.js +2 -1
  31. package/dist/images/prompts.js +2 -2
  32. package/dist/instance.d.ts +29 -27
  33. package/dist/instance.js +49 -76
  34. package/dist/launch-ci.js +5 -5
  35. package/dist/launch.d.ts +2 -1
  36. package/dist/launch.js +74 -111
  37. package/dist/menu.d.ts +17 -5
  38. package/dist/menu.js +34 -11
  39. package/dist/moderation.d.ts +48 -40
  40. package/dist/moderation.js +95 -194
  41. package/dist/oauth/wizard.d.ts +2 -2
  42. package/dist/oauth/wizard.js +6 -26
  43. package/dist/persona.d.ts +18 -0
  44. package/dist/persona.js +221 -0
  45. package/dist/repo/peer-redirect-hooks.d.ts +16 -0
  46. package/dist/repo/peer-redirect-hooks.js +10 -0
  47. package/dist/repo/peer-redirect.d.ts +4 -0
  48. package/dist/repo/peer-redirect.js +26 -0
  49. package/dist/repo/peers.d.ts +15 -0
  50. package/dist/repo/peers.js +26 -1
  51. package/dist/repo/supabase-project.d.ts +60 -0
  52. package/dist/repo/supabase-project.js +124 -0
  53. package/dist/setup.js +2 -2
  54. package/dist/stack.js +142 -29
  55. package/dist/telemetry.d.ts +18 -5
  56. package/dist/telemetry.js +26 -5
  57. package/dist/ui.d.ts +15 -0
  58. package/dist/ui.js +20 -0
  59. package/dist/workflows/commands.d.ts +10 -0
  60. package/dist/workflows/commands.js +119 -25
  61. package/package.json +10 -5
package/dist/cli.js CHANGED
@@ -18,8 +18,9 @@
18
18
  * anywhere, so there is nothing to fall out of step.
19
19
  */
20
20
  import { confirm, intro, log, note, outro, select, spinner, } from "@clack/prompts";
21
- import { assertMigrated, detectLocalInstance, PERSONAS, } from "./instance.js";
22
- import { conformance, listApps, quarantineRoundTrip } from "./moderation.js";
21
+ import { resolveInstance } from "./instance.js";
22
+ import { conformance, withTemporaryModerator } from "./moderation.js";
23
+ import { runPersona, refuseUnlessDevelopment } from "./persona.js";
23
24
  import { runEnvironmentDoctor } from "./environment-doctor.js";
24
25
  import { currentRootHolder, grantRoot, listCandidates, transferRoot, } from "./grantRoot.js";
25
26
  import { connectRemoteProject, runStackCommand, } from "./stack.js";
@@ -44,7 +45,7 @@ import { loadEnv } from "./repo/peers.js";
44
45
  import { setExplicitAccessToken } from "./bws/client.js";
45
46
  import { positionals } from "./args.js";
46
47
  import { resolveVaultTarget } from "./pick.js";
47
- import { bail, errorMessage, explain, renderChecks, unwrap } from "./ui.js";
48
+ import { bail, errorMessage, explain, explainError, renderChecks, unwrap } from "./ui.js";
48
49
  import { helpPath, renderHelp } from "./help.js";
49
50
  import { subcommandList, subcommandNames } from "./commands.js";
50
51
  import { bareGroupStartPath, runMenu } from "./menu.js";
@@ -62,10 +63,6 @@ import { runWorkflows } from "./workflows/commands.js";
62
63
  import { runCf } from "./cf/commands.js";
63
64
  import { captureDevtoolsError, initDevtoolsTelemetry } from "./telemetry.js";
64
65
  import { ownVersion } from "./version.js";
65
- const MODERATION_COMMANDS = ["moderation", "grant-root"];
66
- function isModerationCommand(value) {
67
- return MODERATION_COMMANDS.includes(value);
68
- }
69
66
  function flagValue(rest, flag) {
70
67
  const index = rest.indexOf(flag);
71
68
  if (index === -1)
@@ -93,46 +90,6 @@ function refuseRetiredDbFlags(rest) {
93
90
  }
94
91
  return false;
95
92
  }
96
- // ── Connecting ───────────────────────────────────────────────────────────────
97
- /**
98
- * Finds the local stack and checks it has been migrated.
99
- *
100
- * These commands only ever run against a local stack, and that is structural
101
- * rather than a rule they follow: `detectLocalInstance` reads `supabase
102
- * status`, which describes the Docker stack on this machine and nothing else.
103
- * There is no remote project for it to return, so the tier check that used to
104
- * sit here, reading a `production` flag out of the database, bought nothing and
105
- * has been removed along with the table it read.
106
- */
107
- async function connect() {
108
- const s = spinner();
109
- s.start("Looking for your database");
110
- let instance;
111
- try {
112
- instance = detectLocalInstance();
113
- }
114
- catch (err) {
115
- s.stop("Could not find a running database");
116
- explain("The local Supabase stack is not reachable.", errorMessage(err), [
117
- "1. Make sure Docker is running",
118
- "2. Run `pnpm devtools db start` (or choose Database → start in the menu)",
119
- "3. Confirm with `supabase status`",
120
- ]);
121
- return null;
122
- }
123
- try {
124
- await assertMigrated(instance);
125
- s.stop(`Connected to your database at ${instance.apiUrl}`);
126
- }
127
- catch (err) {
128
- s.stop("Connected, but the schema is not there");
129
- explain("That instance cannot be used here.", errorMessage(err), [
130
- "Run `pnpm devtools db reset` to rebuild your own database from migrations and seeds.",
131
- ]);
132
- return null;
133
- }
134
- return instance;
135
- }
136
93
  // ── Commands ─────────────────────────────────────────────────────────────────
137
94
  async function runStack(command, rest) {
138
95
  // The data commands need the session's connection before anything else —
@@ -227,43 +184,56 @@ async function runStack(command, rest) {
227
184
  }
228
185
  }
229
186
  catch (err) {
230
- explain(`\`${command}\` failed.`, errorMessage(err));
187
+ explainError(`\`${command}\` failed.`, err);
231
188
  process.exitCode = 1;
232
189
  }
233
190
  }
234
- async function runModerationCatalog(instance) {
235
- const catalog = await readCatalog(instance);
236
- note(renderCatalog(catalog), "Moderation catalog");
237
- }
191
+ /**
192
+ * `moderation check [--app <slug>]`.
193
+ *
194
+ * With no app, this is what `moderation catalog` used to be on its own: the
195
+ * report reasons and every app's moderatable content types, the two halves
196
+ * of "what can be reported here" that used to have no answer anywhere but
197
+ * the database. With one, it runs `platform.conformance_check()` for that
198
+ * app, as it always did. Folded into one command because they were always
199
+ * the same question at two different zoom levels, asked through two
200
+ * differently-named commands for no reason better than history.
201
+ *
202
+ * Both halves run inside ONE `withTemporaryModerator` — one throwaway
203
+ * account, created and torn down around whichever half ran, rather than a
204
+ * separate one per RPC call.
205
+ */
238
206
  async function runModerationCheck(instance, appSlug) {
239
- let slug = appSlug;
240
- if (!slug) {
241
- const apps = await listApps(instance);
242
- slug = unwrap(await select({
243
- message: "Which app should I check?",
244
- options: apps.map((value) => ({
245
- value,
246
- label: value,
247
- hint: value === "platform" ? "the worked example" : undefined,
248
- })),
249
- }));
207
+ if (!appSlug) {
208
+ const s = spinner();
209
+ s.start("Signing in as a temporary moderator");
210
+ try {
211
+ const catalog = await withTemporaryModerator(instance, readCatalog);
212
+ s.stop("Read the catalog");
213
+ note(renderCatalog(catalog), "Moderation catalog");
214
+ }
215
+ catch (err) {
216
+ s.stop("Could not read the catalog");
217
+ explainError("Reading the catalog failed.", err);
218
+ process.exitCode = 1;
219
+ }
220
+ return;
250
221
  }
251
222
  const s = spinner();
252
- s.start(`Checking ${slug}`);
223
+ s.start(`Checking ${appSlug}`);
253
224
  let types;
254
225
  try {
255
- types = await conformance(instance, slug);
256
- s.stop(`Checked ${slug}`);
226
+ types = await withTemporaryModerator(instance, (client) => conformance(client, appSlug));
227
+ s.stop(`Checked ${appSlug}`);
257
228
  }
258
229
  catch (err) {
259
230
  s.stop("The check could not run");
260
- explain("conformance_check() failed.", errorMessage(err), [
261
- `Signing in as ${PERSONAS.moderator} needs the seeds — try \`pnpm devtools db reset\`.`,
262
- ]);
231
+ explainError("conformance_check() failed.", err);
232
+ process.exitCode = 1;
263
233
  return;
264
234
  }
265
235
  if (types.length === 0) {
266
- note(`${slug} has no moderatable content types.\n\n` +
236
+ note(`${appSlug} has no moderatable content types.\n\n` +
267
237
  "A table becomes one by carrying a foreign key to\n" +
268
238
  'platform."reportResolutions" -- adding that column is the whole\n' +
269
239
  "registration. See docs/platform/reporting-and-feedback.md.", "Nothing to check");
@@ -276,7 +246,7 @@ async function runModerationCheck(instance, appSlug) {
276
246
  note(renderChecks(type.checks), `${type.tableName} → "${type.contentType}"`);
277
247
  }
278
248
  if (failures === 0) {
279
- log.success(`${slug} looks correctly integrated.`);
249
+ log.success(`${appSlug} looks correctly integrated.`);
280
250
  }
281
251
  else {
282
252
  log.warn(`${failures} check${failures === 1 ? "" : "s"} failed. The last two are ` +
@@ -284,29 +254,6 @@ async function runModerationCheck(instance, appSlug) {
284
254
  "than trusting outright.");
285
255
  }
286
256
  }
287
- async function runModerationRoundTrip(instance) {
288
- const s = spinner();
289
- s.start("Filing a report, quarantining it, and looking again");
290
- try {
291
- const steps = await quarantineRoundTrip(instance);
292
- s.stop("Round-trip finished");
293
- note(renderChecks(steps), "What happened");
294
- const failed = steps.filter((step) => !step.ok);
295
- if (failed.length === 0) {
296
- log.success("Quarantine freezes a reported profile and resets the display name, and the fixtures were cleaned up.");
297
- }
298
- else {
299
- log.warn(`${failed.length} step${failed.length === 1 ? "" : "s"} did not hold. ` +
300
- "The fixtures were still cleaned up.");
301
- }
302
- }
303
- catch (err) {
304
- s.stop("The round-trip could not run");
305
- explain("Something went wrong before the checks could finish.", errorMessage(err), [
306
- "`pnpm devtools db reset` rebuilds the database with the seeded personas and the open report they act on.",
307
- ]);
308
- }
309
- }
310
257
  /**
311
258
  * Grants Root, asking who to if it was not told.
312
259
  *
@@ -314,8 +261,14 @@ async function runModerationRoundTrip(instance) {
314
261
  * different actions wearing the same name: one gives you a console you did not
315
262
  * have, the other takes somebody else's away. `userRoles_root_singleton` means
316
263
  * there is no state where both hold it, so the release cannot be skipped.
264
+ *
265
+ * Runs on any tier the session resolves to now — `resolveInstance` (see
266
+ * `instance.ts`) replaced the local-only `supabase status` probe this used
267
+ * to go through — so a PRODUCTION target gets the same `--yes`/stern-confirm
268
+ * treatment `db reset` does: this is a privilege escalation on whatever
269
+ * database it runs against, and on production that database is live.
317
270
  */
318
- async function runGrantRoot(instance, userEmail) {
271
+ async function runGrantRoot(connection, instance, userEmail, rest) {
319
272
  let holder;
320
273
  let candidates;
321
274
  try {
@@ -325,14 +278,16 @@ async function runGrantRoot(instance, userEmail) {
325
278
  ]);
326
279
  }
327
280
  catch (err) {
328
- explain("Could not read the current roles.", errorMessage(err));
281
+ explainError("Could not read the current roles.", err);
329
282
  process.exitCode = 1;
330
283
  return;
331
284
  }
332
285
  if (candidates.length === 0) {
333
286
  explain("There are no accounts on this database yet.", "", [
334
287
  "Sign in once through the app, then run this again.",
335
- `Or use a seeded persona: ${PERSONAS.member}, password \`password\`.`,
288
+ ...(connection.tier === "development"
289
+ ? ["Or create one: `pnpm devtools persona member`."]
290
+ : []),
336
291
  ]);
337
292
  return;
338
293
  }
@@ -357,14 +312,40 @@ async function runGrantRoot(instance, userEmail) {
357
312
  log.info(`${target.email} already holds Root.`);
358
313
  return;
359
314
  }
360
- try {
361
- if (holder) {
315
+ const action = holder
316
+ ? `Take Root away from ${holder.email} and give it to ${target.email}`
317
+ : `Give ${target.email} Root, which confers every permission`;
318
+ if (connection.tier === "production") {
319
+ // ⚠️ SAFETY: same gate `runStack` uses for `db reset`/`migrate` — see
320
+ // that function's header for why `--yes` is checked before anything
321
+ // TTY-dependent runs.
322
+ if (!rest.includes("--yes")) {
323
+ if (!process.stdin.isTTY) {
324
+ process.stderr.write("devtools grant-root: --yes is required to run non-interactively.\n");
325
+ process.exitCode = 1;
326
+ return;
327
+ }
362
328
  const confirmed = unwrap(await confirm({
363
- message: `Root is held by ${holder.email}. Take it away and give it to ${target.email}?`,
329
+ message: `${action} on the PRODUCTION database. Continue?`,
364
330
  initialValue: false,
365
331
  }));
366
332
  if (!confirmed)
367
333
  bail("Left Root where it was.");
334
+ }
335
+ }
336
+ else if (holder) {
337
+ // Transferring away from someone still asks, even off production —
338
+ // taking a console away from an existing holder is worth a question
339
+ // granting to nobody-yet-holding is not.
340
+ const confirmed = unwrap(await confirm({
341
+ message: `Root is held by ${holder.email}. Take it away and give it to ${target.email}?`,
342
+ initialValue: false,
343
+ }));
344
+ if (!confirmed)
345
+ bail("Left Root where it was.");
346
+ }
347
+ try {
348
+ if (holder) {
368
349
  await transferRoot(instance, holder.userId, target.userId);
369
350
  }
370
351
  else {
@@ -374,8 +355,9 @@ async function runGrantRoot(instance, userEmail) {
374
355
  "Sign out and back in if the console was already open.");
375
356
  }
376
357
  catch (err) {
377
- explain("Could not grant Root.", errorMessage(err), [
378
- "Seeds create the Root role definition — try `pnpm devtools db reset` first.",
358
+ explainError("Could not grant Root.", err, [
359
+ "Seeds create the Root role definition — try `pnpm devtools db reset` " +
360
+ "(development) or `pnpm devtools db seed production` (staging/production) first.",
379
361
  ]);
380
362
  process.exitCode = 1;
381
363
  }
@@ -437,7 +419,7 @@ async function runEnvCommand(rest) {
437
419
  });
438
420
  }
439
421
  catch (err) {
440
- explain("The reset failed.", errorMessage(err));
422
+ explainError("The reset failed.", err);
441
423
  process.exitCode = 1;
442
424
  }
443
425
  return;
@@ -459,7 +441,7 @@ async function runEnvCommand(rest) {
459
441
  await runEnvExample({ check: rest.includes("--check") });
460
442
  }
461
443
  catch (err) {
462
- explain("Generating .env.example failed.", errorMessage(err));
444
+ explainError("Generating .env.example failed.", err);
463
445
  process.exitCode = 1;
464
446
  }
465
447
  return;
@@ -486,7 +468,7 @@ async function runEnvCommand(rest) {
486
468
  await runEnvInit(given, flagValue(rest, "--apps") ?? undefined);
487
469
  }
488
470
  catch (err) {
489
- explain("env init failed.", errorMessage(err));
471
+ explainError("env init failed.", err);
490
472
  process.exitCode = 1;
491
473
  }
492
474
  return;
@@ -523,7 +505,7 @@ async function runEnvCommand(rest) {
523
505
  await runEnvAudit(options);
524
506
  }
525
507
  catch (err) {
526
- explain("The env command failed.", errorMessage(err), [
508
+ explainError("The env command failed.", err, [
527
509
  "The access token is read from --access-token, then BWS_ACCESS_TOKEN,",
528
510
  "then your Bitwarden vault, and finally by asking.",
529
511
  "`gh auth status` shows whether the GitHub CLI is signed in.",
@@ -836,10 +818,10 @@ async function dispatch(argv) {
836
818
  return DONE;
837
819
  }
838
820
  if (first === "images") {
839
- // `connect` is passed rather than called: only event graphics need a
840
- // database, and `images page/*` must not demand a running stack to draw
841
- // pictures that come entirely out of this repo.
842
- await runImages(rest, { connect });
821
+ // No database dependency to pass through any more: event graphics read
822
+ // `@devdogsuga/events`'s committed config directly (see
823
+ // `images/events.ts`), so `images` never needs a running stack at all.
824
+ await runImages(rest);
843
825
  return DONE;
844
826
  }
845
827
  if (first === "emails") {
@@ -917,15 +899,23 @@ async function dispatch(argv) {
917
899
  }
918
900
  // The old moderation names, refused with the new namespace rather than
919
901
  // falling into "Unknown command" — same rationale as `secrets` above.
920
- // `doctor` itself is NOT here: that name now belongs to the environment
921
- // checker below, a deliberate reuse rather than a collision.
922
- if (first === "catalog" || first === "roundtrip") {
923
- explain(`\`${first}\` is now \`devtools moderation ${first}\`.`, "", [
924
- `pnpm devtools moderation ${first}`,
902
+ // `catalog` still has an answer (`moderation check`, now folded together —
903
+ // see that function's header); `roundtrip` does not, because the command it
904
+ // named is gone outright, not renamed. `doctor` itself is NOT here: that
905
+ // name now belongs to the environment checker below, a deliberate reuse
906
+ // rather than a collision.
907
+ if (first === "catalog") {
908
+ explain("`catalog` is now `devtools moderation check`.", "", [
909
+ "pnpm devtools moderation check",
925
910
  ]);
926
911
  process.exitCode = 1;
927
912
  return null;
928
913
  }
914
+ if (first === "roundtrip") {
915
+ explain("`roundtrip` is gone. The app repo's own CI covers the file/quarantine/check round trip more thoroughly than this command ever did.", "", ["pnpm devtools moderation check --app <slug>"]);
916
+ process.exitCode = 1;
917
+ return null;
918
+ }
929
919
  if (first === "doctor") {
930
920
  await runEnvironmentDoctor({
931
921
  app: flagValue(rest, "--app"),
@@ -933,30 +923,23 @@ async function dispatch(argv) {
933
923
  });
934
924
  return DONE;
935
925
  }
936
- if (!isModerationCommand(first)) {
937
- log.error(`Unknown command: ${first}`);
938
- // The top level only. The command is unknown, so there is no level below
939
- // it to describe, and reprinting the whole tree here is what made the old
940
- // help unreadable in the first place.
941
- log.message(renderHelp());
942
- process.exitCode = 1;
943
- return null;
944
- }
945
- const instance = await connect();
946
- if (!instance) {
947
- process.exitCode = 1;
948
- return null;
926
+ if (first === "persona") {
927
+ await runPersona(rest);
928
+ return DONE;
949
929
  }
950
930
  if (first === "moderation") {
951
- const [msub, ...mrest] = rest;
952
- if (msub === "check") {
953
- await runModerationCheck(instance, flagValue(mrest, "--app"));
931
+ const resolved = await resolveInstance({ label: "devtools moderation" });
932
+ if (!resolved) {
933
+ process.exitCode = 1;
934
+ return null;
954
935
  }
955
- else if (msub === "catalog") {
956
- await runModerationCatalog(instance);
936
+ if (refuseUnlessDevelopment(resolved.connection, "devtools moderation check")) {
937
+ process.exitCode = 1;
938
+ return null;
957
939
  }
958
- else if (msub === "roundtrip") {
959
- await runModerationRoundTrip(instance);
940
+ const [msub, ...mrest] = rest;
941
+ if (msub === "check") {
942
+ await runModerationCheck(resolved.instance, flagValue(mrest, "--app"));
960
943
  }
961
944
  else {
962
945
  log.error(msub
@@ -964,11 +947,24 @@ async function dispatch(argv) {
964
947
  : `devtools moderation: which of ${subcommandList(["moderation"])}?`);
965
948
  process.exitCode = 1;
966
949
  }
950
+ return DONE;
967
951
  }
968
- else {
969
- await runGrantRoot(instance, flagValue(rest, "--user"));
952
+ if (first === "grant-root") {
953
+ const resolved = await resolveInstance({ label: "devtools grant-root" });
954
+ if (!resolved) {
955
+ process.exitCode = 1;
956
+ return null;
957
+ }
958
+ await runGrantRoot(resolved.connection, resolved.instance, flagValue(rest, "--user"), rest);
959
+ return DONE;
970
960
  }
971
- return DONE;
961
+ log.error(`Unknown command: ${first}`);
962
+ // The top level only. The command is unknown, so there is no level below
963
+ // it to describe, and reprinting the whole tree here is what made the old
964
+ // help unreadable in the first place.
965
+ log.message(renderHelp());
966
+ process.exitCode = 1;
967
+ return null;
972
968
  }
973
969
  // ── Entry ────────────────────────────────────────────────────────────────────
974
970
  /**
@@ -121,10 +121,16 @@ export interface CommandNode {
121
121
  /**
122
122
  * Offer this always, but say on the line why it will not work right now.
123
123
  *
124
- * The four moderation commands carry it: each one opens a client against
125
- * Supabase on this machine and has no remote path at all, so with it down
126
- * they are a spinner followed by a connection error. The hint turns that
127
- * into a sentence the reader sees before choosing.
124
+ * Nothing in the tree carries it at the moment — the four moderation
125
+ * commands were the only ones that ever did, back when each opened a
126
+ * client against Supabase on this machine with no remote path at all, so
127
+ * with it down they were a spinner followed by a connection error. They
128
+ * moved onto the session system alongside `db migrate`/`db reset`, neither
129
+ * of which has ever gated on a local probe (a hosted session has no local
130
+ * stack to probe), so nothing here needs the hint any more. Left declared
131
+ * rather than deleted: a future local-only command is exactly the shape of
132
+ * thing that would want it back, and `blockedBecause`
133
+ * (`environment.test.ts`) still exercises the mechanism directly.
128
134
  */
129
135
  needs?: Condition;
130
136
  /**
package/dist/commands.js CHANGED
@@ -339,47 +339,63 @@ const DECLARED_GROUPS = [
339
339
  {
340
340
  name: "moderation",
341
341
  summary: "An app's moderation integration: the catalog and its wiring.",
342
- hint: "check, catalog, roundtrip",
342
+ hint: "check",
343
343
  subcommands: [
344
344
  {
345
345
  name: "check",
346
- summary: "Check an app's moderation integration.",
347
- hint: "and whether the catalog holds up",
348
- needs: "instance-running",
346
+ summary: "List the catalog, or check one app's moderation integration.",
347
+ hint: "reasons and content types with no --app; per-app wiring with one",
348
+ // No `needs: "instance-running"` any more — this resolves through
349
+ // the session system now (`resolveInstance`, `instance.ts`), the
350
+ // same as `db migrate`/`db reset`, so it works against a hosted
351
+ // development database with no local stack at all. Refuses
352
+ // staging/production itself, with its own message, rather than a
353
+ // menu-only hint.
349
354
  options: [
350
355
  {
351
356
  flag: "--app",
352
357
  value: "<slug>",
353
- summary: "App to check. Asked for when absent.",
358
+ summary: "App to check. Prints the catalog when absent.",
354
359
  },
355
360
  JSON_FLAG,
356
361
  ],
357
362
  },
358
- {
359
- name: "catalog",
360
- summary: "List the report reasons and content types in the database.",
361
- hint: "what can be reported here",
362
- needs: "instance-running",
363
- options: [JSON_FLAG],
364
- },
365
- {
366
- name: "roundtrip",
367
- summary: "File a report, quarantine it, and check the freeze.",
368
- hint: "end to end, then cleans up",
369
- needs: "instance-running",
370
- },
371
363
  ],
372
364
  },
373
365
  {
374
366
  name: "grant-root",
375
367
  summary: "Give an account every permission on your own database.",
376
- needs: "instance-running",
368
+ // Also on the session system now — every tier, not just local. See
369
+ // `check`'s note above; production additionally gets `YES`'s stern
370
+ // confirmation, the same treatment `db reset` gives it.
377
371
  options: [
378
372
  {
379
373
  flag: "--user",
380
374
  value: "<email>",
381
375
  summary: "Account to grant Root to. Asked for when absent.",
382
376
  },
377
+ YES,
378
+ ],
379
+ },
380
+ ],
381
+ },
382
+ {
383
+ title: "Personas",
384
+ commands: [
385
+ {
386
+ name: "persona",
387
+ summary: "A throwaway member or moderator account for development.",
388
+ hint: "member, moderator, or --clean",
389
+ options: [
390
+ {
391
+ flag: "--clean",
392
+ summary: "Delete every account this command has created, instead of making one.",
393
+ prompt: {
394
+ kind: "confirm",
395
+ message: "Clean up every persona this command created, instead of making one?",
396
+ initial: false,
397
+ },
398
+ },
383
399
  ],
384
400
  },
385
401
  ],
@@ -661,22 +677,12 @@ const DECLARED_GROUPS = [
661
677
  {
662
678
  flag: "--base-url",
663
679
  value: "<url>",
664
- summary: "DevDogs API URL. Only used by \"Paste credentials instead\".",
665
- prompt: {
666
- kind: "text",
667
- message: "DevDogs API URL? (blank asks inside the wizard)",
668
- optional: true,
669
- },
680
+ summary: "DevDogs API URL for \"Paste credentials instead\". Defaults to https://api.devdogsuga.org.",
670
681
  },
671
682
  {
672
683
  flag: "--platform-url",
673
684
  value: "<url>",
674
685
  summary: "Platform URL for one-click connect. Defaults to https://devdogsuga.org.",
675
- prompt: {
676
- kind: "text",
677
- message: "Platform URL? (blank uses the default)",
678
- optional: true,
679
- },
680
686
  },
681
687
  {
682
688
  flag: "--device",
@@ -1111,6 +1117,10 @@ export const GROUPS = [
1111
1117
  title: "Moderation",
1112
1118
  commands: commands("moderation", "grant-root"),
1113
1119
  },
1120
+ {
1121
+ title: "Personas",
1122
+ commands: commands("persona"),
1123
+ },
1114
1124
  {
1115
1125
  title: "Environment",
1116
1126
  commands: commands("doctor"),
@@ -0,0 +1,69 @@
1
+ import type { DeployEnvironment } from "@devdogsuga/env";
2
+ import type { DevDatabase } from "@devdogsuga/env/load";
3
+ export interface EnsureGeneratedEnvDeps {
4
+ /** Is anything listening on the local stack port? Injected so tests never
5
+ * open a real socket. */
6
+ probeLocalStack: () => boolean | Promise<boolean>;
7
+ /** File existence, relative to `repoRoot`. Injected so tests never touch
8
+ * a real filesystem. */
9
+ exists: (file: string) => boolean;
10
+ /** `supabase status -o env`'s stdout, or a rejection when the CLI fails —
11
+ * most commonly because the stack on 54321 belongs to a different
12
+ * project. Injected so tests never spawn the real CLI. */
13
+ captureStatus: () => Promise<string>;
14
+ /** Writes `.env.generated`'s full path. Injected so tests never touch a
15
+ * real filesystem. */
16
+ write: (path: string, contents: string) => Promise<void>;
17
+ /** `docker ps --format '{{.Names}}'`, pre-split — or `null` when Docker
18
+ * could not be read. Injected so tests never spawn a real subprocess. */
19
+ listContainerNames: () => string[] | null;
20
+ /** This checkout's own `project_id`, or `null` if unreadable. Injected so
21
+ * tests never touch a real filesystem. */
22
+ projectId: () => string | null;
23
+ /** Used only to build the path `write` receives. */
24
+ repoRoot: string;
25
+ }
26
+ export type EnsureGeneratedEnvResult = {
27
+ outcome: "skipped";
28
+ } | {
29
+ outcome: "wrote";
30
+ line: string;
31
+ } | {
32
+ outcome: "foreign";
33
+ projectId: string;
34
+ line: string;
35
+ } | {
36
+ outcome: "unreachable";
37
+ };
38
+ /**
39
+ * Regenerates `.env.generated` from a running local stack when THIS
40
+ * checkout's own copy is missing.
41
+ *
42
+ * A no-op (`"skipped"`) unless every one of these holds: `tier` is
43
+ * `"development"`, `devDatabase` is not `"remote"` (an explicit `"local"`
44
+ * or the probe-decides `undefined` both qualify), the file is genuinely
45
+ * absent, and something answers the local stack's port. That last check
46
+ * also covers "the file is missing because the stack is genuinely down" —
47
+ * nothing here runs `supabase status` against a stack that is not there;
48
+ * the existing `LocalStackOfflineError`/warning handling covers that case
49
+ * exactly as before.
50
+ *
51
+ * `"foreign"` and `"unreachable"` both mean `supabase status -o env`
52
+ * itself failed — the port answered, but the CLI could not produce a
53
+ * connection block. `"foreign"` is the case a running Docker container's
54
+ * name resolves to: another project's stack holds the port.
55
+ * `"unreachable"` is everything else (an unreadable `config.toml`, an
56
+ * unreadable `docker ps`, a container name that matches nobody) — the
57
+ * caller falls through to its existing offline handling either way, since
58
+ * `.env.generated` is still missing on disk.
59
+ */
60
+ export declare function ensureGeneratedEnvFile(tier: DeployEnvironment, devDatabase: DevDatabase | undefined, deps: EnsureGeneratedEnvDeps): Promise<EnsureGeneratedEnvResult>;
61
+ /**
62
+ * The production wiring for `EnsureGeneratedEnvDeps`, shared by
63
+ * `../env-entry.ts` (via `realEnvEntryDeps`) and `../stack.ts`'s `db
64
+ * status`. `probeLocalStack` is passed in rather than resolved here because
65
+ * both callers already hold the loaded `@devdogsuga/env/load` peer their
66
+ * own dispatch needs anyway — resolving it again here would mean two
67
+ * different copies of the same optional peer in play.
68
+ */
69
+ export declare function realEnsureGeneratedEnvDeps(probeLocalStack: () => boolean | Promise<boolean>): EnsureGeneratedEnvDeps;