@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,252 @@
|
|
|
1
|
+
import { type Layout, type Manifest, has } from "../manifest";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The small modules `auth.ts` imports: the domain, the payment rail, the
|
|
5
|
+
* notification bridge, and the ERP when one was chosen.
|
|
6
|
+
*
|
|
7
|
+
* Each is written once and then OWNED by the app — they are the files a shop
|
|
8
|
+
* edits when it grows past the wizard, so they are not regenerated. `auth.ts`
|
|
9
|
+
* is the only file with a marked region.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function renderDomainTs(layout: Layout): string {
|
|
13
|
+
const derive =
|
|
14
|
+
layout === "app"
|
|
15
|
+
? ` // One app: the worker IS the site. No separate API host, and no other
|
|
16
|
+
// origin to trust. It answers on www too, but only at a zone's apex —
|
|
17
|
+
// www.shop.example.com is not a name anybody types.
|
|
18
|
+
const hosts = apex ? [host, www] : [host];
|
|
19
|
+
return { host, zone, apex, api: host, www, hosts, appUrl: \`https://\${host}\`, frontendOrigins: [] };`
|
|
20
|
+
: ` // The worker is the API; the storefront is elsewhere and must be trusted.
|
|
21
|
+
const api = \`api.\${host}\`;
|
|
22
|
+
const frontendOrigins = apex ? [\`https://\${host}\`, \`https://\${www}\`] : [\`https://\${host}\`];
|
|
23
|
+
return { host, zone, apex, api, www, hosts: [api], appUrl: \`https://\${api}\`, frontendOrigins };`;
|
|
24
|
+
return `/**
|
|
25
|
+
* The shop's domain, and everything derived from it.
|
|
26
|
+
*
|
|
27
|
+
* One variable. \`SHOP_DOMAIN\` gives ${layout === "app" ? "the worker's hostnames" : "api.<domain> for the worker and the\n * storefront's origins"}, the app's public origin, and the
|
|
28
|
+
* CORS and CSRF allow-list. They were four settings that had to agree and
|
|
29
|
+
* nothing checked that they did.
|
|
30
|
+
*
|
|
31
|
+
* The domain need not be a registrable apex: \`tshirt.saastemly.com\` is a
|
|
32
|
+
* shop inside the zone \`saastemly.com\`, which is where its DNS records go.
|
|
33
|
+
* \`SHOP_ZONE\` names that zone; unset, the domain is its own.
|
|
34
|
+
*/
|
|
35
|
+
export interface ShopDomain {
|
|
36
|
+
/** The shop's public hostname. */
|
|
37
|
+
host: string;
|
|
38
|
+
/** The DNS zone that contains it — where records are written. */
|
|
39
|
+
zone: string;
|
|
40
|
+
/** Is the host the zone's apex? Only then is there a www. */
|
|
41
|
+
apex: boolean;
|
|
42
|
+
/** Where the worker answers. */
|
|
43
|
+
api: string;
|
|
44
|
+
www: string;
|
|
45
|
+
/** Every hostname the worker should answer on. */
|
|
46
|
+
hosts: string[];
|
|
47
|
+
appUrl: string;
|
|
48
|
+
frontendOrigins: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const clean = (value: string): string =>
|
|
52
|
+
value.trim().replace(/^https?:\\/\\//, "").replace(/\\/.*$/, "").replace(/\\.$/, "").toLowerCase();
|
|
53
|
+
|
|
54
|
+
export function shopDomain(input: string, zoneInput?: string): ShopDomain {
|
|
55
|
+
const host = clean(input).replace(/^www\\./, "");
|
|
56
|
+
const zone = zoneInput ? clean(zoneInput) : host.split(".").slice(-2).join(".");
|
|
57
|
+
const apex = host === zone;
|
|
58
|
+
const www = \`www.\${host}\`;
|
|
59
|
+
${derive}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** GitHub Pages' published addresses for an apex. Theirs, not yours. */
|
|
63
|
+
export const GITHUB_PAGES_A = ["185.199.108.153", "185.199.109.153", "185.199.110.153", "185.199.111.153"] as const;
|
|
64
|
+
export const GITHUB_PAGES_AAAA = ["2606:50c0:8000::153", "2606:50c0:8001::153", "2606:50c0:8002::153", "2606:50c0:8003::153"] as const;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The storefront's records. DNS-only, never proxied: GitHub issues the
|
|
68
|
+
* certificate itself and validates by reaching the origin. The API hostname
|
|
69
|
+
* is NOT here — it is a Cloudflare custom domain in wrangler.jsonc.${
|
|
70
|
+
layout === "app" ? "\n * One app has no storefront elsewhere, so this is always empty." : ""
|
|
71
|
+
}
|
|
72
|
+
*/
|
|
73
|
+
export function storefrontRecords(domain: ShopDomain, pagesHost: string) {
|
|
74
|
+
if (!pagesHost${layout === "app" ? " || domain.frontendOrigins.length === 0" : ""}) return [];
|
|
75
|
+
// The record's name is relative to the zone: "@" at the apex, otherwise the
|
|
76
|
+
// labels in front of it.
|
|
77
|
+
const name = domain.apex ? "@" : domain.host.slice(0, -(domain.zone.length + 1));
|
|
78
|
+
return [
|
|
79
|
+
...GITHUB_PAGES_A.map((value, i) => ({ key: \`pages-a-\${i}\`, zone: domain.zone, name, type: "A" as const, value, proxied: false })),
|
|
80
|
+
...GITHUB_PAGES_AAAA.map((value, i) => ({ key: \`pages-aaaa-\${i}\`, zone: domain.zone, name, type: "AAAA" as const, value, proxied: false })),
|
|
81
|
+
// www only exists at an apex; a shop at shop.example.com has no www.
|
|
82
|
+
...(domain.apex ? [{ key: "pages-www", zone: domain.zone, name: "www", type: "CNAME" as const, value: pagesHost, proxied: false }] : []),
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function renderPaymentTs(manifest: Manifest): string {
|
|
89
|
+
const currency = manifest.shop.currency.toUpperCase();
|
|
90
|
+
if (has(manifest, "adyen")) {
|
|
91
|
+
return `import { adyenProvider } from "@saastemly/better-commerce/providers/adyen";
|
|
92
|
+
import { memoryProvider } from "@saastemly/better-commerce/providers/memory";
|
|
93
|
+
import { env } from "void/env";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Adyen. One rail, no fallback to choose between. Locally the memory provider
|
|
97
|
+
* stands in when the credentials read as \`unset\` — preflight refuses that
|
|
98
|
+
* state in production.
|
|
99
|
+
*/
|
|
100
|
+
const read = (key: string): string => {
|
|
101
|
+
try {
|
|
102
|
+
const value = (env as Record<string, unknown>)[key];
|
|
103
|
+
return typeof value === "string" && value !== "unset" ? value : "";
|
|
104
|
+
} catch {
|
|
105
|
+
return "";
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const adyen = { apiKey: read("ADYEN_API_KEY"), merchantAccount: read("ADYEN_MERCHANT_ACCOUNT"), hmacKey: read("ADYEN_HMAC_KEY") };
|
|
110
|
+
const configured = Boolean(adyen.apiKey && adyen.merchantAccount && adyen.hmacKey);
|
|
111
|
+
|
|
112
|
+
export const devPaymentProvider = configured ? null : memoryProvider({ webhookSecret: read("COMMERCE_WEBHOOK_SECRET") || "whsec_dev_secret" });
|
|
113
|
+
|
|
114
|
+
export const paymentProvider =
|
|
115
|
+
devPaymentProvider ??
|
|
116
|
+
adyenProvider({
|
|
117
|
+
...adyen,
|
|
118
|
+
environment: read("ADYEN_ENVIRONMENT") === "live" ? "live" : "test",
|
|
119
|
+
...(read("ADYEN_LIVE_PREFIX") ? { livePrefix: read("ADYEN_LIVE_PREFIX") } : {}),
|
|
120
|
+
refundCurrency: "${currency}",
|
|
121
|
+
});
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
124
|
+
return `import { memoryProvider } from "@saastemly/better-commerce/providers/memory";
|
|
125
|
+
import { stripeProvider } from "@saastemly/better-commerce/providers/stripe";
|
|
126
|
+
import { env } from "void/env";
|
|
127
|
+
|
|
128
|
+
/** Stripe. Locally the memory provider stands in when the key reads as \`unset\`. */
|
|
129
|
+
const read = (key: string): string => {
|
|
130
|
+
try {
|
|
131
|
+
const value = (env as Record<string, unknown>)[key];
|
|
132
|
+
return typeof value === "string" && value !== "unset" ? value : "";
|
|
133
|
+
} catch {
|
|
134
|
+
return "";
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const secretKey = read("STRIPE_SECRET_KEY");
|
|
139
|
+
const webhookSecret = read("COMMERCE_WEBHOOK_SECRET") || "whsec_dev_secret";
|
|
140
|
+
|
|
141
|
+
export const devPaymentProvider = secretKey ? null : memoryProvider({ webhookSecret });
|
|
142
|
+
export const paymentProvider = devPaymentProvider ?? stripeProvider({ secretKey, webhookSecret });
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function renderNotificationsTs(): string {
|
|
147
|
+
return `import type { NotificationProvider } from "@saastemly/better-commerce/providers/notification";
|
|
148
|
+
import type { Emailer } from "@saastemly/better-email";
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* betterCommerce's notifications through the ONE emailer, so an order
|
|
152
|
+
* confirmation goes out with the same sender and lands in the same log as a
|
|
153
|
+
* sign-in code.
|
|
154
|
+
*/
|
|
155
|
+
export const emailNotifications = (emailer: Emailer): NotificationProvider => ({
|
|
156
|
+
id: "email",
|
|
157
|
+
channels: ["email"],
|
|
158
|
+
transport: emailer.transport.transport === "none" ? "none" : "live",
|
|
159
|
+
async send(message) {
|
|
160
|
+
if (message.channel !== "email") return { id: "", status: "failed", error: "email only" };
|
|
161
|
+
// \`data\` is the rendered content: subject, text, and html when a template made one.
|
|
162
|
+
const result = await emailer.send({
|
|
163
|
+
to: message.to,
|
|
164
|
+
subject: message.data.subject,
|
|
165
|
+
text: message.data.text,
|
|
166
|
+
...(message.data.html ? { html: message.data.html } : {}),
|
|
167
|
+
kind: \`notification:\${message.template}\`,
|
|
168
|
+
});
|
|
169
|
+
return { id: result.id, status: result.status, ...(result.error ? { error: result.error } : {}) };
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Which chain, and who mints. Written once, then yours.
|
|
177
|
+
*
|
|
178
|
+
* The default mints NOTHING and says so: a shop that has not chosen a chain
|
|
179
|
+
* has not chosen one, and a fulfilment row claiming a token exists when it
|
|
180
|
+
* does not is a lie with a customer attached. Swap `recordedMint()` for a
|
|
181
|
+
* real provider when you have decided, and every past order keeps working
|
|
182
|
+
* because the choice lives on the line, not in the catalogue.
|
|
183
|
+
*/
|
|
184
|
+
export function renderMintTs(): string {
|
|
185
|
+
return `import { type MintProvider, recordedMint } from "@saastemly/better-commerce/plugins/nft";
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Where a token comes from.
|
|
189
|
+
*
|
|
190
|
+
* \`recordedMint()\` is the honest default: the order is taken, the buyer's
|
|
191
|
+
* destination is recorded, the fulfilment sits \`pending\`, and nobody is told
|
|
192
|
+
* a token exists. Replace it when you have chosen a chain and a minter.
|
|
193
|
+
*
|
|
194
|
+
* OpenSea does not mint. Its API has an endpoint called \`mint\`, and what it
|
|
195
|
+
* returns is unsigned transaction data for somebody else to sign — so the
|
|
196
|
+
* only real question is who holds a key. Three shapes fit:
|
|
197
|
+
*
|
|
198
|
+
* custodial API one authenticated call per mint, no key here. Crossmint
|
|
199
|
+
* takes an EMAIL as the recipient, so a buyer who has never
|
|
200
|
+
* held a wallet still gets something:
|
|
201
|
+
*
|
|
202
|
+
* import { crossmintMint } from "@saastemly/better-commerce/providers/crossmint";
|
|
203
|
+
* export const mintProvider = crossmintMint({
|
|
204
|
+
* apiKey: env.CROSSMINT_API_KEY, chain: "base",
|
|
205
|
+
* });
|
|
206
|
+
*
|
|
207
|
+
* own contract you hold a key and sign in the worker. It lives as a
|
|
208
|
+
* secret, which is a real thing to weigh: a leaked key is
|
|
209
|
+
* not a leaked password, it is the collection. Cheapest per
|
|
210
|
+
* mint, and yours to keep.
|
|
211
|
+
* signed voucher you sign an offer and the buyer redeems it, paying the
|
|
212
|
+
* gas. Costs nothing per mint and needs no key at runtime,
|
|
213
|
+
* but the buyer needs a wallet AND has to pay — which is
|
|
214
|
+
* most of the audience for a "take a useless token" offer.
|
|
215
|
+
*
|
|
216
|
+
* A collection appears on OpenSea by itself once a standard contract mints on
|
|
217
|
+
* a chain OpenSea indexes; none of these needs an OpenSea account.
|
|
218
|
+
*
|
|
219
|
+
* Whichever it is, \`mint()\` must RETURN a failure rather than throw: it runs
|
|
220
|
+
* after payment, and an exception there strands a paid order.
|
|
221
|
+
*/
|
|
222
|
+
export const mintProvider: MintProvider = recordedMint();
|
|
223
|
+
`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function renderErpTs(): string {
|
|
227
|
+
return `import { businessCentralProvider } from "@saastemly/better-commerce/providers/business-central";
|
|
228
|
+
import type { FulfillmentProvider } from "@saastemly/better-commerce/plugins/fulfillments";
|
|
229
|
+
import { env } from "void/env";
|
|
230
|
+
|
|
231
|
+
/** A paid order becomes a Business Central sales order. Inert until all four credentials are real. */
|
|
232
|
+
const read = (key: string): string => {
|
|
233
|
+
try {
|
|
234
|
+
const value = (env as Record<string, unknown>)[key];
|
|
235
|
+
return typeof value === "string" && value !== "unset" ? value : "";
|
|
236
|
+
} catch {
|
|
237
|
+
return "";
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
export const erpFulfillment = (currency: string): FulfillmentProvider =>
|
|
242
|
+
businessCentralProvider({
|
|
243
|
+
tenantId: read("BC_TENANT_ID"),
|
|
244
|
+
clientId: read("BC_CLIENT_ID"),
|
|
245
|
+
clientSecret: read("BC_CLIENT_SECRET"),
|
|
246
|
+
companyId: read("BC_COMPANY_ID"),
|
|
247
|
+
environment: read("BC_ENVIRONMENT") || "production",
|
|
248
|
+
// BC prices are in the company's local currency.
|
|
249
|
+
currency,
|
|
250
|
+
}).fulfillment();
|
|
251
|
+
`;
|
|
252
|
+
}
|
package/src/help.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import color from "picocolors";
|
|
2
|
+
import pkg from "../package.json" with { type: "json" };
|
|
3
|
+
import { captureVoid } from "./void";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Help that extends void's rather than replacing it.
|
|
7
|
+
*
|
|
8
|
+
* `vc --help` is void's help with a `shop` group merged into its Commands
|
|
9
|
+
* box. `vc init --help` is void's init help with a second box under it. Only
|
|
10
|
+
* when void cannot be asked does vc print its own text alone — and says why.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** What vc adds. One row per extended command; the merge reads this. */
|
|
14
|
+
export const EXTENDED_ROWS: Array<[command: string, summary: string]> = [
|
|
15
|
+
["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"],
|
|
16
|
+
["vc generate", "the files from voidcommerce.json without the form — in strict, the whole app under .vc/app"],
|
|
17
|
+
["vc dev | build | preview", "the app's own script, run where the app is — api/ for a monorepo, .vc/app for strict"],
|
|
18
|
+
["vc dist", "the deployable app as a self-contained tree — what the void-dist branch carries"],
|
|
19
|
+
["vc import", "push data/ and content/ into the running shop, as the shop itself — an upsert, safe on every deploy"],
|
|
20
|
+
["vc preflight", "is the shop ready to advertise on? every required key, and what breaks without it"],
|
|
21
|
+
["vc deploy", "preflight, then void deploy; or --cloudflare: wrangler, your own account, no Void login"],
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const ANSI = /\x1b\[[0-9;]*[a-zA-Z]/g;
|
|
25
|
+
const strip = (line: string) => line.replace(ANSI, "");
|
|
26
|
+
const visible = (line: string) => strip(line).length;
|
|
27
|
+
/** The text between a box line's borders, trailing space removed. */
|
|
28
|
+
const inner = (line: string) => strip(line).replace(/^│/, "").replace(/│$/, "").trimEnd();
|
|
29
|
+
|
|
30
|
+
const COMMAND_WIDTH = 34;
|
|
31
|
+
|
|
32
|
+
function wrap(text: string, width: number): string[] {
|
|
33
|
+
const lines: string[] = [];
|
|
34
|
+
let current = "";
|
|
35
|
+
for (const raw of text.split(/\s+/).filter(Boolean)) {
|
|
36
|
+
// A word wider than the column is split, so no line ever crosses the border.
|
|
37
|
+
const pieces = raw.match(new RegExp(`.{1,${width}}`, "g")) ?? [raw];
|
|
38
|
+
for (const word of pieces) {
|
|
39
|
+
if (current && current.length + 1 + word.length > width) {
|
|
40
|
+
lines.push(current);
|
|
41
|
+
current = word;
|
|
42
|
+
} else {
|
|
43
|
+
current = current ? `${current} ${word}` : word;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (current) lines.push(current);
|
|
48
|
+
return lines;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One box line: `│ text…│`, padded to the box's width. */
|
|
52
|
+
export function line(text: string, width: number): string {
|
|
53
|
+
return `│${` ${text}`.padEnd(width - 2)}│`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A command and its description in void's two-column layout, wrapped at the
|
|
58
|
+
* description column. A box too narrow for two columns, or a command too long
|
|
59
|
+
* for its column, stacks the description beneath the command instead.
|
|
60
|
+
*/
|
|
61
|
+
export function row(command: string, description: string, width: number, indent = 0): string[] {
|
|
62
|
+
const pad = " ".repeat(indent);
|
|
63
|
+
const usable = width - 4 - indent;
|
|
64
|
+
const twoColumn = command.length + indent < COMMAND_WIDTH && usable - COMMAND_WIDTH >= 20;
|
|
65
|
+
// One space of margin before the border, as void leaves.
|
|
66
|
+
if (twoColumn) {
|
|
67
|
+
const parts = wrap(description, usable - COMMAND_WIDTH - 1);
|
|
68
|
+
if (parts.length === 0) parts.push("");
|
|
69
|
+
return parts.map((part, i) => line(pad + (i === 0 ? command : "").padEnd(COMMAND_WIDTH) + part, width));
|
|
70
|
+
}
|
|
71
|
+
const parts = wrap(description, Math.max(10, usable - 5));
|
|
72
|
+
return [line(pad + command, width), ...parts.map((part) => line(`${pad} ${part}`, width))];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A clack-style box like void's own: `◇ title ───╮`, lines, `├───╯`. */
|
|
76
|
+
export function box(title: string, body: string[], width: number): string {
|
|
77
|
+
const head = `◇ ${title} `;
|
|
78
|
+
return [
|
|
79
|
+
"│",
|
|
80
|
+
`${head}${"─".repeat(Math.max(0, width - head.length - 1))}╮`,
|
|
81
|
+
line("", width),
|
|
82
|
+
...body,
|
|
83
|
+
line("", width),
|
|
84
|
+
`├${"─".repeat(width - 2)}╯`,
|
|
85
|
+
].join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Void's help with vc's rows merged into the Commands box, as a `shop` group
|
|
90
|
+
* ahead of void's `help` group. Null when the output is not the box we know —
|
|
91
|
+
* then the caller prints void's help untouched and vc's after it.
|
|
92
|
+
*/
|
|
93
|
+
export function mergeHelp(voidHelp: string): string | null {
|
|
94
|
+
const lines = voidHelp.split("\n");
|
|
95
|
+
const start = lines.findIndex((l) => strip(l).startsWith("◇ Commands"));
|
|
96
|
+
if (start < 0) return null;
|
|
97
|
+
const end = lines.findIndex((l, i) => i > start && strip(l).startsWith("├"));
|
|
98
|
+
if (end < 0) return null;
|
|
99
|
+
const width = visible(lines[start]!);
|
|
100
|
+
|
|
101
|
+
let at = lines.findIndex((l, i) => i > start && i < end && inner(l) === " help");
|
|
102
|
+
if (at < 0) at = end;
|
|
103
|
+
|
|
104
|
+
const group = [
|
|
105
|
+
line(color.bold("shop"), width + (color.bold("shop").length - "shop".length)),
|
|
106
|
+
...EXTENDED_ROWS.flatMap(([command, summary]) => row(command, summary, width)),
|
|
107
|
+
line("", width),
|
|
108
|
+
];
|
|
109
|
+
return [...lines.slice(0, at), ...group, ...lines.slice(at)].join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** What vc prints when void cannot be asked. */
|
|
113
|
+
export function standaloneHelp(reason: string): string {
|
|
114
|
+
return [
|
|
115
|
+
"",
|
|
116
|
+
`${color.bold("vc")} — Void, with a shop in it. ${color.dim(reason)}`,
|
|
117
|
+
"",
|
|
118
|
+
...EXTENDED_ROWS.map(([command, summary]) => ` ${color.cyan(command.padEnd(12))} ${summary}`),
|
|
119
|
+
` ${color.dim("vc <anything>")} passes through to void: dev, build, deploy, db, gen …`,
|
|
120
|
+
"",
|
|
121
|
+
].join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function fullHelp(): Promise<number> {
|
|
125
|
+
const captured = await captureVoid(["--help"]);
|
|
126
|
+
if (!captured) {
|
|
127
|
+
console.log(standaloneHelp("void is not installed here: bun add -d void"));
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
const merged = mergeHelp(captured.out);
|
|
131
|
+
if (merged) {
|
|
132
|
+
process.stdout.write(merged);
|
|
133
|
+
} else {
|
|
134
|
+
process.stdout.write(captured.out);
|
|
135
|
+
console.log(standaloneHelp("and, from vc:"));
|
|
136
|
+
}
|
|
137
|
+
return captured.code;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** `vc init --help`: void's init help, then what vc adds, in the same style. */
|
|
141
|
+
export async function initHelp(): Promise<number> {
|
|
142
|
+
const captured = await captureVoid(["init", "--help"]);
|
|
143
|
+
const width = captured ? Math.max(40, ...captured.out.split("\n").map(visible)) : 80;
|
|
144
|
+
const ours = box("vc init", [
|
|
145
|
+
line("Everything void init does, then a form for the shop.", width),
|
|
146
|
+
line("", width),
|
|
147
|
+
line(color.bold("Usage"), width),
|
|
148
|
+
...row("vc init", "asks the layout, runs void init where it is needed, then the form", width, 2),
|
|
149
|
+
...row("vc init --layout app", "one Void app: API and the generated storefront and panel, on Workers", width, 2),
|
|
150
|
+
...row("vc init --layout monorepo", "api/ on Workers with the panel, frontend/ on GitHub Pages", width, 2),
|
|
151
|
+
...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),
|
|
152
|
+
...row("vc init --agents | --tsconfig | --github", "void's partial modes, handed to void unchanged", width, 2),
|
|
153
|
+
line("", width),
|
|
154
|
+
line(color.bold("Writes"), width),
|
|
155
|
+
...row("voidcommerce.json", "the answers; run vc init again to change them", width, 2),
|
|
156
|
+
...row("auth.ts, env.ts, .env.example", "regenerated from the answers each time", width, 2),
|
|
157
|
+
...row(".env.production, lib/deploy/", "", width, 2),
|
|
158
|
+
...row(".env, lib/domain.ts, lib/payment.ts", "written once, then yours — never overwritten", width, 2),
|
|
159
|
+
...row("lib/notifications.ts, lib/erp.ts", "", width, 2),
|
|
160
|
+
], width);
|
|
161
|
+
// void's box may or may not end in a newline; ours starts on its own line either way.
|
|
162
|
+
if (captured) process.stdout.write(captured.out.replace(/\n*$/, "\n"));
|
|
163
|
+
console.log(ours);
|
|
164
|
+
return captured?.code ?? 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function version(): Promise<number> {
|
|
168
|
+
const captured = await captureVoid(["--version"]);
|
|
169
|
+
console.log(`vc ${pkg.version}`);
|
|
170
|
+
console.log(captured ? `void ${captured.out.trim()}` : "void not installed here");
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
package/src/import.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import color from "picocolors";
|
|
5
|
+
import { box, line, row } from "./help";
|
|
6
|
+
import { has } from "./manifest";
|
|
7
|
+
import { type Project, findProject } from "./project";
|
|
8
|
+
import { productionEnv } from "./deploy/preflight";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `vc import` — push what is in the repository into the running shop.
|
|
12
|
+
*
|
|
13
|
+
* ── Why the catalogue is not a migration ─────────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* The obvious place for products is a seed or a data migration, and it is
|
|
16
|
+
* the wrong place. A catalogue is not schema: it changes on a different
|
|
17
|
+
* clock, it is edited by people who do not deploy, and putting it in
|
|
18
|
+
* `db/migrations/` makes every future deploy re-verify megabytes of SQL that
|
|
19
|
+
* has nothing to do with the schema.
|
|
20
|
+
*
|
|
21
|
+
* So it is data in the repository and an UPSERT over HTTP. The same
|
|
22
|
+
* operation runs in development and in production, which is the actual
|
|
23
|
+
* argument: a seed script that only ever ran on a developer's machine is a
|
|
24
|
+
* second, weaker copy of the real thing, and it is always the copy that has
|
|
25
|
+
* the bug.
|
|
26
|
+
*
|
|
27
|
+
* ── Who it authenticates as ──────────────────────────────────────────────
|
|
28
|
+
*
|
|
29
|
+
* The deployment, not a person. `SYSTEM_API_KEY` is the system identity's
|
|
30
|
+
* key, so filling a shop needs nobody to sign in anywhere — which is what
|
|
31
|
+
* makes "deploy means live" true rather than aspirational.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** What the catalogue push answers with — a report, nested by what was written. */
|
|
35
|
+
interface Counts {
|
|
36
|
+
created?: number;
|
|
37
|
+
updated?: number;
|
|
38
|
+
unchanged?: number;
|
|
39
|
+
written?: number;
|
|
40
|
+
}
|
|
41
|
+
interface Pushed {
|
|
42
|
+
report?: { categories?: Counts; products?: Counts; prices?: Counts; addons?: Counts };
|
|
43
|
+
/** The content imports answer flatter than the catalogue does. */
|
|
44
|
+
created?: number;
|
|
45
|
+
updated?: number;
|
|
46
|
+
error?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** "3 created, 1 updated" — and nothing at all when nothing happened. */
|
|
50
|
+
function describe(counts: Counts | undefined): string {
|
|
51
|
+
if (!counts) return "";
|
|
52
|
+
const parts = [
|
|
53
|
+
counts.created ? `${counts.created} created` : "",
|
|
54
|
+
counts.updated ? `${counts.updated} updated` : "",
|
|
55
|
+
counts.written ? `${counts.written} written` : "",
|
|
56
|
+
counts.unchanged ? `${counts.unchanged} unchanged` : "",
|
|
57
|
+
].filter(Boolean);
|
|
58
|
+
return parts.join(", ") || "nothing to do";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Where the shop is, in order of what a person most likely means. */
|
|
62
|
+
function resolveTarget(project: Project, args: string[]): { url: string; how: string } {
|
|
63
|
+
const flag = args.indexOf("--url");
|
|
64
|
+
if (flag >= 0 && args[flag + 1]) return { url: args[flag + 1]!.replace(/\/$/, ""), how: "--url" };
|
|
65
|
+
if (process.env["APP_URL"]) return { url: process.env["APP_URL"].replace(/\/$/, ""), how: "APP_URL" };
|
|
66
|
+
if (args.includes("--local")) return { url: "http://127.0.0.1:5173", how: "--local" };
|
|
67
|
+
|
|
68
|
+
// .env.production carries the domain as a committed plaintext value.
|
|
69
|
+
const domain = productionEnv(project.appDir).get("SHOP_DOMAIN") ?? project.manifest.shop.domain;
|
|
70
|
+
const host = project.manifest.layout === "app" ? domain : `api.${domain}`;
|
|
71
|
+
return { url: `https://${host}`, how: "the shop's own domain" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function post(url: string, key: string, body: unknown): Promise<{ ok: boolean; status: number; body: Pushed & { error?: string } }> {
|
|
75
|
+
let response: Response;
|
|
76
|
+
try {
|
|
77
|
+
response = await fetch(url, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { "content-type": "application/json", "x-system-key": key },
|
|
80
|
+
body: JSON.stringify(body),
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return { ok: false, status: 0, body: { error: error instanceof Error ? error.message : String(error) } };
|
|
84
|
+
}
|
|
85
|
+
const parsed = (await response.json().catch(() => ({}))) as Pushed & { message?: string };
|
|
86
|
+
return { ok: response.ok, status: response.status, body: { ...parsed, error: parsed.error ?? parsed.message } };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Read a JSON file, or nothing if there is none. */
|
|
90
|
+
async function readJson<T>(path: string): Promise<T | null> {
|
|
91
|
+
if (!existsSync(path)) return null;
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(await readFile(path, "utf8")) as T;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
throw new Error(`${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function importCommand(args: string[]): Promise<number> {
|
|
100
|
+
const project = await findProject();
|
|
101
|
+
if (!project) {
|
|
102
|
+
console.error("vc: no voidcommerce.json here.");
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
const dry = args.includes("--dry-run");
|
|
106
|
+
const key = process.env["SYSTEM_API_KEY"];
|
|
107
|
+
if (!key && !dry) {
|
|
108
|
+
console.error(
|
|
109
|
+
"vc: SYSTEM_API_KEY is not in this shell.\n\n" +
|
|
110
|
+
" It is the deployment's own identity, so the import authenticates as the\n" +
|
|
111
|
+
" shop rather than as you. It is a worker secret; export it here:\n\n" +
|
|
112
|
+
" SYSTEM_API_KEY=… vc import\n",
|
|
113
|
+
);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const { url, how } = resolveTarget(project, args);
|
|
118
|
+
if (!dry) console.log(`${color.dim("→")} ${url} ${color.dim(`(${how})`)}\n`);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Who does the shop think we are? Asked once, up front: a wrong key is
|
|
122
|
+
* otherwise a 401 on every push, which is four confusing failures instead
|
|
123
|
+
* of one clear one. Skipped entirely for a dry run, which reads files and
|
|
124
|
+
* needs no shop to be running.
|
|
125
|
+
*/
|
|
126
|
+
if (!dry) {
|
|
127
|
+
let whoami: Response;
|
|
128
|
+
try {
|
|
129
|
+
whoami = await fetch(`${url}/api/auth/system/whoami`, { headers: { "x-system-key": key! } });
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error(
|
|
132
|
+
`${color.red("✗")} could not reach ${url}: ${error instanceof Error ? error.message : String(error)}\n` +
|
|
133
|
+
` Is the shop running? \`vc dev\` serves it locally; --local points here.\n`,
|
|
134
|
+
);
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
if (!whoami.ok) {
|
|
138
|
+
console.error(
|
|
139
|
+
`${color.red("✗")} the shop refused the system key (HTTP ${whoami.status}).\n` +
|
|
140
|
+
` Is SYSTEM_API_KEY the one this deployment was given?\n`,
|
|
141
|
+
);
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
console.log(`${color.green("✓")} authenticated as the system identity`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const root = project.root;
|
|
148
|
+
const products = await readJson<unknown[]>(join(root, "data", "catalog.json"));
|
|
149
|
+
const categories = await readJson<unknown[]>(join(root, "data", "categories.json"));
|
|
150
|
+
const faqs = has(project.manifest, "faqs") ? await readJson<unknown[]>(join(root, "content", "faqs.json")) : null;
|
|
151
|
+
const posts = has(project.manifest, "blogs") ? await readJson<unknown[]>(join(root, "content", "posts.json")) : null;
|
|
152
|
+
|
|
153
|
+
if (!products && !faqs?.length && !posts?.length) {
|
|
154
|
+
console.log(
|
|
155
|
+
`\n${color.yellow("Nothing to import.")} data/catalog.json is absent and the content files are empty.\n` +
|
|
156
|
+
`Products go in data/catalog.json as a JSON array; \`vc import --dry-run\` checks it without pushing.\n`,
|
|
157
|
+
);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (dry) {
|
|
162
|
+
console.log(`\n${color.dim("--dry-run: nothing was pushed.")}`);
|
|
163
|
+
console.log(` ${products?.length ?? 0} products, ${categories?.length ?? 0} categories`);
|
|
164
|
+
console.log(` ${faqs?.length ?? 0} FAQ entries, ${posts?.length ?? 0} posts\n`);
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let failed = false;
|
|
169
|
+
|
|
170
|
+
if (products) {
|
|
171
|
+
/**
|
|
172
|
+
* Categories travel WITH the products in one call, because a product
|
|
173
|
+
* naming a category that does not exist yet is the ordering bug this
|
|
174
|
+
* would otherwise have.
|
|
175
|
+
*/
|
|
176
|
+
const result = await post(`${url}/api/auth/commerce/admin/catalog-import/push`, key!, {
|
|
177
|
+
source: "devprints",
|
|
178
|
+
...(categories ? { categories } : {}),
|
|
179
|
+
products,
|
|
180
|
+
// Never true by default: a partial file would deactivate a shop's
|
|
181
|
+
// entire catalogue, and the file is edited by hand.
|
|
182
|
+
...(args.includes("--deactivate-missing") ? { deactivateMissing: true } : {}),
|
|
183
|
+
});
|
|
184
|
+
if (result.ok) {
|
|
185
|
+
// The push answers with a report per thing it wrote, and reading it
|
|
186
|
+
// at the top level is how this said "0 created" while writing seven.
|
|
187
|
+
const report = result.body.report ?? {};
|
|
188
|
+
console.log(`${color.green("✓")} products: ${describe(report.products)}`);
|
|
189
|
+
if (report.categories) console.log(`${color.green("✓")} categories: ${describe(report.categories)}`);
|
|
190
|
+
if (report.prices) console.log(`${color.green("✓")} prices: ${describe(report.prices)}`);
|
|
191
|
+
if (report.addons) console.log(`${color.green("✓")} addons: ${describe(report.addons)}`);
|
|
192
|
+
} else {
|
|
193
|
+
console.error(`${color.red("✗")} catalogue: ${result.body.error ?? `HTTP ${result.status}`}`);
|
|
194
|
+
failed = true;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const [label, rows, path] of [
|
|
199
|
+
["FAQ", faqs, "/api/auth/faqs/import"],
|
|
200
|
+
["posts", posts, "/api/auth/blog/import"],
|
|
201
|
+
] as const) {
|
|
202
|
+
if (!rows?.length) continue;
|
|
203
|
+
const result = await post(`${url}${path}`, key!, { entries: rows, posts: rows });
|
|
204
|
+
if (result.ok) {
|
|
205
|
+
console.log(`${color.green("✓")} ${label}: ${describe(result.body)}`);
|
|
206
|
+
} else {
|
|
207
|
+
console.error(`${color.red("✗")} ${label}: ${result.body.error ?? `HTTP ${result.status}`}`);
|
|
208
|
+
failed = true;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (failed) {
|
|
213
|
+
console.error(`\n${color.red("Some of it did not land.")} The import is an upsert, so fixing the cause and running it again is safe.\n`);
|
|
214
|
+
return 1;
|
|
215
|
+
}
|
|
216
|
+
console.log(`\n${color.green("Imported.")} It is an upsert, so running it again costs one pass and changes nothing.\n`);
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function importHelp(): Promise<number> {
|
|
221
|
+
const width = 80;
|
|
222
|
+
console.log(
|
|
223
|
+
box("vc import", [
|
|
224
|
+
line("Push data/ and content/ into the running shop, as the shop itself.", width),
|
|
225
|
+
line("", width),
|
|
226
|
+
...row("SYSTEM_API_KEY=… vc import", "to the shop's own domain", width, 2),
|
|
227
|
+
...row("… vc import --local", "to http://127.0.0.1:5173, for a dev server", width, 2),
|
|
228
|
+
...row("… vc import --url <url>", "somewhere else; APP_URL does the same", width, 2),
|
|
229
|
+
...row("vc import --dry-run", "count what would be pushed, push nothing", width, 2),
|
|
230
|
+
...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),
|
|
231
|
+
line("", width),
|
|
232
|
+
line("An upsert, so it is safe on every deploy and costs one pass when nothing", width),
|
|
233
|
+
line("changed. It authenticates as the system identity, so nobody signs in.", width),
|
|
234
|
+
], width),
|
|
235
|
+
);
|
|
236
|
+
return 0;
|
|
237
|
+
}
|