@supatype/cli 0.1.0 → 0.1.1

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 (59) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +96 -85
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/commands/admin.d.ts +28 -1
  5. package/dist/commands/admin.d.ts.map +1 -1
  6. package/dist/commands/admin.js +273 -111
  7. package/dist/commands/admin.js.map +1 -1
  8. package/dist/commands/dev.d.ts.map +1 -1
  9. package/dist/commands/dev.js +2 -0
  10. package/dist/commands/dev.js.map +1 -1
  11. package/dist/commands/init.d.ts +5 -0
  12. package/dist/commands/init.d.ts.map +1 -1
  13. package/dist/commands/init.js +70 -1
  14. package/dist/commands/init.js.map +1 -1
  15. package/dist/commands/push.js +3 -3
  16. package/dist/commands/push.js.map +1 -1
  17. package/dist/compose-rename.d.ts +10 -0
  18. package/dist/compose-rename.d.ts.map +1 -0
  19. package/dist/compose-rename.js +67 -0
  20. package/dist/compose-rename.js.map +1 -0
  21. package/dist/dev-compose.d.ts.map +1 -1
  22. package/dist/dev-compose.js +34 -50
  23. package/dist/dev-compose.js.map +1 -1
  24. package/dist/dev-ports.d.ts +27 -0
  25. package/dist/dev-ports.d.ts.map +1 -0
  26. package/dist/dev-ports.js +171 -0
  27. package/dist/dev-ports.js.map +1 -0
  28. package/dist/dev-session-lock.d.ts +25 -0
  29. package/dist/dev-session-lock.d.ts.map +1 -0
  30. package/dist/dev-session-lock.js +81 -0
  31. package/dist/dev-session-lock.js.map +1 -0
  32. package/dist/dev-shutdown.d.ts +18 -2
  33. package/dist/dev-shutdown.d.ts.map +1 -1
  34. package/dist/dev-shutdown.js +69 -5
  35. package/dist/dev-shutdown.js.map +1 -1
  36. package/dist/env-file.d.ts +5 -0
  37. package/dist/env-file.d.ts.map +1 -0
  38. package/dist/env-file.js +33 -0
  39. package/dist/env-file.js.map +1 -0
  40. package/dist/self-host-compose.d.ts.map +1 -1
  41. package/dist/self-host-compose.js +2 -1
  42. package/dist/self-host-compose.js.map +1 -1
  43. package/package.json +3 -1
  44. package/src/commands/admin.ts +361 -136
  45. package/src/commands/dev.ts +3 -0
  46. package/src/commands/init.ts +93 -1
  47. package/src/commands/push.ts +3 -3
  48. package/src/compose-rename.ts +76 -0
  49. package/src/dev-compose.ts +44 -50
  50. package/src/dev-ports.ts +212 -0
  51. package/src/dev-session-lock.ts +101 -0
  52. package/src/dev-shutdown.ts +98 -5
  53. package/src/env-file.ts +37 -0
  54. package/src/self-host-compose.ts +2 -1
  55. package/tests/admin-ensure.test.ts +59 -0
  56. package/tests/dev-ports.test.ts +41 -0
  57. package/tests/dev-session-lock.test.ts +54 -0
  58. package/tests/dev-ui.test.ts +24 -1
  59. package/tsconfig.tsbuildinfo +1 -1
@@ -52,6 +52,8 @@ export interface ScaffoldOptions {
52
52
  projectName: string
53
53
  /** Local development runtime (docker recommended). */
54
54
  provider: "docker" | "native"
55
+ /** Host Kong port when provider is docker (unique per project). */
56
+ kongPort?: number
55
57
  productionTarget: ProductionTarget
56
58
  domain?: string
57
59
  /** ACME contact email for Let's Encrypt HTTPS (self-host + domain). */
@@ -64,6 +66,9 @@ export interface ScaffoldOptions {
64
66
  /** Object storage when deployed to production. */
65
67
  storageProduction: "local" | "s3"
66
68
  helloFunction: boolean
69
+ /** When set, written to .env for first dev/push to create the admin panel user. */
70
+ adminEmail?: string
71
+ adminPassword?: string
67
72
  }
68
73
 
69
74
  type StorageProvider = ScaffoldOptions["storageLocal"]
@@ -126,6 +131,9 @@ interface InitCliOptions {
126
131
  defaults?: boolean
127
132
  install: boolean
128
133
  keys: boolean
134
+ adminEmail?: string
135
+ adminPassword?: string
136
+ admin?: boolean
129
137
  }
130
138
 
131
139
  export function registerInit(program: Command): void {
@@ -140,6 +148,9 @@ export function registerInit(program: Command): void {
140
148
  .option("-y, --defaults", "Skip all prompts and use sensible defaults")
141
149
  .option("--no-install", "Do not run the package manager install step")
142
150
  .option("--no-keys", "Do not generate ANON_KEY / SERVICE_ROLE_KEY")
151
+ .option("--admin-email <email>", "First admin panel user email (written to .env)")
152
+ .option("--admin-password <password>", "First admin panel user password (written to .env)")
153
+ .option("--no-admin", "Do not configure a first admin user")
143
154
  .action(async (name: string | undefined, opts: InitCliOptions) => {
144
155
  const dir = name ? resolve(process.cwd(), name) : process.cwd()
145
156
 
@@ -164,6 +175,23 @@ export function registerInit(program: Command): void {
164
175
  install: true,
165
176
  generateKeys: true,
166
177
  }
178
+ if (result.provider === "docker") {
179
+ const { findNextFreePort } = await import("../dev-ports.js")
180
+ const { COMPOSE_DEV_KONG_PORT } = await import("../project-config.js")
181
+ result.kongPort = await findNextFreePort(COMPOSE_DEV_KONG_PORT)
182
+ }
183
+ if (!opts.admin && opts.adminEmail && opts.adminPassword) {
184
+ result.adminEmail = opts.adminEmail
185
+ result.adminPassword = opts.adminPassword
186
+ }
187
+ }
188
+
189
+ if (opts.admin === false) {
190
+ delete result.adminEmail
191
+ delete result.adminPassword
192
+ } else if (opts.adminEmail && opts.adminPassword) {
193
+ result.adminEmail = opts.adminEmail
194
+ result.adminPassword = opts.adminPassword
167
195
  }
168
196
 
169
197
  // CLI flags override wizard / default action choices.
@@ -262,6 +290,12 @@ async function runWizard(
262
290
  }),
263
291
  )
264
292
 
293
+ let kongPort: number | undefined
294
+ if (provider === "docker") {
295
+ const { promptKongPortChoice } = await import("../dev-ports.js")
296
+ kongPort = await promptKongPortChoice()
297
+ }
298
+
265
299
  const schemaPath = ensureNotCancelled(
266
300
  await p.text({
267
301
  message: "Where should your schema live?",
@@ -322,11 +356,43 @@ async function runWizard(
322
356
  }),
323
357
  )
324
358
 
359
+ let adminEmail: string | undefined
360
+ let adminPassword: string | undefined
361
+ const createAdmin = ensureNotCancelled(
362
+ await p.confirm({
363
+ message: "Create an admin user for /admin?",
364
+ initialValue: true,
365
+ }),
366
+ )
367
+ if (createAdmin) {
368
+ adminEmail =
369
+ ensureNotCancelled(
370
+ await p.text({
371
+ message: "Admin email",
372
+ placeholder: "you@example.com",
373
+ defaultValue: "",
374
+ }),
375
+ ).trim() || undefined
376
+ if (adminEmail) {
377
+ adminPassword =
378
+ ensureNotCancelled(
379
+ await p.text({
380
+ message: "Admin password (min 8 chars)",
381
+ defaultValue: "",
382
+ }),
383
+ ).trim() || undefined
384
+ if (adminPassword && adminPassword.length < 8) {
385
+ adminPassword = undefined
386
+ }
387
+ }
388
+ }
389
+
325
390
  p.outro("Setting up your project...")
326
391
 
327
392
  return {
328
393
  projectName,
329
394
  provider,
395
+ ...(kongPort !== undefined ? { kongPort } : {}),
330
396
  productionTarget,
331
397
  ...(domain !== undefined ? { domain } : {}),
332
398
  ...(tlsEmail !== undefined ? { tlsEmail } : {}),
@@ -339,6 +405,7 @@ async function runWizard(
339
405
  packageManager,
340
406
  install,
341
407
  generateKeys,
408
+ ...(adminEmail && adminPassword ? { adminEmail, adminPassword } : {}),
342
409
  }
343
410
  }
344
411
 
@@ -910,9 +977,27 @@ SERVICE_ROLE_KEY=`)
910
977
  sections.push(`# Site URL (used by GoTrue for email redirects)
911
978
  SITE_URL=http://localhost:3000`)
912
979
 
980
+ if (opts.provider === "docker" && opts.kongPort !== undefined) {
981
+ const apiUrl = `http://localhost:${opts.kongPort}`
982
+ sections.push(
983
+ `# Local API gateway (Kong) — unique per project so multiple stacks can run concurrently
984
+ SUPATYPE_KONG_PORT=${opts.kongPort}
985
+ PUBLIC_SUPATYPE_URL=${apiUrl}
986
+ API_EXTERNAL_URL=${apiUrl}`,
987
+ )
988
+ }
989
+
913
990
  sections.push(emailEnvSection(opts.email, opts.projectName))
914
991
  sections.push(storageEnvSections(opts.storageLocal, opts.storageProduction))
915
992
 
993
+ if (opts.adminEmail && opts.adminPassword) {
994
+ sections.push(
995
+ `# First admin for /admin — consumed on first supatype dev or push (password removed after use)
996
+ SUPATYPE_ADMIN_EMAIL=${opts.adminEmail}
997
+ SUPATYPE_ADMIN_PASSWORD=${opts.adminPassword}`,
998
+ )
999
+ }
1000
+
916
1001
  sections.push(
917
1002
  `# Self-host compose uses the same DATABASE_URL when Postgres is published on localhost:5432`,
918
1003
  )
@@ -1106,8 +1191,15 @@ function printNextSteps(args: {
1106
1191
  if (name) steps.push(`cd ${name}`)
1107
1192
  if (!installed) steps.push(`${result.packageManager} install`)
1108
1193
  if (!keysGenerated) steps.push("supatype keys")
1109
- steps.push("supatype dev # Docker Compose stack (Kong :18473)")
1194
+ const kongHint =
1195
+ result.provider === "docker" && result.kongPort !== undefined
1196
+ ? `supatype dev # Docker Compose stack (Kong :${result.kongPort})`
1197
+ : "supatype dev # Docker Compose stack (Kong :18473)"
1198
+ steps.push(kongHint)
1110
1199
  steps.push("supatype push # apply schema + generate types")
1200
+ if (result.adminEmail) {
1201
+ steps.push(" # first admin user is created on dev or push")
1202
+ }
1111
1203
  if (result.helloFunction) {
1112
1204
  steps.push("supatype functions serve # run edge functions locally")
1113
1205
  }
@@ -8,7 +8,7 @@ import { printDiffOperations, printDiffWarnings } from "../diff-output.js"
8
8
  import { signJwt } from "../jwt.js"
9
9
  import { provisionBucketsFromAst } from "../storage-provision.js"
10
10
  import type { ExtractedSchemaAstV2 } from "../schema-ast-v2.js"
11
- import { promptFirstAdminUser } from "./admin.js"
11
+ import { ensureFirstAdminUser } from "./admin.js"
12
12
  import { withAdminRoles } from "../studio-admin-roles.js"
13
13
  import { restoreSystemRelationTargets } from "../restore-system-relation-targets.js"
14
14
  import type { SupatypeProjectConfig } from "../project-config.js"
@@ -145,8 +145,8 @@ async function pushViaTarget(
145
145
 
146
146
  if (target.mode === "direct" || target.mode === "local") {
147
147
  await writeLocalAdminConfig(ast, config)
148
- if (ops.length > 0 && target.databaseUrl) {
149
- await promptFirstAdminUser(target.databaseUrl)
148
+ if (target.databaseUrl) {
149
+ await ensureFirstAdminUser(target.databaseUrl, { cwd })
150
150
  }
151
151
  await generateTypesLocal(ast, config)
152
152
  await provisionLocalStorage(ast, config)
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Detect and clean up Docker Compose state when `project.name` changes.
3
+ */
4
+
5
+ import { existsSync, readFileSync } from "node:fs"
6
+ import { join } from "node:path"
7
+ import * as p from "@clack/prompts"
8
+ import { loadLocalEnvironment } from "./link.js"
9
+ import { composeStackHasContainers } from "./dev-session-lock.js"
10
+ import { composeProjectName, runDockerCompose, type SelfHostComposePaths } from "./self-host-compose.js"
11
+ import { isInteractive } from "./ui/interactive.js"
12
+ import { warn } from "./ui/messages.js"
13
+
14
+ /**
15
+ * When `project.name` changes, the compose project slug changes too (`supatype-{name}`).
16
+ * Offer to stop containers from the previous slug so volumes/ports do not linger.
17
+ */
18
+ export async function handleComposeProjectRename(
19
+ cwd: string,
20
+ currentProjectName: string,
21
+ paths: SelfHostComposePaths,
22
+ ): Promise<void> {
23
+ const local = loadLocalEnvironment(cwd)
24
+ const previousRef = local?.projectRef?.trim()
25
+ if (!previousRef || previousRef === currentProjectName) return
26
+
27
+ const previousCompose = composeProjectName(previousRef)
28
+ const currentCompose = composeProjectName(currentProjectName)
29
+ if (previousCompose === currentCompose) return
30
+ if (!composeStackHasContainers(previousCompose)) return
31
+
32
+ const message =
33
+ `Project renamed from "${previousRef}" to "${currentProjectName}".\n` +
34
+ `Docker stack "${previousCompose}" may still be running.`
35
+
36
+ if (!isInteractive()) {
37
+ warn(message)
38
+ warn(`Stop it manually: docker compose -p ${previousCompose} down`)
39
+ return
40
+ }
41
+
42
+ const stopOld = await p.confirm({
43
+ message: `${message}\n\nStop the old Docker stack now?`,
44
+ initialValue: true,
45
+ })
46
+
47
+ if (p.isCancel(stopOld) || !stopOld) {
48
+ warn(`Leaving "${previousCompose}" running. Stop it with: docker compose -p ${previousCompose} down`)
49
+ return
50
+ }
51
+
52
+ const status = runDockerCompose(paths.composePath, ["down"], cwd, previousCompose, { quiet: true })
53
+ if (status === 0) {
54
+ p.log.success(`Stopped old stack "${previousCompose}".`)
55
+ warnIfHardcodedComposeScripts(cwd, previousCompose)
56
+ } else {
57
+ warn(`Could not stop "${previousCompose}" (exit ${status}). Try: docker compose -p ${previousCompose} down`)
58
+ }
59
+ }
60
+
61
+ function warnIfHardcodedComposeScripts(cwd: string, oldComposeProject: string): void {
62
+ const pkgPath = join(cwd, "package.json")
63
+ if (!existsSync(pkgPath)) return
64
+ try {
65
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { scripts?: Record<string, string> }
66
+ const scripts = Object.values(pkg.scripts ?? {}).join("\n")
67
+ if (scripts.includes(`-p ${oldComposeProject}`)) {
68
+ warn(
69
+ `package.json scripts still reference compose project "${oldComposeProject}". ` +
70
+ "Update them if you renamed project.name in supatype.config.ts.",
71
+ )
72
+ }
73
+ } catch {
74
+ // ignore invalid package.json
75
+ }
76
+ }
@@ -2,7 +2,7 @@
2
2
  * `supatype dev` when `provider: docker` — full self-host Compose stack (Kong gateway).
3
3
  */
4
4
 
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
5
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
6
6
  import { homedir } from "node:os"
7
7
  import { dirname, join, resolve } from "node:path"
8
8
  import { spawnSync } from "node:child_process"
@@ -17,7 +17,10 @@ import {
17
17
  type SupatypeProjectConfig,
18
18
  } from "./project-config.js"
19
19
  import { signJwt } from "./jwt.js"
20
- import { isPortInUse } from "./postgres-ctl.js"
20
+ import { ensureDevDbPort, ensureKongPort } from "./dev-ports.js"
21
+ import { handleComposeProjectRename } from "./compose-rename.js"
22
+ import { recoverStaleDevSession, writeDevSessionLock } from "./dev-session-lock.js"
23
+ import { readEnvValue, upsertEnvFile } from "./env-file.js"
21
24
  import {
22
25
  COMPOSE_PINNED_IMAGE_ENV_KEYS,
23
26
  composeDockerImageEnv,
@@ -46,6 +49,7 @@ import { withAdminRoles } from "./studio-admin-roles.js"
46
49
  import { restoreSystemRelationTargets } from "./restore-system-relation-targets.js"
47
50
  import { provisionBucketsFromAst } from "./storage-provision.js"
48
51
  import type { ExtractedSchemaAstV2 } from "./schema-ast-v2.js"
52
+ import { ensureFirstAdminUserForProject } from "./commands/admin.js"
49
53
 
50
54
  const LOCAL_JWT_SECRET = "super-secret-jwt-token-with-at-least-32-characters-long"
51
55
 
@@ -70,30 +74,12 @@ export function composeDbUrl(): string {
70
74
 
71
75
  /**
72
76
  * Resolve the host Kong port for this project. Persisted in `.env` as
73
- * SUPATYPE_KONG_PORT so re-runs are stable; on first run it picks the default
74
- * (18473) or the next free port, so multiple projects can run concurrently.
77
+ * SUPATYPE_KONG_PORT; prompts when the configured port is already taken.
75
78
  */
76
79
  async function resolveDevDbPort(cwd: string): Promise<number> {
77
- const envPath = join(cwd, ".env")
78
- if (existsSync(envPath)) {
79
- const m = readFileSync(envPath, "utf8").match(/^SUPATYPE_DEV_DB_PORT=(\d+)/m)
80
- if (m && m[1]) return Number(m[1])
81
- }
82
- let port = COMPOSE_DEV_DB_PORT
83
- while (await isPortInUse(port)) port++
84
- return port
80
+ return ensureDevDbPort(cwd)
85
81
  }
86
82
 
87
- function readEnvValue(cwd: string, key: string, fallback: string): string {
88
- const envPath = join(cwd, ".env")
89
- if (existsSync(envPath)) {
90
- const m = readFileSync(envPath, "utf8").match(new RegExp(`^${key}=(.+)$`, "m"))
91
- if (m?.[1]) return m[1].trim()
92
- }
93
- return fallback
94
- }
95
-
96
- /** Postgres DSN for compose db when published to the host (local engine push). */
97
83
  function hostComposeDbUrl(cwd: string): string {
98
84
  const port = readEnvValue(cwd, "SUPATYPE_DEV_DB_PORT", String(COMPOSE_DEV_DB_PORT))
99
85
  const user = readEnvValue(cwd, "POSTGRES_USER", "supatype_admin")
@@ -175,36 +161,10 @@ export async function resolveHostEngineDatabaseUrl(
175
161
  }
176
162
 
177
163
  async function resolveKongPort(cwd: string): Promise<number> {
178
- const envPath = join(cwd, ".env")
179
- if (existsSync(envPath)) {
180
- const m = readFileSync(envPath, "utf8").match(/^SUPATYPE_KONG_PORT=(\d+)/m)
181
- if (m && m[1]) return Number(m[1])
182
- }
183
- let port = COMPOSE_DEV_KONG_PORT
184
- while (await isPortInUse(port)) port++
185
- return port
164
+ return ensureKongPort(cwd, { context: "dev" })
186
165
  }
187
166
 
188
- function upsertEnvFile(
189
- cwd: string,
190
- updates: Record<string, string>,
191
- removeKeys: readonly string[] = [],
192
- ): void {
193
- const envPath = join(cwd, ".env")
194
- const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : ""
195
- const keys = new Set([...Object.keys(updates), ...removeKeys])
196
- const kept = existing
197
- .split("\n")
198
- .filter((line) => {
199
- const key = line.split("=")[0]?.trim()
200
- return key && line.includes("=") && !keys.has(key)
201
- })
202
- const merged = [...kept, ...Object.entries(updates).map(([key, value]) => `${key}=${value}`)]
203
- writeFileSync(envPath, `${merged.join("\n").trimEnd()}\n`, "utf8")
204
- }
205
-
206
- /** Keep compose + Studio on the same freshly signed dev JWTs; sync optional image pins from config. */
207
- function ensureDevComposeEnv(
167
+ function upsertDevComposeEnv(
208
168
  cwd: string,
209
169
  config: SupatypeProjectConfig,
210
170
  anonKey: string,
@@ -222,6 +182,7 @@ function ensureDevComposeEnv(
222
182
  ANON_KEY: anonKey,
223
183
  SERVICE_ROLE_KEY: serviceRoleKey,
224
184
  PUBLIC_SUPATYPE_ANON_KEY: anonKey,
185
+ VITE_SUPATYPE_ANON_KEY: anonKey,
225
186
  PUBLIC_SUPATYPE_URL: apiUrl,
226
187
  SUPATYPE_KONG_PORT: String(kongPort),
227
188
  API_EXTERNAL_URL: apiUrl,
@@ -238,6 +199,18 @@ function ensureDevComposeEnv(
238
199
  upsertEnvFile(cwd, updates, removeImageKeys)
239
200
  }
240
201
 
202
+ /** Keep compose + Studio on the same freshly signed dev JWTs; sync optional image pins from config. */
203
+ function ensureDevComposeEnv(
204
+ cwd: string,
205
+ config: SupatypeProjectConfig,
206
+ anonKey: string,
207
+ serviceRoleKey: string,
208
+ kongPort: number,
209
+ devDbPort?: number,
210
+ ): void {
211
+ upsertDevComposeEnv(cwd, config, anonKey, serviceRoleKey, kongPort, devDbPort)
212
+ }
213
+
241
214
  async function waitComposeHealthy(paths: SelfHostComposePaths, cwd: string, maxMs: number, composeProject: string): Promise<void> {
242
215
  const composeDir = dirname(paths.composePath)
243
216
  const envFile = join(cwd, ".env")
@@ -670,6 +643,10 @@ export async function pushSchemaDocker(cwd: string, config: SupatypeProjectConfi
670
643
  await waitKongReady(kongPort, 120)
671
644
  await provisionDockerStorageBuckets(ast, kongPort, serviceRoleKey)
672
645
 
646
+ await ensureFirstAdminUserForProject(cwd, config, {
647
+ compose: { project, composePath: paths.composePath },
648
+ })
649
+
673
650
  console.log("[supatype] Schema pushed.")
674
651
  }
675
652
 
@@ -693,6 +670,8 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
693
670
 
694
671
  console.log(`[supatype] provider: docker — starting self-host Compose stack (project ${project}, gateway :${kongPort})...`)
695
672
  const paths = writeSelfHostCompose(cwd, config, { devLocal: true })
673
+ await recoverStaleDevSession(cwd)
674
+ await handleComposeProjectRename(cwd, config.project.name, paths)
696
675
  const devBrand = { intro: "Local development" }
697
676
 
698
677
  const upStatus = runDockerCompose(paths.composePath, ["up", "-d"], cwd, project, {
@@ -712,6 +691,10 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
712
691
  console.error("[supatype] Initial schema push failed:", (e as Error).message),
713
692
  )
714
693
 
694
+ await ensureFirstAdminUserForProject(cwd, config, {
695
+ compose: { project, composePath: paths.composePath },
696
+ })
697
+
715
698
  console.log("[supatype] Waiting for API gateway...")
716
699
  await waitKongReady(kongPort, 120)
717
700
 
@@ -724,6 +707,14 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
724
707
  provider: "docker",
725
708
  })
726
709
 
710
+ writeDevSessionLock(cwd, {
711
+ composeProject: project,
712
+ projectRef: config.project.name,
713
+ composePath: paths.composePath,
714
+ kongPort,
715
+ startedAt: new Date().toISOString(),
716
+ })
717
+
727
718
  const ast = loadSchemaAst(schemaPath, cwd)
728
719
  await provisionDockerStorageBuckets(ast, kongPort, serviceRoleKey)
729
720
 
@@ -785,6 +776,9 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
785
776
  } else {
786
777
  console.warn(`[supatype] Compose down exited with status ${downStatus}.`)
787
778
  }
779
+ }, {
780
+ cwd,
781
+ compose: { cwd, composePath: paths.composePath, composeProject: project },
788
782
  })
789
783
 
790
784
  if (opts.watch) {
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Kong / Postgres host port resolution for local Docker dev.
3
+ * Persists SUPATYPE_KONG_PORT in `.env` so re-runs are stable, but re-checks
4
+ * availability so multiple projects and port collisions are surfaced clearly.
5
+ */
6
+
7
+ import * as p from "@clack/prompts"
8
+ import { COMPOSE_DEV_KONG_PORT } from "./project-config.js"
9
+ import { isPortInUse } from "./postgres-ctl.js"
10
+ import { readEnvInt, upsertEnvFile } from "./env-file.js"
11
+ import { isInteractive } from "./ui/interactive.js"
12
+ import { fatalError } from "./ui/fatal.js"
13
+
14
+ const MIN_PORT = 1024
15
+ const MAX_PORT = 65535
16
+
17
+ export function isValidHostPort(port: number): boolean {
18
+ return Number.isInteger(port) && port >= MIN_PORT && port <= MAX_PORT
19
+ }
20
+
21
+ export function parseHostPortInput(raw: string): number | null {
22
+ const port = Number(raw.trim())
23
+ return isValidHostPort(port) ? port : null
24
+ }
25
+
26
+ /** Next free TCP port on 127.0.0.1 starting at `start`. */
27
+ export async function findNextFreePort(start: number): Promise<number> {
28
+ let port = Math.max(MIN_PORT, start)
29
+ while (port <= MAX_PORT && (await isPortInUse(port))) port++
30
+ if (port > MAX_PORT) {
31
+ throw new Error(`No free local port found in range ${start}–${MAX_PORT}.`)
32
+ }
33
+ return port
34
+ }
35
+
36
+ export function readPersistedKongPort(cwd: string): number | null {
37
+ return readEnvInt(cwd, "SUPATYPE_KONG_PORT")
38
+ }
39
+
40
+ function persistKongPort(cwd: string, port: number): void {
41
+ const apiUrl = `http://localhost:${port}`
42
+ upsertEnvFile(cwd, {
43
+ SUPATYPE_KONG_PORT: String(port),
44
+ PUBLIC_SUPATYPE_URL: apiUrl,
45
+ API_EXTERNAL_URL: apiUrl,
46
+ SITE_URL: apiUrl,
47
+ })
48
+ }
49
+
50
+ async function promptPortConflict(
51
+ cwd: string,
52
+ blockedPort: number,
53
+ reason: "in_use" | "init",
54
+ ): Promise<number> {
55
+ const port = await promptPortConflictWithoutPersist(blockedPort, reason)
56
+ persistKongPort(cwd, port)
57
+ return port
58
+ }
59
+
60
+ export interface EnsureKongPortOptions {
61
+ /** When false, fail fast instead of prompting (CI / scripts). */
62
+ interactive?: boolean
63
+ /** init wizard — slightly different copy */
64
+ context?: "dev" | "init"
65
+ }
66
+
67
+ /**
68
+ * Resolve the Kong host port for this project directory.
69
+ * - Uses `.env` when set and available.
70
+ * - Prompts (or errors) when the configured port is taken.
71
+ * - Auto-picks the next free port on first run when unset.
72
+ */
73
+ export async function ensureKongPort(
74
+ cwd: string,
75
+ opts: EnsureKongPortOptions = {},
76
+ ): Promise<number> {
77
+ const interactive = opts.interactive ?? isInteractive()
78
+ const context = opts.context ?? "dev"
79
+ const persisted = readPersistedKongPort(cwd)
80
+
81
+ if (persisted !== null) {
82
+ if (!(await isPortInUse(persisted))) return persisted
83
+
84
+ if (!interactive) {
85
+ fatalError(`Port ${persisted} is already in use (SUPATYPE_KONG_PORT in .env).`, [
86
+ "Stop the other service or set a different SUPATYPE_KONG_PORT.",
87
+ "Run `supatype dev` in a terminal to pick a new port interactively.",
88
+ ])
89
+ }
90
+
91
+ return promptPortConflict(cwd, persisted, "in_use")
92
+ }
93
+
94
+ const preferred = COMPOSE_DEV_KONG_PORT
95
+ const port =
96
+ (await isPortInUse(preferred))
97
+ ? interactive && context === "init"
98
+ ? await promptPortConflict(cwd, preferred, "init")
99
+ : await findNextFreePort(preferred)
100
+ : preferred
101
+
102
+ return port
103
+ }
104
+
105
+ /** Wizard / init — pick a Kong port without writing `.env` (scaffold writes it). */
106
+ export async function promptKongPortChoice(): Promise<number> {
107
+ const freeDefault = (await isPortInUse(COMPOSE_DEV_KONG_PORT))
108
+ ? await findNextFreePort(COMPOSE_DEV_KONG_PORT)
109
+ : COMPOSE_DEV_KONG_PORT
110
+
111
+ const value = await p.text({
112
+ message: "Local API gateway port (Kong)",
113
+ defaultValue: String(freeDefault),
114
+ placeholder: String(COMPOSE_DEV_KONG_PORT),
115
+ validate: (raw) => {
116
+ const port = parseHostPortInput(raw ?? "")
117
+ if (!port) return `Enter a port between ${MIN_PORT} and ${MAX_PORT}.`
118
+ return undefined
119
+ },
120
+ })
121
+
122
+ if (p.isCancel(value)) {
123
+ p.cancel("Cancelled.")
124
+ process.exit(0)
125
+ }
126
+
127
+ const port = parseHostPortInput(value)!
128
+ if (await isPortInUse(port)) {
129
+ return promptPortConflictWithoutPersist(port, "init")
130
+ }
131
+ return port
132
+ }
133
+
134
+ async function promptPortConflictWithoutPersist(
135
+ blockedPort: number,
136
+ reason: "in_use" | "init",
137
+ ): Promise<number> {
138
+ const suggested = await findNextFreePort(blockedPort + 1)
139
+ const headline =
140
+ reason === "init"
141
+ ? `Port ${blockedPort} is already in use on this machine.`
142
+ : `Port ${blockedPort} is in use — another Supatype project or service may already be bound to it.`
143
+
144
+ const choice = await p.select<"suggested" | "custom" | "cancel">({
145
+ message: headline,
146
+ options: [
147
+ {
148
+ value: "suggested",
149
+ label: `Use ${suggested} instead`,
150
+ hint: "next available port",
151
+ },
152
+ { value: "custom", label: "Enter a different port" },
153
+ { value: "cancel", label: "Cancel" },
154
+ ],
155
+ })
156
+
157
+ if (p.isCancel(choice) || choice === "cancel") {
158
+ p.cancel("Cancelled.")
159
+ process.exit(0)
160
+ }
161
+
162
+ if (choice === "suggested") return suggested
163
+
164
+ const custom = await p.text({
165
+ message: "Local API gateway port (Kong)",
166
+ defaultValue: String(suggested),
167
+ validate: (value) => {
168
+ const port = parseHostPortInput(value ?? "")
169
+ if (!port) return `Enter a port between ${MIN_PORT} and ${MAX_PORT}.`
170
+ return undefined
171
+ },
172
+ })
173
+
174
+ if (p.isCancel(custom)) {
175
+ p.cancel("Cancelled.")
176
+ process.exit(0)
177
+ }
178
+
179
+ const port = parseHostPortInput(custom)
180
+ if (!port) {
181
+ p.cancel("Invalid port.")
182
+ process.exit(1)
183
+ }
184
+
185
+ if (await isPortInUse(port)) {
186
+ fatalError(`Port ${port} is still in use.`, [
187
+ "Pick another port or stop the service using it.",
188
+ "Check Docker Desktop for other Supatype stacks.",
189
+ ])
190
+ }
191
+
192
+ return port
193
+ }
194
+
195
+ const COMPOSE_DEV_DB_PORT = 54329
196
+
197
+ export async function ensureDevDbPort(cwd: string): Promise<number> {
198
+ const persisted = readEnvInt(cwd, "SUPATYPE_DEV_DB_PORT")
199
+ if (persisted !== null) {
200
+ if (!(await isPortInUse(persisted))) return persisted
201
+ const next = await findNextFreePort(persisted + 1)
202
+ upsertEnvFile(cwd, {
203
+ SUPATYPE_DEV_DB_PORT: String(next),
204
+ DATABASE_URL: `postgresql://supatype_admin:postgres@localhost:${next}/supatype?sslmode=disable`,
205
+ })
206
+ return next
207
+ }
208
+
209
+ let port = COMPOSE_DEV_DB_PORT
210
+ while (await isPortInUse(port)) port++
211
+ return port
212
+ }