@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,111 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { delimiter, dirname, join } from "node:path";
4
+
5
+ /**
6
+ * wrangler, as vc uses it: found, asked who it is, and driven for the
7
+ * account operations a deploy needs. Nothing here needs a Void account.
8
+ */
9
+
10
+ export function findWrangler(from = process.cwd()): string | null {
11
+ let dir = from;
12
+ for (;;) {
13
+ const local = join(dir, "node_modules", ".bin", "wrangler");
14
+ if (existsSync(local)) return local;
15
+ const parent = dirname(dir);
16
+ if (parent === dir) break;
17
+ dir = parent;
18
+ }
19
+ const onPath = (process.env["PATH"] ?? "")
20
+ .split(delimiter)
21
+ .filter(Boolean)
22
+ .some((d) => existsSync(join(d, "wrangler")));
23
+ return onPath ? "wrangler" : null;
24
+ }
25
+
26
+ export interface Ran {
27
+ code: number;
28
+ out: string;
29
+ }
30
+
31
+ /** Run wrangler. `inherit` hands the terminal over (prompts, progress); otherwise output is captured. */
32
+ export function wrangler(bin: string, args: string[], cwd: string, inherit = false): Promise<Ran> {
33
+ return new Promise((resolve) => {
34
+ const child = spawn(bin, args, { cwd, stdio: inherit ? "inherit" : ["ignore", "pipe", "pipe"] });
35
+ let out = "";
36
+ child.stdout?.on("data", (chunk) => {
37
+ out += chunk;
38
+ });
39
+ child.stderr?.on("data", (chunk) => {
40
+ out += chunk;
41
+ });
42
+ child.on("error", (error) => resolve({ code: 1, out: String(error) }));
43
+ child.on("exit", (code) => resolve({ code: code ?? 1, out }));
44
+ });
45
+ }
46
+
47
+ export interface WhoAmI {
48
+ state: "logged-in" | "logged-out" | "unknown";
49
+ accounts: Array<{ name: string; id: string }>;
50
+ raw: string;
51
+ }
52
+
53
+ /**
54
+ * `wrangler whoami` exits 0 whether or not you are logged in, so the text
55
+ * decides. Logged in, it prints a table of accounts; those are what pinning
56
+ * needs.
57
+ */
58
+ export function parseWhoAmI(raw: string): WhoAmI {
59
+ if (/not authenticated/i.test(raw)) return { state: "logged-out", accounts: [], raw };
60
+ const accounts = [...raw.matchAll(/│\s*([^│\n]+?)\s*│\s*([0-9a-f]{32})\s*│/g)].map((m) => ({ name: m[1]!, id: m[2]! }));
61
+ if (/logged in/i.test(raw) || accounts.length > 0) return { state: "logged-in", accounts, raw };
62
+ return { state: "unknown", accounts: [], raw };
63
+ }
64
+
65
+ export async function whoami(bin: string, cwd: string): Promise<WhoAmI> {
66
+ return parseWhoAmI((await wrangler(bin, ["whoami"], cwd)).out);
67
+ }
68
+
69
+ /** Secret names on the worker, or null when they cannot be listed — a worker not deployed yet says so. */
70
+ export async function secretNames(bin: string, cwd: string): Promise<Set<string> | null> {
71
+ const ran = await wrangler(bin, ["secret", "list", "--format", "json"], cwd);
72
+ if (ran.code !== 0) return null;
73
+ try {
74
+ const start = ran.out.indexOf("[");
75
+ const rows = JSON.parse(ran.out.slice(start)) as Array<{ name: string }>;
76
+ return new Set(rows.map((row) => row.name));
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ export interface D1 {
83
+ name: string;
84
+ uuid: string;
85
+ }
86
+
87
+ export async function d1List(bin: string, cwd: string): Promise<D1[]> {
88
+ const ran = await wrangler(bin, ["d1", "list", "--json"], cwd);
89
+ if (ran.code !== 0) throw new Error(`wrangler d1 list failed:\n${ran.out.trim()}`);
90
+ const start = ran.out.indexOf("[");
91
+ return JSON.parse(ran.out.slice(start)) as D1[];
92
+ }
93
+
94
+ /** The database by name, created if absent. Idempotent. */
95
+ export async function ensureD1(bin: string, cwd: string, name: string): Promise<D1> {
96
+ const existing = (await d1List(bin, cwd)).find((db) => db.name === name);
97
+ if (existing) return existing;
98
+ const created = await wrangler(bin, ["d1", "create", name], cwd);
99
+ if (created.code !== 0) throw new Error(`wrangler d1 create ${name} failed:\n${created.out.trim()}`);
100
+ const found = (await d1List(bin, cwd)).find((db) => db.name === name);
101
+ if (!found) throw new Error(`created D1 "${name}" but it is not in wrangler d1 list`);
102
+ return found;
103
+ }
104
+
105
+ /** A queue by name. Already existing is fine. */
106
+ export async function ensureQueue(bin: string, cwd: string, name: string): Promise<void> {
107
+ const ran = await wrangler(bin, ["queues", "create", name], cwd);
108
+ if (ran.code !== 0 && !/already exists|11009/i.test(ran.out)) {
109
+ throw new Error(`wrangler queues create ${name} failed:\n${ran.out.trim()}`);
110
+ }
111
+ }
package/src/dist.ts ADDED
@@ -0,0 +1,157 @@
1
+ import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import color from "picocolors";
5
+ import { generate } from "./generate/index";
6
+ import { finishStrict } from "./generate/strict";
7
+ import { box, line, row } from "./help";
8
+ import { findProject } from "./project";
9
+
10
+ /**
11
+ * `vc dist` — the deployable app, as a self-contained tree.
12
+ *
13
+ * A strict repository has no app in it: `.vc/app` is generated and
14
+ * gitignored, and it reaches the root through symlinks. Nothing can build
15
+ * that except a machine that has run `vc generate` first.
16
+ *
17
+ * So this materialises it: the generated app with every symlink replaced by
18
+ * the real thing, a package.json carrying the versions the repository
19
+ * resolved, and the lockfile beside it. The result is an ordinary Void app
20
+ * that `bun install && vp build && wrangler deploy` can take from a cold
21
+ * checkout — which is exactly what a CI runner, or Cloudflare's own build,
22
+ * has.
23
+ *
24
+ * The generated workflow pushes this tree to a branch (`void-dist`) on every
25
+ * push to main, so the thing Cloudflare builds is a plain Void app and the
26
+ * thing a person edits stays a manifest.
27
+ */
28
+
29
+ export const DIST_DIR = join(".vc", "dist");
30
+ export const DIST_BRANCH = "void-dist";
31
+
32
+ /** Never copied into the distributed tree, whatever the layout put there. */
33
+ const EXCLUDE = new Set(["node_modules", ".env", ".env.local", "dist", ".void", ".wrangler", ".git"]);
34
+
35
+ const README = (domain: string) => `# ${domain} — generated
36
+
37
+ Do not edit, and do not open a pull request against this branch. Every file
38
+ here is written by \`vc dist\` from the manifest on \`main\`, and this branch is
39
+ replaced wholesale on every push.
40
+
41
+ It is an ordinary Void app so that a build machine needs nothing but a
42
+ checkout:
43
+
44
+ \`\`\`sh
45
+ bun install
46
+ bunx void prepare
47
+ bunx vp build
48
+ bunx wrangler deploy -c dist/ssr/wrangler.json
49
+ \`\`\`
50
+
51
+ Secrets are the worker's own (\`wrangler secret put\`), never files here. The
52
+ committed migrations under \`db/migrations\` are what production applies.
53
+ `;
54
+
55
+ /**
56
+ * Copy a directory, following symlinks into real files and skipping what a
57
+ * build must not carry. `cp -r` would copy the symlinks themselves, which
58
+ * point outside the tree and break the moment it is checked out elsewhere.
59
+ */
60
+ async function copyResolved(from: string, to: string): Promise<void> {
61
+ await mkdir(to, { recursive: true });
62
+ for (const entry of await readdir(from, { withFileTypes: true })) {
63
+ if (EXCLUDE.has(entry.name)) continue;
64
+ const source = join(from, entry.name);
65
+ const target = join(to, entry.name);
66
+ // `dereference` resolves the link; `recursive` walks a directory.
67
+ if (entry.isDirectory() || entry.isSymbolicLink()) {
68
+ const stat = await import("node:fs/promises").then((fs) => fs.stat(source).catch(() => null));
69
+ if (!stat) continue; // a dangling link is not an error worth failing a build for
70
+ if (stat.isDirectory()) {
71
+ await copyResolved(source, target);
72
+ continue;
73
+ }
74
+ }
75
+ await cp(source, target, { dereference: true });
76
+ }
77
+ }
78
+
79
+ export async function distCommand(args: string[]): Promise<number> {
80
+ const project = await findProject();
81
+ if (!project) {
82
+ console.error("vc: no voidcommerce.json here.");
83
+ return 1;
84
+ }
85
+ const outIndex = args.indexOf("--out");
86
+ const out = outIndex >= 0 ? args[outIndex + 1] : undefined;
87
+ if (outIndex >= 0 && !out) {
88
+ console.error("vc: --out needs a directory.");
89
+ return 1;
90
+ }
91
+ const target = join(project.root, out ?? DIST_DIR);
92
+
93
+ // Always regenerate: the point is that the tree matches the manifest.
94
+ await generate(project.root, project.manifest);
95
+ const prepared = await finishStrict(project.root);
96
+ if (prepared !== 0) return prepared;
97
+
98
+ await rm(target, { recursive: true, force: true });
99
+ await copyResolved(project.appDir, target);
100
+
101
+ // The app's package.json lists what the choices need, at "latest". The
102
+ // repository's own package.json holds what was actually resolved — pins,
103
+ // and links to packages that are not published. Those are what a cold
104
+ // install must reproduce, so they win.
105
+ const rootPkg = JSON.parse(await readFile(join(project.root, "package.json"), "utf8")) as Record<string, Record<string, string>>;
106
+ const appPkgPath = join(target, "package.json");
107
+ const appPkg = JSON.parse(await readFile(appPkgPath, "utf8")) as Record<string, unknown>;
108
+ const local: string[] = [];
109
+ for (const section of ["dependencies", "devDependencies"]) {
110
+ const merged = { ...((appPkg[section] as Record<string, string>) ?? {}) };
111
+ for (const [name, version] of Object.entries(rootPkg[section] ?? {})) {
112
+ if (name === "@saastemly/voidcommerce") continue; // the generator is not the app's dependency
113
+ if (name in merged || section === "devDependencies") merged[name] = version;
114
+ if (version.startsWith("file:") || version.startsWith("link:")) local.push(`${name}@${version}`);
115
+ }
116
+ appPkg[section] = Object.fromEntries(Object.entries(merged).sort());
117
+ }
118
+ if (rootPkg["patchedDependencies"]) appPkg["patchedDependencies"] = rootPkg["patchedDependencies"];
119
+ await writeFile(appPkgPath, `${JSON.stringify(appPkg, null, 2)}\n`, "utf8");
120
+
121
+ // The patches the pins refer to, and the lockfile, so the install is the
122
+ // one that was tested rather than whatever resolves today.
123
+ for (const file of ["bun.lock", "bun.lockb", "package-lock.json", "pnpm-lock.yaml"]) {
124
+ const source = join(project.root, file);
125
+ if (existsSync(source)) await cp(source, join(target, file));
126
+ }
127
+ if (existsSync(join(project.root, "patches"))) await copyResolved(join(project.root, "patches"), join(target, "patches"));
128
+ await writeFile(join(target, "README.md"), README(project.manifest.shop.domain), "utf8");
129
+ await writeFile(join(target, ".gitignore"), "node_modules\ndist\n.void\n.wrangler\n.env\n.env.local\n", "utf8");
130
+
131
+ console.log(`${color.green("✓")} ${relative(project.root, target) || target} — a self-contained Void app`);
132
+ if (local.length > 0) {
133
+ console.log(
134
+ color.yellow(
135
+ `\n! ${local.length} dependenc${local.length === 1 ? "y is" : "ies are"} local paths and will not install from a fresh checkout:\n ${local.join("\n ")}\n Publish them, or point them at a registry, before a build machine uses this tree.`,
136
+ ),
137
+ );
138
+ }
139
+ return 0;
140
+ }
141
+
142
+ export async function distHelp(): Promise<number> {
143
+ const width = 80;
144
+ console.log(
145
+ box("vc dist", [
146
+ line("The deployable app as a self-contained tree: symlinks resolved, the", width),
147
+ line("repository's resolved versions, the lockfile, the patches.", width),
148
+ line("", width),
149
+ ...row("vc dist", `writes ${DIST_DIR}`, width, 2),
150
+ ...row("vc dist --out <dir>", "writes it somewhere else", width, 2),
151
+ line("", width),
152
+ line(`The generated workflow pushes this to the \`${DIST_BRANCH}\` branch on every`, width),
153
+ line("push to main, so Cloudflare builds a plain Void app and you edit a manifest.", width),
154
+ ], width),
155
+ );
156
+ return 0;
157
+ }
@@ -0,0 +1,386 @@
1
+ import { type Manifest, has } from "../manifest";
2
+
3
+ /**
4
+ * `auth.ts` — the plugin ring, wired from the manifest.
5
+ *
6
+ * ── Generated INTO a file you own ────────────────────────────────────────
7
+ *
8
+ * Void will not scan a `plugins/` directory — its convention set is closed —
9
+ * and a Vite plugin emitting a virtual module would be hidden machinery that
10
+ * makes a future merge into Void harder, not easier. So this is written where
11
+ * a reader can see it, the way `rails generate` writes into files you own.
12
+ * Regenerate from the manifest; do not hand-edit the marked region.
13
+ *
14
+ * Every choice the wizard made shows up here as a plugin call with the same
15
+ * comment the wizard showed. The file explains itself to the next reader.
16
+ */
17
+
18
+ interface Line {
19
+ /** The import statement. */
20
+ import?: string | undefined;
21
+ /** The call inside the relevant plugins array. Empty for an import-only line. */
22
+ call: string;
23
+ /** Which array it goes in. */
24
+ ring: "auth" | "commerce";
25
+ /** One line of why. */
26
+ why: string;
27
+ }
28
+
29
+ /** Rates by country, so a fresh deploy is never tax-free. Standard VAT only. */
30
+ const VAT: Record<string, { rate: number; name: string }> = {
31
+ DK: { rate: 2500, name: "Moms" },
32
+ SE: { rate: 2500, name: "Moms" },
33
+ NO: { rate: 2500, name: "MVA" },
34
+ FI: { rate: 2550, name: "ALV" },
35
+ DE: { rate: 1900, name: "MwSt" },
36
+ NL: { rate: 2100, name: "BTW" },
37
+ FR: { rate: 2000, name: "TVA" },
38
+ GB: { rate: 2000, name: "VAT" },
39
+ IE: { rate: 2300, name: "VAT" },
40
+ ES: { rate: 2100, name: "IVA" },
41
+ IT: { rate: 2200, name: "IVA" },
42
+ AT: { rate: 2000, name: "USt" },
43
+ BE: { rate: 2100, name: "BTW" },
44
+ PL: { rate: 2300, name: "VAT" },
45
+ };
46
+
47
+ const social = (manifest: Manifest, id: string, envPrefix: string) =>
48
+ has(manifest, id)
49
+ ? ` ${id}: { clientId: must(env, "${envPrefix}_CLIENT_ID"), clientSecret: must(env, "${envPrefix}_CLIENT_SECRET") },`
50
+ : null;
51
+
52
+ export function renderAuthTs(manifest: Manifest): string {
53
+ const { shop } = manifest;
54
+ const lines: Line[] = [];
55
+ /**
56
+ * The token route, when it was chosen. It wraps the PHYSICAL provider
57
+ * rather than sitting beside it, because the buyer's choice lives on the
58
+ * line: one provider reads it and sends the line down one road or the
59
+ * other. `mintProvider` is the app's to configure — the default records
60
+ * the intent and mints nothing, which is the honest state for a shop that
61
+ * has not chosen a chain.
62
+ */
63
+ const wantsNft = has(manifest, "nft");
64
+ const nftProvider = wantsNft
65
+ ? `, nftOrPhysical({ mint: mintProvider, physical: ${has(manifest, "business-central") ? `erpFulfillment("${shop.currency.toUpperCase()}")` : "manualFulfillment()"} })`
66
+ : "";
67
+ const nftDefault = wantsNft ? `, defaultProvider: "nft-or-physical"` : "";
68
+
69
+ const vat = VAT[shop.country];
70
+
71
+ // ── auth ring ────────────────────────────────────────────────────────
72
+ lines.push({
73
+ import: `import { admin } from "better-auth/plugins/admin";`,
74
+ call: `admin()`,
75
+ ring: "auth",
76
+ why: "who may open the panel",
77
+ });
78
+ lines.push({
79
+ import: `import { systemUser } from "@saastemly/better-system-user";`,
80
+ call: `systemUser({ key: read(env, "SYSTEM_API_KEY"), email: "system@${shop.domain}", name: "${shop.name} system" })`,
81
+ ring: "auth",
82
+ why: "the deployment authenticates as itself — no human sign-in to bootstrap",
83
+ });
84
+ if (has(manifest, "magic-link"))
85
+ lines.push({
86
+ import: `import { magicLink } from "better-auth/plugins/magic-link";`,
87
+ call: `magicLink({ sendMagicLink: emailer.auth.sendMagicLink, expiresIn: 60 * 15 })`,
88
+ ring: "auth",
89
+ why: "one tap on the device the mail is open on; signs up too",
90
+ });
91
+ if (has(manifest, "email-otp"))
92
+ lines.push({
93
+ import: `import { emailOTP } from "better-auth/plugins/email-otp";`,
94
+ call: `emailOTP({ sendVerificationOTP: emailer.auth.sendVerificationOTP, otpLength: 6, expiresIn: 60 * 10, allowedAttempts: 3 })`,
95
+ ring: "auth",
96
+ why: "a code, for when the mail is on a different device; three tries",
97
+ });
98
+ if (has(manifest, "passkey"))
99
+ lines.push({
100
+ import: `import { passkey } from "@better-auth/passkey";`,
101
+ call: `passkey({ rpName: "${shop.name}", rpID: domain.host, origin: domain.appUrl })`,
102
+ ring: "auth",
103
+ why: "sign-in only; a passkey is created against an account that exists",
104
+ });
105
+ if (has(manifest, "username"))
106
+ lines.push({ import: `import { username } from "better-auth/plugins/username";`, call: `username()`, ring: "auth", why: "sign in by handle" });
107
+ if (has(manifest, "phone"))
108
+ lines.push({ import: `import { phoneNumber } from "better-auth/plugins/phone-number";`, call: `phoneNumber({ sendOTP: async () => { /* TODO: wire an SMS provider */ } })`, ring: "auth", why: "SMS codes — TODO wire a provider" });
109
+ if (has(manifest, "anonymous"))
110
+ lines.push({ import: `import { anonymous } from "better-auth/plugins/anonymous";`, call: `anonymous()`, ring: "auth", why: "a cart before an address" });
111
+ if (has(manifest, "api-key"))
112
+ lines.push({
113
+ import: `import { apiKey } from "@better-auth/api-key";`,
114
+ call: `apiKey({ enableSessionForAPIKeys: true, requireName: true, enableMetadata: true, rateLimit: { enabled: true, timeWindow: 60_000, maxRequests: 120 } })`,
115
+ ring: "auth",
116
+ why: "machine access; the default is 10 requests per DAY and surfaces as a 500",
117
+ });
118
+ if (has(manifest, "two-factor"))
119
+ lines.push({ import: `import { twoFactor } from "better-auth/plugins/two-factor";`, call: `twoFactor()`, ring: "auth", why: "TOTP for operators" });
120
+ if (has(manifest, "organization"))
121
+ lines.push({ import: `import { organization } from "better-auth/plugins/organization";`, call: `organization()`, ring: "auth", why: "teams that share a shop account" });
122
+ if (has(manifest, "multi-session"))
123
+ lines.push({ import: `import { multiSession } from "better-auth/plugins/multi-session";`, call: `multiSession()`, ring: "auth", why: "switch accounts without signing out" });
124
+ if (has(manifest, "jwt"))
125
+ lines.push({ import: `import { jwt } from "better-auth/plugins/jwt";`, call: `jwt()`, ring: "auth", why: "tokens for a service that cannot hold a cookie" });
126
+ if (has(manifest, "bearer"))
127
+ lines.push({ import: `import { bearer } from "better-auth/plugins/bearer";`, call: `bearer()`, ring: "auth", why: "the session token in a header" });
128
+ if (has(manifest, "haveibeenpwned"))
129
+ lines.push({ import: `import { haveIBeenPwned } from "better-auth/plugins/haveibeenpwned";`, call: `haveIBeenPwned()`, ring: "auth", why: "refuse a breached password" });
130
+ if (has(manifest, "one-time-token"))
131
+ lines.push({ import: `import { oneTimeToken } from "better-auth/plugins/one-time-token";`, call: `oneTimeToken()`, ring: "auth", why: "hand a session to another origin once" });
132
+ if (has(manifest, "generic-oauth"))
133
+ lines.push({ import: `import { genericOAuth } from "better-auth/plugins/generic-oauth";`, call: `genericOAuth({ config: [] /* TODO: your provider */ })`, ring: "auth", why: "an identity provider Better Auth does not ship" });
134
+ if (has(manifest, "oauth-proxy"))
135
+ lines.push({ import: `import { oAuthProxy } from "better-auth/plugins/oauth-proxy";`, call: `oAuthProxy()`, ring: "auth", why: "OAuth callbacks on preview URLs" });
136
+ if (has(manifest, "siwe"))
137
+ lines.push({ import: `import { siwe } from "better-auth/plugins/siwe";`, call: `siwe({ domain: domain.host, getNonce: async () => crypto.randomUUID(), verifyMessage: async () => false /* TODO */ })`, ring: "auth", why: "wallet sign-in — TODO verifyMessage" });
138
+ if (has(manifest, "device-authorization"))
139
+ lines.push({ import: `import { deviceAuthorization } from "better-auth/plugins/device-authorization";`, call: `deviceAuthorization()`, ring: "auth", why: "sign in on a TV or CLI" });
140
+ if (has(manifest, "custom-session"))
141
+ lines.push({ import: `import { customSession } from "better-auth/plugins/custom-session";`, call: `customSession(async ({ user, session }) => ({ user, session }))`, ring: "auth", why: "computed fields on every session" });
142
+ // Strict has no array to edit: launch content is content/*.json, snapshotted into lib/content.ts.
143
+ const strict = manifest.layout === "strict";
144
+ if (has(manifest, "blogs"))
145
+ lines.push({
146
+ import: `import { blogs } from "@saastemly/better-blogs";${strict ? `\nimport { posts as launchPosts } from "./lib/content.ts";` : ""}`,
147
+ call: `blogs({ defaultLocale: "${shop.locale}", posts: ${strict ? "launchPosts" : "[]"} })`,
148
+ ring: "auth",
149
+ why: strict ? "launch posts come from content/posts.json" : "launch posts go in the array; an operator edits rows afterwards",
150
+ });
151
+ if (has(manifest, "faqs"))
152
+ lines.push({
153
+ import: `import { faqs } from "@saastemly/better-faqs";${strict ? `\nimport { faqEntries } from "./lib/content.ts";` : ""}`,
154
+ call: `faqs({ defaultLocale: "${shop.locale}", entries: ${strict ? "faqEntries" : "[]"} })`,
155
+ ring: "auth",
156
+ why: strict ? "launch FAQ comes from content/faqs.json" : "launch FAQ goes in the array",
157
+ });
158
+ if (has(manifest, "cloudflare") || has(manifest, "simply"))
159
+ lines.push({
160
+ import: `import { dns } from "@saastemly/better-dns";\nimport { ${has(manifest, "cloudflare") ? "cloudflareDns" : "simplyDns"} } from "@saastemly/better-dns/providers/${has(manifest, "cloudflare") ? "cloudflare" : "simply"}";`,
161
+ call: has(manifest, "cloudflare")
162
+ ? `dns({ providers: dnsToken ? { cloudflare: cloudflareDns({ apiToken: dnsToken }) } : {}, zones: dnsToken ? { [domain.zone]: "cloudflare" } : {}, records: dnsToken ? storefrontRecords(domain, read(env, "GITHUB_PAGES_HOST") ?? "") : [] })`
163
+ : `dns({ providers: dnsToken ? { simply: simplyDns({ apiKey: dnsToken }) } : {}, zones: dnsToken ? { [domain.zone]: "simply" } : {}, records: dnsToken ? storefrontRecords(domain, read(env, "GITHUB_PAGES_HOST") ?? "") : [] })`,
164
+ ring: "auth",
165
+ why: "the deploy points the domain; a sync never removes a record it did not declare",
166
+ });
167
+ lines.push({ import: `import { email } from "@saastemly/better-email";`, call: `email({ emailer })`, ring: "auth", why: "a log of every send — the difference between a shrug and the provider's own refusal" });
168
+ if (has(manifest, "admin")) {
169
+ lines.push({ import: `import { apiDashboard } from "@saastemly/better-admin-ui/server";`, call: `apiDashboard()`, ring: "auth", why: "the generated storefront and panel's server half" });
170
+ }
171
+
172
+ // ── commerce ring ────────────────────────────────────────────────────
173
+ const commerce = (id: string, call: string, why: string, imp?: string) => {
174
+ if (has(manifest, id)) lines.push({ import: imp ?? `import { ${camel(id)} } from "@saastemly/better-commerce/plugins/${id}";`, call, ring: "commerce", why });
175
+ };
176
+ commerce("catalog", `catalog()`, "products as rows; the panel grows Product screens");
177
+ commerce("catalog-import", `catalogImport({ sources: [] })`, "pushed from \`vc import\`; the worker carries no catalogue");
178
+ commerce("metafields", `metafields({ definitions: [] })`, "typed custom data on any row");
179
+ commerce("personalization", `personalization()`, "validated at add-to-cart, priced from rows never from the client");
180
+ commerce("inventory", `inventory()`, "stock and reservations");
181
+ commerce("bundles", `bundles({ bundles: [] })`, "kits at a set price");
182
+ commerce("price-lists", `priceLists()`, "per-group and per-date prices");
183
+ commerce("translations", `translations()`, "copy in more than one language");
184
+ commerce("search", `search({ provider: memorySearchProvider() })`, "memory locally; swap for Meilisearch", `import { search } from "@saastemly/better-commerce/plugins/search";\nimport { memorySearchProvider } from "@saastemly/better-commerce/providers/search";`);
185
+ commerce("regions", `regions({ regions: [{ id: "${shop.country.toLowerCase()}", name: "${shop.country}", currency: "${shop.currency}", countries: ["${shop.country}"], isDefault: true }] })`, "where you sell, in what currency");
186
+ commerce("sales-channels", `salesChannels({ channels: [{ id: "web", name: "Web", isDefault: true }] })`, "scope products per channel");
187
+ commerce("discounts", `discounts()`, "codes and campaigns");
188
+ commerce("gift-cards", `giftCards()`, "balance derived from the ledger", `import { giftCards, giftCardFulfillment } from "@saastemly/better-commerce/plugins/gift-cards";`);
189
+ commerce("loyalty", `loyalty()`, "earn on paid orders");
190
+ commerce("subscriptions", `subscriptions()`, "recurring orders");
191
+ commerce("draft-orders", `draftOrders()`, "an operator builds a cart for a buyer");
192
+ commerce("order-edits", `orderEdits()`, "change a paid order, charge or refund the difference");
193
+ commerce("returns", `returns()`, "the road to a refund");
194
+ commerce("wishlists", `wishlists()`, "prices resolved on read");
195
+ commerce("reviews", `reviews()`, "verified badge derived from a paid order");
196
+ commerce("customer-groups", `customerGroups()`, "segments with their own prices");
197
+ commerce("acp", `acp({ merchantDisplayName: "${shop.name}" })`, "agentic commerce", `import { acp } from "@saastemly/better-commerce/acp";`);
198
+ commerce(
199
+ "fulfillments",
200
+ `fulfillments({ providers: [authFulfillment(), creditsFulfillment()${has(manifest, "gift-cards") ? ", giftCardFulfillment()" : ""}${has(manifest, "business-central") ? `, erpFulfillment("${shop.currency.toUpperCase()}")` : ""}${nftProvider}]${nftDefault} })`,
201
+ "what happens when the money is confirmed",
202
+ `import { authFulfillment, creditsFulfillment, fulfillments, manualFulfillment } from "@saastemly/better-commerce/plugins/fulfillments";${has(manifest, "business-central") ? `\nimport { erpFulfillment } from "./lib/erp.ts";` : ""}`,
203
+ );
204
+ commerce(
205
+ "shipping-zones",
206
+ `shippingZones({ zones: [{ id: "domestic", name: "${shop.country}", countries: ["${shop.country}"], rank: 0 }], shippingOptions: [{ id: "standard", zoneId: "domestic", name: "Standard", priceType: "flat", amount: Number(must(env, "SHIPPING_DOMESTIC_MINOR")), currency: "${shop.currency}", rank: 0 }, { id: "free", zoneId: "domestic", name: "Free", priceType: "free", currency: "${shop.currency}", minSubtotal: Number(must(env, "SHIPPING_FREE_FROM_MINOR")), rank: 1 }] })`,
207
+ "quoted from required env; a shop with no option quotes FREE delivery silently",
208
+ );
209
+ /**
210
+ * Tax is declared only when the shop is REGISTERED to charge it. A
211
+ * business below its registration threshold that adds tax to a price is
212
+ * collecting money it has no right to; one above it that does not is
213
+ * paying the tax out of its own margin. Neither is a default worth
214
+ * guessing, so the manifest answers it.
215
+ */
216
+ const registered = shop.taxRegistered === true;
217
+ const askedLive = has(manifest, "stripe-tax");
218
+ commerce(
219
+ "tax-regions",
220
+ askedLive
221
+ ? `taxRegions({ regions: [{ countryCode: "${shop.country}", name: "${shop.country}", rates: [] }], attachTax: false })`
222
+ : !registered
223
+ ? `taxRegions({ regions: [{ countryCode: "${shop.country}", name: "${shop.country}", rates: [] }] })`
224
+ : vat
225
+ ? `taxRegions({ regions: [{ countryCode: "${shop.country}", name: "${shop.country}", rates: [{ name: "${vat.name}", code: "vat", rate: ${vat.rate} }] }] })`
226
+ : `taxRegions({ regions: [{ countryCode: "${shop.country}", name: "${shop.country}", rates: [] }] /* TODO VERIFY: no standard rate known for ${shop.country} */ })`,
227
+ askedLive
228
+ ? "Stripe is asked per basket, so an unregistered shop charges nothing and a registered one charges the buyer's own rate — with nothing here to keep up to date"
229
+ : !registered
230
+ ? `NOT registered for ${vat?.name ?? "VAT"}: no tax is added, because a business below the threshold must not charge it. Set taxRegistered in voidcommerce.json when you register.`
231
+ : vat
232
+ ? `${vat.rate / 100}% ${vat.name} declared in code, so a fresh deploy is never tax-free`
233
+ : "TODO VERIFY: registered, but no standard rate is known for this country — declare it",
234
+ );
235
+ // Import-only: the calculator is an argument to taxRegions(), not a plugin.
236
+ if (askedLive)
237
+ lines.push({
238
+ import: `import { taxCalculator } from "@saastemly/better-commerce/providers/tax";\nimport { stripeTax } from "@saastemly/better-commerce/providers/stripe-tax";`,
239
+ call: "",
240
+ ring: "commerce",
241
+ why: "",
242
+ });
243
+ commerce("notifications", `notifications({ provider: emailNotifications(emailer) })`, "order confirmations, queued and retried", `import { notifications } from "@saastemly/better-commerce/plugins/notifications";\nimport { emailNotifications } from "./lib/notifications.ts";`);
244
+ commerce("events", `events()`, "webhooks with retries");
245
+ commerce("workflows", `workflows()`, "multi-step operations that survive a crash");
246
+ commerce("files", `files({ provider: memoryFileProvider() })`, "uploads; swap for R2", `import { files } from "@saastemly/better-commerce/plugins/files";\nimport { memoryFileProvider } from "@saastemly/better-commerce/providers/file";`);
247
+ commerce("merchants", `merchants()`, "several sellers, with payouts");
248
+ if (has(manifest, "nft"))
249
+ lines.push({
250
+ import: `import { nft, nftOrPhysical } from "@saastemly/better-commerce/plugins/nft";\nimport { mintProvider } from "./lib/mint.ts";`,
251
+ call: `nft({ blurb: "Rather not have the object? Take an authentic, completely useless token of it instead. Nothing gets printed, nothing gets posted." })`,
252
+ ring: "commerce",
253
+ why: "the buyer chooses a parcel or a token; the catalogue is the same either way",
254
+ });
255
+ commerce("store", `store({ name: "${shop.name}", settings: { "support.email": { value: "info@${shop.domain}", public: true } } })`, "public settings the storefront reads");
256
+ commerce("runtime", `runtime({ cache: memoryCacheProvider(), lock: databaseLockProvider })`, "cache and locks", `import { runtime } from "@saastemly/better-commerce/plugins/runtime";\nimport { memoryCacheProvider } from "@saastemly/better-commerce/providers/cache";\nimport { databaseLockProvider } from "@saastemly/better-commerce/providers/locking";`);
257
+
258
+ const imports = [...new Set(lines.flatMap((line) => (line.import ? line.import.split("\n") : [])))].sort();
259
+ const render = (ring: Line["ring"]) =>
260
+ lines
261
+ // A line may carry only an import — the tax calculator is a value the
262
+ // plugin takes, not a plugin of its own.
263
+ .filter((line) => line.ring === ring && line.call !== "")
264
+ .map((line) => ` // ${line.why}\n ${line.call},`)
265
+ .join("\n");
266
+
267
+ const rail = has(manifest, "adyen") ? "adyen" : "stripe";
268
+
269
+ /**
270
+ * Mail. Cloudflare's binding is the one transport with no credential at
271
+ * all — the Worker's own account is the authorisation — so where it is
272
+ * chosen there is nothing to put in a secret and nothing to rotate.
273
+ * Neither provider exists locally, so MailDev stands in for both.
274
+ */
275
+ // A shop with no DNS provider has no token to read, and naming one that is
276
+ // not in env.ts is a lie a reader has to disprove.
277
+ const dnsProvider = has(manifest, "cloudflare") ? "CLOUDFLARE_DNS_TOKEN" : has(manifest, "simply") ? "SIMPLY_API_KEY" : null;
278
+ const dnsDeclaration = dnsProvider ? ` const dnsToken = read(env, "${dnsProvider}");` : "";
279
+ const cloudflareMail = has(manifest, "cloudflare-email");
280
+ const mailProvider = cloudflareMail ? "Cloudflare" : "Resend";
281
+ const mailImport = cloudflareMail
282
+ ? `import { cloudflareTransport } from "@saastemly/better-email/transports/cloudflare";`
283
+ : `import { resendTransport } from "@saastemly/better-email/transports/resend";`;
284
+ const mailBinding = cloudflareMail
285
+ ? `// The Worker's own send_email binding. Read late: bindings are not always
286
+ // readable at module scope, and locally there is none at all.
287
+ const mail = (env as Record<string, unknown>)["EMAIL"] as Parameters<typeof cloudflareTransport>[0]["binding"];`
288
+ : `const resendKey = read(env, "RESEND_API_KEY");`;
289
+ const mailTransport = cloudflareMail
290
+ ? `mail ? cloudflareTransport({ binding: mail }) : smtpTransport()`
291
+ : `resendKey ? resendTransport({ apiKey: resendKey }) : smtpTransport()`;
292
+ const mailTransportId = cloudflareMail ? `mail ? "cloudflare" : "maildev"` : `resendKey ? "resend" : "maildev"`;
293
+ const socials = [social(manifest, "google", "GOOGLE"), social(manifest, "apple", "APPLE"), social(manifest, "github", "GITHUB")].filter(Boolean);
294
+
295
+ return `// GENERATED by \`vc init\` from voidcommerce.json. Edit the manifest and
296
+ // run \`vc init\` again; hand edits between the markers are overwritten.
297
+ import { commerce } from "@saastemly/better-commerce";
298
+ import { defineAuth } from "void/auth";
299
+ import { createEmailer, logSends } from "@saastemly/better-email";
300
+ import { getCurrentAuthContext } from "@better-auth/core/context";
301
+ ${mailImport}
302
+ import { smtpTransport } from "@saastemly/better-email/transports/smtp";
303
+ import { paymentProvider } from "./lib/payment.ts";
304
+ import { shopDomain, storefrontRecords } from "./lib/domain.ts";
305
+ ${imports.join("\n")}
306
+
307
+ type ConfigEnv = Record<string, unknown>;
308
+
309
+ /** \`unset\` is the one documented value that reads as absent. Preflight refuses it. */
310
+ const UNSET = "unset";
311
+ const read = (env: ConfigEnv, key: string): string | undefined => {
312
+ const value = env[key];
313
+ if (typeof value !== "string" || value.length === 0) return undefined;
314
+ return value === UNSET ? undefined : value;
315
+ };
316
+ const must = (env: ConfigEnv, key: string): string => read(env, key) ?? "";
317
+
318
+ export default defineAuth(({ defaults, env }) => {
319
+ const domain = shopDomain(must(env, "SHOP_DOMAIN") || "localhost.test", read(env, "SHOP_ZONE"));
320
+ ${mailBinding}
321
+ const cronSecret = read(env, "COMMERCE_CRON_SECRET");
322
+ ${dnsDeclaration}
323
+
324
+ /** One transport for every email; MailDev locally, ${mailProvider} in production. */
325
+ const emailer = createEmailer({
326
+ appName: "${shop.name}",
327
+ from: read(env, "EMAIL_FROM") ?? "${shop.name} <noreply@${shop.domain}>",
328
+ locale: "${shop.locale.slice(0, 2)}" as never,
329
+ transport: ${mailTransport},
330
+ onSent: logSends(async () => {
331
+ try {
332
+ const endpoint = (await getCurrentAuthContext()) as unknown as { context?: { adapter?: unknown } };
333
+ return (endpoint.context?.adapter ?? null) as never;
334
+ } catch {
335
+ return null;
336
+ }
337
+ }, { transportId: ${mailTransportId} }),
338
+ });
339
+
340
+ return {
341
+ ...defaults,
342
+ // Better Auth defaults this to NODE_ENV=production, which a Worker never sets.
343
+ rateLimit: { enabled: true, storage: "database" },
344
+ // Explicit, because the default is off and a version bump could flip it.
345
+ emailAndPassword: { enabled: ${has(manifest, "password")} },
346
+ socialProviders: {
347
+ ${socials.join("\n")}
348
+ },
349
+ account: { accountLinking: { enabled: true, trustedProviders: [${has(manifest, "google") ? '"google"' : ""}${has(manifest, "apple") ? ', "apple"' : ""}] } },
350
+ emailVerification: { sendVerificationEmail: emailer.auth.sendVerificationEmail, sendOnSignUp: false },
351
+ trustedOrigins: [...(Array.isArray(defaults.trustedOrigins) ? defaults.trustedOrigins : []), ...domain.frontendOrigins],
352
+ advanced: {
353
+ ...defaults.advanced,
354
+ ...(domain.frontendOrigins.length > 0 ? { defaultCookieAttributes: { sameSite: "none" as const, secure: true } } : {}),
355
+ },
356
+ plugins: [
357
+ ...(Array.isArray(defaults.plugins) ? defaults.plugins : []),
358
+ // ── vc:auth ─────────────────────────────────────────────────
359
+ ${render("auth")}
360
+ // ── /vc:auth ────────────────────────────────────────────────
361
+ commerce({
362
+ provider: paymentProvider, // ${rail}
363
+ defaultCurrency: () => "${shop.currency}",${
364
+ askedLive
365
+ ? `
366
+ // The rate is asked for per basket, so there is none to declare:
367
+ // no registration means no tax, and a registration means the
368
+ // buyer's own rate. taxRegions() keeps its tables and admin
369
+ // routes but does not claim this slot.
370
+ pricing: { tax: taxCalculator(stripeTax({ secretKey: must(env, "STRIPE_SECRET_KEY") })) },`
371
+ : ""
372
+ }
373
+ ...(cronSecret ? { cron: { secret: cronSecret } } : {}),
374
+ plugins: [
375
+ // ── vc:commerce ─────────────────────────────────────
376
+ ${render("commerce").replace(/^/gm, "\t")}
377
+ // ── /vc:commerce ────────────────────────────────────
378
+ ],
379
+ }),
380
+ ],
381
+ };
382
+ });
383
+ `;
384
+ }
385
+
386
+ const camel = (id: string) => id.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());