@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.
Files changed (96) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +151 -143
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/cli-version-embedded.d.ts.map +1 -1
  5. package/dist/cli-version-embedded.js +1 -1
  6. package/dist/cli-version-embedded.js.map +1 -1
  7. package/dist/commands/admin.d.ts +5 -5
  8. package/dist/commands/admin.d.ts.map +1 -1
  9. package/dist/commands/admin.js +10 -10
  10. package/dist/commands/admin.js.map +1 -1
  11. package/dist/commands/dev.js +51 -51
  12. package/dist/commands/dev.js.map +1 -1
  13. package/dist/commands/init.js +1 -1
  14. package/dist/commands/logs.d.ts +14 -1
  15. package/dist/commands/logs.d.ts.map +1 -1
  16. package/dist/commands/logs.js +33 -37
  17. package/dist/commands/logs.js.map +1 -1
  18. package/dist/commands/push.d.ts.map +1 -1
  19. package/dist/commands/push.js +3 -0
  20. package/dist/commands/push.js.map +1 -1
  21. package/dist/commands/status.d.ts +1 -1
  22. package/dist/commands/status.d.ts.map +1 -1
  23. package/dist/commands/status.js +33 -64
  24. package/dist/commands/status.js.map +1 -1
  25. package/dist/compose-services.d.ts +59 -0
  26. package/dist/compose-services.d.ts.map +1 -0
  27. package/dist/compose-services.js +160 -0
  28. package/dist/compose-services.js.map +1 -0
  29. package/dist/config.d.ts +1 -1
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/dev-compose.d.ts.map +1 -1
  32. package/dist/dev-compose.js +123 -20
  33. package/dist/dev-compose.js.map +1 -1
  34. package/dist/dev-ports.d.ts +24 -0
  35. package/dist/dev-ports.d.ts.map +1 -1
  36. package/dist/dev-ports.js +37 -0
  37. package/dist/dev-ports.js.map +1 -1
  38. package/dist/docker-postgres.d.ts +1 -1
  39. package/dist/docker-postgres.d.ts.map +1 -1
  40. package/dist/docker-postgres.js +2 -2
  41. package/dist/docker-postgres.js.map +1 -1
  42. package/dist/engine-floor.d.ts +50 -0
  43. package/dist/engine-floor.d.ts.map +1 -0
  44. package/dist/engine-floor.js +80 -0
  45. package/dist/engine-floor.js.map +1 -0
  46. package/dist/link.d.ts +1 -1
  47. package/dist/link.d.ts.map +1 -1
  48. package/dist/pgbouncer-userlist.d.ts +1 -1
  49. package/dist/pgbouncer-userlist.d.ts.map +1 -1
  50. package/dist/pgbouncer-userlist.js +1 -1
  51. package/dist/pgbouncer-userlist.js.map +1 -1
  52. package/dist/postgres-ctl.d.ts +5 -1
  53. package/dist/postgres-ctl.d.ts.map +1 -1
  54. package/dist/postgres-ctl.js +43 -4
  55. package/dist/postgres-ctl.js.map +1 -1
  56. package/dist/project-config.d.ts +4 -4
  57. package/dist/resolve-target.d.ts +1 -1
  58. package/dist/resolve-target.d.ts.map +1 -1
  59. package/dist/self-host-compose.d.ts.map +1 -1
  60. package/dist/self-host-compose.js +41 -18
  61. package/dist/self-host-compose.js.map +1 -1
  62. package/dist/studio-dev-server.d.ts +8 -1
  63. package/dist/studio-dev-server.d.ts.map +1 -1
  64. package/dist/studio-dev-server.js +1 -2
  65. package/dist/studio-dev-server.js.map +1 -1
  66. package/package.json +2 -2
  67. package/src/cli-version-embedded.ts +1 -1
  68. package/src/commands/admin.ts +10 -10
  69. package/src/commands/dev.ts +51 -51
  70. package/src/commands/init.ts +1 -1
  71. package/src/commands/logs.ts +34 -37
  72. package/src/commands/push.ts +3 -0
  73. package/src/commands/status.ts +34 -70
  74. package/src/compose-services.ts +174 -0
  75. package/src/config.ts +1 -1
  76. package/src/dev-compose.ts +130 -20
  77. package/src/dev-ports.ts +48 -0
  78. package/src/docker-postgres.ts +2 -2
  79. package/src/engine-floor.ts +113 -0
  80. package/src/link.ts +1 -1
  81. package/src/pgbouncer-userlist.ts +1 -1
  82. package/src/postgres-ctl.ts +44 -4
  83. package/src/project-config.ts +4 -4
  84. package/src/resolve-target.ts +1 -1
  85. package/src/self-host-compose.ts +41 -18
  86. package/src/studio-dev-server.ts +9 -3
  87. package/tests/admin-ensure.test.ts +10 -10
  88. package/tests/compose-parsable-defaults.test.ts +60 -0
  89. package/tests/compose-services.test.ts +144 -0
  90. package/tests/dev-ports-owner.test.ts +40 -0
  91. package/tests/engine-floor.test.ts +102 -0
  92. package/tests/external-database-compose.test.ts +3 -3
  93. package/tests/port-in-use.test.ts +58 -0
  94. package/tests/readiness-poll.test.ts +84 -0
  95. package/tests/runtime-contract.test.ts +1 -1
  96. package/tsconfig.tsbuildinfo +1 -1
@@ -168,23 +168,63 @@ export async function waitReady(opts: PgOptions, timeoutMs = 10_000): Promise<vo
168
168
  // ---------------------------------------------------------------------------
169
169
 
170
170
  /**
171
- * Returns true if a TCP listener is already bound to port on 127.0.0.1.
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
- export async function isPortInUse(port: number): Promise<boolean> {
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, Windows excluded/reserved port range.
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, "127.0.0.1")
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
  // ---------------------------------------------------------------------------
@@ -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 GOTRUE_SMTP_* in `.env`)
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 GOTRUE_SMTP_*).
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 GoTrue send-email HTTP hook pointing at this
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 `GOTRUE_HOOK_SEND_EMAIL_*` in `.env` if needed.
186
+ * Override `SUPATYPE_HOOK_SEND_EMAIL_*` in `.env` if needed.
187
187
  */
188
188
  send_email_hook?: boolean
189
189
  /**
@@ -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 GoTrue refresh token (mode === "cloud" only). */
47
+ /** Cloud auth refresh token (mode === "cloud" only). */
48
48
  refreshToken?: string | undefined
49
49
  orgId?: string | undefined
50
50
  link: ProjectLink | null
@@ -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
- // GoTrue's driver wants the `postgres://` spelling; an external URL is used as given.
301
- const gotrueUrl = external ? ownerUrl : ownerDatabaseUrl(config, "postgres")
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 gotrueSearchPathSeparator = externalDatabaseUrl(config)?.includes("?") ? "&" : "?"
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
- GOTRUE_API_HOST: 0.0.0.0
571
- GOTRUE_API_PORT: 9999
572
- API_EXTERNAL_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
573
- GOTRUE_API_EXTERNAL_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
574
- GOTRUE_DB_DRIVER: postgres
575
- GOTRUE_DB_DATABASE_URL: "${gotrueUrl}${gotrueSearchPathSeparator}search_path=auth"
576
- GOTRUE_SITE_URL: \${SITE_URL:-${siteUrlFallback}}
577
- GOTRUE_JWT_SECRET: \${JWT_SECRET:?JWT_SECRET is missing from .env}
578
- GOTRUE_JWT_EXP: 3600
579
- GOTRUE_JWT_AUD: authenticated
580
- GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
581
- GOTRUE_JWT_ADMIN_ROLES: service_role,supatype_admin
582
- GOTRUE_MAILER_AUTOCONFIRM: \${GOTRUE_MAILER_AUTOCONFIRM:-true}
583
- GOTRUE_DISABLE_SIGNUP: \${DISABLE_SIGNUP:-false}
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 GoTrue DSN wrong, since whether to append `search_path` with
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 {
@@ -9,7 +9,14 @@ export interface StudioDevServerOptions {
9
9
  cwd: string
10
10
  studioOverride: string
11
11
  pidDir: string
12
- serviceRoleKey: string
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.serviceRoleKey,
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
- GOTRUE_NIL_INSTANCE_ID,
8
- gotrueJwtAud,
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("gotrueJwtAud", () => {
69
- it("reads GOTRUE_JWT_AUD from .env", () => {
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"), "GOTRUE_JWT_AUD=custom-aud\n", "utf8")
72
+ writeFileSync(join(dir, ".env"), "SUPATYPE_JWT_AUD=custom-aud\n", "utf8")
73
73
  try {
74
- expect(gotrueJwtAud(dir)).toBe("custom-aud")
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(gotrueJwtAud(dir)).toBe("authenticated")
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("GOTRUE_NIL_INSTANCE_ID", () => {
92
- it("is the nil UUID GoTrue uses for lookup", () => {
93
- expect(GOTRUE_NIL_INSTANCE_ID).toBe("00000000-0000-0000-0000-000000000000")
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 GoTrue.
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 GoTrue's search_path with the right separator", () => {
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 GoTrue would exit rather than run its auth migrations.
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(