@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.
- package/README.md +271 -0
- package/bin/vc +2 -0
- package/dist/catalog.d.ts +69 -0
- package/dist/catalog.js +34 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +544 -0
- package/dist/deploy/cloudflare.d.ts +25 -0
- package/dist/deploy/index.d.ts +16 -0
- package/dist/deploy/jsonc.d.ts +8 -0
- package/dist/deploy/preflight.d.ts +29 -0
- package/dist/deploy/wrangler.d.ts +37 -0
- package/dist/dist.d.ts +22 -0
- package/dist/generate/auth.d.ts +2 -0
- package/dist/generate/ci.d.ts +24 -0
- package/dist/generate/env.d.ts +13 -0
- package/dist/generate/frontend.d.ts +47 -0
- package/dist/generate/index.d.ts +28 -0
- package/dist/generate/requirements.d.ts +13 -0
- package/dist/generate/strict.d.ts +72 -0
- package/dist/generate/support.d.ts +23 -0
- package/dist/help.d.ts +31 -0
- package/dist/import.d.ts +2 -0
- package/dist/index-s7sq41qs.js +590 -0
- package/dist/index-ssv3a6wc.js +172 -0
- package/dist/index-wzy1xtr1.js +3155 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +190 -0
- package/dist/init.d.ts +1 -0
- package/dist/manifest.d.ts +131 -0
- package/dist/manifest.js +41 -0
- package/dist/project.d.ts +20 -0
- package/dist/regenerate.d.ts +9 -0
- package/dist/scripts.d.ts +12 -0
- package/dist/void.d.ts +30 -0
- package/dist/wizard.d.ts +7 -0
- package/package.json +50 -0
- package/src/catalog.ts +673 -0
- package/src/cli.ts +78 -0
- package/src/deploy/cloudflare.ts +166 -0
- package/src/deploy/index.ts +101 -0
- package/src/deploy/jsonc.ts +148 -0
- package/src/deploy/preflight.ts +137 -0
- package/src/deploy/wrangler.ts +111 -0
- package/src/dist.ts +157 -0
- package/src/generate/auth.ts +386 -0
- package/src/generate/ci.ts +208 -0
- package/src/generate/env.ts +164 -0
- package/src/generate/frontend.ts +275 -0
- package/src/generate/index.ts +390 -0
- package/src/generate/requirements.ts +48 -0
- package/src/generate/strict.ts +692 -0
- package/src/generate/support.ts +252 -0
- package/src/help.ts +172 -0
- package/src/import.ts +237 -0
- package/src/index.ts +37 -0
- package/src/init.ts +187 -0
- package/src/manifest.ts +303 -0
- package/src/project.ts +63 -0
- package/src/regenerate.ts +51 -0
- package/src/scripts.ts +53 -0
- package/src/void.ts +115 -0
- package/src/wizard.ts +234 -0
|
@@ -0,0 +1,3155 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MANIFEST_FILE,
|
|
3
|
+
envKeysOf,
|
|
4
|
+
has,
|
|
5
|
+
hasFrontend,
|
|
6
|
+
isApex,
|
|
7
|
+
isSingleApp,
|
|
8
|
+
packagesOf,
|
|
9
|
+
readManifest,
|
|
10
|
+
specifier,
|
|
11
|
+
workerHosts,
|
|
12
|
+
writeManifest,
|
|
13
|
+
zone
|
|
14
|
+
} from "./index-ssv3a6wc.js";
|
|
15
|
+
import {
|
|
16
|
+
CHOICES,
|
|
17
|
+
__require
|
|
18
|
+
} from "./index-s7sq41qs.js";
|
|
19
|
+
|
|
20
|
+
// src/generate/auth.ts
|
|
21
|
+
var VAT = {
|
|
22
|
+
DK: { rate: 2500, name: "Moms" },
|
|
23
|
+
SE: { rate: 2500, name: "Moms" },
|
|
24
|
+
NO: { rate: 2500, name: "MVA" },
|
|
25
|
+
FI: { rate: 2550, name: "ALV" },
|
|
26
|
+
DE: { rate: 1900, name: "MwSt" },
|
|
27
|
+
NL: { rate: 2100, name: "BTW" },
|
|
28
|
+
FR: { rate: 2000, name: "TVA" },
|
|
29
|
+
GB: { rate: 2000, name: "VAT" },
|
|
30
|
+
IE: { rate: 2300, name: "VAT" },
|
|
31
|
+
ES: { rate: 2100, name: "IVA" },
|
|
32
|
+
IT: { rate: 2200, name: "IVA" },
|
|
33
|
+
AT: { rate: 2000, name: "USt" },
|
|
34
|
+
BE: { rate: 2100, name: "BTW" },
|
|
35
|
+
PL: { rate: 2300, name: "VAT" }
|
|
36
|
+
};
|
|
37
|
+
var social = (manifest, id, envPrefix) => has(manifest, id) ? ` ${id}: { clientId: must(env, "${envPrefix}_CLIENT_ID"), clientSecret: must(env, "${envPrefix}_CLIENT_SECRET") },` : null;
|
|
38
|
+
function renderAuthTs(manifest) {
|
|
39
|
+
const { shop } = manifest;
|
|
40
|
+
const lines = [];
|
|
41
|
+
const wantsNft = has(manifest, "nft");
|
|
42
|
+
const nftProvider = wantsNft ? `, nftOrPhysical({ mint: mintProvider, physical: ${has(manifest, "business-central") ? `erpFulfillment("${shop.currency.toUpperCase()}")` : "manualFulfillment()"} })` : "";
|
|
43
|
+
const nftDefault = wantsNft ? `, defaultProvider: "nft-or-physical"` : "";
|
|
44
|
+
const vat = VAT[shop.country];
|
|
45
|
+
lines.push({
|
|
46
|
+
import: `import { admin } from "better-auth/plugins/admin";`,
|
|
47
|
+
call: `admin()`,
|
|
48
|
+
ring: "auth",
|
|
49
|
+
why: "who may open the panel"
|
|
50
|
+
});
|
|
51
|
+
lines.push({
|
|
52
|
+
import: `import { systemUser } from "@saastemly/better-system-user";`,
|
|
53
|
+
call: `systemUser({ key: read(env, "SYSTEM_API_KEY"), email: "system@${shop.domain}", name: "${shop.name} system" })`,
|
|
54
|
+
ring: "auth",
|
|
55
|
+
why: "the deployment authenticates as itself — no human sign-in to bootstrap"
|
|
56
|
+
});
|
|
57
|
+
if (has(manifest, "magic-link"))
|
|
58
|
+
lines.push({
|
|
59
|
+
import: `import { magicLink } from "better-auth/plugins/magic-link";`,
|
|
60
|
+
call: `magicLink({ sendMagicLink: emailer.auth.sendMagicLink, expiresIn: 60 * 15 })`,
|
|
61
|
+
ring: "auth",
|
|
62
|
+
why: "one tap on the device the mail is open on; signs up too"
|
|
63
|
+
});
|
|
64
|
+
if (has(manifest, "email-otp"))
|
|
65
|
+
lines.push({
|
|
66
|
+
import: `import { emailOTP } from "better-auth/plugins/email-otp";`,
|
|
67
|
+
call: `emailOTP({ sendVerificationOTP: emailer.auth.sendVerificationOTP, otpLength: 6, expiresIn: 60 * 10, allowedAttempts: 3 })`,
|
|
68
|
+
ring: "auth",
|
|
69
|
+
why: "a code, for when the mail is on a different device; three tries"
|
|
70
|
+
});
|
|
71
|
+
if (has(manifest, "passkey"))
|
|
72
|
+
lines.push({
|
|
73
|
+
import: `import { passkey } from "@better-auth/passkey";`,
|
|
74
|
+
call: `passkey({ rpName: "${shop.name}", rpID: domain.host, origin: domain.appUrl })`,
|
|
75
|
+
ring: "auth",
|
|
76
|
+
why: "sign-in only; a passkey is created against an account that exists"
|
|
77
|
+
});
|
|
78
|
+
if (has(manifest, "username"))
|
|
79
|
+
lines.push({ import: `import { username } from "better-auth/plugins/username";`, call: `username()`, ring: "auth", why: "sign in by handle" });
|
|
80
|
+
if (has(manifest, "phone"))
|
|
81
|
+
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" });
|
|
82
|
+
if (has(manifest, "anonymous"))
|
|
83
|
+
lines.push({ import: `import { anonymous } from "better-auth/plugins/anonymous";`, call: `anonymous()`, ring: "auth", why: "a cart before an address" });
|
|
84
|
+
if (has(manifest, "api-key"))
|
|
85
|
+
lines.push({
|
|
86
|
+
import: `import { apiKey } from "@better-auth/api-key";`,
|
|
87
|
+
call: `apiKey({ enableSessionForAPIKeys: true, requireName: true, enableMetadata: true, rateLimit: { enabled: true, timeWindow: 60_000, maxRequests: 120 } })`,
|
|
88
|
+
ring: "auth",
|
|
89
|
+
why: "machine access; the default is 10 requests per DAY and surfaces as a 500"
|
|
90
|
+
});
|
|
91
|
+
if (has(manifest, "two-factor"))
|
|
92
|
+
lines.push({ import: `import { twoFactor } from "better-auth/plugins/two-factor";`, call: `twoFactor()`, ring: "auth", why: "TOTP for operators" });
|
|
93
|
+
if (has(manifest, "organization"))
|
|
94
|
+
lines.push({ import: `import { organization } from "better-auth/plugins/organization";`, call: `organization()`, ring: "auth", why: "teams that share a shop account" });
|
|
95
|
+
if (has(manifest, "multi-session"))
|
|
96
|
+
lines.push({ import: `import { multiSession } from "better-auth/plugins/multi-session";`, call: `multiSession()`, ring: "auth", why: "switch accounts without signing out" });
|
|
97
|
+
if (has(manifest, "jwt"))
|
|
98
|
+
lines.push({ import: `import { jwt } from "better-auth/plugins/jwt";`, call: `jwt()`, ring: "auth", why: "tokens for a service that cannot hold a cookie" });
|
|
99
|
+
if (has(manifest, "bearer"))
|
|
100
|
+
lines.push({ import: `import { bearer } from "better-auth/plugins/bearer";`, call: `bearer()`, ring: "auth", why: "the session token in a header" });
|
|
101
|
+
if (has(manifest, "haveibeenpwned"))
|
|
102
|
+
lines.push({ import: `import { haveIBeenPwned } from "better-auth/plugins/haveibeenpwned";`, call: `haveIBeenPwned()`, ring: "auth", why: "refuse a breached password" });
|
|
103
|
+
if (has(manifest, "one-time-token"))
|
|
104
|
+
lines.push({ import: `import { oneTimeToken } from "better-auth/plugins/one-time-token";`, call: `oneTimeToken()`, ring: "auth", why: "hand a session to another origin once" });
|
|
105
|
+
if (has(manifest, "generic-oauth"))
|
|
106
|
+
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" });
|
|
107
|
+
if (has(manifest, "oauth-proxy"))
|
|
108
|
+
lines.push({ import: `import { oAuthProxy } from "better-auth/plugins/oauth-proxy";`, call: `oAuthProxy()`, ring: "auth", why: "OAuth callbacks on preview URLs" });
|
|
109
|
+
if (has(manifest, "siwe"))
|
|
110
|
+
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" });
|
|
111
|
+
if (has(manifest, "device-authorization"))
|
|
112
|
+
lines.push({ import: `import { deviceAuthorization } from "better-auth/plugins/device-authorization";`, call: `deviceAuthorization()`, ring: "auth", why: "sign in on a TV or CLI" });
|
|
113
|
+
if (has(manifest, "custom-session"))
|
|
114
|
+
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" });
|
|
115
|
+
const strict = manifest.layout === "strict";
|
|
116
|
+
if (has(manifest, "blogs"))
|
|
117
|
+
lines.push({
|
|
118
|
+
import: `import { blogs } from "@saastemly/better-blogs";${strict ? `
|
|
119
|
+
import { posts as launchPosts } from "./lib/content.ts";` : ""}`,
|
|
120
|
+
call: `blogs({ defaultLocale: "${shop.locale}", posts: ${strict ? "launchPosts" : "[]"} })`,
|
|
121
|
+
ring: "auth",
|
|
122
|
+
why: strict ? "launch posts come from content/posts.json" : "launch posts go in the array; an operator edits rows afterwards"
|
|
123
|
+
});
|
|
124
|
+
if (has(manifest, "faqs"))
|
|
125
|
+
lines.push({
|
|
126
|
+
import: `import { faqs } from "@saastemly/better-faqs";${strict ? `
|
|
127
|
+
import { faqEntries } from "./lib/content.ts";` : ""}`,
|
|
128
|
+
call: `faqs({ defaultLocale: "${shop.locale}", entries: ${strict ? "faqEntries" : "[]"} })`,
|
|
129
|
+
ring: "auth",
|
|
130
|
+
why: strict ? "launch FAQ comes from content/faqs.json" : "launch FAQ goes in the array"
|
|
131
|
+
});
|
|
132
|
+
if (has(manifest, "cloudflare") || has(manifest, "simply"))
|
|
133
|
+
lines.push({
|
|
134
|
+
import: `import { dns } from "@saastemly/better-dns";
|
|
135
|
+
import { ${has(manifest, "cloudflare") ? "cloudflareDns" : "simplyDns"} } from "@saastemly/better-dns/providers/${has(manifest, "cloudflare") ? "cloudflare" : "simply"}";`,
|
|
136
|
+
call: has(manifest, "cloudflare") ? `dns({ providers: dnsToken ? { cloudflare: cloudflareDns({ apiToken: dnsToken }) } : {}, zones: dnsToken ? { [domain.zone]: "cloudflare" } : {}, records: dnsToken ? storefrontRecords(domain, read(env, "GITHUB_PAGES_HOST") ?? "") : [] })` : `dns({ providers: dnsToken ? { simply: simplyDns({ apiKey: dnsToken }) } : {}, zones: dnsToken ? { [domain.zone]: "simply" } : {}, records: dnsToken ? storefrontRecords(domain, read(env, "GITHUB_PAGES_HOST") ?? "") : [] })`,
|
|
137
|
+
ring: "auth",
|
|
138
|
+
why: "the deploy points the domain; a sync never removes a record it did not declare"
|
|
139
|
+
});
|
|
140
|
+
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" });
|
|
141
|
+
if (has(manifest, "admin")) {
|
|
142
|
+
lines.push({ import: `import { apiDashboard } from "@saastemly/better-admin-ui/server";`, call: `apiDashboard()`, ring: "auth", why: "the generated storefront and panel's server half" });
|
|
143
|
+
}
|
|
144
|
+
const commerce = (id, call, why, imp) => {
|
|
145
|
+
if (has(manifest, id))
|
|
146
|
+
lines.push({ import: imp ?? `import { ${camel(id)} } from "@saastemly/better-commerce/plugins/${id}";`, call, ring: "commerce", why });
|
|
147
|
+
};
|
|
148
|
+
commerce("catalog", `catalog()`, "products as rows; the panel grows Product screens");
|
|
149
|
+
commerce("catalog-import", `catalogImport({ sources: [] })`, "pushed from `vc import`; the worker carries no catalogue");
|
|
150
|
+
commerce("metafields", `metafields({ definitions: [] })`, "typed custom data on any row");
|
|
151
|
+
commerce("personalization", `personalization()`, "validated at add-to-cart, priced from rows never from the client");
|
|
152
|
+
commerce("inventory", `inventory()`, "stock and reservations");
|
|
153
|
+
commerce("bundles", `bundles({ bundles: [] })`, "kits at a set price");
|
|
154
|
+
commerce("price-lists", `priceLists()`, "per-group and per-date prices");
|
|
155
|
+
commerce("translations", `translations()`, "copy in more than one language");
|
|
156
|
+
commerce("search", `search({ provider: memorySearchProvider() })`, "memory locally; swap for Meilisearch", `import { search } from "@saastemly/better-commerce/plugins/search";
|
|
157
|
+
import { memorySearchProvider } from "@saastemly/better-commerce/providers/search";`);
|
|
158
|
+
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");
|
|
159
|
+
commerce("sales-channels", `salesChannels({ channels: [{ id: "web", name: "Web", isDefault: true }] })`, "scope products per channel");
|
|
160
|
+
commerce("discounts", `discounts()`, "codes and campaigns");
|
|
161
|
+
commerce("gift-cards", `giftCards()`, "balance derived from the ledger", `import { giftCards, giftCardFulfillment } from "@saastemly/better-commerce/plugins/gift-cards";`);
|
|
162
|
+
commerce("loyalty", `loyalty()`, "earn on paid orders");
|
|
163
|
+
commerce("subscriptions", `subscriptions()`, "recurring orders");
|
|
164
|
+
commerce("draft-orders", `draftOrders()`, "an operator builds a cart for a buyer");
|
|
165
|
+
commerce("order-edits", `orderEdits()`, "change a paid order, charge or refund the difference");
|
|
166
|
+
commerce("returns", `returns()`, "the road to a refund");
|
|
167
|
+
commerce("wishlists", `wishlists()`, "prices resolved on read");
|
|
168
|
+
commerce("reviews", `reviews()`, "verified badge derived from a paid order");
|
|
169
|
+
commerce("customer-groups", `customerGroups()`, "segments with their own prices");
|
|
170
|
+
commerce("acp", `acp({ merchantDisplayName: "${shop.name}" })`, "agentic commerce", `import { acp } from "@saastemly/better-commerce/acp";`);
|
|
171
|
+
commerce("fulfillments", `fulfillments({ providers: [authFulfillment(), creditsFulfillment()${has(manifest, "gift-cards") ? ", giftCardFulfillment()" : ""}${has(manifest, "business-central") ? `, erpFulfillment("${shop.currency.toUpperCase()}")` : ""}${nftProvider}]${nftDefault} })`, "what happens when the money is confirmed", `import { authFulfillment, creditsFulfillment, fulfillments, manualFulfillment } from "@saastemly/better-commerce/plugins/fulfillments";${has(manifest, "business-central") ? `
|
|
172
|
+
import { erpFulfillment } from "./lib/erp.ts";` : ""}`);
|
|
173
|
+
commerce("shipping-zones", `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 }] })`, "quoted from required env; a shop with no option quotes FREE delivery silently");
|
|
174
|
+
const registered = shop.taxRegistered === true;
|
|
175
|
+
const askedLive = has(manifest, "stripe-tax");
|
|
176
|
+
commerce("tax-regions", askedLive ? `taxRegions({ regions: [{ countryCode: "${shop.country}", name: "${shop.country}", rates: [] }], attachTax: false })` : !registered ? `taxRegions({ regions: [{ countryCode: "${shop.country}", name: "${shop.country}", rates: [] }] })` : vat ? `taxRegions({ regions: [{ countryCode: "${shop.country}", name: "${shop.country}", rates: [{ name: "${vat.name}", code: "vat", rate: ${vat.rate} }] }] })` : `taxRegions({ regions: [{ countryCode: "${shop.country}", name: "${shop.country}", rates: [] }] /* TODO VERIFY: no standard rate known for ${shop.country} */ })`, askedLive ? "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" : !registered ? `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.` : vat ? `${vat.rate / 100}% ${vat.name} declared in code, so a fresh deploy is never tax-free` : "TODO VERIFY: registered, but no standard rate is known for this country — declare it");
|
|
177
|
+
if (askedLive)
|
|
178
|
+
lines.push({
|
|
179
|
+
import: `import { taxCalculator } from "@saastemly/better-commerce/providers/tax";
|
|
180
|
+
import { stripeTax } from "@saastemly/better-commerce/providers/stripe-tax";`,
|
|
181
|
+
call: "",
|
|
182
|
+
ring: "commerce",
|
|
183
|
+
why: ""
|
|
184
|
+
});
|
|
185
|
+
commerce("notifications", `notifications({ provider: emailNotifications(emailer) })`, "order confirmations, queued and retried", `import { notifications } from "@saastemly/better-commerce/plugins/notifications";
|
|
186
|
+
import { emailNotifications } from "./lib/notifications.ts";`);
|
|
187
|
+
commerce("events", `events()`, "webhooks with retries");
|
|
188
|
+
commerce("workflows", `workflows()`, "multi-step operations that survive a crash");
|
|
189
|
+
commerce("files", `files({ provider: memoryFileProvider() })`, "uploads; swap for R2", `import { files } from "@saastemly/better-commerce/plugins/files";
|
|
190
|
+
import { memoryFileProvider } from "@saastemly/better-commerce/providers/file";`);
|
|
191
|
+
commerce("merchants", `merchants()`, "several sellers, with payouts");
|
|
192
|
+
if (has(manifest, "nft"))
|
|
193
|
+
lines.push({
|
|
194
|
+
import: `import { nft, nftOrPhysical } from "@saastemly/better-commerce/plugins/nft";
|
|
195
|
+
import { mintProvider } from "./lib/mint.ts";`,
|
|
196
|
+
call: `nft({ blurb: "Rather not have the object? Take an authentic, completely useless token of it instead. Nothing gets printed, nothing gets posted." })`,
|
|
197
|
+
ring: "commerce",
|
|
198
|
+
why: "the buyer chooses a parcel or a token; the catalogue is the same either way"
|
|
199
|
+
});
|
|
200
|
+
commerce("store", `store({ name: "${shop.name}", settings: { "support.email": { value: "info@${shop.domain}", public: true } } })`, "public settings the storefront reads");
|
|
201
|
+
commerce("runtime", `runtime({ cache: memoryCacheProvider(), lock: databaseLockProvider })`, "cache and locks", `import { runtime } from "@saastemly/better-commerce/plugins/runtime";
|
|
202
|
+
import { memoryCacheProvider } from "@saastemly/better-commerce/providers/cache";
|
|
203
|
+
import { databaseLockProvider } from "@saastemly/better-commerce/providers/locking";`);
|
|
204
|
+
const imports = [...new Set(lines.flatMap((line) => line.import ? line.import.split(`
|
|
205
|
+
`) : []))].sort();
|
|
206
|
+
const render = (ring) => lines.filter((line) => line.ring === ring && line.call !== "").map((line) => ` // ${line.why}
|
|
207
|
+
${line.call},`).join(`
|
|
208
|
+
`);
|
|
209
|
+
const rail = has(manifest, "adyen") ? "adyen" : "stripe";
|
|
210
|
+
const dnsProvider = has(manifest, "cloudflare") ? "CLOUDFLARE_DNS_TOKEN" : has(manifest, "simply") ? "SIMPLY_API_KEY" : null;
|
|
211
|
+
const dnsDeclaration = dnsProvider ? ` const dnsToken = read(env, "${dnsProvider}");` : "";
|
|
212
|
+
const cloudflareMail = has(manifest, "cloudflare-email");
|
|
213
|
+
const mailProvider = cloudflareMail ? "Cloudflare" : "Resend";
|
|
214
|
+
const mailImport = cloudflareMail ? `import { cloudflareTransport } from "@saastemly/better-email/transports/cloudflare";` : `import { resendTransport } from "@saastemly/better-email/transports/resend";`;
|
|
215
|
+
const mailBinding = cloudflareMail ? `// The Worker's own send_email binding. Read late: bindings are not always
|
|
216
|
+
// readable at module scope, and locally there is none at all.
|
|
217
|
+
const mail = (env as Record<string, unknown>)["EMAIL"] as Parameters<typeof cloudflareTransport>[0]["binding"];` : `const resendKey = read(env, "RESEND_API_KEY");`;
|
|
218
|
+
const mailTransport = cloudflareMail ? `mail ? cloudflareTransport({ binding: mail }) : smtpTransport()` : `resendKey ? resendTransport({ apiKey: resendKey }) : smtpTransport()`;
|
|
219
|
+
const mailTransportId = cloudflareMail ? `mail ? "cloudflare" : "maildev"` : `resendKey ? "resend" : "maildev"`;
|
|
220
|
+
const socials = [social(manifest, "google", "GOOGLE"), social(manifest, "apple", "APPLE"), social(manifest, "github", "GITHUB")].filter(Boolean);
|
|
221
|
+
return `// GENERATED by \`vc init\` from voidcommerce.json. Edit the manifest and
|
|
222
|
+
// run \`vc init\` again; hand edits between the markers are overwritten.
|
|
223
|
+
import { commerce } from "@saastemly/better-commerce";
|
|
224
|
+
import { defineAuth } from "void/auth";
|
|
225
|
+
import { createEmailer, logSends } from "@saastemly/better-email";
|
|
226
|
+
import { getCurrentAuthContext } from "@better-auth/core/context";
|
|
227
|
+
${mailImport}
|
|
228
|
+
import { smtpTransport } from "@saastemly/better-email/transports/smtp";
|
|
229
|
+
import { paymentProvider } from "./lib/payment.ts";
|
|
230
|
+
import { shopDomain, storefrontRecords } from "./lib/domain.ts";
|
|
231
|
+
${imports.join(`
|
|
232
|
+
`)}
|
|
233
|
+
|
|
234
|
+
type ConfigEnv = Record<string, unknown>;
|
|
235
|
+
|
|
236
|
+
/** \`unset\` is the one documented value that reads as absent. Preflight refuses it. */
|
|
237
|
+
const UNSET = "unset";
|
|
238
|
+
const read = (env: ConfigEnv, key: string): string | undefined => {
|
|
239
|
+
const value = env[key];
|
|
240
|
+
if (typeof value !== "string" || value.length === 0) return undefined;
|
|
241
|
+
return value === UNSET ? undefined : value;
|
|
242
|
+
};
|
|
243
|
+
const must = (env: ConfigEnv, key: string): string => read(env, key) ?? "";
|
|
244
|
+
|
|
245
|
+
export default defineAuth(({ defaults, env }) => {
|
|
246
|
+
const domain = shopDomain(must(env, "SHOP_DOMAIN") || "localhost.test", read(env, "SHOP_ZONE"));
|
|
247
|
+
${mailBinding}
|
|
248
|
+
const cronSecret = read(env, "COMMERCE_CRON_SECRET");
|
|
249
|
+
${dnsDeclaration}
|
|
250
|
+
|
|
251
|
+
/** One transport for every email; MailDev locally, ${mailProvider} in production. */
|
|
252
|
+
const emailer = createEmailer({
|
|
253
|
+
appName: "${shop.name}",
|
|
254
|
+
from: read(env, "EMAIL_FROM") ?? "${shop.name} <noreply@${shop.domain}>",
|
|
255
|
+
locale: "${shop.locale.slice(0, 2)}" as never,
|
|
256
|
+
transport: ${mailTransport},
|
|
257
|
+
onSent: logSends(async () => {
|
|
258
|
+
try {
|
|
259
|
+
const endpoint = (await getCurrentAuthContext()) as unknown as { context?: { adapter?: unknown } };
|
|
260
|
+
return (endpoint.context?.adapter ?? null) as never;
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}, { transportId: ${mailTransportId} }),
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
...defaults,
|
|
269
|
+
// Better Auth defaults this to NODE_ENV=production, which a Worker never sets.
|
|
270
|
+
rateLimit: { enabled: true, storage: "database" },
|
|
271
|
+
// Explicit, because the default is off and a version bump could flip it.
|
|
272
|
+
emailAndPassword: { enabled: ${has(manifest, "password")} },
|
|
273
|
+
socialProviders: {
|
|
274
|
+
${socials.join(`
|
|
275
|
+
`)}
|
|
276
|
+
},
|
|
277
|
+
account: { accountLinking: { enabled: true, trustedProviders: [${has(manifest, "google") ? '"google"' : ""}${has(manifest, "apple") ? ', "apple"' : ""}] } },
|
|
278
|
+
emailVerification: { sendVerificationEmail: emailer.auth.sendVerificationEmail, sendOnSignUp: false },
|
|
279
|
+
trustedOrigins: [...(Array.isArray(defaults.trustedOrigins) ? defaults.trustedOrigins : []), ...domain.frontendOrigins],
|
|
280
|
+
advanced: {
|
|
281
|
+
...defaults.advanced,
|
|
282
|
+
...(domain.frontendOrigins.length > 0 ? { defaultCookieAttributes: { sameSite: "none" as const, secure: true } } : {}),
|
|
283
|
+
},
|
|
284
|
+
plugins: [
|
|
285
|
+
...(Array.isArray(defaults.plugins) ? defaults.plugins : []),
|
|
286
|
+
// ── vc:auth ─────────────────────────────────────────────────
|
|
287
|
+
${render("auth")}
|
|
288
|
+
// ── /vc:auth ────────────────────────────────────────────────
|
|
289
|
+
commerce({
|
|
290
|
+
provider: paymentProvider, // ${rail}
|
|
291
|
+
defaultCurrency: () => "${shop.currency}",${askedLive ? `
|
|
292
|
+
// The rate is asked for per basket, so there is none to declare:
|
|
293
|
+
// no registration means no tax, and a registration means the
|
|
294
|
+
// buyer's own rate. taxRegions() keeps its tables and admin
|
|
295
|
+
// routes but does not claim this slot.
|
|
296
|
+
pricing: { tax: taxCalculator(stripeTax({ secretKey: must(env, "STRIPE_SECRET_KEY") })) },` : ""}
|
|
297
|
+
...(cronSecret ? { cron: { secret: cronSecret } } : {}),
|
|
298
|
+
plugins: [
|
|
299
|
+
// ── vc:commerce ─────────────────────────────────────
|
|
300
|
+
${render("commerce").replace(/^/gm, "\t")}
|
|
301
|
+
// ── /vc:commerce ────────────────────────────────────
|
|
302
|
+
],
|
|
303
|
+
}),
|
|
304
|
+
],
|
|
305
|
+
};
|
|
306
|
+
});
|
|
307
|
+
`;
|
|
308
|
+
}
|
|
309
|
+
var camel = (id) => id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
310
|
+
|
|
311
|
+
// src/generate/env.ts
|
|
312
|
+
var BASE = [
|
|
313
|
+
{
|
|
314
|
+
key: "SHOP_DOMAIN",
|
|
315
|
+
breaks: "everything derived from it is wrong at once — no public origin, the storefront rejected as untrusted, no DNS records",
|
|
316
|
+
plaintext: true
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
key: "COMMERCE_CRON_SECRET",
|
|
320
|
+
breaks: "the scheduler cannot authenticate, so every background job stops",
|
|
321
|
+
where: "openssl rand -base64 32"
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
key: "COMMERCE_WEBHOOK_SECRET",
|
|
325
|
+
breaks: "payment webhooks cannot be verified, so no order ever becomes paid",
|
|
326
|
+
where: "the payment provider's dashboard"
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
key: "SHIPPING_DOMESTIC_MINOR",
|
|
330
|
+
breaks: "delivery is quoted at nothing, and every order loses the carrier cost",
|
|
331
|
+
plaintext: true,
|
|
332
|
+
dev: "4900",
|
|
333
|
+
where: "your carrier agreement, in minor units ex VAT"
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
key: "SHIPPING_FREE_FROM_MINOR",
|
|
337
|
+
breaks: "the free-delivery threshold is undefined",
|
|
338
|
+
plaintext: true,
|
|
339
|
+
dev: "100000"
|
|
340
|
+
}
|
|
341
|
+
];
|
|
342
|
+
function allEnvKeys(manifest) {
|
|
343
|
+
const keys = [...BASE, ...envKeysOf(manifest)];
|
|
344
|
+
if (!isApex(manifest)) {
|
|
345
|
+
keys.splice(1, 0, {
|
|
346
|
+
key: "SHOP_ZONE",
|
|
347
|
+
breaks: "DNS records are written to the wrong zone, or to none",
|
|
348
|
+
plaintext: true
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
if (hasFrontend(manifest.layout) && manifest.shop.pagesHost) {
|
|
352
|
+
keys.splice(1, 0, {
|
|
353
|
+
key: "GITHUB_PAGES_HOST",
|
|
354
|
+
breaks: "the www record has nothing to point at",
|
|
355
|
+
plaintext: true
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
return keys;
|
|
359
|
+
}
|
|
360
|
+
var NUMERIC = new Set(["SHIPPING_DOMESTIC_MINOR", "SHIPPING_FREE_FROM_MINOR"]);
|
|
361
|
+
function renderEnvTs(manifest) {
|
|
362
|
+
const keys = allEnvKeys(manifest);
|
|
363
|
+
const lines = keys.map((key) => {
|
|
364
|
+
const helper = NUMERIC.has(key.key) ? "number()" : "string()";
|
|
365
|
+
const doc = [` /** ${key.breaks}${key.where ? ` — from: ${key.where}` : ""} */`];
|
|
366
|
+
return `${doc.join(`
|
|
367
|
+
`)}
|
|
368
|
+
${key.key}: ${helper},`;
|
|
369
|
+
});
|
|
370
|
+
return `import { defineEnv, number, string } from "void/env";
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Every env key the app reads. All of them are REQUIRED.
|
|
374
|
+
*
|
|
375
|
+
* Generated by \`vc init\` from voidcommerce.json — edit the manifest and
|
|
376
|
+
* regenerate rather than editing this by hand.
|
|
377
|
+
*
|
|
378
|
+
* Nothing is optional: an integration this shop supports is one the
|
|
379
|
+
* deployment sets up. No key has a default, because a default is compiled
|
|
380
|
+
* into the worker's vars and shadows the real secret. Locally, the literal
|
|
381
|
+
* value \`unset\` is the one documented way to say "I do not have this yet",
|
|
382
|
+
* and \`vc preflight\` refuses it.
|
|
383
|
+
*/
|
|
384
|
+
export default defineEnv({
|
|
385
|
+
${lines.join(`
|
|
386
|
+
`)}
|
|
387
|
+
});
|
|
388
|
+
`;
|
|
389
|
+
}
|
|
390
|
+
function renderEnvExample(manifest) {
|
|
391
|
+
const keys = allEnvKeys(manifest);
|
|
392
|
+
return [
|
|
393
|
+
"# Every key is required. Copy to .env for local development.",
|
|
394
|
+
"# `unset` is the one value that reads as absent — for a credential you do not have yet.",
|
|
395
|
+
"",
|
|
396
|
+
...keys.map((key) => `${key.key}=${key.dev ?? (key.plaintext ? "" : "unset")}`),
|
|
397
|
+
""
|
|
398
|
+
].join(`
|
|
399
|
+
`);
|
|
400
|
+
}
|
|
401
|
+
function renderEnvLocal(manifest) {
|
|
402
|
+
const keys = allEnvKeys(manifest);
|
|
403
|
+
const local = {
|
|
404
|
+
SHOP_DOMAIN: `${manifest.shop.domain.split(".")[0]}.test`,
|
|
405
|
+
GITHUB_PAGES_HOST: manifest.shop.pagesHost ?? "",
|
|
406
|
+
SHOP_ZONE: zone(manifest),
|
|
407
|
+
COMMERCE_CRON_SECRET: "dev-cron-secret-not-for-production",
|
|
408
|
+
COMMERCE_WEBHOOK_SECRET: "whsec_dev_secret",
|
|
409
|
+
SYSTEM_API_KEY: "dev-system-key-0123456789abcdefghijklmnopqrstuvwxyz",
|
|
410
|
+
EMAIL_FROM: `"${manifest.shop.name} <noreply@${manifest.shop.domain}>"`
|
|
411
|
+
};
|
|
412
|
+
return [
|
|
413
|
+
"# Local development. Gitignored; .env.example documents every key.",
|
|
414
|
+
"",
|
|
415
|
+
...keys.map((key) => `${key.key}=${local[key.key] ?? key.dev ?? "unset"}`),
|
|
416
|
+
...hasFrontend(manifest.layout) ? ["", "# The storefront, reaching the worker. Start the worker first.", "VITE_API_ORIGIN=http://localhost:5173"] : [],
|
|
417
|
+
""
|
|
418
|
+
].join(`
|
|
419
|
+
`);
|
|
420
|
+
}
|
|
421
|
+
function renderEnvProduction(manifest) {
|
|
422
|
+
const keys = allEnvKeys(manifest).filter((key) => key.plaintext);
|
|
423
|
+
const values = {
|
|
424
|
+
SHOP_DOMAIN: manifest.shop.domain,
|
|
425
|
+
GITHUB_PAGES_HOST: manifest.shop.pagesHost ?? "",
|
|
426
|
+
SHOP_ZONE: zone(manifest),
|
|
427
|
+
EMAIL_FROM: `"${manifest.shop.name} <noreply@${manifest.shop.domain}>"`,
|
|
428
|
+
ADYEN_ENVIRONMENT: "live",
|
|
429
|
+
BC_ENVIRONMENT: "production"
|
|
430
|
+
};
|
|
431
|
+
return [
|
|
432
|
+
"# Production values that are NOT secrets.",
|
|
433
|
+
"#",
|
|
434
|
+
"# On `void deploy --backend cloudflare` every .env* file is baked into the",
|
|
435
|
+
"# worker's vars as PLAINTEXT, so this holds only values safe to commit. Every",
|
|
436
|
+
"# credential is `wrangler secret put <NAME>` instead.",
|
|
437
|
+
"",
|
|
438
|
+
...keys.map((key) => `${key.key}=${values[key.key] ?? key.dev ?? ""}`),
|
|
439
|
+
""
|
|
440
|
+
].join(`
|
|
441
|
+
`);
|
|
442
|
+
}
|
|
443
|
+
function envSummary(manifest) {
|
|
444
|
+
const keys = allEnvKeys(manifest);
|
|
445
|
+
return {
|
|
446
|
+
secrets: keys.filter((key) => !key.plaintext).map((key) => key.key),
|
|
447
|
+
plaintext: keys.filter((key) => key.plaintext).map((key) => key.key)
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// src/generate/frontend.ts
|
|
452
|
+
var FRONTEND_OUT = "dist/client";
|
|
453
|
+
var FRONTEND_BRANCH = "frontend-static";
|
|
454
|
+
function renderFrontendApiTs(manifest) {
|
|
455
|
+
return `/// <reference types="vite/client" />
|
|
456
|
+
import { createAuthClient } from "better-auth/react";
|
|
457
|
+
import { commerceClient } from "@saastemly/better-commerce/client";
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The one client the storefront talks through.
|
|
461
|
+
*
|
|
462
|
+
* The API is another origin — api.${manifest.shop.domain} — so its address is
|
|
463
|
+
* baked at build time: .env.production for the build, .env locally. There is
|
|
464
|
+
* no fallback. A storefront that silently points nowhere is worse than one
|
|
465
|
+
* that refuses to build.
|
|
466
|
+
*/
|
|
467
|
+
const origin = import.meta.env.VITE_API_ORIGIN as string | undefined;
|
|
468
|
+
if (!origin) throw new Error("VITE_API_ORIGIN is not set — .env locally, .env.production for the build.");
|
|
469
|
+
|
|
470
|
+
export const API_ORIGIN: string = origin;
|
|
471
|
+
export const api = createAuthClient({ baseURL: API_ORIGIN, plugins: [commerceClient()] });
|
|
472
|
+
`;
|
|
473
|
+
}
|
|
474
|
+
function renderFrontendEnvProduction(manifest) {
|
|
475
|
+
return `# Baked into the storefront build. Generated by \`vc init\` from voidcommerce.json.
|
|
476
|
+
VITE_API_ORIGIN=https://api.${manifest.shop.domain}
|
|
477
|
+
`;
|
|
478
|
+
}
|
|
479
|
+
function renderFrontendViteConfig() {
|
|
480
|
+
return `import { defineConfig } from "vite";
|
|
481
|
+
import { voidPlugin } from "void";
|
|
482
|
+
import { voidReact } from "@void/react/plugin";
|
|
483
|
+
|
|
484
|
+
/** The storefront. The API is a different origin, so nothing is proxied here. */
|
|
485
|
+
export default defineConfig({
|
|
486
|
+
plugins: [voidPlugin(), voidReact()],
|
|
487
|
+
});
|
|
488
|
+
`;
|
|
489
|
+
}
|
|
490
|
+
function renderFrontendVoidJson() {
|
|
491
|
+
return `{
|
|
492
|
+
"$schema": "./node_modules/void/schema.json",
|
|
493
|
+
"target": "node",
|
|
494
|
+
"output": "static"
|
|
495
|
+
}
|
|
496
|
+
`;
|
|
497
|
+
}
|
|
498
|
+
function renderFrontendTsconfig() {
|
|
499
|
+
return `{
|
|
500
|
+
"extends": "./.void/tsconfig.json",
|
|
501
|
+
"compilerOptions": {
|
|
502
|
+
"target": "ES2022",
|
|
503
|
+
"module": "ESNext",
|
|
504
|
+
"moduleResolution": "bundler",
|
|
505
|
+
"strict": true,
|
|
506
|
+
"esModuleInterop": true,
|
|
507
|
+
"skipLibCheck": true,
|
|
508
|
+
"noEmit": true,
|
|
509
|
+
"types": ["vite/client"],
|
|
510
|
+
"jsx": "react-jsx",
|
|
511
|
+
"paths": { "@/*": ["./*"] }
|
|
512
|
+
},
|
|
513
|
+
"include": ["pages", "lib", "src"]
|
|
514
|
+
}
|
|
515
|
+
`;
|
|
516
|
+
}
|
|
517
|
+
function renderStorefrontPage(manifest) {
|
|
518
|
+
const { name, currency } = manifest.shop;
|
|
519
|
+
return `import { useEffect, useState } from "react";
|
|
520
|
+
import { api } from "@/lib/api";
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The shop front.
|
|
524
|
+
*
|
|
525
|
+
* Yours to replace — this is a starting point, not a framework. What it is
|
|
526
|
+
* here to prove is the wiring: the catalogue comes from the worker over HTTP,
|
|
527
|
+
* with no server code on this side, which is what makes the whole storefront
|
|
528
|
+
* a static export.
|
|
529
|
+
*
|
|
530
|
+
* Prices arrive in MINOR units (øre, cents) because that is the only way to
|
|
531
|
+
* add money without losing it to floating point.
|
|
532
|
+
*/
|
|
533
|
+
interface Product {
|
|
534
|
+
id: string;
|
|
535
|
+
title: string;
|
|
536
|
+
handle?: string;
|
|
537
|
+
description?: string;
|
|
538
|
+
priceMinor?: number;
|
|
539
|
+
currency?: string;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const money = (minor: number, currency: string) =>
|
|
543
|
+
new Intl.NumberFormat(undefined, { style: "currency", currency: currency.toUpperCase() }).format(minor / 100);
|
|
544
|
+
|
|
545
|
+
export default function StorefrontPage() {
|
|
546
|
+
const [products, setProducts] = useState<Product[] | null>(null);
|
|
547
|
+
const [error, setError] = useState<string | null>(null);
|
|
548
|
+
|
|
549
|
+
useEffect(() => {
|
|
550
|
+
// The catalogue is public: no session, no key. A visitor sees it.
|
|
551
|
+
api.$fetch("/commerce/products", { method: "GET" })
|
|
552
|
+
.then((result) => {
|
|
553
|
+
const data = (result as { data?: { products?: Product[] } }).data;
|
|
554
|
+
setProducts(data?.products ?? []);
|
|
555
|
+
})
|
|
556
|
+
.catch((cause: unknown) => setError(cause instanceof Error ? cause.message : String(cause)));
|
|
557
|
+
}, []);
|
|
558
|
+
|
|
559
|
+
return (
|
|
560
|
+
<main style={{ maxWidth: "60rem", margin: "0 auto", padding: "2rem 1rem", fontFamily: "system-ui, sans-serif" }}>
|
|
561
|
+
<h1>${name}</h1>
|
|
562
|
+
{error && <p role="alert">The catalogue could not be loaded: {error}</p>}
|
|
563
|
+
{!products && !error && <p>Loading the catalogue…</p>}
|
|
564
|
+
{products?.length === 0 && <p>No products yet. Run <code>vc import</code> to push <code>data/</code> into the shop.</p>}
|
|
565
|
+
<ul style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(14rem, 1fr))", gap: "1.5rem", listStyle: "none", padding: 0 }}>
|
|
566
|
+
{products?.map((product) => (
|
|
567
|
+
<li key={product.id}>
|
|
568
|
+
<h2 style={{ fontSize: "1rem", margin: "0 0 .25rem" }}>{product.title}</h2>
|
|
569
|
+
{product.description && <p style={{ margin: "0 0 .5rem", opacity: 0.8 }}>{product.description}</p>}
|
|
570
|
+
{typeof product.priceMinor === "number" && <p style={{ margin: 0 }}>{money(product.priceMinor, product.currency ?? "${currency}")}</p>}
|
|
571
|
+
</li>
|
|
572
|
+
))}
|
|
573
|
+
</ul>
|
|
574
|
+
</main>
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
`;
|
|
578
|
+
}
|
|
579
|
+
function renderFrontendWorkflow(manifest, dir) {
|
|
580
|
+
const at = dir ? `
|
|
581
|
+
working-directory: ${dir}` : "";
|
|
582
|
+
const out = dir ? `${dir}/${FRONTEND_OUT}` : FRONTEND_OUT;
|
|
583
|
+
const paths = dir ? ` - "${dir}/**"
|
|
584
|
+
- "package.json"
|
|
585
|
+
- "bun.lock"` : ` - "pages/**"
|
|
586
|
+
- "lib/**"
|
|
587
|
+
- "src/**"
|
|
588
|
+
- "public/**"
|
|
589
|
+
- "vite.config.ts"
|
|
590
|
+
- "void.json"
|
|
591
|
+
- "package.json"
|
|
592
|
+
- "bun.lock"`;
|
|
593
|
+
return `name: Publish the storefront
|
|
594
|
+
|
|
595
|
+
# Generated by \`vc init\` from voidcommerce.json.
|
|
596
|
+
#
|
|
597
|
+
# Builds the storefront and force-pushes the prerendered tree to
|
|
598
|
+
# \`${FRONTEND_BRANCH}\`. Point GitHub Pages (or Cloudflare Pages) at that branch
|
|
599
|
+
# and a push here is a live storefront. The worker deploys separately, from
|
|
600
|
+
# \`void-dist\`.
|
|
601
|
+
on:
|
|
602
|
+
push:
|
|
603
|
+
branches: [main, master]
|
|
604
|
+
paths:
|
|
605
|
+
${paths}
|
|
606
|
+
- ".github/workflows/frontend-static.yml"
|
|
607
|
+
workflow_dispatch:
|
|
608
|
+
|
|
609
|
+
# A later push wins: an older build must never overwrite a newer one.
|
|
610
|
+
concurrency:
|
|
611
|
+
group: ${FRONTEND_BRANCH}-\${{ github.repository }}
|
|
612
|
+
cancel-in-progress: true
|
|
613
|
+
|
|
614
|
+
# contents: write is what lets GITHUB_TOKEN force-push the branch.
|
|
615
|
+
permissions:
|
|
616
|
+
contents: write
|
|
617
|
+
|
|
618
|
+
env:
|
|
619
|
+
# Served from the root at the custom domain. Set a BASE_PATH repository
|
|
620
|
+
# variable to "/<repo>/" only while the site is still at <owner>.github.io/<repo>/.
|
|
621
|
+
BASE_PATH: \${{ vars.BASE_PATH || '/' }}
|
|
622
|
+
# The publish step force-pushes, which would otherwise wipe the CNAME file
|
|
623
|
+
# GitHub keeps in the branch.
|
|
624
|
+
CNAME: ${manifest.shop.domain}
|
|
625
|
+
|
|
626
|
+
jobs:
|
|
627
|
+
build:
|
|
628
|
+
runs-on: ubuntu-latest
|
|
629
|
+
steps:
|
|
630
|
+
- uses: actions/checkout@v6
|
|
631
|
+
- uses: oven-sh/setup-bun@v2
|
|
632
|
+
|
|
633
|
+
- run: bun install --frozen-lockfile
|
|
634
|
+
|
|
635
|
+
# Generates .void/* codegen without booting Vite.
|
|
636
|
+
- run: bunx void prepare${at}
|
|
637
|
+
|
|
638
|
+
# void.json sets output: "static", so this prerenders every page to HTML
|
|
639
|
+
# under ${FRONTEND_OUT} alongside the client bundle.
|
|
640
|
+
- run: bunx vp build${at}
|
|
641
|
+
|
|
642
|
+
- name: Rewrite absolute URLs onto the Pages base path${at}
|
|
643
|
+
run: |
|
|
644
|
+
set -euo pipefail
|
|
645
|
+
BASE="\${BASE_PATH%/}/"
|
|
646
|
+
if [ "$BASE" = "/" ]; then
|
|
647
|
+
echo "BASE_PATH is /, nothing to rewrite."
|
|
648
|
+
exit 0
|
|
649
|
+
fi
|
|
650
|
+
# Void emits root-absolute URLs (/assets/..., /_void/...) and has no
|
|
651
|
+
# Vite \`base\` support, so they are rewritten here for the subpath a
|
|
652
|
+
# project site is served from.
|
|
653
|
+
find ${FRONTEND_OUT} -type f \\( -name '*.html' -o -name '*.js' -o -name '*.css' \\) -print0 \\
|
|
654
|
+
| xargs -0 -r sed -i \\
|
|
655
|
+
-e "s|\\"/assets/|\\"\${BASE}assets/|g" \\
|
|
656
|
+
-e "s|'/assets/|'\${BASE}assets/|g" \\
|
|
657
|
+
-e "s|\\"/_void/|\\"\${BASE}_void/|g" \\
|
|
658
|
+
-e "s|'/_void/|'\${BASE}_void/|g"
|
|
659
|
+
echo "Rewrote absolute URLs onto \${BASE}"
|
|
660
|
+
|
|
661
|
+
- name: Prepare the published tree${at}
|
|
662
|
+
run: |
|
|
663
|
+
set -euo pipefail
|
|
664
|
+
# Without .nojekyll, Pages runs Jekyll and drops _void/ (and every
|
|
665
|
+
# other underscore-prefixed path) from the served site.
|
|
666
|
+
touch ${FRONTEND_OUT}/.nojekyll
|
|
667
|
+
# Unknown paths fall back to the prerendered entry page so the client
|
|
668
|
+
# router can take over instead of showing GitHub's 404.
|
|
669
|
+
[ -f ${FRONTEND_OUT}/index.html ] && cp ${FRONTEND_OUT}/index.html ${FRONTEND_OUT}/404.html
|
|
670
|
+
echo "\${CNAME}" > ${FRONTEND_OUT}/CNAME
|
|
671
|
+
ls -la ${FRONTEND_OUT}
|
|
672
|
+
|
|
673
|
+
- name: Publish to ${FRONTEND_BRANCH}
|
|
674
|
+
env:
|
|
675
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
676
|
+
run: |
|
|
677
|
+
set -euo pipefail
|
|
678
|
+
cd ${out}
|
|
679
|
+
# A fresh single-commit history each run: the branch is build output.
|
|
680
|
+
git init -q -b ${FRONTEND_BRANCH}
|
|
681
|
+
git config user.name "github-actions[bot]"
|
|
682
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
683
|
+
git add -A
|
|
684
|
+
git commit -q -m "Storefront \${GITHUB_SHA}"
|
|
685
|
+
git push -f "https://x-access-token:\${GITHUB_TOKEN}@github.com/\${{ github.repository }}.git" ${FRONTEND_BRANCH}
|
|
686
|
+
`;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// src/void.ts
|
|
690
|
+
import { spawn } from "node:child_process";
|
|
691
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
692
|
+
import { constants as os } from "node:os";
|
|
693
|
+
import { delimiter, dirname, join } from "node:path";
|
|
694
|
+
function findVoid(from = process.cwd()) {
|
|
695
|
+
let dir = from;
|
|
696
|
+
for (;; ) {
|
|
697
|
+
const local = join(dir, "node_modules", ".bin", "void");
|
|
698
|
+
if (existsSync(local))
|
|
699
|
+
return { cmd: local, prefix: [] };
|
|
700
|
+
const parent = dirname(dir);
|
|
701
|
+
if (parent === dir)
|
|
702
|
+
break;
|
|
703
|
+
dir = parent;
|
|
704
|
+
}
|
|
705
|
+
if (onPath("void"))
|
|
706
|
+
return { cmd: "void", prefix: [] };
|
|
707
|
+
if (onPath("bunx"))
|
|
708
|
+
return { cmd: "bunx", prefix: ["void"] };
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
function onPath(bin) {
|
|
712
|
+
return (process.env["PATH"] ?? "").split(delimiter).filter(Boolean).some((dir) => existsSync(join(dir, bin)));
|
|
713
|
+
}
|
|
714
|
+
var VOID_MISSING = "`void` is not installed here. `bun add -d void` — Void recommends a local install — then try again.";
|
|
715
|
+
function runVoid(args, cwd = process.cwd()) {
|
|
716
|
+
const bin = findVoid(cwd);
|
|
717
|
+
if (!bin) {
|
|
718
|
+
console.error(VOID_MISSING);
|
|
719
|
+
return Promise.resolve(1);
|
|
720
|
+
}
|
|
721
|
+
return runInherit(bin.cmd, [...bin.prefix, ...args], cwd);
|
|
722
|
+
}
|
|
723
|
+
function runInherit(cmd, args, cwd) {
|
|
724
|
+
return new Promise((resolve) => {
|
|
725
|
+
const child = spawn(cmd, args, { cwd, stdio: "inherit" });
|
|
726
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
727
|
+
const forward = signals.map((signal) => [signal, () => child.kill(signal)]);
|
|
728
|
+
for (const [signal, handler] of forward)
|
|
729
|
+
process.on(signal, handler);
|
|
730
|
+
child.on("error", () => {
|
|
731
|
+
console.error(VOID_MISSING);
|
|
732
|
+
resolve(1);
|
|
733
|
+
});
|
|
734
|
+
child.on("exit", (code, signal) => {
|
|
735
|
+
for (const [name, handler] of forward)
|
|
736
|
+
process.off(name, handler);
|
|
737
|
+
resolve(code ?? (signal ? 128 + (os.signals[signal] ?? 0) : 1));
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
function captureVoid(args, cwd = process.cwd()) {
|
|
742
|
+
const bin = findVoid(cwd);
|
|
743
|
+
if (!bin)
|
|
744
|
+
return Promise.resolve(null);
|
|
745
|
+
const wantColor = Boolean(process.stdout.isTTY) && !process.env["NO_COLOR"];
|
|
746
|
+
return new Promise((resolve) => {
|
|
747
|
+
const child = spawn(bin.cmd, [...bin.prefix, ...args], {
|
|
748
|
+
cwd,
|
|
749
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
750
|
+
env: { ...process.env, ...wantColor ? { FORCE_COLOR: "1" } : {} }
|
|
751
|
+
});
|
|
752
|
+
let out = "";
|
|
753
|
+
child.stdout.on("data", (chunk) => {
|
|
754
|
+
out += chunk;
|
|
755
|
+
});
|
|
756
|
+
child.stderr.on("data", (chunk) => {
|
|
757
|
+
out += chunk;
|
|
758
|
+
});
|
|
759
|
+
child.on("error", () => resolve(null));
|
|
760
|
+
child.on("exit", (code) => resolve({ code: code ?? 1, out }));
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
function isVoidApp(dir) {
|
|
764
|
+
if (existsSync(join(dir, "void.json")))
|
|
765
|
+
return true;
|
|
766
|
+
for (const name of ["vite.config.ts", "vite.config.mts", "vite.config.js", "vite.config.mjs"]) {
|
|
767
|
+
const path = join(dir, name);
|
|
768
|
+
if (!existsSync(path))
|
|
769
|
+
continue;
|
|
770
|
+
if (/from\s+["']void["']|voidPlugin\s*\(/.test(readFileSync(path, "utf8")))
|
|
771
|
+
return true;
|
|
772
|
+
}
|
|
773
|
+
return false;
|
|
774
|
+
}
|
|
775
|
+
function voidAppsIn(dir) {
|
|
776
|
+
return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules").map((entry) => join(dir, entry.name)).filter(isVoidApp);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// src/generate/strict.ts
|
|
780
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
781
|
+
import { join as join2 } from "node:path";
|
|
782
|
+
var STRICT_APP = join2(".vc", "app");
|
|
783
|
+
var VOID_VERSION = "0.10.13";
|
|
784
|
+
function strictDependencies(manifest) {
|
|
785
|
+
const dependencies = {};
|
|
786
|
+
for (const name of [
|
|
787
|
+
...packagesOf(manifest),
|
|
788
|
+
"@saastemly/better-admin-ui",
|
|
789
|
+
"@better-auth-ui/core",
|
|
790
|
+
"@better-auth-ui/react",
|
|
791
|
+
"@radix-ui/react-tooltip",
|
|
792
|
+
"@tanstack/react-query",
|
|
793
|
+
"hono",
|
|
794
|
+
"react",
|
|
795
|
+
"react-dom",
|
|
796
|
+
"react-router",
|
|
797
|
+
"sonner",
|
|
798
|
+
"zod"
|
|
799
|
+
])
|
|
800
|
+
dependencies[name] = specifier(manifest, name);
|
|
801
|
+
const devDependencies = {};
|
|
802
|
+
for (const name of [
|
|
803
|
+
"@hono/node-server",
|
|
804
|
+
"@rolldown/plugin-babel",
|
|
805
|
+
"@tailwindcss/vite",
|
|
806
|
+
"@types/node",
|
|
807
|
+
"@types/react",
|
|
808
|
+
"@types/react-dom",
|
|
809
|
+
"@vitejs/plugin-react",
|
|
810
|
+
"babel-plugin-react-compiler",
|
|
811
|
+
"maildev",
|
|
812
|
+
"tailwindcss",
|
|
813
|
+
"tw-animate-css",
|
|
814
|
+
"typescript",
|
|
815
|
+
"vite",
|
|
816
|
+
"vite-plus"
|
|
817
|
+
])
|
|
818
|
+
devDependencies[name] = specifier(manifest, name);
|
|
819
|
+
devDependencies["@saastemly/voidcommerce"] = specifier(manifest, "@saastemly/voidcommerce");
|
|
820
|
+
devDependencies["void"] = VOID_VERSION;
|
|
821
|
+
devDependencies["@void/react"] = VOID_VERSION;
|
|
822
|
+
return {
|
|
823
|
+
dependencies: Object.fromEntries(Object.entries(dependencies).sort()),
|
|
824
|
+
devDependencies: Object.fromEntries(Object.entries(devDependencies).sort()),
|
|
825
|
+
patchedDependencies: { [`void@${VOID_VERSION}`]: `patches/void@${VOID_VERSION}.patch` }
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
function renderVoidPatch() {
|
|
829
|
+
return `diff --git a/dist/better-auth-shared-BQooDxbw.mjs b/dist/better-auth-shared-BQooDxbw.mjs
|
|
830
|
+
index dffd76b7f502f273daf2c63cef3ad8c21b90e68b..454ba59eee004d34616f717b12a770c111b61762 100644
|
|
831
|
+
--- a/dist/better-auth-shared-BQooDxbw.mjs
|
|
832
|
+
+++ b/dist/better-auth-shared-BQooDxbw.mjs
|
|
833
|
+
@@ -82,11 +82,17 @@ function resolveSocialProviders(env, configuredProviders) {
|
|
834
|
+
function appendAuthHeaders(target, source) {
|
|
835
|
+
if (!source) return;
|
|
836
|
+
source.forEach((value, key) => {
|
|
837
|
+
- if (key.toLowerCase() === "set-cookie") {
|
|
838
|
+
- target.append(key, value);
|
|
839
|
+
- return;
|
|
840
|
+
+ try {
|
|
841
|
+
+ if (key.toLowerCase() === "set-cookie") {
|
|
842
|
+
+ target.append(key, value);
|
|
843
|
+
+ return;
|
|
844
|
+
+ }
|
|
845
|
+
+ target.set(key, value);
|
|
846
|
+
+ } catch {
|
|
847
|
+
+ // Immutable response headers (e.g. Vite dev asset responses in
|
|
848
|
+
+ // pages mode). Auth headers are irrelevant for those responses;
|
|
849
|
+
+ // dropping them beats throwing a 500 for the whole request.
|
|
850
|
+
}
|
|
851
|
+
- target.set(key, value);
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
function resolveUserConfig(userConfig, defaults, request, env, dialect) {
|
|
855
|
+
`;
|
|
856
|
+
}
|
|
857
|
+
function renderViteConfig(manifest) {
|
|
858
|
+
const clients = [
|
|
859
|
+
...has(manifest, "passkey") ? [`"@better-auth/passkey/client"`] : [],
|
|
860
|
+
...has(manifest, "api-key") ? [`"@better-auth/api-key/client"`] : []
|
|
861
|
+
];
|
|
862
|
+
const list = (items) => items.map((item) => ` ${item},`).join(`
|
|
863
|
+
`);
|
|
864
|
+
return `// GENERATED by \`vc generate\`. Do not edit: this file is rewritten from voidcommerce.json.
|
|
865
|
+
import babel from "@rolldown/plugin-babel";
|
|
866
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
867
|
+
import { reactCompilerPreset } from "@vitejs/plugin-react";
|
|
868
|
+
import path from "node:path";
|
|
869
|
+
import { defineConfig } from "vite";
|
|
870
|
+
import { voidPlugin } from "void";
|
|
871
|
+
import { voidReact } from "@void/react/plugin";
|
|
872
|
+
|
|
873
|
+
export default defineConfig({
|
|
874
|
+
// The worker answers CORS, not Vite — same behaviour locally as deployed.
|
|
875
|
+
server: { cors: false },
|
|
876
|
+
preview: { cors: false },
|
|
877
|
+
// Pre-bundle react-hook-form as its own entry, or Vite inlines a second
|
|
878
|
+
// copy inside ra-core and every list screen with filters dies on
|
|
879
|
+
// "useFormContext(...) is null".
|
|
880
|
+
optimizeDeps: {
|
|
881
|
+
include: [
|
|
882
|
+
"react-hook-form",
|
|
883
|
+
"ra-core",
|
|
884
|
+
"react-router",
|
|
885
|
+
"@tanstack/react-query",
|
|
886
|
+
${list(clients)}
|
|
887
|
+
],
|
|
888
|
+
},
|
|
889
|
+
// The auth client's plugins are evaluated during SSR inside workerd, which
|
|
890
|
+
// resolves through Vite's runner rather than from disk.
|
|
891
|
+
ssr: {
|
|
892
|
+
optimizeDeps: {
|
|
893
|
+
include: [
|
|
894
|
+
${list(clients)}
|
|
895
|
+
],
|
|
896
|
+
},
|
|
897
|
+
},
|
|
898
|
+
resolve: {
|
|
899
|
+
alias: [
|
|
900
|
+
{ find: "@", replacement: path.resolve(import.meta.dirname, ".") },
|
|
901
|
+
// Vite 8 cannot resolve this export from app source; point it at the built file.
|
|
902
|
+
{
|
|
903
|
+
find: /^better-auth\\/client\\/plugins$/,
|
|
904
|
+
replacement: path.resolve(import.meta.dirname, "node_modules/better-auth/dist/client/plugins/index.mjs"),
|
|
905
|
+
},
|
|
906
|
+
],
|
|
907
|
+
// One copy of everything that carries React context or module-level state.
|
|
908
|
+
dedupe: [
|
|
909
|
+
"react",
|
|
910
|
+
"react-dom",
|
|
911
|
+
"react-router",
|
|
912
|
+
"ra-core",
|
|
913
|
+
"react-hook-form",
|
|
914
|
+
"@tanstack/react-query",
|
|
915
|
+
"@better-auth-ui/core",
|
|
916
|
+
"@better-auth-ui/react",
|
|
917
|
+
"next-themes",
|
|
918
|
+
],
|
|
919
|
+
},
|
|
920
|
+
plugins: [babel({ presets: [reactCompilerPreset()] }), tailwindcss(), voidPlugin(), voidReact()],
|
|
921
|
+
});
|
|
922
|
+
`;
|
|
923
|
+
}
|
|
924
|
+
function renderStrictTsconfig() {
|
|
925
|
+
return `{
|
|
926
|
+
"extends": "./.void/tsconfig.json",
|
|
927
|
+
"compilerOptions": {
|
|
928
|
+
"target": "ES2022",
|
|
929
|
+
"module": "ESNext",
|
|
930
|
+
"moduleResolution": "bundler",
|
|
931
|
+
"resolveJsonModule": true,
|
|
932
|
+
"strict": true,
|
|
933
|
+
"esModuleInterop": true,
|
|
934
|
+
"skipLibCheck": true,
|
|
935
|
+
"noEmit": true,
|
|
936
|
+
"allowImportingTsExtensions": true,
|
|
937
|
+
"types": ["void/env"],
|
|
938
|
+
"jsx": "react-jsx",
|
|
939
|
+
"paths": {
|
|
940
|
+
"@/*": ["./*"],
|
|
941
|
+
"void/db": ["./.void/db.d.ts"],
|
|
942
|
+
"void/routes": ["./.void/routes.d.ts"],
|
|
943
|
+
"@schema": ["./db/schema.ts"],
|
|
944
|
+
"@schema/*": ["./db/schema/*"],
|
|
945
|
+
"better-auth": ["./node_modules/better-auth"],
|
|
946
|
+
"better-auth/*": ["./node_modules/better-auth/*"],
|
|
947
|
+
"@better-auth/core": ["./node_modules/@better-auth/core"],
|
|
948
|
+
"@better-auth/core/*": ["./node_modules/@better-auth/core/*"]
|
|
949
|
+
}
|
|
950
|
+
},
|
|
951
|
+
"include": ["src", "pages", "routes", "middleware", "crons", "queues", "db", "lib", "auth.ts", "env.ts"]
|
|
952
|
+
}
|
|
953
|
+
`;
|
|
954
|
+
}
|
|
955
|
+
function renderVoidJson() {
|
|
956
|
+
return `{
|
|
957
|
+
"$schema": "./node_modules/void/schema.json",
|
|
958
|
+
"worker": {
|
|
959
|
+
"compatibility_date": "2026-09-01"
|
|
960
|
+
},
|
|
961
|
+
"auth": {
|
|
962
|
+
"providers": ["email"]
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
`;
|
|
966
|
+
}
|
|
967
|
+
function renderDbSchema() {
|
|
968
|
+
return `/**
|
|
969
|
+
* Re-export the Better Auth + betterCommerce schema so \`void db generate\`
|
|
970
|
+
* emits migrations for it.
|
|
971
|
+
*
|
|
972
|
+
* Deploying to your OWN Cloudflare account never creates the auth tables at
|
|
973
|
+
* runtime — the checked-in migrations are what production applies. Without
|
|
974
|
+
* this line the app deploys with no \`user\` table. \`.void/better-auth-schema\`
|
|
975
|
+
* is written by \`void prepare\`, which \`vc generate\` runs.
|
|
976
|
+
*/
|
|
977
|
+
export * from "../.void/better-auth-schema";
|
|
978
|
+
`;
|
|
979
|
+
}
|
|
980
|
+
function renderDbSeed() {
|
|
981
|
+
return `import { defineSeed } from "void/seed";
|
|
982
|
+
|
|
983
|
+
/** Nothing: the catalogue is IMPORTED by \`vc import\` from data/, the same way in development and production. */
|
|
984
|
+
export default defineSeed<typeof import("./schema")>(async () => {
|
|
985
|
+
console.log("nothing to seed — run vc import");
|
|
986
|
+
});
|
|
987
|
+
`;
|
|
988
|
+
}
|
|
989
|
+
function renderLayoutTsx() {
|
|
990
|
+
return `import type { ComponentProps, ComponentPropsWithoutRef, ComponentType, PropsWithChildren } from "react";
|
|
991
|
+
import { Link, useRouter } from "@void/react";
|
|
992
|
+
import { AuthProvider } from "@better-auth-ui/react";
|
|
993
|
+
import { adminPlugin } from "@better-auth-ui/core/plugins/admin";
|
|
994
|
+
import { apiDashboardPlugin } from "@saastemly/better-admin-ui/dashboard";
|
|
995
|
+
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
|
996
|
+
import { Toaster } from "sonner";
|
|
997
|
+
import { authClient } from "@/lib/auth-client";
|
|
998
|
+
import "./app.css";
|
|
999
|
+
|
|
1000
|
+
declare module "@better-auth-ui/core" {
|
|
1001
|
+
interface AuthConfig {
|
|
1002
|
+
/** The component internal links render with — Void's. */
|
|
1003
|
+
Link: ComponentType<
|
|
1004
|
+
PropsWithChildren<
|
|
1005
|
+
{ className?: string; href: string; to?: string } & Pick<ComponentPropsWithoutRef<"a">, "aria-disabled" | "tabIndex" | "onClick">
|
|
1006
|
+
>
|
|
1007
|
+
>;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Providers in, chrome out. The whole interface is /api-dashboard, generated
|
|
1013
|
+
* from the schema; this layout only wires the auth context it reads.
|
|
1014
|
+
*/
|
|
1015
|
+
export default function RootLayout({ children }: PropsWithChildren) {
|
|
1016
|
+
const router = useRouter();
|
|
1017
|
+
return (
|
|
1018
|
+
<TooltipProvider>
|
|
1019
|
+
<AuthProvider
|
|
1020
|
+
authClient={authClient}
|
|
1021
|
+
navigate={({ to, replace }) => router.visit(to, { replace })}
|
|
1022
|
+
redirectTo="/api-dashboard"
|
|
1023
|
+
Link={({ href, onClick, ...props }) => (
|
|
1024
|
+
<Link href={href} {...props} onClick={onClick as ComponentProps<typeof Link>["onClick"]} />
|
|
1025
|
+
)}
|
|
1026
|
+
plugins={[adminPlugin(), apiDashboardPlugin()]}
|
|
1027
|
+
>
|
|
1028
|
+
{children}
|
|
1029
|
+
<Toaster />
|
|
1030
|
+
</AuthProvider>
|
|
1031
|
+
</TooltipProvider>
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
`;
|
|
1035
|
+
}
|
|
1036
|
+
function renderAppCss() {
|
|
1037
|
+
return `@import "tailwindcss";
|
|
1038
|
+
/* The panel ships built; Tailwind must scan the package for its class names. */
|
|
1039
|
+
@source "../node_modules/better-admin-ui/dist";
|
|
1040
|
+
@import "tw-animate-css";
|
|
1041
|
+
|
|
1042
|
+
@custom-variant dark (&:is(.dark *));
|
|
1043
|
+
|
|
1044
|
+
:root {
|
|
1045
|
+
--radius: 0.625rem;
|
|
1046
|
+
--background: oklch(1 0 0);
|
|
1047
|
+
--foreground: oklch(0.145 0 0);
|
|
1048
|
+
--card: oklch(1 0 0);
|
|
1049
|
+
--card-foreground: oklch(0.145 0 0);
|
|
1050
|
+
--popover: oklch(1 0 0);
|
|
1051
|
+
--popover-foreground: oklch(0.145 0 0);
|
|
1052
|
+
--primary: oklch(0.205 0 0);
|
|
1053
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
1054
|
+
--secondary: oklch(0.97 0 0);
|
|
1055
|
+
--secondary-foreground: oklch(0.205 0 0);
|
|
1056
|
+
--muted: oklch(0.97 0 0);
|
|
1057
|
+
--muted-foreground: oklch(0.556 0 0);
|
|
1058
|
+
--accent: oklch(0.97 0 0);
|
|
1059
|
+
--accent-foreground: oklch(0.205 0 0);
|
|
1060
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
1061
|
+
--border: oklch(0.922 0 0);
|
|
1062
|
+
--input: oklch(0.922 0 0);
|
|
1063
|
+
--ring: oklch(0.708 0 0);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
.dark {
|
|
1067
|
+
--background: oklch(0.145 0 0);
|
|
1068
|
+
--foreground: oklch(0.985 0 0);
|
|
1069
|
+
--card: oklch(0.205 0 0);
|
|
1070
|
+
--card-foreground: oklch(0.985 0 0);
|
|
1071
|
+
--popover: oklch(0.205 0 0);
|
|
1072
|
+
--popover-foreground: oklch(0.985 0 0);
|
|
1073
|
+
--primary: oklch(0.922 0 0);
|
|
1074
|
+
--primary-foreground: oklch(0.205 0 0);
|
|
1075
|
+
--secondary: oklch(0.269 0 0);
|
|
1076
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
1077
|
+
--muted: oklch(0.269 0 0);
|
|
1078
|
+
--muted-foreground: oklch(0.708 0 0);
|
|
1079
|
+
--accent: oklch(0.269 0 0);
|
|
1080
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
1081
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
1082
|
+
--border: oklch(1 0 0 / 10%);
|
|
1083
|
+
--input: oklch(1 0 0 / 15%);
|
|
1084
|
+
--ring: oklch(0.556 0 0);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
@theme inline {
|
|
1088
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
1089
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
1090
|
+
--radius-lg: var(--radius);
|
|
1091
|
+
--radius-xl: calc(var(--radius) + 4px);
|
|
1092
|
+
--color-background: var(--background);
|
|
1093
|
+
--color-foreground: var(--foreground);
|
|
1094
|
+
--color-card: var(--card);
|
|
1095
|
+
--color-card-foreground: var(--card-foreground);
|
|
1096
|
+
--color-popover: var(--popover);
|
|
1097
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
1098
|
+
--color-primary: var(--primary);
|
|
1099
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
1100
|
+
--color-secondary: var(--secondary);
|
|
1101
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
1102
|
+
--color-muted: var(--muted);
|
|
1103
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
1104
|
+
--color-accent: var(--accent);
|
|
1105
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
1106
|
+
--color-destructive: var(--destructive);
|
|
1107
|
+
--color-border: var(--border);
|
|
1108
|
+
--color-input: var(--input);
|
|
1109
|
+
--color-ring: var(--ring);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
@layer base {
|
|
1113
|
+
* {
|
|
1114
|
+
@apply border-border outline-ring/50;
|
|
1115
|
+
}
|
|
1116
|
+
body {
|
|
1117
|
+
@apply bg-background text-foreground;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
`;
|
|
1121
|
+
}
|
|
1122
|
+
function renderIndexServer() {
|
|
1123
|
+
return `import { defineHandler } from "void";
|
|
1124
|
+
|
|
1125
|
+
/** No home page: the interface is /api-dashboard, so send a browser there. */
|
|
1126
|
+
export const loader = defineHandler((c) => c.redirect("/api-dashboard"));
|
|
1127
|
+
`;
|
|
1128
|
+
}
|
|
1129
|
+
var INDEX_PAGE = `/** Never rendered: index.server.ts redirects to /api-dashboard first. */
|
|
1130
|
+
export default function HomePage() {
|
|
1131
|
+
return null;
|
|
1132
|
+
}
|
|
1133
|
+
`;
|
|
1134
|
+
function renderDashboardPage(manifest) {
|
|
1135
|
+
return `import { lazy, Suspense } from "react";
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Mounted, not owned. The panel builds itself from the resolved schema, and
|
|
1139
|
+
* what a caller sees is the result of authorization — so no session check
|
|
1140
|
+
* here: a visitor gets the public surface, a customer their rows, staff all.
|
|
1141
|
+
* Lazy, because the panel's module graph must not reach the worker.
|
|
1142
|
+
*/
|
|
1143
|
+
const Dashboard = lazy(async () => {
|
|
1144
|
+
const [{ ApiDashboard }, { authClient }] = await Promise.all([import("@saastemly/better-admin-ui/dashboard"), import("@/lib/auth-client")]);
|
|
1145
|
+
return {
|
|
1146
|
+
default: () => <ApiDashboard fetch={authClient.$fetch} title=${JSON.stringify(manifest.shop.name)} signInPath="/sign-in" />,
|
|
1147
|
+
};
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
export default function ApiDashboardPage() {
|
|
1151
|
+
return (
|
|
1152
|
+
<Suspense fallback={<p className="text-muted-foreground p-6 text-sm">Generating the dashboard…</p>}>
|
|
1153
|
+
<Dashboard />
|
|
1154
|
+
</Suspense>
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
`;
|
|
1158
|
+
}
|
|
1159
|
+
var CLIENT_ONLY = `// Client-mounted: the kit's module graph must stay out of the Cloudflare worker.
|
|
1160
|
+
export const ssr = false;
|
|
1161
|
+
`;
|
|
1162
|
+
function renderSignInPage(manifest) {
|
|
1163
|
+
const socials = ["google", "apple", "github"].filter((id) => has(manifest, id)).map((id) => `{ id: "${id}", label: "${id[0].toUpperCase()}${id.slice(1)}" }`);
|
|
1164
|
+
return `import { lazy, Suspense } from "react";
|
|
1165
|
+
|
|
1166
|
+
/** The panel's front door, drawn from what the auth client can actually do. */
|
|
1167
|
+
const SignIn = lazy(async () => {
|
|
1168
|
+
const [{ ApiSignIn }, { authClient }] = await Promise.all([import("@saastemly/better-admin-ui/dashboard"), import("@/lib/auth-client")]);
|
|
1169
|
+
return {
|
|
1170
|
+
default: () => (
|
|
1171
|
+
<ApiSignIn authClient={authClient} callbackURL="/api-dashboard" title=${JSON.stringify(manifest.shop.name)} socialProviders={[${socials.join(", ")}]} />
|
|
1172
|
+
),
|
|
1173
|
+
};
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
export default function SignInPage() {
|
|
1177
|
+
return (
|
|
1178
|
+
<Suspense fallback={null}>
|
|
1179
|
+
<SignIn />
|
|
1180
|
+
</Suspense>
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
`;
|
|
1184
|
+
}
|
|
1185
|
+
function renderAuthClient(manifest) {
|
|
1186
|
+
const imports = [
|
|
1187
|
+
...has(manifest, "api-key") ? [`import { apiKeyClient } from "@better-auth/api-key/client";`] : [],
|
|
1188
|
+
...has(manifest, "passkey") ? [`import { passkeyClient } from "@better-auth/passkey/client";`] : [],
|
|
1189
|
+
`import { commerceClient } from "@saastemly/better-commerce/client";`,
|
|
1190
|
+
`import { createAuthClient } from "void/client/react";`
|
|
1191
|
+
];
|
|
1192
|
+
const plugins = [
|
|
1193
|
+
"commerceClient()",
|
|
1194
|
+
...has(manifest, "passkey") ? ["passkeyClient()"] : [],
|
|
1195
|
+
...has(manifest, "api-key") ? ["apiKeyClient()"] : []
|
|
1196
|
+
];
|
|
1197
|
+
return `${imports.join(`
|
|
1198
|
+
`)}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* The client plugins that mirror the server's, so typed calls are inferred.
|
|
1202
|
+
* Magic link and email codes are plain POSTs the sign-in screen makes with
|
|
1203
|
+
* \`$fetch\`; they need no client plugin.
|
|
1204
|
+
*/
|
|
1205
|
+
export const authClient = createAuthClient({
|
|
1206
|
+
basePath: "/api/auth",
|
|
1207
|
+
plugins: [${plugins.join(", ")}],
|
|
1208
|
+
});
|
|
1209
|
+
`;
|
|
1210
|
+
}
|
|
1211
|
+
function renderContentTs(manifest) {
|
|
1212
|
+
const parts = [];
|
|
1213
|
+
if (has(manifest, "faqs")) {
|
|
1214
|
+
parts.push(`import type { faqs } from "@saastemly/better-faqs";
|
|
1215
|
+
import faqsJson from "../content/faqs.json" with { type: "json" };
|
|
1216
|
+
type FaqEntries = NonNullable<NonNullable<Parameters<typeof faqs>[0]>["entries"]>;
|
|
1217
|
+
export const faqEntries = faqsJson as FaqEntries;`);
|
|
1218
|
+
}
|
|
1219
|
+
if (has(manifest, "blogs")) {
|
|
1220
|
+
parts.push(`import type { blogs } from "@saastemly/better-blogs";
|
|
1221
|
+
import postsJson from "../content/posts.json" with { type: "json" };
|
|
1222
|
+
type Posts = NonNullable<NonNullable<Parameters<typeof blogs>[0]>["posts"]>;
|
|
1223
|
+
export const posts = postsJson as Posts;`);
|
|
1224
|
+
}
|
|
1225
|
+
return `// GENERATED by \`vc generate\` from content/. Edit content/*.json, not this.
|
|
1226
|
+
${parts.join(`
|
|
1227
|
+
|
|
1228
|
+
`)}
|
|
1229
|
+
`;
|
|
1230
|
+
}
|
|
1231
|
+
function renderCron() {
|
|
1232
|
+
return `import { defineScheduled } from "void";
|
|
1233
|
+
import { shopDomain } from "../lib/domain.ts";
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* The commerce cron runs the work the plugins say is TIME-driven. It asks
|
|
1237
|
+
* the job registry what exists and runs what matches the expression that
|
|
1238
|
+
* fired, so installing a plugin brings its scheduled work with it. Reactive
|
|
1239
|
+
* work is the queue's; the daily pass is the reconciliation backstop.
|
|
1240
|
+
*/
|
|
1241
|
+
export const cron = ["0 * * * *", "0 3 * * *", "0 4 * * *"];
|
|
1242
|
+
|
|
1243
|
+
interface Job {
|
|
1244
|
+
id: string;
|
|
1245
|
+
schedule: string | null;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
export default defineScheduled(async (controller, env) => {
|
|
1249
|
+
const shop = String(env["SHOP_DOMAIN"] ?? "");
|
|
1250
|
+
const appUrl = String(env["APP_URL"] ?? (shop ? shopDomain(shop).appUrl : "http://localhost:5173"));
|
|
1251
|
+
const headers = { "content-type": "application/json", origin: appUrl, "x-commerce-cron-secret": String(env["COMMERCE_CRON_SECRET"] ?? "") };
|
|
1252
|
+
|
|
1253
|
+
const listed = await fetch(new URL("/api/auth/commerce/admin/jobs", appUrl), { headers });
|
|
1254
|
+
if (!listed.ok) {
|
|
1255
|
+
console.error(\`[commerce-cron] cannot read the job registry: \${listed.status}\`);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
const { jobs } = (await listed.json()) as { jobs: Job[] };
|
|
1259
|
+
for (const job of jobs.filter((job) => job.schedule === controller.cron)) {
|
|
1260
|
+
try {
|
|
1261
|
+
const res = await fetch(new URL("/api/auth/commerce/admin/jobs/run", appUrl), { method: "POST", headers, body: JSON.stringify({ jobId: job.id }) });
|
|
1262
|
+
console.log(\`[commerce-cron] \${job.id} → \${res.status}\`);
|
|
1263
|
+
} catch (error) {
|
|
1264
|
+
// One failing job must not strand the ones after it.
|
|
1265
|
+
console.error(\`[commerce-cron] \${job.id} threw\`, error);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
});
|
|
1269
|
+
`;
|
|
1270
|
+
}
|
|
1271
|
+
function renderQueue() {
|
|
1272
|
+
return `import { defineQueue } from "void";
|
|
1273
|
+
import { shopDomain } from "../lib/domain.ts";
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* One consumer for the whole plugin ring: the message names the job, the
|
|
1277
|
+
* registry knows how to run it. A message is a nudge, not the record — the
|
|
1278
|
+
* rows are — so a lost message costs latency until the sweep, never the work.
|
|
1279
|
+
*/
|
|
1280
|
+
interface CommerceJobMessage {
|
|
1281
|
+
jobId: string;
|
|
1282
|
+
payload?: Record<string, unknown>;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
export const maxBatchSize = 10;
|
|
1286
|
+
export const maxBatchTimeout = 2;
|
|
1287
|
+
export const maxRetries = 3;
|
|
1288
|
+
export const retryDelay = 30;
|
|
1289
|
+
|
|
1290
|
+
export default defineQueue<CommerceJobMessage>(async (batch, env) => {
|
|
1291
|
+
const shop = String(env["SHOP_DOMAIN"] ?? "");
|
|
1292
|
+
const appUrl = String(env["APP_URL"] ?? (shop ? shopDomain(shop).appUrl : "http://localhost:5173"));
|
|
1293
|
+
const headers = { "content-type": "application/json", origin: appUrl, "x-commerce-cron-secret": String(env["COMMERCE_CRON_SECRET"] ?? "") };
|
|
1294
|
+
|
|
1295
|
+
// Ten events in a burst all ask for the same job, which drains every due row in one run.
|
|
1296
|
+
const wanted = new Map<string, CommerceJobMessage>();
|
|
1297
|
+
for (const message of batch.messages) if (message.body?.jobId) wanted.set(message.body.jobId, message.body);
|
|
1298
|
+
|
|
1299
|
+
for (const [jobId, body] of wanted) {
|
|
1300
|
+
try {
|
|
1301
|
+
const res = await fetch(new URL("/api/auth/commerce/admin/jobs/run", appUrl), { method: "POST", headers, body: JSON.stringify({ jobId, payload: body.payload }) });
|
|
1302
|
+
if (!res.ok) throw new Error(\`\${res.status} \${(await res.text()).slice(0, 200)}\`);
|
|
1303
|
+
} catch (error) {
|
|
1304
|
+
console.error(\`[commerce-queue] \${jobId} failed, retrying\`, error);
|
|
1305
|
+
for (const message of batch.messages) if (message.body?.jobId === jobId) message.retry({ delaySeconds: retryDelay });
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
for (const message of batch.messages) if (message.body?.jobId === jobId) message.ack();
|
|
1309
|
+
}
|
|
1310
|
+
});
|
|
1311
|
+
`;
|
|
1312
|
+
}
|
|
1313
|
+
function renderLiveStream() {
|
|
1314
|
+
return `import { defineLiveStream } from "void/live";
|
|
1315
|
+
|
|
1316
|
+
/**
|
|
1317
|
+
* The app's one live stream. Topics are minted by the plugins; this owns the
|
|
1318
|
+
* transport. In src/ because that is where Void scans for the Durable Object
|
|
1319
|
+
* binding — defined elsewhere, every connection fails with a missing binding.
|
|
1320
|
+
* Every subscription is checked with the commerce plugin and fails closed.
|
|
1321
|
+
*/
|
|
1322
|
+
export const live = defineLiveStream({
|
|
1323
|
+
id: "commerce",
|
|
1324
|
+
allowAnonymousControl: true,
|
|
1325
|
+
async onSubscribe({ request, topic }) {
|
|
1326
|
+
const deny = () => new Response(JSON.stringify({ error: "not allowed to subscribe to that topic" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
1327
|
+
try {
|
|
1328
|
+
const res = await fetch(new URL("/api/auth/commerce/streams/authorize", request.url), {
|
|
1329
|
+
method: "POST",
|
|
1330
|
+
headers: { "content-type": "application/json", origin: new URL(request.url).origin, cookie: request.headers.get("cookie") ?? "" },
|
|
1331
|
+
body: JSON.stringify({ topic }),
|
|
1332
|
+
});
|
|
1333
|
+
if (!res.ok) return deny();
|
|
1334
|
+
return (await res.json() as { allowed?: boolean }).allowed === true ? undefined : deny();
|
|
1335
|
+
} catch {
|
|
1336
|
+
return deny();
|
|
1337
|
+
}
|
|
1338
|
+
},
|
|
1339
|
+
});
|
|
1340
|
+
`;
|
|
1341
|
+
}
|
|
1342
|
+
function renderLiveRoute() {
|
|
1343
|
+
return `import { defineHandler } from "void";
|
|
1344
|
+
import { live } from "../src/live";
|
|
1345
|
+
|
|
1346
|
+
/** GET opens the SSE connection, POST carries subscribe/unsubscribe. */
|
|
1347
|
+
export const GET = defineHandler((c) => live.connect(c));
|
|
1348
|
+
export const POST = defineHandler((c) => live.control(c));
|
|
1349
|
+
`;
|
|
1350
|
+
}
|
|
1351
|
+
function renderStrictAppPackageJson(manifest) {
|
|
1352
|
+
const deps = strictDependencies(manifest);
|
|
1353
|
+
return `${JSON.stringify({
|
|
1354
|
+
name: `${manifest.shop.domain.split(".")[0]}-app`,
|
|
1355
|
+
private: true,
|
|
1356
|
+
type: "module",
|
|
1357
|
+
scripts: { dev: "vp dev", build: "vp build", preview: "vp preview" },
|
|
1358
|
+
dependencies: deps.dependencies,
|
|
1359
|
+
devDependencies: deps.devDependencies
|
|
1360
|
+
}, null, 2)}
|
|
1361
|
+
`;
|
|
1362
|
+
}
|
|
1363
|
+
var DATA_README = `# data/
|
|
1364
|
+
|
|
1365
|
+
The catalogue: \`catalog.json\` and \`categories.json\`, pushed into the running
|
|
1366
|
+
shop by \`vc import\` — the same operation in development and production. The
|
|
1367
|
+
worker never carries a copy.
|
|
1368
|
+
`;
|
|
1369
|
+
var BRANDING_README = `# branding/
|
|
1370
|
+
|
|
1371
|
+
Static assets — logo, favicon, images. Copied to \`public/branding/\` at
|
|
1372
|
+
generate time and served at \`/branding/<file>\`.
|
|
1373
|
+
`;
|
|
1374
|
+
var MIGRATIONS_README = `# migrations/
|
|
1375
|
+
|
|
1376
|
+
What production has already applied. \`void db generate\` writes here through
|
|
1377
|
+
\`.vc/app/db/migrations\`, and a deploy verifies these produce the schema.
|
|
1378
|
+
Commit them: a migration regenerated on a fresh clone would get a new name,
|
|
1379
|
+
and the deploy would try to apply it twice.
|
|
1380
|
+
`;
|
|
1381
|
+
async function finishStrict(root) {
|
|
1382
|
+
if (!existsSync2(join2(root, "node_modules"))) {
|
|
1383
|
+
console.log("vc: no node_modules yet — after `bun install`, run `vc generate` to prepare the app.");
|
|
1384
|
+
return 0;
|
|
1385
|
+
}
|
|
1386
|
+
const app = join2(root, STRICT_APP);
|
|
1387
|
+
for (const args of [["prepare"], ["db", "generate"]]) {
|
|
1388
|
+
const code = await runVoid(args, app);
|
|
1389
|
+
if (code !== 0)
|
|
1390
|
+
return code;
|
|
1391
|
+
}
|
|
1392
|
+
return 0;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// src/help.ts
|
|
1396
|
+
import color from "picocolors";
|
|
1397
|
+
// package.json
|
|
1398
|
+
var package_default = {
|
|
1399
|
+
name: "@saastemly/voidcommerce",
|
|
1400
|
+
version: "0.1.0",
|
|
1401
|
+
description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
|
|
1402
|
+
type: "module",
|
|
1403
|
+
license: "MIT",
|
|
1404
|
+
bin: {
|
|
1405
|
+
vc: "./bin/vc",
|
|
1406
|
+
"@saastemly/voidcommerce": "./bin/vc"
|
|
1407
|
+
},
|
|
1408
|
+
files: [
|
|
1409
|
+
"bin",
|
|
1410
|
+
"dist",
|
|
1411
|
+
"src"
|
|
1412
|
+
],
|
|
1413
|
+
exports: {
|
|
1414
|
+
".": {
|
|
1415
|
+
types: "./dist/index.d.ts",
|
|
1416
|
+
default: "./dist/index.js"
|
|
1417
|
+
},
|
|
1418
|
+
"./catalog": {
|
|
1419
|
+
types: "./dist/catalog.d.ts",
|
|
1420
|
+
default: "./dist/catalog.js"
|
|
1421
|
+
},
|
|
1422
|
+
"./manifest": {
|
|
1423
|
+
types: "./dist/manifest.d.ts",
|
|
1424
|
+
default: "./dist/manifest.js"
|
|
1425
|
+
}
|
|
1426
|
+
},
|
|
1427
|
+
scripts: {
|
|
1428
|
+
build: "rm -rf dist && bun build ./src/index.ts ./src/catalog.ts ./src/manifest.ts ./src/cli.ts --root ./src --outdir dist --target node --format esm --splitting --external @clack/prompts --external picocolors && tsc -p tsconfig.build.json",
|
|
1429
|
+
test: "bun test --parallel --no-isolate",
|
|
1430
|
+
typecheck: "tsc --noEmit"
|
|
1431
|
+
},
|
|
1432
|
+
dependencies: {
|
|
1433
|
+
"@clack/prompts": "^0.11.0",
|
|
1434
|
+
picocolors: "^1.1.1"
|
|
1435
|
+
},
|
|
1436
|
+
devDependencies: {
|
|
1437
|
+
"@types/bun": "latest",
|
|
1438
|
+
typescript: "^5.6.0"
|
|
1439
|
+
},
|
|
1440
|
+
publishConfig: {
|
|
1441
|
+
access: "public"
|
|
1442
|
+
},
|
|
1443
|
+
repository: {
|
|
1444
|
+
type: "git",
|
|
1445
|
+
url: "https://github.com/saastemly/voidcommerce.git"
|
|
1446
|
+
}
|
|
1447
|
+
};
|
|
1448
|
+
|
|
1449
|
+
// src/help.ts
|
|
1450
|
+
var EXTENDED_ROWS = [
|
|
1451
|
+
["vc init", "one app, api + frontend monorepo, or strict; void init, then a form for the shop: who signs in, every Better Auth and betterCommerce plugin, payment, tax, carriers, email, DNS"],
|
|
1452
|
+
["vc generate", "the files from voidcommerce.json without the form — in strict, the whole app under .vc/app"],
|
|
1453
|
+
["vc dev | build | preview", "the app's own script, run where the app is — api/ for a monorepo, .vc/app for strict"],
|
|
1454
|
+
["vc dist", "the deployable app as a self-contained tree — what the void-dist branch carries"],
|
|
1455
|
+
["vc import", "push data/ and content/ into the running shop, as the shop itself — an upsert, safe on every deploy"],
|
|
1456
|
+
["vc preflight", "is the shop ready to advertise on? every required key, and what breaks without it"],
|
|
1457
|
+
["vc deploy", "preflight, then void deploy; or --cloudflare: wrangler, your own account, no Void login"]
|
|
1458
|
+
];
|
|
1459
|
+
var ANSI = /\x1b\[[0-9;]*[a-zA-Z]/g;
|
|
1460
|
+
var strip = (line) => line.replace(ANSI, "");
|
|
1461
|
+
var visible = (line) => strip(line).length;
|
|
1462
|
+
var inner = (line) => strip(line).replace(/^│/, "").replace(/│$/, "").trimEnd();
|
|
1463
|
+
var COMMAND_WIDTH = 34;
|
|
1464
|
+
function wrap(text, width) {
|
|
1465
|
+
const lines = [];
|
|
1466
|
+
let current = "";
|
|
1467
|
+
for (const raw of text.split(/\s+/).filter(Boolean)) {
|
|
1468
|
+
const pieces = raw.match(new RegExp(`.{1,${width}}`, "g")) ?? [raw];
|
|
1469
|
+
for (const word of pieces) {
|
|
1470
|
+
if (current && current.length + 1 + word.length > width) {
|
|
1471
|
+
lines.push(current);
|
|
1472
|
+
current = word;
|
|
1473
|
+
} else {
|
|
1474
|
+
current = current ? `${current} ${word}` : word;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
if (current)
|
|
1479
|
+
lines.push(current);
|
|
1480
|
+
return lines;
|
|
1481
|
+
}
|
|
1482
|
+
function line(text, width) {
|
|
1483
|
+
return `│${` ${text}`.padEnd(width - 2)}│`;
|
|
1484
|
+
}
|
|
1485
|
+
function row(command, description, width, indent = 0) {
|
|
1486
|
+
const pad = " ".repeat(indent);
|
|
1487
|
+
const usable = width - 4 - indent;
|
|
1488
|
+
const twoColumn = command.length + indent < COMMAND_WIDTH && usable - COMMAND_WIDTH >= 20;
|
|
1489
|
+
if (twoColumn) {
|
|
1490
|
+
const parts2 = wrap(description, usable - COMMAND_WIDTH - 1);
|
|
1491
|
+
if (parts2.length === 0)
|
|
1492
|
+
parts2.push("");
|
|
1493
|
+
return parts2.map((part, i) => line(pad + (i === 0 ? command : "").padEnd(COMMAND_WIDTH) + part, width));
|
|
1494
|
+
}
|
|
1495
|
+
const parts = wrap(description, Math.max(10, usable - 5));
|
|
1496
|
+
return [line(pad + command, width), ...parts.map((part) => line(`${pad} ${part}`, width))];
|
|
1497
|
+
}
|
|
1498
|
+
function box(title, body, width) {
|
|
1499
|
+
const head = `◇ ${title} `;
|
|
1500
|
+
return [
|
|
1501
|
+
"│",
|
|
1502
|
+
`${head}${"─".repeat(Math.max(0, width - head.length - 1))}╮`,
|
|
1503
|
+
line("", width),
|
|
1504
|
+
...body,
|
|
1505
|
+
line("", width),
|
|
1506
|
+
`├${"─".repeat(width - 2)}╯`
|
|
1507
|
+
].join(`
|
|
1508
|
+
`);
|
|
1509
|
+
}
|
|
1510
|
+
function mergeHelp(voidHelp) {
|
|
1511
|
+
const lines = voidHelp.split(`
|
|
1512
|
+
`);
|
|
1513
|
+
const start = lines.findIndex((l) => strip(l).startsWith("◇ Commands"));
|
|
1514
|
+
if (start < 0)
|
|
1515
|
+
return null;
|
|
1516
|
+
const end = lines.findIndex((l, i) => i > start && strip(l).startsWith("├"));
|
|
1517
|
+
if (end < 0)
|
|
1518
|
+
return null;
|
|
1519
|
+
const width = visible(lines[start]);
|
|
1520
|
+
let at = lines.findIndex((l, i) => i > start && i < end && inner(l) === " help");
|
|
1521
|
+
if (at < 0)
|
|
1522
|
+
at = end;
|
|
1523
|
+
const group = [
|
|
1524
|
+
line(color.bold("shop"), width + (color.bold("shop").length - "shop".length)),
|
|
1525
|
+
...EXTENDED_ROWS.flatMap(([command, summary]) => row(command, summary, width)),
|
|
1526
|
+
line("", width)
|
|
1527
|
+
];
|
|
1528
|
+
return [...lines.slice(0, at), ...group, ...lines.slice(at)].join(`
|
|
1529
|
+
`);
|
|
1530
|
+
}
|
|
1531
|
+
function standaloneHelp(reason) {
|
|
1532
|
+
return [
|
|
1533
|
+
"",
|
|
1534
|
+
`${color.bold("vc")} — Void, with a shop in it. ${color.dim(reason)}`,
|
|
1535
|
+
"",
|
|
1536
|
+
...EXTENDED_ROWS.map(([command, summary]) => ` ${color.cyan(command.padEnd(12))} ${summary}`),
|
|
1537
|
+
` ${color.dim("vc <anything>")} passes through to void: dev, build, deploy, db, gen …`,
|
|
1538
|
+
""
|
|
1539
|
+
].join(`
|
|
1540
|
+
`);
|
|
1541
|
+
}
|
|
1542
|
+
async function fullHelp() {
|
|
1543
|
+
const captured = await captureVoid(["--help"]);
|
|
1544
|
+
if (!captured) {
|
|
1545
|
+
console.log(standaloneHelp("void is not installed here: bun add -d void"));
|
|
1546
|
+
return 0;
|
|
1547
|
+
}
|
|
1548
|
+
const merged = mergeHelp(captured.out);
|
|
1549
|
+
if (merged) {
|
|
1550
|
+
process.stdout.write(merged);
|
|
1551
|
+
} else {
|
|
1552
|
+
process.stdout.write(captured.out);
|
|
1553
|
+
console.log(standaloneHelp("and, from vc:"));
|
|
1554
|
+
}
|
|
1555
|
+
return captured.code;
|
|
1556
|
+
}
|
|
1557
|
+
async function initHelp() {
|
|
1558
|
+
const captured = await captureVoid(["init", "--help"]);
|
|
1559
|
+
const width = captured ? Math.max(40, ...captured.out.split(`
|
|
1560
|
+
`).map(visible)) : 80;
|
|
1561
|
+
const ours = box("vc init", [
|
|
1562
|
+
line("Everything void init does, then a form for the shop.", width),
|
|
1563
|
+
line("", width),
|
|
1564
|
+
line(color.bold("Usage"), width),
|
|
1565
|
+
...row("vc init", "asks the layout, runs void init where it is needed, then the form", width, 2),
|
|
1566
|
+
...row("vc init --layout app", "one Void app: API and the generated storefront and panel, on Workers", width, 2),
|
|
1567
|
+
...row("vc init --layout monorepo", "api/ on Workers with the panel, frontend/ on GitHub Pages", width, 2),
|
|
1568
|
+
...row("vc init --layout strict", "experimental: only the manifest, data, content, branding and migrations are yours; the app is generated under .vc/app", width, 2),
|
|
1569
|
+
...row("vc init --agents | --tsconfig | --github", "void's partial modes, handed to void unchanged", width, 2),
|
|
1570
|
+
line("", width),
|
|
1571
|
+
line(color.bold("Writes"), width),
|
|
1572
|
+
...row("voidcommerce.json", "the answers; run vc init again to change them", width, 2),
|
|
1573
|
+
...row("auth.ts, env.ts, .env.example", "regenerated from the answers each time", width, 2),
|
|
1574
|
+
...row(".env.production, lib/deploy/", "", width, 2),
|
|
1575
|
+
...row(".env, lib/domain.ts, lib/payment.ts", "written once, then yours — never overwritten", width, 2),
|
|
1576
|
+
...row("lib/notifications.ts, lib/erp.ts", "", width, 2)
|
|
1577
|
+
], width);
|
|
1578
|
+
if (captured)
|
|
1579
|
+
process.stdout.write(captured.out.replace(/\n*$/, `
|
|
1580
|
+
`));
|
|
1581
|
+
console.log(ours);
|
|
1582
|
+
return captured?.code ?? 0;
|
|
1583
|
+
}
|
|
1584
|
+
async function version() {
|
|
1585
|
+
const captured = await captureVoid(["--version"]);
|
|
1586
|
+
console.log(`vc ${package_default.version}`);
|
|
1587
|
+
console.log(captured ? `void ${captured.out.trim()}` : "void not installed here");
|
|
1588
|
+
return 0;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// src/project.ts
|
|
1592
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
1593
|
+
import { dirname as dirname3, join as join5 } from "node:path";
|
|
1594
|
+
|
|
1595
|
+
// src/generate/index.ts
|
|
1596
|
+
import { cp as cp2, mkdir as mkdir2, readFile as readFile2, readdir as readdir2, symlink, writeFile as writeFile2 } from "node:fs/promises";
|
|
1597
|
+
import { existsSync as existsSync4, lstatSync } from "node:fs";
|
|
1598
|
+
import { dirname as dirname2, join as join4 } from "node:path";
|
|
1599
|
+
|
|
1600
|
+
// src/dist.ts
|
|
1601
|
+
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
1602
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
1603
|
+
import { join as join3, relative } from "node:path";
|
|
1604
|
+
import color2 from "picocolors";
|
|
1605
|
+
var DIST_DIR = join3(".vc", "dist");
|
|
1606
|
+
var DIST_BRANCH = "void-dist";
|
|
1607
|
+
var EXCLUDE = new Set(["node_modules", ".env", ".env.local", "dist", ".void", ".wrangler", ".git"]);
|
|
1608
|
+
var README = (domain) => `# ${domain} — generated
|
|
1609
|
+
|
|
1610
|
+
Do not edit, and do not open a pull request against this branch. Every file
|
|
1611
|
+
here is written by \`vc dist\` from the manifest on \`main\`, and this branch is
|
|
1612
|
+
replaced wholesale on every push.
|
|
1613
|
+
|
|
1614
|
+
It is an ordinary Void app so that a build machine needs nothing but a
|
|
1615
|
+
checkout:
|
|
1616
|
+
|
|
1617
|
+
\`\`\`sh
|
|
1618
|
+
bun install
|
|
1619
|
+
bunx void prepare
|
|
1620
|
+
bunx vp build
|
|
1621
|
+
bunx wrangler deploy -c dist/ssr/wrangler.json
|
|
1622
|
+
\`\`\`
|
|
1623
|
+
|
|
1624
|
+
Secrets are the worker's own (\`wrangler secret put\`), never files here. The
|
|
1625
|
+
committed migrations under \`db/migrations\` are what production applies.
|
|
1626
|
+
`;
|
|
1627
|
+
async function copyResolved(from, to) {
|
|
1628
|
+
await mkdir(to, { recursive: true });
|
|
1629
|
+
for (const entry of await readdir(from, { withFileTypes: true })) {
|
|
1630
|
+
if (EXCLUDE.has(entry.name))
|
|
1631
|
+
continue;
|
|
1632
|
+
const source = join3(from, entry.name);
|
|
1633
|
+
const target = join3(to, entry.name);
|
|
1634
|
+
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
|
1635
|
+
const stat = await import("node:fs/promises").then((fs) => fs.stat(source).catch(() => null));
|
|
1636
|
+
if (!stat)
|
|
1637
|
+
continue;
|
|
1638
|
+
if (stat.isDirectory()) {
|
|
1639
|
+
await copyResolved(source, target);
|
|
1640
|
+
continue;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
await cp(source, target, { dereference: true });
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
async function distCommand(args) {
|
|
1647
|
+
const project = await findProject();
|
|
1648
|
+
if (!project) {
|
|
1649
|
+
console.error("vc: no voidcommerce.json here.");
|
|
1650
|
+
return 1;
|
|
1651
|
+
}
|
|
1652
|
+
const outIndex = args.indexOf("--out");
|
|
1653
|
+
const out = outIndex >= 0 ? args[outIndex + 1] : undefined;
|
|
1654
|
+
if (outIndex >= 0 && !out) {
|
|
1655
|
+
console.error("vc: --out needs a directory.");
|
|
1656
|
+
return 1;
|
|
1657
|
+
}
|
|
1658
|
+
const target = join3(project.root, out ?? DIST_DIR);
|
|
1659
|
+
await generate(project.root, project.manifest);
|
|
1660
|
+
const prepared = await finishStrict(project.root);
|
|
1661
|
+
if (prepared !== 0)
|
|
1662
|
+
return prepared;
|
|
1663
|
+
await rm(target, { recursive: true, force: true });
|
|
1664
|
+
await copyResolved(project.appDir, target);
|
|
1665
|
+
const rootPkg = JSON.parse(await readFile(join3(project.root, "package.json"), "utf8"));
|
|
1666
|
+
const appPkgPath = join3(target, "package.json");
|
|
1667
|
+
const appPkg = JSON.parse(await readFile(appPkgPath, "utf8"));
|
|
1668
|
+
const local = [];
|
|
1669
|
+
for (const section of ["dependencies", "devDependencies"]) {
|
|
1670
|
+
const merged = { ...appPkg[section] ?? {} };
|
|
1671
|
+
for (const [name, version2] of Object.entries(rootPkg[section] ?? {})) {
|
|
1672
|
+
if (name === "@saastemly/voidcommerce")
|
|
1673
|
+
continue;
|
|
1674
|
+
if (name in merged || section === "devDependencies")
|
|
1675
|
+
merged[name] = version2;
|
|
1676
|
+
if (version2.startsWith("file:") || version2.startsWith("link:"))
|
|
1677
|
+
local.push(`${name}@${version2}`);
|
|
1678
|
+
}
|
|
1679
|
+
appPkg[section] = Object.fromEntries(Object.entries(merged).sort());
|
|
1680
|
+
}
|
|
1681
|
+
if (rootPkg["patchedDependencies"])
|
|
1682
|
+
appPkg["patchedDependencies"] = rootPkg["patchedDependencies"];
|
|
1683
|
+
await writeFile(appPkgPath, `${JSON.stringify(appPkg, null, 2)}
|
|
1684
|
+
`, "utf8");
|
|
1685
|
+
for (const file of ["bun.lock", "bun.lockb", "package-lock.json", "pnpm-lock.yaml"]) {
|
|
1686
|
+
const source = join3(project.root, file);
|
|
1687
|
+
if (existsSync3(source))
|
|
1688
|
+
await cp(source, join3(target, file));
|
|
1689
|
+
}
|
|
1690
|
+
if (existsSync3(join3(project.root, "patches")))
|
|
1691
|
+
await copyResolved(join3(project.root, "patches"), join3(target, "patches"));
|
|
1692
|
+
await writeFile(join3(target, "README.md"), README(project.manifest.shop.domain), "utf8");
|
|
1693
|
+
await writeFile(join3(target, ".gitignore"), `node_modules
|
|
1694
|
+
dist
|
|
1695
|
+
.void
|
|
1696
|
+
.wrangler
|
|
1697
|
+
.env
|
|
1698
|
+
.env.local
|
|
1699
|
+
`, "utf8");
|
|
1700
|
+
console.log(`${color2.green("✓")} ${relative(project.root, target) || target} — a self-contained Void app`);
|
|
1701
|
+
if (local.length > 0) {
|
|
1702
|
+
console.log(color2.yellow(`
|
|
1703
|
+
! ${local.length} dependenc${local.length === 1 ? "y is" : "ies are"} local paths and will not install from a fresh checkout:
|
|
1704
|
+
${local.join(`
|
|
1705
|
+
`)}
|
|
1706
|
+
Publish them, or point them at a registry, before a build machine uses this tree.`));
|
|
1707
|
+
}
|
|
1708
|
+
return 0;
|
|
1709
|
+
}
|
|
1710
|
+
async function distHelp() {
|
|
1711
|
+
const width = 80;
|
|
1712
|
+
console.log(box("vc dist", [
|
|
1713
|
+
line("The deployable app as a self-contained tree: symlinks resolved, the", width),
|
|
1714
|
+
line("repository's resolved versions, the lockfile, the patches.", width),
|
|
1715
|
+
line("", width),
|
|
1716
|
+
...row("vc dist", `writes ${DIST_DIR}`, width, 2),
|
|
1717
|
+
...row("vc dist --out <dir>", "writes it somewhere else", width, 2),
|
|
1718
|
+
line("", width),
|
|
1719
|
+
line(`The generated workflow pushes this to the \`${DIST_BRANCH}\` branch on every`, width),
|
|
1720
|
+
line("push to main, so Cloudflare builds a plain Void app and you edit a manifest.", width)
|
|
1721
|
+
], width));
|
|
1722
|
+
return 0;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// src/generate/ci.ts
|
|
1726
|
+
function renderDistWorkflow(manifest) {
|
|
1727
|
+
const worker = manifest.shop.domain.split(".")[0];
|
|
1728
|
+
return `name: Build the deployable app
|
|
1729
|
+
|
|
1730
|
+
# Generated by \`vc init\` from voidcommerce.json.
|
|
1731
|
+
#
|
|
1732
|
+
# Every push to main regenerates the Void app from the manifest and force-pushes
|
|
1733
|
+
# it to \`${DIST_BRANCH}\`. Cloudflare's Workers Builds watches THAT branch and
|
|
1734
|
+
# deploys it, so a push here is a deploy — and no Cloudflare credential is
|
|
1735
|
+
# stored in GitHub.
|
|
1736
|
+
#
|
|
1737
|
+
# The Cloudflare side is configured once, in the dashboard, for the Worker
|
|
1738
|
+
# named \`${worker}\` (Settings → Builds). DEPLOY.md has the settings and the
|
|
1739
|
+
# one non-obvious part: the build's API token needs D1:Edit, which the token
|
|
1740
|
+
# Cloudflare generates for you does not have.
|
|
1741
|
+
on:
|
|
1742
|
+
push:
|
|
1743
|
+
branches: [main, master]
|
|
1744
|
+
workflow_dispatch:
|
|
1745
|
+
|
|
1746
|
+
# A later push wins: an older tree must never overwrite a newer one.
|
|
1747
|
+
concurrency:
|
|
1748
|
+
group: ${DIST_BRANCH}-\${{ github.repository }}
|
|
1749
|
+
cancel-in-progress: true
|
|
1750
|
+
|
|
1751
|
+
# contents: write is what lets GITHUB_TOKEN force-push the branch. Nothing else.
|
|
1752
|
+
permissions:
|
|
1753
|
+
contents: write
|
|
1754
|
+
|
|
1755
|
+
jobs:
|
|
1756
|
+
build:
|
|
1757
|
+
runs-on: ubuntu-latest
|
|
1758
|
+
steps:
|
|
1759
|
+
- uses: actions/checkout@v6
|
|
1760
|
+
- uses: oven-sh/setup-bun@v2
|
|
1761
|
+
|
|
1762
|
+
- run: bun install --frozen-lockfile
|
|
1763
|
+
|
|
1764
|
+
# Regenerates the app from voidcommerce.json, runs void's own prepare and
|
|
1765
|
+
# migration generation, then resolves the symlinks into a tree that stands
|
|
1766
|
+
# on its own.
|
|
1767
|
+
- run: bunx vc dist
|
|
1768
|
+
|
|
1769
|
+
# A migration generated here and not committed would be applied under a
|
|
1770
|
+
# new name on the next run, against tables that already exist. The
|
|
1771
|
+
# migrations belong to the repository, so this fails rather than
|
|
1772
|
+
# inventing one.
|
|
1773
|
+
- name: Refuse to ship a migration that is not committed
|
|
1774
|
+
run: |
|
|
1775
|
+
set -euo pipefail
|
|
1776
|
+
if [ -n "$(git status --porcelain migrations/)" ]; then
|
|
1777
|
+
echo "::error::vc generate wrote a migration that is not committed."
|
|
1778
|
+
git status --porcelain migrations/
|
|
1779
|
+
echo "Run 'vc generate' locally, review it, and commit it."
|
|
1780
|
+
exit 1
|
|
1781
|
+
fi
|
|
1782
|
+
|
|
1783
|
+
- name: Publish to ${DIST_BRANCH}
|
|
1784
|
+
env:
|
|
1785
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
1786
|
+
run: |
|
|
1787
|
+
set -euo pipefail
|
|
1788
|
+
cd ${DIST_DIR}
|
|
1789
|
+
# A fresh single-commit history each run: the branch is build output,
|
|
1790
|
+
# so it is replaced rather than accumulating commits.
|
|
1791
|
+
git init -q -b ${DIST_BRANCH}
|
|
1792
|
+
git config user.name "github-actions[bot]"
|
|
1793
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
1794
|
+
git add -A
|
|
1795
|
+
git commit -q -m "\${{ github.sha }} — ${manifest.shop.domain}"
|
|
1796
|
+
git push -f "https://x-access-token:\${GITHUB_TOKEN}@github.com/\${{ github.repository }}.git" ${DIST_BRANCH}
|
|
1797
|
+
`;
|
|
1798
|
+
}
|
|
1799
|
+
function renderDeployReadme(manifest, zone2, hosts) {
|
|
1800
|
+
const worker = manifest.shop.domain.split(".")[0];
|
|
1801
|
+
return `# Going live
|
|
1802
|
+
|
|
1803
|
+
\`${manifest.shop.domain}\` on Cloudflare Workers. Generated by \`vc init\`;
|
|
1804
|
+
regenerate with \`vc generate\`.
|
|
1805
|
+
|
|
1806
|
+
## The loop, once it is set up
|
|
1807
|
+
|
|
1808
|
+
Push to \`main\`. GitHub Actions regenerates the app from \`voidcommerce.json\`
|
|
1809
|
+
and force-pushes it to \`${DIST_BRANCH}\`; Cloudflare's Workers Builds builds that
|
|
1810
|
+
branch and deploys it. Nothing else runs.
|
|
1811
|
+
|
|
1812
|
+
Your secrets survive every deploy — \`wrangler deploy\` never deletes a secret.
|
|
1813
|
+
Plaintext \`vars\` are replaced from \`.env.production\` on each deploy, which is
|
|
1814
|
+
why no credential is allowed in that file.
|
|
1815
|
+
|
|
1816
|
+
## Once, before the first push
|
|
1817
|
+
|
|
1818
|
+
Each of these either creates something in your Cloudflare account or holds a
|
|
1819
|
+
credential, so none of them can live in a repository.
|
|
1820
|
+
|
|
1821
|
+
### 1. The zone
|
|
1822
|
+
|
|
1823
|
+
\`${zone2}\` must be on Cloudflare. A Worker custom domain is a record Cloudflare
|
|
1824
|
+
creates in its own zone, so a domain hosted anywhere else cannot have one. The
|
|
1825
|
+
worker answers on ${hosts.map((h) => `\`${h}\``).join(" and ")}.
|
|
1826
|
+
|
|
1827
|
+
### 2. The database, the queue, and the account id
|
|
1828
|
+
|
|
1829
|
+
\`\`\`sh
|
|
1830
|
+
vc deploy --cloudflare --provision
|
|
1831
|
+
\`\`\`
|
|
1832
|
+
|
|
1833
|
+
Idempotent: it creates nothing that already exists. It pins your account id,
|
|
1834
|
+
creates the D1 database and the queue, and records both in \`wrangler.jsonc\`
|
|
1835
|
+
and \`voidcommerce.json\` so every later generate carries the real ids.
|
|
1836
|
+
**Commit that change.**
|
|
1837
|
+
|
|
1838
|
+
### 3. The secrets
|
|
1839
|
+
|
|
1840
|
+
\`\`\`sh
|
|
1841
|
+
vc preflight --cloudflare
|
|
1842
|
+
\`\`\`
|
|
1843
|
+
|
|
1844
|
+
lists every required key, says what breaks without it, and prints the
|
|
1845
|
+
\`wrangler secret put\` line. On a Worker that has never been deployed, setting
|
|
1846
|
+
the first secret is what creates it.
|
|
1847
|
+
|
|
1848
|
+
### 4. An API token the build can use
|
|
1849
|
+
|
|
1850
|
+
This is the one non-obvious step. Cloudflare generates an API token for
|
|
1851
|
+
Workers Builds automatically, and **that token has no D1 permission** — its
|
|
1852
|
+
scopes are Workers Scripts, KV, R2, Workers Routes and account/user reads. The
|
|
1853
|
+
deploy command below applies database migrations, so it needs more.
|
|
1854
|
+
|
|
1855
|
+
Create a token at **My Profile → API Tokens** with:
|
|
1856
|
+
|
|
1857
|
+
| scope | permission |
|
|
1858
|
+
|---|---|
|
|
1859
|
+
| Account | Workers Scripts: Edit |
|
|
1860
|
+
| Account | D1: Edit |
|
|
1861
|
+
| Account | Workers KV Storage: Edit, Workers R2 Storage: Edit |
|
|
1862
|
+
| Account | Queues: Edit |
|
|
1863
|
+
| Account | Account Settings: Read |
|
|
1864
|
+
| Zone | Workers Routes: Edit (${zone2}) |
|
|
1865
|
+
| User | User Details: Read, Memberships: Read |
|
|
1866
|
+
|
|
1867
|
+
Then select it in the build settings below. Use the same token for every
|
|
1868
|
+
deploy of this Worker; permissions are whatever that token has.
|
|
1869
|
+
|
|
1870
|
+
### 5. Connect the repository
|
|
1871
|
+
|
|
1872
|
+
In the Cloudflare dashboard, on the Worker named \`${worker}\`, under
|
|
1873
|
+
**Settings → Builds → Connect**. The Worker's name must equal the \`name\` in
|
|
1874
|
+
the \`wrangler.jsonc\` at the root directory, or the build fails.
|
|
1875
|
+
|
|
1876
|
+
| setting | value |
|
|
1877
|
+
|---|---|
|
|
1878
|
+
| branch | \`${DIST_BRANCH}\` |
|
|
1879
|
+
| root directory | \`/\` |
|
|
1880
|
+
| build command | \`bun install && bunx void prepare && bunx vp build\` |
|
|
1881
|
+
| deploy command | \`bunx wrangler d1 migrations apply DB --remote && bunx wrangler deploy -c dist/ssr/wrangler.json\` |
|
|
1882
|
+
| API token | the one from step 4 |
|
|
1883
|
+
|
|
1884
|
+
The migration command names the **binding** (\`DB\`), not the database, so it
|
|
1885
|
+
still points at the right database if the name ever differs.
|
|
1886
|
+
|
|
1887
|
+
Cloudflare's build image has Bun; pin its version with a \`BUN_VERSION\` build
|
|
1888
|
+
variable if you ever need to. The free plan allows 3,000 build minutes a month
|
|
1889
|
+
and one build at a time.
|
|
1890
|
+
|
|
1891
|
+
### 6. The catalogue
|
|
1892
|
+
|
|
1893
|
+
\`vc import\` pushes \`data/\` into the running shop. It is an upsert, so it is
|
|
1894
|
+
safe on every deploy and costs one pass when nothing changed.
|
|
1895
|
+
|
|
1896
|
+
## Without Cloudflare's build
|
|
1897
|
+
|
|
1898
|
+
The same thing by hand, from a checkout of either branch:
|
|
1899
|
+
|
|
1900
|
+
\`\`\`sh
|
|
1901
|
+
vc deploy --cloudflare
|
|
1902
|
+
\`\`\`
|
|
1903
|
+
|
|
1904
|
+
which preflights, builds, strips the baked development values out of the
|
|
1905
|
+
worker's vars, applies the remote migrations, and deploys exactly what it
|
|
1906
|
+
verified.
|
|
1907
|
+
`;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// src/generate/requirements.ts
|
|
1911
|
+
function renderRequirementsTs(manifest) {
|
|
1912
|
+
const keys = allEnvKeys(manifest);
|
|
1913
|
+
const entries = keys.map((key) => ` { key: ${JSON.stringify(key.key)}, breaks: ${JSON.stringify(key.breaks)}${key.where ? `, where: ${JSON.stringify(key.where)}` : ""}${key.plaintext ? ", plaintext: true" : ""} },`);
|
|
1914
|
+
return `// GENERATED by \`vc init\` from voidcommerce.json.
|
|
1915
|
+
/**
|
|
1916
|
+
* What "live" requires, and what breaks without each thing.
|
|
1917
|
+
*
|
|
1918
|
+
* \`vc deploy\` refuses on any missing entry; \`vc preflight\` prints the same
|
|
1919
|
+
* list with what is set. The \`unset\` sentinel counts as missing.
|
|
1920
|
+
*/
|
|
1921
|
+
export const UNSET = "unset";
|
|
1922
|
+
|
|
1923
|
+
export interface Requirement {
|
|
1924
|
+
key: string;
|
|
1925
|
+
breaks: string;
|
|
1926
|
+
where?: string;
|
|
1927
|
+
plaintext?: boolean;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
export const REQUIREMENTS: Requirement[] = [
|
|
1931
|
+
${entries.join(`
|
|
1932
|
+
`)}
|
|
1933
|
+
];
|
|
1934
|
+
|
|
1935
|
+
export function check(present: Map<string, string | undefined>): Requirement[] {
|
|
1936
|
+
return REQUIREMENTS.filter((requirement) => {
|
|
1937
|
+
const value = present.get(requirement.key);
|
|
1938
|
+
return !present.has(requirement.key) || value === "" || value === UNSET;
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1941
|
+
`;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// src/generate/support.ts
|
|
1945
|
+
function renderDomainTs(layout) {
|
|
1946
|
+
const derive = layout === "app" ? ` // One app: the worker IS the site. No separate API host, and no other
|
|
1947
|
+
// origin to trust. It answers on www too, but only at a zone's apex —
|
|
1948
|
+
// www.shop.example.com is not a name anybody types.
|
|
1949
|
+
const hosts = apex ? [host, www] : [host];
|
|
1950
|
+
return { host, zone, apex, api: host, www, hosts, appUrl: \`https://\${host}\`, frontendOrigins: [] };` : ` // The worker is the API; the storefront is elsewhere and must be trusted.
|
|
1951
|
+
const api = \`api.\${host}\`;
|
|
1952
|
+
const frontendOrigins = apex ? [\`https://\${host}\`, \`https://\${www}\`] : [\`https://\${host}\`];
|
|
1953
|
+
return { host, zone, apex, api, www, hosts: [api], appUrl: \`https://\${api}\`, frontendOrigins };`;
|
|
1954
|
+
return `/**
|
|
1955
|
+
* The shop's domain, and everything derived from it.
|
|
1956
|
+
*
|
|
1957
|
+
* One variable. \`SHOP_DOMAIN\` gives ${layout === "app" ? "the worker's hostnames" : `api.<domain> for the worker and the
|
|
1958
|
+
* storefront's origins`}, the app's public origin, and the
|
|
1959
|
+
* CORS and CSRF allow-list. They were four settings that had to agree and
|
|
1960
|
+
* nothing checked that they did.
|
|
1961
|
+
*
|
|
1962
|
+
* The domain need not be a registrable apex: \`tshirt.saastemly.com\` is a
|
|
1963
|
+
* shop inside the zone \`saastemly.com\`, which is where its DNS records go.
|
|
1964
|
+
* \`SHOP_ZONE\` names that zone; unset, the domain is its own.
|
|
1965
|
+
*/
|
|
1966
|
+
export interface ShopDomain {
|
|
1967
|
+
/** The shop's public hostname. */
|
|
1968
|
+
host: string;
|
|
1969
|
+
/** The DNS zone that contains it — where records are written. */
|
|
1970
|
+
zone: string;
|
|
1971
|
+
/** Is the host the zone's apex? Only then is there a www. */
|
|
1972
|
+
apex: boolean;
|
|
1973
|
+
/** Where the worker answers. */
|
|
1974
|
+
api: string;
|
|
1975
|
+
www: string;
|
|
1976
|
+
/** Every hostname the worker should answer on. */
|
|
1977
|
+
hosts: string[];
|
|
1978
|
+
appUrl: string;
|
|
1979
|
+
frontendOrigins: string[];
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
const clean = (value: string): string =>
|
|
1983
|
+
value.trim().replace(/^https?:\\/\\//, "").replace(/\\/.*$/, "").replace(/\\.$/, "").toLowerCase();
|
|
1984
|
+
|
|
1985
|
+
export function shopDomain(input: string, zoneInput?: string): ShopDomain {
|
|
1986
|
+
const host = clean(input).replace(/^www\\./, "");
|
|
1987
|
+
const zone = zoneInput ? clean(zoneInput) : host.split(".").slice(-2).join(".");
|
|
1988
|
+
const apex = host === zone;
|
|
1989
|
+
const www = \`www.\${host}\`;
|
|
1990
|
+
${derive}
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
/** GitHub Pages' published addresses for an apex. Theirs, not yours. */
|
|
1994
|
+
export const GITHUB_PAGES_A = ["185.199.108.153", "185.199.109.153", "185.199.110.153", "185.199.111.153"] as const;
|
|
1995
|
+
export const GITHUB_PAGES_AAAA = ["2606:50c0:8000::153", "2606:50c0:8001::153", "2606:50c0:8002::153", "2606:50c0:8003::153"] as const;
|
|
1996
|
+
|
|
1997
|
+
/**
|
|
1998
|
+
* The storefront's records. DNS-only, never proxied: GitHub issues the
|
|
1999
|
+
* certificate itself and validates by reaching the origin. The API hostname
|
|
2000
|
+
* is NOT here — it is a Cloudflare custom domain in wrangler.jsonc.${layout === "app" ? `
|
|
2001
|
+
* One app has no storefront elsewhere, so this is always empty.` : ""}
|
|
2002
|
+
*/
|
|
2003
|
+
export function storefrontRecords(domain: ShopDomain, pagesHost: string) {
|
|
2004
|
+
if (!pagesHost${layout === "app" ? " || domain.frontendOrigins.length === 0" : ""}) return [];
|
|
2005
|
+
// The record's name is relative to the zone: "@" at the apex, otherwise the
|
|
2006
|
+
// labels in front of it.
|
|
2007
|
+
const name = domain.apex ? "@" : domain.host.slice(0, -(domain.zone.length + 1));
|
|
2008
|
+
return [
|
|
2009
|
+
...GITHUB_PAGES_A.map((value, i) => ({ key: \`pages-a-\${i}\`, zone: domain.zone, name, type: "A" as const, value, proxied: false })),
|
|
2010
|
+
...GITHUB_PAGES_AAAA.map((value, i) => ({ key: \`pages-aaaa-\${i}\`, zone: domain.zone, name, type: "AAAA" as const, value, proxied: false })),
|
|
2011
|
+
// www only exists at an apex; a shop at shop.example.com has no www.
|
|
2012
|
+
...(domain.apex ? [{ key: "pages-www", zone: domain.zone, name: "www", type: "CNAME" as const, value: pagesHost, proxied: false }] : []),
|
|
2013
|
+
];
|
|
2014
|
+
}
|
|
2015
|
+
`;
|
|
2016
|
+
}
|
|
2017
|
+
function renderPaymentTs(manifest) {
|
|
2018
|
+
const currency = manifest.shop.currency.toUpperCase();
|
|
2019
|
+
if (has(manifest, "adyen")) {
|
|
2020
|
+
return `import { adyenProvider } from "@saastemly/better-commerce/providers/adyen";
|
|
2021
|
+
import { memoryProvider } from "@saastemly/better-commerce/providers/memory";
|
|
2022
|
+
import { env } from "void/env";
|
|
2023
|
+
|
|
2024
|
+
/**
|
|
2025
|
+
* Adyen. One rail, no fallback to choose between. Locally the memory provider
|
|
2026
|
+
* stands in when the credentials read as \`unset\` — preflight refuses that
|
|
2027
|
+
* state in production.
|
|
2028
|
+
*/
|
|
2029
|
+
const read = (key: string): string => {
|
|
2030
|
+
try {
|
|
2031
|
+
const value = (env as Record<string, unknown>)[key];
|
|
2032
|
+
return typeof value === "string" && value !== "unset" ? value : "";
|
|
2033
|
+
} catch {
|
|
2034
|
+
return "";
|
|
2035
|
+
}
|
|
2036
|
+
};
|
|
2037
|
+
|
|
2038
|
+
const adyen = { apiKey: read("ADYEN_API_KEY"), merchantAccount: read("ADYEN_MERCHANT_ACCOUNT"), hmacKey: read("ADYEN_HMAC_KEY") };
|
|
2039
|
+
const configured = Boolean(adyen.apiKey && adyen.merchantAccount && adyen.hmacKey);
|
|
2040
|
+
|
|
2041
|
+
export const devPaymentProvider = configured ? null : memoryProvider({ webhookSecret: read("COMMERCE_WEBHOOK_SECRET") || "whsec_dev_secret" });
|
|
2042
|
+
|
|
2043
|
+
export const paymentProvider =
|
|
2044
|
+
devPaymentProvider ??
|
|
2045
|
+
adyenProvider({
|
|
2046
|
+
...adyen,
|
|
2047
|
+
environment: read("ADYEN_ENVIRONMENT") === "live" ? "live" : "test",
|
|
2048
|
+
...(read("ADYEN_LIVE_PREFIX") ? { livePrefix: read("ADYEN_LIVE_PREFIX") } : {}),
|
|
2049
|
+
refundCurrency: "${currency}",
|
|
2050
|
+
});
|
|
2051
|
+
`;
|
|
2052
|
+
}
|
|
2053
|
+
return `import { memoryProvider } from "@saastemly/better-commerce/providers/memory";
|
|
2054
|
+
import { stripeProvider } from "@saastemly/better-commerce/providers/stripe";
|
|
2055
|
+
import { env } from "void/env";
|
|
2056
|
+
|
|
2057
|
+
/** Stripe. Locally the memory provider stands in when the key reads as \`unset\`. */
|
|
2058
|
+
const read = (key: string): string => {
|
|
2059
|
+
try {
|
|
2060
|
+
const value = (env as Record<string, unknown>)[key];
|
|
2061
|
+
return typeof value === "string" && value !== "unset" ? value : "";
|
|
2062
|
+
} catch {
|
|
2063
|
+
return "";
|
|
2064
|
+
}
|
|
2065
|
+
};
|
|
2066
|
+
|
|
2067
|
+
const secretKey = read("STRIPE_SECRET_KEY");
|
|
2068
|
+
const webhookSecret = read("COMMERCE_WEBHOOK_SECRET") || "whsec_dev_secret";
|
|
2069
|
+
|
|
2070
|
+
export const devPaymentProvider = secretKey ? null : memoryProvider({ webhookSecret });
|
|
2071
|
+
export const paymentProvider = devPaymentProvider ?? stripeProvider({ secretKey, webhookSecret });
|
|
2072
|
+
`;
|
|
2073
|
+
}
|
|
2074
|
+
function renderNotificationsTs() {
|
|
2075
|
+
return `import type { NotificationProvider } from "@saastemly/better-commerce/providers/notification";
|
|
2076
|
+
import type { Emailer } from "@saastemly/better-email";
|
|
2077
|
+
|
|
2078
|
+
/**
|
|
2079
|
+
* betterCommerce's notifications through the ONE emailer, so an order
|
|
2080
|
+
* confirmation goes out with the same sender and lands in the same log as a
|
|
2081
|
+
* sign-in code.
|
|
2082
|
+
*/
|
|
2083
|
+
export const emailNotifications = (emailer: Emailer): NotificationProvider => ({
|
|
2084
|
+
id: "email",
|
|
2085
|
+
channels: ["email"],
|
|
2086
|
+
transport: emailer.transport.transport === "none" ? "none" : "live",
|
|
2087
|
+
async send(message) {
|
|
2088
|
+
if (message.channel !== "email") return { id: "", status: "failed", error: "email only" };
|
|
2089
|
+
// \`data\` is the rendered content: subject, text, and html when a template made one.
|
|
2090
|
+
const result = await emailer.send({
|
|
2091
|
+
to: message.to,
|
|
2092
|
+
subject: message.data.subject,
|
|
2093
|
+
text: message.data.text,
|
|
2094
|
+
...(message.data.html ? { html: message.data.html } : {}),
|
|
2095
|
+
kind: \`notification:\${message.template}\`,
|
|
2096
|
+
});
|
|
2097
|
+
return { id: result.id, status: result.status, ...(result.error ? { error: result.error } : {}) };
|
|
2098
|
+
},
|
|
2099
|
+
});
|
|
2100
|
+
`;
|
|
2101
|
+
}
|
|
2102
|
+
function renderMintTs() {
|
|
2103
|
+
return `import { type MintProvider, recordedMint } from "@saastemly/better-commerce/plugins/nft";
|
|
2104
|
+
|
|
2105
|
+
/**
|
|
2106
|
+
* Where a token comes from.
|
|
2107
|
+
*
|
|
2108
|
+
* \`recordedMint()\` is the honest default: the order is taken, the buyer's
|
|
2109
|
+
* destination is recorded, the fulfilment sits \`pending\`, and nobody is told
|
|
2110
|
+
* a token exists. Replace it when you have chosen a chain and a minter.
|
|
2111
|
+
*
|
|
2112
|
+
* OpenSea does not mint. Its API has an endpoint called \`mint\`, and what it
|
|
2113
|
+
* returns is unsigned transaction data for somebody else to sign — so the
|
|
2114
|
+
* only real question is who holds a key. Three shapes fit:
|
|
2115
|
+
*
|
|
2116
|
+
* custodial API one authenticated call per mint, no key here. Crossmint
|
|
2117
|
+
* takes an EMAIL as the recipient, so a buyer who has never
|
|
2118
|
+
* held a wallet still gets something:
|
|
2119
|
+
*
|
|
2120
|
+
* import { crossmintMint } from "@saastemly/better-commerce/providers/crossmint";
|
|
2121
|
+
* export const mintProvider = crossmintMint({
|
|
2122
|
+
* apiKey: env.CROSSMINT_API_KEY, chain: "base",
|
|
2123
|
+
* });
|
|
2124
|
+
*
|
|
2125
|
+
* own contract you hold a key and sign in the worker. It lives as a
|
|
2126
|
+
* secret, which is a real thing to weigh: a leaked key is
|
|
2127
|
+
* not a leaked password, it is the collection. Cheapest per
|
|
2128
|
+
* mint, and yours to keep.
|
|
2129
|
+
* signed voucher you sign an offer and the buyer redeems it, paying the
|
|
2130
|
+
* gas. Costs nothing per mint and needs no key at runtime,
|
|
2131
|
+
* but the buyer needs a wallet AND has to pay — which is
|
|
2132
|
+
* most of the audience for a "take a useless token" offer.
|
|
2133
|
+
*
|
|
2134
|
+
* A collection appears on OpenSea by itself once a standard contract mints on
|
|
2135
|
+
* a chain OpenSea indexes; none of these needs an OpenSea account.
|
|
2136
|
+
*
|
|
2137
|
+
* Whichever it is, \`mint()\` must RETURN a failure rather than throw: it runs
|
|
2138
|
+
* after payment, and an exception there strands a paid order.
|
|
2139
|
+
*/
|
|
2140
|
+
export const mintProvider: MintProvider = recordedMint();
|
|
2141
|
+
`;
|
|
2142
|
+
}
|
|
2143
|
+
function renderErpTs() {
|
|
2144
|
+
return `import { businessCentralProvider } from "@saastemly/better-commerce/providers/business-central";
|
|
2145
|
+
import type { FulfillmentProvider } from "@saastemly/better-commerce/plugins/fulfillments";
|
|
2146
|
+
import { env } from "void/env";
|
|
2147
|
+
|
|
2148
|
+
/** A paid order becomes a Business Central sales order. Inert until all four credentials are real. */
|
|
2149
|
+
const read = (key: string): string => {
|
|
2150
|
+
try {
|
|
2151
|
+
const value = (env as Record<string, unknown>)[key];
|
|
2152
|
+
return typeof value === "string" && value !== "unset" ? value : "";
|
|
2153
|
+
} catch {
|
|
2154
|
+
return "";
|
|
2155
|
+
}
|
|
2156
|
+
};
|
|
2157
|
+
|
|
2158
|
+
export const erpFulfillment = (currency: string): FulfillmentProvider =>
|
|
2159
|
+
businessCentralProvider({
|
|
2160
|
+
tenantId: read("BC_TENANT_ID"),
|
|
2161
|
+
clientId: read("BC_CLIENT_ID"),
|
|
2162
|
+
clientSecret: read("BC_CLIENT_SECRET"),
|
|
2163
|
+
companyId: read("BC_COMPANY_ID"),
|
|
2164
|
+
environment: read("BC_ENVIRONMENT") || "production",
|
|
2165
|
+
// BC prices are in the company's local currency.
|
|
2166
|
+
currency,
|
|
2167
|
+
}).fulfillment();
|
|
2168
|
+
`;
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
// src/generate/index.ts
|
|
2172
|
+
async function exists(path) {
|
|
2173
|
+
try {
|
|
2174
|
+
await readFile2(path);
|
|
2175
|
+
return true;
|
|
2176
|
+
} catch {
|
|
2177
|
+
return false;
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
async function put(root, file, content, result, mode) {
|
|
2181
|
+
const path = join4(root, file);
|
|
2182
|
+
if (mode === "own" && await exists(path)) {
|
|
2183
|
+
result.kept.push(file);
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
2187
|
+
await writeFile2(path, content, "utf8");
|
|
2188
|
+
result.written.push(file);
|
|
2189
|
+
}
|
|
2190
|
+
async function mergeJson(root, file, fallback, mutate, result) {
|
|
2191
|
+
const path = join4(root, file);
|
|
2192
|
+
const json = await exists(path) ? JSON.parse(await readFile2(path, "utf8")) : { ...fallback };
|
|
2193
|
+
mutate(json);
|
|
2194
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
2195
|
+
await writeFile2(path, `${JSON.stringify(json, null, 2)}
|
|
2196
|
+
`, "utf8");
|
|
2197
|
+
result.written.push(file);
|
|
2198
|
+
}
|
|
2199
|
+
async function link(root, file, target, result) {
|
|
2200
|
+
const path = join4(root, file);
|
|
2201
|
+
try {
|
|
2202
|
+
lstatSync(path);
|
|
2203
|
+
result.kept.push(file);
|
|
2204
|
+
return;
|
|
2205
|
+
} catch {
|
|
2206
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
2207
|
+
await symlink(target, path);
|
|
2208
|
+
result.written.push(`${file} → ${target}`);
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
var sorted = (record) => Object.fromEntries(Object.entries(record).sort());
|
|
2212
|
+
async function generate(root, manifest) {
|
|
2213
|
+
const result = { written: [], kept: [], packages: packagesOf(manifest) };
|
|
2214
|
+
await writeManifest(root, manifest);
|
|
2215
|
+
result.written.push(MANIFEST_FILE);
|
|
2216
|
+
switch (manifest.layout) {
|
|
2217
|
+
case "app":
|
|
2218
|
+
await generateApi(root, "", manifest, result, { ownable: true, env: true });
|
|
2219
|
+
break;
|
|
2220
|
+
case "monorepo":
|
|
2221
|
+
await generateApi(root, "api", manifest, result, { ownable: true, env: true });
|
|
2222
|
+
await generateFrontend(root, "frontend", manifest, result);
|
|
2223
|
+
await generateRoot(root, manifest, result);
|
|
2224
|
+
break;
|
|
2225
|
+
case "strict":
|
|
2226
|
+
await generateStrictRoot(root, manifest, result);
|
|
2227
|
+
await generateFrontend(root, "", manifest, result);
|
|
2228
|
+
await generateApi(root, STRICT_APP, manifest, result, { ownable: false, env: false });
|
|
2229
|
+
await generateStrictApp(root, STRICT_APP, manifest, result);
|
|
2230
|
+
break;
|
|
2231
|
+
}
|
|
2232
|
+
return result;
|
|
2233
|
+
}
|
|
2234
|
+
async function generateApi(root, dir, manifest, result, opts) {
|
|
2235
|
+
const at = (file) => join4(dir, file);
|
|
2236
|
+
const own = opts.ownable ? "own" : "regenerate";
|
|
2237
|
+
const single = isSingleApp(manifest.layout);
|
|
2238
|
+
await put(root, at("auth.ts"), renderAuthTs(manifest), result, "regenerate");
|
|
2239
|
+
await put(root, at("env.ts"), renderEnvTs(manifest), result, "regenerate");
|
|
2240
|
+
await put(root, at(".env.example"), renderEnvExample(manifest), result, "regenerate");
|
|
2241
|
+
await put(root, at(".env.production"), renderEnvProduction(manifest), result, "regenerate");
|
|
2242
|
+
await put(root, at("lib/deploy/requirements.ts"), renderRequirementsTs(manifest), result, "regenerate");
|
|
2243
|
+
if (opts.env)
|
|
2244
|
+
await put(root, at(".env"), renderEnvLocal(manifest), result, "own");
|
|
2245
|
+
await put(root, at("lib/domain.ts"), renderDomainTs(manifest.layout), result, own);
|
|
2246
|
+
await put(root, at("lib/payment.ts"), renderPaymentTs(manifest), result, own);
|
|
2247
|
+
await put(root, at("lib/notifications.ts"), renderNotificationsTs(), result, own);
|
|
2248
|
+
if (has(manifest, "nft")) {
|
|
2249
|
+
await put(root, at("lib/mint.ts"), renderMintTs(), result, own);
|
|
2250
|
+
}
|
|
2251
|
+
if (has(manifest, "business-central")) {
|
|
2252
|
+
await put(root, at("lib/erp.ts"), renderErpTs(), result, own);
|
|
2253
|
+
}
|
|
2254
|
+
const name = manifest.shop.domain.split(".")[0];
|
|
2255
|
+
const hosts = workerHosts(manifest);
|
|
2256
|
+
const routes = `[
|
|
2257
|
+
${hosts.map((host) => ` { "pattern": "${host}", "custom_domain": true }`).join(`,
|
|
2258
|
+
`)}
|
|
2259
|
+
]`;
|
|
2260
|
+
const sendEmail = has(manifest, "cloudflare-email") ? `,
|
|
2261
|
+
// Cloudflare Email Service. The binding IS the credential; the sending
|
|
2262
|
+
// domain is onboarded once in the dashboard, which writes its own SPF,
|
|
2263
|
+
// DKIM, DMARC and bounce records.
|
|
2264
|
+
"send_email": [{ "name": "EMAIL" }]` : "";
|
|
2265
|
+
const cf = manifest.cloudflare;
|
|
2266
|
+
const d1 = cf?.d1 ? `,
|
|
2267
|
+
// Created by \`vc deploy --cloudflare --provision\`; the id is what the deploy binds.
|
|
2268
|
+
"d1_databases": [
|
|
2269
|
+
{ "binding": "DB", "database_name": "${cf.d1.name}", "database_id": "${cf.d1.id}", "migrations_dir": "./db/migrations" }
|
|
2270
|
+
]` : "";
|
|
2271
|
+
await put(root, at("wrangler.jsonc"), `{
|
|
2272
|
+
"$schema": "./node_modules/wrangler/config-schema.json",
|
|
2273
|
+
"name": "${single ? name : `${name}-api`}",
|
|
2274
|
+
// Pin the account. A multi-account token makes wrangler prompt, which CI cannot answer.
|
|
2275
|
+
"account_id": "${cf?.accountId ?? ""}",
|
|
2276
|
+
// The worker's hostname${hosts.length > 1 ? "s" : ""}. Cloudflare custom domain${hosts.length > 1 ? "s" : ""}, not DNS records this
|
|
2277
|
+
// app writes — Cloudflare creates the record and the certificate itself.
|
|
2278
|
+
// The zone (${zone(manifest)}) must be on Cloudflare; \`vc preflight\` checks the names.
|
|
2279
|
+
"routes": ${routes}${sendEmail}${d1}
|
|
2280
|
+
}
|
|
2281
|
+
`, result, own);
|
|
2282
|
+
if (manifest.layout === "strict")
|
|
2283
|
+
return;
|
|
2284
|
+
await mergeJson(root, at("package.json"), { name: single ? name : `${name}-api`, type: "module", private: true }, (pkg) => {
|
|
2285
|
+
const deps = { ...pkg["dependencies"] ?? {} };
|
|
2286
|
+
for (const dep of result.packages)
|
|
2287
|
+
deps[dep] ??= specifier(manifest, dep);
|
|
2288
|
+
pkg["dependencies"] = sorted(deps);
|
|
2289
|
+
const scripts = { ...pkg["scripts"] ?? {} };
|
|
2290
|
+
scripts["preflight"] ??= "vc preflight";
|
|
2291
|
+
scripts["deploy"] ??= "vc deploy";
|
|
2292
|
+
scripts["import:catalog"] ??= "vc import";
|
|
2293
|
+
scripts["maildev"] ??= "maildev --smtp 1025 --web 1080";
|
|
2294
|
+
pkg["scripts"] = scripts;
|
|
2295
|
+
}, result);
|
|
2296
|
+
}
|
|
2297
|
+
async function generateFrontend(root, dir, manifest, result) {
|
|
2298
|
+
const at = (file) => join4(dir, file);
|
|
2299
|
+
await put(root, at("lib/api.ts"), renderFrontendApiTs(manifest), result, "own");
|
|
2300
|
+
await put(root, at(".env.production"), renderFrontendEnvProduction(manifest), result, "regenerate");
|
|
2301
|
+
await put(root, at("vite.config.ts"), renderFrontendViteConfig(), result, "own");
|
|
2302
|
+
await put(root, at("void.json"), renderFrontendVoidJson(), result, "own");
|
|
2303
|
+
await put(root, at("tsconfig.json"), renderFrontendTsconfig(), result, "own");
|
|
2304
|
+
await put(root, at("pages/index.tsx"), renderStorefrontPage(manifest), result, "own");
|
|
2305
|
+
await put(root, ".github/workflows/frontend-static.yml", renderFrontendWorkflow(manifest, dir), result, "regenerate");
|
|
2306
|
+
if (dir === "")
|
|
2307
|
+
return;
|
|
2308
|
+
await mergeJson(root, at("package.json"), { name: `${manifest.shop.domain.split(".")[0]}-frontend`, type: "module", private: true }, (pkg) => {
|
|
2309
|
+
const deps = { ...pkg["dependencies"] ?? {} };
|
|
2310
|
+
for (const dep of ["better-auth", "@saastemly/better-commerce", ...CHOICES.get("custom")?.packages ?? []])
|
|
2311
|
+
deps[dep] ??= specifier(manifest, dep);
|
|
2312
|
+
pkg["dependencies"] = sorted(deps);
|
|
2313
|
+
const dev = { ...pkg["devDependencies"] ?? {} };
|
|
2314
|
+
for (const dep of ["@hono/node-server", "@void/react", "vite", "vite-plus", "void"])
|
|
2315
|
+
dev[dep] ??= "latest";
|
|
2316
|
+
pkg["devDependencies"] = sorted(dev);
|
|
2317
|
+
}, result);
|
|
2318
|
+
}
|
|
2319
|
+
async function generateRoot(root, manifest, result) {
|
|
2320
|
+
await mergeJson(root, "package.json", { name: manifest.shop.domain.split(".")[0], type: "module", private: true }, (pkg) => {
|
|
2321
|
+
pkg["workspaces"] = ["api", "frontend"];
|
|
2322
|
+
const scripts = { ...pkg["scripts"] ?? {} };
|
|
2323
|
+
scripts["dev:api"] ??= "cd api && vc dev";
|
|
2324
|
+
scripts["dev:frontend"] ??= "cd frontend && vc dev";
|
|
2325
|
+
scripts["preflight"] ??= "cd api && vc preflight";
|
|
2326
|
+
scripts["deploy"] ??= "cd api && vc deploy";
|
|
2327
|
+
scripts["import:catalog"] ??= "cd api && vc import";
|
|
2328
|
+
scripts["maildev"] ??= "cd api && bun run maildev";
|
|
2329
|
+
pkg["scripts"] = scripts;
|
|
2330
|
+
}, result);
|
|
2331
|
+
await put(root, ".gitignore", `node_modules
|
|
2332
|
+
dist
|
|
2333
|
+
.void
|
|
2334
|
+
.wrangler
|
|
2335
|
+
*.tsbuildinfo
|
|
2336
|
+
.DS_Store
|
|
2337
|
+
`, result, "own");
|
|
2338
|
+
}
|
|
2339
|
+
async function generateStrictRoot(root, manifest, result) {
|
|
2340
|
+
const deps = strictDependencies(manifest);
|
|
2341
|
+
await mergeJson(root, "package.json", { name: manifest.shop.domain.split(".")[0], type: "module", private: true }, (pkg) => {
|
|
2342
|
+
pkg["dependencies"] = sorted({ ...deps.dependencies, ...pkg["dependencies"] ?? {} });
|
|
2343
|
+
pkg["devDependencies"] = sorted({ ...deps.devDependencies, ...pkg["devDependencies"] ?? {} });
|
|
2344
|
+
pkg["patchedDependencies"] = { ...deps.patchedDependencies, ...pkg["patchedDependencies"] ?? {} };
|
|
2345
|
+
const scripts = { ...pkg["scripts"] ?? {} };
|
|
2346
|
+
scripts["generate"] ??= "vc generate";
|
|
2347
|
+
scripts["dist"] ??= "vc dist";
|
|
2348
|
+
scripts["dev"] ??= "vc dev";
|
|
2349
|
+
scripts["build"] ??= "vc build";
|
|
2350
|
+
scripts["dev:storefront"] ??= "vp dev";
|
|
2351
|
+
scripts["build:storefront"] ??= "vp build";
|
|
2352
|
+
scripts["typecheck"] ??= `vc generate && tsc -p ${STRICT_APP}`;
|
|
2353
|
+
scripts["preflight"] ??= "vc preflight";
|
|
2354
|
+
scripts["deploy"] ??= "vc deploy";
|
|
2355
|
+
scripts["import:catalog"] ??= "vc import";
|
|
2356
|
+
scripts["maildev"] ??= "maildev --smtp 1025 --web 1080";
|
|
2357
|
+
pkg["scripts"] = scripts;
|
|
2358
|
+
}, result);
|
|
2359
|
+
await put(root, ".gitignore", `node_modules
|
|
2360
|
+
.vc
|
|
2361
|
+
.void
|
|
2362
|
+
.wrangler
|
|
2363
|
+
.env
|
|
2364
|
+
.env.local
|
|
2365
|
+
dist
|
|
2366
|
+
*.tsbuildinfo
|
|
2367
|
+
.DS_Store
|
|
2368
|
+
`, result, "own");
|
|
2369
|
+
await put(root, "patches/void@0.10.13.patch", renderVoidPatch(), result, "regenerate");
|
|
2370
|
+
await put(root, ".github/workflows/void-dist.yml", renderDistWorkflow(manifest), result, "regenerate");
|
|
2371
|
+
await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
|
|
2372
|
+
await put(root, ".env", renderEnvLocal(manifest), result, "own");
|
|
2373
|
+
await put(root, "data/README.md", DATA_README, result, "own");
|
|
2374
|
+
await put(root, "branding/README.md", BRANDING_README, result, "own");
|
|
2375
|
+
await put(root, "migrations/README.md", MIGRATIONS_README, result, "own");
|
|
2376
|
+
if (has(manifest, "faqs"))
|
|
2377
|
+
await put(root, "content/faqs.json", `[]
|
|
2378
|
+
`, result, "own");
|
|
2379
|
+
if (has(manifest, "blogs"))
|
|
2380
|
+
await put(root, "content/posts.json", `[]
|
|
2381
|
+
`, result, "own");
|
|
2382
|
+
}
|
|
2383
|
+
async function generateStrictApp(root, dir, manifest, result) {
|
|
2384
|
+
const at = (file) => join4(dir, file);
|
|
2385
|
+
const gen = "regenerate";
|
|
2386
|
+
await link(root, at("node_modules"), join4("..", "..", "node_modules"), result);
|
|
2387
|
+
await link(root, at(".env"), join4("..", "..", ".env"), result);
|
|
2388
|
+
await link(root, at("db/migrations"), join4("..", "..", "..", "migrations"), result);
|
|
2389
|
+
await put(root, at("package.json"), renderStrictAppPackageJson(manifest), result, gen);
|
|
2390
|
+
await put(root, at("vite.config.ts"), renderViteConfig(manifest), result, gen);
|
|
2391
|
+
await put(root, at("tsconfig.json"), renderStrictTsconfig(), result, gen);
|
|
2392
|
+
await put(root, at("void.json"), renderVoidJson(), result, gen);
|
|
2393
|
+
await put(root, at("db/schema.ts"), renderDbSchema(), result, gen);
|
|
2394
|
+
await put(root, at("db/seed.ts"), renderDbSeed(), result, gen);
|
|
2395
|
+
await put(root, at("pages/layout.tsx"), renderLayoutTsx(), result, gen);
|
|
2396
|
+
await put(root, at("pages/app.css"), renderAppCss(), result, gen);
|
|
2397
|
+
await put(root, at("pages/index.server.ts"), renderIndexServer(), result, gen);
|
|
2398
|
+
await put(root, at("pages/index.tsx"), INDEX_PAGE, result, gen);
|
|
2399
|
+
await put(root, at("pages/api-dashboard/index.tsx"), renderDashboardPage(manifest), result, gen);
|
|
2400
|
+
await put(root, at("pages/api-dashboard/index.server.ts"), CLIENT_ONLY, result, gen);
|
|
2401
|
+
await put(root, at("pages/sign-in/index.tsx"), renderSignInPage(manifest), result, gen);
|
|
2402
|
+
await put(root, at("pages/sign-in/index.server.ts"), CLIENT_ONLY, result, gen);
|
|
2403
|
+
await put(root, at("lib/auth-client.ts"), renderAuthClient(manifest), result, gen);
|
|
2404
|
+
await put(root, at("crons/commerce-ticks.ts"), renderCron(), result, gen);
|
|
2405
|
+
await put(root, at("queues/commerce.ts"), renderQueue(), result, gen);
|
|
2406
|
+
await put(root, at("src/live.ts"), renderLiveStream(), result, gen);
|
|
2407
|
+
await put(root, at("routes/live.ts"), renderLiveRoute(), result, gen);
|
|
2408
|
+
if (has(manifest, "faqs") || has(manifest, "blogs")) {
|
|
2409
|
+
await mkdir2(join4(root, at("content")), { recursive: true });
|
|
2410
|
+
for (const file of ["faqs.json", "posts.json"]) {
|
|
2411
|
+
const source = join4(root, "content", file);
|
|
2412
|
+
if (existsSync4(source)) {
|
|
2413
|
+
await cp2(source, join4(root, at(`content/${file}`)));
|
|
2414
|
+
result.written.push(at(`content/${file}`));
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
await put(root, at("lib/content.ts"), renderContentTs(manifest), result, gen);
|
|
2418
|
+
}
|
|
2419
|
+
const branding = join4(root, "branding");
|
|
2420
|
+
if (existsSync4(branding)) {
|
|
2421
|
+
const files = (await readdir2(branding)).filter((name) => name !== "README.md");
|
|
2422
|
+
if (files.length > 0) {
|
|
2423
|
+
await mkdir2(join4(root, at("public/branding")), { recursive: true });
|
|
2424
|
+
for (const file of files)
|
|
2425
|
+
await cp2(join4(branding, file), join4(root, at(`public/branding/${file}`)), { recursive: true });
|
|
2426
|
+
result.written.push(`${at("public/branding/")} (${files.length} files)`);
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
// src/project.ts
|
|
2432
|
+
async function findProject(cwd = process.cwd()) {
|
|
2433
|
+
let root = cwd;
|
|
2434
|
+
let manifest = null;
|
|
2435
|
+
for (let hops = 0;hops < 3 && root; hops++) {
|
|
2436
|
+
manifest = await readManifest(root);
|
|
2437
|
+
if (manifest)
|
|
2438
|
+
break;
|
|
2439
|
+
const parent = dirname3(root);
|
|
2440
|
+
if (parent === root)
|
|
2441
|
+
break;
|
|
2442
|
+
root = parent;
|
|
2443
|
+
}
|
|
2444
|
+
if (!manifest)
|
|
2445
|
+
return null;
|
|
2446
|
+
switch (manifest.layout) {
|
|
2447
|
+
case "monorepo": {
|
|
2448
|
+
const api = join5(root, "api");
|
|
2449
|
+
if (cwd !== root && cwd !== api)
|
|
2450
|
+
return null;
|
|
2451
|
+
return { root, manifest, appDir: api, needsGenerate: false };
|
|
2452
|
+
}
|
|
2453
|
+
case "strict": {
|
|
2454
|
+
if (cwd !== root)
|
|
2455
|
+
return null;
|
|
2456
|
+
const appDir = join5(root, STRICT_APP);
|
|
2457
|
+
return { root, manifest, appDir, needsGenerate: !existsSync5(join5(appDir, "auth.ts")) };
|
|
2458
|
+
}
|
|
2459
|
+
default:
|
|
2460
|
+
if (cwd !== root)
|
|
2461
|
+
return null;
|
|
2462
|
+
return { root, manifest, appDir: root, needsGenerate: false };
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
async function ensureGenerated(project) {
|
|
2466
|
+
if (!project.needsGenerate)
|
|
2467
|
+
return 0;
|
|
2468
|
+
console.log("vc: no app under .vc/app yet — generating it from voidcommerce.json.");
|
|
2469
|
+
await generate(project.root, project.manifest);
|
|
2470
|
+
const code = await finishStrict(project.root);
|
|
2471
|
+
if (code === 0)
|
|
2472
|
+
project.needsGenerate = false;
|
|
2473
|
+
return code;
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
// src/deploy/jsonc.ts
|
|
2477
|
+
function parseJsonc(text) {
|
|
2478
|
+
let out = "";
|
|
2479
|
+
let inString = false;
|
|
2480
|
+
for (let i = 0;i < text.length; i++) {
|
|
2481
|
+
const ch = text[i];
|
|
2482
|
+
const next = text[i + 1];
|
|
2483
|
+
if (inString) {
|
|
2484
|
+
out += ch;
|
|
2485
|
+
if (ch === "\\") {
|
|
2486
|
+
out += next ?? "";
|
|
2487
|
+
i++;
|
|
2488
|
+
} else if (ch === '"')
|
|
2489
|
+
inString = false;
|
|
2490
|
+
continue;
|
|
2491
|
+
}
|
|
2492
|
+
if (ch === '"') {
|
|
2493
|
+
inString = true;
|
|
2494
|
+
out += ch;
|
|
2495
|
+
} else if (ch === "/" && next === "/") {
|
|
2496
|
+
while (i < text.length && text[i] !== `
|
|
2497
|
+
`)
|
|
2498
|
+
i++;
|
|
2499
|
+
out += `
|
|
2500
|
+
`;
|
|
2501
|
+
} else if (ch === "/" && next === "*") {
|
|
2502
|
+
i += 2;
|
|
2503
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
2504
|
+
i++;
|
|
2505
|
+
i++;
|
|
2506
|
+
} else
|
|
2507
|
+
out += ch;
|
|
2508
|
+
}
|
|
2509
|
+
let cleaned = "";
|
|
2510
|
+
inString = false;
|
|
2511
|
+
for (let i = 0;i < out.length; i++) {
|
|
2512
|
+
const ch = out[i];
|
|
2513
|
+
if (inString) {
|
|
2514
|
+
cleaned += ch;
|
|
2515
|
+
if (ch === "\\") {
|
|
2516
|
+
cleaned += out[i + 1] ?? "";
|
|
2517
|
+
i++;
|
|
2518
|
+
} else if (ch === '"')
|
|
2519
|
+
inString = false;
|
|
2520
|
+
continue;
|
|
2521
|
+
}
|
|
2522
|
+
if (ch === '"')
|
|
2523
|
+
inString = true;
|
|
2524
|
+
if (ch === ",") {
|
|
2525
|
+
let j = i + 1;
|
|
2526
|
+
while (j < out.length && /\s/.test(out[j]))
|
|
2527
|
+
j++;
|
|
2528
|
+
if (out[j] === "}" || out[j] === "]")
|
|
2529
|
+
continue;
|
|
2530
|
+
}
|
|
2531
|
+
cleaned += ch;
|
|
2532
|
+
}
|
|
2533
|
+
return JSON.parse(cleaned);
|
|
2534
|
+
}
|
|
2535
|
+
function findTopLevel(text, key) {
|
|
2536
|
+
let depth = 0;
|
|
2537
|
+
let inString = false;
|
|
2538
|
+
let stringStart = -1;
|
|
2539
|
+
let lastKey = null;
|
|
2540
|
+
for (let i = 0;i < text.length; i++) {
|
|
2541
|
+
const ch = text[i];
|
|
2542
|
+
const next = text[i + 1];
|
|
2543
|
+
if (inString) {
|
|
2544
|
+
if (ch === "\\")
|
|
2545
|
+
i++;
|
|
2546
|
+
else if (ch === '"') {
|
|
2547
|
+
inString = false;
|
|
2548
|
+
if (depth === 1)
|
|
2549
|
+
lastKey = { text: text.slice(stringStart + 1, i), start: stringStart };
|
|
2550
|
+
}
|
|
2551
|
+
continue;
|
|
2552
|
+
}
|
|
2553
|
+
if (ch === "/" && next === "/") {
|
|
2554
|
+
while (i < text.length && text[i] !== `
|
|
2555
|
+
`)
|
|
2556
|
+
i++;
|
|
2557
|
+
continue;
|
|
2558
|
+
}
|
|
2559
|
+
if (ch === "/" && next === "*") {
|
|
2560
|
+
i += 2;
|
|
2561
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
2562
|
+
i++;
|
|
2563
|
+
i++;
|
|
2564
|
+
continue;
|
|
2565
|
+
}
|
|
2566
|
+
if (ch === '"') {
|
|
2567
|
+
inString = true;
|
|
2568
|
+
stringStart = i;
|
|
2569
|
+
continue;
|
|
2570
|
+
}
|
|
2571
|
+
if (ch === ":" && depth === 1 && lastKey?.text === key) {
|
|
2572
|
+
let j = i + 1;
|
|
2573
|
+
while (j < text.length && /\s/.test(text[j]))
|
|
2574
|
+
j++;
|
|
2575
|
+
return { keyStart: lastKey.start, valueStart: j, valueEnd: valueEnd(text, j) };
|
|
2576
|
+
}
|
|
2577
|
+
if (ch === "{" || ch === "[")
|
|
2578
|
+
depth++;
|
|
2579
|
+
else if (ch === "}" || ch === "]")
|
|
2580
|
+
depth--;
|
|
2581
|
+
}
|
|
2582
|
+
return null;
|
|
2583
|
+
}
|
|
2584
|
+
function valueEnd(text, from) {
|
|
2585
|
+
const open = text[from];
|
|
2586
|
+
if (open === '"') {
|
|
2587
|
+
for (let i2 = from + 1;i2 < text.length; i2++) {
|
|
2588
|
+
if (text[i2] === "\\")
|
|
2589
|
+
i2++;
|
|
2590
|
+
else if (text[i2] === '"')
|
|
2591
|
+
return i2 + 1;
|
|
2592
|
+
}
|
|
2593
|
+
return text.length;
|
|
2594
|
+
}
|
|
2595
|
+
if (open === "{" || open === "[") {
|
|
2596
|
+
let depth = 0;
|
|
2597
|
+
let inString = false;
|
|
2598
|
+
for (let i2 = from;i2 < text.length; i2++) {
|
|
2599
|
+
const ch = text[i2];
|
|
2600
|
+
if (inString) {
|
|
2601
|
+
if (ch === "\\")
|
|
2602
|
+
i2++;
|
|
2603
|
+
else if (ch === '"')
|
|
2604
|
+
inString = false;
|
|
2605
|
+
continue;
|
|
2606
|
+
}
|
|
2607
|
+
if (ch === '"')
|
|
2608
|
+
inString = true;
|
|
2609
|
+
else if (ch === "/" && text[i2 + 1] === "/") {
|
|
2610
|
+
while (i2 < text.length && text[i2] !== `
|
|
2611
|
+
`)
|
|
2612
|
+
i2++;
|
|
2613
|
+
} else if (ch === "{" || ch === "[")
|
|
2614
|
+
depth++;
|
|
2615
|
+
else if (ch === "}" || ch === "]") {
|
|
2616
|
+
depth--;
|
|
2617
|
+
if (depth === 0)
|
|
2618
|
+
return i2 + 1;
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
return text.length;
|
|
2622
|
+
}
|
|
2623
|
+
let i = from;
|
|
2624
|
+
while (i < text.length && !/[,}\]\s]/.test(text[i]))
|
|
2625
|
+
i++;
|
|
2626
|
+
return i;
|
|
2627
|
+
}
|
|
2628
|
+
function upsertJsonc(text, key, value, indent = " ") {
|
|
2629
|
+
const rendered = JSON.stringify(value, null, indent).replace(/\n/g, `
|
|
2630
|
+
${indent}`);
|
|
2631
|
+
const found = findTopLevel(text, key);
|
|
2632
|
+
if (found)
|
|
2633
|
+
return text.slice(0, found.valueStart) + rendered + text.slice(found.valueEnd);
|
|
2634
|
+
const close = text.lastIndexOf("}");
|
|
2635
|
+
if (close < 0)
|
|
2636
|
+
return `{
|
|
2637
|
+
${indent}${JSON.stringify(key)}: ${rendered}
|
|
2638
|
+
}
|
|
2639
|
+
`;
|
|
2640
|
+
let k = close - 1;
|
|
2641
|
+
while (k >= 0 && /\s/.test(text[k]))
|
|
2642
|
+
k--;
|
|
2643
|
+
const needsComma = text[k] !== "{" && text[k] !== ",";
|
|
2644
|
+
return `${text.slice(0, close).replace(/\s*$/, "")}${needsComma ? "," : ""}
|
|
2645
|
+
${indent}${JSON.stringify(key)}: ${rendered}
|
|
2646
|
+
${text.slice(close)}`;
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
// src/deploy/wrangler.ts
|
|
2650
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
2651
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
2652
|
+
import { delimiter as delimiter2, dirname as dirname4, join as join6 } from "node:path";
|
|
2653
|
+
function findWrangler(from = process.cwd()) {
|
|
2654
|
+
let dir = from;
|
|
2655
|
+
for (;; ) {
|
|
2656
|
+
const local = join6(dir, "node_modules", ".bin", "wrangler");
|
|
2657
|
+
if (existsSync6(local))
|
|
2658
|
+
return local;
|
|
2659
|
+
const parent = dirname4(dir);
|
|
2660
|
+
if (parent === dir)
|
|
2661
|
+
break;
|
|
2662
|
+
dir = parent;
|
|
2663
|
+
}
|
|
2664
|
+
const onPath2 = (process.env["PATH"] ?? "").split(delimiter2).filter(Boolean).some((d) => existsSync6(join6(d, "wrangler")));
|
|
2665
|
+
return onPath2 ? "wrangler" : null;
|
|
2666
|
+
}
|
|
2667
|
+
function wrangler(bin, args, cwd, inherit = false) {
|
|
2668
|
+
return new Promise((resolve) => {
|
|
2669
|
+
const child = spawn2(bin, args, { cwd, stdio: inherit ? "inherit" : ["ignore", "pipe", "pipe"] });
|
|
2670
|
+
let out = "";
|
|
2671
|
+
child.stdout?.on("data", (chunk) => {
|
|
2672
|
+
out += chunk;
|
|
2673
|
+
});
|
|
2674
|
+
child.stderr?.on("data", (chunk) => {
|
|
2675
|
+
out += chunk;
|
|
2676
|
+
});
|
|
2677
|
+
child.on("error", (error) => resolve({ code: 1, out: String(error) }));
|
|
2678
|
+
child.on("exit", (code) => resolve({ code: code ?? 1, out }));
|
|
2679
|
+
});
|
|
2680
|
+
}
|
|
2681
|
+
function parseWhoAmI(raw) {
|
|
2682
|
+
if (/not authenticated/i.test(raw))
|
|
2683
|
+
return { state: "logged-out", accounts: [], raw };
|
|
2684
|
+
const accounts = [...raw.matchAll(/│\s*([^│\n]+?)\s*│\s*([0-9a-f]{32})\s*│/g)].map((m) => ({ name: m[1], id: m[2] }));
|
|
2685
|
+
if (/logged in/i.test(raw) || accounts.length > 0)
|
|
2686
|
+
return { state: "logged-in", accounts, raw };
|
|
2687
|
+
return { state: "unknown", accounts: [], raw };
|
|
2688
|
+
}
|
|
2689
|
+
async function whoami(bin, cwd) {
|
|
2690
|
+
return parseWhoAmI((await wrangler(bin, ["whoami"], cwd)).out);
|
|
2691
|
+
}
|
|
2692
|
+
async function secretNames(bin, cwd) {
|
|
2693
|
+
const ran = await wrangler(bin, ["secret", "list", "--format", "json"], cwd);
|
|
2694
|
+
if (ran.code !== 0)
|
|
2695
|
+
return null;
|
|
2696
|
+
try {
|
|
2697
|
+
const start = ran.out.indexOf("[");
|
|
2698
|
+
const rows = JSON.parse(ran.out.slice(start));
|
|
2699
|
+
return new Set(rows.map((row2) => row2.name));
|
|
2700
|
+
} catch {
|
|
2701
|
+
return null;
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
async function d1List(bin, cwd) {
|
|
2705
|
+
const ran = await wrangler(bin, ["d1", "list", "--json"], cwd);
|
|
2706
|
+
if (ran.code !== 0)
|
|
2707
|
+
throw new Error(`wrangler d1 list failed:
|
|
2708
|
+
${ran.out.trim()}`);
|
|
2709
|
+
const start = ran.out.indexOf("[");
|
|
2710
|
+
return JSON.parse(ran.out.slice(start));
|
|
2711
|
+
}
|
|
2712
|
+
async function ensureD1(bin, cwd, name) {
|
|
2713
|
+
const existing = (await d1List(bin, cwd)).find((db) => db.name === name);
|
|
2714
|
+
if (existing)
|
|
2715
|
+
return existing;
|
|
2716
|
+
const created = await wrangler(bin, ["d1", "create", name], cwd);
|
|
2717
|
+
if (created.code !== 0)
|
|
2718
|
+
throw new Error(`wrangler d1 create ${name} failed:
|
|
2719
|
+
${created.out.trim()}`);
|
|
2720
|
+
const found = (await d1List(bin, cwd)).find((db) => db.name === name);
|
|
2721
|
+
if (!found)
|
|
2722
|
+
throw new Error(`created D1 "${name}" but it is not in wrangler d1 list`);
|
|
2723
|
+
return found;
|
|
2724
|
+
}
|
|
2725
|
+
async function ensureQueue(bin, cwd, name) {
|
|
2726
|
+
const ran = await wrangler(bin, ["queues", "create", name], cwd);
|
|
2727
|
+
if (ran.code !== 0 && !/already exists|11009/i.test(ran.out)) {
|
|
2728
|
+
throw new Error(`wrangler queues create ${name} failed:
|
|
2729
|
+
${ran.out.trim()}`);
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
// src/deploy/preflight.ts
|
|
2734
|
+
import { existsSync as existsSync7, readFileSync as readFileSync2 } from "node:fs";
|
|
2735
|
+
import { join as join7 } from "node:path";
|
|
2736
|
+
import color3 from "picocolors";
|
|
2737
|
+
var UNSET = "unset";
|
|
2738
|
+
function productionEnv(appDir) {
|
|
2739
|
+
const out = new Map;
|
|
2740
|
+
const path = join7(appDir, ".env.production");
|
|
2741
|
+
if (!existsSync7(path))
|
|
2742
|
+
return out;
|
|
2743
|
+
for (const line2 of readFileSync2(path, "utf8").split(`
|
|
2744
|
+
`)) {
|
|
2745
|
+
const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line2);
|
|
2746
|
+
if (!match)
|
|
2747
|
+
continue;
|
|
2748
|
+
const value = (match[2] ?? "").trim().replace(/^['"]|['"]$/g, "");
|
|
2749
|
+
if (value)
|
|
2750
|
+
out.set(match[1], value);
|
|
2751
|
+
}
|
|
2752
|
+
return out;
|
|
2753
|
+
}
|
|
2754
|
+
async function voidSecretNames(appDir) {
|
|
2755
|
+
const captured = await captureVoid(["secret", "list"], appDir);
|
|
2756
|
+
if (!captured || captured.code !== 0)
|
|
2757
|
+
return null;
|
|
2758
|
+
const names = new Set;
|
|
2759
|
+
for (const line2 of captured.out.split(`
|
|
2760
|
+
`)) {
|
|
2761
|
+
const match = /\b([A-Z][A-Z0-9_]{2,})\b/.exec(line2.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""));
|
|
2762
|
+
if (match)
|
|
2763
|
+
names.add(match[1]);
|
|
2764
|
+
}
|
|
2765
|
+
return names;
|
|
2766
|
+
}
|
|
2767
|
+
function routeProblem(project) {
|
|
2768
|
+
const path = join7(project.appDir, "wrangler.jsonc");
|
|
2769
|
+
if (!existsSync7(path))
|
|
2770
|
+
return "wrangler.jsonc is missing";
|
|
2771
|
+
let routes = [];
|
|
2772
|
+
try {
|
|
2773
|
+
routes = parseJsonc(readFileSync2(path, "utf8")).routes ?? [];
|
|
2774
|
+
} catch {
|
|
2775
|
+
return "wrangler.jsonc cannot be parsed";
|
|
2776
|
+
}
|
|
2777
|
+
const expected = workerHosts(project.manifest);
|
|
2778
|
+
const found = routes.map((r) => r.pattern ?? "");
|
|
2779
|
+
const absent = expected.filter((host) => !found.includes(host));
|
|
2780
|
+
return absent.length === 0 ? null : `wrangler.jsonc routes to [${found.join(", ")}] but the shop needs ${absent.join(", ")}`;
|
|
2781
|
+
}
|
|
2782
|
+
async function preflight(project, source) {
|
|
2783
|
+
const present = productionEnv(project.appDir);
|
|
2784
|
+
let remote = null;
|
|
2785
|
+
if (source === "wrangler") {
|
|
2786
|
+
const bin = findWrangler(project.appDir);
|
|
2787
|
+
remote = bin ? await secretNames(bin, project.appDir) : null;
|
|
2788
|
+
} else {
|
|
2789
|
+
remote = await voidSecretNames(project.appDir);
|
|
2790
|
+
}
|
|
2791
|
+
for (const name of remote ?? [])
|
|
2792
|
+
present.set(name, "<secret>");
|
|
2793
|
+
const missing = allEnvKeys(project.manifest).filter((key) => {
|
|
2794
|
+
const value = present.get(key.key);
|
|
2795
|
+
return value === undefined || value === "" || value === UNSET;
|
|
2796
|
+
});
|
|
2797
|
+
const problem = routeProblem(project);
|
|
2798
|
+
return { present, remote, missing, routeProblem: problem, ready: missing.length === 0 && problem === null };
|
|
2799
|
+
}
|
|
2800
|
+
function printPreflight(project, result, source) {
|
|
2801
|
+
const keys = allEnvKeys(project.manifest);
|
|
2802
|
+
if (result.remote === null) {
|
|
2803
|
+
console.log(color3.dim(source === "wrangler" ? `Could not read the worker's secrets — not deployed yet, or wrangler is not logged in.
|
|
2804
|
+
Anything not in .env.production is reported as missing.
|
|
2805
|
+
` : `Could not read the project's secrets from Void — not logged in, or no linked project.
|
|
2806
|
+
Anything not in .env.production is reported as missing.
|
|
2807
|
+
`));
|
|
2808
|
+
}
|
|
2809
|
+
console.log("REQUIRED — the shop cannot trade without these");
|
|
2810
|
+
for (const key of keys) {
|
|
2811
|
+
const value = result.present.get(key.key);
|
|
2812
|
+
const set = value !== undefined && value !== "" && value !== UNSET;
|
|
2813
|
+
console.log(` ${set ? color3.green("set ") : color3.dim("unset")} ${key.key}${key.plaintext ? color3.dim(" (plaintext)") : ""}`);
|
|
2814
|
+
}
|
|
2815
|
+
if (result.ready) {
|
|
2816
|
+
console.log(`
|
|
2817
|
+
${color3.green("Ready to go live.")}
|
|
2818
|
+
`);
|
|
2819
|
+
return;
|
|
2820
|
+
}
|
|
2821
|
+
console.log(`
|
|
2822
|
+
${color3.red("NOT ready to go live.")}
|
|
2823
|
+
`);
|
|
2824
|
+
if (result.routeProblem) {
|
|
2825
|
+
console.log(`${color3.red("✗")} the worker's hostname
|
|
2826
|
+
${result.routeProblem}
|
|
2827
|
+
`);
|
|
2828
|
+
}
|
|
2829
|
+
for (const key of result.missing) {
|
|
2830
|
+
console.log(`${color3.red("✗")} ${key.key}`);
|
|
2831
|
+
console.log(` ${key.breaks}`);
|
|
2832
|
+
if (key.where)
|
|
2833
|
+
console.log(` ${color3.dim(`from: ${key.where}`)}`);
|
|
2834
|
+
console.log("");
|
|
2835
|
+
}
|
|
2836
|
+
const secrets = result.missing.filter((key) => !key.plaintext);
|
|
2837
|
+
if (secrets.length > 0) {
|
|
2838
|
+
console.log(source === "wrangler" ? "Set each secret on the worker (this also creates the draft worker on a first deploy):" : "Set each secret on the project:");
|
|
2839
|
+
for (const key of secrets)
|
|
2840
|
+
console.log(` ${source === "wrangler" ? "wrangler secret put" : "void secret put"} ${key.key}`);
|
|
2841
|
+
}
|
|
2842
|
+
const plain = result.missing.filter((key) => key.plaintext);
|
|
2843
|
+
if (plain.length > 0)
|
|
2844
|
+
console.log(`Values safe to commit go in .env.production: ${plain.map((k) => k.key).join(", ")}`);
|
|
2845
|
+
console.log("");
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
// src/deploy/cloudflare.ts
|
|
2849
|
+
import { existsSync as existsSync8, readFileSync as readFileSync3, writeFileSync } from "node:fs";
|
|
2850
|
+
import { dirname as dirname5, join as join8 } from "node:path";
|
|
2851
|
+
import color4 from "picocolors";
|
|
2852
|
+
var fail = (message) => {
|
|
2853
|
+
console.error(`
|
|
2854
|
+
${color4.red("✗")} ${message}
|
|
2855
|
+
`);
|
|
2856
|
+
return 1;
|
|
2857
|
+
};
|
|
2858
|
+
function readConfig(path) {
|
|
2859
|
+
return parseJsonc(readFileSync3(path, "utf8"));
|
|
2860
|
+
}
|
|
2861
|
+
function findBuilder(from) {
|
|
2862
|
+
let dir = from;
|
|
2863
|
+
for (;; ) {
|
|
2864
|
+
for (const [bin, label] of [
|
|
2865
|
+
["vp", "vp build"],
|
|
2866
|
+
["vite", "vite build"]
|
|
2867
|
+
]) {
|
|
2868
|
+
const path = join8(dir, "node_modules", ".bin", bin);
|
|
2869
|
+
if (existsSync8(path))
|
|
2870
|
+
return { cmd: path, label };
|
|
2871
|
+
}
|
|
2872
|
+
const parent = dirname5(dir);
|
|
2873
|
+
if (parent === dir)
|
|
2874
|
+
return null;
|
|
2875
|
+
dir = parent;
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
async function deployCloudflare(project, opts) {
|
|
2879
|
+
const app = project.appDir;
|
|
2880
|
+
const configPath = join8(app, "wrangler.jsonc");
|
|
2881
|
+
if (!existsSync8(configPath))
|
|
2882
|
+
return fail("wrangler.jsonc is missing — `vc generate` writes it.");
|
|
2883
|
+
const bin = findWrangler(app);
|
|
2884
|
+
if (!bin)
|
|
2885
|
+
return fail("wrangler is not installed. `bun add -d wrangler`, then `wrangler login`.");
|
|
2886
|
+
const who = await whoami(bin, app);
|
|
2887
|
+
if (who.state === "logged-out")
|
|
2888
|
+
return fail("wrangler is not logged in. Run `wrangler login` (or export CLOUDFLARE_API_TOKEN).");
|
|
2889
|
+
if (who.state === "unknown")
|
|
2890
|
+
return fail(`wrangler could not confirm who you are:
|
|
2891
|
+
${who.raw.trim().split(`
|
|
2892
|
+
`).slice(-4).join(`
|
|
2893
|
+
`)}`);
|
|
2894
|
+
console.log(`${color4.green("✓")} wrangler is logged in`);
|
|
2895
|
+
let config = readConfig(configPath);
|
|
2896
|
+
let accountId = config.account_id || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
|
|
2897
|
+
if (!accountId) {
|
|
2898
|
+
if (who.accounts.length === 1) {
|
|
2899
|
+
accountId = who.accounts[0].id;
|
|
2900
|
+
writeFileSync(configPath, upsertJsonc(readFileSync3(configPath, "utf8"), "account_id", accountId));
|
|
2901
|
+
project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId };
|
|
2902
|
+
await writeManifest(project.root, project.manifest);
|
|
2903
|
+
console.log(`${color4.green("✓")} pinned the account "${who.accounts[0].name}" in wrangler.jsonc`);
|
|
2904
|
+
} else {
|
|
2905
|
+
return fail(`the account is not pinned and wrangler sees ${who.accounts.length}. Set account_id in wrangler.jsonc to one of:
|
|
2906
|
+
${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
2907
|
+
`)}`);
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
const check = await preflight(project, "wrangler");
|
|
2911
|
+
printPreflight(project, check, "wrangler");
|
|
2912
|
+
if (!check.ready) {
|
|
2913
|
+
if (!opts.force)
|
|
2914
|
+
return fail("refusing to deploy a shop that is not ready for customers. Fix the above, or pass --force for a deliberate partial deploy.");
|
|
2915
|
+
console.error(color4.yellow(`--force: deploying a shop that is NOT ready for customers.
|
|
2916
|
+
`));
|
|
2917
|
+
}
|
|
2918
|
+
const worker = config.name || project.manifest.shop.domain.split(".")[0];
|
|
2919
|
+
const d1 = config.d1_databases?.find((db) => db.binding === "DB");
|
|
2920
|
+
const provisioned = Boolean(d1?.database_id && d1.database_id !== "local");
|
|
2921
|
+
if (!provisioned) {
|
|
2922
|
+
if (!opts.provision)
|
|
2923
|
+
return fail("the D1 database is not provisioned. Run once with --provision to create it and record its id.");
|
|
2924
|
+
const db = await ensureD1(bin, app, `${worker}-db`);
|
|
2925
|
+
const entry = { binding: "DB", database_name: db.name, database_id: db.uuid, migrations_dir: "./db/migrations" };
|
|
2926
|
+
writeFileSync(configPath, upsertJsonc(readFileSync3(configPath, "utf8"), "d1_databases", [entry]));
|
|
2927
|
+
project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId, d1: { name: db.name, id: db.uuid } };
|
|
2928
|
+
await writeManifest(project.root, project.manifest);
|
|
2929
|
+
console.log(`${color4.green("✓")} D1 "${db.name}" recorded in wrangler.jsonc and voidcommerce.json`);
|
|
2930
|
+
config = readConfig(configPath);
|
|
2931
|
+
}
|
|
2932
|
+
if (opts.provision) {
|
|
2933
|
+
await ensureQueue(bin, app, "commerce");
|
|
2934
|
+
console.log(`${color4.green("✓")} queue "commerce"`);
|
|
2935
|
+
}
|
|
2936
|
+
const builder = findBuilder(app);
|
|
2937
|
+
if (!builder)
|
|
2938
|
+
return fail("no build tool: neither vite-plus nor vite is installed.");
|
|
2939
|
+
console.log(`
|
|
2940
|
+
▸ ${builder.label}`);
|
|
2941
|
+
const built = await wrangler(builder.cmd, ["build"], app, true);
|
|
2942
|
+
if (built.code !== 0)
|
|
2943
|
+
return fail(`the build failed (exit ${built.code}).`);
|
|
2944
|
+
const emittedPath = join8(app, "dist", "ssr", "wrangler.json");
|
|
2945
|
+
if (!existsSync8(emittedPath))
|
|
2946
|
+
return fail(`the build emitted no ${emittedPath} — is this a Void app on the Cloudflare target?`);
|
|
2947
|
+
const emitted = JSON.parse(readFileSync3(emittedPath, "utf8"));
|
|
2948
|
+
const secretKeys = new Set(allEnvKeys(project.manifest).filter((key) => !key.plaintext).map((key) => key.key));
|
|
2949
|
+
const scrubbed = [];
|
|
2950
|
+
for (const [key, value] of Object.entries(emitted.vars ?? {})) {
|
|
2951
|
+
if (secretKeys.has(key) || value === "unset") {
|
|
2952
|
+
delete emitted.vars[key];
|
|
2953
|
+
scrubbed.push(key);
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2956
|
+
const emittedD1 = emitted.d1_databases?.find((db) => db.binding === "DB");
|
|
2957
|
+
if (!emittedD1 || emittedD1.database_id === "local")
|
|
2958
|
+
return fail("the emitted config still carries a placeholder D1 id; wrangler.jsonc's DB binding was not picked up by the build.");
|
|
2959
|
+
writeFileSync(emittedPath, JSON.stringify(emitted, null, 2));
|
|
2960
|
+
console.log(`${color4.green("✓")} scrubbed ${scrubbed.length} baked value${scrubbed.length === 1 ? "" : "s"} from the worker's vars${scrubbed.length ? `: ${scrubbed.join(", ")}` : ""}`);
|
|
2961
|
+
console.log(`
|
|
2962
|
+
▸ wrangler d1 migrations apply ${emittedD1.database_name} --remote`);
|
|
2963
|
+
const migrated = await wrangler(bin, ["d1", "migrations", "apply", emittedD1.database_name, "--remote"], app, true);
|
|
2964
|
+
if (migrated.code !== 0)
|
|
2965
|
+
return fail(`applying migrations failed (exit ${migrated.code}); nothing was deployed.`);
|
|
2966
|
+
console.log(`
|
|
2967
|
+
▸ wrangler deploy -c dist/ssr/wrangler.json`);
|
|
2968
|
+
const deployed = await wrangler(bin, ["deploy", "-c", join8("dist", "ssr", "wrangler.json")], app, true);
|
|
2969
|
+
if (deployed.code !== 0)
|
|
2970
|
+
return fail(`wrangler deploy failed (exit ${deployed.code}).`);
|
|
2971
|
+
const domain = project.manifest.shop.domain;
|
|
2972
|
+
const url = `https://${workerHosts(project.manifest)[0]}`;
|
|
2973
|
+
console.log(`
|
|
2974
|
+
${color4.green("Live:")} ${url}`);
|
|
2975
|
+
if (hasFrontend(project.manifest.layout))
|
|
2976
|
+
console.log(color4.dim("The storefront deploys itself from GitHub Actions on push."));
|
|
2977
|
+
return 0;
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
// src/import.ts
|
|
2981
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
2982
|
+
import { existsSync as existsSync9 } from "node:fs";
|
|
2983
|
+
import { join as join9 } from "node:path";
|
|
2984
|
+
import color5 from "picocolors";
|
|
2985
|
+
function describe(counts) {
|
|
2986
|
+
if (!counts)
|
|
2987
|
+
return "";
|
|
2988
|
+
const parts = [
|
|
2989
|
+
counts.created ? `${counts.created} created` : "",
|
|
2990
|
+
counts.updated ? `${counts.updated} updated` : "",
|
|
2991
|
+
counts.written ? `${counts.written} written` : "",
|
|
2992
|
+
counts.unchanged ? `${counts.unchanged} unchanged` : ""
|
|
2993
|
+
].filter(Boolean);
|
|
2994
|
+
return parts.join(", ") || "nothing to do";
|
|
2995
|
+
}
|
|
2996
|
+
function resolveTarget(project, args) {
|
|
2997
|
+
const flag = args.indexOf("--url");
|
|
2998
|
+
if (flag >= 0 && args[flag + 1])
|
|
2999
|
+
return { url: args[flag + 1].replace(/\/$/, ""), how: "--url" };
|
|
3000
|
+
if (process.env["APP_URL"])
|
|
3001
|
+
return { url: process.env["APP_URL"].replace(/\/$/, ""), how: "APP_URL" };
|
|
3002
|
+
if (args.includes("--local"))
|
|
3003
|
+
return { url: "http://127.0.0.1:5173", how: "--local" };
|
|
3004
|
+
const domain = productionEnv(project.appDir).get("SHOP_DOMAIN") ?? project.manifest.shop.domain;
|
|
3005
|
+
const host = project.manifest.layout === "app" ? domain : `api.${domain}`;
|
|
3006
|
+
return { url: `https://${host}`, how: "the shop's own domain" };
|
|
3007
|
+
}
|
|
3008
|
+
async function post(url, key, body) {
|
|
3009
|
+
let response;
|
|
3010
|
+
try {
|
|
3011
|
+
response = await fetch(url, {
|
|
3012
|
+
method: "POST",
|
|
3013
|
+
headers: { "content-type": "application/json", "x-system-key": key },
|
|
3014
|
+
body: JSON.stringify(body)
|
|
3015
|
+
});
|
|
3016
|
+
} catch (error) {
|
|
3017
|
+
return { ok: false, status: 0, body: { error: error instanceof Error ? error.message : String(error) } };
|
|
3018
|
+
}
|
|
3019
|
+
const parsed = await response.json().catch(() => ({}));
|
|
3020
|
+
return { ok: response.ok, status: response.status, body: { ...parsed, error: parsed.error ?? parsed.message } };
|
|
3021
|
+
}
|
|
3022
|
+
async function readJson(path) {
|
|
3023
|
+
if (!existsSync9(path))
|
|
3024
|
+
return null;
|
|
3025
|
+
try {
|
|
3026
|
+
return JSON.parse(await readFile3(path, "utf8"));
|
|
3027
|
+
} catch (error) {
|
|
3028
|
+
throw new Error(`${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
async function importCommand(args) {
|
|
3032
|
+
const project = await findProject();
|
|
3033
|
+
if (!project) {
|
|
3034
|
+
console.error("vc: no voidcommerce.json here.");
|
|
3035
|
+
return 1;
|
|
3036
|
+
}
|
|
3037
|
+
const dry = args.includes("--dry-run");
|
|
3038
|
+
const key = process.env["SYSTEM_API_KEY"];
|
|
3039
|
+
if (!key && !dry) {
|
|
3040
|
+
console.error(`vc: SYSTEM_API_KEY is not in this shell.
|
|
3041
|
+
|
|
3042
|
+
` + ` It is the deployment's own identity, so the import authenticates as the
|
|
3043
|
+
` + ` shop rather than as you. It is a worker secret; export it here:
|
|
3044
|
+
|
|
3045
|
+
` + ` SYSTEM_API_KEY=… vc import
|
|
3046
|
+
`);
|
|
3047
|
+
return 1;
|
|
3048
|
+
}
|
|
3049
|
+
const { url, how } = resolveTarget(project, args);
|
|
3050
|
+
if (!dry)
|
|
3051
|
+
console.log(`${color5.dim("→")} ${url} ${color5.dim(`(${how})`)}
|
|
3052
|
+
`);
|
|
3053
|
+
if (!dry) {
|
|
3054
|
+
let whoami2;
|
|
3055
|
+
try {
|
|
3056
|
+
whoami2 = await fetch(`${url}/api/auth/system/whoami`, { headers: { "x-system-key": key } });
|
|
3057
|
+
} catch (error) {
|
|
3058
|
+
console.error(`${color5.red("✗")} could not reach ${url}: ${error instanceof Error ? error.message : String(error)}
|
|
3059
|
+
` + ` Is the shop running? \`vc dev\` serves it locally; --local points here.
|
|
3060
|
+
`);
|
|
3061
|
+
return 1;
|
|
3062
|
+
}
|
|
3063
|
+
if (!whoami2.ok) {
|
|
3064
|
+
console.error(`${color5.red("✗")} the shop refused the system key (HTTP ${whoami2.status}).
|
|
3065
|
+
` + ` Is SYSTEM_API_KEY the one this deployment was given?
|
|
3066
|
+
`);
|
|
3067
|
+
return 1;
|
|
3068
|
+
}
|
|
3069
|
+
console.log(`${color5.green("✓")} authenticated as the system identity`);
|
|
3070
|
+
}
|
|
3071
|
+
const root = project.root;
|
|
3072
|
+
const products = await readJson(join9(root, "data", "catalog.json"));
|
|
3073
|
+
const categories = await readJson(join9(root, "data", "categories.json"));
|
|
3074
|
+
const faqs = has(project.manifest, "faqs") ? await readJson(join9(root, "content", "faqs.json")) : null;
|
|
3075
|
+
const posts = has(project.manifest, "blogs") ? await readJson(join9(root, "content", "posts.json")) : null;
|
|
3076
|
+
if (!products && !faqs?.length && !posts?.length) {
|
|
3077
|
+
console.log(`
|
|
3078
|
+
${color5.yellow("Nothing to import.")} data/catalog.json is absent and the content files are empty.
|
|
3079
|
+
` + `Products go in data/catalog.json as a JSON array; \`vc import --dry-run\` checks it without pushing.
|
|
3080
|
+
`);
|
|
3081
|
+
return 0;
|
|
3082
|
+
}
|
|
3083
|
+
if (dry) {
|
|
3084
|
+
console.log(`
|
|
3085
|
+
${color5.dim("--dry-run: nothing was pushed.")}`);
|
|
3086
|
+
console.log(` ${products?.length ?? 0} products, ${categories?.length ?? 0} categories`);
|
|
3087
|
+
console.log(` ${faqs?.length ?? 0} FAQ entries, ${posts?.length ?? 0} posts
|
|
3088
|
+
`);
|
|
3089
|
+
return 0;
|
|
3090
|
+
}
|
|
3091
|
+
let failed = false;
|
|
3092
|
+
if (products) {
|
|
3093
|
+
const result = await post(`${url}/api/auth/commerce/admin/catalog-import/push`, key, {
|
|
3094
|
+
source: "devprints",
|
|
3095
|
+
...categories ? { categories } : {},
|
|
3096
|
+
products,
|
|
3097
|
+
...args.includes("--deactivate-missing") ? { deactivateMissing: true } : {}
|
|
3098
|
+
});
|
|
3099
|
+
if (result.ok) {
|
|
3100
|
+
const report = result.body.report ?? {};
|
|
3101
|
+
console.log(`${color5.green("✓")} products: ${describe(report.products)}`);
|
|
3102
|
+
if (report.categories)
|
|
3103
|
+
console.log(`${color5.green("✓")} categories: ${describe(report.categories)}`);
|
|
3104
|
+
if (report.prices)
|
|
3105
|
+
console.log(`${color5.green("✓")} prices: ${describe(report.prices)}`);
|
|
3106
|
+
if (report.addons)
|
|
3107
|
+
console.log(`${color5.green("✓")} addons: ${describe(report.addons)}`);
|
|
3108
|
+
} else {
|
|
3109
|
+
console.error(`${color5.red("✗")} catalogue: ${result.body.error ?? `HTTP ${result.status}`}`);
|
|
3110
|
+
failed = true;
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
for (const [label, rows, path] of [
|
|
3114
|
+
["FAQ", faqs, "/api/auth/faqs/import"],
|
|
3115
|
+
["posts", posts, "/api/auth/blog/import"]
|
|
3116
|
+
]) {
|
|
3117
|
+
if (!rows?.length)
|
|
3118
|
+
continue;
|
|
3119
|
+
const result = await post(`${url}${path}`, key, { entries: rows, posts: rows });
|
|
3120
|
+
if (result.ok) {
|
|
3121
|
+
console.log(`${color5.green("✓")} ${label}: ${describe(result.body)}`);
|
|
3122
|
+
} else {
|
|
3123
|
+
console.error(`${color5.red("✗")} ${label}: ${result.body.error ?? `HTTP ${result.status}`}`);
|
|
3124
|
+
failed = true;
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
if (failed) {
|
|
3128
|
+
console.error(`
|
|
3129
|
+
${color5.red("Some of it did not land.")} The import is an upsert, so fixing the cause and running it again is safe.
|
|
3130
|
+
`);
|
|
3131
|
+
return 1;
|
|
3132
|
+
}
|
|
3133
|
+
console.log(`
|
|
3134
|
+
${color5.green("Imported.")} It is an upsert, so running it again costs one pass and changes nothing.
|
|
3135
|
+
`);
|
|
3136
|
+
return 0;
|
|
3137
|
+
}
|
|
3138
|
+
async function importHelp() {
|
|
3139
|
+
const width = 80;
|
|
3140
|
+
console.log(box("vc import", [
|
|
3141
|
+
line("Push data/ and content/ into the running shop, as the shop itself.", width),
|
|
3142
|
+
line("", width),
|
|
3143
|
+
...row("SYSTEM_API_KEY=… vc import", "to the shop's own domain", width, 2),
|
|
3144
|
+
...row("… vc import --local", "to http://127.0.0.1:5173, for a dev server", width, 2),
|
|
3145
|
+
...row("… vc import --url <url>", "somewhere else; APP_URL does the same", width, 2),
|
|
3146
|
+
...row("vc import --dry-run", "count what would be pushed, push nothing", width, 2),
|
|
3147
|
+
...row("… vc import --deactivate-missing", "also switch off products absent from the file — never the default, because a partial file would empty the shop", width, 2),
|
|
3148
|
+
line("", width),
|
|
3149
|
+
line("An upsert, so it is safe on every deploy and costs one pass when nothing", width),
|
|
3150
|
+
line("changed. It authenticates as the system identity, so nobody signs in.", width)
|
|
3151
|
+
], width));
|
|
3152
|
+
return 0;
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
export { renderAuthTs, allEnvKeys, renderEnvTs, renderEnvExample, renderEnvLocal, renderEnvProduction, envSummary, FRONTEND_BRANCH, renderFrontendApiTs, renderFrontendEnvProduction, renderStorefrontPage, renderFrontendWorkflow, runVoid, runInherit, captureVoid, isVoidApp, voidAppsIn, STRICT_APP, VOID_VERSION, strictDependencies, renderVoidPatch, renderViteConfig, renderStrictTsconfig, renderVoidJson, renderDbSchema, renderDbSeed, renderLayoutTsx, renderAppCss, renderIndexServer, INDEX_PAGE, renderDashboardPage, CLIENT_ONLY, renderSignInPage, renderAuthClient, renderContentTs, renderCron, renderQueue, renderLiveStream, renderLiveRoute, renderStrictAppPackageJson, DATA_README, BRANDING_README, MIGRATIONS_README, finishStrict, line, row, box, fullHelp, initHelp, version, findProject, ensureGenerated, DIST_DIR, DIST_BRANCH, distCommand, distHelp, renderDistWorkflow, renderDeployReadme, renderRequirementsTs, renderDomainTs, generate, parseJsonc, upsertJsonc, findWrangler, parseWhoAmI, productionEnv, routeProblem, preflight, printPreflight, deployCloudflare, importCommand, importHelp };
|