@supatype/cli 0.1.5 → 0.1.7

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 (247) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +143 -93
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/deno.d.ts +52 -0
  5. package/dist/augmentation-generator.d.ts +22 -0
  6. package/dist/augmentation-generator.d.ts.map +1 -1
  7. package/dist/augmentation-generator.js +43 -7
  8. package/dist/augmentation-generator.js.map +1 -1
  9. package/dist/binary-cache.d.ts +14 -0
  10. package/dist/binary-cache.d.ts.map +1 -1
  11. package/dist/binary-cache.js +33 -13
  12. package/dist/binary-cache.js.map +1 -1
  13. package/dist/cache-clean.d.ts +15 -0
  14. package/dist/cache-clean.d.ts.map +1 -0
  15. package/dist/cache-clean.js +76 -0
  16. package/dist/cache-clean.js.map +1 -0
  17. package/dist/cache-pins.d.ts +51 -0
  18. package/dist/cache-pins.d.ts.map +1 -0
  19. package/dist/cache-pins.js +184 -0
  20. package/dist/cache-pins.js.map +1 -0
  21. package/dist/cli-install-method.d.ts +19 -0
  22. package/dist/cli-install-method.d.ts.map +1 -0
  23. package/dist/cli-install-method.js +60 -0
  24. package/dist/cli-install-method.js.map +1 -0
  25. package/dist/cli.d.ts.map +1 -1
  26. package/dist/cli.js +6 -0
  27. package/dist/cli.js.map +1 -1
  28. package/dist/cloud-credentials.d.ts +34 -0
  29. package/dist/cloud-credentials.d.ts.map +1 -0
  30. package/dist/cloud-credentials.js +139 -0
  31. package/dist/cloud-credentials.js.map +1 -0
  32. package/dist/commands/admin.d.ts +11 -0
  33. package/dist/commands/admin.d.ts.map +1 -1
  34. package/dist/commands/admin.js +131 -22
  35. package/dist/commands/admin.js.map +1 -1
  36. package/dist/commands/cache.d.ts.map +1 -1
  37. package/dist/commands/cache.js +67 -71
  38. package/dist/commands/cache.js.map +1 -1
  39. package/dist/commands/cloud.d.ts.map +1 -1
  40. package/dist/commands/cloud.js +32 -8
  41. package/dist/commands/cloud.js.map +1 -1
  42. package/dist/commands/db.d.ts +1 -0
  43. package/dist/commands/db.d.ts.map +1 -1
  44. package/dist/commands/db.js +147 -1
  45. package/dist/commands/db.js.map +1 -1
  46. package/dist/commands/dev.d.ts.map +1 -1
  47. package/dist/commands/dev.js +50 -10
  48. package/dist/commands/dev.js.map +1 -1
  49. package/dist/commands/doctor.d.ts +28 -0
  50. package/dist/commands/doctor.d.ts.map +1 -1
  51. package/dist/commands/doctor.js +60 -3
  52. package/dist/commands/doctor.js.map +1 -1
  53. package/dist/commands/functions.d.ts +7 -0
  54. package/dist/commands/functions.d.ts.map +1 -1
  55. package/dist/commands/functions.js +40 -6
  56. package/dist/commands/functions.js.map +1 -1
  57. package/dist/commands/generate.d.ts.map +1 -1
  58. package/dist/commands/generate.js +5 -1
  59. package/dist/commands/generate.js.map +1 -1
  60. package/dist/commands/hooks.d.ts +12 -0
  61. package/dist/commands/hooks.d.ts.map +1 -0
  62. package/dist/commands/hooks.js +118 -0
  63. package/dist/commands/hooks.js.map +1 -0
  64. package/dist/commands/init.d.ts.map +1 -1
  65. package/dist/commands/init.js +41 -7
  66. package/dist/commands/init.js.map +1 -1
  67. package/dist/commands/internal.d.ts +6 -0
  68. package/dist/commands/internal.d.ts.map +1 -0
  69. package/dist/commands/internal.js +22 -0
  70. package/dist/commands/internal.js.map +1 -0
  71. package/dist/commands/keys.d.ts.map +1 -1
  72. package/dist/commands/keys.js +2 -0
  73. package/dist/commands/keys.js.map +1 -1
  74. package/dist/commands/link-helpers.d.ts.map +1 -1
  75. package/dist/commands/link-helpers.js +8 -2
  76. package/dist/commands/link-helpers.js.map +1 -1
  77. package/dist/commands/login.d.ts +7 -0
  78. package/dist/commands/login.d.ts.map +1 -0
  79. package/dist/commands/login.js +110 -0
  80. package/dist/commands/login.js.map +1 -0
  81. package/dist/commands/push.d.ts.map +1 -1
  82. package/dist/commands/push.js +110 -7
  83. package/dist/commands/push.js.map +1 -1
  84. package/dist/commands/self-update.d.ts +7 -0
  85. package/dist/commands/self-update.d.ts.map +1 -1
  86. package/dist/commands/self-update.js +18 -11
  87. package/dist/commands/self-update.js.map +1 -1
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +18 -3
  90. package/dist/config.js.map +1 -1
  91. package/dist/db-preflight.d.ts +78 -0
  92. package/dist/db-preflight.d.ts.map +1 -0
  93. package/dist/db-preflight.js +452 -0
  94. package/dist/db-preflight.js.map +1 -0
  95. package/dist/dev-compose.d.ts +1 -1
  96. package/dist/dev-compose.d.ts.map +1 -1
  97. package/dist/dev-compose.js +129 -72
  98. package/dist/dev-compose.js.map +1 -1
  99. package/dist/env-file.d.ts +40 -2
  100. package/dist/env-file.d.ts.map +1 -1
  101. package/dist/env-file.js +132 -11
  102. package/dist/env-file.js.map +1 -1
  103. package/dist/field-masking-tier.d.ts +50 -0
  104. package/dist/field-masking-tier.d.ts.map +1 -0
  105. package/dist/field-masking-tier.js +91 -0
  106. package/dist/field-masking-tier.js.map +1 -0
  107. package/dist/functions-deno-types.d.ts +24 -0
  108. package/dist/functions-deno-types.d.ts.map +1 -0
  109. package/dist/functions-deno-types.js +155 -0
  110. package/dist/functions-deno-types.js.map +1 -0
  111. package/dist/hook-upload.d.ts +30 -0
  112. package/dist/hook-upload.d.ts.map +1 -0
  113. package/dist/hook-upload.js +86 -0
  114. package/dist/hook-upload.js.map +1 -0
  115. package/dist/hooks-generator.d.ts +8 -0
  116. package/dist/hooks-generator.d.ts.map +1 -0
  117. package/dist/hooks-generator.js +332 -0
  118. package/dist/hooks-generator.js.map +1 -0
  119. package/dist/link.d.ts +4 -0
  120. package/dist/link.d.ts.map +1 -1
  121. package/dist/link.js +2 -0
  122. package/dist/link.js.map +1 -1
  123. package/dist/local-secrets.d.ts +71 -0
  124. package/dist/local-secrets.d.ts.map +1 -0
  125. package/dist/local-secrets.js +100 -0
  126. package/dist/local-secrets.js.map +1 -0
  127. package/dist/model-hooks.d.ts +73 -0
  128. package/dist/model-hooks.d.ts.map +1 -0
  129. package/dist/model-hooks.js +211 -0
  130. package/dist/model-hooks.js.map +1 -0
  131. package/dist/postgres-ctl.d.ts +11 -0
  132. package/dist/postgres-ctl.d.ts.map +1 -1
  133. package/dist/postgres-ctl.js +25 -2
  134. package/dist/postgres-ctl.js.map +1 -1
  135. package/dist/project-config.d.ts +121 -1
  136. package/dist/project-config.d.ts.map +1 -1
  137. package/dist/project-config.js +150 -1
  138. package/dist/project-config.js.map +1 -1
  139. package/dist/resolve-target.d.ts +4 -0
  140. package/dist/resolve-target.d.ts.map +1 -1
  141. package/dist/resolve-target.js +69 -73
  142. package/dist/resolve-target.js.map +1 -1
  143. package/dist/schema-ast-v2.d.ts +2 -1
  144. package/dist/schema-ast-v2.d.ts.map +1 -1
  145. package/dist/schema-ast-v2.js +4 -2
  146. package/dist/schema-ast-v2.js.map +1 -1
  147. package/dist/schema-push-lock.d.ts.map +1 -1
  148. package/dist/schema-push-lock.js +43 -16
  149. package/dist/schema-push-lock.js.map +1 -1
  150. package/dist/self-host-compose.d.ts +24 -0
  151. package/dist/self-host-compose.d.ts.map +1 -1
  152. package/dist/self-host-compose.js +301 -69
  153. package/dist/self-host-compose.js.map +1 -1
  154. package/dist/service-role-check.d.ts +21 -0
  155. package/dist/service-role-check.d.ts.map +1 -0
  156. package/dist/service-role-check.js +85 -0
  157. package/dist/service-role-check.js.map +1 -0
  158. package/dist/target-client.d.ts +17 -0
  159. package/dist/target-client.d.ts.map +1 -1
  160. package/dist/target-client.js +69 -17
  161. package/dist/target-client.js.map +1 -1
  162. package/dist/type-extractor.d.ts.map +1 -1
  163. package/dist/type-extractor.js +736 -32
  164. package/dist/type-extractor.js.map +1 -1
  165. package/dist/ui/runtime/command-chrome.js +1 -1
  166. package/dist/ui/runtime/command-chrome.js.map +1 -1
  167. package/dist/ui/runtime/stdin-after-ink.d.ts.map +1 -1
  168. package/dist/ui/runtime/stdin-after-ink.js +3 -1
  169. package/dist/ui/runtime/stdin-after-ink.js.map +1 -1
  170. package/package.json +4 -1
  171. package/scripts/generate-homebrew-formula.mjs +121 -0
  172. package/src/augmentation-generator.ts +46 -9
  173. package/src/binary-cache.ts +47 -15
  174. package/src/cache-clean.ts +81 -0
  175. package/src/cache-pins.ts +243 -0
  176. package/src/cli-install-method.ts +67 -0
  177. package/src/cli.ts +6 -0
  178. package/src/cloud-credentials.ts +183 -0
  179. package/src/commands/admin.ts +153 -28
  180. package/src/commands/cache.ts +90 -75
  181. package/src/commands/cloud.ts +32 -8
  182. package/src/commands/db.ts +162 -1
  183. package/src/commands/dev.ts +57 -8
  184. package/src/commands/doctor.ts +69 -3
  185. package/src/commands/functions.ts +42 -5
  186. package/src/commands/generate.ts +5 -1
  187. package/src/commands/hooks.ts +138 -0
  188. package/src/commands/init.ts +42 -8
  189. package/src/commands/internal.ts +23 -0
  190. package/src/commands/keys.ts +2 -0
  191. package/src/commands/link-helpers.ts +9 -2
  192. package/src/commands/login.ts +127 -0
  193. package/src/commands/push.ts +124 -7
  194. package/src/commands/self-update.ts +85 -13
  195. package/src/config.ts +22 -3
  196. package/src/db-preflight.ts +575 -0
  197. package/src/dev-compose.ts +144 -73
  198. package/src/env-file.ts +157 -11
  199. package/src/field-masking-tier.ts +107 -0
  200. package/src/functions-deno-types.ts +188 -0
  201. package/src/hook-upload.ts +107 -0
  202. package/src/hooks-generator.ts +352 -0
  203. package/src/link.ts +7 -0
  204. package/src/local-secrets.ts +104 -0
  205. package/src/model-hooks.ts +260 -0
  206. package/src/postgres-ctl.ts +29 -2
  207. package/src/project-config.ts +236 -2
  208. package/src/resolve-target.ts +113 -68
  209. package/src/schema-ast-v2.ts +5 -2
  210. package/src/schema-push-lock.ts +56 -17
  211. package/src/self-host-compose.ts +344 -68
  212. package/src/service-role-check.ts +111 -0
  213. package/src/target-client.ts +105 -18
  214. package/src/type-extractor.ts +883 -28
  215. package/src/ui/runtime/command-chrome.ts +1 -1
  216. package/src/ui/runtime/stdin-after-ink.ts +3 -1
  217. package/tests/access-composition.test.ts +667 -0
  218. package/tests/admin-studio-membership.test.ts +121 -0
  219. package/tests/api-schemas.test.ts +83 -0
  220. package/tests/archive-name-alias.test.ts +42 -0
  221. package/tests/augmentation-generator.test.ts +72 -4
  222. package/tests/cache-clean.test.ts +206 -0
  223. package/tests/cli-help.test.ts +3 -0
  224. package/tests/cli-install-method.test.ts +66 -0
  225. package/tests/command-chrome-coverage.test.ts +31 -26
  226. package/tests/command-chrome-wrap.test.ts +1 -0
  227. package/tests/db-preflight.test.ts +72 -0
  228. package/tests/doctor-render.test.ts +62 -0
  229. package/tests/doctor-service-role.test.ts +55 -0
  230. package/tests/env-file.test.ts +121 -0
  231. package/tests/external-database-compose.test.ts +184 -0
  232. package/tests/external-database-config.test.ts +154 -0
  233. package/tests/field-masking-tier.test.ts +184 -0
  234. package/tests/functions-deno-types.test.ts +197 -0
  235. package/tests/homebrew-formula.test.ts +58 -0
  236. package/tests/hook-upload.test.ts +125 -0
  237. package/tests/hooks-generator.test.ts +212 -0
  238. package/tests/init.test.ts +3 -0
  239. package/tests/local-secrets.test.ts +92 -0
  240. package/tests/model-hooks.test.ts +322 -0
  241. package/tests/runtime-contract.test.ts +113 -1
  242. package/tests/service-role-check.test.ts +109 -0
  243. package/tests/service-role-routes.test.ts +51 -0
  244. package/tests/target-client-refresh.test.ts +94 -0
  245. package/tests/type-extractor.test.ts +187 -0
  246. package/tsconfig.tsbuildinfo +1 -1
  247. package/tests/engine-distribution.test.ts +0 -418
@@ -20,6 +20,7 @@ import { registerApp } from "../src/commands/app.js"
20
20
  import { registerAdd } from "../src/commands/add.js"
21
21
  import { registerSelfHost } from "../src/commands/self-host.js"
22
22
  import { registerCloud } from "../src/commands/cloud.js"
23
+ import { registerLogin } from "../src/commands/login.js"
23
24
  import { registerEngine } from "../src/commands/engine.js"
24
25
  import { registerDb } from "../src/commands/db.js"
25
26
  import { registerDeploy } from "../src/commands/deploy.js"
@@ -80,6 +81,7 @@ function buildProgram(): Command {
80
81
  registerApp(program)
81
82
  registerAdd(program)
82
83
  registerSelfHost(program)
84
+ registerLogin(program)
83
85
  registerCloud(program)
84
86
  registerEngine(program)
85
87
  registerDb(program)
@@ -118,32 +120,35 @@ describe("CLI command chrome coverage", () => {
118
120
  expect(handlerBefore, `${path} should be wrapped`).toBeDefined()
119
121
  }
120
122
 
121
- expect(excluded).toEqual(
122
- expect.arrayContaining([
123
- "dev",
124
- "init",
125
- "link",
126
- "add domain",
127
- "logs",
128
- "pg psql",
129
- "functions serve",
130
- "self-host compose logs",
131
- ]),
132
- )
123
+ for (const path of [
124
+ "dev",
125
+ "init",
126
+ "link",
127
+ "login",
128
+ "add domain",
129
+ "logs",
130
+ "pg psql",
131
+ "functions serve",
132
+ "self-host compose logs",
133
+ ]) {
134
+ expect(excluded, `${path} should skip chrome`).toContain(path)
135
+ }
133
136
 
134
- expect(wrapped).toEqual(
135
- expect.arrayContaining([
136
- "push",
137
- "diff",
138
- "doctor",
139
- "pull",
140
- "cache list",
141
- "cache rest list",
142
- "deploy",
143
- "deploy rollback",
144
- "keys",
145
- "generate",
146
- ]),
147
- )
137
+ for (const path of [
138
+ "push",
139
+ "diff",
140
+ "doctor",
141
+ "pull",
142
+ "cache list",
143
+ "cache rest list",
144
+ "deploy",
145
+ "deploy rollback",
146
+ "keys",
147
+ "generate",
148
+ "logout",
149
+ "whoami",
150
+ ]) {
151
+ expect(wrapped, `${path} should use chrome`).toContain(path)
152
+ }
148
153
  })
149
154
  })
@@ -10,6 +10,7 @@ describe("shouldExcludeCommandChrome", () => {
10
10
  expect(shouldExcludeCommandChrome("dev")).toBe(true)
11
11
  expect(shouldExcludeCommandChrome("init")).toBe(true)
12
12
  expect(shouldExcludeCommandChrome("link")).toBe(true)
13
+ expect(shouldExcludeCommandChrome("_postinstall")).toBe(true)
13
14
  expect(shouldExcludeCommandChrome("add domain")).toBe(true)
14
15
  expect(shouldExcludeCommandChrome("logs")).toBe(true)
15
16
  expect(shouldExcludeCommandChrome("pg psql")).toBe(true)
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ PASSWORD_PLACEHOLDER,
4
+ needsOperatorPassword,
5
+ operatorRemedies,
6
+ transactionalRemedies,
7
+ type CheckResult,
8
+ type PreflightReport,
9
+ } from "../src/db-preflight.js"
10
+
11
+ // The preflight's live behaviour is exercised against a real database; these cover the decisions
12
+ // it makes about its own findings, which is where a mistake would be quiet rather than loud.
13
+
14
+ const result = (over: Partial<CheckResult>): CheckResult => ({
15
+ id: "x",
16
+ title: "x",
17
+ severity: "pass",
18
+ detail: "x",
19
+ ...over,
20
+ })
21
+
22
+ const report = (results: CheckResult[]): PreflightReport => ({ results, worst: "pass" })
23
+
24
+ describe("splitting remedies", () => {
25
+ // `--fix` runs everything it takes in one transaction. Anything that cannot be transactional
26
+ // must be excluded, or the transaction fails and the fixable work rolls back with it.
27
+ it("keeps server-setting remedies out of the transaction", () => {
28
+ const r = report([
29
+ result({ id: "roles", remedy: "CREATE ROLE anon;" }),
30
+ result({ id: "wal", remedy: "ALTER SYSTEM SET wal_level = 'logical';", remedyNeedsOperator: true }),
31
+ result({ id: "clean" }),
32
+ ])
33
+
34
+ expect(transactionalRemedies(r).map((x) => x.id)).toEqual(["roles"])
35
+ expect(operatorRemedies(r).map((x) => x.id)).toEqual(["wal"])
36
+ })
37
+
38
+ it("ignores findings with nothing to apply", () => {
39
+ const r = report([result({ id: "a" }), result({ id: "b", impact: "something is off" })])
40
+ expect(transactionalRemedies(r)).toHaveLength(0)
41
+ expect(operatorRemedies(r)).toHaveLength(0)
42
+ })
43
+ })
44
+
45
+ describe("the authenticator password guard", () => {
46
+ // The credential is the operator's by decision. Executing the placeholder would create a role
47
+ // with a password from our source code — worse than refusing, and harder to notice.
48
+ it("detects a remedy that would execute the placeholder", () => {
49
+ const remedies = [
50
+ result({ remedy: `CREATE ROLE authenticator LOGIN NOINHERIT PASSWORD '${PASSWORD_PLACEHOLDER}';` }),
51
+ ]
52
+ expect(needsOperatorPassword(remedies)).toBe(true)
53
+ })
54
+
55
+ it("is satisfied once a real password is present", () => {
56
+ const remedies = [
57
+ result({ remedy: "CREATE ROLE authenticator LOGIN NOINHERIT PASSWORD 'a-real-one';" }),
58
+ ]
59
+ expect(needsOperatorPassword(remedies)).toBe(false)
60
+ })
61
+
62
+ it("does not flag remedies that have no password at all", () => {
63
+ expect(needsOperatorPassword([result({ remedy: "GRANT anon TO authenticator;" })])).toBe(false)
64
+ expect(needsOperatorPassword([result({})])).toBe(false)
65
+ })
66
+
67
+ // The placeholder has to be something no operator would plausibly choose *and* invalid-looking,
68
+ // so that if it ever did reach a database it would be obvious in pg_authid rather than subtle.
69
+ it("uses an obviously-not-a-password placeholder", () => {
70
+ expect(PASSWORD_PLACEHOLDER).toMatch(/^<.*>$/)
71
+ })
72
+ })
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest"
2
+ import { printSection } from "../src/commands/doctor.js"
3
+
4
+ /**
5
+ * How a drift item reads to the operator.
6
+ *
7
+ * Items are keyed `table` + `name`, which for an index or constraint are different things and for a
8
+ * *table* are the same — so the natural `table.name` form printed "widget.widget". Tables only
9
+ * started appearing in this report when the ownership gate got its reporting (E17), and a report an
10
+ * operator distrusts is worse than no report.
11
+ */
12
+ const lines = (): string[] => {
13
+ const captured: string[] = []
14
+ vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
15
+ captured.push(args.map(String).join(" "))
16
+ })
17
+ return captured
18
+ }
19
+
20
+ afterEach(() => {
21
+ vi.restoreAllMocks()
22
+ })
23
+
24
+ describe("printSection", () => {
25
+ it("prints a table once, not twice", () => {
26
+ const captured = lines()
27
+ printSection("Unmanaged drift", [
28
+ {
29
+ kind: "table",
30
+ table: "widget",
31
+ name: "widget",
32
+ fields: [],
33
+ message: "Unmanaged table widget — push will not drop; manual decision required",
34
+ },
35
+ ])
36
+ const text = captured.join("\n")
37
+ expect(text).toContain("• widget")
38
+ expect(text).not.toContain("widget.widget")
39
+ // The message carries the whole point; a bare object name says nothing about consequence.
40
+ expect(text).toContain("push will not drop; manual decision required")
41
+ })
42
+
43
+ it("keeps the qualified form for objects on a table", () => {
44
+ const captured = lines()
45
+ printSection("Unmanaged drift", [
46
+ {
47
+ kind: "index",
48
+ table: "widget",
49
+ name: "widget_name_idx",
50
+ fields: ["name"],
51
+ message: "Unmanaged index widget_name_idx on widget — push will not drop",
52
+ },
53
+ ])
54
+ expect(captured.join("\n")).toContain("• widget.widget_name_idx (name)")
55
+ })
56
+
57
+ it("prints nothing at all for an empty section", () => {
58
+ const captured = lines()
59
+ printSection("Unmanaged drift", [])
60
+ expect(captured).toEqual([])
61
+ })
62
+ })
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
2
+
3
+ // `doctor` reaches this section only after talking to the engine, so the printing is exercised here
4
+ // instead: the section is how an operator answers "which of my functions can bypass every access rule?"
5
+ const lines: string[] = []
6
+ vi.mock("../src/ui/messages.js", () => ({
7
+ plain: (line?: string) => lines.push(line ?? ""),
8
+ info: () => {},
9
+ }))
10
+
11
+ const { printServiceRoleGrants } = await import("../src/commands/doctor.js")
12
+
13
+ beforeEach(() => {
14
+ lines.length = 0
15
+ })
16
+ afterEach(() => {
17
+ vi.clearAllMocks()
18
+ })
19
+
20
+ const empty = { errors: [], missing: [], warnings: [], available: [] }
21
+
22
+ describe("printServiceRoleGrants", () => {
23
+ it("prints nothing when a project grants nothing", () => {
24
+ printServiceRoleGrants(empty, [])
25
+ expect(lines).toEqual([])
26
+ })
27
+
28
+ it("lists every grant, and says what the list means", () => {
29
+ printServiceRoleGrants(empty, ["audit", "send-email"])
30
+ const out = lines.join("\n")
31
+ expect(out).toContain("Service-role grants (2)")
32
+ expect(out).toContain("• audit")
33
+ expect(out).toContain("• send-email")
34
+ expect(out).toContain("bypass every access rule")
35
+ })
36
+
37
+ it("marks a grant that matches no function", () => {
38
+ printServiceRoleGrants(
39
+ { ...empty, errors: [' "send-emial" — no functions/send-emial/index.ts'], missing: ["send-emial"] },
40
+ ["send-emial", "audit"],
41
+ )
42
+ const out = lines.join("\n")
43
+ expect(out).toContain("✗ send-emial")
44
+ expect(out).toContain("• audit")
45
+ expect(out).toContain("grant nothing")
46
+ })
47
+
48
+ it("passes a redundant-entry warning through", () => {
49
+ printServiceRoleGrants(
50
+ { ...empty, warnings: [' "hooks/x" is not needed — a hook receives the key'] },
51
+ ["hooks/x"],
52
+ )
53
+ expect(lines.join("\n")).toContain("is not needed")
54
+ })
55
+ })
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest"
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
3
+ import { join } from "node:path"
4
+ import { tmpdir } from "node:os"
5
+ import { MANAGED_MARKER, readEnvFile, upsertEnvFile } from "../src/env-file.js"
6
+
7
+ /**
8
+ * `.env` is a file operators edit by hand, and the CLI rewrites it on every compose run.
9
+ *
10
+ * Two defects motivated this: the rewrite rebuilt the file from its assignments alone, so every
11
+ * comment and blank line vanished; and the image keys derivable from `versions` were deleted
12
+ * unconditionally, so `SUPATYPE_REALTIME_IMAGE` could be overridden from `.env` and
13
+ * `SUPATYPE_SERVER_IMAGE` could not. Same file, same shape, different rules — which is how an hour
14
+ * goes into wondering why a locally built image is ignored.
15
+ */
16
+ let dir: string
17
+
18
+ beforeEach(() => {
19
+ dir = mkdtempSync(join(tmpdir(), "supatype-env-"))
20
+ })
21
+
22
+ afterEach(() => {
23
+ rmSync(dir, { recursive: true, force: true })
24
+ })
25
+
26
+ const write = (body: string) => writeFileSync(join(dir, ".env"), body, "utf8")
27
+ const read = () => readFileSync(join(dir, ".env"), "utf8")
28
+
29
+ describe("upsertEnvFile", () => {
30
+ it("keeps comments, blank lines and order", () => {
31
+ write(`# notes about this project
32
+ DATABASE_URL=postgres://me@host/db
33
+
34
+ # the image I built by hand
35
+ SUPATYPE_SERVER_IMAGE=my/server:local
36
+ JWT_SECRET=abc
37
+ `)
38
+ upsertEnvFile(dir, { ANON_KEY: "new" })
39
+
40
+ const after = read()
41
+ expect(after).toContain("# notes about this project")
42
+ expect(after).toContain("# the image I built by hand")
43
+ expect(after).toContain("SUPATYPE_SERVER_IMAGE=my/server:local")
44
+ // Order preserved, new keys appended.
45
+ expect(after.indexOf("DATABASE_URL")).toBeLessThan(after.indexOf("JWT_SECRET"))
46
+ expect(after.indexOf("JWT_SECRET")).toBeLessThan(after.indexOf("ANON_KEY"))
47
+ expect(after).toMatch(/\n\n/)
48
+ })
49
+
50
+ it("updates a value in place rather than moving it to the end", () => {
51
+ write("A=1\nB=2\nC=3\n")
52
+ upsertEnvFile(dir, { B: "changed" })
53
+ expect(read()).toBe("A=1\nB=changed\nC=3\n")
54
+ })
55
+
56
+ it("leaves a hand-written value alone when asked to clean up managed keys", () => {
57
+ // The reported bug, exactly: an operator's image override survived for realtime and was deleted
58
+ // for the server.
59
+ write("SUPATYPE_SERVER_IMAGE=my/server:local\n")
60
+ upsertEnvFile(dir, {}, { removeManaged: ["SUPATYPE_SERVER_IMAGE"] })
61
+ expect(readEnvFile(dir)["SUPATYPE_SERVER_IMAGE"]).toBe("my/server:local")
62
+ })
63
+
64
+ it("does clean up a value it wrote itself", () => {
65
+ // Otherwise removing a pin from config leaves a stale image reference that silently keeps
66
+ // running the old version — the reason the deletion existed at all.
67
+ upsertEnvFile(
68
+ dir,
69
+ { SUPATYPE_SERVER_IMAGE: "supatype/server:v1.2.3" },
70
+ { managed: ["SUPATYPE_SERVER_IMAGE"] },
71
+ )
72
+ expect(read()).toContain(MANAGED_MARKER)
73
+
74
+ upsertEnvFile(dir, {}, { removeManaged: ["SUPATYPE_SERVER_IMAGE"] })
75
+ expect(readEnvFile(dir)["SUPATYPE_SERVER_IMAGE"]).toBeUndefined()
76
+ // And no orphaned marker comment left behind.
77
+ expect(read()).not.toContain(MANAGED_MARKER)
78
+ })
79
+
80
+ it("takes over a hand-written value when config starts pinning it, and marks it", () => {
81
+ write("SUPATYPE_SERVER_IMAGE=my/server:local\n")
82
+ upsertEnvFile(
83
+ dir,
84
+ { SUPATYPE_SERVER_IMAGE: "supatype/server:v2.0.0" },
85
+ { managed: ["SUPATYPE_SERVER_IMAGE"] },
86
+ )
87
+ const after = read()
88
+ expect(after).toContain("SUPATYPE_SERVER_IMAGE=supatype/server:v2.0.0")
89
+ expect(after).toContain(MANAGED_MARKER)
90
+ // So the next run can clean it up again.
91
+ upsertEnvFile(dir, {}, { removeManaged: ["SUPATYPE_SERVER_IMAGE"] })
92
+ expect(readEnvFile(dir)["SUPATYPE_SERVER_IMAGE"]).toBeUndefined()
93
+ })
94
+
95
+ it("removes outright when asked, whoever wrote it", () => {
96
+ // For a seed secret being retired.
97
+ write("SUPATYPE_ADMIN_PASSWORD=temporary\nJWT_SECRET=keep\n")
98
+ upsertEnvFile(dir, {}, { remove: ["SUPATYPE_ADMIN_PASSWORD"] })
99
+ expect(readEnvFile(dir)["SUPATYPE_ADMIN_PASSWORD"]).toBeUndefined()
100
+ expect(readEnvFile(dir)["JWT_SECRET"]).toBe("keep")
101
+ })
102
+
103
+ it("still accepts the old array argument as an unconditional removal", () => {
104
+ write("GONE=1\nSTAYS=2\n")
105
+ upsertEnvFile(dir, {}, ["GONE"])
106
+ expect(readEnvFile(dir)).toEqual({ STAYS: "2" })
107
+ })
108
+
109
+ it("creates the file when absent", () => {
110
+ upsertEnvFile(dir, { A: "1" })
111
+ expect(read()).toBe("A=1\n")
112
+ })
113
+
114
+ it("does not treat a commented-out assignment as a value", () => {
115
+ // `# FOO=bar` is a note, not a setting — the old parser kept it as a key called "# FOO".
116
+ write("# FOO=bar\nREAL=1\n")
117
+ upsertEnvFile(dir, { REAL: "2" })
118
+ expect(read()).toContain("# FOO=bar")
119
+ expect(readEnvFile(dir)).toEqual({ REAL: "2" })
120
+ })
121
+ })
@@ -0,0 +1,184 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { loopbackExternalHost, 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 for a database Supatype does not manage.
8
+ *
9
+ * The cheap test that pins the whole workstream: no `db` service, no `@db:5432` anywhere, and every
10
+ * service resolving to the operator's URL. A stack that still names `db` starts six containers that
11
+ * cannot resolve the host, which is a confusing way to learn the config was ignored.
12
+ */
13
+ const EXTERNAL_URL = "postgres://owner:secret@db.example.com:5432/app"
14
+
15
+ const base = {
16
+ project: { name: "acme" },
17
+ server: { mode: "dev" as const },
18
+ app: { mode: "none" as const },
19
+ versions: {
20
+ engine: "0.4.2",
21
+ server: "0.1.0",
22
+ postgres: "17.2",
23
+ deno: DENO_RELEASE_PIN,
24
+ },
25
+ }
26
+
27
+ const project = (database: unknown): SupatypeProjectConfig =>
28
+ validateProjectConfig({ ...base, database }, "supatype.config.ts")
29
+
30
+ const external = (url = EXTERNAL_URL) => project({ external: { url } })
31
+ const managed = () => project({ provider: "docker" })
32
+
33
+ describe("external database — compose", () => {
34
+ it("omits the db service and says why", () => {
35
+ const compose = renderSelfHostCompose(external())
36
+ expect(compose).not.toMatch(/^ {2}db:$/m)
37
+ expect(compose).toContain("# No `db` service: database.external")
38
+ })
39
+
40
+ it("keeps the db service for a managed database", () => {
41
+ // The gate must not cost everyone else their database.
42
+ const compose = renderSelfHostCompose(managed())
43
+ expect(compose).toMatch(/^ {2}db:$/m)
44
+ expect(compose).toContain("pg_isready")
45
+ })
46
+
47
+ it("leaves no reference to the db host anywhere", () => {
48
+ const compose = renderSelfHostCompose(external())
49
+ expect(compose).not.toContain("@db:5432")
50
+ expect(compose).not.toContain("db-data")
51
+ expect(compose).not.toContain("condition: service_healthy")
52
+ })
53
+
54
+ it("points every database consumer at the one URL", () => {
55
+ const compose = renderSelfHostCompose(external())
56
+ // storage, realtime, control-plane, server (SUPATYPE_SQL_DATABASE_URL), and GoTrue.
57
+ const references = compose.match(/\$\{DATABASE_URL:\?/g) ?? []
58
+ expect(references.length).toBe(5)
59
+ })
60
+
61
+ it("interpolates the URL rather than baking the password into the file", () => {
62
+ // The compose file is generated into .supatype/, which operators do commit.
63
+ const compose = renderSelfHostCompose(external())
64
+ // "secret" alone would match minio's own S3 credentials, so match the userinfo form.
65
+ expect(compose).not.toContain("owner:secret@")
66
+ expect(compose).not.toContain("://owner")
67
+ expect(compose).toContain("${DATABASE_URL:?DATABASE_URL is missing from .env")
68
+ })
69
+
70
+ it("derives PostgREST's authenticator DSN from the URL, password excepted", () => {
71
+ // A second full URL in .env would be one more thing to keep in step; two that disagree about
72
+ // which database is a split-brain nobody notices until the API and migrations diverge.
73
+ const compose = renderSelfHostCompose(external())
74
+ expect(compose).toContain(
75
+ "PGRST_DB_URI: postgresql://authenticator:${AUTHENTICATOR_PASSWORD:?AUTHENTICATOR_PASSWORD is missing from .env}@db.example.com:5432/app",
76
+ )
77
+ })
78
+
79
+ it("carries the URL's query string into the authenticator DSN", () => {
80
+ // `?sslmode=require` is the default on most managed providers, and dropping it turns a working
81
+ // owner connection into a PostgREST that cannot connect at all.
82
+ const compose = renderSelfHostCompose(
83
+ external("postgres://owner:secret@db.example.com:5432/app?sslmode=require"),
84
+ )
85
+ expect(compose).toContain("@db.example.com:5432/app?sslmode=require")
86
+ })
87
+
88
+ it("appends GoTrue's search_path with the right separator", () => {
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.
91
+ expect(renderSelfHostCompose(external())).toContain('${DATABASE_URL:?DATABASE_URL is missing from .env — required by database.external}?search_path=auth"')
92
+ expect(
93
+ renderSelfHostCompose(
94
+ external("postgres://owner:secret@db.example.com:5432/app?sslmode=require"),
95
+ ),
96
+ ).toContain('${DATABASE_URL:?DATABASE_URL is missing from .env — required by database.external}&search_path=auth"')
97
+ })
98
+
99
+ it("omits realtime entirely when the project turns it off", () => {
100
+ // For the operator who has read `supatype db check` and knows their database cannot do logical
101
+ // decoding. The service degrades on its own, so this is about not running a container whose only
102
+ // job would be to report that.
103
+ const compose = renderSelfHostCompose(project({ external: { url: EXTERNAL_URL, realtime: false } }))
104
+ expect(compose).not.toMatch(/^ {2}realtime:$/m)
105
+ expect(compose).toContain("# No `realtime` service: database.external.realtime is false")
106
+ // And nothing else may reference the service that is not there.
107
+ expect(compose).not.toContain("SUPATYPE_REALTIME_URL")
108
+ expect(compose).not.toContain(" realtime:\n condition:")
109
+ })
110
+
111
+ it("keeps realtime when the flag is unset", () => {
112
+ // Unset means "probe it", not "assume it is broken" — the service reports its own capability.
113
+ const compose = renderSelfHostCompose(external())
114
+ expect(compose).toMatch(/^ {2}realtime:$/m)
115
+ expect(compose).toContain("SUPATYPE_REALTIME_URL: http://realtime:4000")
116
+ })
117
+
118
+ it("does not publish a port for a database it does not run", () => {
119
+ const compose = renderSelfHostCompose(external())
120
+ expect(compose).not.toContain('"5432:5432"')
121
+ })
122
+
123
+ it("still declares the volumes it does own", () => {
124
+ const compose = renderSelfHostCompose(external())
125
+ expect(compose).toContain(" minio-data:")
126
+ expect(compose).toContain(" valkey-data:")
127
+ })
128
+
129
+ it("keeps the rest of the stack intact", () => {
130
+ // The db service is the only thing that goes; a missing storage or realtime service would be a
131
+ // far quieter failure than a missing database.
132
+ const compose = renderSelfHostCompose(external())
133
+ for (const service of [
134
+ "postgrest:",
135
+ "storage:",
136
+ "functions-worker:",
137
+ "realtime:",
138
+ "control-plane:",
139
+ "server:",
140
+ "minio:",
141
+ "schema-engine:",
142
+ "studio:",
143
+ "valkey:",
144
+ "kong:",
145
+ ]) {
146
+ expect(compose).toContain(` ${service}`)
147
+ }
148
+ })
149
+
150
+ it("renders valid YAML shape for the server's remaining dependencies", () => {
151
+ // The server waited on db *and* six others, so removing one entry must not leave a dangling
152
+ // `depends_on:` or a stray blank mapping key.
153
+ const compose = renderSelfHostCompose(external())
154
+ expect(compose).toContain(" depends_on:\n valkey:\n condition: service_started")
155
+ })
156
+ })
157
+
158
+ describe("a loopback external URL", () => {
159
+ // Found by rehearsing a push against a real external Postgres: `db check` passed and `push`
160
+ // applied the schema, because the CLI runs on the host — then storage, realtime and the server all
161
+ // died with ECONNREFUSED against their own loopback, because inside a container `127.0.0.1` is
162
+ // that container. A retry loop would only have hidden it.
163
+ it("is detected, whatever form it takes", () => {
164
+ for (const host of ["localhost", "127.0.0.1", "127.0.0.53", "[::1]"]) {
165
+ const url = host.startsWith("[")
166
+ ? `postgres://u:p@${host}:5432/d`
167
+ : `postgres://u:p@${host}:5432/d`
168
+ expect(loopbackExternalHost(project({ external: { url } })), host).toBeDefined()
169
+ }
170
+ })
171
+
172
+ it("does not fire for a host containers can actually reach", () => {
173
+ for (const host of ["host.docker.internal", "db.example.com", "172.17.0.1", "10.0.0.5"]) {
174
+ expect(
175
+ loopbackExternalHost(project({ external: { url: `postgres://u:p@${host}:5432/d` } })),
176
+ host,
177
+ ).toBeUndefined()
178
+ }
179
+ })
180
+
181
+ it("does not fire for a managed database", () => {
182
+ expect(loopbackExternalHost(managed())).toBeUndefined()
183
+ })
184
+ })