@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
@@ -1,10 +1,12 @@
1
1
  import type { Command } from "commander"
2
2
  import { loadConfig, loadSchemaAst } from "../config.js"
3
3
  import { info, plain } from "../ui/messages.js"
4
- import { schemaPathFromProject } from "../project-config.js"
4
+ import { hooksPathFromProject, schemaPathFromProject, serviceRoleRoutes } from "../project-config.js"
5
5
  import { resolveTarget, targetSchemaDoctor, schemaPgSchema } from "../resolve-target.js"
6
6
  import { loadProjectLink } from "../link.js"
7
7
  import { resolveHostEngineDatabaseUrl } from "../dev-compose.js"
8
+ import { hooksReport, type HooksReport } from "../model-hooks.js"
9
+ import { checkServiceRoleRoutes, type ServiceRoleProblems } from "../service-role-check.js"
8
10
 
9
11
  interface DoctorItem {
10
12
  kind: string
@@ -20,12 +22,15 @@ interface DoctorReport {
20
22
  unmanagedDrift: DoctorItem[]
21
23
  }
22
24
 
23
- function printSection(title: string, items: DoctorItem[]): void {
25
+ /** Exported for tests: the label form is easy to get subtly wrong per item kind. */
26
+ export function printSection(title: string, items: DoctorItem[]): void {
24
27
  if (items.length === 0) return
25
28
  plain(`\n${title} (${items.length}):\n`)
26
29
  for (const item of items) {
27
30
  const fields = item.fields.length > 0 ? ` (${item.fields.join(", ")})` : ""
28
- plain(` ${item.table}.${item.name}${fields}`)
31
+ // A table's `name` *is* its table, so the usual `table.name` form renders "widget.widget".
32
+ const label = item.table === item.name ? item.name : `${item.table}.${item.name}`
33
+ plain(` • ${label}${fields}`)
29
34
  plain(` ${item.message}`)
30
35
  }
31
36
  }
@@ -82,6 +87,9 @@ export function registerDoctor(program: Command): void {
82
87
  })) as DoctorReport
83
88
  }
84
89
 
90
+ printHooks(hooksReport(cwd, hooksPathFromProject(config, cwd), ast))
91
+ printServiceRoleGrants(checkServiceRoleRoutes(config, cwd), serviceRoleRoutes(config))
92
+
85
93
  printSection("Missing (in AST, not in DB)", report.missing ?? [])
86
94
  printSection("Stale managed (stamped, not in AST)", report.staleManaged ?? [])
87
95
  printSection("Unmanaged drift (manual decision)", report.unmanagedDrift ?? [])
@@ -101,3 +109,61 @@ export function registerDoctor(program: Command): void {
101
109
  }
102
110
  })
103
111
  }
112
+
113
+ /**
114
+ * Whether declared hooks can actually run.
115
+ *
116
+ * Worth its own section because every failure here is silent: a hook whose function is missing, or a
117
+ * stack with functions switched off, produces no error anywhere — the write just succeeds
118
+ * unvalidated. Drift you cannot see is the thing doctor exists for.
119
+ */
120
+ export function printHooks(report: HooksReport): void {
121
+ if (report.declared.length === 0) return
122
+
123
+ plain(`\nHooks (${report.declared.length}):\n`)
124
+ for (const hook of report.declared) {
125
+ const broken = report.missing.some(
126
+ (m) => m.model === hook.model && m.event === hook.event,
127
+ )
128
+ plain(` ${broken ? "✗" : "•"} ${hook.model}.${hook.event} → ${hook.function}`)
129
+ }
130
+
131
+ if (report.missing.length > 0) {
132
+ plain("\n Those marked ✗ name a function that does not exist, so they never fire.")
133
+ plain(" Create it with: supatype hooks new <name>")
134
+ }
135
+ if (report.functionsDisabled) {
136
+ plain("\n functions_enabled is false in .supatype/manifest.json — every hook is inert.")
137
+ plain(" Regenerate the stack config with: supatype self-host compose")
138
+ }
139
+ if (report.mapMissing) {
140
+ plain("\n .supatype/manifest.json carries no hook map, so the server has nothing to call.")
141
+ plain(" Run: supatype push")
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Report which functions may see the service-role key, and which grants do nothing.
147
+ *
148
+ * Worth printing even when everything resolves: this is the list of functions that can read and write
149
+ * past every access rule in the schema, and "which ones are those again?" should be answerable without
150
+ * opening the config.
151
+ */
152
+ export function printServiceRoleGrants(
153
+ problems: ServiceRoleProblems,
154
+ declared: readonly string[],
155
+ ): void {
156
+ if (declared.length === 0) return
157
+
158
+ plain(`\nService-role grants (${declared.length}):\n`)
159
+ const broken = new Set(problems.missing)
160
+ for (const name of declared) {
161
+ plain(` ${broken.has(name) ? "✗" : "•"} ${name}`)
162
+ }
163
+
164
+ if (broken.size > 0) {
165
+ plain("\n Those marked ✗ match no function, so they grant nothing — the function reads no key.")
166
+ }
167
+ for (const warning of problems.warnings) plain(warning)
168
+ plain("\n These functions bypass every access rule in the schema. Anything not listed cannot.")
169
+ }
@@ -16,6 +16,7 @@ import { ensureBinary } from "../ensure-binary.js"
16
16
  import {
17
17
  functionsPathCandidatesFromProject,
18
18
  preferredFunctionsPathFromProject,
19
+ serviceRoleRoutes,
19
20
  } from "../project-config.js"
20
21
  import {
21
22
  discoverTsFunctionsInDir,
@@ -24,6 +25,7 @@ import {
24
25
  import { loadProjectLink } from "../link.js"
25
26
  import { resolveTarget } from "../resolve-target.js"
26
27
  import { targetFetch } from "../target-client.js"
28
+ import { ensureFunctionsDenoTypes } from "../functions-deno-types.js"
27
29
  import { error, info, plain } from "../ui/messages.js"
28
30
  import { nextSteps } from "../ui/next-steps.js"
29
31
 
@@ -140,18 +142,19 @@ function scaffoldFunction(cwd: string, name: string): void {
140
142
  // Docs: https://supatype.com/docs/edge-functions
141
143
 
142
144
  export default async function handler(req: Request): Promise<Response> {
143
- const { method, url } = req
145
+ const { method } = req
146
+ const url = Deno.env.get("SUPATYPE_URL")
144
147
 
145
148
  // Example: read request body for POST requests
146
149
  if (method === "POST") {
147
150
  const body = await req.json()
148
- return new Response(JSON.stringify({ message: "Hello from ${name}!", received: body }), {
151
+ return new Response(JSON.stringify({ message: "Hello from ${name}!", received: body, url }), {
149
152
  status: 200,
150
153
  headers: { "Content-Type": "application/json" },
151
154
  })
152
155
  }
153
156
 
154
- return new Response(JSON.stringify({ message: "Hello from ${name}!" }), {
157
+ return new Response(JSON.stringify({ message: "Hello from ${name}!", url }), {
155
158
  status: 200,
156
159
  headers: { "Content-Type": "application/json" },
157
160
  })
@@ -181,13 +184,27 @@ export default async function handler(req: Request): Promise<Response> {
181
184
  )
182
185
  }
183
186
 
187
+ const denoTypes = ensureFunctionsDenoTypes(cwd, functionsDir)
188
+
184
189
  const functionsDirLabel = relativeFunctionsDir(cwd, functionsDir)
185
190
  info(`Created function: ${functionsDirLabel}/${name}/index.ts`)
186
- nextSteps("Next steps:", [
191
+ if (denoTypes.wroteDenoDts) {
192
+ info(`Added Deno IDE types: ${functionsDirLabel}/deno.d.ts`)
193
+ }
194
+ if (denoTypes.wroteTsconfig) {
195
+ info(`Added functions tsconfig: ${functionsDirLabel}/tsconfig.json`)
196
+ }
197
+ if (denoTypes.rootExclude === "updated") {
198
+ info('Updated root tsconfig.json to exclude "functions"')
199
+ }
200
+
201
+ const steps = [
187
202
  "Local dev: npx supatype functions serve",
188
203
  `Invoke: npx supatype functions invoke ${name}`,
189
204
  "Deploy: npx supatype functions deploy",
190
- ])
205
+ ...denoTypes.hints,
206
+ ]
207
+ nextSteps("Next steps:", steps)
191
208
  }
192
209
 
193
210
  // ─── Discover functions ──────────────────────────────────────────────────────
@@ -422,6 +439,9 @@ async function deployViaTarget(
422
439
  source,
423
440
  entrypoint: `${fn.name}/index.ts`,
424
441
  }],
442
+ // Sent every deploy, and replaced wholesale by the platform: the config file is the whole
443
+ // truth, so a name removed from it must stop being entitled to the service-role key.
444
+ serviceRole: serviceRoleRoutesFor(cwd),
425
445
  },
426
446
  token: target.token!,
427
447
  orgId: target.orgId,
@@ -479,6 +499,9 @@ async function deployCloud(cwd: string, fns: DiscoveredFunction[], env?: string)
479
499
  source,
480
500
  entrypoint: `${fn.name}/index.ts`,
481
501
  }],
502
+ // Same grants as the target path: both reach the same endpoint, and a project deployed one
503
+ // way must not end up with a different credential surface than the other.
504
+ serviceRole: serviceRoleRoutesFor(cwd),
482
505
  }),
483
506
  signal: AbortSignal.timeout(60_000),
484
507
  })
@@ -500,6 +523,20 @@ async function deployCloud(cwd: string, fns: DiscoveredFunction[], env?: string)
500
523
  info(`Invoke: https://${linked.ref}.supatype.dev/functions/v1/<name>`)
501
524
  }
502
525
 
526
+ /**
527
+ * The project's `functions.serviceRole` list, or undefined when the config cannot be read.
528
+ *
529
+ * Undefined rather than `[]`: the platform replaces the stored list wholesale when the field is present,
530
+ * so sending an empty array because a config failed to load would revoke every grant the project has.
531
+ */
532
+ export function serviceRoleRoutesFor(cwd: string): string[] | undefined {
533
+ try {
534
+ return serviceRoleRoutes(loadConfig(cwd))
535
+ } catch {
536
+ return undefined
537
+ }
538
+ }
539
+
503
540
  function readFunctionSource(fn: DiscoveredFunction): string {
504
541
  const stat = statSync(fn.absPath)
505
542
  if (stat.isFile()) {
@@ -2,9 +2,10 @@ import type { Command } from "commander"
2
2
  import { mkdirSync, writeFileSync } from "node:fs"
3
3
  import { resolve, dirname } from "node:path"
4
4
  import { loadConfig, loadSchemaAst } from "../config.js"
5
- import { schemaPathFromProject } from "../project-config.js"
5
+ import { hooksPathFromProject, schemaPathFromProject } from "../project-config.js"
6
6
  import { ensureEngine, engineRequest } from "../engine-client.js"
7
7
  import { generateClientAugmentation } from "../augmentation-generator.js"
8
+ import { writeHooksModule } from "../model-hooks.js"
8
9
  import { error, info } from "../ui/messages.js"
9
10
 
10
11
  export function registerGenerate(program: Command): void {
@@ -41,5 +42,8 @@ export function registerGenerate(program: Command): void {
41
42
  mkdirSync(dirname(augmentationOutPath), { recursive: true })
42
43
  writeFileSync(augmentationOutPath, augmentationCode, "utf8")
43
44
  info(`Client augmentation written to ${outputClientPath}`)
45
+
46
+ const hooksPath = writeHooksModule(cwd, hooksPathFromProject(config, cwd), ast)
47
+ if (hooksPath !== null) info(`Hook handler types written to ${hooksPath}`)
44
48
  })
45
49
  }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `supatype hooks` — scaffold and inspect model hooks.
3
+ *
4
+ * Separate from `supatype functions` because the two are different things wearing the same runtime.
5
+ * A function is a **public endpoint**: anyone holding the anon key can invoke it. A hook is
6
+ * **procedural** — only the API server calls it, around a write, and the gateway refuses the route
7
+ * from outside. Keeping them in separate directories is what makes that boundary structural instead
8
+ * of a list somebody has to maintain.
9
+ */
10
+ import type { Command } from "commander"
11
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
12
+ import { join, relative } from "node:path"
13
+ import { loadConfig, loadSchemaAst } from "../config.js"
14
+ import { hooksPathFromProject, schemaPathFromProject } from "../project-config.js"
15
+ import { declaredHooks, hooksReport } from "../model-hooks.js"
16
+ import { error, info, plain } from "../ui/messages.js"
17
+
18
+ export function registerHooks(program: Command): void {
19
+ const hooksCmd = program
20
+ .command("hooks")
21
+ .description("Manage model hooks (procedural handlers the API runs around a write)")
22
+
23
+ hooksCmd
24
+ .command("new <name>")
25
+ .description("Scaffold a hook handler")
26
+ .action((name: string) => {
27
+ scaffoldHook(process.cwd(), name)
28
+ })
29
+
30
+ hooksCmd
31
+ .command("list")
32
+ .description("Show the hooks the schema declares and whether each one can run")
33
+ .action(() => {
34
+ const cwd = process.cwd()
35
+ const config = loadConfig(cwd)
36
+ const ast = loadSchemaAst(schemaPathFromProject(config, cwd), cwd)
37
+ const report = hooksReport(cwd, hooksPathFromProject(config, cwd), ast)
38
+
39
+ if (report.declared.length === 0) {
40
+ info("No model declares a hook.")
41
+ return
42
+ }
43
+ for (const hook of report.declared) {
44
+ const broken = report.missing.some((m) => m.model === hook.model && m.event === hook.event)
45
+ plain(` ${broken ? "✗" : "•"} ${hook.model}.${hook.event} → ${hook.function}`)
46
+ }
47
+ if (report.missing.length > 0) {
48
+ plain("\n ✗ names a handler that does not exist, so that hook never fires.")
49
+ }
50
+ })
51
+ }
52
+
53
+ function scaffoldHook(cwd: string, name: string): void {
54
+ const config = loadConfig(cwd)
55
+ const hooksDir = hooksPathFromProject(config, cwd)
56
+ const dir = join(hooksDir, name)
57
+
58
+ if (existsSync(dir)) {
59
+ error(`Hook "${name}" already exists at ${relative(cwd, dir)}`)
60
+ process.exit(1)
61
+ }
62
+
63
+ const ast = loadSchemaAst(schemaPathFromProject(config, cwd), cwd)
64
+ const declared = declaredHooks(ast).filter((hook) => hook.function === name)
65
+
66
+ mkdirSync(dir, { recursive: true })
67
+ writeFileSync(join(dir, "index.ts"), handlerTemplate(name, declared), "utf8")
68
+
69
+ info(`Created hook: ${relative(cwd, join(dir, "index.ts"))}`)
70
+ if (declared.length === 0) {
71
+ // Scaffolding a handler nothing calls is easy to do and impossible to notice, since a hook that
72
+ // is never referenced simply never runs.
73
+ plain("")
74
+ plain(" No model references it yet. Add it to a model's meta:")
75
+ plain(` hooks: { beforeChange: "${name}" }`)
76
+ plain(" then run `supatype push` to generate its types and register it.")
77
+ } else {
78
+ plain("")
79
+ for (const hook of declared) {
80
+ plain(` Wired to ${hook.model}.${hook.event}`)
81
+ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * The scaffold deliberately imports from `../_supatype/hooks.ts`, which `push` generates.
87
+ *
88
+ * Before the first push that file does not exist, so the handler will not compile — which is the
89
+ * honest state: the types come from the schema, and a handler typed against a model that has not been
90
+ * pushed is a handler typed against a guess.
91
+ */
92
+ function handlerTemplate(name: string, declared: { model: string; event: string }[]): string {
93
+ const event = declared[0]?.event ?? "beforeChange"
94
+ const model = declared[0]?.model ?? "YourModel"
95
+
96
+ if (event.startsWith("after")) {
97
+ return `// ${name} — model hook (${event})
98
+ //
99
+ // Runs after the write has succeeded. It cannot change or undo it: a rejection here would be a
100
+ // rejection of something that already happened. Failures are logged, not surfaced to the caller.
101
+
102
+ import { hook, type AfterChange } from "../_supatype/hooks.ts"
103
+
104
+ const handler: AfterChange<"your_table"> = async (ctx) => {
105
+ console.log(\`\${ctx.table} \${ctx.operation} by \${ctx.user?.sub ?? "anon"}\`)
106
+ }
107
+
108
+ export default hook(handler)
109
+ `
110
+ }
111
+
112
+ return `// ${name} — model hook (${event}) for ${model}
113
+ //
114
+ // Runs before the write reaches Postgres, so it can reject it or rewrite the body.
115
+ //
116
+ // Not a security boundary: hooks fire for writes through the API, and direct SQL, seeds and anything
117
+ // holding the service role bypass them. Invariants belong in the model's access rules or a CHECK.
118
+
119
+ import { hook, type BeforeChange } from "../_supatype/hooks.ts"
120
+
121
+ const handler: BeforeChange<"your_table"> = async (ctx) => {
122
+ if (ctx.operation === "insert") {
123
+ // \`ctx.rows\` is the submitted rows, typed from the model.
124
+ return { rows: ctx.rows }
125
+ }
126
+
127
+ // \`ctx.patch\` is the submitted partial; \`ctx.previous()\` reads the rows about to change.
128
+ // Those come back as stored — unmasked — so do not echo them into a rejection message.
129
+ return {}
130
+
131
+ // Reject with a message the caller sees, and a status of your choosing:
132
+ // return { reject: "A post needs a title" }
133
+ // return { reject: { message: "Already exists", status: 409, code: "duplicate" } }
134
+ }
135
+
136
+ export default hook(handler)
137
+ `
138
+ }
@@ -2,6 +2,7 @@ import type { Command } from "commander"
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
3
3
  import { resolve, join, basename } from "node:path"
4
4
  import { spawnSync } from "node:child_process"
5
+ import { randomBytes } from "node:crypto"
5
6
  import { p, runClackFlow } from "../ui/clack.js"
6
7
  import { ensureNotCancelled } from "../ui/prompts.js"
7
8
  import { generateAndWriteKeys } from "./keys.js"
@@ -13,6 +14,7 @@ import {
13
14
  ensureComponentBinaries,
14
15
  reportComponentBinaryFailures,
15
16
  } from "../ensure-component-binaries.js"
17
+ import { ensureFunctionsDenoTypes } from "../functions-deno-types.js"
16
18
  import { mergeSupatypePackageJson } from "../init-package-json.js"
17
19
  import { cliPackageVersion } from "../cli-package-version.js"
18
20
  import {
@@ -792,6 +794,12 @@ function scaffoldHelloFunction(
792
794
  if (!existsSync(join(dir, "functions/.env.local"))) {
793
795
  write("functions/.env.local", functionsEnvLocalTemplate())
794
796
  }
797
+ const denoTypes = ensureFunctionsDenoTypes(dir, join(dir, "functions"))
798
+ if (denoTypes.wroteDenoDts) file("created", "functions/deno.d.ts")
799
+ if (denoTypes.wroteTsconfig) file("created", "functions/tsconfig.json")
800
+ if (denoTypes.rootExclude === "updated") {
801
+ file("updated", 'tsconfig.json (exclude "functions")')
802
+ }
795
803
  }
796
804
 
797
805
  // ─── Templates ───────────────────────────────────────────────────────────────
@@ -1182,13 +1190,28 @@ export type SiteSettings = Model<{
1182
1190
 
1183
1191
  function envTemplate(opts: ScaffoldOptions): string {
1184
1192
  const sections: string[] = []
1185
- sections.push(`DATABASE_URL=postgresql://supatype_admin:postgres@localhost:5432/${opts.projectName}
1186
- POSTGRES_USER=supatype_admin
1187
- POSTGRES_PASSWORD=postgres
1188
- POSTGRES_DB=${opts.projectName}`)
1193
+ // Generated, not constant. These reach a server: the previous defaults meant a self-host
1194
+ // deployment ran with a password of `postgres` and a JWT secret published in this repo —
1195
+ // and anyone holding that secret can mint a token naming any Postgres role. Safe to generate
1196
+ // only because `supatype dev` no longer overwrites them on every run (see local-secrets.ts).
1197
+ const postgresPassword = randomBytes(24).toString("base64url")
1189
1198
 
1190
- sections.push(`# JWT — run \`supatype keys\` to generate ANON_KEY and SERVICE_ROLE_KEY
1191
- JWT_SECRET=super-secret-jwt-token-change-in-production
1199
+ sections.push(`DATABASE_URL=postgresql://supatype_admin:${postgresPassword}@localhost:5432/${opts.projectName}
1200
+ POSTGRES_USER=supatype_admin
1201
+ POSTGRES_PASSWORD=${postgresPassword}
1202
+ POSTGRES_DB=${opts.projectName}
1203
+
1204
+ # PostgREST's own database credential. It connects as \`authenticator\`, a role that holds no
1205
+ # privileges and can only switch to anon/authenticated/service_role — so a request whose JWT
1206
+ # names any other role is refused by Postgres rather than honoured.
1207
+ #
1208
+ # Deliberately NOT the same as POSTGRES_PASSWORD: that one is yours, for direct SQL access, and
1209
+ # rotating it should never take the REST API down with it.
1210
+ AUTHENTICATOR_PASSWORD=${randomBytes(24).toString("base64url")}`)
1211
+
1212
+ sections.push(`# JWT — ANON_KEY and SERVICE_ROLE_KEY are derived from this secret.
1213
+ # Changing it invalidates every issued token; re-run \`supatype keys\` afterwards.
1214
+ JWT_SECRET=${randomBytes(48).toString("base64url")}
1192
1215
  ANON_KEY=
1193
1216
  SERVICE_ROLE_KEY=`)
1194
1217
 
@@ -1340,16 +1363,17 @@ function helloFunctionTemplate(): string {
1340
1363
 
1341
1364
  export default async function handler(req: Request): Promise<Response> {
1342
1365
  const { method } = req
1366
+ const url = Deno.env.get("SUPATYPE_URL")
1343
1367
 
1344
1368
  if (method === "POST") {
1345
1369
  const body = await req.json()
1346
- return new Response(JSON.stringify({ message: "Hello from hello!", received: body }), {
1370
+ return new Response(JSON.stringify({ message: "Hello from hello!", received: body, url }), {
1347
1371
  status: 200,
1348
1372
  headers: { "Content-Type": "application/json" },
1349
1373
  })
1350
1374
  }
1351
1375
 
1352
- return new Response(JSON.stringify({ message: "Hello from hello!" }), {
1376
+ return new Response(JSON.stringify({ message: "Hello from hello!", url }), {
1353
1377
  status: 200,
1354
1378
  headers: { "Content-Type": "application/json" },
1355
1379
  })
@@ -1435,6 +1459,16 @@ function printNextSteps(args: {
1435
1459
  info(`Supatype project ready${name ? ` in ${name}/` : ""}.`)
1436
1460
  nextSteps(setupComplete ? "Next steps:" : "Finish setup:", steps)
1437
1461
 
1462
+ // Shown once, at creation. These are generated per project rather than shipped as
1463
+ // constants, so this is the moment to say they exist and where they live — nothing prints
1464
+ // them again, and the JWT secret is only echoed as a fingerprint from here on.
1465
+ nextSteps("Secrets — generated for this project, kept in .env:", [
1466
+ "POSTGRES_PASSWORD direct SQL access as the superuser",
1467
+ "AUTHENTICATOR_PASSWORD PostgREST's own login; rotating the one above does not affect it",
1468
+ "JWT_SECRET signs ANON_KEY and SERVICE_ROLE_KEY; changing it invalidates every token",
1469
+ "Keep .env out of version control, and use different values in production.",
1470
+ ])
1471
+
1438
1472
  if (result.app.mode === "none") {
1439
1473
  nextSteps("Static frontend (self-host):", [
1440
1474
  "supatype app add --static ./public",
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Hidden internal commands (Homebrew post_install, etc.).
3
+ */
4
+
5
+ import type { Command } from "commander"
6
+ import { downloadAll } from "../binary-cache.js"
7
+ import { error, info } from "../ui/messages.js"
8
+
9
+ export function registerInternalCommands(program: Command): void {
10
+ program
11
+ .command("_postinstall", { hidden: true })
12
+ .description("Prefetch CDN component binaries after install (Homebrew post_install)")
13
+ .action(async () => {
14
+ try {
15
+ info("Prefetching Supatype component binaries for this platform...")
16
+ await downloadAll(undefined, true)
17
+ info("Component prefetch complete.")
18
+ } catch (err) {
19
+ error(err instanceof Error ? err.message : String(err))
20
+ process.exit(1)
21
+ }
22
+ })
23
+ }
@@ -72,9 +72,11 @@ export function generateAndWriteKeys(
72
72
  content = upsertEnvVar(content, "SERVICE_ROLE_KEY", serviceKey)
73
73
  content = upsertEnvVar(content, "VITE_SUPATYPE_ANON_KEY", anonKey)
74
74
  content = upsertEnvVar(content, "PUBLIC_SUPATYPE_ANON_KEY", anonKey)
75
+ content = upsertEnvVar(content, "EXPO_PUBLIC_SUPATYPE_ANON_KEY", anonKey)
75
76
  const apiUrl = readEnvVar(content, "PUBLIC_SUPATYPE_URL") ?? readEnvVar(content, "API_EXTERNAL_URL")
76
77
  if (apiUrl) {
77
78
  content = upsertEnvVar(content, "VITE_SUPATYPE_URL", apiUrl)
79
+ content = upsertEnvVar(content, "EXPO_PUBLIC_SUPATYPE_URL", apiUrl)
78
80
  }
79
81
  writeFileSync(envPath, content, "utf8")
80
82
  }
@@ -1,5 +1,6 @@
1
1
  import type { Command } from "commander"
2
2
  import { loadConfig } from "../config.js"
3
+ import { loadCloudCredentials } from "../cloud-credentials.js"
3
4
  import {
4
5
  createCloudLink,
5
6
  createSelfHostLink,
@@ -23,7 +24,8 @@ function resolveLinkToken(opts: {
23
24
  opts.serviceRoleKey ??
24
25
  process.env["SUPATYPE_ACCESS_TOKEN"] ??
25
26
  process.env["SUPATYPE_TOKEN"] ??
26
- process.env["SERVICE_ROLE_KEY"]
27
+ process.env["SERVICE_ROLE_KEY"] ??
28
+ loadCloudCredentials()?.accessToken
27
29
  )
28
30
  }
29
31
 
@@ -211,11 +213,15 @@ export async function runLinkAction(opts: {
211
213
  }
212
214
 
213
215
  if (!token) {
214
- error("Authentication required. Set SUPATYPE_ACCESS_TOKEN or pass --token.")
216
+ error("Authentication required. Run: supatype login (or set SUPATYPE_ACCESS_TOKEN / pass --token).")
215
217
  process.exit(1)
216
218
  }
217
219
 
218
220
  const cloudApiUrl = opts.apiUrl.replace(/\/$/, "")
221
+ const creds = loadCloudCredentials()
222
+ const refreshToken =
223
+ (creds?.apiUrl.replace(/\/$/, "") === cloudApiUrl ? creds.refreshToken : undefined) ??
224
+ existing?.refreshToken
219
225
 
220
226
  if (project) {
221
227
  const one = await targetFetch<{ slug: string; orgId: string }>(cloudApiUrl, "/api/v1", {
@@ -250,6 +256,7 @@ export async function runLinkAction(opts: {
250
256
  orgId: one.orgId,
251
257
  environments,
252
258
  existing,
259
+ ...(refreshToken !== undefined ? { refreshToken } : {}),
253
260
  })
254
261
  saveProjectLink(cwd, link)
255
262
  info(`Linked to cloud project: ${project}`)
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Cloud session login / logout for CLI control-plane auth.
3
+ * Self-host uses SERVICE_ROLE_KEY via `supatype link --url` — not this command.
4
+ */
5
+ import type { Command } from "commander"
6
+ import {
7
+ clearCloudCredentials,
8
+ cloudPasswordLogin,
9
+ loadCloudCredentials,
10
+ persistCloudSession,
11
+ } from "../cloud-credentials.js"
12
+ import { loadProjectLink, saveProjectLink } from "../link.js"
13
+ import { isInteractive } from "../ui/interactive.js"
14
+ import { error, info, plain } from "../ui/messages.js"
15
+ import { ensureNotCancelled, p, printLogo, runClackFlow } from "../ui/prompts.js"
16
+
17
+ function defaultApiUrl(cwd: string, flag?: string): string {
18
+ if (flag?.trim()) return flag.trim().replace(/\/$/, "")
19
+ const link = loadProjectLink(cwd)
20
+ if (link?.kind === "cloud" && link.cloudApiUrl) {
21
+ return link.cloudApiUrl.replace(/\/$/, "")
22
+ }
23
+ return (process.env["SUPATYPE_API_URL"] ?? "https://api.supatype.com").replace(/\/$/, "")
24
+ }
25
+
26
+ export function registerLogin(program: Command): void {
27
+ program
28
+ .command("login")
29
+ .description("Sign in to Supatype Cloud (stores refreshable session for push/deploy)")
30
+ .option("--api-url <url>", "Cloud control plane API URL")
31
+ .option("--email <email>", "Account email (non-interactive)")
32
+ .option("--password <password>", "Account password (prefer SUPATYPE_PASSWORD)")
33
+ .action(async (opts: { apiUrl?: string; email?: string; password?: string }) => {
34
+ const cwd = process.cwd()
35
+ const apiUrl = defaultApiUrl(cwd, opts.apiUrl)
36
+
37
+ let email = opts.email?.trim() ?? process.env["SUPATYPE_EMAIL"]?.trim()
38
+ let password =
39
+ opts.password ?? process.env["SUPATYPE_PASSWORD"] ?? process.env["SUPATYPE_LOGIN_PASSWORD"]
40
+
41
+ if ((!email || !password) && isInteractive()) {
42
+ await runClackFlow(async () => {
43
+ printLogo()
44
+ p.intro("Supatype Cloud login")
45
+ if (!email) {
46
+ email = ensureNotCancelled(
47
+ await p.text({
48
+ message: "Email",
49
+ placeholder: "you@example.com",
50
+ }),
51
+ ).trim()
52
+ }
53
+ if (!password) {
54
+ password = ensureNotCancelled(await p.password({ message: "Password" }))
55
+ }
56
+ p.outro("Signing in...")
57
+ })
58
+ }
59
+
60
+ if (!email || !password) {
61
+ error(
62
+ "Email and password required. Pass --email / --password, set SUPATYPE_EMAIL + SUPATYPE_PASSWORD, or run interactively.",
63
+ )
64
+ process.exit(1)
65
+ }
66
+
67
+ try {
68
+ const session = await cloudPasswordLogin(apiUrl, email, password)
69
+ persistCloudSession(cwd, {
70
+ apiUrl,
71
+ accessToken: session.accessToken,
72
+ ...(session.refreshToken !== undefined ? { refreshToken: session.refreshToken } : {}),
73
+ ...(session.email !== undefined ? { email: session.email } : { email }),
74
+ })
75
+ const link = loadProjectLink(cwd)
76
+ info(`Logged in to ${apiUrl}${session.email ? ` as ${session.email}` : ""}`)
77
+ if (link?.kind === "cloud") {
78
+ info(`Updated cloud session for linked project ${link.projectRef}`)
79
+ } else if (!session.refreshToken) {
80
+ plain("Warning: no refresh_token returned — session may expire in ~1h. Update control plane.")
81
+ } else {
82
+ plain("Tip: link a project with: supatype link --project <slug>")
83
+ }
84
+ } catch (err) {
85
+ error((err as Error).message)
86
+ process.exit(1)
87
+ }
88
+ })
89
+
90
+ program
91
+ .command("logout")
92
+ .description("Clear stored Supatype Cloud credentials")
93
+ .action(() => {
94
+ const cwd = process.cwd()
95
+ clearCloudCredentials()
96
+ const link = loadProjectLink(cwd)
97
+ if (link?.kind === "cloud") {
98
+ delete link.token
99
+ delete link.refreshToken
100
+ saveProjectLink(cwd, link)
101
+ }
102
+ info("Logged out of Supatype Cloud.")
103
+ })
104
+
105
+ program
106
+ .command("whoami")
107
+ .description("Show the current Supatype Cloud session")
108
+ .action(() => {
109
+ const creds = loadCloudCredentials()
110
+ const cwd = process.cwd()
111
+ const link = loadProjectLink(cwd)
112
+ if (!creds && !(link?.kind === "cloud" && link.token)) {
113
+ info("Not logged in. Run: supatype login")
114
+ return
115
+ }
116
+ if (creds) {
117
+ plain(`API: ${creds.apiUrl}`)
118
+ if (creds.email) plain(`Email: ${creds.email}`)
119
+ plain(`Refresh token: ${creds.refreshToken ? "yes" : "no"}`)
120
+ plain(`Updated: ${creds.updatedAt}`)
121
+ }
122
+ if (link?.kind === "cloud") {
123
+ plain(`Linked project: ${link.projectRef}`)
124
+ plain(`Link has refresh token: ${link.refreshToken ? "yes" : "no"}`)
125
+ }
126
+ })
127
+ }