@supatype/cli 0.1.5 → 0.1.7

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 (247) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +143 -93
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/deno.d.ts +52 -0
  5. package/dist/augmentation-generator.d.ts +22 -0
  6. package/dist/augmentation-generator.d.ts.map +1 -1
  7. package/dist/augmentation-generator.js +43 -7
  8. package/dist/augmentation-generator.js.map +1 -1
  9. package/dist/binary-cache.d.ts +14 -0
  10. package/dist/binary-cache.d.ts.map +1 -1
  11. package/dist/binary-cache.js +33 -13
  12. package/dist/binary-cache.js.map +1 -1
  13. package/dist/cache-clean.d.ts +15 -0
  14. package/dist/cache-clean.d.ts.map +1 -0
  15. package/dist/cache-clean.js +76 -0
  16. package/dist/cache-clean.js.map +1 -0
  17. package/dist/cache-pins.d.ts +51 -0
  18. package/dist/cache-pins.d.ts.map +1 -0
  19. package/dist/cache-pins.js +184 -0
  20. package/dist/cache-pins.js.map +1 -0
  21. package/dist/cli-install-method.d.ts +19 -0
  22. package/dist/cli-install-method.d.ts.map +1 -0
  23. package/dist/cli-install-method.js +60 -0
  24. package/dist/cli-install-method.js.map +1 -0
  25. package/dist/cli.d.ts.map +1 -1
  26. package/dist/cli.js +6 -0
  27. package/dist/cli.js.map +1 -1
  28. package/dist/cloud-credentials.d.ts +34 -0
  29. package/dist/cloud-credentials.d.ts.map +1 -0
  30. package/dist/cloud-credentials.js +139 -0
  31. package/dist/cloud-credentials.js.map +1 -0
  32. package/dist/commands/admin.d.ts +11 -0
  33. package/dist/commands/admin.d.ts.map +1 -1
  34. package/dist/commands/admin.js +131 -22
  35. package/dist/commands/admin.js.map +1 -1
  36. package/dist/commands/cache.d.ts.map +1 -1
  37. package/dist/commands/cache.js +67 -71
  38. package/dist/commands/cache.js.map +1 -1
  39. package/dist/commands/cloud.d.ts.map +1 -1
  40. package/dist/commands/cloud.js +32 -8
  41. package/dist/commands/cloud.js.map +1 -1
  42. package/dist/commands/db.d.ts +1 -0
  43. package/dist/commands/db.d.ts.map +1 -1
  44. package/dist/commands/db.js +147 -1
  45. package/dist/commands/db.js.map +1 -1
  46. package/dist/commands/dev.d.ts.map +1 -1
  47. package/dist/commands/dev.js +50 -10
  48. package/dist/commands/dev.js.map +1 -1
  49. package/dist/commands/doctor.d.ts +28 -0
  50. package/dist/commands/doctor.d.ts.map +1 -1
  51. package/dist/commands/doctor.js +60 -3
  52. package/dist/commands/doctor.js.map +1 -1
  53. package/dist/commands/functions.d.ts +7 -0
  54. package/dist/commands/functions.d.ts.map +1 -1
  55. package/dist/commands/functions.js +40 -6
  56. package/dist/commands/functions.js.map +1 -1
  57. package/dist/commands/generate.d.ts.map +1 -1
  58. package/dist/commands/generate.js +5 -1
  59. package/dist/commands/generate.js.map +1 -1
  60. package/dist/commands/hooks.d.ts +12 -0
  61. package/dist/commands/hooks.d.ts.map +1 -0
  62. package/dist/commands/hooks.js +118 -0
  63. package/dist/commands/hooks.js.map +1 -0
  64. package/dist/commands/init.d.ts.map +1 -1
  65. package/dist/commands/init.js +41 -7
  66. package/dist/commands/init.js.map +1 -1
  67. package/dist/commands/internal.d.ts +6 -0
  68. package/dist/commands/internal.d.ts.map +1 -0
  69. package/dist/commands/internal.js +22 -0
  70. package/dist/commands/internal.js.map +1 -0
  71. package/dist/commands/keys.d.ts.map +1 -1
  72. package/dist/commands/keys.js +2 -0
  73. package/dist/commands/keys.js.map +1 -1
  74. package/dist/commands/link-helpers.d.ts.map +1 -1
  75. package/dist/commands/link-helpers.js +8 -2
  76. package/dist/commands/link-helpers.js.map +1 -1
  77. package/dist/commands/login.d.ts +7 -0
  78. package/dist/commands/login.d.ts.map +1 -0
  79. package/dist/commands/login.js +110 -0
  80. package/dist/commands/login.js.map +1 -0
  81. package/dist/commands/push.d.ts.map +1 -1
  82. package/dist/commands/push.js +110 -7
  83. package/dist/commands/push.js.map +1 -1
  84. package/dist/commands/self-update.d.ts +7 -0
  85. package/dist/commands/self-update.d.ts.map +1 -1
  86. package/dist/commands/self-update.js +18 -11
  87. package/dist/commands/self-update.js.map +1 -1
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +18 -3
  90. package/dist/config.js.map +1 -1
  91. package/dist/db-preflight.d.ts +78 -0
  92. package/dist/db-preflight.d.ts.map +1 -0
  93. package/dist/db-preflight.js +452 -0
  94. package/dist/db-preflight.js.map +1 -0
  95. package/dist/dev-compose.d.ts +1 -1
  96. package/dist/dev-compose.d.ts.map +1 -1
  97. package/dist/dev-compose.js +129 -72
  98. package/dist/dev-compose.js.map +1 -1
  99. package/dist/env-file.d.ts +40 -2
  100. package/dist/env-file.d.ts.map +1 -1
  101. package/dist/env-file.js +132 -11
  102. package/dist/env-file.js.map +1 -1
  103. package/dist/field-masking-tier.d.ts +50 -0
  104. package/dist/field-masking-tier.d.ts.map +1 -0
  105. package/dist/field-masking-tier.js +91 -0
  106. package/dist/field-masking-tier.js.map +1 -0
  107. package/dist/functions-deno-types.d.ts +24 -0
  108. package/dist/functions-deno-types.d.ts.map +1 -0
  109. package/dist/functions-deno-types.js +155 -0
  110. package/dist/functions-deno-types.js.map +1 -0
  111. package/dist/hook-upload.d.ts +30 -0
  112. package/dist/hook-upload.d.ts.map +1 -0
  113. package/dist/hook-upload.js +86 -0
  114. package/dist/hook-upload.js.map +1 -0
  115. package/dist/hooks-generator.d.ts +8 -0
  116. package/dist/hooks-generator.d.ts.map +1 -0
  117. package/dist/hooks-generator.js +332 -0
  118. package/dist/hooks-generator.js.map +1 -0
  119. package/dist/link.d.ts +4 -0
  120. package/dist/link.d.ts.map +1 -1
  121. package/dist/link.js +2 -0
  122. package/dist/link.js.map +1 -1
  123. package/dist/local-secrets.d.ts +71 -0
  124. package/dist/local-secrets.d.ts.map +1 -0
  125. package/dist/local-secrets.js +100 -0
  126. package/dist/local-secrets.js.map +1 -0
  127. package/dist/model-hooks.d.ts +73 -0
  128. package/dist/model-hooks.d.ts.map +1 -0
  129. package/dist/model-hooks.js +211 -0
  130. package/dist/model-hooks.js.map +1 -0
  131. package/dist/postgres-ctl.d.ts +11 -0
  132. package/dist/postgres-ctl.d.ts.map +1 -1
  133. package/dist/postgres-ctl.js +25 -2
  134. package/dist/postgres-ctl.js.map +1 -1
  135. package/dist/project-config.d.ts +121 -1
  136. package/dist/project-config.d.ts.map +1 -1
  137. package/dist/project-config.js +150 -1
  138. package/dist/project-config.js.map +1 -1
  139. package/dist/resolve-target.d.ts +4 -0
  140. package/dist/resolve-target.d.ts.map +1 -1
  141. package/dist/resolve-target.js +69 -73
  142. package/dist/resolve-target.js.map +1 -1
  143. package/dist/schema-ast-v2.d.ts +2 -1
  144. package/dist/schema-ast-v2.d.ts.map +1 -1
  145. package/dist/schema-ast-v2.js +4 -2
  146. package/dist/schema-ast-v2.js.map +1 -1
  147. package/dist/schema-push-lock.d.ts.map +1 -1
  148. package/dist/schema-push-lock.js +43 -16
  149. package/dist/schema-push-lock.js.map +1 -1
  150. package/dist/self-host-compose.d.ts +24 -0
  151. package/dist/self-host-compose.d.ts.map +1 -1
  152. package/dist/self-host-compose.js +301 -69
  153. package/dist/self-host-compose.js.map +1 -1
  154. package/dist/service-role-check.d.ts +21 -0
  155. package/dist/service-role-check.d.ts.map +1 -0
  156. package/dist/service-role-check.js +85 -0
  157. package/dist/service-role-check.js.map +1 -0
  158. package/dist/target-client.d.ts +17 -0
  159. package/dist/target-client.d.ts.map +1 -1
  160. package/dist/target-client.js +69 -17
  161. package/dist/target-client.js.map +1 -1
  162. package/dist/type-extractor.d.ts.map +1 -1
  163. package/dist/type-extractor.js +736 -32
  164. package/dist/type-extractor.js.map +1 -1
  165. package/dist/ui/runtime/command-chrome.js +1 -1
  166. package/dist/ui/runtime/command-chrome.js.map +1 -1
  167. package/dist/ui/runtime/stdin-after-ink.d.ts.map +1 -1
  168. package/dist/ui/runtime/stdin-after-ink.js +3 -1
  169. package/dist/ui/runtime/stdin-after-ink.js.map +1 -1
  170. package/package.json +4 -1
  171. package/scripts/generate-homebrew-formula.mjs +121 -0
  172. package/src/augmentation-generator.ts +46 -9
  173. package/src/binary-cache.ts +47 -15
  174. package/src/cache-clean.ts +81 -0
  175. package/src/cache-pins.ts +243 -0
  176. package/src/cli-install-method.ts +67 -0
  177. package/src/cli.ts +6 -0
  178. package/src/cloud-credentials.ts +183 -0
  179. package/src/commands/admin.ts +153 -28
  180. package/src/commands/cache.ts +90 -75
  181. package/src/commands/cloud.ts +32 -8
  182. package/src/commands/db.ts +162 -1
  183. package/src/commands/dev.ts +57 -8
  184. package/src/commands/doctor.ts +69 -3
  185. package/src/commands/functions.ts +42 -5
  186. package/src/commands/generate.ts +5 -1
  187. package/src/commands/hooks.ts +138 -0
  188. package/src/commands/init.ts +42 -8
  189. package/src/commands/internal.ts +23 -0
  190. package/src/commands/keys.ts +2 -0
  191. package/src/commands/link-helpers.ts +9 -2
  192. package/src/commands/login.ts +127 -0
  193. package/src/commands/push.ts +124 -7
  194. package/src/commands/self-update.ts +85 -13
  195. package/src/config.ts +22 -3
  196. package/src/db-preflight.ts +575 -0
  197. package/src/dev-compose.ts +144 -73
  198. package/src/env-file.ts +157 -11
  199. package/src/field-masking-tier.ts +107 -0
  200. package/src/functions-deno-types.ts +188 -0
  201. package/src/hook-upload.ts +107 -0
  202. package/src/hooks-generator.ts +352 -0
  203. package/src/link.ts +7 -0
  204. package/src/local-secrets.ts +104 -0
  205. package/src/model-hooks.ts +260 -0
  206. package/src/postgres-ctl.ts +29 -2
  207. package/src/project-config.ts +236 -2
  208. package/src/resolve-target.ts +113 -68
  209. package/src/schema-ast-v2.ts +5 -2
  210. package/src/schema-push-lock.ts +56 -17
  211. package/src/self-host-compose.ts +344 -68
  212. package/src/service-role-check.ts +111 -0
  213. package/src/target-client.ts +105 -18
  214. package/src/type-extractor.ts +883 -28
  215. package/src/ui/runtime/command-chrome.ts +1 -1
  216. package/src/ui/runtime/stdin-after-ink.ts +3 -1
  217. package/tests/access-composition.test.ts +667 -0
  218. package/tests/admin-studio-membership.test.ts +121 -0
  219. package/tests/api-schemas.test.ts +83 -0
  220. package/tests/archive-name-alias.test.ts +42 -0
  221. package/tests/augmentation-generator.test.ts +72 -4
  222. package/tests/cache-clean.test.ts +206 -0
  223. package/tests/cli-help.test.ts +3 -0
  224. package/tests/cli-install-method.test.ts +66 -0
  225. package/tests/command-chrome-coverage.test.ts +31 -26
  226. package/tests/command-chrome-wrap.test.ts +1 -0
  227. package/tests/db-preflight.test.ts +72 -0
  228. package/tests/doctor-render.test.ts +62 -0
  229. package/tests/doctor-service-role.test.ts +55 -0
  230. package/tests/env-file.test.ts +121 -0
  231. package/tests/external-database-compose.test.ts +184 -0
  232. package/tests/external-database-config.test.ts +154 -0
  233. package/tests/field-masking-tier.test.ts +184 -0
  234. package/tests/functions-deno-types.test.ts +197 -0
  235. package/tests/homebrew-formula.test.ts +58 -0
  236. package/tests/hook-upload.test.ts +125 -0
  237. package/tests/hooks-generator.test.ts +212 -0
  238. package/tests/init.test.ts +3 -0
  239. package/tests/local-secrets.test.ts +92 -0
  240. package/tests/model-hooks.test.ts +322 -0
  241. package/tests/runtime-contract.test.ts +113 -1
  242. package/tests/service-role-check.test.ts +109 -0
  243. package/tests/service-role-routes.test.ts +51 -0
  244. package/tests/target-client-refresh.test.ts +94 -0
  245. package/tests/type-extractor.test.ts +187 -0
  246. package/tsconfig.tsbuildinfo +1 -1
  247. package/tests/engine-distribution.test.ts +0 -418
@@ -15,6 +15,7 @@ import {
15
15
  projectRootFromConfig,
16
16
  resolveRuntimeProvider,
17
17
  schemaPathFromProject,
18
+ usesExternalDatabase,
18
19
  type SupatypeProjectConfig,
19
20
  } from "./project-config.js"
20
21
  import { signJwt } from "./jwt.js"
@@ -40,6 +41,12 @@ import { ensureLocalServerDockerImage } from "./compose-local-server-image.js"
40
41
  import { ensureEngine, engineRequest, type DiffResult } from "./engine-client.js"
41
42
  import { writeSchemaSourcePushArtifacts, type SchemaSourcePushArtifacts } from "./schema-sources.js"
42
43
  import { readEnvValue, upsertEnvFile } from "./env-file.js"
44
+ import {
45
+ devJwtSecret,
46
+ devPostgresPassword,
47
+ seedMissingDatabaseIdentity,
48
+ seedMissingLocalSecrets,
49
+ } from "./local-secrets.js"
43
50
  import { writeLocalEnvironment } from "./link.js"
44
51
  import { registerDevShutdown } from "./dev-shutdown.js"
45
52
  import {
@@ -55,16 +62,19 @@ import type { ExtractedSchemaAstV2 } from "./schema-ast-v2.js"
55
62
  import { ensureFirstAdminUserForProject } from "./commands/admin.js"
56
63
  import { publishDevReady } from "./dev-ready-panel.js"
57
64
 
58
- const LOCAL_JWT_SECRET = "super-secret-jwt-token-with-at-least-32-characters-long"
59
-
60
65
  /** Default host port for compose Postgres when `overrides.engine` is set (devLocal). */
61
66
  const COMPOSE_DEV_DB_PORT = 54329
62
67
 
63
68
  /** Sync optional Docker image pins from config into `.env` (no JWT rotation). */
64
69
  export function syncComposeImagePins(cwd: string, config: SupatypeProjectConfig): void {
65
70
  const imagePins = composeDockerImageEnv(config)
71
+ // Clean up only what a previous run wrote from config. A hand-written image tag in `.env` is the
72
+ // documented way to run a local build, and this used to delete it on every compose run.
66
73
  const removeImageKeys = COMPOSE_PINNED_IMAGE_ENV_KEYS.filter((key) => !(key in imagePins))
67
- upsertEnvFile(cwd, imagePins, removeImageKeys)
74
+ upsertEnvFile(cwd, imagePins, {
75
+ removeManaged: removeImageKeys,
76
+ managed: COMPOSE_PINNED_IMAGE_ENV_KEYS,
77
+ })
68
78
  }
69
79
 
70
80
  export interface DevComposeOptions {
@@ -72,8 +82,10 @@ export interface DevComposeOptions {
72
82
  }
73
83
 
74
84
  /** In-compose Postgres URL (SCRAM; not published to the host). */
75
- export function composeDbUrl(): string {
76
- return "postgresql://supatype_admin:postgres@db:5432/supatype?sslmode=disable"
85
+ export function composeDbUrl(cwd: string): string {
86
+ const user = readEnvValue(cwd, "POSTGRES_USER", "supatype_admin")
87
+ const db = readEnvValue(cwd, "POSTGRES_DB", "supatype")
88
+ return `postgresql://${user}:${devPostgresPassword(cwd)}@db:5432/${db}?sslmode=disable`
77
89
  }
78
90
 
79
91
  /**
@@ -84,10 +96,23 @@ async function resolveDevDbPort(cwd: string): Promise<number> {
84
96
  return ensureDevDbPort(cwd)
85
97
  }
86
98
 
99
+ /**
100
+ * The database URL to hand a tool, from wherever it happens to run.
101
+ *
102
+ * The compose helpers below describe the `db` *container* — on the host at
103
+ * `SUPATYPE_DEV_DB_PORT`, or in-network at `db:5432`. Neither exists for a project pointed at an
104
+ * external database, and passing one produced "pool timed out while waiting for an open connection"
105
+ * — a message with nothing in it about the URL being wrong.
106
+ */
107
+ function projectDatabaseUrl(cwd: string, config: SupatypeProjectConfig, inNetwork = false): string {
108
+ if (usesExternalDatabase(config)) return connectionString(config)
109
+ return inNetwork ? composeDbUrl(cwd) : hostComposeDbUrl(cwd)
110
+ }
111
+
87
112
  function hostComposeDbUrl(cwd: string): string {
88
113
  const port = readEnvValue(cwd, "SUPATYPE_DEV_DB_PORT", String(COMPOSE_DEV_DB_PORT))
89
114
  const user = readEnvValue(cwd, "POSTGRES_USER", "supatype_admin")
90
- const pass = readEnvValue(cwd, "POSTGRES_PASSWORD", "postgres")
115
+ const pass = devPostgresPassword(cwd)
91
116
  const db = readEnvValue(cwd, "POSTGRES_DB", "supatype")
92
117
  return `postgresql://${user}:${pass}@127.0.0.1:${port}/${db}?sslmode=disable`
93
118
  }
@@ -117,19 +142,12 @@ export async function ensureDockerDbPublishedForHostEngine(
117
142
 
118
143
  const now = Math.floor(Date.now() / 1000)
119
144
  const jwtBase = { iss: "supatype", iat: now, exp: now + 315_360_000 }
120
- const anonKey = signJwt({ ...jwtBase, role: "anon" }, LOCAL_JWT_SECRET)
121
- const serviceRoleKey = signJwt({ ...jwtBase, role: "service_role" }, LOCAL_JWT_SECRET)
145
+ const anonKey = signJwt({ ...jwtBase, role: "anon" }, devJwtSecret(cwd))
146
+ const serviceRoleKey = signJwt({ ...jwtBase, role: "service_role" }, devJwtSecret(cwd))
122
147
  ensureDevComposeEnv(cwd, config, anonKey, serviceRoleKey, kongPort, devDbPort)
123
148
 
124
149
  const paths = writeSelfHostCompose(cwd, config, { devLocal: true })
125
- const up = runDockerCompose(paths.composePath, ["up", "-d", "db"], cwd, project, {
126
- quiet: true,
127
- ...(brand !== undefined && { brand }),
128
- })
129
- if (up !== 0) {
130
- exitComposeFailed(up, "Could not start Postgres (compose db service).", brand)
131
- }
132
- await waitComposeHealthy(paths, cwd, 120_000, project)
150
+ await startComposeDatabase(config, paths, cwd, project, brand)
133
151
  }
134
152
 
135
153
  /**
@@ -142,6 +160,9 @@ export function usesLocalDockerEngineDb(
142
160
  ): boolean {
143
161
  if (explicitConnection?.trim()) return false
144
162
  if (config.connection?.trim()) return false
163
+ // An external database is already reachable and is not ours to publish. Without this the engine
164
+ // path tried `compose up -d db` against a compose file that deliberately has no `db` service.
165
+ if (usesExternalDatabase(config)) return false
145
166
  return resolveRuntimeProvider(config) === "docker" && hasEngineOverride(config)
146
167
  }
147
168
 
@@ -168,6 +189,36 @@ async function resolveKongPort(cwd: string): Promise<number> {
168
189
  return ensureKongPort(cwd, { context: "dev" })
169
190
  }
170
191
 
192
+ /**
193
+ * Bring up the compose `db` service and wait for it to answer.
194
+ *
195
+ * A no-op when the project uses an external database: there is no such service, the database is
196
+ * already running, and its readiness is the operator's — `supatype db check` is what reports on it.
197
+ * Four call sites did this inline, so an external project hit `compose up -d db` against a compose
198
+ * file that deliberately has no `db`.
199
+ */
200
+ async function startComposeDatabase(
201
+ config: SupatypeProjectConfig,
202
+ paths: SelfHostComposePaths,
203
+ cwd: string,
204
+ project: string,
205
+ brand: DockerBrandOptions | undefined,
206
+ timeoutMs = 120_000,
207
+ onFailure?: () => void,
208
+ ): Promise<void> {
209
+ if (usesExternalDatabase(config)) return
210
+
211
+ const up = runDockerCompose(paths.composePath, ["up", "-d", "db"], cwd, project, {
212
+ quiet: true,
213
+ ...(brand !== undefined && { brand }),
214
+ })
215
+ if (up !== 0) {
216
+ onFailure?.()
217
+ exitComposeFailed(up, "Could not start Postgres (compose db service).", brand)
218
+ }
219
+ await waitComposeHealthy(paths, cwd, timeoutMs, project)
220
+ }
221
+
171
222
  function upsertDevComposeEnv(
172
223
  cwd: string,
173
224
  config: SupatypeProjectConfig,
@@ -179,16 +230,32 @@ function upsertDevComposeEnv(
179
230
  ): void {
180
231
  const apiUrl = `http://localhost:${kongPort}`
181
232
  const imagePins = composeDockerImageEnv(config)
233
+ // `POSTGRES_PASSWORD` and `JWT_SECRET` are deliberately absent.
234
+ //
235
+ // They used to be pinned here to published constants on every `dev` run, against the same
236
+ // `.env` a self-host deployment reads — so a project could not hold its own secrets, and one
237
+ // `dev` after generating them put the published values back with nothing to show it. They are
238
+ // now resolved from `.env` (see `local-secrets.ts`) and written by `init` alone.
239
+ //
240
+ // The keys below still have to be written, because they are *derived*: the anon and
241
+ // service-role tokens are signed with the effective secret, so leaving a stale pair in place
242
+ // would desync them from the secret that validates them.
182
243
  const updates: Record<string, string> = {
183
- POSTGRES_USER: "supatype_admin",
184
- POSTGRES_PASSWORD: "postgres",
185
- POSTGRES_DB: "supatype",
186
- JWT_SECRET: LOCAL_JWT_SECRET,
244
+ // The docker dev path renders the *self-host* compose file, where the secrets have no
245
+ // defaults — an unset value is a hard compose error rather than a service quietly starting
246
+ // with a published constant. This guarantees presence without overwriting: only keys
247
+ // genuinely absent from `.env` are filled, and with the value the project has been running
248
+ // with rather than a fresh one.
249
+ ...seedMissingLocalSecrets(cwd),
250
+ // Project configuration, seeded not overwritten — see seedMissingDatabaseIdentity.
251
+ ...seedMissingDatabaseIdentity(cwd),
187
252
  ANON_KEY: anonKey,
188
253
  SERVICE_ROLE_KEY: serviceRoleKey,
189
254
  PUBLIC_SUPATYPE_ANON_KEY: anonKey,
190
255
  VITE_SUPATYPE_ANON_KEY: anonKey,
256
+ EXPO_PUBLIC_SUPATYPE_ANON_KEY: anonKey,
191
257
  PUBLIC_SUPATYPE_URL: apiUrl,
258
+ EXPO_PUBLIC_SUPATYPE_URL: apiUrl,
192
259
  SUPATYPE_KONG_PORT: String(kongPort),
193
260
  API_EXTERNAL_URL: apiUrl,
194
261
  SITE_URL: apiUrl,
@@ -196,13 +263,26 @@ function upsertDevComposeEnv(
196
263
  ...imagePins,
197
264
  ...(localServerImage !== undefined && { SUPATYPE_SERVER_IMAGE: localServerImage }),
198
265
  }
199
- if (devDbPort !== undefined) {
266
+ // Never for an external database: this URL describes the `db` container, which that project does
267
+ // not have. Writing it overwrote the operator's own DATABASE_URL — the value the whole stack and
268
+ // every CLI command reads — with a DSN pointing at a database that does not exist. Found by
269
+ // rehearsing a push against a real external Postgres, where the compose guard then refused to
270
+ // proceed because .env and the config disagreed. They disagreed because of this line.
271
+ if (devDbPort !== undefined && !usesExternalDatabase(config)) {
200
272
  updates.SUPATYPE_DEV_DB_PORT = String(devDbPort)
273
+ // User and database from the project, not hardcoded — same reason as
274
+ // `seedMissingDatabaseIdentity`: a project not named "supatype" had this URL pointing at a
275
+ // database that does not exist.
276
+ const dbUser = readEnvValue(cwd, "POSTGRES_USER", "supatype_admin")
277
+ const dbName = readEnvValue(cwd, "POSTGRES_DB", "supatype")
201
278
  updates.DATABASE_URL =
202
- `postgresql://supatype_admin:postgres@localhost:${devDbPort}/supatype?sslmode=disable`
279
+ `postgresql://${dbUser}:${devPostgresPassword(cwd)}@localhost:${devDbPort}/${dbName}?sslmode=disable`
203
280
  }
204
281
  const removeImageKeys = COMPOSE_PINNED_IMAGE_ENV_KEYS.filter((key) => !(key in imagePins))
205
- upsertEnvFile(cwd, updates, removeImageKeys)
282
+ upsertEnvFile(cwd, updates, {
283
+ removeManaged: removeImageKeys,
284
+ managed: COMPOSE_PINNED_IMAGE_ENV_KEYS,
285
+ })
206
286
  }
207
287
 
208
288
  /** Keep compose + Studio on the same freshly signed dev JWTs; sync optional image pins from config. */
@@ -438,7 +518,7 @@ async function runComposeSchemaPush(
438
518
  try {
439
519
  await engineRequest("/push", {
440
520
  ast,
441
- database_url: hostComposeDbUrl(cwd),
521
+ database_url: projectDatabaseUrl(cwd, config),
442
522
  schema: pgSchema,
443
523
  force: true,
444
524
  ...(sources
@@ -538,7 +618,7 @@ async function runComposeEnginePush(
538
618
  "-i",
539
619
  "/project/.supatype/schema.ast.json",
540
620
  "--database-url",
541
- composeDbUrl(),
621
+ projectDatabaseUrl(cwd, config, true),
542
622
  "--force",
543
623
  "--non-interactive",
544
624
  )
@@ -604,7 +684,7 @@ async function runComposeEngineDiff(
604
684
  "-i",
605
685
  "/project/.supatype/schema.ast.json",
606
686
  "--database-url",
607
- composeDbUrl(),
687
+ projectDatabaseUrl(cwd, config, true),
608
688
  "--schema",
609
689
  pgSchema,
610
690
  )
@@ -649,7 +729,7 @@ export async function diffSchemaDocker(cwd: string, config: SupatypeProjectConfi
649
729
  await ensureEngine()
650
730
  return engineRequest<DiffResult>("/diff", {
651
731
  ast,
652
- database_url: hostComposeDbUrl(cwd),
732
+ database_url: projectDatabaseUrl(cwd, config),
653
733
  schema: pgSchema,
654
734
  })
655
735
  }
@@ -657,21 +737,14 @@ export async function diffSchemaDocker(cwd: string, config: SupatypeProjectConfi
657
737
  const kongPort = await resolveKongPort(cwd)
658
738
  const now = Math.floor(Date.now() / 1000)
659
739
  const jwtBase = { iss: "supatype", iat: now, exp: now + 315_360_000 }
660
- const anonKey = signJwt({ ...jwtBase, role: "anon" }, LOCAL_JWT_SECRET)
661
- const serviceRoleKey = signJwt({ ...jwtBase, role: "service_role" }, LOCAL_JWT_SECRET)
740
+ const anonKey = signJwt({ ...jwtBase, role: "anon" }, devJwtSecret(cwd))
741
+ const serviceRoleKey = signJwt({ ...jwtBase, role: "service_role" }, devJwtSecret(cwd))
662
742
  ensureDevComposeEnv(cwd, config, anonKey, serviceRoleKey, kongPort, undefined)
663
743
 
664
744
  const paths = writeSelfHostCompose(cwd, config, { devLocal: true })
665
745
  const diffBrand = { intro: "Schema diff" }
666
746
 
667
- const up = runDockerCompose(paths.composePath, ["up", "-d", "db"], cwd, project, {
668
- quiet: true,
669
- brand: diffBrand,
670
- })
671
- if (up !== 0) {
672
- exitComposeFailed(up, "Could not start Postgres (compose db service).", diffBrand)
673
- }
674
- await waitComposeHealthy(paths, cwd, 120_000, project)
747
+ await startComposeDatabase(config, paths, cwd, project, diffBrand)
675
748
 
676
749
  const schemaPath = schemaPathFromProject(config, cwd)
677
750
  const ast = loadSchemaAst(schemaPath, cwd)
@@ -708,26 +781,25 @@ export async function pushSchemaDocker(cwd: string, config: SupatypeProjectConfi
708
781
  }
709
782
  const project = composeProjectName(config.project.name)
710
783
  const kongPort = await resolveKongPort(cwd)
711
- const devDbPort = hasEngineOverride(config) ? await resolveDevDbPort(cwd) : undefined
784
+ // No dev db port for an external database: `ensureDevDbPort` allocates a host port for the `db`
785
+ // container *and persists a matching DATABASE_URL*, which overwrote the operator's own URL — the
786
+ // one the whole stack and every CLI command reads.
787
+ const devDbPort =
788
+ hasEngineOverride(config) && !usesExternalDatabase(config)
789
+ ? await resolveDevDbPort(cwd)
790
+ : undefined
712
791
 
713
792
  const now = Math.floor(Date.now() / 1000)
714
793
  const jwtBase = { iss: "supatype", iat: now, exp: now + 315_360_000 }
715
- const anonKey = signJwt({ ...jwtBase, role: "anon" }, LOCAL_JWT_SECRET)
716
- const serviceRoleKey = signJwt({ ...jwtBase, role: "service_role" }, LOCAL_JWT_SECRET)
794
+ const anonKey = signJwt({ ...jwtBase, role: "anon" }, devJwtSecret(cwd))
795
+ const serviceRoleKey = signJwt({ ...jwtBase, role: "service_role" }, devJwtSecret(cwd))
717
796
  ensureDevComposeEnv(cwd, config, anonKey, serviceRoleKey, kongPort, devDbPort)
718
797
 
719
798
  const paths = writeSelfHostCompose(cwd, config, { devLocal: true })
720
799
  const pushBrand = { intro: "Push schema" }
721
800
 
722
801
  console.log(`[supatype] provider: docker — applying schema via compose (project ${project})...`)
723
- const up = runDockerCompose(paths.composePath, ["up", "-d", "db"], cwd, project, {
724
- quiet: true,
725
- brand: pushBrand,
726
- })
727
- if (up !== 0) {
728
- exitComposeFailed(up, "Could not start Postgres (compose db service).", pushBrand)
729
- }
730
- await waitComposeHealthy(paths, cwd, 120_000, project)
802
+ await startComposeDatabase(config, paths, cwd, project, pushBrand)
731
803
 
732
804
  const schemaPath = schemaPathFromProject(config, cwd)
733
805
  const ast = loadSchemaAst(schemaPath, cwd)
@@ -760,12 +832,18 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
760
832
  // stack on the machine (own containers, volumes, network, and gateway port).
761
833
  const project = composeProjectName(config.project.name)
762
834
  const kongPort = await resolveKongPort(cwd)
763
- const devDbPort = hasEngineOverride(config) ? await resolveDevDbPort(cwd) : undefined
835
+ // No dev db port for an external database: `ensureDevDbPort` allocates a host port for the `db`
836
+ // container *and persists a matching DATABASE_URL*, which overwrote the operator's own URL — the
837
+ // one the whole stack and every CLI command reads.
838
+ const devDbPort =
839
+ hasEngineOverride(config) && !usesExternalDatabase(config)
840
+ ? await resolveDevDbPort(cwd)
841
+ : undefined
764
842
 
765
843
  const now = Math.floor(Date.now() / 1000)
766
844
  const jwtBase = { iss: "supatype", iat: now, exp: now + 315_360_000 }
767
- const anonKey = signJwt({ ...jwtBase, role: "anon" }, LOCAL_JWT_SECRET)
768
- const serviceRoleKey = signJwt({ ...jwtBase, role: "service_role" }, LOCAL_JWT_SECRET)
845
+ const anonKey = signJwt({ ...jwtBase, role: "anon" }, devJwtSecret(cwd))
846
+ const serviceRoleKey = signJwt({ ...jwtBase, role: "service_role" }, devJwtSecret(cwd))
769
847
 
770
848
  const devBrand = { intro: "Local development" }
771
849
  const localServerImage = await ensureLocalServerDockerImage(cwd, config, devBrand)
@@ -816,18 +894,10 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
816
894
  await recoverStaleDevSession(cwd)
817
895
  await handleComposeProjectRename(cwd, config.project.name, paths)
818
896
 
819
- console.log("[supatype] Bringing up Postgres (compose db)...")
820
- const upDbStatus = runDockerCompose(paths.composePath, ["up", "-d", "db"], cwd, project, {
821
- quiet: true,
822
- brand: devBrand,
823
- })
824
- if (upDbStatus !== 0) {
825
- endDevSession()
826
- exitComposeFailed(upDbStatus, "Could not start Postgres (compose db service).", devBrand)
897
+ if (!usesExternalDatabase(config)) {
898
+ console.log("[supatype] Bringing up Postgres (compose db)...")
827
899
  }
828
-
829
- console.log("[supatype] Waiting for Postgres (compose)...")
830
- await waitComposeHealthy(paths, cwd, 180_000, project)
900
+ await startComposeDatabase(config, paths, cwd, project, devBrand, 180_000, endDevSession)
831
901
  // Settle before DDL — pg_isready can pass slightly before the instance is stable.
832
902
  await new Promise((r) => setTimeout(r, 3000))
833
903
 
@@ -849,16 +919,17 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
849
919
  )
850
920
  dumpComposeDbLogs(paths, cwd, project, `schema push attempt ${attempt}/${maxAttempts}`)
851
921
  if (attempt < maxAttempts) {
852
- console.log("[supatype] Resetting Postgres after failed schema push...")
853
- runDockerCompose(paths.composePath, ["down", "-v"], cwd, project, {
854
- quiet: true,
855
- brand: devBrand,
856
- })
857
- runDockerCompose(paths.composePath, ["up", "-d", "db"], cwd, project, {
858
- quiet: true,
859
- brand: devBrand,
860
- })
861
- await waitComposeHealthy(paths, cwd, 120_000, project)
922
+ // Only for a database Supatype created. Tearing down an external one is not ours to do,
923
+ // and `down -v` would destroy the stack's other volumes for a failure that was never
924
+ // about Postgres.
925
+ if (!usesExternalDatabase(config)) {
926
+ console.log("[supatype] Resetting Postgres after failed schema push...")
927
+ runDockerCompose(paths.composePath, ["down", "-v"], cwd, project, {
928
+ quiet: true,
929
+ brand: devBrand,
930
+ })
931
+ await startComposeDatabase(config, paths, cwd, project, devBrand)
932
+ }
862
933
  await new Promise((r) => setTimeout(r, 3000 * attempt))
863
934
  }
864
935
  }
@@ -925,7 +996,7 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
925
996
  writeLocalEnvironment(cwd, {
926
997
  target: "local",
927
998
  apiUrl: `http://localhost:${kongPort}`,
928
- databaseUrl: hasEngineOverride(config) ? hostComposeDbUrl(cwd) : composeDbUrl(),
999
+ databaseUrl: projectDatabaseUrl(cwd, config, !hasEngineOverride(config)),
929
1000
  projectRef: config.project.name,
930
1001
  kongPort,
931
1002
  provider: "docker",
package/src/env-file.ts CHANGED
@@ -1,23 +1,162 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs"
2
2
  import { join } from "node:path"
3
3
 
4
- /** Merge key/value updates into a project `.env` without dropping unrelated lines. */
4
+ /**
5
+ * Marks a value the CLI wrote from `versions` in `supatype.config.ts`.
6
+ *
7
+ * Provenance, so that removing a pin from config can clean up the stale image reference it left
8
+ * behind *without* deleting a value the operator wrote by hand. Before this, the image keys derived
9
+ * from config were removed unconditionally on every compose run: an operator could override
10
+ * `SUPATYPE_REALTIME_IMAGE` from `.env` and could not override `SUPATYPE_SERVER_IMAGE`, because the
11
+ * latter is derivable from a pin and the former is not. Same file, same shape, different rules.
12
+ */
13
+ export const MANAGED_MARKER = "# supatype:managed — value from versions in supatype.config.ts"
14
+
15
+ interface EnvLine {
16
+ /** `KEY` for an assignment, undefined for comments and blanks. */
17
+ key?: string
18
+ text: string
19
+ /** True when the preceding line is the managed marker. */
20
+ managed: boolean
21
+ }
22
+
23
+ function parseLines(text: string): EnvLine[] {
24
+ const out: EnvLine[] = []
25
+ if (text === "") return out
26
+ let markerPending = false
27
+ // A file ending in a newline yields a final empty element; keeping it would put a blank line
28
+ // between the existing content and anything appended.
29
+ const raws = text.split(/\r?\n/)
30
+ while (raws.length > 0 && raws[raws.length - 1] === "") raws.pop()
31
+ for (const raw of raws) {
32
+ const trimmed = raw.trim()
33
+ if (trimmed === MANAGED_MARKER) {
34
+ markerPending = true
35
+ out.push({ text: raw, managed: false })
36
+ continue
37
+ }
38
+ const eq = raw.indexOf("=")
39
+ const key = eq > 0 ? raw.slice(0, eq).trim() : undefined
40
+ const isAssignment = key !== undefined && /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)
41
+ out.push({ ...(isAssignment && { key }), text: raw, managed: isAssignment && markerPending })
42
+ if (!isAssignment && trimmed !== "") markerPending = false
43
+ else if (isAssignment) markerPending = false
44
+ }
45
+ return out
46
+ }
47
+
48
+ export interface UpsertOptions {
49
+ /**
50
+ * Keys to delete — but only where the CLI wrote them (see [`MANAGED_MARKER`]). A hand-written value
51
+ * is left alone, because the operator putting an image tag in `.env` is the documented way to run a
52
+ * local build and deleting it silently sent them back to `:latest`.
53
+ */
54
+ removeManaged?: readonly string[]
55
+ /** Keys in `updates` that come from config and may later be cleaned up. Written with the marker. */
56
+ managed?: readonly string[]
57
+ /** Keys to delete outright, whoever wrote them. For secrets being rotated out. */
58
+ remove?: readonly string[]
59
+ }
60
+
61
+ /**
62
+ * Merge key/value updates into a project `.env`.
63
+ *
64
+ * Comments, blank lines and ordering are preserved: this file is edited by hand, and the previous
65
+ * implementation rebuilt it from the assignments alone, so every comment an operator had written
66
+ * disappeared the first time they ran anything that touched `.env`.
67
+ */
5
68
  export function upsertEnvFile(
6
69
  cwd: string,
7
70
  updates: Record<string, string>,
8
- removeKeys: readonly string[] = [],
71
+ options: UpsertOptions | readonly string[] = {},
9
72
  ): void {
73
+ // Historically the third argument was a plain list of keys to remove unconditionally.
74
+ const opts: UpsertOptions = Array.isArray(options)
75
+ ? { remove: options as readonly string[] }
76
+ : (options as UpsertOptions)
77
+ const removeManaged = new Set(opts.removeManaged ?? [])
78
+ const removeAlways = new Set(opts.remove ?? [])
79
+ const managed = new Set(opts.managed ?? [])
80
+
10
81
  const envPath = join(cwd, ".env")
11
82
  const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : ""
12
- const keys = new Set([...Object.keys(updates), ...removeKeys])
13
- const kept = existing
14
- .split("\n")
15
- .filter((line) => {
16
- const key = line.split("=")[0]?.trim()
17
- return key && line.includes("=") && !keys.has(key)
18
- })
19
- const merged = [...kept, ...Object.entries(updates).map(([key, value]) => `${key}=${value}`)]
20
- writeFileSync(envPath, `${merged.join("\n").trimEnd()}\n`, "utf8")
83
+ const lines = parseLines(existing)
84
+
85
+ const written = new Set<string>()
86
+ const kept: EnvLine[] = []
87
+
88
+ for (let i = 0; i < lines.length; i++) {
89
+ const line = lines[i]!
90
+ const key = line.key
91
+
92
+ if (key === undefined) {
93
+ kept.push(line)
94
+ continue
95
+ }
96
+
97
+ if (removeAlways.has(key) || (removeManaged.has(key) && line.managed)) {
98
+ // Drop the marker that introduced it, so no orphan comment is left behind.
99
+ if (line.managed && kept[kept.length - 1]?.text.trim() === MANAGED_MARKER) kept.pop()
100
+ continue
101
+ }
102
+
103
+ if (key in updates) {
104
+ const wantsMarker = managed.has(key)
105
+ if (wantsMarker && kept[kept.length - 1]?.text.trim() !== MANAGED_MARKER) {
106
+ kept.push({ text: MANAGED_MARKER, managed: false })
107
+ }
108
+ if (!wantsMarker && line.managed && kept[kept.length - 1]?.text.trim() === MANAGED_MARKER) {
109
+ kept.pop()
110
+ }
111
+ kept.push({ key, text: `${key}=${updates[key]}`, managed: wantsMarker })
112
+ written.add(key)
113
+ continue
114
+ }
115
+
116
+ kept.push(line)
117
+ }
118
+
119
+ const appended: string[] = []
120
+ for (const [key, value] of Object.entries(updates)) {
121
+ if (written.has(key)) continue
122
+ if (managed.has(key)) appended.push(MANAGED_MARKER)
123
+ appended.push(`${key}=${value}`)
124
+ }
125
+
126
+ const body = [...kept.map((l) => l.text), ...appended].join("\n")
127
+ writeFileSync(envPath, `${body.replace(/\n+$/, "")}\n`, "utf8")
128
+ }
129
+
130
+ /**
131
+ * Parse a project `.env` into key/value pairs.
132
+ *
133
+ * Deliberately small: `KEY=value`, `#` comments, one optional layer of surrounding quotes. No
134
+ * interpolation and no `export` handling, because Compose's own parser does not do those either and
135
+ * a `.env` that means two different things depending on who reads it is worse than a limited one.
136
+ */
137
+ export function readEnvFile(cwd: string): Record<string, string> {
138
+ const envPath = join(cwd, ".env")
139
+ if (!existsSync(envPath)) return {}
140
+
141
+ const out: Record<string, string> = {}
142
+ for (const line of readFileSync(envPath, "utf8").split(/\r?\n/)) {
143
+ const trimmed = line.trim()
144
+ if (trimmed.length === 0 || trimmed.startsWith("#")) continue
145
+ const eq = trimmed.indexOf("=")
146
+ if (eq <= 0) continue
147
+ const key = trimmed.slice(0, eq).trim()
148
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue
149
+ let value = trimmed.slice(eq + 1).trim()
150
+ if (
151
+ value.length >= 2 &&
152
+ ((value.startsWith('"') && value.endsWith('"')) ||
153
+ (value.startsWith("'") && value.endsWith("'")))
154
+ ) {
155
+ value = value.slice(1, -1)
156
+ }
157
+ out[key] = value
158
+ }
159
+ return out
21
160
  }
22
161
 
23
162
  export function readEnvValue(cwd: string, key: string, fallback: string): string {
@@ -29,6 +168,13 @@ export function readEnvValue(cwd: string, key: string, fallback: string): string
29
168
  return fallback
30
169
  }
31
170
 
171
+ /** Whether `.env` carries a non-empty value for `key`. Distinguishes absent from defaulted. */
172
+ export function hasEnvValue(cwd: string, key: string): boolean {
173
+ const envPath = join(cwd, ".env")
174
+ if (!existsSync(envPath)) return false
175
+ return new RegExp(`^${key}=(.+)$`, "m").test(readFileSync(envPath, "utf8"))
176
+ }
177
+
32
178
  export function readEnvInt(cwd: string, key: string): number | null {
33
179
  const raw = readEnvValue(cwd, key, "")
34
180
  if (!raw) return null