@supatype/cli 0.1.0-alpha.6

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 (200) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-test.log +7 -0
  3. package/.turbo/turbo-typecheck.log +4 -0
  4. package/bin/dev-entry.ts +2 -0
  5. package/bin/supatype.js +5 -0
  6. package/dist/app/framework.d.ts +44 -0
  7. package/dist/app/framework.d.ts.map +1 -0
  8. package/dist/app/framework.js +200 -0
  9. package/dist/app/framework.js.map +1 -0
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.d.ts.map +1 -0
  12. package/dist/cli.js +55 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/commands/admin.d.ts +4 -0
  15. package/dist/commands/admin.d.ts.map +1 -0
  16. package/dist/commands/admin.js +270 -0
  17. package/dist/commands/admin.js.map +1 -0
  18. package/dist/commands/app.d.ts +3 -0
  19. package/dist/commands/app.d.ts.map +1 -0
  20. package/dist/commands/app.js +235 -0
  21. package/dist/commands/app.js.map +1 -0
  22. package/dist/commands/cloud.d.ts +3 -0
  23. package/dist/commands/cloud.d.ts.map +1 -0
  24. package/dist/commands/cloud.js +256 -0
  25. package/dist/commands/cloud.js.map +1 -0
  26. package/dist/commands/db.d.ts +8 -0
  27. package/dist/commands/db.d.ts.map +1 -0
  28. package/dist/commands/db.js +123 -0
  29. package/dist/commands/db.js.map +1 -0
  30. package/dist/commands/deploy-types.d.ts +14 -0
  31. package/dist/commands/deploy-types.d.ts.map +1 -0
  32. package/dist/commands/deploy-types.js +38 -0
  33. package/dist/commands/deploy-types.js.map +1 -0
  34. package/dist/commands/deploy.d.ts +14 -0
  35. package/dist/commands/deploy.d.ts.map +1 -0
  36. package/dist/commands/deploy.js +295 -0
  37. package/dist/commands/deploy.js.map +1 -0
  38. package/dist/commands/dev.d.ts +3 -0
  39. package/dist/commands/dev.d.ts.map +1 -0
  40. package/dist/commands/dev.js +428 -0
  41. package/dist/commands/dev.js.map +1 -0
  42. package/dist/commands/diff.d.ts +3 -0
  43. package/dist/commands/diff.d.ts.map +1 -0
  44. package/dist/commands/diff.js +39 -0
  45. package/dist/commands/diff.js.map +1 -0
  46. package/dist/commands/engine.d.ts +9 -0
  47. package/dist/commands/engine.d.ts.map +1 -0
  48. package/dist/commands/engine.js +99 -0
  49. package/dist/commands/engine.js.map +1 -0
  50. package/dist/commands/functions.d.ts +3 -0
  51. package/dist/commands/functions.d.ts.map +1 -0
  52. package/dist/commands/functions.js +762 -0
  53. package/dist/commands/functions.js.map +1 -0
  54. package/dist/commands/generate.d.ts +3 -0
  55. package/dist/commands/generate.d.ts.map +1 -0
  56. package/dist/commands/generate.js +28 -0
  57. package/dist/commands/generate.js.map +1 -0
  58. package/dist/commands/init.d.ts +7 -0
  59. package/dist/commands/init.d.ts.map +1 -0
  60. package/dist/commands/init.js +515 -0
  61. package/dist/commands/init.js.map +1 -0
  62. package/dist/commands/keys.d.ts +4 -0
  63. package/dist/commands/keys.d.ts.map +1 -0
  64. package/dist/commands/keys.js +57 -0
  65. package/dist/commands/keys.js.map +1 -0
  66. package/dist/commands/logs.d.ts +6 -0
  67. package/dist/commands/logs.d.ts.map +1 -0
  68. package/dist/commands/logs.js +52 -0
  69. package/dist/commands/logs.js.map +1 -0
  70. package/dist/commands/migrate.d.ts +3 -0
  71. package/dist/commands/migrate.d.ts.map +1 -0
  72. package/dist/commands/migrate.js +71 -0
  73. package/dist/commands/migrate.js.map +1 -0
  74. package/dist/commands/plugins.d.ts +3 -0
  75. package/dist/commands/plugins.d.ts.map +1 -0
  76. package/dist/commands/plugins.js +431 -0
  77. package/dist/commands/plugins.js.map +1 -0
  78. package/dist/commands/pull.d.ts +3 -0
  79. package/dist/commands/pull.d.ts.map +1 -0
  80. package/dist/commands/pull.js +73 -0
  81. package/dist/commands/pull.js.map +1 -0
  82. package/dist/commands/push.d.ts +3 -0
  83. package/dist/commands/push.d.ts.map +1 -0
  84. package/dist/commands/push.js +87 -0
  85. package/dist/commands/push.js.map +1 -0
  86. package/dist/commands/seed.d.ts +3 -0
  87. package/dist/commands/seed.d.ts.map +1 -0
  88. package/dist/commands/seed.js +22 -0
  89. package/dist/commands/seed.js.map +1 -0
  90. package/dist/commands/self-host.d.ts +3 -0
  91. package/dist/commands/self-host.d.ts.map +1 -0
  92. package/dist/commands/self-host.js +796 -0
  93. package/dist/commands/self-host.js.map +1 -0
  94. package/dist/commands/status.d.ts +6 -0
  95. package/dist/commands/status.d.ts.map +1 -0
  96. package/dist/commands/status.js +69 -0
  97. package/dist/commands/status.js.map +1 -0
  98. package/dist/config.d.ts +106 -0
  99. package/dist/config.d.ts.map +1 -0
  100. package/dist/config.js +66 -0
  101. package/dist/config.js.map +1 -0
  102. package/dist/engine/cache.d.ts +37 -0
  103. package/dist/engine/cache.d.ts.map +1 -0
  104. package/dist/engine/cache.js +121 -0
  105. package/dist/engine/cache.js.map +1 -0
  106. package/dist/engine/download.d.ts +19 -0
  107. package/dist/engine/download.d.ts.map +1 -0
  108. package/dist/engine/download.js +108 -0
  109. package/dist/engine/download.js.map +1 -0
  110. package/dist/engine/platform.d.ts +24 -0
  111. package/dist/engine/platform.d.ts.map +1 -0
  112. package/dist/engine/platform.js +50 -0
  113. package/dist/engine/platform.js.map +1 -0
  114. package/dist/engine/resolve.d.ts +37 -0
  115. package/dist/engine/resolve.d.ts.map +1 -0
  116. package/dist/engine/resolve.js +133 -0
  117. package/dist/engine/resolve.js.map +1 -0
  118. package/dist/engine/update-notify.d.ts +11 -0
  119. package/dist/engine/update-notify.d.ts.map +1 -0
  120. package/dist/engine/update-notify.js +43 -0
  121. package/dist/engine/update-notify.js.map +1 -0
  122. package/dist/engine/verify.d.ts +50 -0
  123. package/dist/engine/verify.d.ts.map +1 -0
  124. package/dist/engine/verify.js +161 -0
  125. package/dist/engine/verify.js.map +1 -0
  126. package/dist/engine-version.d.ts +35 -0
  127. package/dist/engine-version.d.ts.map +1 -0
  128. package/dist/engine-version.js +35 -0
  129. package/dist/engine-version.js.map +1 -0
  130. package/dist/engine.d.ts +34 -0
  131. package/dist/engine.d.ts.map +1 -0
  132. package/dist/engine.js +76 -0
  133. package/dist/engine.js.map +1 -0
  134. package/dist/index.d.ts +12 -0
  135. package/dist/index.d.ts.map +1 -0
  136. package/dist/index.js +10 -0
  137. package/dist/index.js.map +1 -0
  138. package/dist/jwt.d.ts +3 -0
  139. package/dist/jwt.d.ts.map +1 -0
  140. package/dist/jwt.js +13 -0
  141. package/dist/jwt.js.map +1 -0
  142. package/dist/pull-utils.d.ts +16 -0
  143. package/dist/pull-utils.d.ts.map +1 -0
  144. package/dist/pull-utils.js +65 -0
  145. package/dist/pull-utils.js.map +1 -0
  146. package/dist/scripts/postinstall.d.ts +12 -0
  147. package/dist/scripts/postinstall.d.ts.map +1 -0
  148. package/dist/scripts/postinstall.js +31 -0
  149. package/dist/scripts/postinstall.js.map +1 -0
  150. package/dist/tsx-runner.d.ts +18 -0
  151. package/dist/tsx-runner.d.ts.map +1 -0
  152. package/dist/tsx-runner.js +62 -0
  153. package/dist/tsx-runner.js.map +1 -0
  154. package/package.json +36 -0
  155. package/src/app/framework.ts +249 -0
  156. package/src/cli.ts +58 -0
  157. package/src/commands/admin.ts +371 -0
  158. package/src/commands/app.ts +261 -0
  159. package/src/commands/cloud.ts +326 -0
  160. package/src/commands/db.ts +145 -0
  161. package/src/commands/deploy-types.ts +49 -0
  162. package/src/commands/deploy.ts +366 -0
  163. package/src/commands/dev.ts +477 -0
  164. package/src/commands/diff.ts +61 -0
  165. package/src/commands/engine.ts +133 -0
  166. package/src/commands/functions.ts +919 -0
  167. package/src/commands/generate.ts +31 -0
  168. package/src/commands/init.ts +532 -0
  169. package/src/commands/keys.ts +66 -0
  170. package/src/commands/logs.ts +58 -0
  171. package/src/commands/migrate.ts +83 -0
  172. package/src/commands/plugins.ts +508 -0
  173. package/src/commands/pull.ts +96 -0
  174. package/src/commands/push.ts +119 -0
  175. package/src/commands/seed.ts +26 -0
  176. package/src/commands/self-host.ts +932 -0
  177. package/src/commands/status.ts +83 -0
  178. package/src/config.ts +190 -0
  179. package/src/engine/cache.ts +135 -0
  180. package/src/engine/download.ts +143 -0
  181. package/src/engine/platform.ts +66 -0
  182. package/src/engine/resolve.ts +197 -0
  183. package/src/engine/update-notify.ts +50 -0
  184. package/src/engine/verify.ts +206 -0
  185. package/src/engine-version.ts +39 -0
  186. package/src/engine.ts +99 -0
  187. package/src/index.ts +19 -0
  188. package/src/jwt.ts +14 -0
  189. package/src/pull-utils.ts +57 -0
  190. package/src/scripts/postinstall.ts +40 -0
  191. package/src/tsx-runner.ts +79 -0
  192. package/tests/cli-help.test.ts +107 -0
  193. package/tests/config.test.ts +117 -0
  194. package/tests/engine-distribution.test.ts +418 -0
  195. package/tests/init.test.ts +184 -0
  196. package/tests/keys.test.ts +160 -0
  197. package/tests/pull-utils.test.ts +115 -0
  198. package/tests/tsx-runner.test.ts +66 -0
  199. package/tsconfig.json +10 -0
  200. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,919 @@
1
+ import type { Command } from "commander"
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ statSync,
8
+ writeFileSync,
9
+ unlinkSync,
10
+ } from "node:fs"
11
+ import { resolve, join, basename, relative } from "node:path"
12
+ import { spawnSync, execSync } from "node:child_process"
13
+
14
+ // ─── Constants ───────────────────────────────────────────────────────────────
15
+
16
+ const FUNCTIONS_DIR = "supatype/functions"
17
+ const SHARED_DIR = "_shared"
18
+ const ENV_LOCAL = ".env.local"
19
+ const ENV_PRODUCTION = ".env.production"
20
+
21
+ // ─── Registration ────────────────────────────────────────────────────────────
22
+
23
+ export function registerFunctions(program: Command): void {
24
+ const fnCmd = program
25
+ .command("functions")
26
+ .description("Manage Supatype Edge Functions (Deno-based serverless TypeScript)")
27
+
28
+ fnCmd
29
+ .command("new <name>")
30
+ .description("Scaffold a new edge function")
31
+ .action((name: string) => {
32
+ scaffoldFunction(process.cwd(), name)
33
+ })
34
+
35
+ fnCmd
36
+ .command("serve")
37
+ .description("Start a local Deno server that serves all functions with hot reload")
38
+ .option("--port <port>", "Port to serve on", "54321")
39
+ .option("--env-file <path>", "Path to env file", `${FUNCTIONS_DIR}/${ENV_LOCAL}`)
40
+ .action((opts: { port: string; envFile: string }) => {
41
+ serve(process.cwd(), opts)
42
+ })
43
+
44
+ fnCmd
45
+ .command("deploy")
46
+ .description("Deploy all functions (or --only <name> for one) to the linked project")
47
+ .option("--only <name>", "Deploy a single function")
48
+ .option("--dry-run", "Show what would be deployed without deploying")
49
+ .action(async (opts: { only?: string; dryRun?: boolean }) => {
50
+ await deploy(process.cwd(), opts)
51
+ })
52
+
53
+ fnCmd
54
+ .command("list")
55
+ .description("List all deployed functions for the linked project")
56
+ .action(async () => {
57
+ await listFunctions(process.cwd())
58
+ })
59
+
60
+ fnCmd
61
+ .command("delete <name>")
62
+ .description("Remove a deployed function")
63
+ .action(async (name: string) => {
64
+ await deleteFunction(process.cwd(), name)
65
+ })
66
+
67
+ fnCmd
68
+ .command("logs <name>")
69
+ .description("Tail logs for a deployed function")
70
+ .option("--since <duration>", "Show logs since duration (e.g. 1h, 30m)", "1h")
71
+ .action(async (name: string, opts: { since: string }) => {
72
+ await functionLogs(process.cwd(), name, opts)
73
+ })
74
+
75
+ fnCmd
76
+ .command("invoke <name>")
77
+ .description("Invoke a local or deployed function")
78
+ .option("--data <json>", "JSON body to send", "{}")
79
+ .option("--auth", "Include a test JWT in the request")
80
+ .option("--local", "Invoke the local dev server (default if serve is running)")
81
+ .action(async (name: string, opts: { data: string; auth?: boolean; local?: boolean }) => {
82
+ await invoke(process.cwd(), name, opts)
83
+ })
84
+
85
+ const envCmd = fnCmd
86
+ .command("env")
87
+ .description("Manage function environment variables")
88
+
89
+ envCmd
90
+ .command("list")
91
+ .description("List environment variables (values masked)")
92
+ .action(async () => {
93
+ await envList(process.cwd())
94
+ })
95
+
96
+ envCmd
97
+ .command("set <keyvalue>")
98
+ .description("Set an environment variable (KEY=value)")
99
+ .action(async (keyvalue: string) => {
100
+ await envSet(process.cwd(), keyvalue)
101
+ })
102
+
103
+ envCmd
104
+ .command("unset <key>")
105
+ .description("Remove an environment variable")
106
+ .action(async (key: string) => {
107
+ await envUnset(process.cwd(), key)
108
+ })
109
+ }
110
+
111
+ // ─── Scaffold ────────────────────────────────────────────────────────────────
112
+
113
+ function scaffoldFunction(cwd: string, name: string): void {
114
+ const fnDir = resolve(cwd, FUNCTIONS_DIR, name)
115
+ if (existsSync(fnDir)) {
116
+ console.error(`Function "${name}" already exists at ${relative(cwd, fnDir)}`)
117
+ process.exit(1)
118
+ }
119
+
120
+ mkdirSync(fnDir, { recursive: true })
121
+
122
+ const indexContent = `// ${name} — Supatype Edge Function
123
+ // Docs: https://supatype.dev/docs/edge-functions
124
+
125
+ export default async function handler(req: Request): Promise<Response> {
126
+ const { method, url } = req
127
+
128
+ // Example: read request body for POST requests
129
+ if (method === "POST") {
130
+ const body = await req.json()
131
+ return new Response(JSON.stringify({ message: "Hello from ${name}!", received: body }), {
132
+ status: 200,
133
+ headers: { "Content-Type": "application/json" },
134
+ })
135
+ }
136
+
137
+ return new Response(JSON.stringify({ message: "Hello from ${name}!" }), {
138
+ status: 200,
139
+ headers: { "Content-Type": "application/json" },
140
+ })
141
+ }
142
+ `
143
+
144
+ writeFileSync(join(fnDir, "index.ts"), indexContent, "utf8")
145
+
146
+ // Ensure _shared directory exists
147
+ const sharedDir = resolve(cwd, FUNCTIONS_DIR, SHARED_DIR)
148
+ if (!existsSync(sharedDir)) {
149
+ mkdirSync(sharedDir, { recursive: true })
150
+ writeFileSync(
151
+ join(sharedDir, "README.md"),
152
+ "# Shared Code\n\nFiles in `_shared/` are available to all functions via relative imports.\nThis directory is not deployed as a function.\n\nExample: `import { sendEmail } from '../_shared/email.ts'`\n",
153
+ "utf8",
154
+ )
155
+ }
156
+
157
+ // Ensure .env.local exists
158
+ const envLocalPath = resolve(cwd, FUNCTIONS_DIR, ENV_LOCAL)
159
+ if (!existsSync(envLocalPath)) {
160
+ writeFileSync(
161
+ envLocalPath,
162
+ "# Local environment variables for edge functions\n# These are NOT committed to git\n# Set production env vars via: npx supatype functions env set KEY=value\n",
163
+ "utf8",
164
+ )
165
+ }
166
+
167
+ console.log(`Created function: ${FUNCTIONS_DIR}/${name}/index.ts`)
168
+ console.log()
169
+ console.log(" Local dev: npx supatype functions serve")
170
+ console.log(` Invoke: npx supatype functions invoke ${name}`)
171
+ console.log(" Deploy: npx supatype functions deploy")
172
+ }
173
+
174
+ // ─── Discover functions ──────────────────────────────────────────────────────
175
+
176
+ interface DiscoveredFunction {
177
+ name: string
178
+ entrypoint: string
179
+ absPath: string
180
+ }
181
+
182
+ function discoverFunctions(cwd: string): DiscoveredFunction[] {
183
+ const functionsDir = resolve(cwd, FUNCTIONS_DIR)
184
+ if (!existsSync(functionsDir)) return []
185
+
186
+ const entries = readdirSync(functionsDir)
187
+ const fns: DiscoveredFunction[] = []
188
+
189
+ for (const entry of entries) {
190
+ if (entry.startsWith("_") || entry.startsWith(".")) continue
191
+
192
+ const fullPath = join(functionsDir, entry)
193
+ const stat = statSync(fullPath)
194
+
195
+ if (stat.isDirectory()) {
196
+ // Directory function — look for index.ts
197
+ const indexPath = join(fullPath, "index.ts")
198
+ if (existsSync(indexPath)) {
199
+ fns.push({ name: entry, entrypoint: indexPath, absPath: fullPath })
200
+ }
201
+ } else if (entry.endsWith(".ts") && !entry.endsWith(".d.ts")) {
202
+ // Single-file function
203
+ const name = basename(entry, ".ts")
204
+ fns.push({ name, entrypoint: fullPath, absPath: fullPath })
205
+ }
206
+ }
207
+
208
+ return fns.sort((a, b) => a.name.localeCompare(b.name))
209
+ }
210
+
211
+ // ─── Serve (local dev) ──────────────────────────────────────────────────────
212
+
213
+ function serve(cwd: string, opts: { port: string; envFile: string }): void {
214
+ const fns = discoverFunctions(cwd)
215
+ if (fns.length === 0) {
216
+ console.error(`No functions found in ${FUNCTIONS_DIR}/`)
217
+ console.error("Create one with: npx supatype functions new <name>")
218
+ process.exit(1)
219
+ }
220
+
221
+ console.log(`Discovered ${fns.length} function(s):`)
222
+ for (const fn of fns) {
223
+ console.log(` /${fn.name} → ${relative(cwd, fn.entrypoint)}`)
224
+ }
225
+ console.log()
226
+
227
+ // Generate a Deno entry script that routes requests to the correct function
228
+ const routerScript = generateLocalRouter(fns, cwd)
229
+ const routerPath = resolve(cwd, FUNCTIONS_DIR, ".serve-router.ts")
230
+ writeFileSync(routerPath, routerScript, "utf8")
231
+
232
+ const envFilePath = resolve(cwd, opts.envFile)
233
+ const envArgs: string[] = []
234
+ if (existsSync(envFilePath)) {
235
+ envArgs.push("--env-file", envFilePath)
236
+ }
237
+
238
+ console.log(`Serving functions at http://localhost:${opts.port}/functions/v1/`)
239
+ console.log("Watching for changes...\n")
240
+
241
+ const result = spawnSync(
242
+ "deno",
243
+ [
244
+ "run",
245
+ "--allow-net",
246
+ "--allow-env",
247
+ "--allow-read",
248
+ "--watch",
249
+ ...envArgs,
250
+ routerPath,
251
+ ],
252
+ {
253
+ stdio: "inherit",
254
+ cwd,
255
+ env: {
256
+ ...process.env,
257
+ PORT: opts.port,
258
+ SUPATYPE_URL: process.env["SUPATYPE_URL"] ?? `http://localhost:8000`,
259
+ SUPATYPE_ANON_KEY: process.env["SUPATYPE_ANON_KEY"] ?? "",
260
+ SUPATYPE_SERVICE_ROLE_KEY: process.env["SUPATYPE_SERVICE_ROLE_KEY"] ?? "",
261
+ },
262
+ },
263
+ )
264
+
265
+ // Clean up router script
266
+ try { unlinkSync(routerPath) } catch { /* ignore */ }
267
+
268
+ if (result.status !== 0) {
269
+ console.error("Function server exited with errors.")
270
+ console.error("Make sure Deno is installed: https://deno.land/manual/getting_started/installation")
271
+ process.exit(result.status ?? 1)
272
+ }
273
+ }
274
+
275
+ function generateLocalRouter(fns: DiscoveredFunction[], cwd: string): string {
276
+ const imports = fns.map(
277
+ (fn, i) => `import handler_${i} from "./${relative(resolve(cwd, FUNCTIONS_DIR), fn.entrypoint).replace(/\\/g, "/")}"`,
278
+ )
279
+
280
+ const routes = fns.map(
281
+ (fn, i) => ` "${fn.name}": handler_${i},`,
282
+ )
283
+
284
+ return `// Auto-generated local function router — do not edit
285
+ ${imports.join("\n")}
286
+
287
+ const handlers: Record<string, (req: Request) => Response | Promise<Response>> = {
288
+ ${routes.join("\n")}
289
+ }
290
+
291
+ const port = parseInt(Deno.env.get("PORT") ?? "54321", 10)
292
+
293
+ Deno.serve({ port }, async (req: Request): Promise<Response> => {
294
+ const url = new URL(req.url)
295
+ const pathParts = url.pathname.replace(/^\\/functions\\/v1\\//, "").split("/")
296
+ const fnName = pathParts[0] ?? ""
297
+
298
+ if (!fnName || !handlers[fnName]) {
299
+ return new Response(JSON.stringify({
300
+ error: "not_found",
301
+ message: fnName ? \`Function "\${fnName}" not found\` : "No function specified",
302
+ available: Object.keys(handlers),
303
+ }), { status: 404, headers: { "Content-Type": "application/json" } })
304
+ }
305
+
306
+ try {
307
+ const start = performance.now()
308
+ const response = await handlers[fnName]!(req)
309
+ const duration = (performance.now() - start).toFixed(1)
310
+ console.log(\`\${req.method} /functions/v1/\${fnName} → \${response.status} (\${duration}ms)\`)
311
+ return response
312
+ } catch (err) {
313
+ console.error(\`Error in function "\${fnName}":\`, err)
314
+ return new Response(JSON.stringify({
315
+ error: "function_error",
316
+ message: err instanceof Error ? err.message : "Unknown error",
317
+ }), { status: 500, headers: { "Content-Type": "application/json" } })
318
+ }
319
+ })
320
+
321
+ console.log(\`Edge function server running on http://localhost:\${port}/functions/v1/\`)
322
+ `
323
+ }
324
+
325
+ // ─── Deploy ──────────────────────────────────────────────────────────────────
326
+
327
+ async function deploy(cwd: string, opts: { only?: string; dryRun?: boolean }): Promise<void> {
328
+ const allFns = discoverFunctions(cwd)
329
+ const fns = opts.only
330
+ ? allFns.filter(f => f.name === opts.only)
331
+ : allFns
332
+
333
+ if (fns.length === 0) {
334
+ if (opts.only) {
335
+ console.error(`Function "${opts.only}" not found in ${FUNCTIONS_DIR}/`)
336
+ } else {
337
+ console.error(`No functions found in ${FUNCTIONS_DIR}/`)
338
+ }
339
+ process.exit(1)
340
+ }
341
+
342
+ if (opts.dryRun) {
343
+ console.log("Dry run — the following functions would be deployed:\n")
344
+ for (const fn of fns) {
345
+ console.log(` ${fn.name} → ${relative(cwd, fn.entrypoint)}`)
346
+ }
347
+ console.log(`\nTotal: ${fns.length} function(s)`)
348
+ return
349
+ }
350
+
351
+ // Check if this is a self-hosted deployment or cloud
352
+ const isSelfHosted = detectSelfHosted(cwd)
353
+
354
+ if (isSelfHosted) {
355
+ await deploySelfHosted(cwd, fns)
356
+ } else {
357
+ await deployCloud(cwd, fns)
358
+ }
359
+ }
360
+
361
+ function detectSelfHosted(cwd: string): boolean {
362
+ return existsSync(resolve(cwd, "deploy/docker-compose.yml"))
363
+ }
364
+
365
+ async function deploySelfHosted(cwd: string, fns: DiscoveredFunction[]): Promise<void> {
366
+ console.log("Self-hosted deployment detected.\n")
367
+ console.log("Bundling functions...\n")
368
+
369
+ const bundleDir = resolve(cwd, "deploy/functions")
370
+ mkdirSync(bundleDir, { recursive: true })
371
+
372
+ for (const fn of fns) {
373
+ const start = Date.now()
374
+ const outFile = join(bundleDir, `${fn.name}.js`)
375
+
376
+ // Bundle with Deno
377
+ const result = spawnSync("deno", ["bundle", fn.entrypoint, outFile], {
378
+ stdio: "pipe",
379
+ cwd,
380
+ })
381
+
382
+ if (result.status !== 0) {
383
+ const stderr = result.stderr?.toString() ?? ""
384
+ console.error(` ${fn.name} ✗ bundle failed`)
385
+ if (stderr) console.error(` ${stderr.trim()}`)
386
+ continue
387
+ }
388
+
389
+ const duration = Date.now() - start
390
+ console.log(` ${fn.name} ✓ deployed (${duration}ms)`)
391
+ }
392
+
393
+ console.log(`\nDeployed ${fns.length} function(s) to deploy/functions/`)
394
+ console.log("The supatype-functions container will pick up changes automatically.")
395
+ }
396
+
397
+ async function deployCloud(cwd: string, fns: DiscoveredFunction[]): Promise<void> {
398
+ const { getLinkedProject, getCloudToken, getCloudApiUrl } = await loadCloudHelpers()
399
+ const linked = getLinkedProject(cwd)
400
+
401
+ if (!linked) {
402
+ console.error("No linked project. Run: npx supatype cloud link")
403
+ process.exit(1)
404
+ }
405
+
406
+ const token = getCloudToken()
407
+ if (!token) {
408
+ console.error("Not logged in. Run: npx supatype cloud login")
409
+ process.exit(1)
410
+ }
411
+
412
+ const apiUrl = getCloudApiUrl()
413
+ console.log(`Deploying to project: ${linked.ref}\n`)
414
+
415
+ for (const fn of fns) {
416
+ const start = Date.now()
417
+
418
+ // Read source code
419
+ const source = readFunctionSource(fn)
420
+
421
+ try {
422
+ const res = await fetch(`${apiUrl}/api/v1/projects/${linked.ref}/functions/deploy`, {
423
+ method: "POST",
424
+ headers: {
425
+ Authorization: `Bearer ${token}`,
426
+ "Content-Type": "application/json",
427
+ "X-Org-Id": linked.orgId ?? "",
428
+ },
429
+ body: JSON.stringify({
430
+ functions: [{
431
+ name: fn.name,
432
+ source,
433
+ entrypoint: `${fn.name}/index.ts`,
434
+ }],
435
+ }),
436
+ signal: AbortSignal.timeout(60_000),
437
+ })
438
+
439
+ if (!res.ok) {
440
+ const body = await res.json().catch(() => ({})) as Record<string, string>
441
+ console.error(` ${fn.name} ✗ ${body["message"] ?? res.statusText}`)
442
+ continue
443
+ }
444
+
445
+ const duration = Date.now() - start
446
+ console.log(` ${fn.name} ✓ deployed (${duration}ms)`)
447
+ } catch (err) {
448
+ console.error(` ${fn.name} ✗ ${err instanceof Error ? err.message : "unknown error"}`)
449
+ }
450
+ }
451
+
452
+ console.log(`\nDeployed ${fns.length} function(s)`)
453
+ console.log(`Invoke: https://${linked.ref}.supatype.io/functions/v1/<name>`)
454
+ }
455
+
456
+ function readFunctionSource(fn: DiscoveredFunction): string {
457
+ const stat = statSync(fn.absPath)
458
+ if (stat.isFile()) {
459
+ return readFileSync(fn.absPath, "utf8")
460
+ }
461
+
462
+ // Directory function — read all .ts files
463
+ const files: Record<string, string> = {}
464
+ const entries = readdirSync(fn.absPath, { recursive: true }) as string[]
465
+ for (const entry of entries) {
466
+ const fullPath = join(fn.absPath, entry)
467
+ if (statSync(fullPath).isFile() && (entry.endsWith(".ts") || entry.endsWith(".js"))) {
468
+ files[entry] = readFileSync(fullPath, "utf8")
469
+ }
470
+ }
471
+ return JSON.stringify(files)
472
+ }
473
+
474
+ // ─── List ────────────────────────────────────────────────────────────────────
475
+
476
+ async function listFunctions(cwd: string): Promise<void> {
477
+ const { getLinkedProject, getCloudToken, getCloudApiUrl } = await loadCloudHelpers()
478
+ const linked = getLinkedProject(cwd)
479
+
480
+ if (!linked) {
481
+ // Show local functions instead
482
+ const fns = discoverFunctions(cwd)
483
+ if (fns.length === 0) {
484
+ console.log("No functions found locally or remotely.")
485
+ return
486
+ }
487
+ console.log("Local functions (not linked to a cloud project):\n")
488
+ for (const fn of fns) {
489
+ console.log(` ${fn.name.padEnd(30)} ${relative(cwd, fn.entrypoint)}`)
490
+ }
491
+ return
492
+ }
493
+
494
+ const token = getCloudToken()
495
+ if (!token) {
496
+ console.error("Not logged in. Run: npx supatype cloud login")
497
+ process.exit(1)
498
+ }
499
+
500
+ try {
501
+ const res = await fetch(`${getCloudApiUrl()}/api/v1/projects/${linked.ref}/functions`, {
502
+ headers: {
503
+ Authorization: `Bearer ${token}`,
504
+ "X-Org-Id": linked.orgId ?? "",
505
+ },
506
+ signal: AbortSignal.timeout(10_000),
507
+ })
508
+
509
+ if (!res.ok) {
510
+ console.error(`Failed to list functions: ${res.statusText}`)
511
+ process.exit(1)
512
+ }
513
+
514
+ const { data } = await res.json() as { data: Array<{ name: string; deployedAt: string; invocations24h: number; avgDurationMs: number }> }
515
+
516
+ if (data.length === 0) {
517
+ console.log("No deployed functions.")
518
+ return
519
+ }
520
+
521
+ console.log("Deployed functions:\n")
522
+ console.log(` ${"Name".padEnd(28)} ${"Last Deployed".padEnd(24)} ${"Invocations (24h)".padEnd(20)} Avg Duration`)
523
+ console.log(` ${"─".repeat(28)} ${"─".repeat(24)} ${"─".repeat(20)} ${"─".repeat(12)}`)
524
+
525
+ for (const fn of data) {
526
+ const deployed = fn.deployedAt ? new Date(fn.deployedAt).toLocaleString() : "—"
527
+ console.log(
528
+ ` ${fn.name.padEnd(28)} ${deployed.padEnd(24)} ${String(fn.invocations24h ?? 0).padEnd(20)} ${fn.avgDurationMs ?? 0}ms`,
529
+ )
530
+ }
531
+ } catch (err) {
532
+ console.error(`Error: ${err instanceof Error ? err.message : "unknown"}`)
533
+ process.exit(1)
534
+ }
535
+ }
536
+
537
+ // ─── Delete ──────────────────────────────────────────────────────────────────
538
+
539
+ async function deleteFunction(cwd: string, name: string): Promise<void> {
540
+ const { getLinkedProject, getCloudToken, getCloudApiUrl } = await loadCloudHelpers()
541
+ const linked = getLinkedProject(cwd)
542
+
543
+ if (!linked) {
544
+ console.error("No linked project. Run: npx supatype cloud link")
545
+ process.exit(1)
546
+ }
547
+
548
+ const token = getCloudToken()
549
+ if (!token) {
550
+ console.error("Not logged in. Run: npx supatype cloud login")
551
+ process.exit(1)
552
+ }
553
+
554
+ try {
555
+ const res = await fetch(`${getCloudApiUrl()}/api/v1/projects/${linked.ref}/functions/${name}`, {
556
+ method: "DELETE",
557
+ headers: {
558
+ Authorization: `Bearer ${token}`,
559
+ "X-Org-Id": linked.orgId ?? "",
560
+ },
561
+ signal: AbortSignal.timeout(10_000),
562
+ })
563
+
564
+ if (!res.ok) {
565
+ const body = await res.json().catch(() => ({})) as Record<string, string>
566
+ console.error(`Failed to delete "${name}": ${body["message"] ?? res.statusText}`)
567
+ process.exit(1)
568
+ }
569
+
570
+ console.log(`Function "${name}" deleted. It will return 404 immediately.`)
571
+ } catch (err) {
572
+ console.error(`Error: ${err instanceof Error ? err.message : "unknown"}`)
573
+ process.exit(1)
574
+ }
575
+ }
576
+
577
+ // ─── Logs ────────────────────────────────────────────────────────────────────
578
+
579
+ async function functionLogs(cwd: string, name: string, opts: { since: string }): Promise<void> {
580
+ const { getLinkedProject, getCloudToken, getCloudApiUrl } = await loadCloudHelpers()
581
+ const linked = getLinkedProject(cwd)
582
+
583
+ if (!linked) {
584
+ console.error("No linked project. Run: npx supatype cloud link")
585
+ process.exit(1)
586
+ }
587
+
588
+ const token = getCloudToken()
589
+ if (!token) {
590
+ console.error("Not logged in. Run: npx supatype cloud login")
591
+ process.exit(1)
592
+ }
593
+
594
+ try {
595
+ const res = await fetch(
596
+ `${getCloudApiUrl()}/api/v1/projects/${linked.ref}/functions/${name}/logs?since=${opts.since}`,
597
+ {
598
+ headers: {
599
+ Authorization: `Bearer ${token}`,
600
+ "X-Org-Id": linked.orgId ?? "",
601
+ },
602
+ signal: AbortSignal.timeout(10_000),
603
+ },
604
+ )
605
+
606
+ if (!res.ok) {
607
+ console.error(`Failed to fetch logs: ${res.statusText}`)
608
+ process.exit(1)
609
+ }
610
+
611
+ const { data } = await res.json() as { data: Array<{ timestamp: string; level: string; message: string }> }
612
+
613
+ if (data.length === 0) {
614
+ console.log(`No logs for "${name}" in the last ${opts.since}.`)
615
+ return
616
+ }
617
+
618
+ for (const entry of data) {
619
+ const ts = new Date(entry.timestamp).toISOString().slice(11, 23)
620
+ const level = entry.level.toUpperCase().padEnd(5)
621
+ console.log(`${ts} [${level}] ${entry.message}`)
622
+ }
623
+ } catch (err) {
624
+ console.error(`Error: ${err instanceof Error ? err.message : "unknown"}`)
625
+ process.exit(1)
626
+ }
627
+ }
628
+
629
+ // ─── Invoke ──────────────────────────────────────────────────────────────────
630
+
631
+ async function invoke(
632
+ cwd: string,
633
+ name: string,
634
+ opts: { data: string; auth?: boolean; local?: boolean },
635
+ ): Promise<void> {
636
+ let url: string
637
+ const headers: Record<string, string> = { "Content-Type": "application/json" }
638
+
639
+ if (opts.local) {
640
+ url = `http://localhost:54321/functions/v1/${name}`
641
+ } else {
642
+ const { getLinkedProject, getCloudToken } = await loadCloudHelpers()
643
+ const linked = getLinkedProject(cwd)
644
+ if (linked) {
645
+ url = `https://${linked.ref}.supatype.io/functions/v1/${name}`
646
+ const token = getCloudToken()
647
+ if (token && opts.auth) {
648
+ headers["Authorization"] = `Bearer ${token}`
649
+ }
650
+ } else {
651
+ // Default to local
652
+ url = `http://localhost:54321/functions/v1/${name}`
653
+ }
654
+ }
655
+
656
+ if (opts.auth && !headers["Authorization"]) {
657
+ // Generate a test JWT for local invocation
658
+ headers["Authorization"] = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXIiLCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImlhdCI6MTcwMDAwMDAwMH0.test"
659
+ }
660
+
661
+ try {
662
+ let body: string | undefined
663
+ try {
664
+ JSON.parse(opts.data)
665
+ body = opts.data
666
+ } catch {
667
+ console.error("Invalid JSON data. Use --data '{\"key\": \"value\"}'")
668
+ process.exit(1)
669
+ }
670
+
671
+ const start = Date.now()
672
+ const res = await fetch(url, {
673
+ method: "POST",
674
+ headers,
675
+ body,
676
+ signal: AbortSignal.timeout(30_000),
677
+ })
678
+
679
+ const duration = Date.now() - start
680
+ const responseBody = await res.text()
681
+
682
+ console.log(`Status: ${res.status} (${duration}ms)`)
683
+ console.log()
684
+
685
+ // Try to pretty-print JSON
686
+ try {
687
+ const json = JSON.parse(responseBody)
688
+ console.log(JSON.stringify(json, null, 2))
689
+ } catch {
690
+ console.log(responseBody)
691
+ }
692
+ } catch (err) {
693
+ if (err instanceof TypeError && (err as Error).message.includes("fetch")) {
694
+ console.error(`Cannot reach ${url}`)
695
+ console.error("Is the function server running? Start it with: npx supatype functions serve")
696
+ } else {
697
+ console.error(`Error: ${err instanceof Error ? err.message : "unknown"}`)
698
+ }
699
+ process.exit(1)
700
+ }
701
+ }
702
+
703
+ // ─── Env management ──────────────────────────────────────────────────────────
704
+
705
+ async function envList(cwd: string): Promise<void> {
706
+ const { getLinkedProject, getCloudToken, getCloudApiUrl } = await loadCloudHelpers()
707
+ const linked = getLinkedProject(cwd)
708
+
709
+ if (!linked) {
710
+ // Show local env vars
711
+ const envPath = resolve(cwd, FUNCTIONS_DIR, ENV_LOCAL)
712
+ if (!existsSync(envPath)) {
713
+ console.log("No environment variables configured.")
714
+ return
715
+ }
716
+
717
+ const lines = readFileSync(envPath, "utf8").split("\n")
718
+ console.log("Local environment variables:\n")
719
+ for (const line of lines) {
720
+ const trimmed = line.trim()
721
+ if (!trimmed || trimmed.startsWith("#")) continue
722
+ const eqIdx = trimmed.indexOf("=")
723
+ if (eqIdx > 0) {
724
+ const key = trimmed.slice(0, eqIdx)
725
+ console.log(` ${key} = ••••••••`)
726
+ }
727
+ }
728
+ return
729
+ }
730
+
731
+ const token = getCloudToken()
732
+ if (!token) {
733
+ console.error("Not logged in. Run: npx supatype cloud login")
734
+ process.exit(1)
735
+ }
736
+
737
+ try {
738
+ const res = await fetch(`${getCloudApiUrl()}/api/v1/projects/${linked.ref}/functions/env`, {
739
+ headers: {
740
+ Authorization: `Bearer ${token}`,
741
+ "X-Org-Id": linked.orgId ?? "",
742
+ },
743
+ signal: AbortSignal.timeout(10_000),
744
+ })
745
+
746
+ if (!res.ok) {
747
+ console.error(`Failed to list env vars: ${res.statusText}`)
748
+ process.exit(1)
749
+ }
750
+
751
+ const { data } = await res.json() as { data: string[] }
752
+
753
+ if (data.length === 0) {
754
+ console.log("No environment variables set.")
755
+ return
756
+ }
757
+
758
+ console.log("Environment variables (values masked):\n")
759
+ for (const key of data) {
760
+ console.log(` ${key} = ••••••••`)
761
+ }
762
+ } catch (err) {
763
+ console.error(`Error: ${err instanceof Error ? err.message : "unknown"}`)
764
+ process.exit(1)
765
+ }
766
+ }
767
+
768
+ async function envSet(cwd: string, keyvalue: string): Promise<void> {
769
+ const eqIdx = keyvalue.indexOf("=")
770
+ if (eqIdx <= 0) {
771
+ console.error("Invalid format. Use: npx supatype functions env set KEY=value")
772
+ process.exit(1)
773
+ }
774
+
775
+ const key = keyvalue.slice(0, eqIdx)
776
+ const value = keyvalue.slice(eqIdx + 1)
777
+
778
+ const { getLinkedProject, getCloudToken, getCloudApiUrl } = await loadCloudHelpers()
779
+ const linked = getLinkedProject(cwd)
780
+
781
+ if (!linked) {
782
+ // Set in local env file
783
+ const envPath = resolve(cwd, FUNCTIONS_DIR, ENV_LOCAL)
784
+ let content = existsSync(envPath) ? readFileSync(envPath, "utf8") : ""
785
+
786
+ // Replace existing or append
787
+ const regex = new RegExp(`^${key}=.*$`, "m")
788
+ if (regex.test(content)) {
789
+ content = content.replace(regex, `${key}=${value}`)
790
+ } else {
791
+ content = content.trimEnd() + `\n${key}=${value}\n`
792
+ }
793
+
794
+ writeFileSync(envPath, content, "utf8")
795
+ console.log(`Set ${key} in local env file.`)
796
+ return
797
+ }
798
+
799
+ const token = getCloudToken()
800
+ if (!token) {
801
+ console.error("Not logged in. Run: npx supatype cloud login")
802
+ process.exit(1)
803
+ }
804
+
805
+ try {
806
+ const res = await fetch(`${getCloudApiUrl()}/api/v1/projects/${linked.ref}/functions/env`, {
807
+ method: "POST",
808
+ headers: {
809
+ Authorization: `Bearer ${token}`,
810
+ "Content-Type": "application/json",
811
+ "X-Org-Id": linked.orgId ?? "",
812
+ },
813
+ body: JSON.stringify({ key, value }),
814
+ signal: AbortSignal.timeout(10_000),
815
+ })
816
+
817
+ if (!res.ok) {
818
+ const body = await res.json().catch(() => ({})) as Record<string, string>
819
+ console.error(`Failed to set env var: ${body["message"] ?? res.statusText}`)
820
+ process.exit(1)
821
+ }
822
+
823
+ console.log(`Set ${key} for project ${linked.ref}.`)
824
+ } catch (err) {
825
+ console.error(`Error: ${err instanceof Error ? err.message : "unknown"}`)
826
+ process.exit(1)
827
+ }
828
+ }
829
+
830
+ async function envUnset(cwd: string, key: string): Promise<void> {
831
+ const { getLinkedProject, getCloudToken, getCloudApiUrl } = await loadCloudHelpers()
832
+ const linked = getLinkedProject(cwd)
833
+
834
+ if (!linked) {
835
+ const envPath = resolve(cwd, FUNCTIONS_DIR, ENV_LOCAL)
836
+ if (!existsSync(envPath)) {
837
+ console.error("No local env file found.")
838
+ process.exit(1)
839
+ }
840
+
841
+ let content = readFileSync(envPath, "utf8")
842
+ const regex = new RegExp(`^${key}=.*\n?`, "m")
843
+ content = content.replace(regex, "")
844
+ writeFileSync(envPath, content, "utf8")
845
+ console.log(`Removed ${key} from local env file.`)
846
+ return
847
+ }
848
+
849
+ const token = getCloudToken()
850
+ if (!token) {
851
+ console.error("Not logged in. Run: npx supatype cloud login")
852
+ process.exit(1)
853
+ }
854
+
855
+ try {
856
+ const res = await fetch(`${getCloudApiUrl()}/api/v1/projects/${linked.ref}/functions/env/${key}`, {
857
+ method: "DELETE",
858
+ headers: {
859
+ Authorization: `Bearer ${token}`,
860
+ "X-Org-Id": linked.orgId ?? "",
861
+ },
862
+ signal: AbortSignal.timeout(10_000),
863
+ })
864
+
865
+ if (!res.ok) {
866
+ const body = await res.json().catch(() => ({})) as Record<string, string>
867
+ console.error(`Failed to unset env var: ${body["message"] ?? res.statusText}`)
868
+ process.exit(1)
869
+ }
870
+
871
+ console.log(`Removed ${key} for project ${linked.ref}.`)
872
+ } catch (err) {
873
+ console.error(`Error: ${err instanceof Error ? err.message : "unknown"}`)
874
+ process.exit(1)
875
+ }
876
+ }
877
+
878
+ // ─── Cloud helpers (lazy loaded) ─────────────────────────────────────────────
879
+
880
+ interface CloudHelpers {
881
+ getLinkedProject(cwd: string): { ref: string; orgId?: string } | null
882
+ getCloudToken(): string | null
883
+ getCloudApiUrl(): string
884
+ }
885
+
886
+ async function loadCloudHelpers(): Promise<CloudHelpers> {
887
+ // These helpers read the local .supatype/linked.json and auth token
888
+ return {
889
+ getLinkedProject(cwd: string): { ref: string; orgId?: string } | null {
890
+ const linkedPath = resolve(cwd, ".supatype/linked.json")
891
+ if (!existsSync(linkedPath)) return null
892
+ try {
893
+ const data = JSON.parse(readFileSync(linkedPath, "utf8")) as Record<string, string>
894
+ const ref = data["ref"]
895
+ const orgId = data["orgId"]
896
+ return ref ? { ref, ...(orgId !== undefined ? { orgId } : {}) } : null
897
+ } catch {
898
+ return null
899
+ }
900
+ },
901
+
902
+ getCloudToken(): string | null {
903
+ // Check env first, then config file
904
+ if (process.env["SUPATYPE_ACCESS_TOKEN"]) {
905
+ return process.env["SUPATYPE_ACCESS_TOKEN"]
906
+ }
907
+ const tokenPath = resolve(
908
+ process.env["HOME"] ?? process.env["USERPROFILE"] ?? "~",
909
+ ".supatype/token",
910
+ )
911
+ if (!existsSync(tokenPath)) return null
912
+ return readFileSync(tokenPath, "utf8").trim() || null
913
+ },
914
+
915
+ getCloudApiUrl(): string {
916
+ return process.env["SUPATYPE_API_URL"] ?? "https://api.supatype.io"
917
+ },
918
+ }
919
+ }