@supatype/cli 0.1.6 → 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 (243) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +144 -94
  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 +18 -4
  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 +121 -19
  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/link-helpers.d.ts.map +1 -1
  72. package/dist/commands/link-helpers.js +8 -2
  73. package/dist/commands/link-helpers.js.map +1 -1
  74. package/dist/commands/login.d.ts +7 -0
  75. package/dist/commands/login.d.ts.map +1 -0
  76. package/dist/commands/login.js +110 -0
  77. package/dist/commands/login.js.map +1 -0
  78. package/dist/commands/push.d.ts.map +1 -1
  79. package/dist/commands/push.js +110 -7
  80. package/dist/commands/push.js.map +1 -1
  81. package/dist/commands/self-update.d.ts +7 -0
  82. package/dist/commands/self-update.d.ts.map +1 -1
  83. package/dist/commands/self-update.js +18 -11
  84. package/dist/commands/self-update.js.map +1 -1
  85. package/dist/config.d.ts.map +1 -1
  86. package/dist/config.js +18 -3
  87. package/dist/config.js.map +1 -1
  88. package/dist/db-preflight.d.ts +78 -0
  89. package/dist/db-preflight.d.ts.map +1 -0
  90. package/dist/db-preflight.js +452 -0
  91. package/dist/db-preflight.js.map +1 -0
  92. package/dist/dev-compose.d.ts +1 -1
  93. package/dist/dev-compose.d.ts.map +1 -1
  94. package/dist/dev-compose.js +127 -72
  95. package/dist/dev-compose.js.map +1 -1
  96. package/dist/env-file.d.ts +40 -2
  97. package/dist/env-file.d.ts.map +1 -1
  98. package/dist/env-file.js +132 -11
  99. package/dist/env-file.js.map +1 -1
  100. package/dist/field-masking-tier.d.ts +50 -0
  101. package/dist/field-masking-tier.d.ts.map +1 -0
  102. package/dist/field-masking-tier.js +91 -0
  103. package/dist/field-masking-tier.js.map +1 -0
  104. package/dist/functions-deno-types.d.ts +24 -0
  105. package/dist/functions-deno-types.d.ts.map +1 -0
  106. package/dist/functions-deno-types.js +155 -0
  107. package/dist/functions-deno-types.js.map +1 -0
  108. package/dist/hook-upload.d.ts +30 -0
  109. package/dist/hook-upload.d.ts.map +1 -0
  110. package/dist/hook-upload.js +86 -0
  111. package/dist/hook-upload.js.map +1 -0
  112. package/dist/hooks-generator.d.ts +8 -0
  113. package/dist/hooks-generator.d.ts.map +1 -0
  114. package/dist/hooks-generator.js +332 -0
  115. package/dist/hooks-generator.js.map +1 -0
  116. package/dist/link.d.ts +4 -0
  117. package/dist/link.d.ts.map +1 -1
  118. package/dist/link.js +2 -0
  119. package/dist/link.js.map +1 -1
  120. package/dist/local-secrets.d.ts +71 -0
  121. package/dist/local-secrets.d.ts.map +1 -0
  122. package/dist/local-secrets.js +100 -0
  123. package/dist/local-secrets.js.map +1 -0
  124. package/dist/model-hooks.d.ts +73 -0
  125. package/dist/model-hooks.d.ts.map +1 -0
  126. package/dist/model-hooks.js +211 -0
  127. package/dist/model-hooks.js.map +1 -0
  128. package/dist/postgres-ctl.d.ts +11 -0
  129. package/dist/postgres-ctl.d.ts.map +1 -1
  130. package/dist/postgres-ctl.js +25 -2
  131. package/dist/postgres-ctl.js.map +1 -1
  132. package/dist/project-config.d.ts +121 -1
  133. package/dist/project-config.d.ts.map +1 -1
  134. package/dist/project-config.js +150 -1
  135. package/dist/project-config.js.map +1 -1
  136. package/dist/resolve-target.d.ts +4 -0
  137. package/dist/resolve-target.d.ts.map +1 -1
  138. package/dist/resolve-target.js +69 -73
  139. package/dist/resolve-target.js.map +1 -1
  140. package/dist/schema-ast-v2.d.ts +2 -1
  141. package/dist/schema-ast-v2.d.ts.map +1 -1
  142. package/dist/schema-ast-v2.js +4 -2
  143. package/dist/schema-ast-v2.js.map +1 -1
  144. package/dist/schema-push-lock.d.ts.map +1 -1
  145. package/dist/schema-push-lock.js +43 -16
  146. package/dist/schema-push-lock.js.map +1 -1
  147. package/dist/self-host-compose.d.ts +24 -0
  148. package/dist/self-host-compose.d.ts.map +1 -1
  149. package/dist/self-host-compose.js +301 -69
  150. package/dist/self-host-compose.js.map +1 -1
  151. package/dist/service-role-check.d.ts +21 -0
  152. package/dist/service-role-check.d.ts.map +1 -0
  153. package/dist/service-role-check.js +85 -0
  154. package/dist/service-role-check.js.map +1 -0
  155. package/dist/target-client.d.ts +17 -0
  156. package/dist/target-client.d.ts.map +1 -1
  157. package/dist/target-client.js +69 -17
  158. package/dist/target-client.js.map +1 -1
  159. package/dist/type-extractor.d.ts.map +1 -1
  160. package/dist/type-extractor.js +736 -32
  161. package/dist/type-extractor.js.map +1 -1
  162. package/dist/ui/runtime/command-chrome.js +1 -1
  163. package/dist/ui/runtime/command-chrome.js.map +1 -1
  164. package/dist/ui/runtime/stdin-after-ink.d.ts.map +1 -1
  165. package/dist/ui/runtime/stdin-after-ink.js +3 -1
  166. package/dist/ui/runtime/stdin-after-ink.js.map +1 -1
  167. package/package.json +4 -1
  168. package/scripts/generate-homebrew-formula.mjs +121 -0
  169. package/src/augmentation-generator.ts +18 -5
  170. package/src/binary-cache.ts +47 -15
  171. package/src/cache-clean.ts +81 -0
  172. package/src/cache-pins.ts +243 -0
  173. package/src/cli-install-method.ts +67 -0
  174. package/src/cli.ts +6 -0
  175. package/src/cloud-credentials.ts +183 -0
  176. package/src/commands/admin.ts +140 -25
  177. package/src/commands/cache.ts +90 -75
  178. package/src/commands/cloud.ts +32 -8
  179. package/src/commands/db.ts +162 -1
  180. package/src/commands/dev.ts +57 -8
  181. package/src/commands/doctor.ts +69 -3
  182. package/src/commands/functions.ts +42 -5
  183. package/src/commands/generate.ts +5 -1
  184. package/src/commands/hooks.ts +138 -0
  185. package/src/commands/init.ts +42 -8
  186. package/src/commands/internal.ts +23 -0
  187. package/src/commands/link-helpers.ts +9 -2
  188. package/src/commands/login.ts +127 -0
  189. package/src/commands/push.ts +124 -7
  190. package/src/commands/self-update.ts +85 -13
  191. package/src/config.ts +22 -3
  192. package/src/db-preflight.ts +575 -0
  193. package/src/dev-compose.ts +142 -73
  194. package/src/env-file.ts +157 -11
  195. package/src/field-masking-tier.ts +107 -0
  196. package/src/functions-deno-types.ts +188 -0
  197. package/src/hook-upload.ts +107 -0
  198. package/src/hooks-generator.ts +352 -0
  199. package/src/link.ts +7 -0
  200. package/src/local-secrets.ts +104 -0
  201. package/src/model-hooks.ts +260 -0
  202. package/src/postgres-ctl.ts +29 -2
  203. package/src/project-config.ts +236 -2
  204. package/src/resolve-target.ts +113 -68
  205. package/src/schema-ast-v2.ts +5 -2
  206. package/src/schema-push-lock.ts +56 -17
  207. package/src/self-host-compose.ts +344 -68
  208. package/src/service-role-check.ts +111 -0
  209. package/src/target-client.ts +105 -18
  210. package/src/type-extractor.ts +883 -28
  211. package/src/ui/runtime/command-chrome.ts +1 -1
  212. package/src/ui/runtime/stdin-after-ink.ts +3 -1
  213. package/tests/access-composition.test.ts +667 -0
  214. package/tests/admin-studio-membership.test.ts +121 -0
  215. package/tests/api-schemas.test.ts +83 -0
  216. package/tests/archive-name-alias.test.ts +42 -0
  217. package/tests/augmentation-generator.test.ts +28 -0
  218. package/tests/cache-clean.test.ts +206 -0
  219. package/tests/cli-help.test.ts +3 -0
  220. package/tests/cli-install-method.test.ts +66 -0
  221. package/tests/command-chrome-coverage.test.ts +31 -26
  222. package/tests/command-chrome-wrap.test.ts +1 -0
  223. package/tests/db-preflight.test.ts +72 -0
  224. package/tests/doctor-render.test.ts +62 -0
  225. package/tests/doctor-service-role.test.ts +55 -0
  226. package/tests/env-file.test.ts +121 -0
  227. package/tests/external-database-compose.test.ts +184 -0
  228. package/tests/external-database-config.test.ts +154 -0
  229. package/tests/field-masking-tier.test.ts +184 -0
  230. package/tests/functions-deno-types.test.ts +197 -0
  231. package/tests/homebrew-formula.test.ts +58 -0
  232. package/tests/hook-upload.test.ts +125 -0
  233. package/tests/hooks-generator.test.ts +212 -0
  234. package/tests/init.test.ts +3 -0
  235. package/tests/local-secrets.test.ts +92 -0
  236. package/tests/model-hooks.test.ts +322 -0
  237. package/tests/runtime-contract.test.ts +113 -1
  238. package/tests/service-role-check.test.ts +109 -0
  239. package/tests/service-role-routes.test.ts +51 -0
  240. package/tests/target-client-refresh.test.ts +94 -0
  241. package/tests/type-extractor.test.ts +187 -0
  242. package/tsconfig.tsbuildinfo +1 -1
  243. package/tests/engine-distribution.test.ts +0 -418
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Cloud session credentials (access + refresh) for CLI ↔ control plane.
3
+ * Self-host links keep using long-lived SERVICE_ROLE_KEY and ignore this store.
4
+ */
5
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
6
+ import { homedir } from "node:os"
7
+ import { join } from "node:path"
8
+ import {
9
+ loadProjectLink,
10
+ saveProjectLink,
11
+ type ProjectLink,
12
+ } from "./link.js"
13
+
14
+ export const CREDENTIALS_VERSION = 1 as const
15
+
16
+ export interface CloudCredentials {
17
+ version: typeof CREDENTIALS_VERSION
18
+ apiUrl: string
19
+ accessToken: string
20
+ refreshToken?: string
21
+ email?: string
22
+ updatedAt: string
23
+ }
24
+
25
+ export function credentialsPath(): string {
26
+ return join(homedir(), ".supatype", "credentials.json")
27
+ }
28
+
29
+ export function loadCloudCredentials(): CloudCredentials | null {
30
+ const path = credentialsPath()
31
+ if (!existsSync(path)) return null
32
+ try {
33
+ const raw = JSON.parse(readFileSync(path, "utf8")) as CloudCredentials
34
+ if (!raw.accessToken || !raw.apiUrl) return null
35
+ return raw
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ export function saveCloudCredentials(creds: CloudCredentials): void {
42
+ const dir = join(homedir(), ".supatype")
43
+ mkdirSync(dir, { recursive: true })
44
+ writeFileSync(credentialsPath(), `${JSON.stringify(creds, null, 2)}\n`, "utf8")
45
+ }
46
+
47
+ export function clearCloudCredentials(): void {
48
+ const path = credentialsPath()
49
+ if (!existsSync(path)) return
50
+ try {
51
+ unlinkSync(path)
52
+ } catch {
53
+ /* ignore */
54
+ }
55
+ }
56
+
57
+ /** Prefer link tokens; fall back to ~/.supatype/credentials.json for cloud. */
58
+ export function resolveCloudAccessToken(link: ProjectLink): string | undefined {
59
+ if (link.token) return link.token
60
+ if (link.kind !== "cloud") return undefined
61
+ const creds = loadCloudCredentials()
62
+ if (!creds) return undefined
63
+ const linkApi = (link.cloudApiUrl ?? "").replace(/\/$/, "")
64
+ const credApi = creds.apiUrl.replace(/\/$/, "")
65
+ if (linkApi && credApi && linkApi !== credApi) return undefined
66
+ return creds.accessToken
67
+ }
68
+
69
+ export function resolveCloudRefreshToken(link: ProjectLink): string | undefined {
70
+ if (link.refreshToken) return link.refreshToken
71
+ if (link.kind !== "cloud") return undefined
72
+ const creds = loadCloudCredentials()
73
+ if (!creds?.refreshToken) return undefined
74
+ const linkApi = (link.cloudApiUrl ?? "").replace(/\/$/, "")
75
+ const credApi = creds.apiUrl.replace(/\/$/, "")
76
+ if (linkApi && credApi && linkApi !== credApi) return undefined
77
+ return creds.refreshToken
78
+ }
79
+
80
+ /** Write tokens to global credentials and the current project's cloud link (if any). */
81
+ export function persistCloudSession(
82
+ cwd: string,
83
+ params: {
84
+ apiUrl: string
85
+ accessToken: string
86
+ refreshToken?: string
87
+ email?: string
88
+ },
89
+ ): void {
90
+ const apiUrl = params.apiUrl.replace(/\/$/, "")
91
+ const existing = loadCloudCredentials()
92
+ saveCloudCredentials({
93
+ version: CREDENTIALS_VERSION,
94
+ apiUrl,
95
+ accessToken: params.accessToken,
96
+ updatedAt: new Date().toISOString(),
97
+ ...(params.refreshToken !== undefined
98
+ ? { refreshToken: params.refreshToken }
99
+ : existing?.refreshToken !== undefined
100
+ ? { refreshToken: existing.refreshToken }
101
+ : {}),
102
+ ...(params.email !== undefined
103
+ ? { email: params.email }
104
+ : existing?.email !== undefined
105
+ ? { email: existing.email }
106
+ : {}),
107
+ })
108
+
109
+ const link = loadProjectLink(cwd)
110
+ if (!link || link.kind !== "cloud") return
111
+ link.token = params.accessToken
112
+ if (params.refreshToken !== undefined) {
113
+ link.refreshToken = params.refreshToken
114
+ }
115
+ saveProjectLink(cwd, link)
116
+ }
117
+
118
+ export interface CloudLoginResult {
119
+ accessToken: string
120
+ refreshToken?: string
121
+ expiresIn?: number
122
+ email?: string
123
+ }
124
+
125
+ /** Password login against control-plane `/auth/login` (not `/api/v1`). */
126
+ export async function cloudPasswordLogin(
127
+ apiUrl: string,
128
+ email: string,
129
+ password: string,
130
+ ): Promise<CloudLoginResult> {
131
+ const base = apiUrl.replace(/\/$/, "")
132
+ const res = await fetch(`${base}/auth/login`, {
133
+ method: "POST",
134
+ headers: { "Content-Type": "application/json" },
135
+ body: JSON.stringify({ email: email.toLowerCase(), password }),
136
+ })
137
+ const json = (await res.json().catch(() => ({}))) as {
138
+ message?: string
139
+ error?: string
140
+ access_token?: string
141
+ refresh_token?: string
142
+ expires_in?: number
143
+ user?: { email?: string }
144
+ }
145
+ if (!res.ok || !json.access_token) {
146
+ throw new Error(json.message ?? json.error ?? `Login failed (${res.status})`)
147
+ }
148
+ return {
149
+ accessToken: json.access_token,
150
+ ...(json.refresh_token !== undefined ? { refreshToken: json.refresh_token } : {}),
151
+ ...(json.expires_in !== undefined ? { expiresIn: json.expires_in } : {}),
152
+ ...(json.user?.email !== undefined
153
+ ? { email: json.user.email }
154
+ : { email: email.toLowerCase() }),
155
+ }
156
+ }
157
+
158
+ export async function cloudRefreshSession(
159
+ apiUrl: string,
160
+ refreshToken: string,
161
+ ): Promise<CloudLoginResult> {
162
+ const base = apiUrl.replace(/\/$/, "")
163
+ const res = await fetch(`${base}/auth/refresh`, {
164
+ method: "POST",
165
+ headers: { "Content-Type": "application/json" },
166
+ body: JSON.stringify({ refresh_token: refreshToken }),
167
+ })
168
+ const json = (await res.json().catch(() => ({}))) as {
169
+ message?: string
170
+ error?: string
171
+ access_token?: string
172
+ refresh_token?: string
173
+ expires_in?: number
174
+ }
175
+ if (!res.ok || !json.access_token) {
176
+ throw new Error(json.message ?? json.error ?? `Token refresh failed (${res.status})`)
177
+ }
178
+ return {
179
+ accessToken: json.access_token,
180
+ ...(json.refresh_token !== undefined ? { refreshToken: json.refresh_token } : {}),
181
+ ...(json.expires_in !== undefined ? { expiresIn: json.expires_in } : {}),
182
+ }
183
+ }
@@ -32,6 +32,26 @@ export const ADMIN_PASSWORD_ENV = "SUPATYPE_ADMIN_PASSWORD"
32
32
 
33
33
  const BCRYPT_ROUNDS = 10
34
34
 
35
+ /**
36
+ * Studio roles a membership row may hold, most privileged first.
37
+ *
38
+ * Must match `studioRolePermissions` in supatype-server and
39
+ * `STUDIO_ROLE_PERMISSIONS` in the control plane: capability checks refuse a role
40
+ * they do not recognise, so an unvalidated `--role` here would create a user who
41
+ * is locked out of the very panel they were made for.
42
+ */
43
+ export const STUDIO_ROLES = ["admin", "developer", "editor"] as const
44
+
45
+ function assertStudioRole(role: string): void {
46
+ if ((STUDIO_ROLES as readonly string[]).includes(role)) return
47
+ error(
48
+ `Unknown Studio role "${role}". Choose one of: ${STUDIO_ROLES.join(", ")}.\n` +
49
+ " Studio roles are Supatype's own; your application's roles are separate " +
50
+ "and belong in your access rules.",
51
+ )
52
+ process.exit(1)
53
+ }
54
+
35
55
  /** GoTrue scopes users to the nil instance id in single-tenant/self-host mode. */
36
56
  export const GOTRUE_NIL_INSTANCE_ID = "00000000-0000-0000-0000-000000000000"
37
57
 
@@ -89,7 +109,7 @@ export function registerAdmin(program: Command): void {
89
109
  .description("Create an admin user for the admin panel")
90
110
  .option("--email <email>", "Admin user email address")
91
111
  .option("--password <password>", "Admin user password (prompted if not provided)")
92
- .option("--role <role>", "Admin role to assign", "admin")
112
+ .option("--role <role>", `Studio role to assign (${STUDIO_ROLES.join(" | ")})`, "admin")
93
113
  .option("--connection <url>", "Database connection URL (overrides config)")
94
114
  .action(
95
115
  async (opts: {
@@ -116,6 +136,7 @@ export function registerAdmin(program: Command): void {
116
136
  }
117
137
 
118
138
  const role = opts.role
139
+ assertStudioRole(role)
119
140
 
120
141
  info(`Creating admin user: ${email} (role: ${role})...`)
121
142
 
@@ -154,10 +175,11 @@ export function registerAdmin(program: Command): void {
154
175
  .command("set-role")
155
176
  .description("Change an existing user's admin role")
156
177
  .requiredOption("--email <email>", "User email address")
157
- .requiredOption("--role <role>", "New role to assign")
178
+ .requiredOption("--role <role>", `New Studio role (${STUDIO_ROLES.join(" | ")})`)
158
179
  .option("--connection <url>", "Database connection URL (overrides config)")
159
180
  .action(
160
181
  async (opts: { email: string; role: string; connection?: string }) => {
182
+ assertStudioRole(opts.role)
161
183
  const cwd = process.cwd()
162
184
  const config = loadConfig(cwd)
163
185
  const connection = resolveAdminConnection(cwd, config, opts.connection)
@@ -166,13 +188,23 @@ export function registerAdmin(program: Command): void {
166
188
  const pool = new pg.Pool({ connectionString: connection, max: 2 })
167
189
 
168
190
  try {
191
+ // Studio roles live in `_supatype.studio_members`, not in
192
+ // `app_metadata`: that key is the developer's namespace for their own
193
+ // app roles, and writing Studio access there means assigning an app
194
+ // role could hand out admin UI access by accident.
195
+ await ensureStudioMembersTable((sql, params) => pool.query(sql, params))
169
196
  const result = await pool.query(
170
- `UPDATE auth.users
171
- SET raw_app_meta_data = raw_app_meta_data || $1::jsonb,
172
- updated_at = now()
173
- WHERE email = $2
174
- RETURNING id, email, raw_app_meta_data`,
175
- [JSON.stringify({ role: opts.role }), opts.email.toLowerCase()],
197
+ `WITH target AS (
198
+ SELECT id, email FROM auth.users WHERE email = $2
199
+ ), upsert AS (
200
+ INSERT INTO _supatype.studio_members (user_id, role)
201
+ SELECT id, $1 FROM target
202
+ ON CONFLICT (user_id) DO UPDATE
203
+ SET role = EXCLUDED.role, updated_at = now()
204
+ RETURNING user_id
205
+ )
206
+ SELECT id, email FROM target`,
207
+ [opts.role, opts.email.toLowerCase()],
176
208
  )
177
209
 
178
210
  if (result.rows.length === 0) {
@@ -180,11 +212,7 @@ export function registerAdmin(program: Command): void {
180
212
  process.exit(1)
181
213
  }
182
214
 
183
- const user = result.rows[0] as {
184
- id: string
185
- email: string
186
- raw_app_meta_data: Record<string, unknown>
187
- }
215
+ const user = result.rows[0] as { id: string; email: string }
188
216
 
189
217
  info("Role updated successfully.")
190
218
  plain(` ID: ${user.id}`)
@@ -203,7 +231,7 @@ export function registerAdmin(program: Command): void {
203
231
 
204
232
  adminCmd
205
233
  .command("list-users")
206
- .description("List users with admin roles")
234
+ .description("List users with Studio access")
207
235
  .option("--connection <url>", "Database connection URL (overrides config)")
208
236
  .action(async (opts: { connection?: string }) => {
209
237
  const cwd = process.cwd()
@@ -214,12 +242,17 @@ export function registerAdmin(program: Command): void {
214
242
  const pool = new pg.Pool({ connectionString: connection, max: 2 })
215
243
 
216
244
  try {
245
+ // Studio access is membership, not a claim — list the grant that
246
+ // actually decides admission. LEFT JOIN, because a membership held by a
247
+ // Supatype Cloud account has no row in this project's `auth.users`;
248
+ // hiding those would make `list-users` disagree with who can log in.
217
249
  const result = await pool.query(
218
- `SELECT id, email, raw_app_meta_data->>'role' as role, created_at
219
- FROM auth.users
220
- WHERE raw_app_meta_data->>'role' IS NOT NULL
221
- AND raw_app_meta_data->>'role' != 'authenticated'
222
- ORDER BY created_at ASC`,
250
+ `SELECT COALESCE(m.user_id, m.platform_user_id) AS id,
251
+ COALESCE(u.email, '(cloud account)') AS email,
252
+ m.role, m.created_at
253
+ FROM _supatype.studio_members m
254
+ LEFT JOIN auth.users u ON u.id = m.user_id
255
+ ORDER BY m.created_at ASC`,
223
256
  )
224
257
 
225
258
  if (result.rows.length === 0) {
@@ -318,7 +351,8 @@ export async function ensureFirstAdminUserForProject(
318
351
  await ensureFirstAdminUser(connection, merged)
319
352
  }
320
353
 
321
- async function ensureFirstAdminWithQuery(
354
+ /** Exported for tests: the DB seam both the pool and compose paths share. */
355
+ export async function ensureFirstAdminWithQuery(
322
356
  query: DbQuery,
323
357
  options: EnsureFirstAdminOptions,
324
358
  ): Promise<void> {
@@ -408,7 +442,9 @@ async function resolveAdminCredentials(
408
442
  }
409
443
 
410
444
  export function clearAdminSeedPassword(cwd: string): void {
411
- upsertEnvFile(cwd, {}, [ADMIN_PASSWORD_ENV])
445
+ // Unconditional: this is a one-time seed password being retired, and whoever wrote it wanted it
446
+ // gone once used.
447
+ upsertEnvFile(cwd, {}, { remove: [ADMIN_PASSWORD_ENV] })
412
448
  }
413
449
 
414
450
  export async function hashPasswordForAuth(password: string): Promise<string> {
@@ -442,8 +478,11 @@ async function createAdminUser(
442
478
  }
443
479
 
444
480
  const passwordHash = await hashPasswordForAuth(password)
481
+ // No `role` here: `app_metadata` is the developer's namespace for their own
482
+ // application roles, and Studio access is granted through
483
+ // `_supatype.studio_members` instead. Writing it in both places means an app
484
+ // role assignment can silently confer admin UI access.
445
485
  const appMetadata = JSON.stringify({
446
- role,
447
486
  provider: "email",
448
487
  providers: ["email"],
449
488
  })
@@ -472,6 +511,7 @@ async function createAdminUser(
472
511
  )
473
512
 
474
513
  const user = result.rows[0] as { id: string; email: string }
514
+ await recordStudioMembership(query, user.id, role)
475
515
  if (!opts.quiet) {
476
516
  info("Admin user created successfully.")
477
517
  plain(` ID: ${user.id}`)
@@ -481,6 +521,31 @@ async function createAdminUser(
481
521
  return user
482
522
  }
483
523
 
524
+ /**
525
+ * Record Studio access in `_supatype.studio_members`.
526
+ *
527
+ * Studio capability is deliberately not a JWT claim: `app_metadata` belongs to
528
+ * the developer's own app roles, and letting it grant Studio access means a
529
+ * developer assigning an app role could hand out admin UI access by accident.
530
+ *
531
+ * This is now the *only* grant, so a failure here is fatal to the command — a
532
+ * user created without a membership row cannot reach Studio at all, and
533
+ * reporting success would leave no clue why.
534
+ */
535
+ async function recordStudioMembership(
536
+ query: DbQuery,
537
+ userId: string,
538
+ role: string,
539
+ ): Promise<void> {
540
+ await ensureStudioMembersTable(query)
541
+ await query(
542
+ `INSERT INTO _supatype.studio_members (user_id, role)
543
+ VALUES ($1, $2)
544
+ ON CONFLICT (user_id) DO UPDATE SET role = EXCLUDED.role, updated_at = now()`,
545
+ [userId, role],
546
+ )
547
+ }
548
+
484
549
  async function ensureAuthUsersTable(pool: Pool): Promise<void> {
485
550
  await pool.query(`
486
551
  CREATE SCHEMA IF NOT EXISTS auth;
@@ -521,16 +586,66 @@ async function authUsersTableExists(query: DbQuery): Promise<boolean> {
521
586
  return Boolean(result.rows[0]?.exists)
522
587
  }
523
588
 
589
+ /**
590
+ * Whether anyone can already reach Studio.
591
+ *
592
+ * Counts membership rows, not `app_metadata` claims: the claim no longer grants
593
+ * anything, so counting it would report an admin exists while nobody can log in.
594
+ * A missing table means the project has never been pushed — no admins.
595
+ */
524
596
  async function hasAdminUsers(query: DbQuery): Promise<boolean> {
597
+ // Postgres resolves relations at parse time, so the table has to be checked
598
+ // separately rather than guarded inside the count query. Absent means the
599
+ // project has never been pushed — no admins, and `createAdminUser` will
600
+ // create the table itself.
601
+ if (!(await studioMembersTableExists(query))) return false
602
+
603
+ // Inner JOIN on purpose, unlike `list-users`: a membership held by a cloud
604
+ // account cannot log in to a self-hosted GoTrue, so a project exported from
605
+ // cloud with only those grants genuinely still needs a first admin.
525
606
  const adminCount = await query(
526
- `SELECT COUNT(*)::int as count FROM auth.users
527
- WHERE raw_app_meta_data->>'role' IS NOT NULL
528
- AND raw_app_meta_data->>'role' != 'authenticated'`,
607
+ `SELECT COUNT(*)::int as count
608
+ FROM _supatype.studio_members m
609
+ JOIN auth.users u ON u.id = m.user_id
610
+ WHERE m.role <> 'authenticated'`,
529
611
  )
530
612
  const count = (adminCount.rows[0] as { count: number } | undefined)?.count ?? 0
531
613
  return count > 0
532
614
  }
533
615
 
616
+ /**
617
+ * A project whose engine predates the membership table, or one that has never
618
+ * been pushed, still needs working `admin create-user` / `admin set-role`.
619
+ * Mirrors the engine's own definition (`create_studio_members_table_sql`).
620
+ *
621
+ * A row is held by exactly one identity: `user_id` for a project user (what the
622
+ * CLI grants), or `platform_user_id` for a Supatype Cloud account. The engine
623
+ * migrates older single-identity tables; this only has to create the current
624
+ * shape when there is nothing there at all.
625
+ */
626
+ async function ensureStudioMembersTable(query: DbQuery): Promise<void> {
627
+ await query(`CREATE SCHEMA IF NOT EXISTS _supatype`)
628
+ await query(
629
+ `CREATE TABLE IF NOT EXISTS _supatype.studio_members (
630
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
631
+ user_id UUID UNIQUE,
632
+ platform_user_id UUID UNIQUE,
633
+ role TEXT NOT NULL,
634
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
635
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
636
+ CONSTRAINT studio_members_one_identity
637
+ CHECK (num_nonnulls(user_id, platform_user_id) = 1)
638
+ )`,
639
+ )
640
+ }
641
+
642
+ async function studioMembersTableExists(query: DbQuery): Promise<boolean> {
643
+ const result = await query(
644
+ `SELECT to_regclass('_supatype.studio_members') IS NOT NULL as exists`,
645
+ )
646
+ return Boolean(result.rows[0]?.exists)
647
+ }
648
+
534
649
  /** Postgres password for compose `exec psql` (db is not published to the host). */
535
650
  export function composePostgresPassword(cwd: string): string {
536
651
  return readEnvValue(cwd, "POSTGRES_PASSWORD", "postgres")
@@ -3,8 +3,14 @@
3
3
  */
4
4
 
5
5
  import type { Command } from "commander"
6
- import { existsSync, readdirSync, rmSync, statSync } from "node:fs"
7
- import { join } from "node:path"
6
+ import { existsSync } from "node:fs"
7
+ import {
8
+ BINARY_COMPONENTS,
9
+ formatBytes,
10
+ listCacheEntries,
11
+ resolveCacheProjectDirs,
12
+ } from "../cache-clean.js"
13
+ import { cleanCachedBinaries } from "../cache-pins.js"
8
14
  import { cacheRoot, type Component } from "../binary-cache.js"
9
15
  import {
10
16
  deleteRestCacheEntry,
@@ -12,9 +18,11 @@ import {
12
18
  getRestCacheEntry,
13
19
  listRestCacheEntries,
14
20
  } from "../rest-cache-admin.js"
15
- import { error, info, plain } from "../ui/messages.js"
21
+ import { error, info, plain, warn } from "../ui/messages.js"
16
22
 
17
- const COMPONENTS: Component[] = ["engine", "server", "postgres", "deno"]
23
+ function isComponent(value: string): value is Component {
24
+ return (BINARY_COMPONENTS as readonly string[]).includes(value)
25
+ }
18
26
 
19
27
  export function registerCache(program: Command): void {
20
28
  const cache = program
@@ -24,79 +32,106 @@ export function registerCache(program: Command): void {
24
32
  cache
25
33
  .command("list")
26
34
  .description("List cached component binaries and their sizes")
27
- .action(async () => {
35
+ .option("--project <path>", "Include pins from another project directory", collectPaths, [])
36
+ .action(async (opts: { project: string[] }) => {
37
+ const cwd = process.cwd()
38
+ const projectDirs = resolveCacheProjectDirs(cwd, opts.project)
28
39
  const root = cacheRoot()
40
+
29
41
  if (!existsSync(root)) {
30
42
  info("Cache is empty.")
31
43
  return
32
44
  }
33
45
 
34
- let totalBytes = 0
35
- let found = false
36
-
37
- for (const component of COMPONENTS) {
38
- const compDir = join(root, component)
39
- if (!existsSync(compDir)) continue
40
-
41
- const versions = readdirSync(compDir).filter(
42
- (v) => statSync(join(compDir, v)).isDirectory(),
43
- )
44
- for (const version of versions) {
45
- const vDir = join(compDir, version)
46
- const size = dirSize(vDir)
47
- totalBytes += size
48
- found = true
49
- plain(` ${component}@${version} ${formatBytes(size)}`)
50
- }
51
- }
52
-
53
- if (!found) {
46
+ const entries = listCacheEntries(projectDirs)
47
+ if (entries.length === 0) {
54
48
  info("Cache is empty.")
55
49
  return
56
50
  }
57
51
 
52
+ let totalBytes = 0
53
+ for (const entry of entries) {
54
+ totalBytes += entry.bytes
55
+ const pinLabel = entry.pinned ? " (pinned)" : ""
56
+ plain(` ${entry.component}@${entry.version} ${formatBytes(entry.bytes)}${pinLabel}`)
57
+ }
58
+
58
59
  plain(`\nTotal: ${formatBytes(totalBytes)}`)
59
60
  info(`Cache root: ${root}`)
61
+ if (projectDirs.length > 0) {
62
+ info(`Pin scan: ${projectDirs.length} project(s)`)
63
+ }
60
64
  })
61
65
 
62
66
  cache
63
67
  .command("clean [component] [version]")
64
68
  .description(
65
- "Remove cached binaries. Optionally specify a component and/or version.\n" +
69
+ "Remove cached binaries not pinned by local project configs.\n" +
70
+ "Use --force to remove everything (legacy behavior).\n" +
66
71
  "Examples:\n" +
67
- " supatype cache clean # remove everything\n" +
68
- " supatype cache clean engine # remove all engine versions\n" +
69
- " supatype cache clean engine 0.4.2",
72
+ " supatype cache clean # remove unpinned versions\n" +
73
+ " supatype cache clean engine # remove unpinned engine versions\n" +
74
+ " supatype cache clean engine 0.4.2 # remove one version (warns if pinned)\n" +
75
+ " supatype cache clean --force # remove all cached binaries",
70
76
  )
71
- .action(async (component?: string, version?: string) => {
72
- const root = cacheRoot()
73
- if (!existsSync(root)) {
74
- info("Cache is already empty.")
75
- return
76
- }
77
+ .option("--dry-run", "Show what would be removed without deleting")
78
+ .option("--force", "Remove all matching cache entries, ignoring version pins")
79
+ .option("--project <path>", "Include pins from another project directory", collectPaths, [])
80
+ .action(
81
+ async (
82
+ component?: string,
83
+ version?: string,
84
+ opts?: { dryRun?: boolean; force?: boolean; project?: string[] },
85
+ ) => {
86
+ const cwd = process.cwd()
87
+ const projectDirs = resolveCacheProjectDirs(cwd, opts?.project ?? [])
88
+ const root = cacheRoot()
89
+
90
+ if (!existsSync(root)) {
91
+ info("Cache is already empty.")
92
+ return
93
+ }
77
94
 
78
- const targets = component ? [component as Component] : COMPONENTS
95
+ if (component && !isComponent(component)) {
96
+ error(
97
+ `Unknown component "${component}". Valid: ${BINARY_COMPONENTS.join(", ")}`,
98
+ )
99
+ process.exitCode = 1
100
+ return
101
+ }
79
102
 
80
- for (const comp of targets) {
81
- const compDir = join(root, comp)
82
- if (!existsSync(compDir)) continue
103
+ const components: Component[] =
104
+ component !== undefined ? [component as Component] : [...BINARY_COMPONENTS]
105
+ const result = cleanCachedBinaries({
106
+ components,
107
+ ...(version !== undefined && version !== "" ? { version } : {}),
108
+ ...(opts?.force !== undefined ? { force: opts.force } : {}),
109
+ ...(opts?.dryRun !== undefined ? { dryRun: opts.dryRun } : {}),
110
+ projectDirs,
111
+ })
112
+
113
+ for (const message of result.warned) {
114
+ warn(message)
115
+ }
83
116
 
84
- if (version) {
85
- const vDir = join(compDir, version)
86
- if (!existsSync(vDir)) {
87
- info(`${comp}@${version} not cached.`)
88
- continue
89
- }
90
- rmSync(vDir, { recursive: true, force: true })
91
- plain(` removed ${comp}@${version}`)
92
- } else {
93
- rmSync(compDir, { recursive: true, force: true })
94
- plain(` removed ${comp} (all versions)`)
117
+ for (const entry of result.skipped) {
118
+ plain(` kept ${entry.component}@${entry.version} (${entry.reason})`)
95
119
  }
96
- }
97
120
 
98
- info("Done.")
99
- })
121
+ for (const entry of result.removed) {
122
+ const prefix = opts?.dryRun ? "would remove" : "removed"
123
+ plain(` ${prefix} ${entry.component}@${entry.version}`)
124
+ }
125
+
126
+ if (result.removed.length === 0 && result.skipped.length === 0) {
127
+ info("Nothing to clean.")
128
+ } else if (opts?.dryRun) {
129
+ info(`Dry run — ${result.removed.length} entr${result.removed.length === 1 ? "y" : "ies"} would be removed.`)
130
+ } else {
131
+ info("Done.")
132
+ }
133
+ },
134
+ )
100
135
 
101
136
  const rest = cache
102
137
  .command("rest")
@@ -193,26 +228,6 @@ export function registerCache(program: Command): void {
193
228
  })
194
229
  }
195
230
 
196
- function dirSize(dir: string): number {
197
- let total = 0
198
- try {
199
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
200
- const full = join(dir, entry.name)
201
- if (entry.isDirectory()) {
202
- total += dirSize(full)
203
- } else {
204
- total += statSync(full).size
205
- }
206
- }
207
- } catch {
208
- // Skip unreadable entries.
209
- }
210
- return total
211
- }
212
-
213
- function formatBytes(bytes: number): string {
214
- if (bytes < 1024) return `${bytes} B`
215
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
216
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
217
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
231
+ function collectPaths(value: string, previous: string[]): string[] {
232
+ return [...previous, value]
218
233
  }