@saastemly/voidcommerce 0.1.0

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 (62) hide show
  1. package/README.md +271 -0
  2. package/bin/vc +2 -0
  3. package/dist/catalog.d.ts +69 -0
  4. package/dist/catalog.js +34 -0
  5. package/dist/cli.d.ts +24 -0
  6. package/dist/cli.js +544 -0
  7. package/dist/deploy/cloudflare.d.ts +25 -0
  8. package/dist/deploy/index.d.ts +16 -0
  9. package/dist/deploy/jsonc.d.ts +8 -0
  10. package/dist/deploy/preflight.d.ts +29 -0
  11. package/dist/deploy/wrangler.d.ts +37 -0
  12. package/dist/dist.d.ts +22 -0
  13. package/dist/generate/auth.d.ts +2 -0
  14. package/dist/generate/ci.d.ts +24 -0
  15. package/dist/generate/env.d.ts +13 -0
  16. package/dist/generate/frontend.d.ts +47 -0
  17. package/dist/generate/index.d.ts +28 -0
  18. package/dist/generate/requirements.d.ts +13 -0
  19. package/dist/generate/strict.d.ts +72 -0
  20. package/dist/generate/support.d.ts +23 -0
  21. package/dist/help.d.ts +31 -0
  22. package/dist/import.d.ts +2 -0
  23. package/dist/index-s7sq41qs.js +590 -0
  24. package/dist/index-ssv3a6wc.js +172 -0
  25. package/dist/index-wzy1xtr1.js +3155 -0
  26. package/dist/index.d.ts +24 -0
  27. package/dist/index.js +190 -0
  28. package/dist/init.d.ts +1 -0
  29. package/dist/manifest.d.ts +131 -0
  30. package/dist/manifest.js +41 -0
  31. package/dist/project.d.ts +20 -0
  32. package/dist/regenerate.d.ts +9 -0
  33. package/dist/scripts.d.ts +12 -0
  34. package/dist/void.d.ts +30 -0
  35. package/dist/wizard.d.ts +7 -0
  36. package/package.json +50 -0
  37. package/src/catalog.ts +673 -0
  38. package/src/cli.ts +78 -0
  39. package/src/deploy/cloudflare.ts +166 -0
  40. package/src/deploy/index.ts +101 -0
  41. package/src/deploy/jsonc.ts +148 -0
  42. package/src/deploy/preflight.ts +137 -0
  43. package/src/deploy/wrangler.ts +111 -0
  44. package/src/dist.ts +157 -0
  45. package/src/generate/auth.ts +386 -0
  46. package/src/generate/ci.ts +208 -0
  47. package/src/generate/env.ts +164 -0
  48. package/src/generate/frontend.ts +275 -0
  49. package/src/generate/index.ts +390 -0
  50. package/src/generate/requirements.ts +48 -0
  51. package/src/generate/strict.ts +692 -0
  52. package/src/generate/support.ts +252 -0
  53. package/src/help.ts +172 -0
  54. package/src/import.ts +237 -0
  55. package/src/index.ts +37 -0
  56. package/src/init.ts +187 -0
  57. package/src/manifest.ts +303 -0
  58. package/src/project.ts +63 -0
  59. package/src/regenerate.ts +51 -0
  60. package/src/scripts.ts +53 -0
  61. package/src/void.ts +115 -0
  62. package/src/wizard.ts +234 -0
@@ -0,0 +1,692 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { type Manifest, has, packagesOf, specifier } from "../manifest";
4
+ import { runVoid } from "../void";
5
+
6
+ /**
7
+ * Strict layout — EXPERIMENTAL.
8
+ *
9
+ * The repository is the manifest plus what the manifest cannot express. The
10
+ * Void app is an artifact under `.vc/app`, gitignored, rewritten by
11
+ * `vc generate`, never hand-edited: a hand edit to a generated app drifts
12
+ * from the thing it exists to describe, and strict is the mode that refuses
13
+ * to let it.
14
+ *
15
+ * What is committed, and why each cannot be derived:
16
+ *
17
+ * voidcommerce.json the answers
18
+ * package.json one install for the artifact, which resolves modules
19
+ * through a node_modules symlink
20
+ * data/ the catalogue, pushed by `vc import`
21
+ * content/ launch FAQ and posts, copied in and wired at generate
22
+ * branding/ static assets, copied to public/branding/
23
+ * migrations/ what production has already applied. A migration
24
+ * regenerated on a fresh clone gets a new name, and a
25
+ * deploy would try to apply it again — so they live
26
+ * here and the app reaches them through a symlink.
27
+ * patches/ a void fix that has not shipped upstream
28
+ * .env local values, symlinked into the app
29
+ *
30
+ * The app's skeleton below is what `void init` and the panel's host files
31
+ * come to in the reference shop, with the copied shadcn components replaced
32
+ * by their package imports.
33
+ */
34
+
35
+ export const STRICT_APP = join(".vc", "app");
36
+
37
+ /** The one version the vendored patch applies to. */
38
+ export const VOID_VERSION = "0.10.13";
39
+
40
+ export function strictDependencies(manifest: Manifest): {
41
+ dependencies: Record<string, string>;
42
+ devDependencies: Record<string, string>;
43
+ patchedDependencies: Record<string, string>;
44
+ } {
45
+ const dependencies: Record<string, string> = {};
46
+ for (const name of [
47
+ ...packagesOf(manifest),
48
+ "@saastemly/better-admin-ui",
49
+ "@better-auth-ui/core",
50
+ "@better-auth-ui/react",
51
+ "@radix-ui/react-tooltip",
52
+ "@tanstack/react-query",
53
+ "hono",
54
+ "react",
55
+ "react-dom",
56
+ "react-router",
57
+ "sonner",
58
+ "zod",
59
+ ])
60
+ dependencies[name] = specifier(manifest, name);
61
+ const devDependencies: Record<string, string> = {};
62
+ for (const name of [
63
+ // The storefront at the root prerenders on the node target, whose SSR
64
+ // bundle imports this. Absent, a cold install builds nothing.
65
+ "@hono/node-server",
66
+ "@rolldown/plugin-babel",
67
+ "@tailwindcss/vite",
68
+ "@types/node",
69
+ "@types/react",
70
+ "@types/react-dom",
71
+ "@vitejs/plugin-react",
72
+ "babel-plugin-react-compiler",
73
+ "maildev",
74
+ "tailwindcss",
75
+ "tw-animate-css",
76
+ "typescript",
77
+ "vite",
78
+ "vite-plus",
79
+ ])
80
+ devDependencies[name] = specifier(manifest, name);
81
+ // The repository's own tool: CI runs `bunx vc dist`, so it must be installable.
82
+ devDependencies["@saastemly/voidcommerce"] = specifier(manifest, "@saastemly/voidcommerce");
83
+ devDependencies["void"] = VOID_VERSION;
84
+ devDependencies["@void/react"] = VOID_VERSION;
85
+ return {
86
+ dependencies: Object.fromEntries(Object.entries(dependencies).sort()),
87
+ devDependencies: Object.fromEntries(Object.entries(devDependencies).sort()),
88
+ patchedDependencies: { [`void@${VOID_VERSION}`]: `patches/void@${VOID_VERSION}.patch` },
89
+ };
90
+ }
91
+
92
+ /**
93
+ * void's `appendAuthHeaders` writes to immutable headers on Vite's own
94
+ * dev-asset responses, which 500s every page of a pages-mode app with auth
95
+ * enabled. Unreported upstream (the repo is private); this is the guard.
96
+ */
97
+ export function renderVoidPatch(): string {
98
+ return `diff --git a/dist/better-auth-shared-BQooDxbw.mjs b/dist/better-auth-shared-BQooDxbw.mjs
99
+ index dffd76b7f502f273daf2c63cef3ad8c21b90e68b..454ba59eee004d34616f717b12a770c111b61762 100644
100
+ --- a/dist/better-auth-shared-BQooDxbw.mjs
101
+ +++ b/dist/better-auth-shared-BQooDxbw.mjs
102
+ @@ -82,11 +82,17 @@ function resolveSocialProviders(env, configuredProviders) {
103
+ function appendAuthHeaders(target, source) {
104
+ if (!source) return;
105
+ source.forEach((value, key) => {
106
+ - if (key.toLowerCase() === "set-cookie") {
107
+ - target.append(key, value);
108
+ - return;
109
+ + try {
110
+ + if (key.toLowerCase() === "set-cookie") {
111
+ + target.append(key, value);
112
+ + return;
113
+ + }
114
+ + target.set(key, value);
115
+ + } catch {
116
+ + // Immutable response headers (e.g. Vite dev asset responses in
117
+ + // pages mode). Auth headers are irrelevant for those responses;
118
+ + // dropping them beats throwing a 500 for the whole request.
119
+ }
120
+ - target.set(key, value);
121
+ });
122
+ }
123
+ function resolveUserConfig(userConfig, defaults, request, env, dialect) {
124
+ `;
125
+ }
126
+
127
+ export function renderViteConfig(manifest: Manifest): string {
128
+ const clients = [
129
+ ...(has(manifest, "passkey") ? [`"@better-auth/passkey/client"`] : []),
130
+ ...(has(manifest, "api-key") ? [`"@better-auth/api-key/client"`] : []),
131
+ ];
132
+ const list = (items: string[]) => items.map((item) => ` ${item},`).join("\n");
133
+ return `// GENERATED by \`vc generate\`. Do not edit: this file is rewritten from voidcommerce.json.
134
+ import babel from "@rolldown/plugin-babel";
135
+ import tailwindcss from "@tailwindcss/vite";
136
+ import { reactCompilerPreset } from "@vitejs/plugin-react";
137
+ import path from "node:path";
138
+ import { defineConfig } from "vite";
139
+ import { voidPlugin } from "void";
140
+ import { voidReact } from "@void/react/plugin";
141
+
142
+ export default defineConfig({
143
+ // The worker answers CORS, not Vite — same behaviour locally as deployed.
144
+ server: { cors: false },
145
+ preview: { cors: false },
146
+ // Pre-bundle react-hook-form as its own entry, or Vite inlines a second
147
+ // copy inside ra-core and every list screen with filters dies on
148
+ // "useFormContext(...) is null".
149
+ optimizeDeps: {
150
+ include: [
151
+ "react-hook-form",
152
+ "ra-core",
153
+ "react-router",
154
+ "@tanstack/react-query",
155
+ ${list(clients)}
156
+ ],
157
+ },
158
+ // The auth client's plugins are evaluated during SSR inside workerd, which
159
+ // resolves through Vite's runner rather than from disk.
160
+ ssr: {
161
+ optimizeDeps: {
162
+ include: [
163
+ ${list(clients)}
164
+ ],
165
+ },
166
+ },
167
+ resolve: {
168
+ alias: [
169
+ { find: "@", replacement: path.resolve(import.meta.dirname, ".") },
170
+ // Vite 8 cannot resolve this export from app source; point it at the built file.
171
+ {
172
+ find: /^better-auth\\/client\\/plugins$/,
173
+ replacement: path.resolve(import.meta.dirname, "node_modules/better-auth/dist/client/plugins/index.mjs"),
174
+ },
175
+ ],
176
+ // One copy of everything that carries React context or module-level state.
177
+ dedupe: [
178
+ "react",
179
+ "react-dom",
180
+ "react-router",
181
+ "ra-core",
182
+ "react-hook-form",
183
+ "@tanstack/react-query",
184
+ "@better-auth-ui/core",
185
+ "@better-auth-ui/react",
186
+ "next-themes",
187
+ ],
188
+ },
189
+ plugins: [babel({ presets: [reactCompilerPreset()] }), tailwindcss(), voidPlugin(), voidReact()],
190
+ });
191
+ `;
192
+ }
193
+
194
+ export function renderStrictTsconfig(): string {
195
+ return `{
196
+ "extends": "./.void/tsconfig.json",
197
+ "compilerOptions": {
198
+ "target": "ES2022",
199
+ "module": "ESNext",
200
+ "moduleResolution": "bundler",
201
+ "resolveJsonModule": true,
202
+ "strict": true,
203
+ "esModuleInterop": true,
204
+ "skipLibCheck": true,
205
+ "noEmit": true,
206
+ "allowImportingTsExtensions": true,
207
+ "types": ["void/env"],
208
+ "jsx": "react-jsx",
209
+ "paths": {
210
+ "@/*": ["./*"],
211
+ "void/db": ["./.void/db.d.ts"],
212
+ "void/routes": ["./.void/routes.d.ts"],
213
+ "@schema": ["./db/schema.ts"],
214
+ "@schema/*": ["./db/schema/*"],
215
+ "better-auth": ["./node_modules/better-auth"],
216
+ "better-auth/*": ["./node_modules/better-auth/*"],
217
+ "@better-auth/core": ["./node_modules/@better-auth/core"],
218
+ "@better-auth/core/*": ["./node_modules/@better-auth/core/*"]
219
+ }
220
+ },
221
+ "include": ["src", "pages", "routes", "middleware", "crons", "queues", "db", "lib", "auth.ts", "env.ts"]
222
+ }
223
+ `;
224
+ }
225
+
226
+ export function renderVoidJson(): string {
227
+ return `{
228
+ "$schema": "./node_modules/void/schema.json",
229
+ "worker": {
230
+ "compatibility_date": "2026-09-01"
231
+ },
232
+ "auth": {
233
+ "providers": ["email"]
234
+ }
235
+ }
236
+ `;
237
+ }
238
+
239
+ export function renderDbSchema(): string {
240
+ return `/**
241
+ * Re-export the Better Auth + betterCommerce schema so \`void db generate\`
242
+ * emits migrations for it.
243
+ *
244
+ * Deploying to your OWN Cloudflare account never creates the auth tables at
245
+ * runtime — the checked-in migrations are what production applies. Without
246
+ * this line the app deploys with no \`user\` table. \`.void/better-auth-schema\`
247
+ * is written by \`void prepare\`, which \`vc generate\` runs.
248
+ */
249
+ export * from "../.void/better-auth-schema";
250
+ `;
251
+ }
252
+
253
+ export function renderDbSeed(): string {
254
+ return `import { defineSeed } from "void/seed";
255
+
256
+ /** Nothing: the catalogue is IMPORTED by \`vc import\` from data/, the same way in development and production. */
257
+ export default defineSeed<typeof import("./schema")>(async () => {
258
+ console.log("nothing to seed — run vc import");
259
+ });
260
+ `;
261
+ }
262
+
263
+ export function renderLayoutTsx(): string {
264
+ return `import type { ComponentProps, ComponentPropsWithoutRef, ComponentType, PropsWithChildren } from "react";
265
+ import { Link, useRouter } from "@void/react";
266
+ import { AuthProvider } from "@better-auth-ui/react";
267
+ import { adminPlugin } from "@better-auth-ui/core/plugins/admin";
268
+ import { apiDashboardPlugin } from "@saastemly/better-admin-ui/dashboard";
269
+ import { TooltipProvider } from "@radix-ui/react-tooltip";
270
+ import { Toaster } from "sonner";
271
+ import { authClient } from "@/lib/auth-client";
272
+ import "./app.css";
273
+
274
+ declare module "@better-auth-ui/core" {
275
+ interface AuthConfig {
276
+ /** The component internal links render with — Void's. */
277
+ Link: ComponentType<
278
+ PropsWithChildren<
279
+ { className?: string; href: string; to?: string } & Pick<ComponentPropsWithoutRef<"a">, "aria-disabled" | "tabIndex" | "onClick">
280
+ >
281
+ >;
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Providers in, chrome out. The whole interface is /api-dashboard, generated
287
+ * from the schema; this layout only wires the auth context it reads.
288
+ */
289
+ export default function RootLayout({ children }: PropsWithChildren) {
290
+ const router = useRouter();
291
+ return (
292
+ <TooltipProvider>
293
+ <AuthProvider
294
+ authClient={authClient}
295
+ navigate={({ to, replace }) => router.visit(to, { replace })}
296
+ redirectTo="/api-dashboard"
297
+ Link={({ href, onClick, ...props }) => (
298
+ <Link href={href} {...props} onClick={onClick as ComponentProps<typeof Link>["onClick"]} />
299
+ )}
300
+ plugins={[adminPlugin(), apiDashboardPlugin()]}
301
+ >
302
+ {children}
303
+ <Toaster />
304
+ </AuthProvider>
305
+ </TooltipProvider>
306
+ );
307
+ }
308
+ `;
309
+ }
310
+
311
+ export function renderAppCss(): string {
312
+ return `@import "tailwindcss";
313
+ /* The panel ships built; Tailwind must scan the package for its class names. */
314
+ @source "../node_modules/better-admin-ui/dist";
315
+ @import "tw-animate-css";
316
+
317
+ @custom-variant dark (&:is(.dark *));
318
+
319
+ :root {
320
+ --radius: 0.625rem;
321
+ --background: oklch(1 0 0);
322
+ --foreground: oklch(0.145 0 0);
323
+ --card: oklch(1 0 0);
324
+ --card-foreground: oklch(0.145 0 0);
325
+ --popover: oklch(1 0 0);
326
+ --popover-foreground: oklch(0.145 0 0);
327
+ --primary: oklch(0.205 0 0);
328
+ --primary-foreground: oklch(0.985 0 0);
329
+ --secondary: oklch(0.97 0 0);
330
+ --secondary-foreground: oklch(0.205 0 0);
331
+ --muted: oklch(0.97 0 0);
332
+ --muted-foreground: oklch(0.556 0 0);
333
+ --accent: oklch(0.97 0 0);
334
+ --accent-foreground: oklch(0.205 0 0);
335
+ --destructive: oklch(0.577 0.245 27.325);
336
+ --border: oklch(0.922 0 0);
337
+ --input: oklch(0.922 0 0);
338
+ --ring: oklch(0.708 0 0);
339
+ }
340
+
341
+ .dark {
342
+ --background: oklch(0.145 0 0);
343
+ --foreground: oklch(0.985 0 0);
344
+ --card: oklch(0.205 0 0);
345
+ --card-foreground: oklch(0.985 0 0);
346
+ --popover: oklch(0.205 0 0);
347
+ --popover-foreground: oklch(0.985 0 0);
348
+ --primary: oklch(0.922 0 0);
349
+ --primary-foreground: oklch(0.205 0 0);
350
+ --secondary: oklch(0.269 0 0);
351
+ --secondary-foreground: oklch(0.985 0 0);
352
+ --muted: oklch(0.269 0 0);
353
+ --muted-foreground: oklch(0.708 0 0);
354
+ --accent: oklch(0.269 0 0);
355
+ --accent-foreground: oklch(0.985 0 0);
356
+ --destructive: oklch(0.704 0.191 22.216);
357
+ --border: oklch(1 0 0 / 10%);
358
+ --input: oklch(1 0 0 / 15%);
359
+ --ring: oklch(0.556 0 0);
360
+ }
361
+
362
+ @theme inline {
363
+ --radius-sm: calc(var(--radius) - 4px);
364
+ --radius-md: calc(var(--radius) - 2px);
365
+ --radius-lg: var(--radius);
366
+ --radius-xl: calc(var(--radius) + 4px);
367
+ --color-background: var(--background);
368
+ --color-foreground: var(--foreground);
369
+ --color-card: var(--card);
370
+ --color-card-foreground: var(--card-foreground);
371
+ --color-popover: var(--popover);
372
+ --color-popover-foreground: var(--popover-foreground);
373
+ --color-primary: var(--primary);
374
+ --color-primary-foreground: var(--primary-foreground);
375
+ --color-secondary: var(--secondary);
376
+ --color-secondary-foreground: var(--secondary-foreground);
377
+ --color-muted: var(--muted);
378
+ --color-muted-foreground: var(--muted-foreground);
379
+ --color-accent: var(--accent);
380
+ --color-accent-foreground: var(--accent-foreground);
381
+ --color-destructive: var(--destructive);
382
+ --color-border: var(--border);
383
+ --color-input: var(--input);
384
+ --color-ring: var(--ring);
385
+ }
386
+
387
+ @layer base {
388
+ * {
389
+ @apply border-border outline-ring/50;
390
+ }
391
+ body {
392
+ @apply bg-background text-foreground;
393
+ }
394
+ }
395
+ `;
396
+ }
397
+
398
+ export function renderIndexServer(): string {
399
+ return `import { defineHandler } from "void";
400
+
401
+ /** No home page: the interface is /api-dashboard, so send a browser there. */
402
+ export const loader = defineHandler((c) => c.redirect("/api-dashboard"));
403
+ `;
404
+ }
405
+
406
+ /** Void routes a page only when the component file exists; the loader above answers before this renders. */
407
+ export const INDEX_PAGE = `/** Never rendered: index.server.ts redirects to /api-dashboard first. */
408
+ export default function HomePage() {
409
+ return null;
410
+ }
411
+ `;
412
+
413
+ export function renderDashboardPage(manifest: Manifest): string {
414
+ return `import { lazy, Suspense } from "react";
415
+
416
+ /**
417
+ * Mounted, not owned. The panel builds itself from the resolved schema, and
418
+ * what a caller sees is the result of authorization — so no session check
419
+ * here: a visitor gets the public surface, a customer their rows, staff all.
420
+ * Lazy, because the panel's module graph must not reach the worker.
421
+ */
422
+ const Dashboard = lazy(async () => {
423
+ const [{ ApiDashboard }, { authClient }] = await Promise.all([import("@saastemly/better-admin-ui/dashboard"), import("@/lib/auth-client")]);
424
+ return {
425
+ default: () => <ApiDashboard fetch={authClient.$fetch} title=${JSON.stringify(manifest.shop.name)} signInPath="/sign-in" />,
426
+ };
427
+ });
428
+
429
+ export default function ApiDashboardPage() {
430
+ return (
431
+ <Suspense fallback={<p className="text-muted-foreground p-6 text-sm">Generating the dashboard…</p>}>
432
+ <Dashboard />
433
+ </Suspense>
434
+ );
435
+ }
436
+ `;
437
+ }
438
+
439
+ export const CLIENT_ONLY = `// Client-mounted: the kit's module graph must stay out of the Cloudflare worker.
440
+ export const ssr = false;
441
+ `;
442
+
443
+ export function renderSignInPage(manifest: Manifest): string {
444
+ const socials = (["google", "apple", "github"] as const)
445
+ .filter((id) => has(manifest, id))
446
+ .map((id) => `{ id: "${id}", label: "${id[0]!.toUpperCase()}${id.slice(1)}" }`);
447
+ return `import { lazy, Suspense } from "react";
448
+
449
+ /** The panel's front door, drawn from what the auth client can actually do. */
450
+ const SignIn = lazy(async () => {
451
+ const [{ ApiSignIn }, { authClient }] = await Promise.all([import("@saastemly/better-admin-ui/dashboard"), import("@/lib/auth-client")]);
452
+ return {
453
+ default: () => (
454
+ <ApiSignIn authClient={authClient} callbackURL="/api-dashboard" title=${JSON.stringify(manifest.shop.name)} socialProviders={[${socials.join(", ")}]} />
455
+ ),
456
+ };
457
+ });
458
+
459
+ export default function SignInPage() {
460
+ return (
461
+ <Suspense fallback={null}>
462
+ <SignIn />
463
+ </Suspense>
464
+ );
465
+ }
466
+ `;
467
+ }
468
+
469
+ export function renderAuthClient(manifest: Manifest): string {
470
+ const imports = [
471
+ ...(has(manifest, "api-key") ? [`import { apiKeyClient } from "@better-auth/api-key/client";`] : []),
472
+ ...(has(manifest, "passkey") ? [`import { passkeyClient } from "@better-auth/passkey/client";`] : []),
473
+ `import { commerceClient } from "@saastemly/better-commerce/client";`,
474
+ `import { createAuthClient } from "void/client/react";`,
475
+ ];
476
+ const plugins = [
477
+ "commerceClient()",
478
+ ...(has(manifest, "passkey") ? ["passkeyClient()"] : []),
479
+ ...(has(manifest, "api-key") ? ["apiKeyClient()"] : []),
480
+ ];
481
+ return `${imports.join("\n")}
482
+
483
+ /**
484
+ * The client plugins that mirror the server's, so typed calls are inferred.
485
+ * Magic link and email codes are plain POSTs the sign-in screen makes with
486
+ * \`$fetch\`; they need no client plugin.
487
+ */
488
+ export const authClient = createAuthClient({
489
+ basePath: "/api/auth",
490
+ plugins: [${plugins.join(", ")}],
491
+ });
492
+ `;
493
+ }
494
+
495
+ /** Launch content, copied from content/ at generate time and typed against the plugins that take it. */
496
+ export function renderContentTs(manifest: Manifest): string {
497
+ const parts: string[] = [];
498
+ if (has(manifest, "faqs")) {
499
+ parts.push(`import type { faqs } from "@saastemly/better-faqs";
500
+ import faqsJson from "../content/faqs.json" with { type: "json" };
501
+ type FaqEntries = NonNullable<NonNullable<Parameters<typeof faqs>[0]>["entries"]>;
502
+ export const faqEntries = faqsJson as FaqEntries;`);
503
+ }
504
+ if (has(manifest, "blogs")) {
505
+ parts.push(`import type { blogs } from "@saastemly/better-blogs";
506
+ import postsJson from "../content/posts.json" with { type: "json" };
507
+ type Posts = NonNullable<NonNullable<Parameters<typeof blogs>[0]>["posts"]>;
508
+ export const posts = postsJson as Posts;`);
509
+ }
510
+ return `// GENERATED by \`vc generate\` from content/. Edit content/*.json, not this.
511
+ ${parts.join("\n\n")}
512
+ `;
513
+ }
514
+
515
+ export function renderCron(): string {
516
+ return `import { defineScheduled } from "void";
517
+ import { shopDomain } from "../lib/domain.ts";
518
+
519
+ /**
520
+ * The commerce cron runs the work the plugins say is TIME-driven. It asks
521
+ * the job registry what exists and runs what matches the expression that
522
+ * fired, so installing a plugin brings its scheduled work with it. Reactive
523
+ * work is the queue's; the daily pass is the reconciliation backstop.
524
+ */
525
+ export const cron = ["0 * * * *", "0 3 * * *", "0 4 * * *"];
526
+
527
+ interface Job {
528
+ id: string;
529
+ schedule: string | null;
530
+ }
531
+
532
+ export default defineScheduled(async (controller, env) => {
533
+ const shop = String(env["SHOP_DOMAIN"] ?? "");
534
+ const appUrl = String(env["APP_URL"] ?? (shop ? shopDomain(shop).appUrl : "http://localhost:5173"));
535
+ const headers = { "content-type": "application/json", origin: appUrl, "x-commerce-cron-secret": String(env["COMMERCE_CRON_SECRET"] ?? "") };
536
+
537
+ const listed = await fetch(new URL("/api/auth/commerce/admin/jobs", appUrl), { headers });
538
+ if (!listed.ok) {
539
+ console.error(\`[commerce-cron] cannot read the job registry: \${listed.status}\`);
540
+ return;
541
+ }
542
+ const { jobs } = (await listed.json()) as { jobs: Job[] };
543
+ for (const job of jobs.filter((job) => job.schedule === controller.cron)) {
544
+ try {
545
+ const res = await fetch(new URL("/api/auth/commerce/admin/jobs/run", appUrl), { method: "POST", headers, body: JSON.stringify({ jobId: job.id }) });
546
+ console.log(\`[commerce-cron] \${job.id} → \${res.status}\`);
547
+ } catch (error) {
548
+ // One failing job must not strand the ones after it.
549
+ console.error(\`[commerce-cron] \${job.id} threw\`, error);
550
+ }
551
+ }
552
+ });
553
+ `;
554
+ }
555
+
556
+ export function renderQueue(): string {
557
+ return `import { defineQueue } from "void";
558
+ import { shopDomain } from "../lib/domain.ts";
559
+
560
+ /**
561
+ * One consumer for the whole plugin ring: the message names the job, the
562
+ * registry knows how to run it. A message is a nudge, not the record — the
563
+ * rows are — so a lost message costs latency until the sweep, never the work.
564
+ */
565
+ interface CommerceJobMessage {
566
+ jobId: string;
567
+ payload?: Record<string, unknown>;
568
+ }
569
+
570
+ export const maxBatchSize = 10;
571
+ export const maxBatchTimeout = 2;
572
+ export const maxRetries = 3;
573
+ export const retryDelay = 30;
574
+
575
+ export default defineQueue<CommerceJobMessage>(async (batch, env) => {
576
+ const shop = String(env["SHOP_DOMAIN"] ?? "");
577
+ const appUrl = String(env["APP_URL"] ?? (shop ? shopDomain(shop).appUrl : "http://localhost:5173"));
578
+ const headers = { "content-type": "application/json", origin: appUrl, "x-commerce-cron-secret": String(env["COMMERCE_CRON_SECRET"] ?? "") };
579
+
580
+ // Ten events in a burst all ask for the same job, which drains every due row in one run.
581
+ const wanted = new Map<string, CommerceJobMessage>();
582
+ for (const message of batch.messages) if (message.body?.jobId) wanted.set(message.body.jobId, message.body);
583
+
584
+ for (const [jobId, body] of wanted) {
585
+ try {
586
+ const res = await fetch(new URL("/api/auth/commerce/admin/jobs/run", appUrl), { method: "POST", headers, body: JSON.stringify({ jobId, payload: body.payload }) });
587
+ if (!res.ok) throw new Error(\`\${res.status} \${(await res.text()).slice(0, 200)}\`);
588
+ } catch (error) {
589
+ console.error(\`[commerce-queue] \${jobId} failed, retrying\`, error);
590
+ for (const message of batch.messages) if (message.body?.jobId === jobId) message.retry({ delaySeconds: retryDelay });
591
+ continue;
592
+ }
593
+ for (const message of batch.messages) if (message.body?.jobId === jobId) message.ack();
594
+ }
595
+ });
596
+ `;
597
+ }
598
+
599
+ export function renderLiveStream(): string {
600
+ return `import { defineLiveStream } from "void/live";
601
+
602
+ /**
603
+ * The app's one live stream. Topics are minted by the plugins; this owns the
604
+ * transport. In src/ because that is where Void scans for the Durable Object
605
+ * binding — defined elsewhere, every connection fails with a missing binding.
606
+ * Every subscription is checked with the commerce plugin and fails closed.
607
+ */
608
+ export const live = defineLiveStream({
609
+ id: "commerce",
610
+ allowAnonymousControl: true,
611
+ async onSubscribe({ request, topic }) {
612
+ const deny = () => new Response(JSON.stringify({ error: "not allowed to subscribe to that topic" }), { status: 403, headers: { "content-type": "application/json" } });
613
+ try {
614
+ const res = await fetch(new URL("/api/auth/commerce/streams/authorize", request.url), {
615
+ method: "POST",
616
+ headers: { "content-type": "application/json", origin: new URL(request.url).origin, cookie: request.headers.get("cookie") ?? "" },
617
+ body: JSON.stringify({ topic }),
618
+ });
619
+ if (!res.ok) return deny();
620
+ return (await res.json() as { allowed?: boolean }).allowed === true ? undefined : deny();
621
+ } catch {
622
+ return deny();
623
+ }
624
+ },
625
+ });
626
+ `;
627
+ }
628
+
629
+ export function renderLiveRoute(): string {
630
+ return `import { defineHandler } from "void";
631
+ import { live } from "../src/live";
632
+
633
+ /** GET opens the SSE connection, POST carries subscribe/unsubscribe. */
634
+ export const GET = defineHandler((c) => live.connect(c));
635
+ export const POST = defineHandler((c) => live.control(c));
636
+ `;
637
+ }
638
+
639
+ export function renderStrictAppPackageJson(manifest: Manifest): string {
640
+ const deps = strictDependencies(manifest);
641
+ return `${JSON.stringify(
642
+ {
643
+ name: `${manifest.shop.domain.split(".")[0]}-app`,
644
+ private: true,
645
+ type: "module",
646
+ // GENERATED. Dependencies are the root's, resolved through the node_modules symlink.
647
+ scripts: { dev: "vp dev", build: "vp build", preview: "vp preview" },
648
+ dependencies: deps.dependencies,
649
+ devDependencies: deps.devDependencies,
650
+ },
651
+ null,
652
+ 2,
653
+ )}\n`;
654
+ }
655
+
656
+ export const DATA_README = `# data/
657
+
658
+ The catalogue: \`catalog.json\` and \`categories.json\`, pushed into the running
659
+ shop by \`vc import\` — the same operation in development and production. The
660
+ worker never carries a copy.
661
+ `;
662
+
663
+ export const BRANDING_README = `# branding/
664
+
665
+ Static assets — logo, favicon, images. Copied to \`public/branding/\` at
666
+ generate time and served at \`/branding/<file>\`.
667
+ `;
668
+
669
+ export const MIGRATIONS_README = `# migrations/
670
+
671
+ What production has already applied. \`void db generate\` writes here through
672
+ \`.vc/app/db/migrations\`, and a deploy verifies these produce the schema.
673
+ Commit them: a migration regenerated on a fresh clone would get a new name,
674
+ and the deploy would try to apply it twice.
675
+ `;
676
+
677
+ /**
678
+ * After the files: let void generate its artifacts and the migrations, so
679
+ * the app typechecks and deploys. Needs an install; says so otherwise.
680
+ */
681
+ export async function finishStrict(root: string): Promise<number> {
682
+ if (!existsSync(join(root, "node_modules"))) {
683
+ console.log("vc: no node_modules yet — after `bun install`, run `vc generate` to prepare the app.");
684
+ return 0;
685
+ }
686
+ const app = join(root, STRICT_APP);
687
+ for (const args of [["prepare"], ["db", "generate"]]) {
688
+ const code = await runVoid(args, app);
689
+ if (code !== 0) return code;
690
+ }
691
+ return 0;
692
+ }