@supatype/cli 0.1.0-alpha.6

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 (200) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-test.log +7 -0
  3. package/.turbo/turbo-typecheck.log +4 -0
  4. package/bin/dev-entry.ts +2 -0
  5. package/bin/supatype.js +5 -0
  6. package/dist/app/framework.d.ts +44 -0
  7. package/dist/app/framework.d.ts.map +1 -0
  8. package/dist/app/framework.js +200 -0
  9. package/dist/app/framework.js.map +1 -0
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.d.ts.map +1 -0
  12. package/dist/cli.js +55 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/commands/admin.d.ts +4 -0
  15. package/dist/commands/admin.d.ts.map +1 -0
  16. package/dist/commands/admin.js +270 -0
  17. package/dist/commands/admin.js.map +1 -0
  18. package/dist/commands/app.d.ts +3 -0
  19. package/dist/commands/app.d.ts.map +1 -0
  20. package/dist/commands/app.js +235 -0
  21. package/dist/commands/app.js.map +1 -0
  22. package/dist/commands/cloud.d.ts +3 -0
  23. package/dist/commands/cloud.d.ts.map +1 -0
  24. package/dist/commands/cloud.js +256 -0
  25. package/dist/commands/cloud.js.map +1 -0
  26. package/dist/commands/db.d.ts +8 -0
  27. package/dist/commands/db.d.ts.map +1 -0
  28. package/dist/commands/db.js +123 -0
  29. package/dist/commands/db.js.map +1 -0
  30. package/dist/commands/deploy-types.d.ts +14 -0
  31. package/dist/commands/deploy-types.d.ts.map +1 -0
  32. package/dist/commands/deploy-types.js +38 -0
  33. package/dist/commands/deploy-types.js.map +1 -0
  34. package/dist/commands/deploy.d.ts +14 -0
  35. package/dist/commands/deploy.d.ts.map +1 -0
  36. package/dist/commands/deploy.js +295 -0
  37. package/dist/commands/deploy.js.map +1 -0
  38. package/dist/commands/dev.d.ts +3 -0
  39. package/dist/commands/dev.d.ts.map +1 -0
  40. package/dist/commands/dev.js +428 -0
  41. package/dist/commands/dev.js.map +1 -0
  42. package/dist/commands/diff.d.ts +3 -0
  43. package/dist/commands/diff.d.ts.map +1 -0
  44. package/dist/commands/diff.js +39 -0
  45. package/dist/commands/diff.js.map +1 -0
  46. package/dist/commands/engine.d.ts +9 -0
  47. package/dist/commands/engine.d.ts.map +1 -0
  48. package/dist/commands/engine.js +99 -0
  49. package/dist/commands/engine.js.map +1 -0
  50. package/dist/commands/functions.d.ts +3 -0
  51. package/dist/commands/functions.d.ts.map +1 -0
  52. package/dist/commands/functions.js +762 -0
  53. package/dist/commands/functions.js.map +1 -0
  54. package/dist/commands/generate.d.ts +3 -0
  55. package/dist/commands/generate.d.ts.map +1 -0
  56. package/dist/commands/generate.js +28 -0
  57. package/dist/commands/generate.js.map +1 -0
  58. package/dist/commands/init.d.ts +7 -0
  59. package/dist/commands/init.d.ts.map +1 -0
  60. package/dist/commands/init.js +515 -0
  61. package/dist/commands/init.js.map +1 -0
  62. package/dist/commands/keys.d.ts +4 -0
  63. package/dist/commands/keys.d.ts.map +1 -0
  64. package/dist/commands/keys.js +57 -0
  65. package/dist/commands/keys.js.map +1 -0
  66. package/dist/commands/logs.d.ts +6 -0
  67. package/dist/commands/logs.d.ts.map +1 -0
  68. package/dist/commands/logs.js +52 -0
  69. package/dist/commands/logs.js.map +1 -0
  70. package/dist/commands/migrate.d.ts +3 -0
  71. package/dist/commands/migrate.d.ts.map +1 -0
  72. package/dist/commands/migrate.js +71 -0
  73. package/dist/commands/migrate.js.map +1 -0
  74. package/dist/commands/plugins.d.ts +3 -0
  75. package/dist/commands/plugins.d.ts.map +1 -0
  76. package/dist/commands/plugins.js +431 -0
  77. package/dist/commands/plugins.js.map +1 -0
  78. package/dist/commands/pull.d.ts +3 -0
  79. package/dist/commands/pull.d.ts.map +1 -0
  80. package/dist/commands/pull.js +73 -0
  81. package/dist/commands/pull.js.map +1 -0
  82. package/dist/commands/push.d.ts +3 -0
  83. package/dist/commands/push.d.ts.map +1 -0
  84. package/dist/commands/push.js +87 -0
  85. package/dist/commands/push.js.map +1 -0
  86. package/dist/commands/seed.d.ts +3 -0
  87. package/dist/commands/seed.d.ts.map +1 -0
  88. package/dist/commands/seed.js +22 -0
  89. package/dist/commands/seed.js.map +1 -0
  90. package/dist/commands/self-host.d.ts +3 -0
  91. package/dist/commands/self-host.d.ts.map +1 -0
  92. package/dist/commands/self-host.js +796 -0
  93. package/dist/commands/self-host.js.map +1 -0
  94. package/dist/commands/status.d.ts +6 -0
  95. package/dist/commands/status.d.ts.map +1 -0
  96. package/dist/commands/status.js +69 -0
  97. package/dist/commands/status.js.map +1 -0
  98. package/dist/config.d.ts +106 -0
  99. package/dist/config.d.ts.map +1 -0
  100. package/dist/config.js +66 -0
  101. package/dist/config.js.map +1 -0
  102. package/dist/engine/cache.d.ts +37 -0
  103. package/dist/engine/cache.d.ts.map +1 -0
  104. package/dist/engine/cache.js +121 -0
  105. package/dist/engine/cache.js.map +1 -0
  106. package/dist/engine/download.d.ts +19 -0
  107. package/dist/engine/download.d.ts.map +1 -0
  108. package/dist/engine/download.js +108 -0
  109. package/dist/engine/download.js.map +1 -0
  110. package/dist/engine/platform.d.ts +24 -0
  111. package/dist/engine/platform.d.ts.map +1 -0
  112. package/dist/engine/platform.js +50 -0
  113. package/dist/engine/platform.js.map +1 -0
  114. package/dist/engine/resolve.d.ts +37 -0
  115. package/dist/engine/resolve.d.ts.map +1 -0
  116. package/dist/engine/resolve.js +133 -0
  117. package/dist/engine/resolve.js.map +1 -0
  118. package/dist/engine/update-notify.d.ts +11 -0
  119. package/dist/engine/update-notify.d.ts.map +1 -0
  120. package/dist/engine/update-notify.js +43 -0
  121. package/dist/engine/update-notify.js.map +1 -0
  122. package/dist/engine/verify.d.ts +50 -0
  123. package/dist/engine/verify.d.ts.map +1 -0
  124. package/dist/engine/verify.js +161 -0
  125. package/dist/engine/verify.js.map +1 -0
  126. package/dist/engine-version.d.ts +35 -0
  127. package/dist/engine-version.d.ts.map +1 -0
  128. package/dist/engine-version.js +35 -0
  129. package/dist/engine-version.js.map +1 -0
  130. package/dist/engine.d.ts +34 -0
  131. package/dist/engine.d.ts.map +1 -0
  132. package/dist/engine.js +76 -0
  133. package/dist/engine.js.map +1 -0
  134. package/dist/index.d.ts +12 -0
  135. package/dist/index.d.ts.map +1 -0
  136. package/dist/index.js +10 -0
  137. package/dist/index.js.map +1 -0
  138. package/dist/jwt.d.ts +3 -0
  139. package/dist/jwt.d.ts.map +1 -0
  140. package/dist/jwt.js +13 -0
  141. package/dist/jwt.js.map +1 -0
  142. package/dist/pull-utils.d.ts +16 -0
  143. package/dist/pull-utils.d.ts.map +1 -0
  144. package/dist/pull-utils.js +65 -0
  145. package/dist/pull-utils.js.map +1 -0
  146. package/dist/scripts/postinstall.d.ts +12 -0
  147. package/dist/scripts/postinstall.d.ts.map +1 -0
  148. package/dist/scripts/postinstall.js +31 -0
  149. package/dist/scripts/postinstall.js.map +1 -0
  150. package/dist/tsx-runner.d.ts +18 -0
  151. package/dist/tsx-runner.d.ts.map +1 -0
  152. package/dist/tsx-runner.js +62 -0
  153. package/dist/tsx-runner.js.map +1 -0
  154. package/package.json +36 -0
  155. package/src/app/framework.ts +249 -0
  156. package/src/cli.ts +58 -0
  157. package/src/commands/admin.ts +371 -0
  158. package/src/commands/app.ts +261 -0
  159. package/src/commands/cloud.ts +326 -0
  160. package/src/commands/db.ts +145 -0
  161. package/src/commands/deploy-types.ts +49 -0
  162. package/src/commands/deploy.ts +366 -0
  163. package/src/commands/dev.ts +477 -0
  164. package/src/commands/diff.ts +61 -0
  165. package/src/commands/engine.ts +133 -0
  166. package/src/commands/functions.ts +919 -0
  167. package/src/commands/generate.ts +31 -0
  168. package/src/commands/init.ts +532 -0
  169. package/src/commands/keys.ts +66 -0
  170. package/src/commands/logs.ts +58 -0
  171. package/src/commands/migrate.ts +83 -0
  172. package/src/commands/plugins.ts +508 -0
  173. package/src/commands/pull.ts +96 -0
  174. package/src/commands/push.ts +119 -0
  175. package/src/commands/seed.ts +26 -0
  176. package/src/commands/self-host.ts +932 -0
  177. package/src/commands/status.ts +83 -0
  178. package/src/config.ts +190 -0
  179. package/src/engine/cache.ts +135 -0
  180. package/src/engine/download.ts +143 -0
  181. package/src/engine/platform.ts +66 -0
  182. package/src/engine/resolve.ts +197 -0
  183. package/src/engine/update-notify.ts +50 -0
  184. package/src/engine/verify.ts +206 -0
  185. package/src/engine-version.ts +39 -0
  186. package/src/engine.ts +99 -0
  187. package/src/index.ts +19 -0
  188. package/src/jwt.ts +14 -0
  189. package/src/pull-utils.ts +57 -0
  190. package/src/scripts/postinstall.ts +40 -0
  191. package/src/tsx-runner.ts +79 -0
  192. package/tests/cli-help.test.ts +107 -0
  193. package/tests/config.test.ts +117 -0
  194. package/tests/engine-distribution.test.ts +418 -0
  195. package/tests/init.test.ts +184 -0
  196. package/tests/keys.test.ts +160 -0
  197. package/tests/pull-utils.test.ts +115 -0
  198. package/tests/tsx-runner.test.ts +66 -0
  199. package/tsconfig.json +10 -0
  200. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,932 @@
1
+ import type { Command } from "commander"
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ writeFileSync,
6
+ readFileSync,
7
+ copyFileSync,
8
+ } from "node:fs"
9
+ import { resolve, join } from "node:path"
10
+ import { randomBytes } from "node:crypto"
11
+ import { spawnSync } from "node:child_process"
12
+ import { signJwt } from "../jwt.js"
13
+ import type { SelfHostConfig, ServiceVersionPin } from "../config.js"
14
+
15
+ export function registerSelfHost(program: Command): void {
16
+ const selfHostCmd = program
17
+ .command("self-host")
18
+ .description("Manage self-hosted production deployments")
19
+
20
+ selfHostCmd
21
+ .command("setup")
22
+ .description("Generate a production-ready deploy/ directory with Caddy, PgBouncer, and all secrets")
23
+ .option("--domain <domain>", "Production domain (e.g. api.example.com)")
24
+ .option("--app-dockerfile <path>", "Path to your app Dockerfile (omit to skip app service)")
25
+ .option("--app-port <port>", "Port your app listens on", "3000")
26
+ .option("--ssl-email <email>", "Email address for Let's Encrypt registration")
27
+ .action(async (opts: { domain?: string; appDockerfile?: string; appPort: string; sslEmail?: string }) => {
28
+ await setup(process.cwd(), opts)
29
+ })
30
+
31
+ selfHostCmd
32
+ .command("status")
33
+ .description("Show running service health for the production stack")
34
+ .action(() => {
35
+ runDockerCompose(["ps", "--format", "table"], "status")
36
+ })
37
+
38
+ selfHostCmd
39
+ .command("logs")
40
+ .description("Tail logs from production services")
41
+ .option("--service <name>", "Show logs for a specific service only")
42
+ .option("--follow", "Follow log output")
43
+ .action((opts: { service?: string; follow?: boolean }) => {
44
+ const args = ["logs"]
45
+ if (opts.follow) args.push("--follow")
46
+ if (opts.service) args.push(opts.service)
47
+ runDockerCompose(args, "logs")
48
+ })
49
+
50
+ selfHostCmd
51
+ .command("backup")
52
+ .description("Create a Postgres dump and store it locally")
53
+ .option("--output <path>", "Output file path", `./backups/backup-${timestamp()}.sql.gz`)
54
+ .action((opts: { output: string }) => {
55
+ backup(process.cwd(), opts.output)
56
+ })
57
+
58
+ selfHostCmd
59
+ .command("update")
60
+ .description("Pull latest images and restart the production stack (use 'upgrade' for safe rolling upgrades)")
61
+ .action(() => {
62
+ update(process.cwd())
63
+ })
64
+
65
+ selfHostCmd
66
+ .command("upgrade")
67
+ .description("Safely upgrade services with backup, rolling restart, and automatic rollback")
68
+ .option("--skip-backup", "Skip automatic pre-upgrade backup")
69
+ .option("--skip-migrations", "Skip database migration step")
70
+ .action(async (opts: { skipBackup?: boolean; skipMigrations?: boolean }) => {
71
+ await upgrade(process.cwd(), opts)
72
+ })
73
+ }
74
+
75
+ // ─── Setup ────────────────────────────────────────────────────────────────────
76
+
77
+ interface SetupOpts {
78
+ domain?: string
79
+ appDockerfile?: string
80
+ appPort: string
81
+ sslEmail?: string
82
+ }
83
+
84
+ async function fetchLatestTag(repo: string, fallback: string): Promise<string> {
85
+ try {
86
+ const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
87
+ headers: { Accept: "application/vnd.github+json" },
88
+ signal: AbortSignal.timeout(5000),
89
+ })
90
+ if (!res.ok) return fallback
91
+ const data = await res.json() as { tag_name?: string }
92
+ return data.tag_name ?? fallback
93
+ } catch {
94
+ return fallback
95
+ }
96
+ }
97
+
98
+ async function setup(cwd: string, opts: SetupOpts): Promise<void> {
99
+ // Load domain from opts or supatype.config.ts
100
+ const domain = opts.domain ?? loadDomainFromConfig(cwd)
101
+ if (!domain) {
102
+ console.error(
103
+ "Error: --domain is required (or set selfHost.domain in supatype.config.ts)",
104
+ )
105
+ process.exit(1)
106
+ }
107
+
108
+ console.log("Fetching latest image versions...")
109
+ const [postgresTag, authTag] = await Promise.all([
110
+ fetchLatestTag("supatype/postgres", "17-latest"),
111
+ fetchLatestTag("supatype/auth", "v1.0.0"),
112
+ ])
113
+ console.log(` postgres supatype/postgres:${postgresTag}`)
114
+ console.log(` auth supatype/auth:${authTag}`)
115
+
116
+ const deployDir = resolve(cwd, "deploy")
117
+ mkdirSync(deployDir, { recursive: true })
118
+
119
+ const write = (rel: string, content: string) => {
120
+ const full = join(deployDir, rel)
121
+ mkdirSync(resolve(full, ".."), { recursive: true })
122
+ writeFileSync(full, content, "utf8")
123
+ console.log(` created deploy/${rel}`)
124
+ }
125
+
126
+ // Generate all secrets
127
+ const pgPassword = randomBytes(24).toString("hex")
128
+ const jwtSecret = randomBytes(32).toString("hex")
129
+ const now = Math.floor(Date.now() / 1000)
130
+ const exp = now + 10 * 365 * 24 * 60 * 60 // 10 years
131
+ const anonKey = signJwt({ iss: "supatype", role: "anon", iat: now, exp }, jwtSecret)
132
+ const serviceKey = signJwt({ iss: "supatype", role: "service_role", iat: now, exp }, jwtSecret)
133
+
134
+ console.log("\nGenerating production deployment files...\n")
135
+
136
+ write(".env.production", envProductionTemplate(domain, pgPassword, jwtSecret, anonKey, serviceKey))
137
+ write("docker-compose.yml", productionComposeTemplate(domain, opts, postgresTag, authTag))
138
+ write("Caddyfile", caddyfileTemplate(domain, opts.sslEmail))
139
+ write("pgbouncer.ini", productionPgbouncerIni())
140
+ write("userlist.txt", productionUserlist(pgPassword))
141
+ write("deploy.sh", deployScript(domain))
142
+
143
+ // Copy kong.yml if it exists
144
+ const kongSrc = resolve(cwd, ".supatype/kong.yml")
145
+ if (existsSync(kongSrc)) {
146
+ copyFileSync(kongSrc, join(deployDir, "kong.yml"))
147
+ console.log(" copied deploy/kong.yml")
148
+ }
149
+
150
+ // Make deploy.sh executable on Unix
151
+ try {
152
+ spawnSync("chmod", ["+x", join(deployDir, "deploy.sh")])
153
+ } catch { /* non-Unix, ignore */ }
154
+
155
+ console.log(`
156
+ ╔══════════════════════════════════════════════════════════════╗
157
+ ║ SAVE THESE SECRETS — they will not be shown again! ║
158
+ ╚══════════════════════════════════════════════════════════════╝
159
+
160
+ POSTGRES_PASSWORD=${pgPassword}
161
+ JWT_SECRET=${jwtSecret}
162
+ ANON_KEY=${anonKey}
163
+ SERVICE_ROLE_KEY=${serviceKey}
164
+
165
+ These are also written to deploy/.env.production — back it up securely.
166
+ DO NOT commit deploy/.env.production to source control.
167
+
168
+ Next steps:
169
+ 1. Copy the deploy/ directory to your VPS
170
+ 2. SSH into the VPS and run: bash deploy.sh
171
+ 3. Your app will be live at https://${domain}
172
+ `)
173
+ }
174
+
175
+ // ─── Operations ───────────────────────────────────────────────────────────────
176
+
177
+ function runDockerCompose(args: string[], label: string): void {
178
+ const deployDir = resolve(process.cwd(), "deploy")
179
+ if (!existsSync(join(deployDir, "docker-compose.yml"))) {
180
+ console.error("deploy/docker-compose.yml not found. Run: supatype self-host setup")
181
+ process.exit(1)
182
+ }
183
+ const result = spawnSync("docker", ["compose", "-f", join(deployDir, "docker-compose.yml"), ...args], {
184
+ stdio: "inherit",
185
+ cwd: deployDir,
186
+ })
187
+ if (result.status !== 0) process.exit(result.status ?? 1)
188
+ }
189
+
190
+ function backup(cwd: string, outputPath: string): void {
191
+ const deployDir = resolve(cwd, "deploy")
192
+ if (!existsSync(join(deployDir, "docker-compose.yml"))) {
193
+ console.error("deploy/docker-compose.yml not found. Run: supatype self-host setup")
194
+ process.exit(1)
195
+ }
196
+
197
+ const fullOutput = resolve(cwd, outputPath)
198
+ mkdirSync(resolve(fullOutput, ".."), { recursive: true })
199
+
200
+ console.log(`Backing up database to ${outputPath}...`)
201
+ const result = spawnSync(
202
+ "docker",
203
+ [
204
+ "compose",
205
+ "-f", join(deployDir, "docker-compose.yml"),
206
+ "exec", "-T", "db",
207
+ "sh", "-c", "pg_dumpall -U postgres | gzip",
208
+ ],
209
+ { cwd: deployDir, encoding: "buffer" },
210
+ )
211
+
212
+ if (result.status !== 0) {
213
+ console.error("Backup failed:", result.stderr?.toString())
214
+ process.exit(1)
215
+ }
216
+
217
+ writeFileSync(fullOutput, result.stdout)
218
+ console.log(`Backup saved to ${outputPath}`)
219
+ }
220
+
221
+ function update(cwd: string): void {
222
+ const deployDir = resolve(cwd, "deploy")
223
+ console.log("Pulling latest images...")
224
+ spawnSync("docker", ["compose", "-f", join(deployDir, "docker-compose.yml"), "pull"], {
225
+ stdio: "inherit",
226
+ cwd: deployDir,
227
+ })
228
+ console.log("Restarting services...")
229
+ spawnSync("docker", ["compose", "-f", join(deployDir, "docker-compose.yml"), "up", "-d", "--wait"], {
230
+ stdio: "inherit",
231
+ cwd: deployDir,
232
+ })
233
+ console.log("Update complete.")
234
+ }
235
+
236
+ // ─── Upgrade (safe rolling upgrade) ──────────────────────────────────────────
237
+
238
+ /** Known services and their GitHub repos (for changelog fetching) and Docker images. */
239
+ interface ServiceInfo {
240
+ composeName: string
241
+ image: string
242
+ repo: string | null // GitHub org/repo for changelog lookup, null if third-party
243
+ fallbackTag: string
244
+ }
245
+
246
+ const MANAGED_SERVICES: ServiceInfo[] = [
247
+ { composeName: "db", image: "supatype/postgres", repo: "supatype/postgres", fallbackTag: "17-latest" },
248
+ { composeName: "gotrue", image: "supatype/auth", repo: "supatype/auth", fallbackTag: "v1.0.0" },
249
+ { composeName: "postgrest", image: "postgrest/postgrest", repo: "PostgREST/postgrest", fallbackTag: "v12.2.8" },
250
+ { composeName: "kong", image: "kong", repo: null, fallbackTag: "3.6" },
251
+ { composeName: "caddy", image: "caddy", repo: null, fallbackTag: "2" },
252
+ { composeName: "pgbouncer", image: "pgbouncer/pgbouncer", repo: null, fallbackTag: "latest" },
253
+ { composeName: "functions", image: "denoland/deno", repo: "denoland/deno", fallbackTag: "latest" },
254
+ ]
255
+
256
+ function getCurrentImageTag(deployDir: string, serviceName: string): string | null {
257
+ const result = spawnSync(
258
+ "docker",
259
+ ["compose", "-f", join(deployDir, "docker-compose.yml"), "images", serviceName, "--format", "json"],
260
+ { cwd: deployDir, encoding: "utf8" },
261
+ )
262
+ if (result.status !== 0 || !result.stdout.trim()) return null
263
+ try {
264
+ // docker compose images --format json outputs one JSON object per line
265
+ const lines = result.stdout.trim().split("\n")
266
+ for (const line of lines) {
267
+ const data = JSON.parse(line) as { Tag?: string }
268
+ if (data.Tag) return data.Tag
269
+ }
270
+ return null
271
+ } catch {
272
+ return null
273
+ }
274
+ }
275
+
276
+ async function fetchLatestRelease(repo: string): Promise<{ tag: string; body: string } | null> {
277
+ try {
278
+ const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
279
+ headers: { Accept: "application/vnd.github+json" },
280
+ signal: AbortSignal.timeout(5000),
281
+ })
282
+ if (!res.ok) return null
283
+ const data = await res.json() as { tag_name?: string; body?: string }
284
+ if (!data.tag_name) return null
285
+ return { tag: data.tag_name, body: data.body ?? "" }
286
+ } catch {
287
+ return null
288
+ }
289
+ }
290
+
291
+ function loadSelfHostConfig(cwd: string): SelfHostConfig | undefined {
292
+ try {
293
+ const { loadConfig } = require("../config.js") as typeof import("../config.js")
294
+ const config = loadConfig(cwd)
295
+ return config.selfHost
296
+ } catch {
297
+ return undefined
298
+ }
299
+ }
300
+
301
+ function isServicePinned(
302
+ selfHostConfig: SelfHostConfig | undefined,
303
+ serviceName: string,
304
+ ): ServiceVersionPin | undefined {
305
+ if (!selfHostConfig?.services) return undefined
306
+ return (selfHostConfig.services as Record<string, ServiceVersionPin | undefined>)[serviceName]
307
+ }
308
+
309
+ function checkServiceHealth(deployDir: string, serviceName: string, timeoutSeconds = 60): boolean {
310
+ console.log(` Checking health of ${serviceName}...`)
311
+ const deadline = Date.now() + timeoutSeconds * 1000
312
+ while (Date.now() < deadline) {
313
+ const result = spawnSync(
314
+ "docker",
315
+ ["compose", "-f", join(deployDir, "docker-compose.yml"), "ps", serviceName, "--format", "json"],
316
+ { cwd: deployDir, encoding: "utf8" },
317
+ )
318
+ if (result.status === 0 && result.stdout.trim()) {
319
+ const lines = result.stdout.trim().split("\n")
320
+ for (const line of lines) {
321
+ try {
322
+ const data = JSON.parse(line) as { State?: string; Health?: string; Status?: string }
323
+ // A service is considered healthy if:
324
+ // - it has a healthcheck and Health is "healthy", or
325
+ // - it has no healthcheck and State is "running"
326
+ if (data.Health === "healthy") return true
327
+ if (!data.Health && data.State === "running") return true
328
+ // Status field sometimes contains "Up ... (healthy)"
329
+ if (data.Status && data.Status.includes("healthy")) return true
330
+ if (data.Status && !data.Status.includes("health") && data.State === "running") return true
331
+ } catch { /* skip bad line */ }
332
+ }
333
+ }
334
+ spawnSync("sleep", ["3"])
335
+ }
336
+ return false
337
+ }
338
+
339
+ function rollbackService(deployDir: string, serviceName: string, previousImage: string): boolean {
340
+ console.log(` Rolling back ${serviceName} to ${previousImage}...`)
341
+ // Pull the previous image back
342
+ const pullResult = spawnSync(
343
+ "docker",
344
+ ["pull", previousImage],
345
+ { stdio: "inherit", cwd: deployDir },
346
+ )
347
+ if (pullResult.status !== 0) return false
348
+
349
+ // Restart the service (docker compose will use the image now available)
350
+ // We need to re-tag or use docker compose up with the old image.
351
+ // The simplest reliable approach: stop the service, then start it.
352
+ // Since compose file may have :latest or a tag, we re-pull and restart.
353
+ const upResult = spawnSync(
354
+ "docker",
355
+ ["compose", "-f", join(deployDir, "docker-compose.yml"), "up", "-d", "--no-deps", serviceName],
356
+ { stdio: "inherit", cwd: deployDir },
357
+ )
358
+ return upResult.status === 0
359
+ }
360
+
361
+ function applyDatabaseMigrations(deployDir: string): boolean {
362
+ console.log("\nApplying database migrations...")
363
+ // Check if migrations directory exists
364
+ const migrationsDir = resolve(deployDir, "..", "migrations")
365
+ if (!existsSync(migrationsDir)) {
366
+ console.log(" No migrations directory found, skipping.")
367
+ return true
368
+ }
369
+
370
+ // Run migrations via docker exec into the db container
371
+ const result = spawnSync(
372
+ "docker",
373
+ [
374
+ "compose",
375
+ "-f", join(deployDir, "docker-compose.yml"),
376
+ "exec", "-T", "db",
377
+ "sh", "-c",
378
+ `for f in /migrations/*.sql; do [ -f "$f" ] && psql -U postgres -d supatype -f "$f" && echo "Applied: $f"; done`,
379
+ ],
380
+ {
381
+ cwd: deployDir,
382
+ stdio: "inherit",
383
+ // Mount migrations directory
384
+ env: { ...process.env },
385
+ },
386
+ )
387
+
388
+ // Also try with a copy approach if the volume isn't mounted
389
+ if (result.status !== 0) {
390
+ // Copy and run migrations one at a time
391
+ const { readdirSync } = require("node:fs") as typeof import("node:fs")
392
+ try {
393
+ const files = readdirSync(migrationsDir).filter(f => f.endsWith(".sql")).sort()
394
+ for (const file of files) {
395
+ const sqlPath = resolve(migrationsDir, file)
396
+ const sql = readFileSync(sqlPath, "utf8")
397
+ const execResult = spawnSync(
398
+ "docker",
399
+ [
400
+ "compose",
401
+ "-f", join(deployDir, "docker-compose.yml"),
402
+ "exec", "-T", "db",
403
+ "psql", "-U", "postgres", "-d", "supatype", "-c", sql,
404
+ ],
405
+ { cwd: deployDir, encoding: "utf8" },
406
+ )
407
+ if (execResult.status !== 0) {
408
+ console.error(` Failed to apply migration ${file}: ${execResult.stderr}`)
409
+ return false
410
+ }
411
+ console.log(` Applied: ${file}`)
412
+ }
413
+ } catch (err) {
414
+ console.error(` Error reading migrations: ${err}`)
415
+ return false
416
+ }
417
+ }
418
+
419
+ console.log(" Migrations complete.")
420
+ return true
421
+ }
422
+
423
+ /** Summarize a release body to a short changelog line. */
424
+ function summarizeChangelog(body: string): string {
425
+ if (!body.trim()) return "(no changelog available)"
426
+ // Take first 3 non-empty lines, strip markdown headers
427
+ const lines = body
428
+ .split("\n")
429
+ .map(l => l.trim())
430
+ .filter(l => l.length > 0)
431
+ .map(l => l.replace(/^#+\s*/, ""))
432
+ .slice(0, 3)
433
+ const summary = lines.join("; ")
434
+ return summary.length > 120 ? summary.slice(0, 117) + "..." : summary
435
+ }
436
+
437
+ interface UpgradeOpts {
438
+ skipBackup?: boolean
439
+ skipMigrations?: boolean
440
+ }
441
+
442
+ async function upgrade(cwd: string, opts: UpgradeOpts): Promise<void> {
443
+ const deployDir = resolve(cwd, "deploy")
444
+ if (!existsSync(join(deployDir, "docker-compose.yml"))) {
445
+ console.error("deploy/docker-compose.yml not found. Run: supatype self-host setup")
446
+ process.exit(1)
447
+ }
448
+
449
+ const selfHostConfig = loadSelfHostConfig(cwd)
450
+
451
+ // ── Step 1: Check current vs latest versions ──────────────────────────────
452
+
453
+ console.log("Checking service versions...\n")
454
+
455
+ interface UpgradePlan {
456
+ service: ServiceInfo
457
+ currentTag: string | null
458
+ latestTag: string
459
+ changelog: string
460
+ pinned: boolean
461
+ }
462
+
463
+ const plans: UpgradePlan[] = []
464
+
465
+ for (const svc of MANAGED_SERVICES) {
466
+ const pin = isServicePinned(selfHostConfig, svc.composeName)
467
+ const currentTag = getCurrentImageTag(deployDir, svc.composeName)
468
+
469
+ if (pin) {
470
+ console.log(` ${svc.composeName.padEnd(12)} pinned at ${pin.version} (skipping)`)
471
+ plans.push({
472
+ service: svc,
473
+ currentTag,
474
+ latestTag: pin.version,
475
+ changelog: "",
476
+ pinned: true,
477
+ })
478
+ continue
479
+ }
480
+
481
+ let latestTag = svc.fallbackTag
482
+ let changelog = ""
483
+
484
+ if (svc.repo) {
485
+ const release = await fetchLatestRelease(svc.repo)
486
+ if (release) {
487
+ latestTag = release.tag
488
+ changelog = summarizeChangelog(release.body)
489
+ }
490
+ }
491
+
492
+ const needsUpgrade = currentTag !== latestTag
493
+ const marker = needsUpgrade ? " *" : ""
494
+ console.log(
495
+ ` ${svc.composeName.padEnd(12)} ${(currentTag ?? "unknown").padEnd(16)} -> ${latestTag}${marker}`,
496
+ )
497
+ if (changelog && needsUpgrade) {
498
+ console.log(`${"".padEnd(16)}changelog: ${changelog}`)
499
+ }
500
+
501
+ plans.push({
502
+ service: svc,
503
+ currentTag,
504
+ latestTag,
505
+ changelog,
506
+ pinned: false,
507
+ })
508
+ }
509
+
510
+ const upgradeable = plans.filter(p => !p.pinned && p.currentTag !== p.latestTag)
511
+ if (upgradeable.length === 0) {
512
+ console.log("\nAll services are up to date. Nothing to upgrade.")
513
+ return
514
+ }
515
+
516
+ console.log(`\n${upgradeable.length} service(s) will be upgraded.\n`)
517
+
518
+ // ── Step 2: Pre-upgrade backup ────────────────────────────────────────────
519
+
520
+ if (!opts.skipBackup) {
521
+ const backupPath = `./backups/pre-upgrade-${timestamp()}.sql.gz`
522
+ console.log(`Creating pre-upgrade backup: ${backupPath}`)
523
+ backup(cwd, backupPath)
524
+ console.log("Backup complete.\n")
525
+ } else {
526
+ console.log("Skipping pre-upgrade backup (--skip-backup).\n")
527
+ }
528
+
529
+ // ── Step 3: Apply database migrations ─────────────────────────────────────
530
+
531
+ if (!opts.skipMigrations) {
532
+ const migrationOk = applyDatabaseMigrations(deployDir)
533
+ if (!migrationOk) {
534
+ console.error("\nDatabase migration failed. Aborting upgrade.")
535
+ console.error("Your pre-upgrade backup is available. To restore:")
536
+ console.error(" docker compose exec -T db sh -c 'gunzip | psql -U postgres' < <backup-file>")
537
+ process.exit(1)
538
+ }
539
+ }
540
+
541
+ // ── Step 4: Rolling restart with health checks and rollback ───────────────
542
+
543
+ console.log("\nStarting rolling upgrade...\n")
544
+
545
+ const failed: string[] = []
546
+
547
+ for (const plan of upgradeable) {
548
+ const svc = plan.service
549
+ const fullImage = `${svc.image}:${plan.latestTag}`
550
+ const previousImage = plan.currentTag ? `${svc.image}:${plan.currentTag}` : null
551
+
552
+ console.log(`Upgrading ${svc.composeName}: ${plan.currentTag ?? "unknown"} -> ${plan.latestTag}`)
553
+
554
+ // Pull new image
555
+ console.log(` Pulling ${fullImage}...`)
556
+ const pullResult = spawnSync("docker", ["pull", fullImage], {
557
+ stdio: "inherit",
558
+ cwd: deployDir,
559
+ })
560
+ if (pullResult.status !== 0) {
561
+ console.error(` Failed to pull ${fullImage}. Skipping ${svc.composeName}.`)
562
+ failed.push(svc.composeName)
563
+ continue
564
+ }
565
+
566
+ // Restart just this service (zero-downtime: one at a time)
567
+ console.log(` Restarting ${svc.composeName}...`)
568
+ const upResult = spawnSync(
569
+ "docker",
570
+ ["compose", "-f", join(deployDir, "docker-compose.yml"), "up", "-d", "--no-deps", svc.composeName],
571
+ { stdio: "inherit", cwd: deployDir },
572
+ )
573
+ if (upResult.status !== 0) {
574
+ console.error(` Failed to restart ${svc.composeName}.`)
575
+ if (previousImage) {
576
+ rollbackService(deployDir, svc.composeName, previousImage)
577
+ }
578
+ failed.push(svc.composeName)
579
+ continue
580
+ }
581
+
582
+ // Verify health
583
+ const healthy = checkServiceHealth(deployDir, svc.composeName)
584
+ if (!healthy) {
585
+ console.error(` Health check failed for ${svc.composeName} after upgrade.`)
586
+ if (previousImage) {
587
+ console.log(` Initiating rollback for ${svc.composeName}...`)
588
+ const rolledBack = rollbackService(deployDir, svc.composeName, previousImage)
589
+ if (rolledBack) {
590
+ const healthAfterRollback = checkServiceHealth(deployDir, svc.composeName)
591
+ if (healthAfterRollback) {
592
+ console.log(` Rolled back ${svc.composeName} to ${previousImage} successfully.`)
593
+ } else {
594
+ console.error(` WARNING: ${svc.composeName} is unhealthy even after rollback.`)
595
+ }
596
+ } else {
597
+ console.error(` WARNING: Rollback failed for ${svc.composeName}.`)
598
+ }
599
+ }
600
+ failed.push(svc.composeName)
601
+ continue
602
+ }
603
+
604
+ console.log(` ${svc.composeName} upgraded and healthy.\n`)
605
+ }
606
+
607
+ // ── Step 5: Summary ───────────────────────────────────────────────────────
608
+
609
+ if (failed.length === 0) {
610
+ console.log("Upgrade complete. All services are healthy.")
611
+ } else {
612
+ console.error(`\nUpgrade finished with failures in: ${failed.join(", ")}`)
613
+ console.error("\nManual intervention may be needed:")
614
+ console.error(" 1. Check logs: supatype self-host logs --service <name>")
615
+ console.error(" 2. Check status: supatype self-host status")
616
+ console.error(" 3. Restore backup: docker compose exec -T db sh -c 'gunzip | psql -U postgres' < <backup-file>")
617
+ console.error(" 4. Pin a version: Add services.<name>.version in supatype.config.ts selfHost config")
618
+ process.exit(1)
619
+ }
620
+ }
621
+
622
+ // ─── Config helpers ───────────────────────────────────────────────────────────
623
+
624
+ function loadDomainFromConfig(cwd: string): string | undefined {
625
+ try {
626
+ const { loadConfig } = require("../config.js") as typeof import("../config.js")
627
+ const config = loadConfig(cwd)
628
+ return (config as { selfHost?: { domain?: string } }).selfHost?.domain
629
+ } catch {
630
+ return undefined
631
+ }
632
+ }
633
+
634
+ function timestamp(): string {
635
+ return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
636
+ }
637
+
638
+ // ─── Production templates ─────────────────────────────────────────────────────
639
+
640
+ function envProductionTemplate(
641
+ domain: string,
642
+ pgPassword: string,
643
+ jwtSecret: string,
644
+ anonKey: string,
645
+ serviceKey: string,
646
+ ): string {
647
+ return `# Production secrets — DO NOT commit this file to source control
648
+ # Generated by: supatype self-host setup
649
+
650
+ DOMAIN=${domain}
651
+
652
+ POSTGRES_PASSWORD=${pgPassword}
653
+ POSTGRES_DB=supatype
654
+
655
+ JWT_SECRET=${jwtSecret}
656
+ ANON_KEY=${anonKey}
657
+ SERVICE_ROLE_KEY=${serviceKey}
658
+
659
+ SITE_URL=https://${domain}
660
+
661
+ # SMTP — required for user email confirmation in production
662
+ SMTP_HOST=
663
+ SMTP_PORT=587
664
+ SMTP_USER=
665
+ SMTP_PASS=
666
+ SMTP_SENDER_NAME=Supatype
667
+ `
668
+ }
669
+
670
+ function productionComposeTemplate(domain: string, opts: SetupOpts, postgresTag: string, authTag: string): string {
671
+ const appService = opts.appDockerfile
672
+ ? `
673
+ app:
674
+ build:
675
+ context: ..
676
+ dockerfile: ${opts.appDockerfile}
677
+ environment:
678
+ SUPATYPE_URL: http://kong:8000
679
+ SUPATYPE_ANON_KEY: \${ANON_KEY}
680
+ SUPATYPE_SERVICE_ROLE_KEY: \${SERVICE_ROLE_KEY}
681
+ networks:
682
+ - supatype
683
+ depends_on:
684
+ - kong
685
+ restart: unless-stopped
686
+ `
687
+ : ""
688
+
689
+ return `# Production docker-compose — generated by supatype self-host setup
690
+ # Run with: docker compose up -d (from within the deploy/ directory)
691
+
692
+ services:
693
+ db:
694
+ image: supatype/postgres:${postgresTag}
695
+ environment:
696
+ POSTGRES_PASSWORD: \${POSTGRES_PASSWORD}
697
+ POSTGRES_DB: \${POSTGRES_DB:-supatype}
698
+ volumes:
699
+ - db-data:/var/lib/postgresql/data
700
+ networks:
701
+ - supatype
702
+ healthcheck:
703
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
704
+ interval: 10s
705
+ timeout: 5s
706
+ retries: 20
707
+ restart: unless-stopped
708
+
709
+ pgbouncer:
710
+ image: pgbouncer/pgbouncer:latest
711
+ volumes:
712
+ - ./pgbouncer.ini:/etc/pgbouncer/pgbouncer.ini:ro
713
+ - ./userlist.txt:/etc/pgbouncer/userlist.txt:ro
714
+ networks:
715
+ - supatype
716
+ depends_on:
717
+ db:
718
+ condition: service_healthy
719
+ restart: unless-stopped
720
+
721
+ gotrue:
722
+ image: supatype/auth:${authTag}
723
+ environment:
724
+ GOTRUE_API_HOST: 0.0.0.0
725
+ GOTRUE_API_PORT: 9999
726
+ GOTRUE_DB_DRIVER: postgres
727
+ GOTRUE_DB_DATABASE_URL: "postgres://postgres:\${POSTGRES_PASSWORD}@pgbouncer:6432/\${POSTGRES_DB:-supatype}?search_path=auth"
728
+ GOTRUE_SITE_URL: https://${domain}
729
+ GOTRUE_JWT_SECRET: \${JWT_SECRET}
730
+ GOTRUE_JWT_EXP: 3600
731
+ GOTRUE_JWT_AUD: authenticated
732
+ GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
733
+ GOTRUE_JWT_ADMIN_ROLES: service_role
734
+ GOTRUE_MAILER_AUTOCONFIRM: false
735
+ GOTRUE_SMTP_HOST: \${SMTP_HOST}
736
+ GOTRUE_SMTP_PORT: \${SMTP_PORT:-587}
737
+ GOTRUE_SMTP_USER: \${SMTP_USER}
738
+ GOTRUE_SMTP_PASS: \${SMTP_PASS}
739
+ GOTRUE_SMTP_SENDER_NAME: \${SMTP_SENDER_NAME:-Supatype}
740
+ GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify
741
+ GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify
742
+ GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify
743
+ GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify
744
+ GOTRUE_DISABLE_SIGNUP: false
745
+ networks:
746
+ - supatype
747
+ depends_on:
748
+ pgbouncer:
749
+ condition: service_started
750
+ restart: unless-stopped
751
+
752
+ postgrest:
753
+ image: postgrest/postgrest:v12.2.8
754
+ environment:
755
+ PGRST_DB_URI: postgresql://authenticator:\${POSTGRES_PASSWORD}@pgbouncer:6432/\${POSTGRES_DB:-supatype}
756
+ PGRST_DB_SCHEMA: public
757
+ PGRST_DB_ANON_ROLE: anon
758
+ PGRST_JWT_SECRET: \${JWT_SECRET}
759
+ PGRST_DB_EXTRA_SEARCH_PATH: public,extensions
760
+ PGRST_DB_POOL: 3
761
+ networks:
762
+ - supatype
763
+ depends_on:
764
+ pgbouncer:
765
+ condition: service_started
766
+ restart: unless-stopped
767
+
768
+ kong:
769
+ image: kong:3.6
770
+ environment:
771
+ KONG_DATABASE: "off"
772
+ KONG_DECLARATIVE_CONFIG: /etc/kong/kong.yml
773
+ KONG_PROXY_ACCESS_LOG: /dev/stdout
774
+ KONG_ADMIN_ACCESS_LOG: /dev/stdout
775
+ KONG_PROXY_ERROR_LOG: /dev/stderr
776
+ KONG_ADMIN_ERROR_LOG: /dev/stderr
777
+ volumes:
778
+ - ./kong.yml:/etc/kong/kong.yml:ro
779
+ networks:
780
+ - supatype
781
+ depends_on:
782
+ - postgrest
783
+ - gotrue
784
+ restart: unless-stopped
785
+ ${appService}
786
+ functions:
787
+ image: denoland/deno:latest
788
+ environment:
789
+ SUPATYPE_URL: http://kong:8000
790
+ SUPATYPE_ANON_KEY: \${ANON_KEY}
791
+ SUPATYPE_SERVICE_ROLE_KEY: \${SERVICE_ROLE_KEY}
792
+ FUNCTIONS_DIR: /functions
793
+ volumes:
794
+ - ../supatype/functions:/functions:ro
795
+ networks:
796
+ - supatype
797
+ depends_on:
798
+ - kong
799
+ mem_limit: 512m
800
+ cpus: 1.0
801
+ restart: unless-stopped
802
+
803
+ caddy:
804
+ image: caddy:2
805
+ ports:
806
+ - "80:80"
807
+ - "443:443"
808
+ volumes:
809
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
810
+ - caddy-data:/data
811
+ - caddy-config:/config
812
+ networks:
813
+ - supatype
814
+ depends_on:
815
+ - kong
816
+ restart: unless-stopped
817
+
818
+ networks:
819
+ supatype:
820
+ driver: bridge
821
+
822
+ volumes:
823
+ db-data:
824
+ caddy-data:
825
+ caddy-config:
826
+ `
827
+ }
828
+
829
+ function caddyfileTemplate(domain: string, sslEmail?: string): string {
830
+ const emailLine = sslEmail ? `\n\ttls ${sslEmail}\n` : ""
831
+ return `${domain} {${emailLine}
832
+ \treverse_proxy kong:8000
833
+
834
+ \theader {
835
+ \t\tStrict-Transport-Security "max-age=31536000; includeSubDomains"
836
+ \t\tX-Frame-Options "SAMEORIGIN"
837
+ \t\tX-Content-Type-Options "nosniff"
838
+ \t}
839
+ }
840
+ `
841
+ }
842
+
843
+ function productionPgbouncerIni(): string {
844
+ return `[databases]
845
+ * = host=db port=5432
846
+
847
+ [pgbouncer]
848
+ listen_addr = 0.0.0.0
849
+ listen_port = 6432
850
+ auth_type = md5
851
+ auth_file = /etc/pgbouncer/userlist.txt
852
+ pool_mode = transaction
853
+ default_pool_size = 20
854
+ max_db_connections = 60
855
+ max_client_conn = 100
856
+ server_reset_query = DEALLOCATE ALL
857
+ ignore_startup_parameters = extra_float_digits
858
+ `
859
+ }
860
+
861
+ function productionUserlist(pgPassword: string): string {
862
+ // PgBouncer md5 format: "md5" + md5(password + username)
863
+ const md5Hash = (s: string) => {
864
+ const { createHash } = require("node:crypto") as typeof import("node:crypto")
865
+ return createHash("md5").update(s).digest("hex")
866
+ }
867
+ const postgresHash = "md5" + md5Hash(pgPassword + "postgres")
868
+ const authenticatorHash = "md5" + md5Hash(pgPassword + "authenticator")
869
+
870
+ return `# PgBouncer userlist — generated by supatype self-host setup
871
+ # Regenerate by running: supatype self-host setup
872
+ "postgres" "${postgresHash}"
873
+ "authenticator" "${authenticatorHash}"
874
+ `
875
+ }
876
+
877
+ function deployScript(domain: string): string {
878
+ return `#!/usr/bin/env bash
879
+ # deploy.sh — generated by supatype self-host setup
880
+ # Run once on a fresh VPS: bash deploy.sh
881
+ set -euo pipefail
882
+
883
+ DOMAIN="${domain}"
884
+
885
+ echo "Checking prerequisites..."
886
+
887
+ # Check Docker
888
+ if ! command -v docker &>/dev/null; then
889
+ echo "Docker not found. Installing..."
890
+ curl -fsSL https://get.docker.com | sh
891
+ usermod -aG docker "$USER"
892
+ newgrp docker
893
+ fi
894
+
895
+ # Check ports 80 and 443 are available
896
+ for port in 80 443; do
897
+ if ss -tlnp 2>/dev/null | grep -q ":$port " ; then
898
+ echo "Error: Port $port is already in use. Free it before running deploy.sh."
899
+ exit 1
900
+ fi
901
+ done
902
+
903
+ echo "Loading environment..."
904
+ if [ ! -f .env.production ]; then
905
+ echo "Error: .env.production not found in $(pwd)"
906
+ exit 1
907
+ fi
908
+
909
+ # Export env vars from .env.production
910
+ set -a; source .env.production; set +a
911
+
912
+ echo "Starting services..."
913
+ docker compose up -d --wait
914
+
915
+ echo "Waiting for health checks..."
916
+ timeout=120
917
+ elapsed=0
918
+ while ! docker compose ps --format json 2>/dev/null | grep -q '"Health":"healthy"'; do
919
+ sleep 5
920
+ elapsed=$((elapsed + 5))
921
+ if [ $elapsed -ge $timeout ]; then
922
+ echo "Timeout waiting for services to become healthy."
923
+ docker compose ps
924
+ exit 1
925
+ fi
926
+ done
927
+
928
+ echo ""
929
+ echo "Deployment complete!"
930
+ echo "Your app is live at: https://$DOMAIN"
931
+ `
932
+ }