@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.
- package/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-test.log +7 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/bin/dev-entry.ts +2 -0
- package/bin/supatype.js +5 -0
- package/dist/app/framework.d.ts +44 -0
- package/dist/app/framework.d.ts.map +1 -0
- package/dist/app/framework.js +200 -0
- package/dist/app/framework.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +55 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/admin.d.ts +4 -0
- package/dist/commands/admin.d.ts.map +1 -0
- package/dist/commands/admin.js +270 -0
- package/dist/commands/admin.js.map +1 -0
- package/dist/commands/app.d.ts +3 -0
- package/dist/commands/app.d.ts.map +1 -0
- package/dist/commands/app.js +235 -0
- package/dist/commands/app.js.map +1 -0
- package/dist/commands/cloud.d.ts +3 -0
- package/dist/commands/cloud.d.ts.map +1 -0
- package/dist/commands/cloud.js +256 -0
- package/dist/commands/cloud.js.map +1 -0
- package/dist/commands/db.d.ts +8 -0
- package/dist/commands/db.d.ts.map +1 -0
- package/dist/commands/db.js +123 -0
- package/dist/commands/db.js.map +1 -0
- package/dist/commands/deploy-types.d.ts +14 -0
- package/dist/commands/deploy-types.d.ts.map +1 -0
- package/dist/commands/deploy-types.js +38 -0
- package/dist/commands/deploy-types.js.map +1 -0
- package/dist/commands/deploy.d.ts +14 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +295 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/dev.d.ts +3 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +428 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/diff.d.ts +3 -0
- package/dist/commands/diff.d.ts.map +1 -0
- package/dist/commands/diff.js +39 -0
- package/dist/commands/diff.js.map +1 -0
- package/dist/commands/engine.d.ts +9 -0
- package/dist/commands/engine.d.ts.map +1 -0
- package/dist/commands/engine.js +99 -0
- package/dist/commands/engine.js.map +1 -0
- package/dist/commands/functions.d.ts +3 -0
- package/dist/commands/functions.d.ts.map +1 -0
- package/dist/commands/functions.js +762 -0
- package/dist/commands/functions.js.map +1 -0
- package/dist/commands/generate.d.ts +3 -0
- package/dist/commands/generate.d.ts.map +1 -0
- package/dist/commands/generate.js +28 -0
- package/dist/commands/generate.js.map +1 -0
- package/dist/commands/init.d.ts +7 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +515 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/keys.d.ts +4 -0
- package/dist/commands/keys.d.ts.map +1 -0
- package/dist/commands/keys.js +57 -0
- package/dist/commands/keys.js.map +1 -0
- package/dist/commands/logs.d.ts +6 -0
- package/dist/commands/logs.d.ts.map +1 -0
- package/dist/commands/logs.js +52 -0
- package/dist/commands/logs.js.map +1 -0
- package/dist/commands/migrate.d.ts +3 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +71 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/plugins.d.ts +3 -0
- package/dist/commands/plugins.d.ts.map +1 -0
- package/dist/commands/plugins.js +431 -0
- package/dist/commands/plugins.js.map +1 -0
- package/dist/commands/pull.d.ts +3 -0
- package/dist/commands/pull.d.ts.map +1 -0
- package/dist/commands/pull.js +73 -0
- package/dist/commands/pull.js.map +1 -0
- package/dist/commands/push.d.ts +3 -0
- package/dist/commands/push.d.ts.map +1 -0
- package/dist/commands/push.js +87 -0
- package/dist/commands/push.js.map +1 -0
- package/dist/commands/seed.d.ts +3 -0
- package/dist/commands/seed.d.ts.map +1 -0
- package/dist/commands/seed.js +22 -0
- package/dist/commands/seed.js.map +1 -0
- package/dist/commands/self-host.d.ts +3 -0
- package/dist/commands/self-host.d.ts.map +1 -0
- package/dist/commands/self-host.js +796 -0
- package/dist/commands/self-host.js.map +1 -0
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +69 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/config.d.ts +106 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +66 -0
- package/dist/config.js.map +1 -0
- package/dist/engine/cache.d.ts +37 -0
- package/dist/engine/cache.d.ts.map +1 -0
- package/dist/engine/cache.js +121 -0
- package/dist/engine/cache.js.map +1 -0
- package/dist/engine/download.d.ts +19 -0
- package/dist/engine/download.d.ts.map +1 -0
- package/dist/engine/download.js +108 -0
- package/dist/engine/download.js.map +1 -0
- package/dist/engine/platform.d.ts +24 -0
- package/dist/engine/platform.d.ts.map +1 -0
- package/dist/engine/platform.js +50 -0
- package/dist/engine/platform.js.map +1 -0
- package/dist/engine/resolve.d.ts +37 -0
- package/dist/engine/resolve.d.ts.map +1 -0
- package/dist/engine/resolve.js +133 -0
- package/dist/engine/resolve.js.map +1 -0
- package/dist/engine/update-notify.d.ts +11 -0
- package/dist/engine/update-notify.d.ts.map +1 -0
- package/dist/engine/update-notify.js +43 -0
- package/dist/engine/update-notify.js.map +1 -0
- package/dist/engine/verify.d.ts +50 -0
- package/dist/engine/verify.d.ts.map +1 -0
- package/dist/engine/verify.js +161 -0
- package/dist/engine/verify.js.map +1 -0
- package/dist/engine-version.d.ts +35 -0
- package/dist/engine-version.d.ts.map +1 -0
- package/dist/engine-version.js +35 -0
- package/dist/engine-version.js.map +1 -0
- package/dist/engine.d.ts +34 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +76 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt.d.ts +3 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +13 -0
- package/dist/jwt.js.map +1 -0
- package/dist/pull-utils.d.ts +16 -0
- package/dist/pull-utils.d.ts.map +1 -0
- package/dist/pull-utils.js +65 -0
- package/dist/pull-utils.js.map +1 -0
- package/dist/scripts/postinstall.d.ts +12 -0
- package/dist/scripts/postinstall.d.ts.map +1 -0
- package/dist/scripts/postinstall.js +31 -0
- package/dist/scripts/postinstall.js.map +1 -0
- package/dist/tsx-runner.d.ts +18 -0
- package/dist/tsx-runner.d.ts.map +1 -0
- package/dist/tsx-runner.js +62 -0
- package/dist/tsx-runner.js.map +1 -0
- package/package.json +36 -0
- package/src/app/framework.ts +249 -0
- package/src/cli.ts +58 -0
- package/src/commands/admin.ts +371 -0
- package/src/commands/app.ts +261 -0
- package/src/commands/cloud.ts +326 -0
- package/src/commands/db.ts +145 -0
- package/src/commands/deploy-types.ts +49 -0
- package/src/commands/deploy.ts +366 -0
- package/src/commands/dev.ts +477 -0
- package/src/commands/diff.ts +61 -0
- package/src/commands/engine.ts +133 -0
- package/src/commands/functions.ts +919 -0
- package/src/commands/generate.ts +31 -0
- package/src/commands/init.ts +532 -0
- package/src/commands/keys.ts +66 -0
- package/src/commands/logs.ts +58 -0
- package/src/commands/migrate.ts +83 -0
- package/src/commands/plugins.ts +508 -0
- package/src/commands/pull.ts +96 -0
- package/src/commands/push.ts +119 -0
- package/src/commands/seed.ts +26 -0
- package/src/commands/self-host.ts +932 -0
- package/src/commands/status.ts +83 -0
- package/src/config.ts +190 -0
- package/src/engine/cache.ts +135 -0
- package/src/engine/download.ts +143 -0
- package/src/engine/platform.ts +66 -0
- package/src/engine/resolve.ts +197 -0
- package/src/engine/update-notify.ts +50 -0
- package/src/engine/verify.ts +206 -0
- package/src/engine-version.ts +39 -0
- package/src/engine.ts +99 -0
- package/src/index.ts +19 -0
- package/src/jwt.ts +14 -0
- package/src/pull-utils.ts +57 -0
- package/src/scripts/postinstall.ts +40 -0
- package/src/tsx-runner.ts +79 -0
- package/tests/cli-help.test.ts +107 -0
- package/tests/config.test.ts +117 -0
- package/tests/engine-distribution.test.ts +418 -0
- package/tests/init.test.ts +184 -0
- package/tests/keys.test.ts +160 -0
- package/tests/pull-utils.test.ts +115 -0
- package/tests/tsx-runner.test.ts +66 -0
- package/tsconfig.json +10 -0
- 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
|
+
}
|