@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
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks an active `supatype dev` session so we can recover when the CLI exits
|
|
3
|
+
* without graceful shutdown (terminal closed, kill signal, etc.).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
|
7
|
+
import { join, resolve } from "node:path"
|
|
8
|
+
import { spawnSync } from "node:child_process"
|
|
9
|
+
import * as p from "@clack/prompts"
|
|
10
|
+
import { runDockerCompose } from "./self-host-compose.js"
|
|
11
|
+
import { isInteractive } from "./ui/interactive.js"
|
|
12
|
+
import { warn } from "./ui/messages.js"
|
|
13
|
+
|
|
14
|
+
const LOCK_VERSION = 1 as const
|
|
15
|
+
|
|
16
|
+
export interface DevSessionLock {
|
|
17
|
+
version: typeof LOCK_VERSION
|
|
18
|
+
composeProject: string
|
|
19
|
+
projectRef: string
|
|
20
|
+
composePath: string
|
|
21
|
+
kongPort: number
|
|
22
|
+
startedAt: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function devSessionLockPath(cwd: string): string {
|
|
26
|
+
return resolve(cwd, ".supatype/dev-session.json")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readDevSessionLock(cwd: string): DevSessionLock | null {
|
|
30
|
+
const path = devSessionLockPath(cwd)
|
|
31
|
+
if (!existsSync(path)) return null
|
|
32
|
+
try {
|
|
33
|
+
const data = JSON.parse(readFileSync(path, "utf8")) as DevSessionLock
|
|
34
|
+
if (data.version !== LOCK_VERSION) return null
|
|
35
|
+
return data
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function writeDevSessionLock(cwd: string, lock: Omit<DevSessionLock, "version">): void {
|
|
42
|
+
const dir = join(cwd, ".supatype")
|
|
43
|
+
mkdirSync(dir, { recursive: true })
|
|
44
|
+
const payload: DevSessionLock = { version: LOCK_VERSION, ...lock }
|
|
45
|
+
writeFileSync(devSessionLockPath(cwd), `${JSON.stringify(payload, null, 2)}\n`, "utf8")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function clearDevSessionLock(cwd: string): void {
|
|
49
|
+
const path = devSessionLockPath(cwd)
|
|
50
|
+
if (existsSync(path)) unlinkSync(path)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function composeStackHasContainers(composeProject: string): boolean {
|
|
54
|
+
const result = spawnSync(
|
|
55
|
+
"docker",
|
|
56
|
+
["ps", "-a", "--filter", `label=com.docker.compose.project=${composeProject}`, "--format", "{{.Names}}"],
|
|
57
|
+
{ encoding: "utf8", shell: process.platform === "win32" },
|
|
58
|
+
)
|
|
59
|
+
return Boolean(result.stdout?.trim())
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* When a previous dev session did not shut down cleanly, offer to stop its stack
|
|
64
|
+
* before starting a new one.
|
|
65
|
+
*/
|
|
66
|
+
export async function recoverStaleDevSession(cwd: string): Promise<void> {
|
|
67
|
+
const lock = readDevSessionLock(cwd)
|
|
68
|
+
if (!lock) return
|
|
69
|
+
if (!composeStackHasContainers(lock.composeProject)) {
|
|
70
|
+
clearDevSessionLock(cwd)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const message =
|
|
75
|
+
`Previous dev session for "${lock.projectRef}" may not have shut down cleanly ` +
|
|
76
|
+
`(stack "${lock.composeProject}" is still running).`
|
|
77
|
+
|
|
78
|
+
if (!isInteractive()) {
|
|
79
|
+
warn(message)
|
|
80
|
+
warn(`Stop it manually: docker compose -p ${lock.composeProject} down`)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const stop = await p.confirm({
|
|
85
|
+
message: `${message}\n\nStop the orphaned stack before starting?`,
|
|
86
|
+
initialValue: true,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
if (p.isCancel(stop) || !stop) {
|
|
90
|
+
warn(`Leaving "${lock.composeProject}" running.`)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const status = runDockerCompose(lock.composePath, ["down"], cwd, lock.composeProject, { quiet: true })
|
|
95
|
+
if (status === 0) {
|
|
96
|
+
clearDevSessionLock(cwd)
|
|
97
|
+
p.log.success(`Stopped orphaned stack "${lock.composeProject}".`)
|
|
98
|
+
} else {
|
|
99
|
+
warn(`Could not stop "${lock.composeProject}" (exit ${status}).`)
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/dev-shutdown.ts
CHANGED
|
@@ -1,24 +1,86 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Graceful shutdown for `supatype dev` —
|
|
2
|
+
* Graceful shutdown for `supatype dev` — SIGINT, TUI Ctrl+C, terminal close,
|
|
3
|
+
* and a synchronous compose-down fallback on process exit.
|
|
3
4
|
*/
|
|
4
5
|
|
|
6
|
+
import { existsSync } from "node:fs"
|
|
5
7
|
import { endDevSession } from "./dev-session.js"
|
|
8
|
+
import { clearDevSessionLock } from "./dev-session-lock.js"
|
|
9
|
+
import { runDockerCompose } from "./self-host-compose.js"
|
|
10
|
+
|
|
11
|
+
export interface DevComposeShutdownFallback {
|
|
12
|
+
cwd: string
|
|
13
|
+
composePath: string
|
|
14
|
+
composeProject: string
|
|
15
|
+
}
|
|
6
16
|
|
|
7
17
|
let shutdownWork: (() => Promise<void>) | null = null
|
|
18
|
+
let composeFallback: DevComposeShutdownFallback | null = null
|
|
19
|
+
let shutdownCwd: string | null = null
|
|
8
20
|
let shuttingDown = false
|
|
9
|
-
let
|
|
21
|
+
let shutdownCompleted = false
|
|
22
|
+
let forceQuitRequested = false
|
|
23
|
+
let hooksRegistered = false
|
|
10
24
|
|
|
11
25
|
function onSignal(): void {
|
|
12
26
|
void runDevShutdown()
|
|
13
27
|
}
|
|
14
28
|
|
|
29
|
+
function onStdinClose(): void {
|
|
30
|
+
if (!process.stdin.isTTY) return
|
|
31
|
+
void runDevShutdown()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function syncComposeDownFallback(): void {
|
|
35
|
+
if (shutdownCompleted || !composeFallback) return
|
|
36
|
+
try {
|
|
37
|
+
runDockerCompose(
|
|
38
|
+
composeFallback.composePath,
|
|
39
|
+
["down"],
|
|
40
|
+
composeFallback.cwd,
|
|
41
|
+
composeFallback.composeProject,
|
|
42
|
+
{ quiet: true },
|
|
43
|
+
)
|
|
44
|
+
} catch {
|
|
45
|
+
// best-effort — process is exiting
|
|
46
|
+
}
|
|
47
|
+
if (shutdownCwd) clearDevSessionLock(shutdownCwd)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function onProcessExit(): void {
|
|
51
|
+
syncComposeDownFallback()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RegisterDevShutdownOptions {
|
|
55
|
+
/** Sync `docker compose down` when async teardown cannot finish (terminal close, kill). */
|
|
56
|
+
compose?: DevComposeShutdownFallback
|
|
57
|
+
/** Project root — clears `.supatype/dev-session.json` after shutdown. */
|
|
58
|
+
cwd?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
15
61
|
/** Register async teardown (stop children, compose down, etc.). Call once per dev session. */
|
|
16
|
-
export function registerDevShutdown(
|
|
62
|
+
export function registerDevShutdown(
|
|
63
|
+
work: () => Promise<void>,
|
|
64
|
+
opts?: RegisterDevShutdownOptions,
|
|
65
|
+
): void {
|
|
17
66
|
shutdownWork = work
|
|
18
|
-
|
|
19
|
-
|
|
67
|
+
composeFallback = opts?.compose ?? null
|
|
68
|
+
shutdownCwd = opts?.cwd ?? opts?.compose?.cwd ?? null
|
|
69
|
+
|
|
70
|
+
if (hooksRegistered) return
|
|
71
|
+
hooksRegistered = true
|
|
72
|
+
|
|
20
73
|
process.on("SIGINT", onSignal)
|
|
21
74
|
process.on("SIGTERM", onSignal)
|
|
75
|
+
if (process.platform === "win32") {
|
|
76
|
+
process.on("SIGBREAK", onSignal)
|
|
77
|
+
}
|
|
78
|
+
process.on("exit", onProcessExit)
|
|
79
|
+
|
|
80
|
+
if (process.stdin.isTTY) {
|
|
81
|
+
process.stdin.on("end", onStdinClose)
|
|
82
|
+
process.stdin.on("close", onStdinClose)
|
|
83
|
+
}
|
|
22
84
|
}
|
|
23
85
|
|
|
24
86
|
/** TUI Ctrl+C — do not re-emit SIGINT (avoids double-fire on Windows raw mode). */
|
|
@@ -30,13 +92,32 @@ export function isDevShuttingDown(): boolean {
|
|
|
30
92
|
return shuttingDown
|
|
31
93
|
}
|
|
32
94
|
|
|
95
|
+
/** @internal Tests — reset module state between cases. */
|
|
96
|
+
export function resetDevShutdownForTests(): void {
|
|
97
|
+
shutdownWork = null
|
|
98
|
+
composeFallback = null
|
|
99
|
+
shutdownCwd = null
|
|
100
|
+
shuttingDown = false
|
|
101
|
+
shutdownCompleted = false
|
|
102
|
+
forceQuitRequested = false
|
|
103
|
+
hooksRegistered = false
|
|
104
|
+
}
|
|
105
|
+
|
|
33
106
|
async function runDevShutdown(): Promise<void> {
|
|
34
107
|
if (shuttingDown) {
|
|
108
|
+
if (!forceQuitRequested) {
|
|
109
|
+
forceQuitRequested = true
|
|
110
|
+
process.stderr.write(
|
|
111
|
+
"\n[supatype] Still shutting down (stopping Docker)… press Ctrl+C again to force quit.\n",
|
|
112
|
+
)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
35
115
|
try {
|
|
36
116
|
endDevSession()
|
|
37
117
|
} catch {
|
|
38
118
|
// best-effort terminal restore
|
|
39
119
|
}
|
|
120
|
+
process.stderr.write("\n[supatype] Forced quit — Docker containers may still be running.\n")
|
|
40
121
|
process.stdout.write("\n")
|
|
41
122
|
process.exit(130)
|
|
42
123
|
}
|
|
@@ -46,9 +127,21 @@ async function runDevShutdown(): Promise<void> {
|
|
|
46
127
|
endDevSession()
|
|
47
128
|
process.stdout.write("\n")
|
|
48
129
|
await shutdownWork?.()
|
|
130
|
+
shutdownCompleted = true
|
|
131
|
+
if (shutdownCwd) clearDevSessionLock(shutdownCwd)
|
|
49
132
|
process.exit(0)
|
|
50
133
|
} catch (err) {
|
|
51
134
|
process.stderr.write(`[supatype] Shutdown failed: ${(err as Error).message}\n`)
|
|
135
|
+
process.stderr.write(
|
|
136
|
+
"[supatype] Docker containers may still be running — try: supatype self-host compose down\n",
|
|
137
|
+
)
|
|
138
|
+
syncComposeDownFallback()
|
|
139
|
+
shutdownCompleted = true
|
|
52
140
|
process.exit(1)
|
|
53
141
|
}
|
|
54
142
|
}
|
|
143
|
+
|
|
144
|
+
/** Whether a compose fallback was registered (tests). */
|
|
145
|
+
export function hasComposeShutdownFallback(): boolean {
|
|
146
|
+
return composeFallback !== null && existsSync(composeFallback.composePath)
|
|
147
|
+
}
|
package/src/env-file.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
/** Merge key/value updates into a project `.env` without dropping unrelated lines. */
|
|
5
|
+
export function upsertEnvFile(
|
|
6
|
+
cwd: string,
|
|
7
|
+
updates: Record<string, string>,
|
|
8
|
+
removeKeys: readonly string[] = [],
|
|
9
|
+
): void {
|
|
10
|
+
const envPath = join(cwd, ".env")
|
|
11
|
+
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")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readEnvValue(cwd: string, key: string, fallback: string): string {
|
|
24
|
+
const envPath = join(cwd, ".env")
|
|
25
|
+
if (existsSync(envPath)) {
|
|
26
|
+
const m = readFileSync(envPath, "utf8").match(new RegExp(`^${key}=(.+)$`, "m"))
|
|
27
|
+
if (m?.[1]) return m[1].trim()
|
|
28
|
+
}
|
|
29
|
+
return fallback
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function readEnvInt(cwd: string, key: string): number | null {
|
|
33
|
+
const raw = readEnvValue(cwd, key, "")
|
|
34
|
+
if (!raw) return null
|
|
35
|
+
const port = Number(raw)
|
|
36
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null
|
|
37
|
+
}
|
package/src/self-host-compose.ts
CHANGED
|
@@ -593,7 +593,8 @@ export function runDockerCompose(
|
|
|
593
593
|
const env: NodeJS.ProcessEnv = options?.quiet
|
|
594
594
|
? { ...process.env, COMPOSE_PROGRESS: "quiet" }
|
|
595
595
|
: process.env
|
|
596
|
-
const
|
|
596
|
+
const stdio = options?.quiet ? "pipe" : "inherit"
|
|
597
|
+
const result = spawnSync("docker", composeArgs, { stdio, cwd: projectRoot, env })
|
|
597
598
|
return result.status ?? 1
|
|
598
599
|
}
|
|
599
600
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
ADMIN_EMAIL_ENV,
|
|
4
|
+
ADMIN_PASSWORD_ENV,
|
|
5
|
+
clearAdminSeedPassword,
|
|
6
|
+
hashPasswordForAuth,
|
|
7
|
+
} from "../src/commands/admin.js"
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
9
|
+
import { join } from "node:path"
|
|
10
|
+
import { tmpdir } from "node:os"
|
|
11
|
+
import { scaffold, defaultScaffoldOptions } from "../src/commands/init.js"
|
|
12
|
+
|
|
13
|
+
describe("hashPasswordForAuth", () => {
|
|
14
|
+
it("produces a bcrypt hash", async () => {
|
|
15
|
+
const hash = await hashPasswordForAuth("test-password-123")
|
|
16
|
+
expect(hash.startsWith("$2")).toBe(true)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe("clearAdminSeedPassword", () => {
|
|
21
|
+
it("removes SUPATYPE_ADMIN_PASSWORD from .env", () => {
|
|
22
|
+
const dir = join(tmpdir(), `supatype-admin-seed-${Date.now()}`)
|
|
23
|
+
mkdirSync(dir, { recursive: true })
|
|
24
|
+
writeFileSync(
|
|
25
|
+
join(dir, ".env"),
|
|
26
|
+
`${ADMIN_EMAIL_ENV}=admin@example.com\n${ADMIN_PASSWORD_ENV}=secret123\nJWT_SECRET=x\n`,
|
|
27
|
+
"utf8",
|
|
28
|
+
)
|
|
29
|
+
try {
|
|
30
|
+
clearAdminSeedPassword(dir)
|
|
31
|
+
const content = readFileSync(join(dir, ".env"), "utf8")
|
|
32
|
+
expect(content).toContain(`${ADMIN_EMAIL_ENV}=admin@example.com`)
|
|
33
|
+
expect(content).not.toContain(ADMIN_PASSWORD_ENV)
|
|
34
|
+
expect(content).toContain("JWT_SECRET=x")
|
|
35
|
+
} finally {
|
|
36
|
+
rmSync(dir, { recursive: true, force: true })
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe("init admin seed in .env", () => {
|
|
42
|
+
it("writes SUPATYPE_ADMIN_* when scaffold options include credentials", () => {
|
|
43
|
+
const dir = join(tmpdir(), `supatype-init-admin-${Date.now()}`)
|
|
44
|
+
mkdirSync(dir, { recursive: true })
|
|
45
|
+
try {
|
|
46
|
+
scaffold(dir, {
|
|
47
|
+
...defaultScaffoldOptions("seeded-app"),
|
|
48
|
+
adminEmail: "admin@example.com",
|
|
49
|
+
adminPassword: "password123",
|
|
50
|
+
})
|
|
51
|
+
const env = readFileSync(join(dir, ".env"), "utf8")
|
|
52
|
+
expect(env).toContain("SUPATYPE_ADMIN_EMAIL=admin@example.com")
|
|
53
|
+
expect(env).toContain("SUPATYPE_ADMIN_PASSWORD=password123")
|
|
54
|
+
expect(existsSync(join(dir, ".env"))).toBe(true)
|
|
55
|
+
} finally {
|
|
56
|
+
rmSync(dir, { recursive: true, force: true })
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import {
|
|
6
|
+
findNextFreePort,
|
|
7
|
+
isValidHostPort,
|
|
8
|
+
parseHostPortInput,
|
|
9
|
+
readPersistedKongPort,
|
|
10
|
+
} from "../src/dev-ports.js"
|
|
11
|
+
import { isPortInUse } from "../src/postgres-ctl.js"
|
|
12
|
+
|
|
13
|
+
vi.mock("../src/postgres-ctl.js", () => ({
|
|
14
|
+
isPortInUse: vi.fn(),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
const isPortInUseMock = vi.mocked(isPortInUse)
|
|
18
|
+
|
|
19
|
+
describe("dev-ports", () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
isPortInUseMock.mockReset()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("validates host ports", () => {
|
|
25
|
+
expect(isValidHostPort(18473)).toBe(true)
|
|
26
|
+
expect(isValidHostPort(80)).toBe(false)
|
|
27
|
+
expect(parseHostPortInput("18473")).toBe(18473)
|
|
28
|
+
expect(parseHostPortInput("nope")).toBeNull()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("reads persisted Kong port from .env", () => {
|
|
32
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-ports-"))
|
|
33
|
+
writeFileSync(join(dir, ".env"), "SUPATYPE_KONG_PORT=18474\n", "utf8")
|
|
34
|
+
expect(readPersistedKongPort(dir)).toBe(18474)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("findNextFreePort skips taken ports", async () => {
|
|
38
|
+
isPortInUseMock.mockImplementation(async (port) => port === 18473 || port === 18474)
|
|
39
|
+
await expect(findNextFreePort(18473)).resolves.toBe(18475)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
|
2
|
+
import { mkdtempSync, writeFileSync, existsSync, mkdirSync } from "node:fs"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import {
|
|
6
|
+
clearDevSessionLock,
|
|
7
|
+
devSessionLockPath,
|
|
8
|
+
readDevSessionLock,
|
|
9
|
+
writeDevSessionLock,
|
|
10
|
+
} from "../src/dev-session-lock.js"
|
|
11
|
+
|
|
12
|
+
describe("dev-session-lock", () => {
|
|
13
|
+
let dir: string
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
dir = mkdtempSync(join(tmpdir(), "supatype-lock-"))
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
clearDevSessionLock(dir)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("writes and reads a lock file", () => {
|
|
24
|
+
writeDevSessionLock(dir, {
|
|
25
|
+
composeProject: "supatype-demo",
|
|
26
|
+
projectRef: "demo",
|
|
27
|
+
composePath: join(dir, ".supatype/self-host/docker-compose.yml"),
|
|
28
|
+
kongPort: 18473,
|
|
29
|
+
startedAt: "2026-01-01T00:00:00.000Z",
|
|
30
|
+
})
|
|
31
|
+
expect(existsSync(devSessionLockPath(dir))).toBe(true)
|
|
32
|
+
const lock = readDevSessionLock(dir)
|
|
33
|
+
expect(lock?.composeProject).toBe("supatype-demo")
|
|
34
|
+
expect(lock?.kongPort).toBe(18473)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("clears the lock file", () => {
|
|
38
|
+
mkdirSync(join(dir, ".supatype"), { recursive: true })
|
|
39
|
+
writeFileSync(
|
|
40
|
+
devSessionLockPath(dir),
|
|
41
|
+
JSON.stringify({
|
|
42
|
+
version: 1,
|
|
43
|
+
composeProject: "supatype-demo",
|
|
44
|
+
projectRef: "demo",
|
|
45
|
+
composePath: "x",
|
|
46
|
+
kongPort: 1,
|
|
47
|
+
startedAt: "t",
|
|
48
|
+
}),
|
|
49
|
+
"utf8",
|
|
50
|
+
)
|
|
51
|
+
clearDevSessionLock(dir)
|
|
52
|
+
expect(existsSync(devSessionLockPath(dir))).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
})
|
package/tests/dev-ui.test.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
2
5
|
import { DevLogBus } from "../src/dev-log-bus.js"
|
|
3
6
|
import { filterDevSubprocessLine, formatConsoleArgs, stripAnsi } from "../src/dev-log-filter.js"
|
|
4
7
|
import { appendDevTaskLog, beginDevSession, endDevSession, resolveDevUiMode } from "../src/dev-session.js"
|
|
@@ -104,9 +107,11 @@ describe("dev-task-colors", () => {
|
|
|
104
107
|
|
|
105
108
|
describe("dev-shutdown", () => {
|
|
106
109
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as typeof process.exit)
|
|
110
|
+
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true)
|
|
107
111
|
|
|
108
112
|
beforeEach(() => {
|
|
109
113
|
exitSpy.mockClear()
|
|
114
|
+
stderrSpy.mockClear()
|
|
110
115
|
})
|
|
111
116
|
|
|
112
117
|
afterEach(() => {
|
|
@@ -125,7 +130,7 @@ describe("dev-shutdown", () => {
|
|
|
125
130
|
expect(ran).toBe(true)
|
|
126
131
|
})
|
|
127
132
|
|
|
128
|
-
it("force-exits
|
|
133
|
+
it("warns on second interrupt then force-exits on third", async () => {
|
|
129
134
|
vi.resetModules()
|
|
130
135
|
const { registerDevShutdown, requestDevShutdown } = await import("../src/dev-shutdown.js")
|
|
131
136
|
registerDevShutdown(async () => {
|
|
@@ -134,6 +139,24 @@ describe("dev-shutdown", () => {
|
|
|
134
139
|
requestDevShutdown()
|
|
135
140
|
await vi.waitFor(() => expect(exitSpy).toHaveBeenCalledTimes(0))
|
|
136
141
|
requestDevShutdown()
|
|
142
|
+
expect(stderrSpy).toHaveBeenCalled()
|
|
143
|
+
expect(exitSpy).toHaveBeenCalledTimes(0)
|
|
144
|
+
requestDevShutdown()
|
|
137
145
|
expect(exitSpy).toHaveBeenCalledWith(130)
|
|
138
146
|
})
|
|
147
|
+
|
|
148
|
+
it("registers compose fallback for sync exit cleanup", async () => {
|
|
149
|
+
vi.resetModules()
|
|
150
|
+
const { registerDevShutdown, hasComposeShutdownFallback, resetDevShutdownForTests } =
|
|
151
|
+
await import("../src/dev-shutdown.js")
|
|
152
|
+
resetDevShutdownForTests()
|
|
153
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-shutdown-"))
|
|
154
|
+
const composePath = join(dir, "docker-compose.yml")
|
|
155
|
+
writeFileSync(composePath, "services: {}\n", "utf8")
|
|
156
|
+
registerDevShutdown(async () => undefined, {
|
|
157
|
+
cwd: dir,
|
|
158
|
+
compose: { cwd: dir, composePath, composeProject: "supatype-test" },
|
|
159
|
+
})
|
|
160
|
+
expect(hasComposeShutdownFallback()).toBe(true)
|
|
161
|
+
})
|
|
139
162
|
})
|