@omg-dev/vite-plugin 0.4.24

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.
package/src/build.ts ADDED
@@ -0,0 +1,430 @@
1
+ #!/usr/bin/env bun
2
+ // vibes-build — produces a self-contained deploy artifact set.
3
+ //
4
+ // What this emits, relative to app root:
5
+ // dist/ — vite client build
6
+ // .vibes/data.db — sqlite seeded with the schema (migrations ran here,
7
+ // not at runtime; runtime only registers scopes)
8
+ // .vibes/server.mjs — single-file bundled server (functions + @omg-dev/*
9
+ // inlined via bun build --target=bun). No node_modules
10
+ // needed at runtime.
11
+ // .vibes/artifacts.json — manifest consumed by the orchestrator bundle-copy
12
+ // phase; lists every path that must ship to the
13
+ // runtime sandbox.
14
+ //
15
+ // Why split migration off runtime:
16
+ // migrate() re-opens the DB and diffs sqlite_master on every cold start. For
17
+ // scale-to-zero deploys that adds 10–50ms per wake-up and — more importantly —
18
+ // makes the runtime care about schema.ts, which means the runtime needs the
19
+ // schema file, its transitive @omg-dev/schema dep, and bun's module resolver.
20
+ // Running it at build time against .vibes/data.db means the runtime just
21
+ // opens a pre-seeded sqlite file.
22
+ //
23
+ // Why bundle functions via codegen (not dynamic import at runtime):
24
+ // packages/server's dispatcher previously did `await import(route.module)` —
25
+ // that path would have to exist in the runtime sandbox AND pull in each
26
+ // function's transitive deps from node_modules. Instead we codegen a
27
+ // .vibes/routes.generated.ts with `import * as entries from ".../entries.ts"`
28
+ // and hand the dispatcher preloaded module objects. bun build inlines the
29
+ // lot. Cost: runtime can't hot-swap functions (fine — that's a dev concern).
30
+
31
+ import fs from "node:fs"
32
+ import path from "node:path"
33
+ import { spawnSync } from "node:child_process"
34
+ import { scanFunctions } from "./scanner.ts"
35
+ import { scanTriggers } from "./scanner-triggers.ts"
36
+ import { scanWorkflows } from "./scanner-workflows.ts"
37
+ import { scanBilling } from "./scanner-billing.ts"
38
+ import { generateAll } from "./codegen.ts"
39
+ import { prerenderApp } from "./prerender.ts"
40
+
41
+ const root = process.cwd()
42
+ const vibesDir = path.join(root, ".vibes")
43
+ const distDir = path.join(root, "dist")
44
+
45
+ function log(msg: string) {
46
+ console.log(`[vibes-build] ${msg}`)
47
+ }
48
+
49
+ function die(msg: string): never {
50
+ console.error(`[vibes-build] FATAL: ${msg}`)
51
+ process.exit(1)
52
+ }
53
+
54
+ async function main() {
55
+ // Sanity — schema.ts must exist, since both the seed step and the server
56
+ // entry rely on it.
57
+ const schemaPath = path.join(root, "schema.ts")
58
+ if (!fs.existsSync(schemaPath)) die("schema.ts not found at root")
59
+
60
+ fs.mkdirSync(vibesDir, { recursive: true })
61
+
62
+ // ── 1. codegen (types, zod, routes.json) ──────────────────────────────────
63
+ log("1/5 codegen")
64
+ await generateAll(root)
65
+
66
+ // ── 2. enumerate functions and write routes.generated.ts ──────────────────
67
+ log("2/5 generating .vibes/routes.generated.ts")
68
+ const routes = await scanFunctions(root)
69
+
70
+ // Group by module so we emit one `import * as X from "..."` per file and
71
+ // reuse the namespace object across each exported handler.
72
+ const moduleToAlias = new Map<string, string>()
73
+ for (const r of routes) {
74
+ if (!moduleToAlias.has(r.module)) {
75
+ const base = path.basename(r.module, ".ts").replace(/[^a-zA-Z0-9_]/g, "_")
76
+ moduleToAlias.set(r.module, `fn_${base}`)
77
+ }
78
+ }
79
+
80
+ const lines: string[] = [
81
+ "// AUTO-GENERATED by @omg-dev/vite-plugin. Do not edit.",
82
+ "// Bundled into .vibes/server.mjs at build time; runtime dispatcher",
83
+ "// reads route.mod directly — no dynamic import() happens at runtime.",
84
+ "",
85
+ ]
86
+ for (const [modPath, alias] of moduleToAlias) {
87
+ const rel = relFromVibes(modPath)
88
+ lines.push(`import * as ${alias} from "${rel}"`)
89
+ }
90
+ lines.push("")
91
+ lines.push("export const routes = [")
92
+ for (const r of routes) {
93
+ const alias = moduleToAlias.get(r.module)!
94
+ const stylePart = r.style ? `, style: ${JSON.stringify(r.style)}` : ""
95
+ lines.push(
96
+ ` { method: ${JSON.stringify(r.method)}, path: ${JSON.stringify(r.path)}, ` +
97
+ `handler: ${JSON.stringify(r.handler)}, module: ${JSON.stringify(r.module)}${stylePart}, mod: ${alias} },`
98
+ )
99
+ }
100
+ lines.push("]")
101
+ lines.push("")
102
+ fs.writeFileSync(path.join(vibesDir, "routes.generated.ts"), lines.join("\n"))
103
+
104
+ // ── 2b. enumerate triggers and write triggers.generated.ts ────────────────
105
+ // Same shape as routes.generated.ts so the runtime registry doesn't have
106
+ // to dynamic-import handler modules — those paths are build-time
107
+ // absolutes and won't resolve in the bundled .vibes/server.mjs at runtime.
108
+ // Without this, prod deploys log "unknown handler: <name>" for every
109
+ // dispatch even though .vibes/triggers.json contains the entry.
110
+ log("2b/5 generating .vibes/triggers.generated.ts")
111
+ const triggers = await scanTriggers(root)
112
+ const trigModuleToAlias = new Map<string, string>()
113
+ for (const t of triggers) {
114
+ if (!trigModuleToAlias.has(t.module)) {
115
+ const base = path.basename(t.module, ".ts").replace(/[^a-zA-Z0-9_]/g, "_")
116
+ // Prefix differently from routes to avoid name collision in
117
+ // edge case where a file is BOTH a route module AND a trigger
118
+ // module (unlikely but harmless to namespace).
119
+ trigModuleToAlias.set(t.module, `trg_${base}`)
120
+ }
121
+ }
122
+ const trigLines: string[] = [
123
+ "// AUTO-GENERATED by @omg-dev/vite-plugin. Do not edit.",
124
+ "// Bundled into .vibes/server.mjs at build time; runtime registry reads",
125
+ "// entry.mod directly — no dynamic import() happens at runtime.",
126
+ "",
127
+ ]
128
+ for (const [modPath, alias] of trigModuleToAlias) {
129
+ const rel = relFromVibes(modPath)
130
+ trigLines.push(`import * as ${alias} from "${rel}"`)
131
+ }
132
+ trigLines.push("")
133
+ trigLines.push("export const triggers = [")
134
+ for (const t of triggers) {
135
+ const alias = trigModuleToAlias.get(t.module)!
136
+ trigLines.push(
137
+ ` { handler: ${JSON.stringify(t.handler)}, kind: ${JSON.stringify(t.kind)}, ` +
138
+ `key: ${JSON.stringify(t.key)}, module: ${JSON.stringify(t.module)}, ` +
139
+ `exportName: ${JSON.stringify(t.exportName)}, mod: ${alias} },`,
140
+ )
141
+ }
142
+ trigLines.push("]")
143
+ trigLines.push("")
144
+ fs.writeFileSync(path.join(vibesDir, "triggers.generated.ts"), trigLines.join("\n"))
145
+
146
+ // ── 2c. enumerate workflows and write workflows.generated.ts ──────────────
147
+ // Same preloaded-`mod` pattern as triggers: the bundled runtime must not
148
+ // dynamic-import build-time absolute paths. Scan throws on malformed
149
+ // declarations (non-literal / duplicate names) and fails the build loud.
150
+ log("2c/5 generating .vibes/workflows.generated.ts")
151
+ const workflows = await scanWorkflows(root)
152
+ const wfModuleToAlias = new Map<string, string>()
153
+ for (const w of workflows) {
154
+ if (!wfModuleToAlias.has(w.module)) {
155
+ const base = path.basename(w.module, ".ts").replace(/[^a-zA-Z0-9_]/g, "_")
156
+ wfModuleToAlias.set(w.module, `wf_${base}`)
157
+ }
158
+ }
159
+ const wfLines: string[] = [
160
+ "// AUTO-GENERATED by @omg-dev/vite-plugin. Do not edit.",
161
+ "// Bundled into .vibes/server.mjs at build time; runtime registry reads",
162
+ "// entry.mod directly — no dynamic import() happens at runtime.",
163
+ "",
164
+ ]
165
+ for (const [modPath, alias] of wfModuleToAlias) {
166
+ const rel = relFromVibes(modPath)
167
+ wfLines.push(`import * as ${alias} from "${rel}"`)
168
+ }
169
+ wfLines.push("")
170
+ wfLines.push("export const workflows = [")
171
+ for (const w of workflows) {
172
+ const alias = wfModuleToAlias.get(w.module)!
173
+ wfLines.push(
174
+ ` { name: ${JSON.stringify(w.name)}, handler: ${JSON.stringify(w.handler)}, ` +
175
+ `module: ${JSON.stringify(w.module)}, exportName: ${JSON.stringify(w.exportName)}, mod: ${alias} },`,
176
+ )
177
+ }
178
+ wfLines.push("]")
179
+ wfLines.push("")
180
+ fs.writeFileSync(path.join(vibesDir, "workflows.generated.ts"), wfLines.join("\n"))
181
+
182
+ // ── 3. write server.entry.ts ──────────────────────────────────────────────
183
+ log("3/5 generating .vibes/server.entry.ts")
184
+ const entry = [
185
+ "// AUTO-GENERATED by @omg-dev/vite-plugin. Do not edit.",
186
+ "// This is the bundle input for bun build. After bundling, runtime runs",
187
+ "// `bun .vibes/server.mjs` with no node_modules on disk.",
188
+ "",
189
+ 'import { createVibesServer, addClient, removeClient, addSubClient, removeSubClient, handleSubMessage } from "@omg-dev/server"',
190
+ 'import { createAuthMiddleware } from "@omg-dev/auth"',
191
+ 'import schema from "../schema.ts"',
192
+ 'import { routes } from "./routes.generated.ts"',
193
+ 'import { triggers } from "./triggers.generated.ts"',
194
+ 'import { workflows } from "./workflows.generated.ts"',
195
+ "",
196
+ "const PORT = Number(process.env.PORT) || 3000",
197
+ "",
198
+ "const vibes = await createVibesServer({",
199
+ " root: process.cwd(),",
200
+ ' db: ".vibes/data.db",',
201
+ ' auth: "vibes",',
202
+ " schema,",
203
+ " routes,",
204
+ " triggers,",
205
+ " workflows,",
206
+ " staticDir: \"dist\",",
207
+ " // Migrations run at runtime (idempotent — diffs sqlite_master) so",
208
+ " // user data in .vibes/data.db persists across redeploys. The build",
209
+ " // artifact intentionally does NOT ship a seeded DB.",
210
+ "})",
211
+ "",
212
+ '// Parallel auth middleware for WS upgrades — vibes.fetch() runs its',
213
+ '// own authMW inside apiHandler, but the WS path goes through srv.upgrade',
214
+ '// which never sees apiHandler, so we verify here. Same "vibes" mode.',
215
+ 'const subAuthMW = createAuthMiddleware("vibes")',
216
+ "",
217
+ "const server = Bun.serve({",
218
+ " port: PORT,",
219
+ ' hostname: "0.0.0.0",',
220
+ " async fetch(req, srv) {",
221
+ " const url = new URL(req.url)",
222
+ ' if (url.pathname === "/__vibes_sub") {',
223
+ " // Authenticate the upgrade via the Sec-WebSocket-Protocol header",
224
+ " // (the only header browsers let JS clients add to a WS handshake).",
225
+ " // Subprotocol of the form `vibes-bearer.<jwt>` carries the JWT;",
226
+ " // we verify it via @omg-dev/auth and stash userId on ws.data.ctx.",
227
+ " // If verification fails or no token is present, ctx.userId stays",
228
+ " // null and the snapshot layer rejects scoped-collection subs.",
229
+ " const protoHeader = req.headers.get(\"sec-websocket-protocol\") ?? \"\"",
230
+ " const protos = protoHeader.split(\",\").map(s => s.trim()).filter(Boolean)",
231
+ " const bearerProto = protos.find(p => p.startsWith(\"vibes-bearer.\"))",
232
+ " let userId = null",
233
+ " if (bearerProto) {",
234
+ " const token = bearerProto.slice(\"vibes-bearer.\".length)",
235
+ " try {",
236
+ " const fakeReq = new Request(req.url, { headers: { authorization: `Bearer ${token}` } })",
237
+ " const result = await subAuthMW(fakeReq)",
238
+ " userId = result?.userId ?? null",
239
+ " } catch {",
240
+ " // Invalid / expired token = anonymous upgrade.",
241
+ " }",
242
+ " }",
243
+ " const headers = bearerProto ? { \"sec-websocket-protocol\": bearerProto } : undefined",
244
+ " const ok = srv.upgrade(req, { data: { ctx: { userId } }, headers })",
245
+ ' return ok ? undefined : new Response("upgrade failed", { status: 400 })',
246
+ " }",
247
+ ' if (url.pathname === "/__vibes_events") {',
248
+ " // Heartbeat keeps tunnel + CF edge from idle-closing the stream.",
249
+ " // Without it ~100s of silence drops the conn and the SDK reconnects",
250
+ " // forever, generating thousands of self-requests per day.",
251
+ " const HEARTBEAT_MS = 20000",
252
+ " let heartbeat = null",
253
+ " const encoder = new TextEncoder()",
254
+ " const client = { readyState: 1, send(_d) {} }",
255
+ " const cleanup = () => {",
256
+ " client.readyState = 3",
257
+ " removeClient(client)",
258
+ " if (heartbeat) { clearInterval(heartbeat); heartbeat = null }",
259
+ " }",
260
+ " return new Response(",
261
+ " new ReadableStream({",
262
+ " start(controller) {",
263
+ " try { controller.enqueue(encoder.encode(\":\\n\\n\")) } catch {}",
264
+ " client.send = (data) => {",
265
+ " try { controller.enqueue(encoder.encode(`data: ${data}\\n\\n`)) }",
266
+ " catch { cleanup() }",
267
+ " }",
268
+ " addClient(client)",
269
+ " heartbeat = setInterval(() => {",
270
+ " try { controller.enqueue(encoder.encode(\":\\n\\n\")) }",
271
+ " catch { cleanup() }",
272
+ " }, HEARTBEAT_MS)",
273
+ ' req.signal.addEventListener("abort", () => { cleanup() })',
274
+ " },",
275
+ " cancel() { cleanup() },",
276
+ " }),",
277
+ " { headers: {",
278
+ ' "Content-Type": "text/event-stream",',
279
+ ' "Cache-Control": "no-cache",',
280
+ ' "Connection": "keep-alive",',
281
+ " } },",
282
+ " )",
283
+ " }",
284
+ " return vibes.fetch(req)",
285
+ " },",
286
+ " websocket: {",
287
+ " open(ws) {",
288
+ " const client = {",
289
+ " readyState: 1,",
290
+ " send(data) { try { ws.send(data) } catch {} },",
291
+ " ctx: ws.data?.ctx ?? { userId: null },",
292
+ " }",
293
+ " ws.data.subClient = client",
294
+ " addSubClient(client)",
295
+ " },",
296
+ " message(ws, raw) {",
297
+ " const client = ws.data?.subClient",
298
+ " if (!client) return",
299
+ " const text = typeof raw === \"string\"",
300
+ " ? raw",
301
+ " : new TextDecoder().decode(raw)",
302
+ " void handleSubMessage(client, text)",
303
+ " },",
304
+ " close(ws) {",
305
+ " const client = ws.data?.subClient",
306
+ " if (!client) return",
307
+ " client.readyState = 3",
308
+ " removeSubClient(client)",
309
+ " },",
310
+ " },",
311
+ "})",
312
+ "",
313
+ "console.log(`[vibes] Production server on :${PORT}`)",
314
+ "",
315
+ ].join("\n")
316
+ fs.writeFileSync(path.join(vibesDir, "server.entry.ts"), entry)
317
+
318
+ // ── 4. vite build (client) ────────────────────────────────────────────────
319
+ log("4/5 vite build (client)")
320
+ fs.rmSync(distDir, { recursive: true, force: true })
321
+ run("bunx", ["--bun", "vp", "build"])
322
+ if (!fs.existsSync(path.join(distDir, "index.html"))) {
323
+ die("vite build did not produce dist/index.html")
324
+ }
325
+
326
+ // ── 4b. prerender (SSG) — bake the app's root view into dist/index.html ────
327
+ // So crawlers + first paint see real HTML instead of an empty <div id=root>.
328
+ // Strictly additive and FAIL-SOFT (see prerender.ts): any failure leaves the
329
+ // client build untouched, so it can never break a deploy. The client mounts
330
+ // the full SPA over whatever HTML lands in #root.
331
+ log("4b/5 prerender (SSG, fail-soft)")
332
+ await prerenderApp({ root, distDir, vibesDir, log })
333
+
334
+ // ── 5. bun build (server) ─────────────────────────────────────────────────
335
+ log("5/5 bun build server")
336
+ const serverOut = path.join(vibesDir, "server.mjs")
337
+ fs.rmSync(serverOut, { force: true })
338
+ run("bun", [
339
+ "build",
340
+ path.join(vibesDir, "server.entry.ts"),
341
+ "--target=bun",
342
+ "--outfile",
343
+ serverOut,
344
+ // bun:sqlite is a runtime built-in, not a real npm package — must be external.
345
+ "--external",
346
+ "bun:sqlite",
347
+ ])
348
+ if (!fs.existsSync(serverOut)) die("bun build did not produce .vibes/server.mjs")
349
+
350
+ // ── manifest ──────────────────────────────────────────────────────────────
351
+ // Artifacts are the stateless code; data.db is runtime state owned by the
352
+ // runtime sandbox and is NOT shipped by the builder.
353
+ //
354
+ // hasDb: does this deploy actually need a sqlite DB + litestream wiring?
355
+ // We load the user's schema and check for non-empty collections. Runtimes
356
+ // with hasDb=false skip litestream entirely — faster cold-start, no empty
357
+ // replicas in Tigris. Default to true on any load/parse failure so we don't
358
+ // silently orphan data on an unexpected schema shape.
359
+ let hasDb = true
360
+ try {
361
+ const schemaModule = (await import(schemaPath)) as { default?: { collections?: Record<string, unknown> } }
362
+ const collections = schemaModule.default?.collections
363
+ hasDb = !!collections && Object.keys(collections).length > 0
364
+ } catch (err) {
365
+ log(`warn: could not load schema to determine hasDb, defaulting to true: ${(err as Error).message}`)
366
+ }
367
+
368
+ // A "static" deploy serves dist/ directly from object storage; the
369
+ // orchestrator skips the runtime VM entirely. Three signals must agree:
370
+ // empty schema, no functions discovered, and the user did not opt out
371
+ // (no opt-out wired yet — derived purely from build inputs). The
372
+ // orchestrator re-checks all three server-side, so a stale flag never
373
+ // causes data loss.
374
+ // Workflows force a runtime VM: a static deploy has no server for the
375
+ // engine to invoke, so workflow-only apps are NOT static.
376
+ const isStatic = !hasDb && routes.length === 0 && workflows.length === 0
377
+
378
+ // Serialize the app's @omg-dev/billing declaration (if any) into the manifest.
379
+ // The orchestrator reads manifest.catalog at deploy time and persists it to
380
+ // deploys.catalog_json — the per-version contract the Go enforcer parses for
381
+ // default-plan / overage / lifecycle-grant decisions. No billing → omitted,
382
+ // so the enforcer keeps its conservative no-catalog default. An invalid
383
+ // declaration throws here and fails the build (fail-loud).
384
+ const billing = await scanBilling(root)
385
+ if (billing) {
386
+ log(`billing: catalog from ${path.relative(root, billing.module)}#${billing.exportName}`)
387
+ }
388
+
389
+ const manifest = {
390
+ version: 1,
391
+ hasDb,
392
+ static: isStatic,
393
+ functions: routes.length,
394
+ artifacts: [
395
+ { src: "dist", kind: "dir" },
396
+ { src: ".vibes/server.mjs", kind: "file" },
397
+ ],
398
+ entry: ".vibes/server.mjs",
399
+ port: 3000,
400
+ // Workflow names declared by this build. The orchestrator registers a
401
+ // Restate deployment for the version iff this is non-empty (see
402
+ // apps/infra/WORKFLOWS.md). Omitted when the app declares none.
403
+ ...(workflows.length > 0 ? { workflows: workflows.map(w => w.name) } : {}),
404
+ // Canonical catalog JSON string (deploys.catalog_json contract). Omitted
405
+ // when the app declares no billing.
406
+ ...(billing ? { catalog: billing.catalogJson } : {}),
407
+ }
408
+ fs.writeFileSync(path.join(vibesDir, "artifacts.json"), JSON.stringify(manifest, null, 2))
409
+ log(`manifest: hasDb=${hasDb} static=${isStatic} functions=${routes.length} workflows=${workflows.length} billing=${!!billing}`)
410
+
411
+ log(`done. artifacts listed in .vibes/artifacts.json`)
412
+ }
413
+
414
+ // ── helpers ───────────────────────────────────────────────────────────────────
415
+
416
+ function relFromVibes(absPath: string): string {
417
+ // server.entry.ts lives at <root>/.vibes/, so imports go up one directory.
418
+ const r = path.relative(vibesDir, absPath)
419
+ return r.startsWith(".") ? r : `./${r}`
420
+ }
421
+
422
+ function run(cmd: string, args: string[]) {
423
+ const res = spawnSync(cmd, args, { stdio: "inherit", cwd: root })
424
+ if (res.status !== 0) die(`${cmd} ${args.join(" ")} exited with ${res.status}`)
425
+ }
426
+
427
+ main().catch(err => {
428
+ console.error("[vibes-build] unhandled:", err)
429
+ process.exit(1)
430
+ })
package/src/codegen.ts ADDED
@@ -0,0 +1,134 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { schemaToZod, schemaToCollections, schemaToDrizzle, type Schema } from "@omg-dev/schema"
4
+ import { scanFunctions } from "./scanner.ts"
5
+ import { scanTriggers } from "./scanner-triggers.ts"
6
+ import { scanWorkflows } from "./scanner-workflows.ts"
7
+
8
+ // ── generateAll ───────────────────────────────────────────────────────────────
9
+
10
+ export async function generateAll(root: string): Promise<void> {
11
+ await regenerateSchema(root)
12
+ await regenerateRoutes(root)
13
+ await regenerateTriggers(root)
14
+ await regenerateWorkflows(root)
15
+ }
16
+
17
+ // ── regenerateSchema ──────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Reads schema.ts from root, imports it dynamically, and writes:
21
+ * src/db.generated.ts — TypeScript types + Zod schemas + collection configs
22
+ * src/db.drizzle.ts — Drizzle table definitions for server-side helpers
23
+ */
24
+ export async function regenerateSchema(root: string): Promise<void> {
25
+ const schemaPath = path.join(root, "schema.ts")
26
+ if (!fs.existsSync(schemaPath)) {
27
+ console.warn(`[vibes:codegen] No schema.ts found at ${schemaPath}`)
28
+ return
29
+ }
30
+
31
+ let schema: Schema
32
+ try {
33
+ // Dynamic import — works because we run in Bun/vite-node context
34
+ const mod = (await import(schemaPath)) as { default: Schema }
35
+ schema = mod.default
36
+ } catch (err) {
37
+ console.error(`[vibes:codegen] Failed to import schema.ts:`, err)
38
+ return
39
+ }
40
+
41
+ const srcDir = path.join(root, "src")
42
+ if (!fs.existsSync(srcDir)) {
43
+ fs.mkdirSync(srcDir, { recursive: true })
44
+ }
45
+
46
+ const outputPath = path.join(srcDir, "db.generated.ts")
47
+
48
+ const lines: string[] = [
49
+ "// This file is auto-generated by @omg-dev/vite-plugin. Do not edit manually.",
50
+ "// To regenerate: restart the dev server or run `vp build`.",
51
+ "",
52
+ "// ── Zod schemas & types ─────────────────────────────────────────────────────────",
53
+ "",
54
+ schemaToZod(schema),
55
+ "",
56
+ "// ── Collection configs ─────────────────────────────────────────────────────────",
57
+ "",
58
+ schemaToCollections(schema),
59
+ ]
60
+
61
+ fs.writeFileSync(outputPath, lines.join("\n"), "utf-8")
62
+ console.log(`[vibes:codegen] Generated ${outputPath}`)
63
+
64
+ const drizzlePath = path.join(srcDir, "db.drizzle.ts")
65
+ const drizzleLines = [
66
+ "// This file is auto-generated by @omg-dev/vite-plugin. Do not edit manually.",
67
+ "// To regenerate: restart the dev server or run `vp build`.",
68
+ "",
69
+ schemaToDrizzle(schema),
70
+ ]
71
+
72
+ fs.writeFileSync(drizzlePath, drizzleLines.join("\n"), "utf-8")
73
+ console.log(`[vibes:codegen] Generated ${drizzlePath}`)
74
+ }
75
+
76
+ // ── regenerateRoutes ──────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Scans functions/ directory and writes .vibes/routes.json
80
+ */
81
+ export async function regenerateRoutes(root: string): Promise<void> {
82
+ const routes = await scanFunctions(root)
83
+
84
+ const vibesDir = path.join(root, ".vibes")
85
+ if (!fs.existsSync(vibesDir)) {
86
+ fs.mkdirSync(vibesDir, { recursive: true })
87
+ }
88
+
89
+ const routesPath = path.join(vibesDir, "routes.json")
90
+ fs.writeFileSync(routesPath, JSON.stringify(routes, null, 2), "utf-8")
91
+ console.log(`[vibes:codegen] Generated ${routesPath} with ${routes.length} route(s)`)
92
+ }
93
+
94
+ // ── regenerateTriggers ────────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Scans functions/ for `cron()` and `on()` calls and writes .vibes/triggers.json
98
+ * consumed by @omg-dev/server's scheduler (dev) and by the orchestrator's queue
99
+ * runtime (prod). Throws TriggerScanError on malformed declarations so the
100
+ * user sees the failure at build / HMR time rather than silently losing a
101
+ * trigger.
102
+ */
103
+ export async function regenerateTriggers(root: string): Promise<void> {
104
+ const triggers = await scanTriggers(root)
105
+ const vibesDir = path.join(root, ".vibes")
106
+ if (!fs.existsSync(vibesDir)) {
107
+ fs.mkdirSync(vibesDir, { recursive: true })
108
+ }
109
+ const triggersPath = path.join(vibesDir, "triggers.json")
110
+ fs.writeFileSync(triggersPath, JSON.stringify(triggers, null, 2), "utf-8")
111
+ console.log(`[vibes:codegen] Generated ${triggersPath} with ${triggers.length} trigger(s)`)
112
+ }
113
+
114
+ // ── regenerateWorkflows ───────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * Scans functions/ for `workflow()` declarations and writes
118
+ * .vibes/workflows.json — consumed by @omg-dev/server's dev engine and the
119
+ * prod Restate endpoint, and read by the orchestrator at publish to decide
120
+ * whether to register a workflow deployment. Throws WorkflowScanError on
121
+ * malformed declarations (non-literal name, duplicate names).
122
+ */
123
+ export async function regenerateWorkflows(root: string): Promise<void> {
124
+ const workflows = await scanWorkflows(root)
125
+ const vibesDir = path.join(root, ".vibes")
126
+ if (!fs.existsSync(vibesDir)) {
127
+ fs.mkdirSync(vibesDir, { recursive: true })
128
+ }
129
+ const workflowsPath = path.join(vibesDir, "workflows.json")
130
+ fs.writeFileSync(workflowsPath, JSON.stringify(workflows, null, 2), "utf-8")
131
+ if (workflows.length > 0) {
132
+ console.log(`[vibes:codegen] Generated ${workflowsPath} with ${workflows.length} workflow(s)`)
133
+ }
134
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Error sink writer used by the vibes vite plugin in dev mode.
3
+ *
4
+ * Both server (Vite logger) and runtime (in-iframe reporter) errors get
5
+ * appended as JSONL to <root>/.vibes/errors.jsonl. The agent-server
6
+ * inside the sandbox tails this file between turns and splices new
7
+ * entries into pi's user message.
8
+ *
9
+ * Pure helpers — no plugin/Vite types — so they can be unit-tested.
10
+ */
11
+
12
+ import fs from "node:fs"
13
+ import path from "node:path"
14
+
15
+ export interface ErrorSinkEntry {
16
+ kind: "server" | "runtime"
17
+ source: string
18
+ message: string
19
+ stack?: string
20
+ file?: unknown
21
+ line?: unknown
22
+ col?: unknown
23
+ viaConsole?: unknown
24
+ rejection?: unknown
25
+ }
26
+
27
+ const MAX_BYTES_BEFORE_ROTATE = 1_048_576 // 1 MB
28
+
29
+ export function errorsPathFor(root: string): string {
30
+ return path.join(root, ".vibes", "errors.jsonl")
31
+ }
32
+
33
+ /**
34
+ * Append one entry to the sink. The `at` timestamp is stamped here so
35
+ * the file is the authoritative ordering — callers must not pre-stamp.
36
+ *
37
+ * Errors are logged but not rethrown — the dev server must keep running
38
+ * even if the sink is broken. The previous version swallowed silently,
39
+ * which made a failing writer indistinguishable from "no errors yet".
40
+ */
41
+ export function appendErrorEntry(
42
+ errorsPath: string,
43
+ entry: ErrorSinkEntry,
44
+ ): void {
45
+ try {
46
+ fs.mkdirSync(path.dirname(errorsPath), { recursive: true })
47
+ const line = JSON.stringify({ at: Date.now(), ...entry }) + "\n"
48
+ fs.appendFileSync(errorsPath, line)
49
+ const stat = fs.statSync(errorsPath)
50
+ if (stat.size > MAX_BYTES_BEFORE_ROTATE) {
51
+ fs.renameSync(errorsPath, errorsPath + ".1")
52
+ }
53
+ } catch (err) {
54
+ // Surface the failure to stderr so a missing-file mystery is
55
+ // diagnosable from /tmp/agent-server.log or the dev process log.
56
+ console.error(
57
+ `[vibes:error-sink] failed to append to ${errorsPath}:`,
58
+ err instanceof Error ? err.message : err,
59
+ )
60
+ }
61
+ }
62
+
63
+ /** Drop ANSI color codes from Vite's rendered logger output. */
64
+ export function stripAnsi(s: string): string {
65
+ return s.replace(/\x1b\[[0-9;]*m/g, "")
66
+ }