@devdogsuga/devtools 0.1.0

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 (253) hide show
  1. package/README.md +44 -0
  2. package/bin/devtools-ci-bare.mjs +48 -0
  3. package/bin/devtools-ci.mjs +17 -0
  4. package/bin/devtools.mjs +27 -0
  5. package/dist/args.d.ts +26 -0
  6. package/dist/args.js +78 -0
  7. package/dist/bws/bw.d.ts +9 -0
  8. package/dist/bws/bw.js +49 -0
  9. package/dist/bws/client.d.ts +43 -0
  10. package/dist/bws/client.js +363 -0
  11. package/dist/bws/environments.d.ts +96 -0
  12. package/dist/bws/environments.js +111 -0
  13. package/dist/bws/pace.d.ts +27 -0
  14. package/dist/bws/pace.js +43 -0
  15. package/dist/bws/retry.d.ts +12 -0
  16. package/dist/bws/retry.js +39 -0
  17. package/dist/bws/store.d.ts +1 -0
  18. package/dist/bws/store.js +32 -0
  19. package/dist/bws/token.d.ts +31 -0
  20. package/dist/bws/token.js +88 -0
  21. package/dist/bws/vault.d.ts +29 -0
  22. package/dist/bws/vault.js +200 -0
  23. package/dist/catalog.d.ts +55 -0
  24. package/dist/catalog.js +81 -0
  25. package/dist/cf/commands.d.ts +1 -0
  26. package/dist/cf/commands.js +182 -0
  27. package/dist/cf/local-env.d.ts +44 -0
  28. package/dist/cf/local-env.js +148 -0
  29. package/dist/ci.d.ts +1 -0
  30. package/dist/ci.js +319 -0
  31. package/dist/cli.d.ts +11 -0
  32. package/dist/cli.js +1077 -0
  33. package/dist/commands.d.ts +255 -0
  34. package/dist/commands.js +1279 -0
  35. package/dist/completions.d.ts +4 -0
  36. package/dist/completions.js +110 -0
  37. package/dist/cron/commands.d.ts +47 -0
  38. package/dist/cron/commands.js +372 -0
  39. package/dist/cron/discovery.d.ts +55 -0
  40. package/dist/cron/discovery.js +85 -0
  41. package/dist/cron/schema.d.ts +45 -0
  42. package/dist/cron/schema.js +35 -0
  43. package/dist/db/config-push.d.ts +8 -0
  44. package/dist/db/config-push.js +11 -0
  45. package/dist/db/connection.d.ts +79 -0
  46. package/dist/db/connection.js +114 -0
  47. package/dist/db/exec.d.ts +1 -0
  48. package/dist/db/exec.js +11 -0
  49. package/dist/db/generate-migration.d.ts +1 -0
  50. package/dist/db/generate-migration.js +67 -0
  51. package/dist/db/generate-types.d.ts +1 -0
  52. package/dist/db/generate-types.js +4 -0
  53. package/dist/db/introspect.d.ts +1 -0
  54. package/dist/db/introspect.js +113 -0
  55. package/dist/db/local-env.d.ts +65 -0
  56. package/dist/db/local-env.js +23 -0
  57. package/dist/db/new-migration.d.ts +1 -0
  58. package/dist/db/new-migration.js +8 -0
  59. package/dist/db/run.d.ts +70 -0
  60. package/dist/db/run.js +158 -0
  61. package/dist/db/seed-buckets.d.ts +2 -0
  62. package/dist/db/seed-buckets.js +4 -0
  63. package/dist/db/seed-production.d.ts +8 -0
  64. package/dist/db/seed-production.js +73 -0
  65. package/dist/db/seed-roles.d.ts +7 -0
  66. package/dist/db/seed-roles.js +31 -0
  67. package/dist/db/session-refresh.d.ts +43 -0
  68. package/dist/db/session-refresh.js +40 -0
  69. package/dist/deploy/migrations.d.ts +20 -0
  70. package/dist/deploy/migrations.js +68 -0
  71. package/dist/deploy/orphans.d.ts +20 -0
  72. package/dist/deploy/orphans.js +177 -0
  73. package/dist/deploy/preflight.d.ts +53 -0
  74. package/dist/deploy/preflight.js +189 -0
  75. package/dist/deploy/report.d.ts +26 -0
  76. package/dist/deploy/report.js +70 -0
  77. package/dist/deploy/require-planner.d.ts +2 -0
  78. package/dist/deploy/require-planner.js +60 -0
  79. package/dist/deploy/require-token.d.ts +15 -0
  80. package/dist/deploy/require-token.js +78 -0
  81. package/dist/deploy/secrets-file.d.ts +17 -0
  82. package/dist/deploy/secrets-file.js +167 -0
  83. package/dist/deploy/write-env.d.ts +40 -0
  84. package/dist/deploy/write-env.js +393 -0
  85. package/dist/docs/index-pages.d.ts +36 -0
  86. package/dist/docs/index-pages.js +184 -0
  87. package/dist/doctor.d.ts +62 -0
  88. package/dist/doctor.js +225 -0
  89. package/dist/emails/commands.d.ts +15 -0
  90. package/dist/emails/commands.js +130 -0
  91. package/dist/emails/email-types.d.ts +13 -0
  92. package/dist/emails/email-types.js +1 -0
  93. package/dist/emails/fixtures.d.ts +20 -0
  94. package/dist/emails/fixtures.js +20 -0
  95. package/dist/env/audit.d.ts +206 -0
  96. package/dist/env/audit.js +436 -0
  97. package/dist/env/cloudflare.d.ts +21 -0
  98. package/dist/env/cloudflare.js +61 -0
  99. package/dist/env/commands.d.ts +99 -0
  100. package/dist/env/commands.js +612 -0
  101. package/dist/env/discovery.d.ts +15 -0
  102. package/dist/env/discovery.js +226 -0
  103. package/dist/env/document.d.ts +119 -0
  104. package/dist/env/document.js +323 -0
  105. package/dist/env/example.d.ts +68 -0
  106. package/dist/env/example.js +603 -0
  107. package/dist/env/selection.d.ts +105 -0
  108. package/dist/env/selection.js +226 -0
  109. package/dist/environment.d.ts +92 -0
  110. package/dist/environment.js +206 -0
  111. package/dist/fingerprint.d.ts +10 -0
  112. package/dist/fingerprint.js +18 -0
  113. package/dist/gen/campus-map.d.ts +1 -0
  114. package/dist/gen/campus-map.js +473 -0
  115. package/dist/gen/commands.d.ts +1 -0
  116. package/dist/gen/commands.js +25 -0
  117. package/dist/gen/hypno.d.ts +1 -0
  118. package/dist/gen/hypno.js +134 -0
  119. package/dist/gh/client.d.ts +80 -0
  120. package/dist/gh/client.js +258 -0
  121. package/dist/gh/environments.d.ts +83 -0
  122. package/dist/gh/environments.js +178 -0
  123. package/dist/gh/rulesets/actors.d.ts +14 -0
  124. package/dist/gh/rulesets/actors.js +68 -0
  125. package/dist/gh/rulesets/api.d.ts +15 -0
  126. package/dist/gh/rulesets/api.js +99 -0
  127. package/dist/gh/rulesets/commands.d.ts +3 -0
  128. package/dist/gh/rulesets/commands.js +203 -0
  129. package/dist/gh/rulesets/desired.d.ts +112 -0
  130. package/dist/gh/rulesets/desired.js +247 -0
  131. package/dist/gh/rulesets/diff.d.ts +65 -0
  132. package/dist/gh/rulesets/diff.js +134 -0
  133. package/dist/gh/rulesets/types.d.ts +76 -0
  134. package/dist/gh/rulesets/types.js +13 -0
  135. package/dist/gh/settings/api.d.ts +40 -0
  136. package/dist/gh/settings/api.js +172 -0
  137. package/dist/gh/settings/commands.d.ts +1 -0
  138. package/dist/gh/settings/commands.js +245 -0
  139. package/dist/gh/settings/desired.d.ts +53 -0
  140. package/dist/gh/settings/desired.js +39 -0
  141. package/dist/gh/settings/diff.d.ts +57 -0
  142. package/dist/gh/settings/diff.js +156 -0
  143. package/dist/gh/settings/types.d.ts +92 -0
  144. package/dist/gh/settings/types.js +11 -0
  145. package/dist/gh/settings/workflows.d.ts +41 -0
  146. package/dist/gh/settings/workflows.js +124 -0
  147. package/dist/grantRoot.d.ts +42 -0
  148. package/dist/grantRoot.js +89 -0
  149. package/dist/help.d.ts +18 -0
  150. package/dist/help.js +169 -0
  151. package/dist/images/commands.d.ts +35 -0
  152. package/dist/images/commands.js +233 -0
  153. package/dist/images/events.d.ts +36 -0
  154. package/dist/images/events.js +152 -0
  155. package/dist/images/graphics.d.ts +70 -0
  156. package/dist/images/graphics.js +233 -0
  157. package/dist/images/open-graph-types.d.ts +75 -0
  158. package/dist/images/open-graph-types.js +1 -0
  159. package/dist/images/prompts.d.ts +34 -0
  160. package/dist/images/prompts.js +100 -0
  161. package/dist/images/render.d.ts +21 -0
  162. package/dist/images/render.js +57 -0
  163. package/dist/images/select.d.ts +64 -0
  164. package/dist/images/select.js +96 -0
  165. package/dist/instance.d.ts +67 -0
  166. package/dist/instance.js +129 -0
  167. package/dist/invocation.d.ts +49 -0
  168. package/dist/invocation.js +78 -0
  169. package/dist/launch-ci.d.ts +1 -0
  170. package/dist/launch-ci.js +86 -0
  171. package/dist/launch.d.ts +29 -0
  172. package/dist/launch.js +371 -0
  173. package/dist/menu.d.ts +31 -0
  174. package/dist/menu.js +277 -0
  175. package/dist/newsletter/commands.d.ts +16 -0
  176. package/dist/newsletter/commands.js +311 -0
  177. package/dist/newsletter/imap.d.ts +17 -0
  178. package/dist/newsletter/imap.js +127 -0
  179. package/dist/newsletter/loopback.d.ts +24 -0
  180. package/dist/newsletter/loopback.js +100 -0
  181. package/dist/newsletter/oauth.d.ts +26 -0
  182. package/dist/newsletter/oauth.js +130 -0
  183. package/dist/newsletter/smtp.d.ts +23 -0
  184. package/dist/newsletter/smtp.js +133 -0
  185. package/dist/oauth/browser.d.ts +1 -0
  186. package/dist/oauth/browser.js +13 -0
  187. package/dist/oauth/config.d.ts +24 -0
  188. package/dist/oauth/config.js +24 -0
  189. package/dist/oauth/connect-url.d.ts +17 -0
  190. package/dist/oauth/connect-url.js +23 -0
  191. package/dist/oauth/db.d.ts +37 -0
  192. package/dist/oauth/db.js +108 -0
  193. package/dist/oauth/device.d.ts +93 -0
  194. package/dist/oauth/device.js +190 -0
  195. package/dist/oauth/discovery.d.ts +28 -0
  196. package/dist/oauth/discovery.js +54 -0
  197. package/dist/oauth/env-file.d.ts +6 -0
  198. package/dist/oauth/env-file.js +35 -0
  199. package/dist/oauth/exchange.d.ts +43 -0
  200. package/dist/oauth/exchange.js +87 -0
  201. package/dist/oauth/label.d.ts +1 -0
  202. package/dist/oauth/label.js +25 -0
  203. package/dist/oauth/loopback.d.ts +52 -0
  204. package/dist/oauth/loopback.js +162 -0
  205. package/dist/oauth/pkce.d.ts +6 -0
  206. package/dist/oauth/pkce.js +34 -0
  207. package/dist/oauth/target.d.ts +54 -0
  208. package/dist/oauth/target.js +102 -0
  209. package/dist/oauth/transport.d.ts +41 -0
  210. package/dist/oauth/transport.js +32 -0
  211. package/dist/oauth/wizard.d.ts +33 -0
  212. package/dist/oauth/wizard.js +571 -0
  213. package/dist/pick.d.ts +6 -0
  214. package/dist/pick.js +64 -0
  215. package/dist/planner/checks.d.ts +26 -0
  216. package/dist/planner/checks.js +104 -0
  217. package/dist/planner/commands.d.ts +10 -0
  218. package/dist/planner/commands.js +367 -0
  219. package/dist/planner/db.d.ts +15 -0
  220. package/dist/planner/db.js +33 -0
  221. package/dist/planner/role.d.ts +33 -0
  222. package/dist/planner/role.js +68 -0
  223. package/dist/repo/peers.d.ts +24 -0
  224. package/dist/repo/peers.js +114 -0
  225. package/dist/repo/preflight.d.ts +109 -0
  226. package/dist/repo/preflight.js +266 -0
  227. package/dist/repo/resolve.d.ts +41 -0
  228. package/dist/repo/resolve.js +152 -0
  229. package/dist/repo/root.d.ts +23 -0
  230. package/dist/repo/root.js +91 -0
  231. package/dist/repo/source.d.ts +14 -0
  232. package/dist/repo/source.js +54 -0
  233. package/dist/repo/tsx-loader.d.ts +6 -0
  234. package/dist/repo/tsx-loader.js +41 -0
  235. package/dist/run/pick.d.ts +60 -0
  236. package/dist/run/pick.js +559 -0
  237. package/dist/setup.d.ts +1 -0
  238. package/dist/setup.js +152 -0
  239. package/dist/stack.d.ts +48 -0
  240. package/dist/stack.js +290 -0
  241. package/dist/telemetry.d.ts +34 -0
  242. package/dist/telemetry.js +93 -0
  243. package/dist/tier.d.ts +40 -0
  244. package/dist/tier.js +93 -0
  245. package/dist/tsconfig.typecheck.tsbuildinfo +1 -0
  246. package/dist/ui.d.ts +25 -0
  247. package/dist/ui.js +51 -0
  248. package/dist/workers.d.ts +5 -0
  249. package/dist/workers.js +35 -0
  250. package/dist/workflows/commands.d.ts +44 -0
  251. package/dist/workflows/commands.js +668 -0
  252. package/env.ts +158 -0
  253. package/package.json +80 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1077 @@
1
+ /**
2
+ * `pnpm devtools [--tier <development:local|development:remote|staging|production>] [command]`
3
+ *
4
+ * Run it with no arguments and it opens a menu. That is the point: a
5
+ * contributor should be able to set up a database and check their moderation
6
+ * integration without knowing a single command name, and without reading this
7
+ * file first.
8
+ *
9
+ * The subcommands still exist for anyone who does know them, and for CI, which
10
+ * cannot answer a prompt.
11
+ *
12
+ * ## Three files, one tree
13
+ *
14
+ * `commands.ts` holds the command tree as data. `help.ts` renders one level of
15
+ * it at a time, and `menu.ts` walks it into an argv that comes back through
16
+ * `dispatch()` below, the same entry a typed command line takes. That is why
17
+ * the menu covers everything the CLI does: there is no second list of commands
18
+ * anywhere, so there is nothing to fall out of step.
19
+ */
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 "./doctor.js";
23
+ import { currentRootHolder, grantRoot, listCandidates, transferRoot, } from "./grantRoot.js";
24
+ import { connectRemoteProject, runStackCommand, } from "./stack.js";
25
+ import { runConfigPush } from "./db/config-push.js";
26
+ import { describeDbTarget, isLocalConnection, resolveDbConnection, } from "./db/connection.js";
27
+ import { runDbExec } from "./db/exec.js";
28
+ import { runGenerateMigration } from "./db/generate-migration.js";
29
+ import { runGenerateTypes } from "./db/generate-types.js";
30
+ import { runIntrospect } from "./db/introspect.js";
31
+ import { runNewMigration } from "./db/new-migration.js";
32
+ import { runSeedBuckets } from "./db/seed-buckets.js";
33
+ import { runSeedProduction } from "./db/seed-production.js";
34
+ import { runSeedRoles } from "./db/seed-roles.js";
35
+ import { runOAuthSetup } from "./oauth/wizard.js";
36
+ import { beginInvocation, recordEnteredTier, recordResolved, reproducibleCommand, } from "./invocation.js";
37
+ import { runSetup } from "./setup.js";
38
+ import { readCatalog, renderCatalog } from "./catalog.js";
39
+ import { runEnvAudit, runEnvPull, runEnvPush, runEnvReset, } from "./env/commands.js";
40
+ import { runEnvExample, runEnvInit } from "./env/example.js";
41
+ import { runPlannerCreate, runPlannerDrop, runPlannerResetPassword, runPlannerStatus, } from "./planner/commands.js";
42
+ import { loadRegistry } from "./env/discovery.js";
43
+ import { loadEnv } from "./repo/peers.js";
44
+ import { setExplicitAccessToken } from "./bws/client.js";
45
+ import { positionals } from "./args.js";
46
+ import { resolveVaultTarget } from "./pick.js";
47
+ import { bail, errorMessage, explain, renderChecks, unwrap } from "./ui.js";
48
+ import { helpPath, renderHelp } from "./help.js";
49
+ import { subcommandList, subcommandNames } from "./commands.js";
50
+ import { bareGroupStartPath, runMenu } from "./menu.js";
51
+ import { runDocsIndex } from "./docs/index-pages.js";
52
+ import { runTask } from "./run/pick.js";
53
+ import { runCompletions } from "./completions.js";
54
+ import { runBw } from "./bws/bw.js";
55
+ import { runImages } from "./images/commands.js";
56
+ import { runEmails } from "./emails/commands.js";
57
+ import { runGen } from "./gen/commands.js";
58
+ import { runCronList, runCronRun } from "./cron/commands.js";
59
+ import { runGithubRulesets } from "./gh/rulesets/commands.js";
60
+ import { runGithubSettings } from "./gh/settings/commands.js";
61
+ import { runWorkflows } from "./workflows/commands.js";
62
+ import { runCf } from "./cf/commands.js";
63
+ import { captureDevtoolsError, initDevtoolsTelemetry } from "./telemetry.js";
64
+ import { ownVersion } from "./repo/preflight.js";
65
+ const DOCTOR_COMMANDS = [
66
+ "doctor",
67
+ "roundtrip",
68
+ "catalog",
69
+ "grant-root",
70
+ ];
71
+ function isDoctorCommand(value) {
72
+ return DOCTOR_COMMANDS.includes(value);
73
+ }
74
+ function flagValue(rest, flag) {
75
+ const index = rest.indexOf(flag);
76
+ if (index === -1)
77
+ return undefined;
78
+ const value = rest[index + 1];
79
+ return value && !value.startsWith("--") ? value : undefined;
80
+ }
81
+ /**
82
+ * The retired flags a `db` invocation can still carry from muscle memory or
83
+ * an old script. Refused loudly, never ignored: a flag that looks like it
84
+ * selects the database while actually deciding nothing is exactly the
85
+ * silent lie the session vocabulary replaced. The session (`--tier
86
+ * development:local|development:remote|staging|production`, settled by the
87
+ * launcher before dispatch) is the ONE selector now.
88
+ */
89
+ function refuseRetiredDbFlags(rest) {
90
+ for (const flag of ["--target", "--team"]) {
91
+ if (rest.includes(flag)) {
92
+ process.stderr.write(`devtools db: ${flag} is retired. The session already names the ` +
93
+ "database — relaunch with --tier development:local, " +
94
+ "development:remote, staging, or production.\n");
95
+ process.exitCode = 1;
96
+ return true;
97
+ }
98
+ }
99
+ return false;
100
+ }
101
+ // ── Connecting ───────────────────────────────────────────────────────────────
102
+ /**
103
+ * Finds the local stack and checks it has been migrated.
104
+ *
105
+ * These commands only ever run against a local stack, and that is structural
106
+ * rather than a rule they follow: `detectLocalInstance` reads `supabase
107
+ * status`, which describes the Docker stack on this machine and nothing else.
108
+ * There is no remote project for it to return, so the tier check that used to
109
+ * sit here, reading a `production` flag out of the database, bought nothing and
110
+ * has been removed along with the table it read.
111
+ */
112
+ async function connect() {
113
+ const s = spinner();
114
+ s.start("Looking for your database");
115
+ let instance;
116
+ try {
117
+ instance = detectLocalInstance();
118
+ }
119
+ catch (err) {
120
+ s.stop("Could not find a running database");
121
+ explain("The local Supabase stack is not reachable.", errorMessage(err), [
122
+ "1. Make sure Docker is running",
123
+ "2. Run `pnpm devtools db start` (or choose Database → start in the menu)",
124
+ "3. Confirm with `supabase status`",
125
+ ]);
126
+ return null;
127
+ }
128
+ try {
129
+ await assertMigrated(instance);
130
+ s.stop(`Connected to your database at ${instance.apiUrl}`);
131
+ }
132
+ catch (err) {
133
+ s.stop("Connected, but the schema is not there");
134
+ explain("That instance cannot be used here.", errorMessage(err), [
135
+ "Run `pnpm devtools db reset` to rebuild your own database from migrations and seeds.",
136
+ ]);
137
+ return null;
138
+ }
139
+ return instance;
140
+ }
141
+ // ── Commands ─────────────────────────────────────────────────────────────────
142
+ async function runStack(command, rest) {
143
+ // The data commands need the session's connection before anything else —
144
+ // including before the reset confirmation below, which has to name what it
145
+ // is about to erase. The lifecycle commands (`start`/`stop`/`restart`)
146
+ // skip resolution outright: they act on this machine's containers, and
147
+ // `db start` is the FIX for the very state resolution would refuse on.
148
+ // `status` resolves quietly and treats "nothing resolvable" as an answer.
149
+ let connection = null;
150
+ if (command === "migrate" || command === "reset") {
151
+ connection = await resolveDbConnection({ label: `devtools db ${command}` });
152
+ if (!connection) {
153
+ process.exitCode = 1;
154
+ return;
155
+ }
156
+ }
157
+ else if (command === "status") {
158
+ connection = await resolveDbConnection({ quiet: true });
159
+ }
160
+ // `reset` drops everything, and a non-local `migrate` pushes straight to a
161
+ // shared database — both worth a question before they run. The local stack
162
+ // gets the harmless-sounding question, every hosted database a harder one
163
+ // naming exactly which, and production the hardest of all.
164
+ const local = connection !== null && isLocalConnection(connection);
165
+ if (connection !== null &&
166
+ (command === "reset" || (command === "migrate" && !local))) {
167
+ if (!local) {
168
+ // Named up front, and ONLY the tier/host and project — never the
169
+ // DB_URL, which carries the password — so whoever is about to answer
170
+ // "yes" knows exactly what they are agreeing to.
171
+ log.message(`This will ${command === "reset" ? "reset" : "push migrations to"} ${describeDbTarget(connection)}` +
172
+ (connection.projectRef
173
+ ? ` (project ${connection.projectRef}).`
174
+ : "."));
175
+ }
176
+ // ⚠️ SAFETY: gates every branch below, including production — `--yes` is
177
+ // the ONE way past any of them, checked before anything TTY-dependent
178
+ // runs. clack's `confirm()` never resolves without a TTY (reproduced
179
+ // against @clack/core@1.4.3), so a non-interactive caller without --yes
180
+ // must be refused outright rather than left to hang forever on a prompt
181
+ // nobody is there to answer.
182
+ if (!rest.includes("--yes")) {
183
+ if (!process.stdin.isTTY) {
184
+ process.stderr.write(`devtools db ${command}: --yes is required to run non-interactively.\n`);
185
+ process.exitCode = 1;
186
+ return;
187
+ }
188
+ if (connection.tier === "production") {
189
+ // Production gets the sternest wording of the three: this is the one
190
+ // database in this whole CLI that must never be touched by a
191
+ // reflexive keystroke.
192
+ const confirmed = unwrap(await confirm({
193
+ message: (command === "reset"
194
+ ? "This PERMANENTLY ERASES the PRODUCTION database "
195
+ : "This pushes new migrations to the PRODUCTION database ") +
196
+ `(project ${connection.projectRef ?? "unknown"}). Continue?`,
197
+ initialValue: false,
198
+ }));
199
+ if (!confirmed)
200
+ bail("Left the database alone.");
201
+ }
202
+ else {
203
+ const confirmed = unwrap(await confirm({
204
+ message: command === "reset"
205
+ ? local
206
+ ? "This erases your local database and rebuilds it. Continue?"
207
+ : `This erases ${describeDbTarget(connection)} and rebuilds it. Continue?`
208
+ : `This pushes new migrations to ${describeDbTarget(connection)}. Continue?`,
209
+ initialValue: local,
210
+ }));
211
+ if (!confirmed)
212
+ bail("Left the database alone.");
213
+ }
214
+ }
215
+ }
216
+ try {
217
+ const { code, lines } = await runStackCommand(command, connection);
218
+ for (const line of lines)
219
+ log.message(line);
220
+ if (code !== 0) {
221
+ // Lines on a failure ARE the explanation, which is the contract with
222
+ // `runStackCommand`. "Scroll up for the Supabase CLI's output" is only
223
+ // true when a delegated script ran, and pointing a reader at output that
224
+ // does not exist is worse than adding nothing. A failure with scrollback
225
+ // worth reading says so in its own line.
226
+ if (lines.length === 0) {
227
+ explain(`\`${command}\` did not finish cleanly.`, "", [
228
+ "Scroll up for the output from the Supabase CLI.",
229
+ ]);
230
+ }
231
+ process.exitCode = code;
232
+ }
233
+ }
234
+ catch (err) {
235
+ explain(`\`${command}\` failed.`, errorMessage(err));
236
+ process.exitCode = 1;
237
+ }
238
+ }
239
+ async function runCatalog(instance) {
240
+ const catalog = await readCatalog(instance);
241
+ note(renderCatalog(catalog), "Moderation catalog");
242
+ }
243
+ async function runDoctor(instance, appSlug) {
244
+ let slug = appSlug;
245
+ if (!slug) {
246
+ const apps = await listApps(instance);
247
+ slug = unwrap(await select({
248
+ message: "Which app should I check?",
249
+ options: apps.map((value) => ({
250
+ value,
251
+ label: value,
252
+ hint: value === "platform" ? "the worked example" : undefined,
253
+ })),
254
+ }));
255
+ }
256
+ const s = spinner();
257
+ s.start(`Checking ${slug}`);
258
+ let types;
259
+ try {
260
+ types = await conformance(instance, slug);
261
+ s.stop(`Checked ${slug}`);
262
+ }
263
+ catch (err) {
264
+ s.stop("The check could not run");
265
+ explain("conformance_check() failed.", errorMessage(err), [
266
+ `Signing in as ${PERSONAS.moderator} needs the seeds — try \`pnpm devtools db reset\`.`,
267
+ ]);
268
+ return;
269
+ }
270
+ if (types.length === 0) {
271
+ note(`${slug} has no moderatable content types.\n\n` +
272
+ "A table becomes one by carrying a foreign key to\n" +
273
+ 'platform."reportResolutions" -- adding that column is the whole\n' +
274
+ "registration. See docs/platform/reporting-and-feedback.md.", "Nothing to check");
275
+ return;
276
+ }
277
+ let failures = 0;
278
+ for (const type of types) {
279
+ const failed = type.checks.filter((c) => !c.ok).length;
280
+ failures += failed;
281
+ note(renderChecks(type.checks), `${type.tableName} → "${type.contentType}"`);
282
+ }
283
+ if (failures === 0) {
284
+ log.success(`${slug} looks correctly integrated.`);
285
+ }
286
+ else {
287
+ log.warn(`${failures} check${failures === 1 ? "" : "s"} failed. The last two are ` +
288
+ "heuristics over policy text, so a failure there is worth reading rather " +
289
+ "than trusting outright.");
290
+ }
291
+ }
292
+ async function runRoundTrip(instance) {
293
+ const s = spinner();
294
+ s.start("Filing a report, quarantining it, and looking again");
295
+ try {
296
+ const steps = await quarantineRoundTrip(instance);
297
+ s.stop("Round-trip finished");
298
+ note(renderChecks(steps), "What happened");
299
+ const failed = steps.filter((step) => !step.ok);
300
+ if (failed.length === 0) {
301
+ log.success("Quarantine freezes a reported profile and resets the display name, and the fixtures were cleaned up.");
302
+ }
303
+ else {
304
+ log.warn(`${failed.length} step${failed.length === 1 ? "" : "s"} did not hold. ` +
305
+ "The fixtures were still cleaned up.");
306
+ }
307
+ }
308
+ catch (err) {
309
+ s.stop("The round-trip could not run");
310
+ explain("Something went wrong before the checks could finish.", errorMessage(err), [
311
+ "`pnpm devtools db reset` rebuilds the database with the seeded personas and the open report they act on.",
312
+ ]);
313
+ }
314
+ }
315
+ /**
316
+ * Grants Root, asking who to if it was not told.
317
+ *
318
+ * Transferring is a separate confirmation from granting, because they are
319
+ * different actions wearing the same name: one gives you a console you did not
320
+ * have, the other takes somebody else's away. `userRoles_root_singleton` means
321
+ * there is no state where both hold it, so the release cannot be skipped.
322
+ */
323
+ async function runGrantRoot(instance, userEmail) {
324
+ let holder;
325
+ let candidates;
326
+ try {
327
+ [holder, candidates] = await Promise.all([
328
+ currentRootHolder(instance),
329
+ listCandidates(instance),
330
+ ]);
331
+ }
332
+ catch (err) {
333
+ explain("Could not read the current roles.", errorMessage(err));
334
+ process.exitCode = 1;
335
+ return;
336
+ }
337
+ if (candidates.length === 0) {
338
+ explain("There are no accounts on this database yet.", "", [
339
+ "Sign in once through the app, then run this again.",
340
+ `Or use a seeded persona: ${PERSONAS.member}, password \`password\`.`,
341
+ ]);
342
+ return;
343
+ }
344
+ const chosen = userEmail ??
345
+ unwrap(await select({
346
+ message: "Which account should hold Root?",
347
+ options: candidates.map((c) => ({
348
+ value: c.email,
349
+ label: c.email,
350
+ hint: c.userId === holder?.userId ? "holds it now" : undefined,
351
+ })),
352
+ }));
353
+ const target = candidates.find((c) => c.email === chosen);
354
+ if (!target) {
355
+ explain(`No account on this database has the address ${chosen}.`, "", [
356
+ "Run without --user to pick from a list.",
357
+ ]);
358
+ process.exitCode = 1;
359
+ return;
360
+ }
361
+ if (holder?.userId === target.userId) {
362
+ log.info(`${target.email} already holds Root.`);
363
+ return;
364
+ }
365
+ try {
366
+ if (holder) {
367
+ const confirmed = unwrap(await confirm({
368
+ message: `Root is held by ${holder.email}. Take it away and give it to ${target.email}?`,
369
+ initialValue: false,
370
+ }));
371
+ if (!confirmed)
372
+ bail("Left Root where it was.");
373
+ await transferRoot(instance, holder.userId, target.userId);
374
+ }
375
+ else {
376
+ await grantRoot(instance, target.userId);
377
+ }
378
+ log.success(`${target.email} now holds Root, which confers every permission. ` +
379
+ "Sign out and back in if the console was already open.");
380
+ }
381
+ catch (err) {
382
+ explain("Could not grant Root.", errorMessage(err), [
383
+ "Seeds create the Root role definition — try `pnpm devtools db reset` first.",
384
+ ]);
385
+ process.exitCode = 1;
386
+ }
387
+ }
388
+ /**
389
+ * `env <pull|push|audit> --target <preflight|staging|production>`, plus the
390
+ * three local-only subcommands: `reset`, `example [--check]`, and
391
+ * `init [--target <target>]`.
392
+ *
393
+ * The target has no default for pull/push/audit, and is asked for when
394
+ * `--target` is absent. Every other command here defaults to the local stack
395
+ * because guessing wrong is free; guessing wrong about whose credentials to
396
+ * overwrite is not. (`init` does default, to development: it refuses to touch
397
+ * an existing file, so the worst a wrong guess can do is create a blank one.)
398
+ *
399
+ * One flag, one vocabulary. `--target` names a row in the target table, and
400
+ * the file, the Bitwarden project and whether `DEPLOY_ENV` may say it all come
401
+ * from that row. Its predecessor `--env` named one of two different enums
402
+ * depending on which subcommand read it, which is why `init --env staging`
403
+ * wrote `.env.staging` while `push --env staging` uploaded `.env`.
404
+ */
405
+ async function runEnvCommand(rest) {
406
+ const { ENV_TARGETS, isEnvTarget } = await loadEnv();
407
+ // `positionals` rather than `rest[0]`, so a flag before the subcommand does
408
+ // not become the subcommand -- and, more to the point, so the VALUE of a flag
409
+ // never does: in `env --file production pull`, `production` is a filename and
410
+ // must not be read as anything else.
411
+ const [sub] = positionals(rest);
412
+ // Validated against the command tree rather than a list kept here. One
413
+ // declaration means a subcommand cannot exist in the CLI and be missing
414
+ // from the menu, or the reverse.
415
+ if (!sub || !subcommandNames(["env"]).includes(sub)) {
416
+ log.error(`Unknown env subcommand: ${sub ?? "(none)"}`);
417
+ log.message(`Try ${subcommandList(["env"])}.`);
418
+ process.exitCode = 1;
419
+ return;
420
+ }
421
+ // Refused by name rather than ignored. `--env` used to be this flag, and the
422
+ // words it took (`staging`, `production`) are still valid `--target` values,
423
+ // so a stale invocation would otherwise run with NO target: prompting, or
424
+ // failing as "nobody here to ask", neither of which says what changed.
425
+ if (rest.includes("--env")) {
426
+ explain("`--env` is now `--target`.", "", [
427
+ "It named two different things depending on the subcommand: which file",
428
+ "(init) and which Bitwarden project (pull/push/audit). --target names",
429
+ `one row: ${ENV_TARGETS.join(", ")}.`,
430
+ "wrangler, supabase and gh still have their own --env; this is ours.",
431
+ ]);
432
+ process.exitCode = 1;
433
+ return;
434
+ }
435
+ // `reset` only edits a local file. Asking which target to clear it against
436
+ // would imply it reaches one, which is the opposite of what it does.
437
+ if (sub === "reset") {
438
+ try {
439
+ await runEnvReset({
440
+ file: flagValue(rest, "--file"),
441
+ yes: rest.includes("--yes"),
442
+ });
443
+ }
444
+ catch (err) {
445
+ explain("The reset failed.", errorMessage(err));
446
+ process.exitCode = 1;
447
+ }
448
+ return;
449
+ }
450
+ // Every remaining subcommand reads the registry, which fills only when the
451
+ // env manifests are imported. Loaded HERE, lazily, rather than at CLI
452
+ // start: the import pass touches a manifest in nearly every workspace
453
+ // package, and `pnpm devtools db reset` (or any stack command) should not pay
454
+ // for declarations it never reads. `env reset` returned above for the
455
+ // same reason: it edits the local file and consults no key set.
456
+ await loadRegistry();
457
+ // `example` and `init` are pure registry → text. They return BEFORE the
458
+ // Bitwarden token lookup and the pull/push target prompt, and must
459
+ // keep doing so: CI's credential-free validate job runs `example --check`,
460
+ // and a generator that needed a secret to describe the secrets could not
461
+ // live there.
462
+ if (sub === "example") {
463
+ try {
464
+ await runEnvExample({ check: rest.includes("--check") });
465
+ }
466
+ catch (err) {
467
+ explain("Generating .env.example failed.", errorMessage(err));
468
+ process.exitCode = 1;
469
+ }
470
+ return;
471
+ }
472
+ if (sub === "init") {
473
+ // Every target, including `development` and `preflight`: init maps target
474
+ // → file and nothing else, and every target has a file. It is the one
475
+ // subcommand here that needs no Bitwarden project, though WHAT it writes
476
+ // now depends on the target. See `example.ts`'s header for why a vault
477
+ // target's file is not the development one under a different name.
478
+ const given = flagValue(rest, "--target") ?? "development";
479
+ if (!isEnvTarget(given)) {
480
+ explain(`"${given}" is not a target init can create a file for.`, "", [
481
+ `Pass --target ${ENV_TARGETS.join(" | ")} (default: development).`,
482
+ ]);
483
+ process.exitCode = 1;
484
+ return;
485
+ }
486
+ try {
487
+ // `--apps` (development only): which projects' sections to render, as
488
+ // comma-separated app names, plus `devtools` for the operator role.
489
+ // Absent at a terminal, init asks; absent in a pipe, it renders
490
+ // everything, which is what every pre-picker caller got.
491
+ await runEnvInit(given, flagValue(rest, "--apps") ?? undefined);
492
+ }
493
+ catch (err) {
494
+ explain("env init failed.", errorMessage(err));
495
+ process.exitCode = 1;
496
+ }
497
+ return;
498
+ }
499
+ // The question names the direction, because the answer means something
500
+ // different each way: pull overwrites your file, push overwrites theirs.
501
+ const target = await resolveVaultTarget(flagValue(rest, "--target"), sub === "pull"
502
+ ? "Which target should I pull into its env file?"
503
+ : sub === "push"
504
+ ? "Which target should I push its env file to?"
505
+ : "Which target should I audit?");
506
+ if (!target) {
507
+ process.exitCode = 1;
508
+ return;
509
+ }
510
+ // A target chosen at the prompt (not passed as a flag) is what the rerun
511
+ // line needs to skip that prompt next time.
512
+ if (flagValue(rest, "--target") === undefined) {
513
+ recordResolved("--target", target);
514
+ }
515
+ // Before any command runs, so every `bws` call in it sees the same token.
516
+ setExplicitAccessToken(flagValue(rest, "--access-token"));
517
+ const options = {
518
+ target,
519
+ file: flagValue(rest, "--file"),
520
+ yes: rest.includes("--yes"),
521
+ };
522
+ try {
523
+ if (sub === "pull")
524
+ await runEnvPull(options);
525
+ else if (sub === "push")
526
+ await runEnvPush(options);
527
+ else
528
+ await runEnvAudit(options);
529
+ }
530
+ catch (err) {
531
+ explain("The env command failed.", errorMessage(err), [
532
+ "The access token is read from --access-token, then BWS_ACCESS_TOKEN,",
533
+ "then your Bitwarden vault, and finally by asking.",
534
+ "`gh auth status` shows whether the GitHub CLI is signed in.",
535
+ ]);
536
+ process.exitCode = 1;
537
+ }
538
+ }
539
+ /**
540
+ * `db planner <status|create|reset-password|drop> [--db-url <url>]`
541
+ *
542
+ * Operator-side lifecycle of the `migration_planner` role. See
543
+ * `planner/commands.ts` for the commands themselves and for why there is no
544
+ * `retrieve`. Interactive by design (create and reset confirm before writing
545
+ * to production), so unlike the `deploy` group it talks through clack and is
546
+ * fine to run as plain `pnpm devtools db planner …`.
547
+ */
548
+ async function runPlannerCommand(rest) {
549
+ const [sub] = positionals(rest);
550
+ const options = { dbUrl: flagValue(rest, "--db-url") ?? undefined };
551
+ if (sub === "status") {
552
+ await runPlannerStatus(options);
553
+ return;
554
+ }
555
+ if (sub === "create") {
556
+ await runPlannerCreate(options);
557
+ return;
558
+ }
559
+ if (sub === "reset-password") {
560
+ await runPlannerResetPassword(options);
561
+ return;
562
+ }
563
+ if (sub === "drop") {
564
+ await runPlannerDrop(options);
565
+ return;
566
+ }
567
+ log.error(sub
568
+ ? `devtools db planner: unknown subcommand "${sub}". Try ${subcommandList(["db", "planner"])}.`
569
+ : `devtools db planner: which of ${subcommandList(["db", "planner"])}?`);
570
+ process.exitCode = 1;
571
+ }
572
+ // ── Database ─────────────────────────────────────────────────────────────────
573
+ /**
574
+ * `db <subcommand> …` — the merged Supabase/Database group.
575
+ *
576
+ * One dispatcher, replacing the flat `isStackCommand`/`isDbCommand` checks
577
+ * that used to sit in `dispatch` directly: every verb below (`start`,
578
+ * `migrate`, `types`, `seed roles`, `planner status`, …) used to be its own
579
+ * top-level command, so a bare `push` told the reader nothing about what it
580
+ * touched. Nesting them under `db` is what lets `--help db` and the wizard
581
+ * group them by `scope` (see `commands.ts`) instead of listing all of them
582
+ * flat. `planner` keeps its own dispatcher unchanged; this just routes to it
583
+ * one level deeper.
584
+ */
585
+ async function runDbCommand(rest) {
586
+ const { fileFor } = await loadEnv();
587
+ if (refuseRetiredDbFlags(rest))
588
+ return;
589
+ const [sub, ...subRest] = rest;
590
+ if (sub === "connect") {
591
+ const ref = subRest.find((arg) => !arg.startsWith("-"));
592
+ const code = await connectRemoteProject(ref);
593
+ process.exitCode = code === 0 ? 0 : 1;
594
+ return;
595
+ }
596
+ if (sub === "start" ||
597
+ sub === "stop" ||
598
+ sub === "restart" ||
599
+ sub === "status" ||
600
+ sub === "migrate" ||
601
+ sub === "reset") {
602
+ await runStack(sub, subRest);
603
+ return;
604
+ }
605
+ if (sub === "migration") {
606
+ const [msub, ...mrest] = subRest;
607
+ if (msub === "new") {
608
+ const code = await runNewMigration(mrest.find((arg) => !arg.startsWith("-")));
609
+ process.exitCode = code === 0 ? 0 : 1;
610
+ return;
611
+ }
612
+ if (msub === "generate") {
613
+ const code = await runGenerateMigration(flagValue(mrest, "--app"));
614
+ process.exitCode = code === 0 ? 0 : 1;
615
+ return;
616
+ }
617
+ log.error(msub
618
+ ? `devtools db migration: unknown subcommand "${msub}". Try ${subcommandList(["db", "migration"])}.`
619
+ : `devtools db migration: which of ${subcommandList(["db", "migration"])}?`);
620
+ process.exitCode = 1;
621
+ return;
622
+ }
623
+ if (sub === "types") {
624
+ const connection = await resolveDbConnection({ label: "devtools db types" });
625
+ if (!connection) {
626
+ process.exitCode = 1;
627
+ return;
628
+ }
629
+ const code = await runGenerateTypes(connection.dbUrl);
630
+ process.exitCode = code === 0 ? 0 : 1;
631
+ return;
632
+ }
633
+ if (sub === "seed") {
634
+ const [ssub] = subRest;
635
+ if (ssub === "buckets") {
636
+ const connection = await resolveDbConnection({
637
+ label: "devtools db seed buckets",
638
+ });
639
+ if (!connection) {
640
+ process.exitCode = 1;
641
+ return;
642
+ }
643
+ // `seed buckets` drives the Storage API, which has no `--db-url` mode
644
+ // (see `db/run.ts`), so this is the one data command still keyed on
645
+ // local-vs-hosted rather than handed the session's URL.
646
+ const code = await runSeedBuckets(isLocalConnection(connection)
647
+ ? { kind: "local" }
648
+ : { kind: "remote", projectRef: connection.projectRef });
649
+ process.exitCode = code === 0 ? 0 : 1;
650
+ return;
651
+ }
652
+ if (ssub === "roles") {
653
+ const connection = await resolveDbConnection({
654
+ label: "devtools db seed roles",
655
+ });
656
+ if (!connection) {
657
+ process.exitCode = 1;
658
+ return;
659
+ }
660
+ const code = await runSeedRoles(connection.dbUrl);
661
+ process.exitCode = code === 0 ? 0 : 1;
662
+ return;
663
+ }
664
+ if (ssub === "production") {
665
+ const connection = await resolveDbConnection({
666
+ label: "devtools db seed production",
667
+ });
668
+ if (!connection) {
669
+ process.exitCode = 1;
670
+ return;
671
+ }
672
+ // Same non-local gate `runStack` uses for `reset`/`migrate`: named up
673
+ // front (tier/host only, never the DB_URL) and, absent --yes, refused
674
+ // outright for a non-interactive caller rather than left to hang on a
675
+ // prompt nobody is there to answer. `seed production` writes real rows
676
+ // to whatever it targets, so a staging or production session gets the
677
+ // same confirmation those destructive commands do; a local session
678
+ // (the common case — verifying the seed split, or repairing a local
679
+ // stack after `reset --no-seed`) does not, matching `seed
680
+ // buckets`/`seed roles` today.
681
+ if (!isLocalConnection(connection)) {
682
+ log.message(`This will write supabase/seed/production/*.sql to ${describeDbTarget(connection)}` +
683
+ (connection.projectRef
684
+ ? ` (project ${connection.projectRef}).`
685
+ : "."));
686
+ if (!subRest.includes("--yes")) {
687
+ if (!process.stdin.isTTY) {
688
+ process.stderr.write("devtools db seed production: --yes is required to run non-interactively.\n");
689
+ process.exitCode = 1;
690
+ return;
691
+ }
692
+ const confirmed = unwrap(await confirm({
693
+ message: connection.tier === "production"
694
+ ? "This writes the production seed set to the PRODUCTION database " +
695
+ `(project ${connection.projectRef ?? "unknown"}). Continue?`
696
+ : `This writes the production seed set to ${describeDbTarget(connection)}. Continue?`,
697
+ initialValue: false,
698
+ }));
699
+ if (!confirmed)
700
+ bail("Left the database alone.");
701
+ }
702
+ }
703
+ const code = await runSeedProduction(connection.dbUrl);
704
+ process.exitCode = code === 0 ? 0 : 1;
705
+ return;
706
+ }
707
+ log.error(ssub
708
+ ? `devtools db seed: unknown subcommand "${ssub}". Try ${subcommandList(["db", "seed"])}.`
709
+ : `devtools db seed: which of ${subcommandList(["db", "seed"])}?`);
710
+ process.exitCode = 1;
711
+ return;
712
+ }
713
+ if (sub === "introspect") {
714
+ const code = await runIntrospect(flagValue(subRest, "--app"));
715
+ process.exitCode = code === 0 ? 0 : 1;
716
+ return;
717
+ }
718
+ if (sub === "config") {
719
+ const [csub] = subRest;
720
+ if (csub === "push") {
721
+ // Hosted-only — `config.toml` is pushed to a project ref, and the
722
+ // Docker stack has none (it reads the file directly at `db start`).
723
+ const connection = await resolveDbConnection({
724
+ label: "devtools db config push",
725
+ });
726
+ if (!connection) {
727
+ process.exitCode = 1;
728
+ return;
729
+ }
730
+ if (isLocalConnection(connection)) {
731
+ process.stderr.write("devtools db config push: the local stack reads config.toml " +
732
+ "directly (`db restart` applies changes). Relaunch with --tier " +
733
+ "development:remote, staging, or production to push it to a " +
734
+ "hosted project.\n");
735
+ process.exitCode = 1;
736
+ return;
737
+ }
738
+ if (!connection.projectRef) {
739
+ process.stderr.write(`devtools db config push: ${fileFor(connection.tier)} has no PROJECT_REF.\n`);
740
+ process.exitCode = 1;
741
+ return;
742
+ }
743
+ const code = await runConfigPush(connection.projectRef);
744
+ process.exitCode = code === 0 ? 0 : 1;
745
+ return;
746
+ }
747
+ log.error(csub
748
+ ? `devtools db config: unknown subcommand "${csub}". Try ${subcommandList(["db", "config"])}.`
749
+ : `devtools db config: which of ${subcommandList(["db", "config"])}?`);
750
+ process.exitCode = 1;
751
+ return;
752
+ }
753
+ if (sub === "planner") {
754
+ await runPlannerCommand(subRest);
755
+ return;
756
+ }
757
+ if (sub === "exec") {
758
+ const execArgs = subRest[0] === "--" ? subRest.slice(1) : subRest;
759
+ const code = await runDbExec(execArgs);
760
+ process.exitCode = code === 0 ? 0 : 1;
761
+ return;
762
+ }
763
+ log.error(sub
764
+ ? `devtools db: unknown subcommand "${sub}". Try ${subcommandList(["db"])}.`
765
+ : `devtools db: which of ${subcommandList(["db"])}?`);
766
+ process.exitCode = 1;
767
+ }
768
+ // ── Docs ─────────────────────────────────────────────────────────────────────
769
+ /**
770
+ * `docs index [--target <local|remote>]`, the documentation search index.
771
+ *
772
+ * One subcommand today, and a group rather than a top-level `docs-index`
773
+ * because the artifact it reads has more than one thing worth doing to it
774
+ * (a `--check` that reports drift is the obvious next one).
775
+ */
776
+ async function runDocsCommand(rest) {
777
+ const [sub] = positionals(rest);
778
+ if (!sub || !subcommandNames(["docs"]).includes(sub)) {
779
+ log.error(sub
780
+ ? `devtools docs: unknown subcommand "${sub}". Try ${subcommandList(["docs"])}.`
781
+ : `devtools docs: which subcommand? Try ${subcommandList(["docs"])}.`);
782
+ process.exitCode = 1;
783
+ return;
784
+ }
785
+ // `docs index`'s `--target remote` is NOT the retired db selector: it is
786
+ // the explicit acknowledgment its destructive delete requires when DB_URL
787
+ // is not local (see `docs/index-pages.ts`). The database itself still
788
+ // comes from the session's DB_URL like everything else.
789
+ const target = flagValue(rest, "--target") === "remote" ? "remote" : "local";
790
+ await runDocsIndex({ target });
791
+ }
792
+ // ── Dispatch ─────────────────────────────────────────────────────────────────
793
+ /** The outro line for a command that finished; `null` means one that failed. */
794
+ const DONE = "Done.";
795
+ /**
796
+ * Routes an argv to a command, and reports what to print when it returns.
797
+ *
798
+ * The wizard calls THIS rather than the command functions, so a menu walk and
799
+ * a typed command line take the identical path. `deploy` is not routed here.
800
+ * It is dispatched before `intro()` in `main()`, for the reason
801
+ * `runDeployCommand`'s header gives.
802
+ *
803
+ * Returns the `outro()` line, or `null` where the failure has already been
804
+ * explained and a cheerful "Done." would contradict it.
805
+ */
806
+ async function dispatch(argv) {
807
+ const [first, ...rest] = argv;
808
+ if (!first)
809
+ return DONE;
810
+ if (first === "setup") {
811
+ await runSetup();
812
+ return DONE;
813
+ }
814
+ if (first === "completions") {
815
+ const code = runCompletions(rest);
816
+ process.exitCode = code;
817
+ return code === 0 ? DONE : null;
818
+ }
819
+ // Reached only from the wizard. A typed `run` or `bw` is handled in
820
+ // `main()` before `intro()`. Both exit with their child's status, so
821
+ // neither returns and `outro()` is never reached. That is right: by the
822
+ // time a menu walk gets here the banner is already on screen, above the
823
+ // menu it introduced, rather than wedged between this CLI and pnpm's
824
+ // output.
825
+ if (first === "run")
826
+ return runTask(rest);
827
+ if (first === "bw")
828
+ return runBw(rest);
829
+ if (first === "oauth") {
830
+ const forceDevice = rest.includes("--device");
831
+ const forceLoopback = rest.includes("--loopback");
832
+ if (forceDevice && forceLoopback) {
833
+ process.stderr.write("devtools oauth: --device and --loopback are mutually exclusive — pass at most one.\n");
834
+ process.exitCode = 1;
835
+ return null;
836
+ }
837
+ await runOAuthSetup(flagValue(rest, "--base-url"), flagValue(rest, "--platform-url"), forceDevice ? "device" : forceLoopback ? "loopback" : undefined);
838
+ return 'All done! You\'re ready to "Sign in with DevDogs".';
839
+ }
840
+ if (first === "docs") {
841
+ await runDocsCommand(rest);
842
+ return DONE;
843
+ }
844
+ if (first === "images") {
845
+ // `connect` is passed rather than called: only event graphics need a
846
+ // database, and `images page/*` must not demand a running stack to draw
847
+ // pictures that come entirely out of this repo.
848
+ await runImages(rest, { connect });
849
+ return DONE;
850
+ }
851
+ if (first === "emails") {
852
+ await runEmails(rest);
853
+ return DONE;
854
+ }
855
+ if (first === "cf") {
856
+ const code = await runCf(rest);
857
+ process.exitCode = code;
858
+ return DONE;
859
+ }
860
+ if (first === "gen") {
861
+ const code = await runGen(rest);
862
+ process.exitCode = code;
863
+ return DONE;
864
+ }
865
+ if (first === "cron") {
866
+ const sub = rest[0];
867
+ const cronArgs = rest.slice(1);
868
+ let code;
869
+ if (sub === "list") {
870
+ code = await runCronList(cronArgs);
871
+ }
872
+ else if (sub === "run") {
873
+ code = await runCronRun(cronArgs);
874
+ }
875
+ else {
876
+ process.stderr.write(`devtools cron: unknown subcommand "${sub ?? "(none)"}". Expected: list or run.\n`);
877
+ code = 1;
878
+ }
879
+ process.exitCode = code;
880
+ return DONE;
881
+ }
882
+ if (first === "github") {
883
+ const sub = rest[0];
884
+ const githubArgs = rest.slice(1);
885
+ let code;
886
+ if (sub === "rulesets") {
887
+ code = await runGithubRulesets(githubArgs);
888
+ }
889
+ else if (sub === "settings") {
890
+ code = await runGithubSettings(githubArgs);
891
+ }
892
+ else {
893
+ process.stderr.write(`devtools github: unknown subcommand "${sub ?? "(none)"}". Expected: rulesets or settings.\n`);
894
+ code = 1;
895
+ }
896
+ process.exitCode = code;
897
+ return DONE;
898
+ }
899
+ if (first === "workflows") {
900
+ const code = await runWorkflows(rest);
901
+ process.exitCode = code;
902
+ return code === 0 ? DONE : null;
903
+ }
904
+ if (first === "env") {
905
+ await runEnvCommand(rest);
906
+ return DONE;
907
+ }
908
+ if (first === "db") {
909
+ await runDbCommand(rest);
910
+ return DONE;
911
+ }
912
+ // The old name, refused with the new one rather than falling into "Unknown
913
+ // command". It is in this repo's own docs, scripts and shell histories, and
914
+ // the rename came with a flag rename, so a bare "unknown" would leave both
915
+ // halves to be rediscovered.
916
+ if (first === "secrets") {
917
+ explain("`secrets` is now `env`.", "", [
918
+ "pnpm devtools env <pull|push|audit|reset|example|init>",
919
+ "`--env` is now `--target`, for the same reason: one name, one meaning.",
920
+ ]);
921
+ process.exitCode = 1;
922
+ return null;
923
+ }
924
+ if (!isDoctorCommand(first)) {
925
+ log.error(`Unknown command: ${first}`);
926
+ // The top level only. The command is unknown, so there is no level below
927
+ // it to describe, and reprinting the whole tree here is what made the old
928
+ // help unreadable in the first place.
929
+ log.message(renderHelp());
930
+ process.exitCode = 1;
931
+ return null;
932
+ }
933
+ const instance = await connect();
934
+ if (!instance) {
935
+ process.exitCode = 1;
936
+ return null;
937
+ }
938
+ if (first === "catalog") {
939
+ await runCatalog(instance);
940
+ }
941
+ else if (first === "doctor") {
942
+ await runDoctor(instance, flagValue(rest, "--app"));
943
+ }
944
+ else if (first === "grant-root") {
945
+ await runGrantRoot(instance, flagValue(rest, "--user"));
946
+ }
947
+ else {
948
+ await runRoundTrip(instance);
949
+ }
950
+ return DONE;
951
+ }
952
+ // ── Entry ────────────────────────────────────────────────────────────────────
953
+ /**
954
+ * Runs the CLI against an already-resolved argv.
955
+ *
956
+ * Exported rather than run at import, so `src/launch.ts` can resolve the
957
+ * deploy tier and enter its environment BEFORE this module's imports (and the
958
+ * commands they pull in) ever see `process.env` — see that file's header. The
959
+ * shebang above stays harmless: importing this module runs nothing, only
960
+ * `main()` does, and nothing calls it but `launch.ts` and the `import.meta.url`
961
+ * guard at the bottom of this file, for a direct `tsx src/cli.ts` run.
962
+ */
963
+ export async function main(argv) {
964
+ // Bootstrapped here — after `argv` is parsed off `process.argv`, before any
965
+ // dispatch below (including `bw`'s passthrough) touches it — so the
966
+ // `command` tag on whatever this run reports is the same argv every branch
967
+ // below is about to act on. See `telemetry.ts`'s header for the no-op
968
+ // contract when no DSN is configured.
969
+ initDevtoolsTelemetry(argv[0] ?? "menu");
970
+ // ⚠️ BEFORE the `--help` check, unlike everything else here. `bw` is a
971
+ // passthrough, so `pnpm devtools bw --help` is a request for Bitwarden's
972
+ // help, not for ours. Answering it with our own would be this CLI talking
973
+ // over a tool it promised to get out of the way of.
974
+ if (argv[0] === "bw") {
975
+ await runBw(argv.slice(1));
976
+ return;
977
+ }
978
+ // `helpPath` so that `env pull --help` answers about `env pull` rather than
979
+ // reprinting the top level, which is the whole point of the split.
980
+ if (argv.includes("--help") || argv.includes("-h")) {
981
+ console.log(renderHelp(helpPath(argv)));
982
+ return;
983
+ }
984
+ // `completions --shell bash|zsh` is meant for `eval "$(pnpm devtools
985
+ // completions --shell bash)"`, which executes every line of stdout as a
986
+ // shell command. Dispatched before `intro()` so the banner — and the
987
+ // `outro()` below — never land on that stdout; a typed completions
988
+ // invocation is never part of an interactive wizard walk, so there is no
989
+ // banner worth keeping here the way there is for the menu.
990
+ if (argv[0] === "completions") {
991
+ const code = runCompletions(argv.slice(1));
992
+ process.exitCode = code;
993
+ return;
994
+ }
995
+ // Clean stdout, same reasoning as `completions` above: mostly useful for
996
+ // confirming which build actually ran, since a dlx self-refresh (see
997
+ // `repo/preflight.ts`) is otherwise invisible short of reading stderr's
998
+ // one-line nudge/refresh notice.
999
+ if (argv[0] === "version") {
1000
+ process.stdout.write(`${ownVersion()}\n`);
1001
+ return;
1002
+ }
1003
+ // Deploy has moved to the devtools-ci bin. Point any stray invocations at it
1004
+ // before `intro()`, because `devtools deploy secrets-file` could otherwise
1005
+ // fall through to the wizard banner on a stdout that is a credential channel.
1006
+ if (argv[0] === "deploy") {
1007
+ process.stderr.write("deploy has moved to devtools-ci.\n" +
1008
+ " Use: pnpm devtools-ci deploy <step|app> [flags]\n");
1009
+ process.exitCode = 1;
1010
+ return;
1011
+ }
1012
+ // A bare command group at a terminal resumes the wizard at that node, so
1013
+ // `devtools db` opens db's subcommand screen instead of printing "which of …?"
1014
+ // and exiting 1. Placed here — before `run`'s passthrough and before intro() —
1015
+ // so every group routes the same way (bare `run` resumes too, while
1016
+ // `run <task>` resolves to a leaf and falls through). Non-interactive callers
1017
+ // get startPath === null and keep the dispatcher's error + exit 1.
1018
+ const startPath = process.stdin.isTTY ? bareGroupStartPath(argv) : null;
1019
+ // Also before `intro()`, for the neighbouring reason: this one hands stdout
1020
+ // to pnpm, and through it to a Next dev server or a Flutter run that owns
1021
+ // the terminal until Ctrl-C. A banner above that output would be this CLI
1022
+ // announcing itself over somebody else's, and the `outro()` below would
1023
+ // print "Done." after a dev server was interrupted. `runTask` exits with
1024
+ // the child's own status and never comes back. `!startPath` excludes the one
1025
+ // case that is not this: a bare `run` at a terminal, which the block above
1026
+ // already resolved to its own subcommand screen rather than a task to run.
1027
+ if (!startPath && argv[0] === "run") {
1028
+ await runTask(argv.slice(1));
1029
+ return;
1030
+ }
1031
+ intro("DevDogs devtools");
1032
+ // The wizard builds an argv and hands it back to `dispatch`. See `menu.ts`.
1033
+ // `runMenu` begins its own recording from the built argv; a typed command
1034
+ // begins here, non-interactive until a runner resolves a flag from a prompt.
1035
+ let closing;
1036
+ if (argv.length === 0) {
1037
+ closing = await runMenu(dispatch);
1038
+ }
1039
+ else if (startPath) {
1040
+ // Same wizard entry as the no-argument path, at the resumed node instead
1041
+ // of the first screen. `env` left `undefined` so `runMenu` probes once,
1042
+ // identically to the bare-invocation branch above.
1043
+ closing = await runMenu(dispatch, undefined, { startPath });
1044
+ }
1045
+ else {
1046
+ beginInvocation(argv, false);
1047
+ // `launch.ts` already resolved and entered the session's deploy tier —
1048
+ // see its header — so `process.env.DEPLOY_ENV` names it here for every
1049
+ // path, typed or menu-built, rather than this module resolving a second
1050
+ // opinion.
1051
+ recordEnteredTier(process.env.DEPLOY_ENV ?? "development");
1052
+ closing = await dispatch(argv);
1053
+ }
1054
+ if (closing) {
1055
+ // Only prints when a prompt actually decided something — see
1056
+ // `reproducibleCommand`. Above the outro, so the takeaway is the last
1057
+ // thing on screen.
1058
+ const rerun = reproducibleCommand();
1059
+ if (rerun)
1060
+ note(rerun, "Run it directly next time");
1061
+ outro(closing);
1062
+ }
1063
+ }
1064
+ // Only for a direct `tsx src/cli.ts` run, bypassing `launch.ts` entirely —
1065
+ // no deploy tier resolved, no env entered. Not a path anything in this repo
1066
+ // takes any more (`launch.ts` always runs first, see its header), kept as a
1067
+ // fallback for the same reason `ci.ts`'s guard is.
1068
+ if (import.meta.url === `file://${process.argv[1]}`) {
1069
+ main(process.argv.slice(2)).catch(async (err) => {
1070
+ log.error(errorMessage(err));
1071
+ // Report BEFORE exiting — `process.exit` kills the event loop, taking any
1072
+ // in-flight request to Sentry's ingest endpoint with it. See
1073
+ // `captureDevtoolsError`'s header.
1074
+ await captureDevtoolsError(err);
1075
+ process.exit(1);
1076
+ });
1077
+ }