@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
@@ -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
+ }
@@ -1,24 +1,86 @@
1
1
  /**
2
- * Graceful shutdown for `supatype dev` — single path from SIGINT and TUI Ctrl+C.
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 signalsRegistered = false
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(work: () => Promise<void>): void {
62
+ export function registerDevShutdown(
63
+ work: () => Promise<void>,
64
+ opts?: RegisterDevShutdownOptions,
65
+ ): void {
17
66
  shutdownWork = work
18
- if (signalsRegistered) return
19
- signalsRegistered = true
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
+ }
@@ -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
+ }
@@ -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 result = spawnSync("docker", composeArgs, { stdio: "inherit", cwd: projectRoot, env })
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
+ })
@@ -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 130 on second interrupt", async () => {
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
  })