@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,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
+ }
@@ -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
+ }
@@ -1,8 +1,17 @@
1
1
  import type { Command } from "commander"
2
- import { mkdirSync, writeFileSync } from "node:fs"
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
3
3
  import { join } from "node:path"
4
4
  import { loadConfig, loadSchemaAst } from "../config.js"
5
- import { resolveRuntimeProvider, schemaPathFromProject, serverBaseUrl } from "../project-config.js"
5
+ import { syncManifestHooks, validateModelHooks, writeHooksModule } from "../model-hooks.js"
6
+ import { adapterEntry, readHookUpload } from "../hook-upload.js"
7
+ import { checkServiceRoleRoutes, serviceRoleProblemLines } from "../service-role-check.js"
8
+ import { fatalError } from "../ui/fatal.js"
9
+ import {
10
+ hooksPathFromProject,
11
+ resolveRuntimeProvider,
12
+ schemaPathFromProject,
13
+ serverBaseUrl,
14
+ } from "../project-config.js"
6
15
  import { ensureEngine, engineRequest, type DiffResult } from "../engine-client.js"
7
16
  import { printDiffOperations, printDiffWarnings } from "../diff-output.js"
8
17
  import { signJwt } from "../jwt.js"
@@ -20,6 +29,7 @@ import {
20
29
  type DeployTarget,
21
30
  } from "../resolve-target.js"
22
31
  import { loadProjectLink } from "../link.js"
32
+ import { targetFetch } from "../target-client.js"
23
33
  import {
24
34
  buildSchemaSourcesPayload,
25
35
  cacheSchemaSourcesLocally,
@@ -54,6 +64,8 @@ export function registerPush(program: Command): void {
54
64
  const config = loadConfig(cwd)
55
65
  const pgSchema = schemaPgSchema(cwd)
56
66
  const ast = loadSchemaAst(schemaPathFromProject(config, cwd), cwd)
67
+ assertModelHooksResolve(cwd, config, ast)
68
+ assertServiceRoleGrantsResolve(cwd, config)
57
69
 
58
70
  const linked = loadProjectLink(cwd)
59
71
  const useDirect = opts.direct || opts.local || Boolean(opts.connection)
@@ -143,6 +155,10 @@ async function pushViaTarget(
143
155
  }
144
156
  }
145
157
 
158
+ if (target.mode === "cloud" || target.mode === "self-host") {
159
+ await deployHooksToTarget(cwd, config, target, ast)
160
+ }
161
+
146
162
  if (target.mode === "direct" || target.mode === "local") {
147
163
  await writeLocalAdminConfig(ast, config)
148
164
  if (target.databaseUrl) {
@@ -150,17 +166,86 @@ async function pushViaTarget(
150
166
  }
151
167
  await generateTypesLocal(ast, config)
152
168
  await provisionLocalStorage(ast, config)
153
- } else {
154
- info(`Pushed to ${target.mode} (${target.environment}).`)
169
+
170
+ // Local Studio only — a cloud/self-host push must not advertise the local
171
+ // gateway URL from config, which may not even be running.
172
+ const baseUrl = (serverBaseUrl(config) ?? "").replace(/\/$/, "")
173
+ if (baseUrl) {
174
+ plain(`\nStudio: ${baseUrl}/studio/`)
175
+ }
176
+ return
155
177
  }
156
178
 
157
- const baseUrl = (serverBaseUrl(config) ?? "").replace(/\/$/, "")
158
- if (baseUrl) {
159
- plain(`\nStudio: ${baseUrl}/studio/`)
179
+ info(`Pushed to ${target.mode} (${target.environment}).`)
180
+ const envUrl = target.link?.environments?.[target.environment]?.apiUrl?.replace(/\/$/, "")
181
+ if (envUrl) {
182
+ plain(`\nProject API: ${envUrl}`)
160
183
  }
161
184
  }
162
185
 
186
+ /**
187
+ * Upload the hooks this schema names to a managed stack.
188
+ *
189
+ * On `push` rather than `functions deploy`, because the hook *map* comes from the schema: the two have
190
+ * to move together, and a map naming a handler that was never uploaded turns every write to that table
191
+ * into a 503.
192
+ *
193
+ * The handler types are written locally as well. A project pushing to cloud still edits its hooks on
194
+ * this machine, and `hooks/_supatype/hooks.ts` is what makes them typed — it is generated, never
195
+ * committed, so a fresh clone that has only ever pushed to cloud would otherwise have no types at all.
196
+ */
197
+ async function deployHooksToTarget(
198
+ cwd: string,
199
+ config: SupatypeProjectConfig,
200
+ target: DeployTarget,
201
+ ast: unknown,
202
+ ): Promise<void> {
203
+ const hooksDir = hooksPathFromProject(config, cwd)
204
+ const hooksPath = writeHooksModule(cwd, hooksDir, ast)
205
+ if (hooksPath !== null) info(`Hook handler types written to ${hooksPath}`)
206
+
207
+ let upload: ReturnType<typeof readHookUpload>
208
+ try {
209
+ upload = readHookUpload(cwd, hooksDir, ast)
210
+ } catch (err) {
211
+ // Refused rather than partially uploaded: the schema is already applied, so the honest thing is to
212
+ // say the hooks did not deploy and why, leaving the previous ones in place.
213
+ fatalError(err instanceof Error ? err.message : String(err))
214
+ return
215
+ }
216
+ if (upload === null) return
217
+
218
+ const adapter = readGeneratedAdapter(hooksDir)
219
+ const handlers = adapter === null ? upload.handlers : [...upload.handlers, adapter]
220
+
221
+ await withSpinner(`Deploying ${upload.handlers.length} hook(s)`, () =>
222
+ targetFetch(target.apiBaseUrl, target.apiPrefix, {
223
+ method: "POST",
224
+ path: `/projects/${target.projectRef}/hooks/deploy`,
225
+ body: { handlers, map: upload.map },
226
+ token: target.token!,
227
+ orgId: target.orgId,
228
+ environment: target.mode === "cloud" ? target.environment : undefined,
229
+ }),
230
+ ).then(() => info(`Deployed ${upload.handlers.length} hook(s)`))
231
+ }
232
+
233
+ /** The generated adapter, flattened to the name handlers were rewritten to import. */
234
+ function readGeneratedAdapter(hooksDir: string): { name: string; source: string } | null {
235
+ const file = join(hooksDir, "_supatype", "hooks.ts")
236
+ if (!existsSync(file)) return null
237
+ return adapterEntry(readFileSync(file, "utf8"))
238
+ }
239
+
163
240
  async function generateTypesLocal(ast: unknown, config: SupatypeProjectConfig): Promise<void> {
241
+ // Independent of `output`: a project with hooks needs its handler types whether or not it asked
242
+ // for client types, and the module is written next to the functions that import it.
243
+ const cwd = process.cwd()
244
+ const hooksPath = writeHooksModule(cwd, hooksPathFromProject(config, cwd), ast)
245
+ if (hooksPath !== null) info(`Hook handler types written to ${hooksPath}`)
246
+ // The server watches this file, so a changed hook takes effect without a restart.
247
+ if (syncManifestHooks(cwd, ast)) info("Hook map written to .supatype/manifest.json")
248
+
164
249
  if (!config.output?.types && !config.output?.client) return
165
250
  await withSpinner("Generating types", async () => {
166
251
  await ensureEngine()
@@ -195,3 +280,35 @@ async function writeLocalAdminConfig(ast: unknown, config: SupatypeProjectConfig
195
280
  restoreSystemRelationTargets(admin, ast)
196
281
  writeFileSync(join(dir, "admin-config.json"), `${JSON.stringify(admin, null, 2)}\n`)
197
282
  }
283
+
284
+ /**
285
+ * Stop the push when a declared hook names a function that is not there.
286
+ *
287
+ * A hook is only enforcement if it runs. A typo'd name would extract cleanly, reach the manifest, and
288
+ * then never fire — so the write it was meant to validate would succeed and look fine. Cheaper to
289
+ * fail here, naming the directory searched.
290
+ */
291
+ /**
292
+ * Refuse a push whose `functions.serviceRole` names a function that does not exist.
293
+ *
294
+ * The grant fails closed, which is the safe direction and the invisible one: the function reads no key
295
+ * at runtime, in a deploy that reported success. A warning is printed for an entry that is merely
296
+ * redundant, since a reader would reasonably assume the line is what does the granting.
297
+ */
298
+ function assertServiceRoleGrantsResolve(cwd: string, config: SupatypeProjectConfig): void {
299
+ const problems = checkServiceRoleRoutes(config, cwd)
300
+ for (const warning of problems.warnings) plain(warning)
301
+ const lines = serviceRoleProblemLines(problems)
302
+ if (lines.length === 0) return
303
+ fatalError("functions.serviceRole names a function that does not exist.", lines, {
304
+ brand: { intro: "Push" },
305
+ })
306
+ }
307
+
308
+ function assertModelHooksResolve(cwd: string, config: SupatypeProjectConfig, ast: unknown): void {
309
+ const problems = validateModelHooks(ast, hooksPathFromProject(config, cwd), cwd)
310
+ if (problems.length === 0) return
311
+ fatalError("A model declares a hook whose function does not exist.", problems, {
312
+ brand: { intro: "Push" },
313
+ })
314
+ }