@devdogsuga/devtools 0.1.26 → 0.1.27

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 (271) hide show
  1. package/README.md +72 -11
  2. package/bin/devtools.mjs +4 -4
  3. package/dist/cli-CZKkNHK5.js +6619 -0
  4. package/dist/failure-log-BBPGejQv.js +256 -0
  5. package/dist/launch.js +267 -281
  6. package/dist/local-env-D8HC2JK_.js +351 -0
  7. package/dist/local-env-DrBCXqD8.js +2 -0
  8. package/dist/menu-CQBsQRgH.js +1684 -0
  9. package/dist/peer-redirect-hooks.js +15 -0
  10. package/dist/peers-RBplwovA.js +229 -0
  11. package/dist/root-C2OmZDwD.js +94 -0
  12. package/dist/stack-Bx-55oi_.js +2 -0
  13. package/dist/stack-BynCgRVS.js +181 -0
  14. package/dist/supabase-project-0knM0mqd.js +426 -0
  15. package/dist/telemetry-C4foD8MD.js +249 -0
  16. package/dist/telemetry.js +2 -148
  17. package/package.json +10 -29
  18. package/bin/devtools-ci-bare.mjs +0 -48
  19. package/bin/devtools-ci.mjs +0 -31
  20. package/dist/args.d.ts +0 -26
  21. package/dist/args.js +0 -78
  22. package/dist/bws/bw.d.ts +0 -18
  23. package/dist/bws/bw.js +0 -70
  24. package/dist/bws/client.d.ts +0 -43
  25. package/dist/bws/client.js +0 -363
  26. package/dist/bws/environments.d.ts +0 -96
  27. package/dist/bws/environments.js +0 -111
  28. package/dist/bws/pace.d.ts +0 -25
  29. package/dist/bws/pace.js +0 -43
  30. package/dist/bws/retry.d.ts +0 -12
  31. package/dist/bws/retry.js +0 -39
  32. package/dist/bws/store.d.ts +0 -1
  33. package/dist/bws/store.js +0 -32
  34. package/dist/bws/token.d.ts +0 -31
  35. package/dist/bws/token.js +0 -88
  36. package/dist/bws/vault.d.ts +0 -29
  37. package/dist/bws/vault.js +0 -200
  38. package/dist/catalog.d.ts +0 -57
  39. package/dist/catalog.js +0 -57
  40. package/dist/cf/build.d.ts +0 -13
  41. package/dist/cf/build.js +0 -65
  42. package/dist/cf/commands.d.ts +0 -1
  43. package/dist/cf/commands.js +0 -172
  44. package/dist/cf/local-env.d.ts +0 -58
  45. package/dist/cf/local-env.js +0 -177
  46. package/dist/ci.d.ts +0 -1
  47. package/dist/ci.js +0 -328
  48. package/dist/cli.d.ts +0 -11
  49. package/dist/cli.js +0 -1094
  50. package/dist/commands.d.ts +0 -261
  51. package/dist/commands.js +0 -1318
  52. package/dist/completions.d.ts +0 -4
  53. package/dist/completions.js +0 -110
  54. package/dist/cron/commands.d.ts +0 -47
  55. package/dist/cron/commands.js +0 -372
  56. package/dist/cron/discovery.d.ts +0 -55
  57. package/dist/cron/discovery.js +0 -85
  58. package/dist/cron/schema.d.ts +0 -45
  59. package/dist/cron/schema.js +0 -35
  60. package/dist/db/config-push.d.ts +0 -8
  61. package/dist/db/config-push.js +0 -11
  62. package/dist/db/connection.d.ts +0 -79
  63. package/dist/db/connection.js +0 -114
  64. package/dist/db/exec.d.ts +0 -1
  65. package/dist/db/exec.js +0 -11
  66. package/dist/db/generate-types.d.ts +0 -1
  67. package/dist/db/generate-types.js +0 -4
  68. package/dist/db/generated-env.d.ts +0 -69
  69. package/dist/db/generated-env.js +0 -113
  70. package/dist/db/introspect.d.ts +0 -1
  71. package/dist/db/introspect.js +0 -122
  72. package/dist/db/local-env.d.ts +0 -65
  73. package/dist/db/local-env.js +0 -23
  74. package/dist/db/new-migration.d.ts +0 -8
  75. package/dist/db/new-migration.js +0 -69
  76. package/dist/db/pooler.d.ts +0 -23
  77. package/dist/db/pooler.js +0 -55
  78. package/dist/db/run.d.ts +0 -68
  79. package/dist/db/run.js +0 -156
  80. package/dist/db/seed-buckets.d.ts +0 -2
  81. package/dist/db/seed-buckets.js +0 -4
  82. package/dist/db/seed-production.d.ts +0 -8
  83. package/dist/db/seed-production.js +0 -79
  84. package/dist/db/seed-roles.d.ts +0 -7
  85. package/dist/db/seed-roles.js +0 -33
  86. package/dist/db/session-refresh.d.ts +0 -43
  87. package/dist/db/session-refresh.js +0 -40
  88. package/dist/deploy/migrations.d.ts +0 -20
  89. package/dist/deploy/migrations.js +0 -68
  90. package/dist/deploy/orphans.d.ts +0 -20
  91. package/dist/deploy/orphans.js +0 -177
  92. package/dist/deploy/preflight.d.ts +0 -53
  93. package/dist/deploy/preflight.js +0 -189
  94. package/dist/deploy/report.d.ts +0 -26
  95. package/dist/deploy/report.js +0 -70
  96. package/dist/deploy/require-planner.d.ts +0 -2
  97. package/dist/deploy/require-planner.js +0 -60
  98. package/dist/deploy/require-token.d.ts +0 -15
  99. package/dist/deploy/require-token.js +0 -78
  100. package/dist/deploy/secrets-file.d.ts +0 -17
  101. package/dist/deploy/secrets-file.js +0 -166
  102. package/dist/deploy/write-env.d.ts +0 -40
  103. package/dist/deploy/write-env.js +0 -395
  104. package/dist/docs/index-pages.d.ts +0 -38
  105. package/dist/docs/index-pages.js +0 -186
  106. package/dist/emails/commands.d.ts +0 -15
  107. package/dist/emails/commands.js +0 -130
  108. package/dist/emails/email-types.d.ts +0 -13
  109. package/dist/emails/email-types.js +0 -1
  110. package/dist/emails/fixtures.d.ts +0 -20
  111. package/dist/emails/fixtures.js +0 -20
  112. package/dist/env/audit.d.ts +0 -206
  113. package/dist/env/audit.js +0 -436
  114. package/dist/env/cloudflare.d.ts +0 -21
  115. package/dist/env/cloudflare.js +0 -61
  116. package/dist/env/commands.d.ts +0 -99
  117. package/dist/env/commands.js +0 -613
  118. package/dist/env/discovery.d.ts +0 -15
  119. package/dist/env/discovery.js +0 -239
  120. package/dist/env/document.d.ts +0 -119
  121. package/dist/env/document.js +0 -323
  122. package/dist/env/example.d.ts +0 -68
  123. package/dist/env/example.js +0 -605
  124. package/dist/env/selection.d.ts +0 -105
  125. package/dist/env/selection.js +0 -228
  126. package/dist/env-entry.d.ts +0 -49
  127. package/dist/env-entry.js +0 -204
  128. package/dist/environment-doctor.d.ts +0 -45
  129. package/dist/environment-doctor.js +0 -429
  130. package/dist/environment.d.ts +0 -92
  131. package/dist/environment.js +0 -185
  132. package/dist/fingerprint.d.ts +0 -10
  133. package/dist/fingerprint.js +0 -18
  134. package/dist/gen/campus-map.d.ts +0 -1
  135. package/dist/gen/campus-map.js +0 -473
  136. package/dist/gen/commands.d.ts +0 -1
  137. package/dist/gen/commands.js +0 -25
  138. package/dist/gen/hypno.d.ts +0 -1
  139. package/dist/gen/hypno.js +0 -134
  140. package/dist/gh/client.d.ts +0 -80
  141. package/dist/gh/client.js +0 -259
  142. package/dist/gh/environments.d.ts +0 -83
  143. package/dist/gh/environments.js +0 -178
  144. package/dist/gh/rulesets/actors.d.ts +0 -14
  145. package/dist/gh/rulesets/actors.js +0 -69
  146. package/dist/gh/rulesets/api.d.ts +0 -15
  147. package/dist/gh/rulesets/api.js +0 -104
  148. package/dist/gh/rulesets/commands.d.ts +0 -3
  149. package/dist/gh/rulesets/commands.js +0 -203
  150. package/dist/gh/rulesets/desired.d.ts +0 -112
  151. package/dist/gh/rulesets/desired.js +0 -271
  152. package/dist/gh/rulesets/diff.d.ts +0 -65
  153. package/dist/gh/rulesets/diff.js +0 -170
  154. package/dist/gh/rulesets/types.d.ts +0 -76
  155. package/dist/gh/rulesets/types.js +0 -13
  156. package/dist/gh/settings/api.d.ts +0 -40
  157. package/dist/gh/settings/api.js +0 -180
  158. package/dist/gh/settings/commands.d.ts +0 -1
  159. package/dist/gh/settings/commands.js +0 -254
  160. package/dist/gh/settings/desired.d.ts +0 -53
  161. package/dist/gh/settings/desired.js +0 -43
  162. package/dist/gh/settings/diff.d.ts +0 -57
  163. package/dist/gh/settings/diff.js +0 -161
  164. package/dist/gh/settings/types.d.ts +0 -92
  165. package/dist/gh/settings/types.js +0 -11
  166. package/dist/gh/settings/workflows.d.ts +0 -41
  167. package/dist/gh/settings/workflows.js +0 -128
  168. package/dist/grantRoot.d.ts +0 -42
  169. package/dist/grantRoot.js +0 -89
  170. package/dist/help.d.ts +0 -18
  171. package/dist/help.js +0 -169
  172. package/dist/images/commands.d.ts +0 -32
  173. package/dist/images/commands.js +0 -231
  174. package/dist/images/events.d.ts +0 -47
  175. package/dist/images/events.js +0 -116
  176. package/dist/images/graphics.d.ts +0 -70
  177. package/dist/images/graphics.js +0 -234
  178. package/dist/images/open-graph-types.d.ts +0 -75
  179. package/dist/images/open-graph-types.js +0 -1
  180. package/dist/images/prompts.d.ts +0 -34
  181. package/dist/images/prompts.js +0 -100
  182. package/dist/images/render.d.ts +0 -21
  183. package/dist/images/render.js +0 -61
  184. package/dist/images/select.d.ts +0 -64
  185. package/dist/images/select.js +0 -96
  186. package/dist/instance.d.ts +0 -69
  187. package/dist/instance.js +0 -102
  188. package/dist/invocation.d.ts +0 -49
  189. package/dist/invocation.js +0 -78
  190. package/dist/launch-ci.d.ts +0 -1
  191. package/dist/launch-ci.js +0 -90
  192. package/dist/launch.d.ts +0 -18
  193. package/dist/menu.d.ts +0 -43
  194. package/dist/menu.js +0 -300
  195. package/dist/moderation.d.ts +0 -70
  196. package/dist/moderation.js +0 -128
  197. package/dist/oauth/browser.d.ts +0 -1
  198. package/dist/oauth/browser.js +0 -13
  199. package/dist/oauth/config.d.ts +0 -24
  200. package/dist/oauth/config.js +0 -24
  201. package/dist/oauth/connect-url.d.ts +0 -17
  202. package/dist/oauth/connect-url.js +0 -23
  203. package/dist/oauth/db.d.ts +0 -37
  204. package/dist/oauth/db.js +0 -108
  205. package/dist/oauth/device.d.ts +0 -93
  206. package/dist/oauth/device.js +0 -198
  207. package/dist/oauth/discovery.d.ts +0 -28
  208. package/dist/oauth/discovery.js +0 -54
  209. package/dist/oauth/env-file.d.ts +0 -6
  210. package/dist/oauth/env-file.js +0 -35
  211. package/dist/oauth/exchange.d.ts +0 -43
  212. package/dist/oauth/exchange.js +0 -93
  213. package/dist/oauth/label.d.ts +0 -1
  214. package/dist/oauth/label.js +0 -25
  215. package/dist/oauth/loopback.d.ts +0 -52
  216. package/dist/oauth/loopback.js +0 -168
  217. package/dist/oauth/pkce.d.ts +0 -6
  218. package/dist/oauth/pkce.js +0 -34
  219. package/dist/oauth/target.d.ts +0 -54
  220. package/dist/oauth/target.js +0 -102
  221. package/dist/oauth/transport.d.ts +0 -41
  222. package/dist/oauth/transport.js +0 -32
  223. package/dist/oauth/wizard.d.ts +0 -40
  224. package/dist/oauth/wizard.js +0 -578
  225. package/dist/persona.d.ts +0 -18
  226. package/dist/persona.js +0 -221
  227. package/dist/pick.d.ts +0 -6
  228. package/dist/pick.js +0 -64
  229. package/dist/pipes.d.ts +0 -1
  230. package/dist/pipes.js +0 -25
  231. package/dist/planner/checks.d.ts +0 -26
  232. package/dist/planner/checks.js +0 -104
  233. package/dist/planner/commands.d.ts +0 -10
  234. package/dist/planner/commands.js +0 -367
  235. package/dist/planner/db.d.ts +0 -15
  236. package/dist/planner/db.js +0 -33
  237. package/dist/planner/role.d.ts +0 -33
  238. package/dist/planner/role.js +0 -68
  239. package/dist/repo/peer-redirect-hooks.d.ts +0 -16
  240. package/dist/repo/peer-redirect-hooks.js +0 -10
  241. package/dist/repo/peer-redirect.d.ts +0 -4
  242. package/dist/repo/peer-redirect.js +0 -26
  243. package/dist/repo/peers.d.ts +0 -39
  244. package/dist/repo/peers.js +0 -139
  245. package/dist/repo/resolve.d.ts +0 -41
  246. package/dist/repo/resolve.js +0 -152
  247. package/dist/repo/root.d.ts +0 -23
  248. package/dist/repo/root.js +0 -91
  249. package/dist/repo/source.d.ts +0 -14
  250. package/dist/repo/source.js +0 -54
  251. package/dist/repo/supabase-project.d.ts +0 -60
  252. package/dist/repo/supabase-project.js +0 -124
  253. package/dist/repo/tsx-loader.d.ts +0 -6
  254. package/dist/repo/tsx-loader.js +0 -41
  255. package/dist/run/pick.d.ts +0 -90
  256. package/dist/run/pick.js +0 -664
  257. package/dist/setup.d.ts +0 -1
  258. package/dist/setup.js +0 -280
  259. package/dist/stack.d.ts +0 -48
  260. package/dist/stack.js +0 -403
  261. package/dist/telemetry.d.ts +0 -59
  262. package/dist/tier.d.ts +0 -40
  263. package/dist/tier.js +0 -96
  264. package/dist/ui.d.ts +0 -40
  265. package/dist/ui.js +0 -71
  266. package/dist/version.d.ts +0 -7
  267. package/dist/version.js +0 -17
  268. package/dist/workers.d.ts +0 -5
  269. package/dist/workers.js +0 -33
  270. package/dist/workflows/commands.d.ts +0 -54
  271. package/dist/workflows/commands.js +0 -763
@@ -1,367 +0,0 @@
1
- /**
2
- * `pnpm devtools db planner <status|create|reset-password|drop>`, the operator
3
- * side of the `migration_planner` role.
4
- *
5
- * Four commands because the role has exactly four lifecycle moments:
6
- *
7
- * status does it exist, can it log in, does it hold the two
8
- * validated grants and nothing that reaches further
9
- * create mint it: role + grants + a generated password, verified
10
- * live, written into .env.preflight
11
- * reset-password new password on the existing role, same verification,
12
- * same write
13
- * drop remove it: the recovery path for a role in a shape
14
- * create refuses to repair, and the retirement path if
15
- * the tier is ever redesigned
16
- *
17
- * There is no `retrieve`. A Postgres password is not retrievable, the server
18
- * holds a hash, and the composed DB_URL's home is `.env.preflight`, synced
19
- * outward by `env push --target preflight` and back by `env pull`. Losing the
20
- * local copy is what `pull` is for; losing every copy is what
21
- * `reset-password` is for. Printing it here would put a live credential in a
22
- * terminal scrollback for no state those two do not already cover.
23
- *
24
- * ## Where the admin connection comes from
25
- *
26
- * Creating a role needs a privileged connection to the PRODUCTION database.
27
- * The planner exists so `main` can ask "what would these migrations do to
28
- * production". That connection is read from `.env.production`'s `DB_URL`
29
- * (fetch it with `env pull --target production`), or passed as `--db-url` for
30
- * the bootstrap case where that file is not populated yet. Both paths write to
31
- * production, so both confirm first: `production` is a guarded target, and a
32
- * role created by reflex is a credential nobody meant to mint.
33
- *
34
- * ## Verified live
35
- *
36
- * `create` and `reset-password` end by opening a SECOND connection as the
37
- * planner and running the same three checks CI's `deploy require-planner`
38
- * runs. "The role works and cannot overreach" is observed before the command
39
- * says done: the property the security plan records as "validated working",
40
- * re-established on every mint instead of remembered from one.
41
- */
42
- import { readFile, writeFile } from "node:fs/promises";
43
- import { resolve } from "node:path";
44
- import { confirm, log, note } from "@clack/prompts";
45
- import { fingerprint } from "../fingerprint.js";
46
- import { findRepoRoot } from "../repo/root.js";
47
- import { loadEnv } from "../repo/peers.js";
48
- import { bail, unwrap } from "../ui.js";
49
- import { EnvDocument } from "../env/document.js";
50
- import { checkPlanner } from "./checks.js";
51
- import { connectDb } from "./db.js";
52
- import { PLANNER_ROLE, createRoleSql, generatePassword, plannerUrlFrom, resetPasswordSql, } from "./role.js";
53
- /** Constants, like `checks.ts`: `PlannerDb.run` never sees a composed string. */
54
- const QUERY_ROLE = `select rolcanlogin from pg_roles where rolname = '${PLANNER_ROLE}'`;
55
- /**
56
- * The schema the grants attach to. The Supabase CLI creates it the first time
57
- * it records migration history on a database, so on a database that has never
58
- * been CLI-migrated, or baselined with `supabase migration repair`, there is
59
- * nothing to grant on. `create` has to say that BEFORE issuing CREATE ROLE, or
60
- * it strands a grant-less role behind a refusal to re-run.
61
- */
62
- const QUERY_SCHEMA = "select 1 from pg_namespace where nspname = 'supabase_migrations'";
63
- const QUERY_GRANTS = `select has_schema_privilege('${PLANNER_ROLE}', 'supabase_migrations', 'usage') as schema_usage, ` +
64
- `has_table_privilege('${PLANNER_ROLE}', 'supabase_migrations.schema_migrations', 'select') as table_select`;
65
- /**
66
- * Membership is the quiet way a "narrow" role widens: `grant postgres to
67
- * migration_planner` leaves both privilege queries above looking correct.
68
- */
69
- const QUERY_MEMBERSHIPS = "select r.rolname from pg_auth_members m " +
70
- "join pg_roles r on r.oid = m.roleid " +
71
- "join pg_roles g on g.oid = m.member " +
72
- `where g.rolname = '${PLANNER_ROLE}' order by r.rolname`;
73
- /**
74
- * The schemas a dry run has no business seeing. `public` is absent on
75
- * purpose: PUBLIC-role defaults give every login usage there, so listing it
76
- * would make this red on a correctly minted role.
77
- */
78
- const QUERY_OVERREACH = "select nspname from pg_namespace " +
79
- "where nspname in ('platform', 'schedule_builder', 'study_group_finder', 'auth', 'storage') " +
80
- `and has_schema_privilege('${PLANNER_ROLE}', nspname, 'usage') order by nspname`;
81
- async function preflightPath() {
82
- const { fileFor } = await loadEnv();
83
- return resolve(findRepoRoot(), fileFor("preflight"));
84
- }
85
- /**
86
- * The admin connection string: `--db-url`, else `.env.production`'s `DB_URL`.
87
- *
88
- * Read from the FILE rather than `process.env` so that running this under
89
- * `with-env` (which loads the development `.env`) cannot silently point a
90
- * CREATE ROLE at the shared dev database. The wrong database succeeding is
91
- * worse than the right one refusing.
92
- */
93
- async function adminUrl(options) {
94
- if (options.dbUrl)
95
- return options.dbUrl;
96
- const path = resolve(findRepoRoot(), (await loadEnv()).fileFor("production"));
97
- let doc;
98
- try {
99
- doc = EnvDocument.parse(await readFile(path, "utf8"));
100
- }
101
- catch {
102
- doc = EnvDocument.empty();
103
- }
104
- const url = doc.get("DB_URL");
105
- if (!url) {
106
- bail("No DB_URL in .env.production. The planner lives on the production " +
107
- "database, so this needs its full connection string: run " +
108
- "`pnpm devtools env pull --target production`, or pass --db-url.");
109
- }
110
- return url;
111
- }
112
- async function withDb(connect, url, body) {
113
- const db = connect(url);
114
- try {
115
- return await body(db);
116
- }
117
- finally {
118
- await db.end();
119
- }
120
- }
121
- async function roleExists(db) {
122
- return (await db.run(QUERY_ROLE)).length > 0;
123
- }
124
- async function migrationSchemaExists(db) {
125
- return (await db.run(QUERY_SCHEMA)).length > 0;
126
- }
127
- /** Postgres 42501 (insufficient_privilege), with a text fallback. */
128
- function isPermissionDenied(error) {
129
- const e = error;
130
- return e.code === "42501" || /permission denied/i.test(e.message ?? "");
131
- }
132
- function errorText(error) {
133
- return error instanceof Error ? error.message : String(error);
134
- }
135
- function bailMissingSchema() {
136
- bail("The supabase_migrations schema does not exist on this database, so " +
137
- "there is nothing to grant on — and nothing for the §3.5 dry run to " +
138
- "plan against. The Supabase CLI creates it the first time it records " +
139
- "migration history here: `supabase db push` on a fresh database, or " +
140
- "`supabase migration repair` to baseline one that already has its " +
141
- "objects. Initialize the history first, then re-run this command.");
142
- }
143
- export async function runPlannerStatus(options = {}) {
144
- const connect = options.connect ?? connectDb;
145
- await withDb(connect, await adminUrl(options), async (db) => {
146
- // First, because it recolors everything after it: on a database with no
147
- // migration history, the story is the missing history, not the grants.
148
- const schemaReady = await migrationSchemaExists(db);
149
- const [role] = await db.run(QUERY_ROLE);
150
- if (!role) {
151
- log.warn(schemaReady
152
- ? `${PLANNER_ROLE} does not exist. Mint it with \`pnpm devtools db planner create\`.`
153
- : `${PLANNER_ROLE} does not exist — and neither does the ` +
154
- "supabase_migrations schema, so `planner create` would refuse: " +
155
- "initialize this database's migration history first " +
156
- "(`supabase db push`, or `supabase migration repair` to " +
157
- "baseline an existing database).");
158
- process.exitCode = 1;
159
- return;
160
- }
161
- const lines = [
162
- `exists, ${role.rolcanlogin ? "can log in" : "⚠️ CANNOT log in (nologin)"}`,
163
- ];
164
- if (!schemaReady) {
165
- lines.push("⚠️ the supabase_migrations schema DOES NOT EXIST on this database " +
166
- "— the grants below cannot hold and the dry run cannot plan. " +
167
- "Initialize the migration history, then `planner reset-password` " +
168
- "will re-verify (or `planner drop` and re-run `planner create`).");
169
- }
170
- // Skipped when the schema is absent: has_schema_privilege raises on a
171
- // name that does not exist, and the schema line above already carries
172
- // the diagnosis.
173
- if (schemaReady) {
174
- const [grants] = await db.run(QUERY_GRANTS);
175
- lines.push(grants?.schema_usage
176
- ? "has usage on supabase_migrations"
177
- : "⚠️ MISSING usage on supabase_migrations", grants?.table_select
178
- ? "has select on supabase_migrations.schema_migrations"
179
- : "⚠️ MISSING select on supabase_migrations.schema_migrations");
180
- }
181
- const memberships = await db.run(QUERY_MEMBERSHIPS);
182
- lines.push(memberships.length === 0
183
- ? "member of no other role"
184
- : `⚠️ MEMBER OF: ${memberships.map((m) => String(m.rolname)).join(", ")} — inherited privileges reach past the validated pair`);
185
- const overreach = await db.run(QUERY_OVERREACH);
186
- lines.push(overreach.length === 0
187
- ? "no usage on any application schema (platform, schedule_builder, study_group_finder, auth, storage)"
188
- : `⚠️ CAN REACH: ${overreach.map((s) => String(s.nspname)).join(", ")} — \`planner drop\`, then \`planner create\``);
189
- const healthy = !lines.some((l) => l.includes("⚠️"));
190
- note(lines.map((l) => `• ${l}`).join("\n"), PLANNER_ROLE);
191
- if (!healthy) {
192
- log.error("The role does not match the validated shape. `planner create` " +
193
- "refuses to touch an existing role — `planner drop` and re-run " +
194
- "create, or repair the grants by hand (planner/role.ts holds the " +
195
- "pair).");
196
- process.exitCode = 1;
197
- }
198
- });
199
- }
200
- /** Shared tail of create and reset-password: verify live, write the file. */
201
- async function verifyAndStore(connect, adminDbUrl, password) {
202
- const { fileFor } = await loadEnv();
203
- const url = plannerUrlFrom(adminDbUrl, password);
204
- // The same three checks `deploy require-planner` runs in CI. Observed here,
205
- // at minting time, so the first workflow run is a repeat rather than a
206
- // premiere.
207
- const verdict = await withDb(connect, url, (db) => checkPlanner(db));
208
- for (const line of verdict.lines)
209
- log.info(line);
210
- if (!verdict.ok) {
211
- bail(`The freshly minted credential failed verification: ${verdict.problem}`);
212
- }
213
- const path = await preflightPath();
214
- let doc;
215
- try {
216
- doc = EnvDocument.parse(await readFile(path, "utf8"));
217
- }
218
- catch {
219
- doc = EnvDocument.empty();
220
- }
221
- doc.set("DB_URL", url);
222
- await writeFile(path, doc.toString());
223
- log.success(`Wrote DB_URL (${fingerprint(url)}) to ${fileFor("preflight")}. ` +
224
- "Next: `pnpm devtools env push --target preflight`.");
225
- }
226
- export async function runPlannerCreate(options = {}) {
227
- const connect = options.connect ?? connectDb;
228
- const admin = await adminUrl(options);
229
- const go = unwrap(await confirm({
230
- message: `Create role ${PLANNER_ROLE} on the PRODUCTION database, with its ` +
231
- "two grants and a generated password?",
232
- initialValue: false,
233
- }));
234
- if (!go)
235
- bail();
236
- await withDb(connect, admin, async (db) => {
237
- // Before CREATE ROLE, so a database with no migration history refuses
238
- // cleanly instead of stranding a grant-less role behind the
239
- // already-exists refusal below. That happened the first time this ran
240
- // against a database the CLI had never migrated.
241
- if (!(await migrationSchemaExists(db)))
242
- bailMissingSchema();
243
- if (await roleExists(db)) {
244
- // Refused rather than repaired: an existing role may hold grants this
245
- // command did not give it, and layering the validated pair on top would
246
- // report success over an unvalidated whole. `status` shows the shape;
247
- // `reset-password` rotates the credential without touching grants.
248
- bail(`${PLANNER_ROLE} already exists. Run \`planner status\` to check its ` +
249
- "shape, `planner reset-password` to rotate it — or, for a " +
250
- "from-scratch mint, `planner drop` and re-run create.");
251
- }
252
- const password = generatePassword();
253
- // One transaction: the role and its grants exist together or not at all.
254
- // A mid-flight failure used to leave the role behind with no grants,
255
- // which the already-exists refusal then protected like a real one.
256
- await db.run("begin");
257
- try {
258
- for (const statement of createRoleSql(password)) {
259
- await db.run(statement);
260
- }
261
- await db.run("commit");
262
- }
263
- catch (error) {
264
- await db.run("rollback").catch(() => undefined);
265
- throw error;
266
- }
267
- log.success(`Created ${PLANNER_ROLE} with the two validated grants.`);
268
- await verifyAndStore(connect, admin, password);
269
- });
270
- }
271
- export async function runPlannerDrop(options = {}) {
272
- const { fileFor } = await loadEnv();
273
- const connect = options.connect ?? connectDb;
274
- const admin = await adminUrl(options);
275
- const go = unwrap(await confirm({
276
- message: `Drop role ${PLANNER_ROLE} from the PRODUCTION database? The ` +
277
- "preflight credential stops working the moment this runs.",
278
- initialValue: false,
279
- }));
280
- if (!go)
281
- bail();
282
- await withDb(connect, admin, async (db) => {
283
- if (!(await roleExists(db))) {
284
- bail(`${PLANNER_ROLE} does not exist — nothing to drop.`);
285
- }
286
- // Three statements, one transaction, and the order is load-bearing twice:
287
- //
288
- // * DROP OWNED before DROP ROLE. DROP ROLE alone fails on a role that
289
- // still holds grants ("some objects depend on it"), and a role being
290
- // dropped for the WRONG shape is exactly the one whose grants this
291
- // command cannot enumerate: stranded grants, or an overreaching grant
292
- // somebody added by hand. The role owns no objects by design, so DROP
293
- // OWNED only revokes; if somebody ever made it own something,
294
- // dropping that too is what "drop" has to mean for a from-scratch
295
- // re-mint.
296
- // * GRANT before DROP OWNED. Supabase's `postgres` is CREATEROLE, not
297
- // superuser, and DROP OWNED requires MEMBERSHIP in the role. Having
298
- // created it, which confers ADMIN on the role and the right to drop
299
- // it, is not enough: the first real run failed here with "permission
300
- // denied". Granting the role to ourselves is what a non-superuser
301
- // admin has to do, it is idempotent (a re-grant is a NOTICE), and the
302
- // membership vanishes with the role two statements later, or with the
303
- // rollback.
304
- await db.run("begin");
305
- try {
306
- await db.run(`grant ${PLANNER_ROLE} to current_user`);
307
- await db.run(`drop owned by ${PLANNER_ROLE}`);
308
- await db.run(`drop role ${PLANNER_ROLE}`);
309
- await db.run("commit");
310
- }
311
- catch (error) {
312
- await db.run("rollback").catch(() => undefined);
313
- if (isPermissionDenied(error)) {
314
- bail(`Postgres refused: ${errorText(error)}. Dropping ${PLANNER_ROLE} ` +
315
- "needs a connection that can administer it — on Supabase that " +
316
- "is the `postgres` user's own connection string (what " +
317
- "`.env.production`'s DB_URL should hold), not a lesser role. " +
318
- "If postgres itself is refused, the role was created by " +
319
- "somebody else's admin role; drop it from the dashboard's SQL " +
320
- "editor, which runs as postgres.");
321
- }
322
- throw error;
323
- }
324
- log.success(`Dropped ${PLANNER_ROLE}.`);
325
- });
326
- // The stored credential died with the role, and a dead value left in place
327
- // would push cleanly and audit green. Blanked rather than deleted, because a
328
- // blank line is the file's "fill me in" convention. Only when the value is
329
- // actually the planner's: a hand-set URL under this key is somebody's
330
- // deliberate state.
331
- const path = await preflightPath();
332
- let doc;
333
- try {
334
- doc = EnvDocument.parse(await readFile(path, "utf8"));
335
- }
336
- catch {
337
- return;
338
- }
339
- const stored = doc.get("DB_URL");
340
- if (stored?.includes(PLANNER_ROLE)) {
341
- doc.set("DB_URL", "");
342
- await writeFile(path, doc.toString());
343
- log.info(`Blanked the dead DB_URL in ${fileFor("preflight")}. After the next ` +
344
- "`planner create`, push it with `env push --target preflight`.");
345
- }
346
- }
347
- export async function runPlannerResetPassword(options = {}) {
348
- const connect = options.connect ?? connectDb;
349
- const admin = await adminUrl(options);
350
- const go = unwrap(await confirm({
351
- message: `Reset ${PLANNER_ROLE}'s password on the PRODUCTION database? The ` +
352
- "old connection string stops working the moment this runs.",
353
- initialValue: false,
354
- }));
355
- if (!go)
356
- bail();
357
- await withDb(connect, admin, async (db) => {
358
- if (!(await roleExists(db))) {
359
- bail(`${PLANNER_ROLE} does not exist — nothing to reset. Mint it with ` +
360
- "`pnpm devtools db planner create`.");
361
- }
362
- const password = generatePassword();
363
- await db.run(resetPasswordSql(password));
364
- log.success(`Reset ${PLANNER_ROLE}'s password.`);
365
- await verifyAndStore(connect, admin, password);
366
- });
367
- }
@@ -1,15 +0,0 @@
1
- export interface PlannerDb {
2
- run(query: string): Promise<Record<string, unknown>[]>;
3
- end(): Promise<void>;
4
- }
5
- /** How a caller opens one; injectable so tests never touch the network. */
6
- export type Connect = (url: string) => PlannerDb;
7
- /**
8
- * Opens the single connection the planner commands run through.
9
- *
10
- * `max: 1` because every use here is a handful of sequential statements, and
11
- * `prepare: false` because the session pooler tolerates prepared statements
12
- * but gains nothing from them for one-shot queries. The timeout keeps a
13
- * wrong-host URL from hanging a CI job for its full step timeout.
14
- */
15
- export declare function connectDb(url: string): PlannerDb;
@@ -1,33 +0,0 @@
1
- /**
2
- * The one seam between the planner commands and a real Postgres.
3
- *
4
- * Deliberately tiny: a runner of constant SQL strings and an `end()`. Every
5
- * query in this feature is a string literal. Nothing user-shaped is ever
6
- * interpolated except the generated password, whose alphabet `role.ts`
7
- * constrains. That lets the interface stay `run(text)`, and lets tests hand in
8
- * a map from query to rows instead of mocking a client library.
9
- */
10
- import postgres from "postgres";
11
- /**
12
- * Opens the single connection the planner commands run through.
13
- *
14
- * `max: 1` because every use here is a handful of sequential statements, and
15
- * `prepare: false` because the session pooler tolerates prepared statements
16
- * but gains nothing from them for one-shot queries. The timeout keeps a
17
- * wrong-host URL from hanging a CI job for its full step timeout.
18
- */
19
- export function connectDb(url) {
20
- const sql = postgres(url, {
21
- max: 1,
22
- prepare: false,
23
- connect_timeout: 15,
24
- });
25
- return {
26
- async run(query) {
27
- return await sql.unsafe(query);
28
- },
29
- async end() {
30
- await sql.end({ timeout: 5 });
31
- },
32
- };
33
- }
@@ -1,33 +0,0 @@
1
- export declare const PLANNER_ROLE = "migration_planner";
2
- /**
3
- * The statements `planner create` runs, in order. Exported so the test can
4
- * assert the grants byte-for-byte against the security plan's validated pair.
5
- * A paraphrase here would be a second copy that can drift.
6
- *
7
- * The password is interpolated rather than bound because Postgres accepts no
8
- * bind parameters in a utility statement like CREATE ROLE. That is safe here
9
- * ONLY because `generatePassword()` emits the base64url alphabet: no quote,
10
- * no backslash, nothing needing escape. Do not widen the alphabet without
11
- * revisiting this.
12
- */
13
- export declare function createRoleSql(password: string): string[];
14
- /** ALTER ROLE, same interpolation contract as `createRoleSql`. */
15
- export declare function resetPasswordSql(password: string): string;
16
- /**
17
- * 192 bits, base64url. The alphabet is load-bearing twice over: it needs no
18
- * escaping inside the SQL literal above, and no percent-encoding inside the
19
- * userinfo of the connection URL the password ends up in.
20
- */
21
- export declare function generatePassword(): string;
22
- /**
23
- * The preflight `DB_URL`, derived from the admin one.
24
- *
25
- * Everything but the identity is kept, including host, port, and database,
26
- * because the planner connects to the same place as everyone else. Only the userinfo
27
- * changes, and the username's shape depends on the route in a way worth
28
- * spelling out: through Supabase's session pooler the username is
29
- * `<role>.<project-ref>`, so the ref (everything after the first dot of the
30
- * admin username) survives the swap; on a direct connection the username is
31
- * the bare role.
32
- */
33
- export declare function plannerUrlFrom(adminUrl: string, password: string): string;
@@ -1,68 +0,0 @@
1
- /**
2
- * The `migration_planner` role: the one Postgres credential `main` may hold.
3
- *
4
- * §3.5's stage-1 dry run answers "what would these migrations do to
5
- * production" from `main`, so whatever it authenticates with is reachable
6
- * from the `main` trust tier. The plan refuses even a general read-only role
7
- * there, because that puts every row of production data behind that boundary.
8
- * What is left is a role that can read the migrations-history table and
9
- * nothing else. Two grants are the whole of it:
10
- *
11
- * grant usage on schema supabase_migrations to migration_planner;
12
- * grant select on supabase_migrations.schema_migrations to migration_planner;
13
- *
14
- * This module is the pure half: names, SQL text, password generation, URL
15
- * derivation. Everything that talks to a database lives beside it.
16
- */
17
- import { randomBytes } from "node:crypto";
18
- export const PLANNER_ROLE = "migration_planner";
19
- /**
20
- * The statements `planner create` runs, in order. Exported so the test can
21
- * assert the grants byte-for-byte against the security plan's validated pair.
22
- * A paraphrase here would be a second copy that can drift.
23
- *
24
- * The password is interpolated rather than bound because Postgres accepts no
25
- * bind parameters in a utility statement like CREATE ROLE. That is safe here
26
- * ONLY because `generatePassword()` emits the base64url alphabet: no quote,
27
- * no backslash, nothing needing escape. Do not widen the alphabet without
28
- * revisiting this.
29
- */
30
- export function createRoleSql(password) {
31
- return [
32
- `create role ${PLANNER_ROLE} login password '${password}'`,
33
- `grant usage on schema supabase_migrations to ${PLANNER_ROLE}`,
34
- `grant select on supabase_migrations.schema_migrations to ${PLANNER_ROLE}`,
35
- ];
36
- }
37
- /** ALTER ROLE, same interpolation contract as `createRoleSql`. */
38
- export function resetPasswordSql(password) {
39
- return `alter role ${PLANNER_ROLE} password '${password}'`;
40
- }
41
- /**
42
- * 192 bits, base64url. The alphabet is load-bearing twice over: it needs no
43
- * escaping inside the SQL literal above, and no percent-encoding inside the
44
- * userinfo of the connection URL the password ends up in.
45
- */
46
- export function generatePassword() {
47
- return randomBytes(24).toString("base64url");
48
- }
49
- /**
50
- * The preflight `DB_URL`, derived from the admin one.
51
- *
52
- * Everything but the identity is kept, including host, port, and database,
53
- * because the planner connects to the same place as everyone else. Only the userinfo
54
- * changes, and the username's shape depends on the route in a way worth
55
- * spelling out: through Supabase's session pooler the username is
56
- * `<role>.<project-ref>`, so the ref (everything after the first dot of the
57
- * admin username) survives the swap; on a direct connection the username is
58
- * the bare role.
59
- */
60
- export function plannerUrlFrom(adminUrl, password) {
61
- const url = new URL(adminUrl);
62
- const ref = url.username.includes(".")
63
- ? url.username.slice(url.username.indexOf("."))
64
- : "";
65
- url.username = `${PLANNER_ROLE}${ref}`;
66
- url.password = password;
67
- return url.toString();
68
- }
@@ -1,16 +0,0 @@
1
- /**
2
- * Module-loader hooks for `repo/peer-redirect.ts`. Runs on Node's loader
3
- * thread, not the main one, so nothing here can import devtools state: the
4
- * redirects arrive once, as `initialize()`'s data, and are never updated.
5
- */
6
- import type { InitializeHook, ResolveHook } from "node:module";
7
- export interface PeerRedirect {
8
- /** The exact file URL of the importing module the redirect applies to. */
9
- parentURL: string;
10
- /** The bare specifier it imports, e.g. `@devdogsuga/env`. */
11
- specifier: string;
12
- /** Where that specifier should resolve instead. */
13
- url: string;
14
- }
15
- export declare const initialize: InitializeHook<PeerRedirect[]>;
16
- export declare const resolve: ResolveHook;
@@ -1,10 +0,0 @@
1
- let redirects = [];
2
- export const initialize = (data) => {
3
- redirects = data;
4
- };
5
- export const resolve = (specifier, context, nextResolve) => {
6
- const redirect = redirects.find((r) => r.specifier === specifier && r.parentURL === context.parentURL);
7
- if (redirect)
8
- return { url: redirect.url, shortCircuit: true };
9
- return nextResolve(specifier, context);
10
- };
@@ -1,4 +0,0 @@
1
- import type { PeerRedirect } from "./peer-redirect-hooks.js";
2
- /** Registers `redirect`. Hooks cannot be unregistered, so each call adds a
3
- * loader for the rest of the process; call it once per redirect. */
4
- export declare function redirectPeer(redirect: PeerRedirect): void;
@@ -1,26 +0,0 @@
1
- /**
2
- * Points one module's bare import of a peer at the copy the target repo
3
- * resolved (`repo/peers.ts`).
4
- *
5
- * devtools' own `env.ts` manifest imports `@devdogsuga/env` by bare
6
- * specifier, and from an installed copy that specifier resolves relative to
7
- * the manifest's own location: inside the pnpm store, next to devtools,
8
- * where an optional peer is never installed under `pnpm dlx`. The import
9
- * failed with ERR_MODULE_NOT_FOUND, and every `env` command with it. Even
10
- * where the specifier does resolve (devtools installed as a devDependency),
11
- * the only copy that keeps the registry's module identity is the one the
12
- * repo's manifests use, so the redirect is applied whenever that copy is
13
- * known rather than only as a fallback.
14
- *
15
- * Node resolve hooks (`module.register`) rather than rewriting the file: the
16
- * manifest also imports `zod`, which has to keep resolving from devtools'
17
- * own location, and a copy written anywhere else would lose that.
18
- */
19
- import { register } from "node:module";
20
- /** Registers `redirect`. Hooks cannot be unregistered, so each call adds a
21
- * loader for the rest of the process; call it once per redirect. */
22
- export function redirectPeer(redirect) {
23
- register("./peer-redirect-hooks.js", import.meta.url, {
24
- data: [redirect],
25
- });
26
- }
@@ -1,39 +0,0 @@
1
- /** Test-only: clears the memoized peer modules so a test can re-resolve with a fresh mock. */
2
- export declare function resetPeerCacheForTests(): void;
3
- /**
4
- * The file URL `specifier` was loaded from through the target repo, or
5
- * undefined if it has not been loaded yet or came from a plain bare-specifier
6
- * `import()` (the test-suite and not-in-a-repo fallbacks above).
7
- *
8
- * For code devtools imports that itself names a peer by bare specifier --
9
- * today only devtools' own `env.ts` manifest. From an installed copy that
10
- * specifier cannot resolve at all (an optional peer is never installed next
11
- * to devtools under `pnpm dlx`), and even where it can, the only copy that
12
- * keeps module identity is this one. `repo/peer-redirect.ts` uses it to
13
- * point the import here.
14
- */
15
- export declare function repoPeerUrl(specifier: string): string | undefined;
16
- import type * as EnvModule from "@devdogsuga/env";
17
- import type * as EnvLoadModule from "@devdogsuga/env/load";
18
- import type * as EnvSessionModule from "@devdogsuga/env/session";
19
- export declare function loadEnv(): Promise<typeof EnvModule>;
20
- /**
21
- * The synchronous companion to `loadEnv()`, for the handful of call sites
22
- * (`gh/environments.ts`'s `GITHUB_ENVIRONMENT_SPECS` getters) that cannot
23
- * become async without changing their own callers' contract — they read a
24
- * plain array off a getter, not a Promise. Safe ONLY after `loadEnv()` has
25
- * resolved at least once; every caller of these getters already requires
26
- * `env/discovery.ts`'s `loadRegistry()` (which calls `loadEnv()` itself) to
27
- * have run first, via `assertRegistryLoaded()`'s own guard.
28
- */
29
- export declare function getEnvSync(): typeof EnvModule;
30
- /** Test-only: clears the sync cache alongside `resetPeerCacheForTests()`. */
31
- export declare function resetEnvSyncCacheForTests(): void;
32
- export declare function loadEnvLoad(): Promise<typeof EnvLoadModule>;
33
- export declare function loadEnvSession(): Promise<typeof EnvSessionModule>;
34
- import type * as DbTypegenModule from "@devdogsuga/db/typegen";
35
- export declare function loadDbTypegen(): Promise<typeof DbTypegenModule>;
36
- import type * as BrandEventModule from "@devdogsuga/brand/event";
37
- export declare function loadBrandEvent(): Promise<typeof BrandEventModule>;
38
- import type * as EventsModule from "@devdogsuga/events";
39
- export declare function loadEvents(): Promise<typeof EventsModule>;