@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +96 -85
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/commands/admin.d.ts +28 -1
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +273 -111
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +2 -0
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +70 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/push.js +3 -3
- package/dist/commands/push.js.map +1 -1
- package/dist/compose-rename.d.ts +10 -0
- package/dist/compose-rename.d.ts.map +1 -0
- package/dist/compose-rename.js +67 -0
- package/dist/compose-rename.js.map +1 -0
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +34 -50
- package/dist/dev-compose.js.map +1 -1
- package/dist/dev-ports.d.ts +27 -0
- package/dist/dev-ports.d.ts.map +1 -0
- package/dist/dev-ports.js +171 -0
- package/dist/dev-ports.js.map +1 -0
- package/dist/dev-session-lock.d.ts +25 -0
- package/dist/dev-session-lock.d.ts.map +1 -0
- package/dist/dev-session-lock.js +81 -0
- package/dist/dev-session-lock.js.map +1 -0
- package/dist/dev-shutdown.d.ts +18 -2
- package/dist/dev-shutdown.d.ts.map +1 -1
- package/dist/dev-shutdown.js +69 -5
- package/dist/dev-shutdown.js.map +1 -1
- package/dist/env-file.d.ts +5 -0
- package/dist/env-file.d.ts.map +1 -0
- package/dist/env-file.js +33 -0
- package/dist/env-file.js.map +1 -0
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +2 -1
- package/dist/self-host-compose.js.map +1 -1
- package/package.json +3 -1
- package/src/commands/admin.ts +361 -136
- package/src/commands/dev.ts +3 -0
- package/src/commands/init.ts +93 -1
- package/src/commands/push.ts +3 -3
- package/src/compose-rename.ts +76 -0
- package/src/dev-compose.ts +44 -50
- package/src/dev-ports.ts +212 -0
- package/src/dev-session-lock.ts +101 -0
- package/src/dev-shutdown.ts +98 -5
- package/src/env-file.ts +37 -0
- package/src/self-host-compose.ts +2 -1
- package/tests/admin-ensure.test.ts +59 -0
- package/tests/dev-ports.test.ts +41 -0
- package/tests/dev-session-lock.test.ts +54 -0
- package/tests/dev-ui.test.ts +24 -1
- package/tsconfig.tsbuildinfo +1 -1
package/src/commands/init.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/commands/push.ts
CHANGED
|
@@ -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 {
|
|
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 (
|
|
149
|
-
await
|
|
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
|
+
}
|
package/src/dev-compose.ts
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|
package/src/dev-ports.ts
ADDED
|
@@ -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
|
+
}
|