@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
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { mkdirSync, rmSync } from "node:fs"
3
+ import { join } from "node:path"
4
+ import { tmpdir } from "node:os"
5
+ import type { QueryResult } from "pg"
6
+ import { ensureFirstAdminWithQuery, STUDIO_ROLES } from "../src/commands/admin.js"
7
+
8
+ function result(rows: Record<string, unknown>[]): QueryResult {
9
+ return {
10
+ rows,
11
+ rowCount: rows.length,
12
+ command: "SELECT",
13
+ oid: 0,
14
+ fields: [],
15
+ } as unknown as QueryResult
16
+ }
17
+
18
+ interface Recorded {
19
+ sql: string
20
+ params: unknown[]
21
+ }
22
+
23
+ /**
24
+ * Answers the probes `ensureFirstAdminWithQuery` makes and records every
25
+ * statement, so the assertions can inspect what would reach Postgres.
26
+ */
27
+ function stubDb(): { query: (sql: string, params?: unknown[]) => Promise<QueryResult>; log: Recorded[] } {
28
+ const log: Recorded[] = []
29
+ const query = async (sql: string, params: unknown[] = []): Promise<QueryResult> => {
30
+ log.push({ sql, params })
31
+
32
+ if (sql.includes("information_schema.tables") && sql.includes("'auth'")) {
33
+ return result([{ exists: true }])
34
+ }
35
+ if (sql.includes("to_regclass")) {
36
+ return result([{ exists: false }]) // no membership table yet → no admins
37
+ }
38
+ if (sql.includes("information_schema.columns")) {
39
+ return result([{ column_name: "email_confirmed_at" }])
40
+ }
41
+ if (sql.includes("SELECT id FROM auth.users")) {
42
+ return result([]) // email not taken
43
+ }
44
+ if (sql.includes("INSERT INTO auth.users")) {
45
+ return result([{ id: "11111111-2222-3333-4444-555555555555", email: "admin@example.com" }])
46
+ }
47
+ return result([])
48
+ }
49
+ return { query, log }
50
+ }
51
+
52
+ async function seedFirstAdmin(): Promise<Recorded[]> {
53
+ const dir = join(tmpdir(), `supatype-admin-membership-${process.pid}-${Math.random()}`)
54
+ mkdirSync(dir, { recursive: true })
55
+ const { query, log } = stubDb()
56
+ try {
57
+ await ensureFirstAdminWithQuery(query, {
58
+ cwd: dir,
59
+ email: "admin@example.com",
60
+ password: "password123",
61
+ role: "admin",
62
+ })
63
+ } finally {
64
+ rmSync(dir, { recursive: true, force: true })
65
+ }
66
+ return log
67
+ }
68
+
69
+ // Capability checks refuse a role they do not recognise, so the CLI's set has to
70
+ // match the server's (`studioRolePermissions`) and the control plane's
71
+ // (`STUDIO_ROLE_PERMISSIONS`) exactly — a role only this side knows about creates
72
+ // a user who is locked out of the panel they were made for.
73
+ describe("STUDIO_ROLES", () => {
74
+ it("matches the roles the servers understand", () => {
75
+ expect([...STUDIO_ROLES]).toEqual(["admin", "developer", "editor"])
76
+ })
77
+ })
78
+
79
+ describe("first admin user creation", () => {
80
+ // Regression: Studio access used to be granted by writing `role` into
81
+ // `app_metadata`. That is the developer's namespace for their own application
82
+ // roles, so anything that assigned an app role could hand out admin UI access.
83
+ it("does not write a role into app_metadata", async () => {
84
+ const log = await seedFirstAdmin()
85
+ const insert = log.find((entry) => entry.sql.includes("INSERT INTO auth.users"))
86
+ expect(insert).toBeDefined()
87
+
88
+ const appMetadata = insert!.params.find(
89
+ (p): p is string => typeof p === "string" && p.trimStart().startsWith("{"),
90
+ )
91
+ expect(appMetadata).toBeDefined()
92
+ expect(JSON.parse(appMetadata!)).not.toHaveProperty("role")
93
+ })
94
+
95
+ it("grants Studio access through _supatype.studio_members", async () => {
96
+ const log = await seedFirstAdmin()
97
+ const grant = log.find((entry) =>
98
+ entry.sql.includes("INSERT INTO _supatype.studio_members"),
99
+ )
100
+ expect(grant).toBeDefined()
101
+ expect(grant!.params).toEqual(["11111111-2222-3333-4444-555555555555", "admin"])
102
+ })
103
+
104
+ // The membership table is the only grant, so `admin create-user` has to work
105
+ // on a project whose engine predates it or that has never been pushed.
106
+ it("creates the membership table when it is absent", async () => {
107
+ const log = await seedFirstAdmin()
108
+ expect(
109
+ log.some((entry) => entry.sql.includes("CREATE TABLE IF NOT EXISTS _supatype.studio_members")),
110
+ ).toBe(true)
111
+ })
112
+
113
+ // Counting `app_metadata` claims would report an admin exists while nobody
114
+ // can actually log in, so the "do I need a first admin?" check must read
115
+ // membership.
116
+ it("decides whether an admin exists from membership, not claims", async () => {
117
+ const log = await seedFirstAdmin()
118
+ const probe = log.find((entry) => entry.sql.includes("COUNT(*)"))
119
+ expect(probe?.sql ?? "").not.toContain("raw_app_meta_data")
120
+ })
121
+ })
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ apiSchemaList,
4
+ apiSchemas,
5
+ pgSchema,
6
+ type SupatypeProjectConfig,
7
+ } from "../src/project-config.js"
8
+
9
+ /**
10
+ * `PGRST_DB_SCHEMA` used to be a literal in two places, so `schema.pg_schema` was honoured by the
11
+ * engine and ignored by the API: a project pushed into its own schema and then answered PGRST106 on
12
+ * every request, with nothing in the output naming the setting responsible.
13
+ */
14
+ const project = (schema?: SupatypeProjectConfig["schema"]): SupatypeProjectConfig => ({
15
+ project: { name: "acme" },
16
+ database: { provider: "docker" },
17
+ server: { mode: "dev" },
18
+ app: { mode: "none" },
19
+ ...(schema !== undefined && { schema }),
20
+ })
21
+
22
+ describe("pgSchema", () => {
23
+ it("defaults to public", () => {
24
+ expect(pgSchema(project())).toBe("public")
25
+ expect(pgSchema(project({}))).toBe("public")
26
+ })
27
+
28
+ it("ignores a blank pg_schema rather than emitting an empty identifier", () => {
29
+ expect(pgSchema(project({ pg_schema: " " }))).toBe("public")
30
+ })
31
+ })
32
+
33
+ describe("apiSchemas", () => {
34
+ it("reproduces the list self-host shipped before it was configurable", () => {
35
+ expect(apiSchemaList(project())).toBe("public, supatype, graphql_public, auth")
36
+ })
37
+
38
+ it("is the same list for dev and self-host", () => {
39
+ // Dev used to omit `auth`, so a request that worked against a self-hosted stack 404ed locally.
40
+ expect(apiSchemas(project())).toContain("auth")
41
+ })
42
+
43
+ it("leads with the managed schema when pg_schema moves", () => {
44
+ // First entry is what PostgREST serves as the default profile, so the managed schema has to
45
+ // lead — otherwise unprefixed requests would resolve against `supatype`.
46
+ expect(apiSchemaList(project({ pg_schema: "app" }))).toBe(
47
+ "app, supatype, graphql_public, auth",
48
+ )
49
+ })
50
+
51
+ it("does not repeat a stack schema the project has chosen to manage", () => {
52
+ expect(apiSchemas(project({ pg_schema: "supatype" }))).toEqual([
53
+ "supatype",
54
+ "graphql_public",
55
+ "auth",
56
+ ])
57
+ })
58
+
59
+ it("lets an explicit list replace the whole thing, stack schemas included", () => {
60
+ // The point of making the entire list configurable: dropping `supatype` is a supported way to
61
+ // stop exposing Studio's views over REST.
62
+ expect(apiSchemas(project({ pg_schema: "app", api_schemas: ["app"] }))).toEqual(["app"])
63
+ })
64
+
65
+ it("preserves the order given, since order selects the default profile", () => {
66
+ expect(apiSchemaList(project({ api_schemas: ["reporting", "app"] }))).toBe("reporting, app")
67
+ })
68
+
69
+ it("falls back to the derived list when api_schemas is present but empty", () => {
70
+ // An empty PGRST_DB_SCHEMA makes PostgREST refuse to start; a truncated config should not be
71
+ // the reason the API is down.
72
+ expect(apiSchemaList(project({ pg_schema: "app", api_schemas: [] }))).toBe(
73
+ "app, supatype, graphql_public, auth",
74
+ )
75
+ })
76
+
77
+ it("drops blank and duplicate entries", () => {
78
+ expect(apiSchemas(project({ api_schemas: ["app", " ", "app", " reporting "] }))).toEqual([
79
+ "app",
80
+ "reporting",
81
+ ])
82
+ })
83
+ })
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { archiveNameCandidates } from "../src/binary-cache.js"
4
+
5
+ /**
6
+ * `supatype-postgres` published the Intel macOS archive as `darwin-x86_64` while the CLI asks for
7
+ * `darwin-amd64` — the spelling it derives from `process.arch === "x64"` on every platform. So
8
+ * `supatype dev` on an Intel Mac failed with "Checksum not found", and had done for as long as both
9
+ * sides existed: CI could not run Docker on a macOS runner, so the path was never exercised.
10
+ *
11
+ * Accepting both names means releases already on the CDN work untouched, rather than needing a fresh
12
+ * one cut purely to rename a file.
13
+ */
14
+ describe("archiveNameCandidates", () => {
15
+ it("asks for the canonical amd64 name first on Intel macOS", () => {
16
+ const names = archiveNameCandidates("postgres", "17.2", { os: "darwin", arch: "amd64" })
17
+ expect(names[0]).toBe("supatype-pg-17-darwin-amd64.tar.gz")
18
+ })
19
+
20
+ it("falls back to the legacy x86_64 spelling", () => {
21
+ const names = archiveNameCandidates("postgres", "17.2", { os: "darwin", arch: "amd64" })
22
+ expect(names).toContain("supatype-pg-17-darwin-x86_64.tar.gz")
23
+ })
24
+
25
+ it("offers one name everywhere else, since only darwin ever disagreed", () => {
26
+ // linux-amd64 and windows-amd64 always matched, so widening them would invite a wrong archive to
27
+ // be accepted for a platform that never had a second name.
28
+ expect(archiveNameCandidates("postgres", "17.2", { os: "linux", arch: "amd64" })).toEqual([
29
+ "supatype-pg-17-linux-amd64.tar.gz",
30
+ ])
31
+ expect(archiveNameCandidates("postgres", "17.2", { os: "darwin", arch: "arm64" })).toEqual([
32
+ "supatype-pg-17-darwin-arm64.tar.gz",
33
+ ])
34
+ })
35
+
36
+ it("does not widen other components", () => {
37
+ // Only the postgres archives were ever published under two names.
38
+ expect(archiveNameCandidates("engine", "0.4.2", { os: "darwin", arch: "amd64" })).toEqual([
39
+ "supatype-engine-darwin-amd64",
40
+ ])
41
+ })
42
+ })
@@ -5,18 +5,58 @@ describe("generateClientAugmentation", () => {
5
5
  it("emits deterministic output independent of model order", () => {
6
6
  const astA = {
7
7
  models: [
8
- { tableName: "post", fields: { title: { kind: "text", required: true } } },
9
- { tableName: "comment", fields: { body: { kind: "text", required: true } } },
8
+ {
9
+ name: "Post",
10
+ fields: { title: { kind: "text", required: true } },
11
+ annotations: { db: { tableName: "post", indexes: [] } },
12
+ },
13
+ {
14
+ name: "Comment",
15
+ fields: { body: { kind: "text", required: true } },
16
+ annotations: { db: { tableName: "comment", indexes: [] } },
17
+ },
10
18
  ],
11
19
  }
12
20
  const astB = {
13
21
  models: [
14
- { tableName: "comment", fields: { body: { kind: "text", required: true } } },
15
- { tableName: "post", fields: { title: { kind: "text", required: true } } },
22
+ {
23
+ name: "Comment",
24
+ fields: { body: { kind: "text", required: true } },
25
+ annotations: { db: { tableName: "comment", indexes: [] } },
26
+ },
27
+ {
28
+ name: "Post",
29
+ fields: { title: { kind: "text", required: true } },
30
+ annotations: { db: { tableName: "post", indexes: [] } },
31
+ },
16
32
  ],
17
33
  }
18
34
 
19
35
  expect(generateClientAugmentation(astA)).toEqual(generateClientAugmentation(astB))
36
+ expect(generateClientAugmentation(astA)).toContain("post:")
37
+ expect(generateClientAugmentation(astA)).toContain("comment:")
38
+ })
39
+
40
+ it("resolves tableName from AST v2 annotations.db.tableName", () => {
41
+ const ast = {
42
+ models: [
43
+ {
44
+ name: "Profile",
45
+ fields: { id: { kind: "uuid", required: true } },
46
+ annotations: { db: { tableName: "profile", indexes: [] } },
47
+ },
48
+ ],
49
+ }
50
+ const out = generateClientAugmentation(ast)
51
+ expect(out).toContain("profile:")
52
+ expect(out).not.toContain("undefined:")
53
+ })
54
+
55
+ it("falls back to snake_case model name when tableName is missing", () => {
56
+ const ast = {
57
+ models: [{ name: "Profile", fields: { id: { kind: "uuid", required: true } } }],
58
+ }
59
+ expect(generateClientAugmentation(ast)).toContain("profile:")
20
60
  })
21
61
 
22
62
  it("marks Insert fields optional when serverGenerated or default is set", () => {
@@ -56,4 +96,32 @@ describe("generateClientAugmentation", () => {
56
96
  expect(out).toContain('import("@supatype/types/lexical").SerializedEditorState')
57
97
  expect(out).not.toMatch(/body: Record<string, unknown>/)
58
98
  })
99
+
100
+ it("uses a field's declared tsType instead of collapsing JSONB to an opaque object", () => {
101
+ // `Currency<"USD">` and `Code<"sql">` are stored as JSONB because each carries two values. If
102
+ // the generated row typed them as `Record<string, unknown>`, a caller reading `price.amount`
103
+ // would get no help from the schema that already knows the shape.
104
+ const ast = {
105
+ models: [
106
+ {
107
+ name: "Snippet",
108
+ annotations: { db: { tableName: "snippet", indexes: [] } },
109
+ fields: {
110
+ price: {
111
+ kind: "json",
112
+ pgType: "JSONB",
113
+ required: true,
114
+ tsType: '{ amount: string; code: "USD" }',
115
+ },
116
+ notes: { kind: "json", pgType: "JSONB", required: false },
117
+ },
118
+ },
119
+ ],
120
+ }
121
+
122
+ const out = generateClientAugmentation(ast)
123
+ expect(out).toContain('price: { amount: string; code: "USD" }')
124
+ // A field with no declared shape still falls back, and optional fields still admit null.
125
+ expect(out).toContain("notes: Record<string, unknown> | null")
126
+ })
59
127
  })
@@ -0,0 +1,206 @@
1
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { tmpdir } from "node:os"
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
5
+
6
+ let fakeHome = ""
7
+
8
+ vi.mock("node:os", async (importOriginal) => {
9
+ const actual = await importOriginal<typeof import("node:os")>()
10
+ return {
11
+ ...actual,
12
+ homedir: () => (fakeHome.length > 0 ? fakeHome : actual.homedir()),
13
+ }
14
+ })
15
+
16
+ import {
17
+ cleanCachedBinaries,
18
+ findSupatypeProjectDirs,
19
+ pinnedVersionsFromConfigs,
20
+ } from "../src/cache-pins.js"
21
+ import { cachePath } from "../src/binary-cache.js"
22
+ import type { SupatypeProjectConfig } from "../src/project-config.js"
23
+
24
+ let counter = 0
25
+ let tmpDir: string
26
+
27
+ function minimalConfig(name: string, versions?: Partial<Record<string, string>>): SupatypeProjectConfig {
28
+ return {
29
+ project: { name },
30
+ database: { provider: "docker" },
31
+ server: { mode: "dev" },
32
+ app: { mode: "none" },
33
+ ...(versions !== undefined ? { versions } : {}),
34
+ }
35
+ }
36
+
37
+ function writeProjectConfig(projectDir: string, config: SupatypeProjectConfig): void {
38
+ mkdirSync(projectDir, { recursive: true })
39
+ writeFileSync(
40
+ join(projectDir, "supatype.config.ts"),
41
+ `export default ${JSON.stringify(config)}
42
+ `,
43
+ )
44
+ }
45
+
46
+ function seedCache(component: "engine" | "server" | "postgres" | "deno" | "realtime", version: string, bytes = 8): string {
47
+ const dir = cachePath(component, version)
48
+ mkdirSync(dir, { recursive: true })
49
+ writeFileSync(join(dir, "artifact.bin"), "x".repeat(bytes))
50
+ return dir
51
+ }
52
+
53
+ beforeEach(() => {
54
+ tmpDir = join(tmpdir(), `supatype-cache-clean-${Date.now()}-${++counter}`)
55
+ fakeHome = join(tmpDir, "home")
56
+ mkdirSync(join(fakeHome, ".supatype", "cache"), { recursive: true })
57
+ })
58
+
59
+ afterEach(() => {
60
+ fakeHome = ""
61
+ rmSync(tmpDir, { recursive: true, force: true })
62
+ })
63
+
64
+ describe("pinnedVersionsFromConfigs", () => {
65
+ it("collects semver pins and ignores local", () => {
66
+ const { protectedVersions, sources } = pinnedVersionsFromConfigs([
67
+ {
68
+ projectDir: "/proj/a",
69
+ config: minimalConfig("alpha", { engine: "0.4.2", server: "local" }),
70
+ },
71
+ {
72
+ projectDir: "/proj/b",
73
+ config: minimalConfig("beta", { engine: "0.4.1", postgres: "17.2" }),
74
+ },
75
+ ])
76
+
77
+ expect([...protectedVersions.get("engine")!].sort()).toEqual(["0.4.1", "0.4.2"])
78
+ expect(protectedVersions.get("postgres")).toEqual(new Set(["17.2"]))
79
+ expect(protectedVersions.has("server")).toBe(false)
80
+ expect(sources).toHaveLength(3)
81
+ })
82
+
83
+ it("does not protect anything when versions block is absent", () => {
84
+ const { protectedVersions } = pinnedVersionsFromConfigs([
85
+ { projectDir: "/proj/x", config: minimalConfig("bare") },
86
+ ])
87
+ expect(protectedVersions.size).toBe(0)
88
+ })
89
+ })
90
+
91
+ describe("cleanCachedBinaries", () => {
92
+ it("removes all versions when nothing is pinned", () => {
93
+ const v1 = `c13-a-${counter}`
94
+ const v2 = `c13-b-${counter}`
95
+ const v3 = `c13-c-${counter}`
96
+ seedCache("engine", v1)
97
+ seedCache("engine", v2)
98
+ seedCache("engine", v3)
99
+
100
+ const result = cleanCachedBinaries({
101
+ components: ["engine"],
102
+ projectDirs: [],
103
+ })
104
+
105
+ expect(result.removed.map((e) => e.version).sort()).toEqual([v1, v2, v3].sort())
106
+ expect(result.skipped).toHaveLength(0)
107
+ for (const v of [v1, v2, v3]) {
108
+ expect(existsSync(cachePath("engine", v))).toBe(false)
109
+ }
110
+ })
111
+
112
+ it("keeps pinned versions and removes the rest", () => {
113
+ const pinned = `c13-pin-${counter}`
114
+ const stale = `c13-stale-${counter}`
115
+ seedCache("engine", pinned)
116
+ seedCache("engine", stale)
117
+ seedCache("server", "c13-srv-old")
118
+ seedCache("server", "c13-srv-pin")
119
+
120
+ const projectDir = join(tmpDir, "my-app")
121
+ writeProjectConfig(
122
+ projectDir,
123
+ minimalConfig("my-app", { engine: pinned, server: "c13-srv-pin" }),
124
+ )
125
+
126
+ const result = cleanCachedBinaries({
127
+ components: ["engine", "server"],
128
+ projectDirs: [projectDir],
129
+ })
130
+
131
+ expect(result.removed.map((e) => `${e.component}@${e.version}`).sort()).toEqual([
132
+ `engine@${stale}`,
133
+ "server@c13-srv-old",
134
+ ])
135
+ expect(result.skipped.map((e) => `${e.component}@${e.version}`).sort()).toEqual([
136
+ `engine@${pinned}`,
137
+ "server@c13-srv-pin",
138
+ ])
139
+ expect(existsSync(cachePath("engine", pinned))).toBe(true)
140
+ expect(existsSync(cachePath("engine", stale))).toBe(false)
141
+ })
142
+
143
+ it("--force removes pinned versions", () => {
144
+ const pinned = `c13-force-${counter}`
145
+ seedCache("engine", pinned)
146
+
147
+ const projectDir = join(tmpDir, "forced")
148
+ writeProjectConfig(projectDir, minimalConfig("forced", { engine: pinned }))
149
+
150
+ const result = cleanCachedBinaries({
151
+ components: ["engine"],
152
+ force: true,
153
+ projectDirs: [projectDir],
154
+ })
155
+
156
+ expect(result.removed).toHaveLength(1)
157
+ expect(result.skipped).toHaveLength(0)
158
+ expect(existsSync(cachePath("engine", pinned))).toBe(false)
159
+ })
160
+
161
+ it("explicit version removes even when pinned (with warning)", () => {
162
+ const pinned = `c13-explicit-${counter}`
163
+ seedCache("engine", pinned)
164
+
165
+ const projectDir = join(tmpDir, "explicit")
166
+ writeProjectConfig(projectDir, minimalConfig("explicit", { engine: pinned }))
167
+
168
+ const result = cleanCachedBinaries({
169
+ components: ["engine"],
170
+ version: pinned,
171
+ projectDirs: [projectDir],
172
+ })
173
+
174
+ expect(result.removed).toHaveLength(1)
175
+ expect(result.warned.some((w) => w.includes("pinned"))).toBe(true)
176
+ expect(existsSync(cachePath("engine", pinned))).toBe(false)
177
+ })
178
+
179
+ it("dry-run does not delete files", () => {
180
+ const version = `c13-dry-${counter}`
181
+ seedCache("engine", version)
182
+
183
+ const result = cleanCachedBinaries({
184
+ components: ["engine"],
185
+ dryRun: true,
186
+ projectDirs: [],
187
+ })
188
+
189
+ expect(result.removed).toHaveLength(1)
190
+ expect(existsSync(cachePath("engine", version))).toBe(true)
191
+ })
192
+ })
193
+
194
+ describe("findSupatypeProjectDirs", () => {
195
+ it("finds nested projects up to max depth", () => {
196
+ const root = join(tmpDir, "mono")
197
+ const nested = join(root, "apps", "blog")
198
+ mkdirSync(nested, { recursive: true })
199
+ writeFileSync(join(nested, "supatype.config.ts"), "export default {}")
200
+ mkdirSync(join(root, "node_modules", "fake"), { recursive: true })
201
+ writeFileSync(join(root, "node_modules", "fake", "supatype.config.ts"), "export default {}")
202
+
203
+ const found = findSupatypeProjectDirs(root, 4)
204
+ expect(found).toEqual([nested])
205
+ })
206
+ })
@@ -48,6 +48,9 @@ describe("CLI binary (requires built dist/)", () => {
48
48
  "app",
49
49
  "self-host",
50
50
  "self-update",
51
+ "login",
52
+ "logout",
53
+ "whoami",
51
54
  ]
52
55
  for (const cmd of commands) {
53
56
  expect(stdout, `Expected '${cmd}' in --help output`).toContain(cmd)
@@ -0,0 +1,66 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest"
2
+ import {
3
+ detectCliInstallMethod,
4
+ looksLikeHomebrewCLI,
5
+ looksLikeNpmOrWorkspaceCLI,
6
+ } from "../src/cli-install-method.js"
7
+
8
+ const originalArgv = process.argv
9
+
10
+ afterEach(() => {
11
+ process.argv = originalArgv
12
+ vi.unstubAllEnvs()
13
+ })
14
+
15
+ describe("looksLikeNpmOrWorkspaceCLI", () => {
16
+ it("detects node_modules install path", () => {
17
+ process.argv = ["node", "/usr/lib/node_modules/@supatype/cli/bin/supatype.js"]
18
+ expect(looksLikeNpmOrWorkspaceCLI()).toBe(true)
19
+ })
20
+
21
+ it("detects workspace dist/cli path", () => {
22
+ process.argv = ["node", "/repo/packages/cli/dist/cli.js"]
23
+ expect(looksLikeNpmOrWorkspaceCLI()).toBe(true)
24
+ })
25
+ })
26
+
27
+ describe("looksLikeHomebrewCLI", () => {
28
+ it("detects macOS Cellar layout", () => {
29
+ process.argv = [
30
+ "node",
31
+ "/opt/homebrew/Cellar/supatype/0.1.5/bin/supatype",
32
+ ]
33
+ expect(looksLikeHomebrewCLI()).toBe(true)
34
+ })
35
+
36
+ it("detects Linuxbrew via HOMEBREW_CELLAR", () => {
37
+ vi.stubEnv("HOMEBREW_CELLAR", "/home/linuxbrew/.linuxbrew/Cellar")
38
+ process.argv = [
39
+ "node",
40
+ "/home/linuxbrew/.linuxbrew/Cellar/supatype/0.1.5/bin/supatype",
41
+ ]
42
+ expect(looksLikeHomebrewCLI()).toBe(true)
43
+ })
44
+
45
+ it("does not flag standalone ~/.supatype/bin", () => {
46
+ process.argv = ["node", "/Users/me/.supatype/bin/supatype"]
47
+ expect(looksLikeHomebrewCLI()).toBe(false)
48
+ })
49
+ })
50
+
51
+ describe("detectCliInstallMethod", () => {
52
+ it("prefers npm over homebrew when both match", () => {
53
+ process.argv = ["node", "/repo/packages/cli/dist/cli.js"]
54
+ expect(detectCliInstallMethod()).toBe("npm")
55
+ })
56
+
57
+ it("returns standalone for curl|sh install path", () => {
58
+ process.argv = ["node", "/Users/me/.supatype/bin/supatype"]
59
+ expect(detectCliInstallMethod()).toBe("standalone")
60
+ })
61
+
62
+ it("returns homebrew for Cellar binary", () => {
63
+ process.argv = ["node", "/opt/homebrew/Cellar/supatype/0.1.5/bin/supatype"]
64
+ expect(detectCliInstallMethod()).toBe("homebrew")
65
+ })
66
+ })