@supatype/cli 0.1.0-alpha.12 → 0.1.0-alpha.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +207 -89
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/app-config.d.ts +10 -0
- package/dist/app-config.d.ts.map +1 -1
- package/dist/app-config.js +72 -0
- package/dist/app-config.js.map +1 -1
- package/dist/binary-cache.d.ts +11 -3
- package/dist/binary-cache.d.ts.map +1 -1
- package/dist/binary-cache.js +62 -39
- package/dist/binary-cache.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/add.d.ts +3 -0
- package/dist/commands/add.d.ts.map +1 -0
- package/dist/commands/add.js +83 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/app.js +2 -2
- package/dist/commands/app.js.map +1 -1
- package/dist/commands/init.d.ts +29 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +569 -90
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/keys.d.ts +15 -1
- package/dist/commands/keys.d.ts.map +1 -1
- package/dist/commands/keys.js +39 -4
- package/dist/commands/keys.js.map +1 -1
- package/dist/engine-client.d.ts.map +1 -1
- package/dist/engine-client.js +12 -4
- package/dist/engine-client.js.map +1 -1
- package/dist/kong-config.d.ts +9 -0
- package/dist/kong-config.d.ts.map +1 -1
- package/dist/kong-config.js +18 -1
- package/dist/kong-config.js.map +1 -1
- package/dist/project-config.d.ts +16 -0
- package/dist/project-config.d.ts.map +1 -1
- package/dist/project-config.js +34 -0
- package/dist/project-config.js.map +1 -1
- package/dist/prompts.d.ts +8 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +20 -0
- package/dist/prompts.js.map +1 -0
- package/dist/scripts/postinstall.js +5 -1
- package/dist/scripts/postinstall.js.map +1 -1
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +61 -17
- package/dist/self-host-compose.js.map +1 -1
- package/package.json +2 -1
- package/src/app-config.ts +80 -0
- package/src/binary-cache.ts +64 -42
- package/src/cli.ts +2 -0
- package/src/commands/add.ts +94 -0
- package/src/commands/app.ts +2 -2
- package/src/commands/init.ts +738 -88
- package/src/commands/keys.ts +49 -4
- package/src/engine-client.ts +11 -4
- package/src/kong-config.ts +24 -1
- package/src/project-config.ts +45 -0
- package/src/prompts.ts +21 -0
- package/src/scripts/postinstall.ts +7 -1
- package/src/self-host-compose.ts +61 -17
- package/tests/config.test.ts +26 -0
- package/tests/init.test.ts +128 -15
- package/tests/minisign.test.ts +102 -0
- package/tests/runtime-contract.test.ts +111 -1
- package/tsconfig.tsbuildinfo +1 -1
package/src/commands/keys.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Command } from "commander"
|
|
2
|
-
import { readFileSync, existsSync } from "node:fs"
|
|
2
|
+
import { readFileSync, existsSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { resolve } from "node:path"
|
|
4
4
|
import { signJwt } from "../jwt.js"
|
|
5
5
|
|
|
@@ -41,13 +41,58 @@ export function registerKeys(program: Command): void {
|
|
|
41
41
|
|
|
42
42
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
/** Mint a long-lived anon + service_role JWT pair from a secret. */
|
|
45
|
+
export function signKeyPair(
|
|
46
|
+
secret: string,
|
|
47
|
+
expYears = 10,
|
|
48
|
+
): { anonKey: string; serviceKey: string } {
|
|
49
|
+
const now = Math.floor(Date.now() / 1000)
|
|
50
|
+
const exp = now + expYears * 365 * 24 * 60 * 60
|
|
51
|
+
return {
|
|
52
|
+
anonKey: signJwt({ iss: "supatype", role: "anon", iat: now, exp }, secret),
|
|
53
|
+
serviceKey: signJwt({ iss: "supatype", role: "service_role", iat: now, exp }, secret),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generate keys from the JWT_SECRET found in `dir`'s .env (or env var) and
|
|
59
|
+
* rewrite the ANON_KEY / SERVICE_ROLE_KEY lines in that .env file in place.
|
|
60
|
+
* Returns the minted pair, or null if no secret could be resolved.
|
|
61
|
+
*/
|
|
62
|
+
export function generateAndWriteKeys(
|
|
63
|
+
dir: string,
|
|
64
|
+
expYears = 10,
|
|
65
|
+
): { anonKey: string; serviceKey: string } | null {
|
|
66
|
+
const secret = resolveSecret(dir)
|
|
67
|
+
if (!secret) return null
|
|
68
|
+
|
|
69
|
+
const { anonKey, serviceKey } = signKeyPair(secret, expYears)
|
|
70
|
+
|
|
71
|
+
const envPath = resolve(dir, ".env")
|
|
72
|
+
if (existsSync(envPath)) {
|
|
73
|
+
let content = readFileSync(envPath, "utf8")
|
|
74
|
+
content = upsertEnvVar(content, "ANON_KEY", anonKey)
|
|
75
|
+
content = upsertEnvVar(content, "SERVICE_ROLE_KEY", serviceKey)
|
|
76
|
+
writeFileSync(envPath, content, "utf8")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { anonKey, serviceKey }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function upsertEnvVar(content: string, key: string, value: string): string {
|
|
83
|
+
const re = new RegExp(`^${key}=.*$`, "m")
|
|
84
|
+
if (re.test(content)) return content.replace(re, `${key}=${value}`)
|
|
85
|
+
const sep = content.endsWith("\n") || content.length === 0 ? "" : "\n"
|
|
86
|
+
return `${content}${sep}${key}=${value}\n`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function resolveSecret(dir: string = process.cwd()): string | undefined {
|
|
45
90
|
// 1. Check environment variable
|
|
46
91
|
const fromEnv = process.env["JWT_SECRET"]
|
|
47
92
|
if (fromEnv) return fromEnv
|
|
48
93
|
|
|
49
|
-
// 2. Parse .env file in
|
|
50
|
-
const envPath = resolve(
|
|
94
|
+
// 2. Parse .env file in the target directory
|
|
95
|
+
const envPath = resolve(dir, ".env")
|
|
51
96
|
if (!existsSync(envPath)) return undefined
|
|
52
97
|
|
|
53
98
|
try {
|
package/src/engine-client.ts
CHANGED
|
@@ -13,7 +13,8 @@ import { mkdirSync, writeFileSync, unlinkSync, existsSync, readdirSync } from "n
|
|
|
13
13
|
import { tmpdir, homedir } from "node:os"
|
|
14
14
|
import { join } from "node:path"
|
|
15
15
|
import { loadConfig } from "./config.js"
|
|
16
|
-
import {
|
|
16
|
+
import { currentPlatform, cachePath } from "./binary-cache.js"
|
|
17
|
+
import { ensureBinary } from "./ensure-binary.js"
|
|
17
18
|
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
19
20
|
// Types (kept for backward compatibility with existing callers)
|
|
@@ -86,10 +87,16 @@ async function getEngineBin(): Promise<string> {
|
|
|
86
87
|
|
|
87
88
|
try {
|
|
88
89
|
const config = loadConfig(cwd)
|
|
89
|
-
|
|
90
|
+
// Download-on-miss (with retry) so a fresh machine or a failed postinstall
|
|
91
|
+
// self-heals on first use instead of silently skipping type/admin refresh.
|
|
92
|
+
_engineBin = await ensureBinary("engine", config)
|
|
90
93
|
return _engineBin
|
|
91
|
-
} catch {
|
|
92
|
-
//
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// A real download/verification failure must surface, not fall back to a
|
|
96
|
+
// possibly-stale cached binary from a different version.
|
|
97
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
98
|
+
if (message.includes("Failed to download")) throw err
|
|
99
|
+
// Otherwise (no valid project config) fall through to default cache scan.
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
// No config found — scan the cache for any available engine binary.
|
package/src/kong-config.ts
CHANGED
|
@@ -18,6 +18,11 @@ export interface KongDeclarativeOptions {
|
|
|
18
18
|
studioServiceUrl?: string | undefined
|
|
19
19
|
/** See {@link RuntimeRouteOptions.studioStripPath}. */
|
|
20
20
|
studioStripPath?: boolean | undefined
|
|
21
|
+
/**
|
|
22
|
+
* When set, append a global Kong `acme` plugin (Let's Encrypt) with Redis/Valkey
|
|
23
|
+
* storage so the self-host gateway provisions and renews TLS automatically.
|
|
24
|
+
*/
|
|
25
|
+
acme?: { email: string; domain: string; redisHost: string } | undefined
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
/** Escape a string for use inside YAML double quotes. */
|
|
@@ -85,9 +90,27 @@ ${route.paths.map((path) => ` - ${path}`).join("\n")}
|
|
|
85
90
|
${protocols}${routePlugins}${graphqlPlugins}`
|
|
86
91
|
}).join("\n")
|
|
87
92
|
|
|
93
|
+
const acme = opts.acme
|
|
94
|
+
const pluginsBlock = acme
|
|
95
|
+
? `
|
|
96
|
+
plugins:
|
|
97
|
+
- name: acme
|
|
98
|
+
config:
|
|
99
|
+
account_email: ${yamlQuotedString(acme.email)}
|
|
100
|
+
tos_accepted: true
|
|
101
|
+
domains:
|
|
102
|
+
- ${yamlQuotedString(acme.domain)}
|
|
103
|
+
storage: redis
|
|
104
|
+
storage_config:
|
|
105
|
+
redis:
|
|
106
|
+
host: ${yamlQuotedString(acme.redisHost)}
|
|
107
|
+
port: 6379
|
|
108
|
+
`
|
|
109
|
+
: ""
|
|
110
|
+
|
|
88
111
|
return `_format_version: "3.0"
|
|
89
112
|
${consumersBlock}
|
|
90
113
|
services:
|
|
91
114
|
${servicesBlock}
|
|
92
|
-
`
|
|
115
|
+
${pluginsBlock}`
|
|
93
116
|
}
|
package/src/project-config.ts
CHANGED
|
@@ -59,6 +59,16 @@ export interface SupatypeProjectConfig {
|
|
|
59
59
|
postgrestPort?: number
|
|
60
60
|
/** Domain for ACME TLS certificate (mode=standalone). */
|
|
61
61
|
domain?: string
|
|
62
|
+
/**
|
|
63
|
+
* TLS for self-host HTTPS (Kong ACME / Let's Encrypt).
|
|
64
|
+
* Requires `mode: "standalone"` and a non-empty `domain`.
|
|
65
|
+
*/
|
|
66
|
+
tls?: {
|
|
67
|
+
/** ACME contact email for Let's Encrypt (required to enable HTTPS). */
|
|
68
|
+
email?: string
|
|
69
|
+
/** "kong" (default) = Kong acme plugin; "none" = stay HTTP even with a domain. */
|
|
70
|
+
provider?: "kong" | "none"
|
|
71
|
+
}
|
|
62
72
|
}
|
|
63
73
|
app: {
|
|
64
74
|
/**
|
|
@@ -298,6 +308,23 @@ export function mergeProjectConfig(
|
|
|
298
308
|
...(base.admin !== undefined || override.admin !== undefined
|
|
299
309
|
? { admin: { ...base.admin, ...override.admin } as NonNullable<SupatypeProjectConfig["admin"]> }
|
|
300
310
|
: {}),
|
|
311
|
+
...(base.environments !== undefined || override.environments !== undefined
|
|
312
|
+
? (() => {
|
|
313
|
+
const b = base.environments
|
|
314
|
+
const o = override.environments
|
|
315
|
+
const mergedBranchDefaults =
|
|
316
|
+
b?.branchDefaults !== undefined || o?.branchDefaults !== undefined
|
|
317
|
+
? { ...(b?.branchDefaults ?? {}), ...(o?.branchDefaults ?? {}) }
|
|
318
|
+
: undefined
|
|
319
|
+
return {
|
|
320
|
+
environments: {
|
|
321
|
+
...b,
|
|
322
|
+
...o,
|
|
323
|
+
...(mergedBranchDefaults !== undefined ? { branchDefaults: mergedBranchDefaults } : {}),
|
|
324
|
+
} as NonNullable<SupatypeProjectConfig["environments"]>,
|
|
325
|
+
}
|
|
326
|
+
})()
|
|
327
|
+
: {}),
|
|
301
328
|
}
|
|
302
329
|
}
|
|
303
330
|
|
|
@@ -373,6 +400,24 @@ export function serverBaseUrl(cfg: SupatypeProjectConfig): string | undefined {
|
|
|
373
400
|
}
|
|
374
401
|
}
|
|
375
402
|
|
|
403
|
+
/**
|
|
404
|
+
* True when `supatype self-host compose` should render Kong ACME TLS (Let's Encrypt).
|
|
405
|
+
* Gated on a real self-host render (not `supatype dev`), standalone mode, a non-empty
|
|
406
|
+
* domain, an ACME contact email, and `tls.provider !== "none"`.
|
|
407
|
+
*/
|
|
408
|
+
export function selfHostTlsEnabled(
|
|
409
|
+
cfg: SupatypeProjectConfig,
|
|
410
|
+
devLocal = false,
|
|
411
|
+
): boolean {
|
|
412
|
+
if (devLocal) return false
|
|
413
|
+
if (cfg.server.mode !== "standalone") return false
|
|
414
|
+
const domain = cfg.server.domain?.trim()
|
|
415
|
+
if (!domain) return false
|
|
416
|
+
const tls = cfg.server.tls
|
|
417
|
+
if (!tls || tls.provider === "none") return false
|
|
418
|
+
return Boolean(tls.email?.trim())
|
|
419
|
+
}
|
|
420
|
+
|
|
376
421
|
/** Resolved runtime provider (`config.provider` ?? `database.provider` ?? native). */
|
|
377
422
|
export function resolveRuntimeProvider(cfg: SupatypeProjectConfig): "native" | "docker" {
|
|
378
423
|
return cfg.provider ?? cfg.database.provider ?? "native"
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as p from "@clack/prompts"
|
|
2
|
+
import { SUPATYPE_ASCII_LOGO_WORDMARK, colorLogoLines } from "./dev-logo.js"
|
|
3
|
+
|
|
4
|
+
/** Print the coloured Supatype ASCII wordmark at the top of an interactive command. */
|
|
5
|
+
export function printLogo(): void {
|
|
6
|
+
console.log()
|
|
7
|
+
console.log(colorLogoLines([...SUPATYPE_ASCII_LOGO_WORDMARK]).join("\n"))
|
|
8
|
+
console.log()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Unwrap a clack prompt result, exiting cleanly when the user cancels (Ctrl-C).
|
|
13
|
+
* Shared by all interactive commands so cancellation behaves consistently.
|
|
14
|
+
*/
|
|
15
|
+
export function ensureNotCancelled<T>(value: T | symbol, cancelMessage = "Cancelled."): T {
|
|
16
|
+
if (p.isCancel(value)) {
|
|
17
|
+
p.cancel(cancelMessage)
|
|
18
|
+
process.exit(0)
|
|
19
|
+
}
|
|
20
|
+
return value as T
|
|
21
|
+
}
|
|
@@ -38,7 +38,13 @@ async function main() {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
if (anyFailed) {
|
|
41
|
-
|
|
41
|
+
// npm hides postinstall output unless --foreground-scripts, so don't rely on
|
|
42
|
+
// this being seen: the CLI re-attempts the download (with retry) on first use.
|
|
43
|
+
console.error(
|
|
44
|
+
"[supatype] Some component binaries failed to download. " +
|
|
45
|
+
"They will be re-downloaded automatically on first use; " +
|
|
46
|
+
"run 'supatype update' to retry now.",
|
|
47
|
+
)
|
|
42
48
|
} else {
|
|
43
49
|
console.log("[supatype] All component binaries downloaded successfully.")
|
|
44
50
|
}
|
package/src/self-host-compose.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
2
|
import { dirname, join, relative, resolve } from "node:path"
|
|
3
3
|
import { spawnSync } from "node:child_process"
|
|
4
|
-
import { preferredFunctionsPathFromProject, type SupatypeProjectConfig } from "./project-config.js"
|
|
4
|
+
import { preferredFunctionsPathFromProject, selfHostTlsEnabled, type SupatypeProjectConfig } from "./project-config.js"
|
|
5
5
|
import { hasEngineOverride, hasStudioOverride, pinnedVersion, fetchLatestVersion, VERSION_PIN_LOCAL } from "./binary-cache.js"
|
|
6
6
|
import { buildKongDeclarative } from "./kong-config.js"
|
|
7
7
|
|
|
@@ -228,6 +228,11 @@ export function renderSelfHostCompose(
|
|
|
228
228
|
const projectMount = projectMountPath(cwd)
|
|
229
229
|
const kongMount = kongMountPath(cwd)
|
|
230
230
|
const devLocal = options?.devLocal === true
|
|
231
|
+
const tlsEnabled = selfHostTlsEnabled(config, devLocal)
|
|
232
|
+
const domain = config.server.domain?.trim() ?? ""
|
|
233
|
+
// When TLS is on, default external URLs to https://<domain> so auth links/redirects use HTTPS.
|
|
234
|
+
const externalUrlFallback = tlsEnabled ? `https://${domain}` : "http://localhost:18473"
|
|
235
|
+
const siteUrlFallback = tlsEnabled ? `https://${domain}` : "http://localhost:3000"
|
|
231
236
|
const studioHostDev = devLocal && hasStudioOverride(config)
|
|
232
237
|
const appEnv = serverAppEnvForCompose(config, devLocal)
|
|
233
238
|
const staticDir = staticDirForCompose(config) ?? "./dist"
|
|
@@ -239,7 +244,7 @@ export function renderSelfHostCompose(
|
|
|
239
244
|
studio:
|
|
240
245
|
${studioService}
|
|
241
246
|
environment:
|
|
242
|
-
SUPATYPE_CLOUD_JSON: '{"url":"\${API_EXTERNAL_URL
|
|
247
|
+
SUPATYPE_CLOUD_JSON: '{"url":"\${API_EXTERNAL_URL:-${externalUrlFallback}}","anonKey":"\${ANON_KEY:-}"}'
|
|
243
248
|
expose:
|
|
244
249
|
- "3002"
|
|
245
250
|
`
|
|
@@ -270,6 +275,43 @@ ${studioService}
|
|
|
270
275
|
- "9000:9000"
|
|
271
276
|
- "9001:9001"
|
|
272
277
|
`
|
|
278
|
+
const kongTlsEnv = tlsEnabled
|
|
279
|
+
? ` KONG_PROXY_LISTEN: "0.0.0.0:8000, 0.0.0.0:8443 ssl"
|
|
280
|
+
KONG_LUA_SSL_TRUSTED_CERTIFICATE: system
|
|
281
|
+
`
|
|
282
|
+
: ""
|
|
283
|
+
const kongPorts = tlsEnabled
|
|
284
|
+
? ` - "80:8000"
|
|
285
|
+
- "443:8443"`
|
|
286
|
+
: ` - "\${SUPATYPE_KONG_PORT:-18473}:8000"`
|
|
287
|
+
const kongTlsDependsOn = tlsEnabled ? "\n - valkey" : ""
|
|
288
|
+
const valkeyBlock = tlsEnabled
|
|
289
|
+
? `
|
|
290
|
+
valkey:
|
|
291
|
+
image: \${SUPATYPE_VALKEY_IMAGE:-valkey/valkey:8-alpine}
|
|
292
|
+
command: ["valkey-server", "--appendonly", "yes"]
|
|
293
|
+
expose:
|
|
294
|
+
- "6379"
|
|
295
|
+
volumes:
|
|
296
|
+
- valkey-data:/data
|
|
297
|
+
`
|
|
298
|
+
: ""
|
|
299
|
+
const tlsHintComment = tlsEnabled
|
|
300
|
+
? ""
|
|
301
|
+
: ` # HTTPS is off. To enable automatic TLS (Let's Encrypt) for production, set in supatype.config.ts:
|
|
302
|
+
# server: { mode: "standalone", domain: "your.domain", tls: { email: "you@example.com" } }
|
|
303
|
+
# then re-run \`supatype self-host compose up -d\`. Kong publishes :80/:443 and provisions certs automatically.
|
|
304
|
+
`
|
|
305
|
+
const volumesBlock = tlsEnabled
|
|
306
|
+
? `volumes:
|
|
307
|
+
db-data:
|
|
308
|
+
minio-data:
|
|
309
|
+
valkey-data:
|
|
310
|
+
`
|
|
311
|
+
: `volumes:
|
|
312
|
+
db-data:
|
|
313
|
+
minio-data:
|
|
314
|
+
`
|
|
273
315
|
|
|
274
316
|
return `# Generated by supatype self-host compose
|
|
275
317
|
# Kong → supatype-server (unified gateway) → internal PostgREST / storage / etc.
|
|
@@ -330,12 +372,12 @@ ${dbPorts} volumes:
|
|
|
330
372
|
SUPATYPE_FUNCTIONS_ROOT: /project/functions
|
|
331
373
|
SUPATYPE_DENO_FUNCTIONS_DIR: /project/functions
|
|
332
374
|
PORT: "8001"
|
|
333
|
-
SUPATYPE_URL: \${API_EXTERNAL_URL
|
|
375
|
+
SUPATYPE_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
|
|
334
376
|
SUPATYPE_ANON_KEY: \${ANON_KEY:-}
|
|
335
377
|
SUPATYPE_SERVICE_ROLE_KEY: \${SERVICE_ROLE_KEY:-}
|
|
336
378
|
STRIPE_SECRET_KEY: \${STRIPE_SECRET_KEY:-}
|
|
337
379
|
STRIPE_WEBHOOK_SECRET: \${STRIPE_WEBHOOK_SECRET:-}
|
|
338
|
-
SITE_URL: \${SITE_URL:-\${API_EXTERNAL_URL
|
|
380
|
+
SITE_URL: \${SITE_URL:-\${API_EXTERNAL_URL:-${externalUrlFallback}}}
|
|
339
381
|
depends_on:
|
|
340
382
|
db:
|
|
341
383
|
condition: service_healthy
|
|
@@ -374,7 +416,7 @@ ${serverPorts} volumes:
|
|
|
374
416
|
SUPATYPE_POSTGREST_URL: http://postgrest:3000
|
|
375
417
|
SUPATYPE_GRAPHQL_URL: http://postgrest:3000
|
|
376
418
|
SUPATYPE_STORAGE_URL: http://storage:5000
|
|
377
|
-
SUPATYPE_URL: \${API_EXTERNAL_URL
|
|
419
|
+
SUPATYPE_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
|
|
378
420
|
SUPATYPE_ANON_KEY: \${ANON_KEY:-}
|
|
379
421
|
SUPATYPE_SERVICE_ROLE_KEY: \${SERVICE_ROLE_KEY:-}
|
|
380
422
|
SUPATYPE_SQL_DATABASE_URL: "postgresql://\${POSTGRES_USER:-supatype_admin}:\${POSTGRES_PASSWORD:-postgres}@db:5432/\${POSTGRES_DB:-supatype}"
|
|
@@ -384,11 +426,11 @@ ${serverPorts} volumes:
|
|
|
384
426
|
${appEnv}
|
|
385
427
|
GOTRUE_API_HOST: 0.0.0.0
|
|
386
428
|
GOTRUE_API_PORT: 9999
|
|
387
|
-
API_EXTERNAL_URL: \${API_EXTERNAL_URL
|
|
388
|
-
GOTRUE_API_EXTERNAL_URL: \${API_EXTERNAL_URL
|
|
429
|
+
API_EXTERNAL_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
|
|
430
|
+
GOTRUE_API_EXTERNAL_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
|
|
389
431
|
GOTRUE_DB_DRIVER: postgres
|
|
390
432
|
GOTRUE_DB_DATABASE_URL: "postgres://\${POSTGRES_USER:-supatype_admin}:\${POSTGRES_PASSWORD:-postgres}@db:5432/\${POSTGRES_DB:-supatype}?search_path=auth"
|
|
391
|
-
GOTRUE_SITE_URL: \${SITE_URL
|
|
433
|
+
GOTRUE_SITE_URL: \${SITE_URL:-${siteUrlFallback}}
|
|
392
434
|
GOTRUE_JWT_SECRET: \${JWT_SECRET:-super-secret-jwt-token-change-in-production}
|
|
393
435
|
GOTRUE_JWT_EXP: 3600
|
|
394
436
|
GOTRUE_JWT_AUD: authenticated
|
|
@@ -428,8 +470,7 @@ ${minioPorts} volumes:
|
|
|
428
470
|
depends_on:
|
|
429
471
|
db:
|
|
430
472
|
condition: service_healthy
|
|
431
|
-
${studioBlock}
|
|
432
|
-
kong:
|
|
473
|
+
${studioBlock}${valkeyBlock}${tlsHintComment} kong:
|
|
433
474
|
image: kong:3.6
|
|
434
475
|
environment:
|
|
435
476
|
KONG_DATABASE: "off"
|
|
@@ -438,17 +479,14 @@ ${studioBlock}
|
|
|
438
479
|
KONG_ADMIN_ACCESS_LOG: /dev/stdout
|
|
439
480
|
KONG_PROXY_ERROR_LOG: /dev/stderr
|
|
440
481
|
KONG_ADMIN_ERROR_LOG: /dev/stderr
|
|
441
|
-
volumes:
|
|
482
|
+
${kongTlsEnv} volumes:
|
|
442
483
|
- ${kongMount}:/etc/kong/kong.yml:ro
|
|
443
484
|
ports:
|
|
444
|
-
|
|
485
|
+
${kongPorts}
|
|
445
486
|
depends_on:
|
|
446
|
-
${kongDependsOn}
|
|
487
|
+
${kongDependsOn}${kongTlsDependsOn}
|
|
447
488
|
|
|
448
|
-
|
|
449
|
-
db-data:
|
|
450
|
-
minio-data:
|
|
451
|
-
`
|
|
489
|
+
${volumesBlock}`
|
|
452
490
|
}
|
|
453
491
|
|
|
454
492
|
function ensureComposeManifest(cwd: string): void {
|
|
@@ -480,6 +518,9 @@ export function writeSelfHostCompose(
|
|
|
480
518
|
ensureComposeManifest(cwd)
|
|
481
519
|
writeFileSync(paths.composePath, renderSelfHostCompose(config, cwd, options), "utf8")
|
|
482
520
|
const studioHostDev = options?.devLocal === true && hasStudioOverride(config)
|
|
521
|
+
const tlsEnabled = selfHostTlsEnabled(config, options?.devLocal === true)
|
|
522
|
+
const domain = config.server.domain?.trim()
|
|
523
|
+
const acmeEmail = config.server.tls?.email?.trim()
|
|
483
524
|
writeFileSync(
|
|
484
525
|
paths.kongPath,
|
|
485
526
|
buildKongDeclarative({
|
|
@@ -488,6 +529,9 @@ export function writeSelfHostCompose(
|
|
|
488
529
|
studioServiceUrl: COMPOSE_STUDIO_HOST_URL,
|
|
489
530
|
studioStripPath: false,
|
|
490
531
|
}),
|
|
532
|
+
...(tlsEnabled && domain && acmeEmail
|
|
533
|
+
? { acme: { email: acmeEmail, domain, redisHost: "valkey" } }
|
|
534
|
+
: {}),
|
|
491
535
|
}),
|
|
492
536
|
"utf8",
|
|
493
537
|
)
|
package/tests/config.test.ts
CHANGED
|
@@ -249,4 +249,30 @@ describe("mergeProjectConfig()", () => {
|
|
|
249
249
|
expect(merged.app.start).toBe("dev:site")
|
|
250
250
|
expect(merged.app.static_dir).toBe("./dist")
|
|
251
251
|
})
|
|
252
|
+
|
|
253
|
+
it("preserves base environments when a local override sets only server.mode", () => {
|
|
254
|
+
const base = defineConfig({
|
|
255
|
+
...minimalProject("p"),
|
|
256
|
+
server: { mode: "standalone", domain: "api.example.com" },
|
|
257
|
+
environments: { default: "production" },
|
|
258
|
+
})
|
|
259
|
+
const merged = mergeProjectConfig(base, { server: { mode: "dev" } })
|
|
260
|
+
expect(merged.server.mode).toBe("dev")
|
|
261
|
+
expect(merged.environments?.default).toBe("production")
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it("deep-merges environments.branchDefaults across base and override", () => {
|
|
265
|
+
const base = defineConfig({
|
|
266
|
+
...minimalProject("p"),
|
|
267
|
+
environments: { default: "production", branchDefaults: { main: "production" } },
|
|
268
|
+
})
|
|
269
|
+
const merged = mergeProjectConfig(base, {
|
|
270
|
+
environments: { branchDefaults: { staging: "preview" } },
|
|
271
|
+
})
|
|
272
|
+
expect(merged.environments?.default).toBe("production")
|
|
273
|
+
expect(merged.environments?.branchDefaults).toEqual({
|
|
274
|
+
main: "production",
|
|
275
|
+
staging: "preview",
|
|
276
|
+
})
|
|
277
|
+
})
|
|
252
278
|
})
|
package/tests/init.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"
|
|
|
2
2
|
import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { join } from "node:path"
|
|
4
4
|
import { tmpdir } from "node:os"
|
|
5
|
-
import { scaffold } from "../src/commands/init.js"
|
|
5
|
+
import { scaffold, defaultScaffoldOptions } from "../src/commands/init.js"
|
|
6
6
|
|
|
7
7
|
let tmpRoot: string
|
|
8
8
|
|
|
@@ -17,7 +17,7 @@ afterEach(() => {
|
|
|
17
17
|
|
|
18
18
|
describe("scaffold()", () => {
|
|
19
19
|
it("creates all expected files", () => {
|
|
20
|
-
scaffold(tmpRoot, "my-app")
|
|
20
|
+
scaffold(tmpRoot, defaultScaffoldOptions("my-app"))
|
|
21
21
|
|
|
22
22
|
const expected = [
|
|
23
23
|
"package.json",
|
|
@@ -35,7 +35,7 @@ describe("scaffold()", () => {
|
|
|
35
35
|
})
|
|
36
36
|
|
|
37
37
|
it("supatype.config.ts embeds the project name and exports defineConfig", () => {
|
|
38
|
-
scaffold(tmpRoot, "blog-app")
|
|
38
|
+
scaffold(tmpRoot, defaultScaffoldOptions("blog-app"))
|
|
39
39
|
const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
|
|
40
40
|
expect(content).toContain("blog-app")
|
|
41
41
|
expect(content).toContain("defineConfig")
|
|
@@ -46,7 +46,7 @@ describe("scaffold()", () => {
|
|
|
46
46
|
})
|
|
47
47
|
|
|
48
48
|
it("package.json includes @supatype/cli and @supatype/types", () => {
|
|
49
|
-
scaffold(tmpRoot, "pkg-app")
|
|
49
|
+
scaffold(tmpRoot, defaultScaffoldOptions("pkg-app"))
|
|
50
50
|
const content = readFileSync(join(tmpRoot, "package.json"), "utf8")
|
|
51
51
|
expect(content).toContain("@supatype/cli")
|
|
52
52
|
expect(content).toContain("@supatype/types")
|
|
@@ -56,18 +56,18 @@ describe("scaffold()", () => {
|
|
|
56
56
|
it("skips package.json when it already exists", () => {
|
|
57
57
|
const pkgPath = join(tmpRoot, "package.json")
|
|
58
58
|
writeFileSync(pkgPath, '{"name":"existing"}', "utf8")
|
|
59
|
-
scaffold(tmpRoot, "my-app")
|
|
59
|
+
scaffold(tmpRoot, defaultScaffoldOptions("my-app"))
|
|
60
60
|
expect(readFileSync(pkgPath, "utf8")).toBe('{"name":"existing"}')
|
|
61
61
|
})
|
|
62
62
|
|
|
63
63
|
it("supatype.config.ts documents self-host workflow", () => {
|
|
64
|
-
scaffold(tmpRoot, "my-app")
|
|
64
|
+
scaffold(tmpRoot, defaultScaffoldOptions("my-app"))
|
|
65
65
|
const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
|
|
66
66
|
expect(content).toContain("self-host")
|
|
67
67
|
})
|
|
68
68
|
|
|
69
69
|
it(".env contains DATABASE_URL, JWT_SECRET, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB", () => {
|
|
70
|
-
scaffold(tmpRoot, "my-app")
|
|
70
|
+
scaffold(tmpRoot, defaultScaffoldOptions("my-app"))
|
|
71
71
|
const content = readFileSync(join(tmpRoot, ".env"), "utf8")
|
|
72
72
|
expect(content).toContain("DATABASE_URL=")
|
|
73
73
|
expect(content).toContain("JWT_SECRET=")
|
|
@@ -77,23 +77,24 @@ describe("scaffold()", () => {
|
|
|
77
77
|
})
|
|
78
78
|
|
|
79
79
|
it(".env contains ANON_KEY, SERVICE_ROLE_KEY, and SITE_URL placeholders", () => {
|
|
80
|
-
scaffold(tmpRoot, "my-app")
|
|
80
|
+
scaffold(tmpRoot, defaultScaffoldOptions("my-app"))
|
|
81
81
|
const content = readFileSync(join(tmpRoot, ".env"), "utf8")
|
|
82
82
|
expect(content).toContain("ANON_KEY=")
|
|
83
83
|
expect(content).toContain("SERVICE_ROLE_KEY=")
|
|
84
84
|
expect(content).toContain("SITE_URL=")
|
|
85
85
|
})
|
|
86
86
|
|
|
87
|
-
it("schema/index.ts exports a
|
|
88
|
-
scaffold(tmpRoot, "my-app")
|
|
87
|
+
it("schema/index.ts exports a Profile model using RFC v2 Model<>", () => {
|
|
88
|
+
scaffold(tmpRoot, defaultScaffoldOptions("my-app"))
|
|
89
89
|
const content = readFileSync(join(tmpRoot, "schema/index.ts"), "utf8")
|
|
90
|
-
expect(content).toContain("export type
|
|
90
|
+
expect(content).toContain("export type Profile")
|
|
91
|
+
expect(content).toContain("display_name")
|
|
91
92
|
expect(content).toContain("Model<")
|
|
92
93
|
expect(content).toContain("access:")
|
|
93
94
|
})
|
|
94
95
|
|
|
95
96
|
it(".gitignore excludes .env, node_modules, and engine binary", () => {
|
|
96
|
-
scaffold(tmpRoot, "my-app")
|
|
97
|
+
scaffold(tmpRoot, defaultScaffoldOptions("my-app"))
|
|
97
98
|
const content = readFileSync(join(tmpRoot, ".gitignore"), "utf8")
|
|
98
99
|
expect(content).toContain(".env")
|
|
99
100
|
expect(content).toContain("node_modules/")
|
|
@@ -102,19 +103,19 @@ describe("scaffold()", () => {
|
|
|
102
103
|
})
|
|
103
104
|
|
|
104
105
|
it("seed.ts references the project name", () => {
|
|
105
|
-
scaffold(tmpRoot, "acme")
|
|
106
|
+
scaffold(tmpRoot, defaultScaffoldOptions("acme"))
|
|
106
107
|
const content = readFileSync(join(tmpRoot, "seed.ts"), "utf8")
|
|
107
108
|
expect(content).toContain("acme")
|
|
108
109
|
})
|
|
109
110
|
|
|
110
111
|
it("different project names produce different config bodies", () => {
|
|
111
|
-
scaffold(tmpRoot, "alpha")
|
|
112
|
+
scaffold(tmpRoot, defaultScaffoldOptions("alpha"))
|
|
112
113
|
const alpha = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
|
|
113
114
|
|
|
114
115
|
const tmp2 = join(tmpdir(), `dt-init-test2-${Date.now()}`)
|
|
115
116
|
mkdirSync(tmp2, { recursive: true })
|
|
116
117
|
try {
|
|
117
|
-
scaffold(tmp2, "beta")
|
|
118
|
+
scaffold(tmp2, defaultScaffoldOptions("beta"))
|
|
118
119
|
const beta = readFileSync(join(tmp2, "supatype.config.ts"), "utf8")
|
|
119
120
|
expect(alpha).toContain("alpha")
|
|
120
121
|
expect(beta).toContain("beta")
|
|
@@ -124,4 +125,116 @@ describe("scaffold()", () => {
|
|
|
124
125
|
rmSync(tmp2, { recursive: true, force: true })
|
|
125
126
|
}
|
|
126
127
|
})
|
|
128
|
+
|
|
129
|
+
it("self-host target emits standalone mode + domain and a local override", () => {
|
|
130
|
+
scaffold(tmpRoot, { ...defaultScaffoldOptions("my-app", "self-host"), domain: "api.example.com" })
|
|
131
|
+
const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
|
|
132
|
+
expect(content).toContain('mode: "standalone"')
|
|
133
|
+
expect(content).toContain('domain: "api.example.com"')
|
|
134
|
+
expect(content).toContain('environments: { default: "production" }')
|
|
135
|
+
expect(existsSync(join(tmpRoot, "supatype.local.config.ts"))).toBe(true)
|
|
136
|
+
const local = readFileSync(join(tmpRoot, "supatype.local.config.ts"), "utf8")
|
|
137
|
+
expect(local).toContain('mode: "dev"')
|
|
138
|
+
expect(local).toContain("Partial<SupatypeConfig>")
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("self-host with a TLS email emits an active tls block", () => {
|
|
142
|
+
scaffold(tmpRoot, {
|
|
143
|
+
...defaultScaffoldOptions("my-app", "self-host"),
|
|
144
|
+
domain: "api.example.com",
|
|
145
|
+
tlsEmail: "ops@example.com",
|
|
146
|
+
})
|
|
147
|
+
const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
|
|
148
|
+
expect(content).toContain('tls: { email: "ops@example.com" }')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("self-host without a TLS email emits a commented tls hint", () => {
|
|
152
|
+
scaffold(tmpRoot, { ...defaultScaffoldOptions("my-app", "self-host"), domain: "api.example.com" })
|
|
153
|
+
const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
|
|
154
|
+
expect(content).toContain('// tls: { email: "you@example.com" }')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("cloud target emits managed mode + environments and a local override", () => {
|
|
158
|
+
scaffold(tmpRoot, defaultScaffoldOptions("my-app", "cloud"))
|
|
159
|
+
const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
|
|
160
|
+
expect(content).toContain('mode: "managed"')
|
|
161
|
+
expect(content).toContain('environments: { default: "production" }')
|
|
162
|
+
expect(existsSync(join(tmpRoot, "supatype.local.config.ts"))).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("later target stays in dev mode with no local override", () => {
|
|
166
|
+
scaffold(tmpRoot, defaultScaffoldOptions("my-app", "later"))
|
|
167
|
+
const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
|
|
168
|
+
expect(content).toContain('mode: "dev"')
|
|
169
|
+
expect(content).not.toContain("environments:")
|
|
170
|
+
expect(existsSync(join(tmpRoot, "supatype.local.config.ts"))).toBe(false)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("static app mode writes the static dir and config block", () => {
|
|
174
|
+
scaffold(tmpRoot, {
|
|
175
|
+
...defaultScaffoldOptions("my-app"),
|
|
176
|
+
app: { mode: "static", staticDir: "./dist" },
|
|
177
|
+
})
|
|
178
|
+
const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
|
|
179
|
+
expect(content).toContain('mode: "static"')
|
|
180
|
+
expect(content).toContain('static_dir: "./dist"')
|
|
181
|
+
expect(existsSync(join(tmpRoot, "dist/.gitkeep"))).toBe(true)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it("proxy app mode writes upstream and start in the config", () => {
|
|
185
|
+
scaffold(tmpRoot, {
|
|
186
|
+
...defaultScaffoldOptions("my-app"),
|
|
187
|
+
app: { mode: "proxy", upstream: "http://localhost:4000", start: "dev" },
|
|
188
|
+
})
|
|
189
|
+
const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
|
|
190
|
+
expect(content).toContain('mode: "proxy"')
|
|
191
|
+
expect(content).toContain('upstream: "http://localhost:4000"')
|
|
192
|
+
expect(content).toContain('start: "dev"')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it("hello-world function scaffolds function files and a functions script", () => {
|
|
196
|
+
scaffold(tmpRoot, { ...defaultScaffoldOptions("my-app"), helloFunction: true })
|
|
197
|
+
expect(existsSync(join(tmpRoot, "functions/hello/index.ts"))).toBe(true)
|
|
198
|
+
expect(existsSync(join(tmpRoot, "functions/_shared/README.md"))).toBe(true)
|
|
199
|
+
expect(existsSync(join(tmpRoot, "functions/.env.local"))).toBe(true)
|
|
200
|
+
const pkg = readFileSync(join(tmpRoot, "package.json"), "utf8")
|
|
201
|
+
expect(pkg).toContain("supatype functions serve")
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it("custom schema path is honored", () => {
|
|
205
|
+
scaffold(tmpRoot, { ...defaultScaffoldOptions("my-app"), schemaPath: "db/schema.ts" })
|
|
206
|
+
expect(existsSync(join(tmpRoot, "db/schema.ts"))).toBe(true)
|
|
207
|
+
const content = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
|
|
208
|
+
expect(content).toContain('path: "db/schema.ts"')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it("s3 storage and resend email reflect in config and .env", () => {
|
|
212
|
+
scaffold(tmpRoot, {
|
|
213
|
+
...defaultScaffoldOptions("my-app"),
|
|
214
|
+
email: "resend",
|
|
215
|
+
storageLocal: "s3",
|
|
216
|
+
storageProduction: "s3",
|
|
217
|
+
})
|
|
218
|
+
const config = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
|
|
219
|
+
expect(config).toContain('email: { provider: "resend" }')
|
|
220
|
+
expect(config).toContain('storage: { provider: "s3" }')
|
|
221
|
+
const env = readFileSync(join(tmpRoot, ".env"), "utf8")
|
|
222
|
+
expect(env).toContain("RESEND_API_KEY=")
|
|
223
|
+
expect(env).toContain("S3_BUCKET=")
|
|
224
|
+
expect(env).toContain("local development and production")
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it("mixed local dev and s3 production writes both storage sections", () => {
|
|
228
|
+
scaffold(tmpRoot, {
|
|
229
|
+
...defaultScaffoldOptions("my-app"),
|
|
230
|
+
storageLocal: "local",
|
|
231
|
+
storageProduction: "s3",
|
|
232
|
+
})
|
|
233
|
+
const config = readFileSync(join(tmpRoot, "supatype.config.ts"), "utf8")
|
|
234
|
+
expect(config).toContain('provider: "local"')
|
|
235
|
+
expect(config).toContain("Production storage: external S3")
|
|
236
|
+
const env = readFileSync(join(tmpRoot, ".env"), "utf8")
|
|
237
|
+
expect(env).toContain("local development — MinIO")
|
|
238
|
+
expect(env).toContain("production — external bucket")
|
|
239
|
+
})
|
|
127
240
|
})
|