@supatype/cli 0.1.13 → 0.2.0
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 +151 -143
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/cli-version-embedded.d.ts.map +1 -1
- package/dist/cli-version-embedded.js +1 -1
- package/dist/cli-version-embedded.js.map +1 -1
- package/dist/commands/admin.d.ts +5 -5
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +10 -10
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/dev.js +51 -51
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/init.js +1 -1
- package/dist/commands/logs.d.ts +14 -1
- package/dist/commands/logs.d.ts.map +1 -1
- package/dist/commands/logs.js +33 -37
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +3 -0
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/status.d.ts +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +33 -64
- package/dist/commands/status.js.map +1 -1
- package/dist/compose-services.d.ts +59 -0
- package/dist/compose-services.d.ts.map +1 -0
- package/dist/compose-services.js +160 -0
- package/dist/compose-services.js.map +1 -0
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +123 -20
- package/dist/dev-compose.js.map +1 -1
- package/dist/dev-ports.d.ts +24 -0
- package/dist/dev-ports.d.ts.map +1 -1
- package/dist/dev-ports.js +37 -0
- package/dist/dev-ports.js.map +1 -1
- package/dist/docker-postgres.d.ts +1 -1
- package/dist/docker-postgres.d.ts.map +1 -1
- package/dist/docker-postgres.js +2 -2
- package/dist/docker-postgres.js.map +1 -1
- package/dist/engine-floor.d.ts +50 -0
- package/dist/engine-floor.d.ts.map +1 -0
- package/dist/engine-floor.js +80 -0
- package/dist/engine-floor.js.map +1 -0
- package/dist/link.d.ts +1 -1
- package/dist/link.d.ts.map +1 -1
- package/dist/pgbouncer-userlist.d.ts +1 -1
- package/dist/pgbouncer-userlist.d.ts.map +1 -1
- package/dist/pgbouncer-userlist.js +1 -1
- package/dist/pgbouncer-userlist.js.map +1 -1
- package/dist/postgres-ctl.d.ts +5 -1
- package/dist/postgres-ctl.d.ts.map +1 -1
- package/dist/postgres-ctl.js +43 -4
- package/dist/postgres-ctl.js.map +1 -1
- package/dist/project-config.d.ts +4 -4
- package/dist/resolve-target.d.ts +1 -1
- package/dist/resolve-target.d.ts.map +1 -1
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +41 -18
- package/dist/self-host-compose.js.map +1 -1
- package/dist/studio-dev-server.d.ts +8 -1
- package/dist/studio-dev-server.d.ts.map +1 -1
- package/dist/studio-dev-server.js +1 -2
- package/dist/studio-dev-server.js.map +1 -1
- package/package.json +2 -2
- package/src/cli-version-embedded.ts +1 -1
- package/src/commands/admin.ts +10 -10
- package/src/commands/dev.ts +51 -51
- package/src/commands/init.ts +1 -1
- package/src/commands/logs.ts +34 -37
- package/src/commands/push.ts +3 -0
- package/src/commands/status.ts +34 -70
- package/src/compose-services.ts +174 -0
- package/src/config.ts +1 -1
- package/src/dev-compose.ts +130 -20
- package/src/dev-ports.ts +48 -0
- package/src/docker-postgres.ts +2 -2
- package/src/engine-floor.ts +113 -0
- package/src/link.ts +1 -1
- package/src/pgbouncer-userlist.ts +1 -1
- package/src/postgres-ctl.ts +44 -4
- package/src/project-config.ts +4 -4
- package/src/resolve-target.ts +1 -1
- package/src/self-host-compose.ts +41 -18
- package/src/studio-dev-server.ts +9 -3
- package/tests/admin-ensure.test.ts +10 -10
- package/tests/compose-parsable-defaults.test.ts +60 -0
- package/tests/compose-services.test.ts +144 -0
- package/tests/dev-ports-owner.test.ts +40 -0
- package/tests/engine-floor.test.ts +102 -0
- package/tests/external-database-compose.test.ts +3 -3
- package/tests/port-in-use.test.ts +58 -0
- package/tests/readiness-poll.test.ts +84 -0
- package/tests/runtime-contract.test.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
package/src/postgres-ctl.ts
CHANGED
|
@@ -168,23 +168,63 @@ export async function waitReady(opts: PgOptions, timeoutMs = 10_000): Promise<vo
|
|
|
168
168
|
// ---------------------------------------------------------------------------
|
|
169
169
|
|
|
170
170
|
/**
|
|
171
|
-
*
|
|
171
|
+
* Can this port be bound, on the addresses that matter?
|
|
172
|
+
*
|
|
173
|
+
* - `0.0.0.0` is what Docker publishes on and what compose has to bind.
|
|
174
|
+
* - `127.0.0.1` catches a listener bound to loopback only, which a wildcard bind can be allowed
|
|
175
|
+
* to sit alongside on some platforms.
|
|
172
176
|
*/
|
|
173
|
-
|
|
177
|
+
async function bindFails(port: number, host: string): Promise<boolean> {
|
|
174
178
|
const { createServer } = await import("node:net")
|
|
175
179
|
return new Promise((resolve) => {
|
|
176
180
|
const server = createServer()
|
|
177
181
|
server.once("error", (err: NodeJS.ErrnoException) => {
|
|
178
|
-
// EADDRINUSE: something is listening. EACCES
|
|
182
|
+
// EADDRINUSE: something is listening. EACCES: Windows excluded/reserved port range.
|
|
179
183
|
resolve(err.code === "EADDRINUSE" || err.code === "EACCES")
|
|
180
184
|
})
|
|
181
185
|
server.once("listening", () => {
|
|
182
186
|
server.close(() => resolve(false))
|
|
183
187
|
})
|
|
184
|
-
server.listen(port,
|
|
188
|
+
server.listen(port, host)
|
|
185
189
|
})
|
|
186
190
|
}
|
|
187
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Is something answering on this port?
|
|
194
|
+
*
|
|
195
|
+
* Binding is not enough on its own. Docker Desktop publishes through a proxy that does not hold a
|
|
196
|
+
* bind a normal socket can see, so `bindFails` succeeds while nginx on that port answers HTTP 200.
|
|
197
|
+
* Measured on Windows: bind says free, curl says 200. `supatype dev` believed the bind, handed the
|
|
198
|
+
* port to compose, and compose failed with "Bind for 0.0.0.0:5432 failed: port is already
|
|
199
|
+
* allocated". Connecting is what notices.
|
|
200
|
+
*/
|
|
201
|
+
async function connectSucceeds(port: number, timeoutMs = 400): Promise<boolean> {
|
|
202
|
+
const { connect } = await import("node:net")
|
|
203
|
+
return new Promise((resolve) => {
|
|
204
|
+
const socket = connect({ port, host: "127.0.0.1" })
|
|
205
|
+
const done = (answer: boolean): void => {
|
|
206
|
+
socket.destroy()
|
|
207
|
+
resolve(answer)
|
|
208
|
+
}
|
|
209
|
+
socket.setTimeout(timeoutMs)
|
|
210
|
+
socket.once("connect", () => done(true))
|
|
211
|
+
socket.once("timeout", () => done(false))
|
|
212
|
+
socket.once("error", () => done(false))
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* True when the port cannot be used for a new listener.
|
|
218
|
+
*
|
|
219
|
+
* Either signal is enough. Reporting a port busy when it is usable costs an increment in
|
|
220
|
+
* `findNextFreePort`; reporting it free when it is not costs a failed `docker compose up`, which is
|
|
221
|
+
* the failure this exists to prevent.
|
|
222
|
+
*/
|
|
223
|
+
export async function isPortInUse(port: number): Promise<boolean> {
|
|
224
|
+
if (await connectSucceeds(port)) return true
|
|
225
|
+
return (await bindFails(port, "0.0.0.0")) || (await bindFails(port, "127.0.0.1"))
|
|
226
|
+
}
|
|
227
|
+
|
|
188
228
|
// ---------------------------------------------------------------------------
|
|
189
229
|
// Helpers
|
|
190
230
|
// ---------------------------------------------------------------------------
|
package/src/project-config.ts
CHANGED
|
@@ -157,13 +157,13 @@ export interface SupatypeProjectConfig {
|
|
|
157
157
|
/**
|
|
158
158
|
* Email delivery provider.
|
|
159
159
|
* "console" = log to stdout (default for dev)
|
|
160
|
-
* "smtp" = SMTP (set `smtp` below and/or
|
|
160
|
+
* "smtp" = SMTP (set `smtp` below and/or SUPATYPE_SMTP_* in `.env`)
|
|
161
161
|
* "resend" = Resend API (requires RESEND_API_KEY, RESEND_FROM)
|
|
162
162
|
* "ses" = AWS SES v2 (ambient credentials, requires SES_FROM)
|
|
163
163
|
*/
|
|
164
164
|
provider: "console" | "smtp" | "resend" | "ses"
|
|
165
165
|
/**
|
|
166
|
-
* SMTP settings for provider=smtp (merged into process env as
|
|
166
|
+
* SMTP settings for provider=smtp (merged into process env as SUPATYPE_SMTP_*).
|
|
167
167
|
* Omitted keys can still be set via `.env` / shell.
|
|
168
168
|
*/
|
|
169
169
|
smtp?: {
|
|
@@ -181,9 +181,9 @@ export interface SupatypeProjectConfig {
|
|
|
181
181
|
/** From address for SES (provider=ses, or set SES_FROM env var). */
|
|
182
182
|
ses_from?: string
|
|
183
183
|
/**
|
|
184
|
-
* When true, `supatype dev` enables the
|
|
184
|
+
* When true, `supatype dev` enables the send-email HTTP hook pointing at this
|
|
185
185
|
* server's POST `/internal/v0hooks/send-email` (signed delivery, dev-only secret).
|
|
186
|
-
* Override `
|
|
186
|
+
* Override `SUPATYPE_HOOK_SEND_EMAIL_*` in `.env` if needed.
|
|
187
187
|
*/
|
|
188
188
|
send_email_hook?: boolean
|
|
189
189
|
/**
|
package/src/resolve-target.ts
CHANGED
|
@@ -44,7 +44,7 @@ export interface DeployTarget {
|
|
|
44
44
|
apiBaseUrl: string
|
|
45
45
|
apiPrefix: "/api/v1" | "/platform/v1"
|
|
46
46
|
token?: string | undefined
|
|
47
|
-
/** Cloud
|
|
47
|
+
/** Cloud auth refresh token (mode === "cloud" only). */
|
|
48
48
|
refreshToken?: string | undefined
|
|
49
49
|
orgId?: string | undefined
|
|
50
50
|
link: ProjectLink | null
|
package/src/self-host-compose.ts
CHANGED
|
@@ -297,11 +297,11 @@ export function renderSelfHostCompose(
|
|
|
297
297
|
const kongMount = kongMountPath(cwd)
|
|
298
298
|
const external = usesExternalDatabase(config)
|
|
299
299
|
const ownerUrl = ownerDatabaseUrl(config)
|
|
300
|
-
//
|
|
301
|
-
const
|
|
300
|
+
// the auth driver wants the `postgres://` spelling; an external URL is used as given.
|
|
301
|
+
const authUrl = external ? ownerUrl : ownerDatabaseUrl(config, "postgres")
|
|
302
302
|
// An external URL may already carry query parameters (`?sslmode=require` is common on managed
|
|
303
303
|
// providers), and appending a second `?` produces a DSN that fails to parse.
|
|
304
|
-
const
|
|
304
|
+
const authSearchPathSeparator = externalDatabaseUrl(config)?.includes("?") ? "&" : "?"
|
|
305
305
|
const postgrestUrl = postgrestDatabaseUrl(config)
|
|
306
306
|
const devLocal = options?.devLocal === true
|
|
307
307
|
const tlsEnabled = selfHostTlsEnabled(config, devLocal)
|
|
@@ -546,6 +546,14 @@ ${realtimeBlock}
|
|
|
546
546
|
${dbDependency}
|
|
547
547
|
server:
|
|
548
548
|
image: \${SUPATYPE_SERVER_IMAGE:-\${SUPATYPE_AUTH_IMAGE:-supatype/server:latest}}
|
|
549
|
+
# The server runs its migrations at boot on a connection of their own,
|
|
550
|
+
# and that path does not wait out a database that is still in recovery:
|
|
551
|
+
# it exits. Waiting for db to report healthy is not enough, because
|
|
552
|
+
# Postgres says healthy before it will accept these connections, and
|
|
553
|
+
# on a slow host the gap is wide enough to lose it for good. Bounded
|
|
554
|
+
# rather than unlimited, so a real misconfiguration still stops and
|
|
555
|
+
# stays visible instead of hiding in a crash loop.
|
|
556
|
+
restart: on-failure:5
|
|
549
557
|
${serverPorts} volumes:
|
|
550
558
|
- ${projectMount}:/project:ro
|
|
551
559
|
working_dir: /project
|
|
@@ -567,20 +575,35 @@ ${realtimeServerEnv}
|
|
|
567
575
|
SUPATYPE_CONTROL_PLANE_URL: http://control-plane:8080
|
|
568
576
|
SUPATYPE_VALKEY_ADDR: valkey:6379
|
|
569
577
|
${appEnv}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
578
|
+
SUPATYPE_API_HOST: 0.0.0.0
|
|
579
|
+
SUPATYPE_API_PORT: 9999
|
|
580
|
+
SUPATYPE_API_EXTERNAL_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
|
|
581
|
+
SUPATYPE_DB_DRIVER: postgres
|
|
582
|
+
SUPATYPE_DB_DATABASE_URL: "${authUrl}${authSearchPathSeparator}search_path=auth"
|
|
583
|
+
SUPATYPE_SITE_URL: \${SITE_URL:-${siteUrlFallback}}
|
|
584
|
+
SUPATYPE_JWT_SECRET: \${JWT_SECRET:?JWT_SECRET is missing from .env}
|
|
585
|
+
SUPATYPE_JWT_EXP: 3600
|
|
586
|
+
SUPATYPE_JWT_AUD: authenticated
|
|
587
|
+
SUPATYPE_JWT_DEFAULT_GROUP_NAME: authenticated
|
|
588
|
+
SUPATYPE_JWT_ADMIN_ROLES: service_role,supatype_admin
|
|
589
|
+
SUPATYPE_MAILER_AUTOCONFIRM: \${SUPATYPE_MAILER_AUTOCONFIRM:-true}
|
|
590
|
+
# email.provider and email.smtp are config, and nothing used to carry
|
|
591
|
+
# them here: with no provider and no SMTP host the auth service falls
|
|
592
|
+
# through to its noop client, so every message was dropped in silence and
|
|
593
|
+
# a project asking for smtp got the same nothing as one asking for console.
|
|
594
|
+
# The name really is MAILER_MAILER: the field is Mailer.MailerProvider.
|
|
595
|
+
SUPATYPE_MAILER_MAILER_PROVIDER: \${SUPATYPE_MAILER_MAILER_PROVIDER:-console}
|
|
596
|
+
SUPATYPE_SMTP_HOST: \${SUPATYPE_SMTP_HOST:-}
|
|
597
|
+
# 587, not empty: this one is an int on the server, and compose
|
|
598
|
+
# substitutes an unset variable as "", which fails to parse and takes
|
|
599
|
+
# the whole service down on boot. 587 is the server's own default, so
|
|
600
|
+
# leaving it unset now behaves exactly as it would with no value at all.
|
|
601
|
+
SUPATYPE_SMTP_PORT: \${SUPATYPE_SMTP_PORT:-587}
|
|
602
|
+
SUPATYPE_SMTP_USER: \${SUPATYPE_SMTP_USER:-}
|
|
603
|
+
SUPATYPE_SMTP_PASS: \${SUPATYPE_SMTP_PASS:-}
|
|
604
|
+
SUPATYPE_SMTP_ADMIN_EMAIL: \${SUPATYPE_SMTP_ADMIN_EMAIL:-}
|
|
605
|
+
SUPATYPE_SMTP_SENDER_NAME: \${SUPATYPE_SMTP_SENDER_NAME:-}
|
|
606
|
+
SUPATYPE_DISABLE_SIGNUP: \${DISABLE_SIGNUP:-false}
|
|
584
607
|
${devLocal ? " STUDIO_OPEN_DEV: \"1\"\n" : ""}
|
|
585
608
|
depends_on:
|
|
586
609
|
${dbDependencyClause} valkey:
|
|
@@ -697,7 +720,7 @@ function ensureProjectFunctionsDir(cwd: string, config: SupatypeProjectConfig):
|
|
|
697
720
|
*
|
|
698
721
|
* The CLI resolves the URL from config; Compose substitutes `.env` at up-time. If the two disagree,
|
|
699
722
|
* `push` migrates one database while the services serve another, which reads as data loss and
|
|
700
|
-
* isn't. It also makes the generated
|
|
723
|
+
* isn't. It also makes the generated auth DSN wrong, since whether to append `search_path` with
|
|
701
724
|
* `?` or `&` is decided from the config URL's query string.
|
|
702
725
|
*/
|
|
703
726
|
function assertExternalUrlMatchesEnv(cwd: string, config: SupatypeProjectConfig): void {
|
package/src/studio-dev-server.ts
CHANGED
|
@@ -9,7 +9,14 @@ export interface StudioDevServerOptions {
|
|
|
9
9
|
cwd: string
|
|
10
10
|
studioOverride: string
|
|
11
11
|
pidDir: string
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The anon key, and only the anon key. Studio in the browser is an untrusted
|
|
14
|
+
* client: privileged calls go through /studio/proxy, which holds the service
|
|
15
|
+
* role key server-side and applies membership, role permissions and the audit
|
|
16
|
+
* trail. Studio refuses a service role key handed to the browser and says so
|
|
17
|
+
* loudly, which is what this used to trigger on every dev start.
|
|
18
|
+
*/
|
|
19
|
+
anonKey: string
|
|
13
20
|
/**
|
|
14
21
|
* Where Vite proxies API requests (Kong gateway port for compose dev, or
|
|
15
22
|
* supatype-server port for native `supatype dev`).
|
|
@@ -45,8 +52,7 @@ export function startStudioViteDevServer(opts: StudioDevServerOptions): ProcessM
|
|
|
45
52
|
env: {
|
|
46
53
|
VITE_SUPATYPE_URL: opts.viteSupatypeUrl,
|
|
47
54
|
SUPATYPE_PROXY_TARGET: opts.proxyTarget,
|
|
48
|
-
VITE_SUPATYPE_ANON_KEY: opts.
|
|
49
|
-
VITE_SUPATYPE_SERVICE_ROLE_KEY: opts.serviceRoleKey,
|
|
55
|
+
VITE_SUPATYPE_ANON_KEY: opts.anonKey,
|
|
50
56
|
VITE_BASE_PATH: basePath,
|
|
51
57
|
},
|
|
52
58
|
},
|
|
@@ -4,8 +4,8 @@ import {
|
|
|
4
4
|
ADMIN_PASSWORD_ENV,
|
|
5
5
|
clearAdminSeedPassword,
|
|
6
6
|
composePostgresPassword,
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
SUPATYPE_NIL_INSTANCE_ID,
|
|
8
|
+
authJwtAud,
|
|
9
9
|
hashPasswordForAuth,
|
|
10
10
|
resolveAuthConfirmedAtColumn,
|
|
11
11
|
} from "../src/commands/admin.js"
|
|
@@ -65,13 +65,13 @@ describe("composePostgresPassword", () => {
|
|
|
65
65
|
})
|
|
66
66
|
})
|
|
67
67
|
|
|
68
|
-
describe("
|
|
69
|
-
it("reads
|
|
68
|
+
describe("authJwtAud", () => {
|
|
69
|
+
it("reads SUPATYPE_JWT_AUD from .env", () => {
|
|
70
70
|
const dir = join(tmpdir(), `supatype-admin-aud-${Date.now()}`)
|
|
71
71
|
mkdirSync(dir, { recursive: true })
|
|
72
|
-
writeFileSync(join(dir, ".env"), "
|
|
72
|
+
writeFileSync(join(dir, ".env"), "SUPATYPE_JWT_AUD=custom-aud\n", "utf8")
|
|
73
73
|
try {
|
|
74
|
-
expect(
|
|
74
|
+
expect(authJwtAud(dir)).toBe("custom-aud")
|
|
75
75
|
} finally {
|
|
76
76
|
rmSync(dir, { recursive: true, force: true })
|
|
77
77
|
}
|
|
@@ -81,16 +81,16 @@ describe("gotrueJwtAud", () => {
|
|
|
81
81
|
const dir = join(tmpdir(), `supatype-admin-aud-default-${Date.now()}`)
|
|
82
82
|
mkdirSync(dir, { recursive: true })
|
|
83
83
|
try {
|
|
84
|
-
expect(
|
|
84
|
+
expect(authJwtAud(dir)).toBe("authenticated")
|
|
85
85
|
} finally {
|
|
86
86
|
rmSync(dir, { recursive: true, force: true })
|
|
87
87
|
}
|
|
88
88
|
})
|
|
89
89
|
})
|
|
90
90
|
|
|
91
|
-
describe("
|
|
92
|
-
it("is the nil UUID
|
|
93
|
-
expect(
|
|
91
|
+
describe("SUPATYPE_NIL_INSTANCE_ID", () => {
|
|
92
|
+
it("is the nil UUID the auth service uses for lookup", () => {
|
|
93
|
+
expect(SUPATYPE_NIL_INSTANCE_ID).toBe("00000000-0000-0000-0000-000000000000")
|
|
94
94
|
})
|
|
95
95
|
})
|
|
96
96
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { renderSelfHostCompose } from "../src/self-host-compose.js"
|
|
3
|
+
import { validateProjectConfig, type SupatypeProjectConfig } from "../src/project-config.js"
|
|
4
|
+
import { DENO_RELEASE_PIN } from "../src/release-pins.js"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Compose must not hand the server a value it cannot parse.
|
|
8
|
+
*
|
|
9
|
+
* Every variable here is substituted by compose whether or not anyone set it,
|
|
10
|
+
* and an unset one becomes the empty string. That is harmless for a host or a
|
|
11
|
+
* password and fatal for a port: the server decodes it as an int, fails on "",
|
|
12
|
+
* and exits before it binds. The whole stack then presents as "the gateway
|
|
13
|
+
* never came up", which is a long way from the cause.
|
|
14
|
+
*/
|
|
15
|
+
const base = {
|
|
16
|
+
project: { name: "acme" },
|
|
17
|
+
server: { mode: "dev" as const },
|
|
18
|
+
app: { mode: "none" as const },
|
|
19
|
+
database: { provider: "docker" as const },
|
|
20
|
+
versions: {
|
|
21
|
+
postgres: "17.2",
|
|
22
|
+
deno: DENO_RELEASE_PIN,
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const config = (): SupatypeProjectConfig => validateProjectConfig(base, "supatype.config.ts")
|
|
27
|
+
|
|
28
|
+
/** The `KEY: ${VAR:-default}` lines from the rendered compose file. */
|
|
29
|
+
function defaultsFor(compose: string): Map<string, string> {
|
|
30
|
+
const found = new Map<string, string>()
|
|
31
|
+
for (const line of compose.split("\n")) {
|
|
32
|
+
const match = /^\s*([A-Z_]+):\s*\$\{[A-Z_]+:-(.*)\}\s*$/.exec(line)
|
|
33
|
+
if (match?.[1] !== undefined) found.set(match[1], match[2] ?? "")
|
|
34
|
+
}
|
|
35
|
+
return found
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("compose defaults the server has to be able to parse", () => {
|
|
39
|
+
it("gives SMTP_PORT a number, because an unset variable becomes an empty string", () => {
|
|
40
|
+
const defaults = defaultsFor(renderSelfHostCompose(config()))
|
|
41
|
+
const port = defaults.get("SUPATYPE_SMTP_PORT")
|
|
42
|
+
|
|
43
|
+
expect(port, "SUPATYPE_SMTP_PORT is missing from the compose file").toBeDefined()
|
|
44
|
+
expect(port, "an empty default makes the server exit on boot").not.toBe("")
|
|
45
|
+
expect(Number.isInteger(Number(port))).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("leaves the string-valued mailer settings empty, which the server accepts", () => {
|
|
49
|
+
const defaults = defaultsFor(renderSelfHostCompose(config()))
|
|
50
|
+
for (const key of [
|
|
51
|
+
"SUPATYPE_SMTP_HOST",
|
|
52
|
+
"SUPATYPE_SMTP_USER",
|
|
53
|
+
"SUPATYPE_SMTP_PASS",
|
|
54
|
+
"SUPATYPE_SMTP_ADMIN_EMAIL",
|
|
55
|
+
"SUPATYPE_SMTP_SENDER_NAME",
|
|
56
|
+
]) {
|
|
57
|
+
expect(defaults.get(key), `${key} should be present`).toBeDefined()
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { logsArgs } from "../src/commands/logs.js"
|
|
3
|
+
import { composeArgs, parseComposePs } from "../src/compose-services.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `supatype status` and `supatype logs` each carried a hardcoded list of container
|
|
7
|
+
* names. Both lists were wrong: they named `supatype-postgres` and `supatype-kong`,
|
|
8
|
+
* which the generated compose file does not create, and omitted `server`, `storage`,
|
|
9
|
+
* `functions-worker`, `schema-engine` and `valkey`, which it does. The file sets no
|
|
10
|
+
* `container_name`, so Docker names containers `<project>-<service>-<n>` and no
|
|
11
|
+
* guessed name could ever have matched.
|
|
12
|
+
*
|
|
13
|
+
* The fix is to ask Compose. These tests cover the parsing and argument seams, which
|
|
14
|
+
* are the parts that can be wrong without Docker present.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const ctx = {
|
|
18
|
+
composePath: "/proj/.supatype/self-host/docker-compose.yml",
|
|
19
|
+
projectRoot: "/proj",
|
|
20
|
+
projectName: "supatype-demo",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("parseComposePs", () => {
|
|
24
|
+
// Compose v2 emits newline-delimited objects.
|
|
25
|
+
it("reads the NDJSON form", () => {
|
|
26
|
+
const stdout = [
|
|
27
|
+
JSON.stringify({ Service: "server", Name: "supatype-demo-server-1", State: "running", Status: "Up 2 minutes" }),
|
|
28
|
+
JSON.stringify({ Service: "db", Name: "supatype-demo-db-1", State: "exited", Status: "Exited (1)" }),
|
|
29
|
+
].join("\n")
|
|
30
|
+
|
|
31
|
+
expect(parseComposePs(stdout)).toEqual([
|
|
32
|
+
{ service: "db", container: "supatype-demo-db-1", state: "exited", ports: "", status: "Exited (1)" },
|
|
33
|
+
{ service: "server", container: "supatype-demo-server-1", state: "running", ports: "", status: "Up 2 minutes" },
|
|
34
|
+
])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// Other versions emit one array. Which you get depends on the Docker installed.
|
|
38
|
+
it("reads the array form", () => {
|
|
39
|
+
const stdout = JSON.stringify([{ Service: "studio", Name: "supatype-demo-studio-1", State: "running" }])
|
|
40
|
+
expect(parseComposePs(stdout).map((s) => s.service)).toEqual(["studio"])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("sorts by service so the report is stable between runs", () => {
|
|
44
|
+
const stdout = ["valkey", "db", "server"].map((s) => JSON.stringify({ Service: s, State: "running" })).join("\n")
|
|
45
|
+
expect(parseComposePs(stdout).map((s) => s.service)).toEqual(["db", "server", "valkey"])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Compose prefixes its own progress lines on some versions, and prints nothing
|
|
49
|
+
// at all when the project has never been started.
|
|
50
|
+
it("survives noise and emptiness", () => {
|
|
51
|
+
expect(parseComposePs("")).toEqual([])
|
|
52
|
+
expect(parseComposePs(" \n")).toEqual([])
|
|
53
|
+
expect(parseComposePs("[not json")).toEqual([])
|
|
54
|
+
expect(parseComposePs('pulling...\n{"Service":"db","State":"running"}')).toHaveLength(1)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// A row without a service name cannot be reported against, and a missing state
|
|
58
|
+
// must not read as running.
|
|
59
|
+
it("drops rows with no service and does not invent a state", () => {
|
|
60
|
+
const stdout = [
|
|
61
|
+
JSON.stringify({ Name: "orphan", State: "running" }),
|
|
62
|
+
JSON.stringify({ Service: "db" }),
|
|
63
|
+
"null",
|
|
64
|
+
].join("\n")
|
|
65
|
+
const got = parseComposePs(stdout)
|
|
66
|
+
expect(got).toHaveLength(1)
|
|
67
|
+
expect(got[0]).toMatchObject({ service: "db", state: "unknown", container: "", ports: "" })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe("ports", () => {
|
|
71
|
+
it("takes the string form as given", () => {
|
|
72
|
+
const stdout = JSON.stringify({ Service: "db", State: "running", Publishers: "0.0.0.0:5432->5432/tcp" })
|
|
73
|
+
expect(parseComposePs(stdout)[0]?.ports).toBe("0.0.0.0:5432->5432/tcp")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// Compose lists an entry per protocol and per host interface, so the same
|
|
77
|
+
// mapping arrives several times over.
|
|
78
|
+
it("renders the structured form, de-duplicated", () => {
|
|
79
|
+
const stdout = JSON.stringify({
|
|
80
|
+
Service: "db",
|
|
81
|
+
State: "running",
|
|
82
|
+
Publishers: [
|
|
83
|
+
{ URL: "0.0.0.0", TargetPort: 5432, PublishedPort: 5432, Protocol: "tcp" },
|
|
84
|
+
{ URL: "::", TargetPort: 5432, PublishedPort: 5432, Protocol: "tcp" },
|
|
85
|
+
{ URL: "0.0.0.0", TargetPort: 9000, PublishedPort: 19000, Protocol: "tcp" },
|
|
86
|
+
],
|
|
87
|
+
})
|
|
88
|
+
expect(parseComposePs(stdout)[0]?.ports).toBe("5432->5432, 19000->9000")
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// An unpublished port is reachable only from inside the network, so reporting
|
|
92
|
+
// it would tell the developer to try an address that will refuse.
|
|
93
|
+
it("omits ports that are not published to the host", () => {
|
|
94
|
+
const stdout = JSON.stringify({
|
|
95
|
+
Service: "valkey",
|
|
96
|
+
State: "running",
|
|
97
|
+
Publishers: [{ URL: "", TargetPort: 6379, PublishedPort: 0, Protocol: "tcp" }, null, "nonsense"],
|
|
98
|
+
})
|
|
99
|
+
expect(parseComposePs(stdout)[0]?.ports).toBe("")
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe("composeArgs", () => {
|
|
105
|
+
// Without --project-directory, Compose resolves relative build contexts and
|
|
106
|
+
// env_file paths against .supatype/self-host/ rather than the project root.
|
|
107
|
+
it("names the project, the project directory and the file", () => {
|
|
108
|
+
expect(composeArgs(ctx)).toEqual([
|
|
109
|
+
"compose",
|
|
110
|
+
"-p",
|
|
111
|
+
"supatype-demo",
|
|
112
|
+
"--project-directory",
|
|
113
|
+
"/proj",
|
|
114
|
+
"-f",
|
|
115
|
+
"/proj/.supatype/self-host/docker-compose.yml",
|
|
116
|
+
])
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe("logsArgs", () => {
|
|
121
|
+
it("tails the whole stack by default", () => {
|
|
122
|
+
expect(logsArgs(["compose"], { since: "5m", follow: true })).toEqual([
|
|
123
|
+
"compose",
|
|
124
|
+
"logs",
|
|
125
|
+
"--tail",
|
|
126
|
+
"100",
|
|
127
|
+
"--follow",
|
|
128
|
+
"--since",
|
|
129
|
+
"5m",
|
|
130
|
+
])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// The service name is the Compose service, and it has to come last: Compose
|
|
134
|
+
// reads trailing arguments as the services to tail.
|
|
135
|
+
it("puts the service last", () => {
|
|
136
|
+
const args = logsArgs(["compose"], { service: "server", follow: false })
|
|
137
|
+
expect(args.at(-1)).toBe("server")
|
|
138
|
+
expect(args).not.toContain("--follow")
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("omits --since when there is none", () => {
|
|
142
|
+
expect(logsArgs(["compose"], {})).not.toContain("--since")
|
|
143
|
+
})
|
|
144
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { composeProjectOwnsPort } from "../src/dev-ports.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The decision behind the port guard, without Docker.
|
|
6
|
+
*
|
|
7
|
+
* `supatype self-host compose up -d` followed by `supatype push` was impossible: the second
|
|
8
|
+
* command saw its own gateway on the port and refused. The fix has to accept our own stack while
|
|
9
|
+
* still refusing a stranger's, because proceeding against another project's gateway would push a
|
|
10
|
+
* schema into the wrong database.
|
|
11
|
+
*/
|
|
12
|
+
describe("composeProjectOwnsPort", () => {
|
|
13
|
+
it("accepts a port published by this project", () => {
|
|
14
|
+
expect(composeProjectOwnsPort("supatype-validation\n", "supatype-validation")).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it("refuses a port published by a different project", () => {
|
|
18
|
+
expect(composeProjectOwnsPort("someone-elses-stack\n", "supatype-validation")).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("refuses when any publisher is a different project", () => {
|
|
22
|
+
const output = "supatype-validation\nsomeone-elses-stack\n"
|
|
23
|
+
expect(composeProjectOwnsPort(output, "supatype-validation")).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("refuses when nothing reports a compose project", () => {
|
|
27
|
+
// A container with no compose label: something is on the port, and it is not ours.
|
|
28
|
+
expect(composeProjectOwnsPort("\n\n", "supatype-validation")).toBe(false)
|
|
29
|
+
expect(composeProjectOwnsPort("", "supatype-validation")).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("tolerates the whitespace docker pads its columns with", () => {
|
|
33
|
+
expect(composeProjectOwnsPort(" supatype-validation \n", "supatype-validation")).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("does not match on a prefix", () => {
|
|
37
|
+
// `supatype-validation-2` is a different project, and a substring check would accept it.
|
|
38
|
+
expect(composeProjectOwnsPort("supatype-validation-2\n", "supatype-validation")).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
assertEngineSupportsSchema,
|
|
4
|
+
boundsRequiringHelpers,
|
|
5
|
+
compareVersions,
|
|
6
|
+
ENGINE_MIN_FOR_BOUNDS,
|
|
7
|
+
} from "../src/engine-floor.js"
|
|
8
|
+
import type { ExtractedSchemaAstV2, FieldAstV2, ModelAstV2 } from "../src/schema-ast-v2.js"
|
|
9
|
+
|
|
10
|
+
function field(overrides: Partial<FieldAstV2> = {}): FieldAstV2 {
|
|
11
|
+
return {
|
|
12
|
+
kind: "text",
|
|
13
|
+
annotations: { db: { pgType: "TEXT" }, platform: {} },
|
|
14
|
+
...overrides,
|
|
15
|
+
} as FieldAstV2
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function model(name: string, fields: Record<string, FieldAstV2>, constraints?: unknown[]): ModelAstV2 {
|
|
19
|
+
return {
|
|
20
|
+
name,
|
|
21
|
+
fields,
|
|
22
|
+
options: {},
|
|
23
|
+
annotations: {
|
|
24
|
+
db: { tableName: name.toLowerCase(), indexes: [], ...(constraints && { constraints }) },
|
|
25
|
+
platform: { access: {} },
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function schema(models: ModelAstV2[]): ExtractedSchemaAstV2 {
|
|
31
|
+
return { astVersion: 2, models } as ExtractedSchemaAstV2
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const WITH_BOUND = schema([model("Post", { title: field({ validation: { maxLength: 80 } }) })])
|
|
35
|
+
const WITHOUT_BOUND = schema([model("Post", { title: field() })])
|
|
36
|
+
|
|
37
|
+
describe("compareVersions", () => {
|
|
38
|
+
it("orders by each numeric part, not lexically", () => {
|
|
39
|
+
expect(compareVersions("0.10.0", "0.9.0")).toBeGreaterThan(0)
|
|
40
|
+
expect(compareVersions("0.2.0", "0.2.0")).toBe(0)
|
|
41
|
+
expect(compareVersions("0.1.9", "0.2.0")).toBeLessThan(0)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("ignores a leading v and a pre-release suffix", () => {
|
|
45
|
+
// A release candidate of the engine that creates the helpers does create them.
|
|
46
|
+
expect(compareVersions("v0.2.0", "0.2.0")).toBe(0)
|
|
47
|
+
expect(compareVersions("0.2.0-rc.1", "0.2.0")).toBe(0)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe("boundsRequiringHelpers", () => {
|
|
52
|
+
it("names the fields that declare a bound", () => {
|
|
53
|
+
expect(boundsRequiringHelpers(WITH_BOUND)).toEqual(["Post.title"])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("names models carrying a constraint", () => {
|
|
57
|
+
const ast = schema([model("Event", { start: field() }, [{ expr: "start < finish" }])])
|
|
58
|
+
expect(boundsRequiringHelpers(ast)).toEqual(["Event"])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("is empty for a schema that declares neither", () => {
|
|
62
|
+
expect(boundsRequiringHelpers(WITHOUT_BOUND)).toEqual([])
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe("assertEngineSupportsSchema", () => {
|
|
67
|
+
it("refuses a bound schema on an engine below the floor", () => {
|
|
68
|
+
expect(() => assertEngineSupportsSchema(WITH_BOUND, "0.1.9")).toThrow(/schema-engine 0\.2\.0 or newer/)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("names the pin and the field, so the message is actionable", () => {
|
|
72
|
+
try {
|
|
73
|
+
assertEngineSupportsSchema(WITH_BOUND, "0.1.9")
|
|
74
|
+
expect.unreachable("should have refused")
|
|
75
|
+
} catch (err) {
|
|
76
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
77
|
+
expect(message).toContain("pins 0.1.9")
|
|
78
|
+
expect(message).toContain("Post.title")
|
|
79
|
+
expect(message).toContain("versions: { engine:")
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// The three cases where refusing would be worse than the failure it prevents: an old pin is
|
|
84
|
+
// fine without bounds, an unpinned project resolves to latest, and `local` cannot be compared.
|
|
85
|
+
it("allows an old pin when the schema declares no bounds", () => {
|
|
86
|
+
expect(() => assertEngineSupportsSchema(WITHOUT_BOUND, "0.1.9")).not.toThrow()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("allows an unpinned project", () => {
|
|
90
|
+
expect(() => assertEngineSupportsSchema(WITH_BOUND, undefined)).not.toThrow()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("allows a local override, whose version the config does not know", () => {
|
|
94
|
+
expect(() => assertEngineSupportsSchema(WITH_BOUND, "local")).not.toThrow()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("allows the floor itself and anything above it", () => {
|
|
98
|
+
expect(() => assertEngineSupportsSchema(WITH_BOUND, ENGINE_MIN_FOR_BOUNDS)).not.toThrow()
|
|
99
|
+
expect(() => assertEngineSupportsSchema(WITH_BOUND, "0.3.1")).not.toThrow()
|
|
100
|
+
expect(() => assertEngineSupportsSchema(WITH_BOUND, "1.0.0")).not.toThrow()
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -53,7 +53,7 @@ describe("external database: compose", () => {
|
|
|
53
53
|
|
|
54
54
|
it("points every database consumer at the one URL", () => {
|
|
55
55
|
const compose = renderSelfHostCompose(external())
|
|
56
|
-
// storage, realtime, control-plane, server (SUPATYPE_SQL_DATABASE_URL), and
|
|
56
|
+
// storage, realtime, control-plane, server (SUPATYPE_SQL_DATABASE_URL), and auth.
|
|
57
57
|
const references = compose.match(/\$\{DATABASE_URL:\?/g) ?? []
|
|
58
58
|
expect(references.length).toBe(5)
|
|
59
59
|
})
|
|
@@ -85,9 +85,9 @@ describe("external database: compose", () => {
|
|
|
85
85
|
expect(compose).toContain("@db.example.com:5432/app?sslmode=require")
|
|
86
86
|
})
|
|
87
87
|
|
|
88
|
-
it("appends
|
|
88
|
+
it("appends the auth search_path with the right separator", () => {
|
|
89
89
|
// A URL that already has a query string needs `&`; a second `?` produces a DSN that fails to
|
|
90
|
-
// parse, and
|
|
90
|
+
// parse, and the service would exit rather than run its auth migrations.
|
|
91
91
|
expect(renderSelfHostCompose(external())).toContain('${DATABASE_URL:?DATABASE_URL is missing from .env, required by database.external}?search_path=auth"')
|
|
92
92
|
expect(
|
|
93
93
|
renderSelfHostCompose(
|