@saastemly/voidcommerce 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +271 -0
  2. package/bin/vc +2 -0
  3. package/dist/catalog.d.ts +69 -0
  4. package/dist/catalog.js +34 -0
  5. package/dist/cli.d.ts +24 -0
  6. package/dist/cli.js +544 -0
  7. package/dist/deploy/cloudflare.d.ts +25 -0
  8. package/dist/deploy/index.d.ts +16 -0
  9. package/dist/deploy/jsonc.d.ts +8 -0
  10. package/dist/deploy/preflight.d.ts +29 -0
  11. package/dist/deploy/wrangler.d.ts +37 -0
  12. package/dist/dist.d.ts +22 -0
  13. package/dist/generate/auth.d.ts +2 -0
  14. package/dist/generate/ci.d.ts +24 -0
  15. package/dist/generate/env.d.ts +13 -0
  16. package/dist/generate/frontend.d.ts +47 -0
  17. package/dist/generate/index.d.ts +28 -0
  18. package/dist/generate/requirements.d.ts +13 -0
  19. package/dist/generate/strict.d.ts +72 -0
  20. package/dist/generate/support.d.ts +23 -0
  21. package/dist/help.d.ts +31 -0
  22. package/dist/import.d.ts +2 -0
  23. package/dist/index-s7sq41qs.js +590 -0
  24. package/dist/index-ssv3a6wc.js +172 -0
  25. package/dist/index-wzy1xtr1.js +3155 -0
  26. package/dist/index.d.ts +24 -0
  27. package/dist/index.js +190 -0
  28. package/dist/init.d.ts +1 -0
  29. package/dist/manifest.d.ts +131 -0
  30. package/dist/manifest.js +41 -0
  31. package/dist/project.d.ts +20 -0
  32. package/dist/regenerate.d.ts +9 -0
  33. package/dist/scripts.d.ts +12 -0
  34. package/dist/void.d.ts +30 -0
  35. package/dist/wizard.d.ts +7 -0
  36. package/package.json +50 -0
  37. package/src/catalog.ts +673 -0
  38. package/src/cli.ts +78 -0
  39. package/src/deploy/cloudflare.ts +166 -0
  40. package/src/deploy/index.ts +101 -0
  41. package/src/deploy/jsonc.ts +148 -0
  42. package/src/deploy/preflight.ts +137 -0
  43. package/src/deploy/wrangler.ts +111 -0
  44. package/src/dist.ts +157 -0
  45. package/src/generate/auth.ts +386 -0
  46. package/src/generate/ci.ts +208 -0
  47. package/src/generate/env.ts +164 -0
  48. package/src/generate/frontend.ts +275 -0
  49. package/src/generate/index.ts +390 -0
  50. package/src/generate/requirements.ts +48 -0
  51. package/src/generate/strict.ts +692 -0
  52. package/src/generate/support.ts +252 -0
  53. package/src/help.ts +172 -0
  54. package/src/import.ts +237 -0
  55. package/src/index.ts +37 -0
  56. package/src/init.ts +187 -0
  57. package/src/manifest.ts +303 -0
  58. package/src/project.ts +63 -0
  59. package/src/regenerate.ts +51 -0
  60. package/src/scripts.ts +53 -0
  61. package/src/void.ts +115 -0
  62. package/src/wizard.ts +234 -0
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * voidcommerce — the library half of `vc`.
3
+ *
4
+ * The CLI is `bin/vc`. This is what a generator, a test, or Void itself one
5
+ * day, imports: the catalogue of choices, the manifest they are recorded in,
6
+ * and the generators that turn a manifest into files.
7
+ */
8
+ export * from "./catalog";
9
+ export * from "./manifest";
10
+ export { generate, type GenerateResult } from "./generate/index";
11
+ export { renderAuthTs } from "./generate/auth";
12
+ export {
13
+ allEnvKeys,
14
+ envSummary,
15
+ renderEnvExample,
16
+ renderEnvLocal,
17
+ renderEnvProduction,
18
+ renderEnvTs,
19
+ } from "./generate/env";
20
+ export { renderRequirementsTs } from "./generate/requirements";
21
+ export { renderDomainTs } from "./generate/support";
22
+ export * from "./generate/strict";
23
+ export { findProject, type Project } from "./project";
24
+ export { parseJsonc, upsertJsonc } from "./deploy/jsonc";
25
+ export { parseWhoAmI, findWrangler } from "./deploy/wrangler";
26
+ export { preflight, printPreflight, productionEnv, routeProblem } from "./deploy/preflight";
27
+ export { deployCloudflare } from "./deploy/cloudflare";
28
+ export {
29
+ FRONTEND_BRANCH,
30
+ renderFrontendApiTs,
31
+ renderFrontendEnvProduction,
32
+ renderFrontendWorkflow,
33
+ renderStorefrontPage,
34
+ } from "./generate/frontend";
35
+ export { distCommand, DIST_BRANCH, DIST_DIR } from "./dist";
36
+ export { renderDistWorkflow, renderDeployReadme } from "./generate/ci";
37
+ export { importCommand } from "./import";
package/src/init.ts ADDED
@@ -0,0 +1,187 @@
1
+ import * as p from "@clack/prompts";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { basename, join } from "node:path";
4
+ import color from "picocolors";
5
+ import { generate } from "./generate/index";
6
+ import { envSummary } from "./generate/env";
7
+ import { finishStrict } from "./generate/strict";
8
+ import { type Layout, LAYOUTS, readManifest, validate } from "./manifest";
9
+ import { isVoidApp, runVoid, voidAppsIn } from "./void";
10
+ import { askLayout, runWizard, summarise } from "./wizard";
11
+
12
+ /**
13
+ * `vc init` extends `void init`; it does not replace it.
14
+ *
15
+ * The layout comes first, because it decides where void's own wizard runs:
16
+ * once here for one app, or once each in api/ and frontend/ for a monorepo.
17
+ * void lays the app(s) down — vite config, pages, db — exactly as it would
18
+ * without vc. Then vc's form runs on top. Where the app exists, only the
19
+ * form runs, pre-filled from voidcommerce.json. Void's partial modes
20
+ * (`--agents`, `--tsconfig`, `--github`) do one thing and are handed to void
21
+ * unchanged.
22
+ */
23
+
24
+ /** vc's own flags, split from whatever is void's. */
25
+ function parseFlags(args: string[]): { layout?: Layout; link?: string; voids: string[]; problem?: string } {
26
+ const voids: string[] = [];
27
+ let layout: Layout | undefined;
28
+ let link: string | undefined;
29
+ for (let i = 0; i < args.length; i++) {
30
+ const arg = args[i]!;
31
+ const linked = arg.startsWith("--link=") ? arg.slice("--link=".length) : arg === "--link" ? args[++i] : undefined;
32
+ if (linked !== undefined) {
33
+ if (!linked) return { voids, problem: "--link needs a directory holding the betterCommerce packages" };
34
+ link = linked;
35
+ continue;
36
+ }
37
+ const value = arg.startsWith("--layout=") ? arg.slice("--layout=".length) : arg === "--layout" ? args[++i] : undefined;
38
+ if (value === undefined) {
39
+ voids.push(arg);
40
+ continue;
41
+ }
42
+ if (!LAYOUTS.some((l) => l.id === value)) {
43
+ return { voids, problem: `--layout must be one of ${LAYOUTS.map((l) => l.id).join(", ")}, not "${value ?? ""}"` };
44
+ }
45
+ layout = value as Layout;
46
+ }
47
+ return { layout, link, voids };
48
+ }
49
+
50
+ export async function init(args: string[]): Promise<number> {
51
+ const flags = parseFlags(args);
52
+ if (flags.problem) {
53
+ console.error(`vc: ${flags.problem}`);
54
+ return 1;
55
+ }
56
+ if (flags.voids.length > 0) return runVoid(["init", ...flags.voids]);
57
+
58
+ let root = process.cwd();
59
+ p.intro(color.bgCyan(color.black(" voidcommerce ")));
60
+
61
+ const existing = await readManifest(root);
62
+ if (existing) {
63
+ p.log.info(
64
+ `Found ${color.cyan("voidcommerce.json")} for ${color.bold(existing.shop.name)} — answers are pre-filled; the layout (${existing.layout}) is fixed.`,
65
+ );
66
+ if (flags.layout && flags.layout !== existing.layout) {
67
+ p.log.error(`This shop is laid out as "${existing.layout}". The layout cannot change after init — moving files is not a regeneration.`);
68
+ return 1;
69
+ }
70
+ }
71
+ const layout = existing?.layout ?? flags.layout ?? (await askLayout());
72
+
73
+ if (layout === "strict") {
74
+ // Nothing to lay down: the app is generated, void init included.
75
+ p.log.step(`Strict: the app will be generated under ${color.cyan(".vc/app")} — nothing there is yours to edit.`);
76
+ } else if (layout === "app") {
77
+ if (!isVoidApp(root)) {
78
+ p.log.step(`No Void app here yet — ${color.cyan("void init")} first, then the shop.`);
79
+ const before = new Set(voidAppsIn(root));
80
+ const code = await runVoid(["init"]);
81
+ if (code !== 0) return code;
82
+ if (!isVoidApp(root)) {
83
+ // void init may have chosen a subfolder. Follow it if it made exactly one.
84
+ const created = voidAppsIn(root).filter((dir) => !before.has(dir));
85
+ if (created.length !== 1) {
86
+ console.error(`vc: void init did not leave a Void app in ${root}, so there is nothing to build a shop on.`);
87
+ return 1;
88
+ }
89
+ root = created[0]!;
90
+ process.chdir(root);
91
+ p.log.step(`Continuing in ${color.cyan(`${basename(root)}/`)}`);
92
+ }
93
+ }
94
+ } else {
95
+ const parts: Array<[dir: string, starter: string]> = [
96
+ ["api", "the D1 starter — this is the worker"],
97
+ ["frontend", "Static Pages — this is prerendered for GitHub Pages"],
98
+ ];
99
+ for (const [dir, starter] of parts) {
100
+ const target = join(root, dir);
101
+ if (isVoidApp(target)) continue;
102
+ await mkdir(target, { recursive: true });
103
+ p.log.step(`${color.cyan("void init")} in ${color.cyan(`${dir}/`)} — choose ${starter}.`);
104
+ const code = await runVoid(["init"], target);
105
+ if (code !== 0) return code;
106
+ if (!isVoidApp(target)) {
107
+ console.error(`vc: void init did not leave a Void app in ${dir}/, so there is nothing to build on.`);
108
+ return 1;
109
+ }
110
+ }
111
+ }
112
+
113
+ const manifest = await runWizard(existing, layout);
114
+ // Until the packages are published, a checkout supplies them.
115
+ const link = flags.link ?? existing?.link;
116
+ if (link) manifest.link = link;
117
+
118
+ const problems = validate(manifest);
119
+ if (problems.length > 0) {
120
+ p.log.error("These answers contradict each other:");
121
+ for (const problem of problems) p.log.message(` ${color.red("✗")} ${problem}`);
122
+ p.cancel("Nothing was written.");
123
+ return 1;
124
+ }
125
+
126
+ p.note(summarise(manifest), `${manifest.shop.name} · ${manifest.shop.domain}`);
127
+
128
+ const go = await p.confirm({ message: "Write the files?", initialValue: true });
129
+ if (p.isCancel(go) || !go) {
130
+ p.cancel("Nothing was written.");
131
+ return 0;
132
+ }
133
+
134
+ const spinner = p.spinner();
135
+ spinner.start("Generating");
136
+ const result = await generate(root, manifest);
137
+ spinner.stop("Generated");
138
+
139
+ const { secrets, plaintext } = envSummary(manifest);
140
+ p.note(
141
+ [
142
+ ...result.written.map((file) => `${color.green("+")} ${file}`),
143
+ ...result.kept.map((file) => `${color.dim("=")} ${file} ${color.dim("(kept — yours)")}`),
144
+ ].join("\n"),
145
+ "Files",
146
+ );
147
+ p.note(
148
+ [
149
+ `${color.bold(String(secrets.length))} secrets ${color.dim("→ wrangler secret put <NAME>")}`,
150
+ ...secrets.map((key) => ` ${key}`),
151
+ "",
152
+ `${color.bold(String(plaintext.length))} plaintext ${color.dim("→ .env.production, already written")}`,
153
+ ...plaintext.map((key) => ` ${key}`),
154
+ ].join("\n"),
155
+ "Before going live",
156
+ );
157
+
158
+ if (layout === "strict") {
159
+ const code = await finishStrict(root);
160
+ if (code !== 0) return code;
161
+ }
162
+
163
+ const next =
164
+ layout === "app"
165
+ ? [
166
+ ` ${color.cyan("bun install")}`,
167
+ ` ${color.cyan("bun run maildev")} ${color.dim("local inbox at http://localhost:1080")}`,
168
+ ` ${color.cyan("vc dev")}`,
169
+ ` ${color.cyan("vc preflight")} ${color.dim("what is still missing, and why it matters")}`,
170
+ ]
171
+ : layout === "strict"
172
+ ? [
173
+ ` ${color.cyan("bun install")}`,
174
+ ` ${color.cyan("vc generate")} ${color.dim("the app under .vc/app, void's artifacts, the migrations")}`,
175
+ ` ${color.cyan("bun run maildev")} ${color.dim("local inbox at http://localhost:1080")}`,
176
+ ` ${color.cyan("vc dev")} ${color.dim("runs in .vc/app")}`,
177
+ ]
178
+ : [
179
+ ` ${color.cyan("bun install")} ${color.dim("one install for both workspaces")}`,
180
+ ` ${color.cyan("bun run maildev")} ${color.dim("local inbox at http://localhost:1080")}`,
181
+ ` ${color.cyan("bun run dev:api")} ${color.dim("the worker, with the panel")}`,
182
+ ` ${color.cyan("bun run dev:frontend")} ${color.dim("the storefront, against the local API")}`,
183
+ ` ${color.cyan("bun run preflight")} ${color.dim("what is still missing, and why it matters")}`,
184
+ ];
185
+ p.outro(["Next:", ...next].join("\n"));
186
+ return 0;
187
+ }
@@ -0,0 +1,303 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { CHOICES, GROUPS } from "./catalog";
4
+
5
+ /**
6
+ * `voidcommerce.json` — what this shop is made of.
7
+ *
8
+ * ── Why a manifest and not just generated files ─────────────────────────
9
+ *
10
+ * A wizard that writes `auth.ts` once and forgets what it asked is a
11
+ * scaffolder: useful on day one, useless on day thirty when you want to add
12
+ * subscriptions and cannot remember which of the forty checkboxes you ticked.
13
+ *
14
+ * The manifest is the answers. Every generated file derives from it, so
15
+ * `vc init` on an existing project means "edit the answers and regenerate",
16
+ * and `vc gen` can read it to know what the shop already has. It is the
17
+ * Rails `config/` — the thing that makes the rest reproducible.
18
+ */
19
+
20
+ export const MANIFEST_FILE = "voidcommerce.json";
21
+
22
+ /**
23
+ * How the shop is laid out — on disk and on the internet.
24
+ *
25
+ * app One Void app: the API and the generated storefront and panel,
26
+ * on Cloudflare Workers at <domain> and www.<domain>.
27
+ * monorepo api/ a Void app on Workers at api.<domain>, with the panel
28
+ * frontend/ a static Void site on GitHub Pages at <domain>
29
+ *
30
+ * strict EXPERIMENTAL. A monorepo turned inside out. The REPOSITORY
31
+ * ROOT is the storefront — its package.json, its pages, yours to
32
+ * edit — and the worker is generated underneath it at .vc/app
33
+ * from the manifest, gitignored, never hand-edited. Hosted the
34
+ * way a monorepo is: the worker on api.<domain>, the storefront
35
+ * on <domain>.
36
+ *
37
+ * The first question, because it decides where `void init` runs and what
38
+ * the domain means. It cannot change after init: moving files is not a
39
+ * regeneration.
40
+ */
41
+ export type Layout = "app" | "monorepo" | "strict";
42
+
43
+ export const LAYOUTS: Array<{ id: Layout; label: string; hint: string }> = [
44
+ {
45
+ id: "app",
46
+ label: "One app",
47
+ hint: "the API and the generated storefront and panel in one Void app on Cloudflare Workers, at your domain",
48
+ },
49
+ {
50
+ id: "monorepo",
51
+ label: "Monorepo: api + frontend",
52
+ hint: "api/ on Workers at api.<domain> with the panel; frontend/ a static site on GitHub Pages at <domain>",
53
+ },
54
+ {
55
+ id: "strict",
56
+ label: "Strict (experimental)",
57
+ hint: "the root IS the storefront, sharing one package.json; the worker is generated under .vc/app from the manifest and never hand-edited",
58
+ },
59
+ ];
60
+
61
+ /**
62
+ * One worker that IS the site, with no storefront anywhere else.
63
+ *
64
+ * Only `app`. A monorepo has `frontend/`; strict has the storefront at the
65
+ * ROOT — what a person edits there is the shop front, and the worker is
66
+ * generated underneath it. Both put the worker on `api.<domain>` and the
67
+ * storefront on the domain itself.
68
+ */
69
+ export const isSingleApp = (layout: Layout): boolean => layout === "app";
70
+
71
+ /** Does this layout carry a storefront of its own, and so a second origin to trust? */
72
+ export const hasFrontend = (layout: Layout): boolean => layout !== "app";
73
+
74
+ /**
75
+ * Public suffixes whose second label is not a registrable name, so
76
+ * `shop.example.co.uk` is a subdomain of `example.co.uk`, not of `co.uk`.
77
+ * A short list on purpose: it only picks the DEFAULT the wizard offers, and
78
+ * the answer is written into the manifest where a person can correct it.
79
+ */
80
+ const TWO_LABEL_SUFFIXES = new Set(["co", "com", "net", "org", "ac", "gov", "edu", "or", "ne", "in"]);
81
+
82
+ /** The zone a hostname most likely belongs to: the registrable domain. */
83
+ export function zoneOf(domain: string): string {
84
+ const labels = domain.trim().toLowerCase().replace(/\.$/, "").split(".");
85
+ if (labels.length <= 2) return labels.join(".");
86
+ const take = TWO_LABEL_SUFFIXES.has(labels[labels.length - 2] ?? "") && (labels[labels.length - 1] ?? "").length <= 3 ? 3 : 2;
87
+ return labels.slice(-take).join(".");
88
+ }
89
+
90
+ /** The zone this shop's records go in. */
91
+ export function zone(manifest: Manifest): string {
92
+ return manifest.shop.zone || zoneOf(manifest.shop.domain);
93
+ }
94
+
95
+ /** Is the shop at the zone's apex? Only then is there a `www` to answer on. */
96
+ export function isApex(manifest: Manifest): boolean {
97
+ return manifest.shop.domain === zone(manifest);
98
+ }
99
+
100
+ /** Every hostname the worker answers on — the wrangler routes, in order. */
101
+ export function workerHosts(manifest: Manifest): string[] {
102
+ const { domain } = manifest.shop;
103
+ if (!isSingleApp(manifest.layout)) return [`api.${domain}`];
104
+ return isApex(manifest) ? [domain, `www.${domain}`] : [domain];
105
+ }
106
+
107
+ /** What a layout adds to the choices, whatever was ticked. */
108
+ const LAYOUT_IMPLIES: Record<Layout, Record<string, string[]>> = {
109
+ app: {},
110
+ // The frontend IS a custom storefront; it needs the kit.
111
+ monorepo: { ui: ["custom"] },
112
+ // The storefront at the root is custom code and needs the kit; the panel
113
+ // is still generated, and still mounted on the worker.
114
+ strict: { ui: ["admin", "custom"] },
115
+ };
116
+
117
+ export interface Manifest {
118
+ /** Schema version, for the day the shape changes. */
119
+ version: 1;
120
+ layout: Layout;
121
+ shop: {
122
+ name: string;
123
+ /** `deki.dk`, or `tshirt.saastemly.com` — the shop's public hostname; everything derives from it. */
124
+ domain: string;
125
+ /**
126
+ * The DNS zone that CONTAINS the domain: `deki.dk` for an apex,
127
+ * `saastemly.com` for `tshirt.saastemly.com`. Records are written here,
128
+ * and a Worker custom domain needs this zone on Cloudflare. Absent
129
+ * means the domain is its own zone.
130
+ */
131
+ zone?: string | undefined;
132
+ /** `DK` — drives the declared VAT rate and the default region. */
133
+ country: string;
134
+ /** `dkk` */
135
+ currency: string;
136
+ /** `da` — copy in emails and launch content. */
137
+ locale: string;
138
+ /**
139
+ * Is the shop registered for VAT (or its local equivalent)?
140
+ *
141
+ * Not a preference. A business below the registration threshold must
142
+ * NOT add tax to a price, and one above it must. Getting this wrong is
143
+ * unlawful in both directions, and it is invisible in a generated
144
+ * `auth.ts` unless something asks — so this asks, and there is no
145
+ * default that is safe to assume.
146
+ */
147
+ taxRegistered?: boolean | undefined;
148
+ /** `saastemly.github.io` — the monorepo's frontend lives on GitHub Pages; the www record points here. */
149
+ pagesHost?: string | undefined;
150
+ };
151
+ /** Chosen ids from each group, keyed by group id. */
152
+ chosen: Record<string, string[]>;
153
+ /**
154
+ * Where the betterCommerce packages come from, when not from npm.
155
+ * `vc init --link ../` writes `file:../better-commerce` and friends, so a
156
+ * checkout of the whole suite installs. Absent means the registry.
157
+ */
158
+ link?: string | undefined;
159
+ /**
160
+ * What `vc deploy --cloudflare --provision` created. Recorded here so a
161
+ * regenerated wrangler.jsonc (strict) carries the real ids.
162
+ */
163
+ cloudflare?: {
164
+ accountId?: string;
165
+ d1?: { name: string; id: string };
166
+ };
167
+ }
168
+
169
+ /** Everything a group's `required` choices and the layout imply, whatever was ticked. */
170
+ export function withRequired(chosen: Record<string, string[]>, layout: Layout = "app"): Record<string, string[]> {
171
+ const out: Record<string, string[]> = {};
172
+ for (const group of GROUPS) {
173
+ const picked = new Set([...(chosen[group.id] ?? []), ...(LAYOUT_IMPLIES[layout][group.id] ?? [])]);
174
+ for (const choice of group.choices) {
175
+ if (choice.required) picked.add(choice.id);
176
+ }
177
+ // A choice that requires another pulls it in, so the generator never
178
+ // sees `username` without `password`.
179
+ for (const id of [...picked]) {
180
+ for (const dependency of CHOICES.get(id)?.requires ?? []) picked.add(dependency);
181
+ }
182
+ out[group.id] = group.choices.filter((choice) => picked.has(choice.id)).map((c) => c.id);
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /** Is a choice in the manifest, in any group? */
188
+ export function has(manifest: Manifest, id: string): boolean {
189
+ return Object.values(withRequired(manifest.chosen, manifest.layout)).some((ids) => ids.includes(id));
190
+ }
191
+
192
+ /** The chosen ids of one group — for ids like `none` that several groups use. */
193
+ export function chosenIn(manifest: Manifest, groupId: string): string[] {
194
+ return withRequired(manifest.chosen, manifest.layout)[groupId] ?? [];
195
+ }
196
+
197
+ /** Every env key the chosen set needs, deduplicated, in group order. */
198
+ export function envKeysOf(manifest: Manifest) {
199
+ const seen = new Set<string>();
200
+ const keys: NonNullable<NonNullable<ReturnType<typeof CHOICES.get>>["env"]>[number][] = [];
201
+ for (const ids of Object.values(withRequired(manifest.chosen, manifest.layout))) {
202
+ for (const id of ids) {
203
+ for (const key of CHOICES.get(id)?.env ?? []) {
204
+ if (seen.has(key.key)) continue;
205
+ seen.add(key.key);
206
+ keys.push(key);
207
+ }
208
+ }
209
+ }
210
+ return keys;
211
+ }
212
+
213
+ /** The packages this suite publishes, which a checkout can supply instead of npm. */
214
+ export const OWN_PACKAGES = [
215
+ "@saastemly/better-admin-ui",
216
+ "@saastemly/better-blogs",
217
+ "@saastemly/better-commerce",
218
+ "@saastemly/better-commerce-ui",
219
+ "@saastemly/better-dns",
220
+ "@saastemly/better-email",
221
+ "@saastemly/better-faqs",
222
+ "@saastemly/better-system-user",
223
+ "@saastemly/voidcommerce",
224
+ ];
225
+
226
+ /** What a dependency on `name` should say: a link into the checkout, or a version. */
227
+ export function specifier(manifest: Manifest, name: string, version = "latest"): string {
228
+ if (!manifest.link || !OWN_PACKAGES.includes(name)) return version;
229
+ const base = manifest.link.replace(/\/+$/, "");
230
+ // The package is scoped; the DIRECTORY it lives in is not. `../better-dns`,
231
+ // never `../@saastemly/better-dns`.
232
+ const directory = name.replace(/^@[^/]+\//, "");
233
+ return `file:${base}/${directory}`;
234
+ }
235
+
236
+ /** Every npm package the chosen set needs — for the app, or the monorepo's api/. */
237
+ export function packagesOf(manifest: Manifest): string[] {
238
+ const out = new Set<string>(["better-auth", "@saastemly/better-commerce"]);
239
+ for (const ids of Object.values(withRequired(manifest.chosen, manifest.layout))) {
240
+ for (const id of ids) for (const pkg of CHOICES.get(id)?.packages ?? []) out.add(pkg);
241
+ }
242
+ // The kit is the frontend's dependency, not the API's.
243
+ if (manifest.layout === "monorepo") {
244
+ for (const pkg of CHOICES.get("custom")?.packages ?? []) out.delete(pkg);
245
+ }
246
+ return [...out].sort();
247
+ }
248
+
249
+ export async function readManifest(root: string): Promise<Manifest | null> {
250
+ try {
251
+ const parsed = JSON.parse(await readFile(join(root, MANIFEST_FILE), "utf8")) as Manifest;
252
+ if (parsed.version !== 1) throw new Error(`unknown manifest version ${String(parsed.version)}`);
253
+ // Manifests written before the layout question: a Pages host meant a separate frontend.
254
+ parsed.layout ??= parsed.shop.pagesHost ? "monorepo" : "app";
255
+ return parsed;
256
+ } catch (error) {
257
+ if ((error as { code?: string }).code === "ENOENT") return null;
258
+ throw error;
259
+ }
260
+ }
261
+
262
+ export async function writeManifest(root: string, manifest: Manifest): Promise<void> {
263
+ await writeFile(join(root, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
264
+ }
265
+
266
+ /** Refuse a manifest that contradicts the catalogue's rules or its own layout. */
267
+ export function validate(manifest: Manifest): string[] {
268
+ const problems: string[] = [];
269
+ const chosen = withRequired(manifest.chosen, manifest.layout);
270
+ for (const group of GROUPS) {
271
+ const ids = chosen[group.id] ?? [];
272
+ if (group.kind === "select" && ids.length !== 1) {
273
+ problems.push(`${group.title}: exactly one choice is needed, got ${ids.length}`);
274
+ }
275
+ for (const id of ids) {
276
+ for (const conflict of CHOICES.get(id)?.conflicts ?? []) {
277
+ if (ids.includes(conflict)) problems.push(`${group.title}: ${id} cannot be used with ${conflict}`);
278
+ }
279
+ }
280
+ }
281
+ if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(manifest.shop.domain)) {
282
+ problems.push(`domain "${manifest.shop.domain}" does not look like a hostname`);
283
+ } else if (manifest.shop.zone && manifest.shop.domain !== manifest.shop.zone && !manifest.shop.domain.endsWith(`.${manifest.shop.zone}`)) {
284
+ problems.push(`domain "${manifest.shop.domain}" is not inside the zone "${manifest.shop.zone}" — records would be written to a zone that does not contain it`);
285
+ }
286
+ if (!/^[A-Z]{2}$/.test(manifest.shop.country)) {
287
+ problems.push(`country "${manifest.shop.country}" must be an ISO-3166 alpha-2 code`);
288
+ }
289
+
290
+ const dns = chosen["dns"] ?? [];
291
+ // Every layout puts the worker on a Cloudflare custom domain, and Cloudflare
292
+ // creates that record in its own zone. A zone hosted elsewhere cannot have
293
+ // one, so a second DNS provider is not a preference — it is a contradiction.
294
+ if (dns.includes("simply")) {
295
+ problems.push(
296
+ `DNS at Simply.com cannot host ${workerHosts(manifest)[0]} — a Worker custom domain is created by Cloudflare, in a zone on Cloudflare`,
297
+ );
298
+ }
299
+ if (hasFrontend(manifest.layout) && manifest.shop.pagesHost && !/^[a-z0-9-]+\.github\.io$/i.test(manifest.shop.pagesHost)) {
300
+ problems.push(`Pages host "${manifest.shop.pagesHost}" should be <owner>.github.io`);
301
+ }
302
+ return problems;
303
+ }
package/src/project.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { generate } from "./generate/index";
4
+ import { STRICT_APP, finishStrict } from "./generate/strict";
5
+ import { type Manifest, readManifest } from "./manifest";
6
+
7
+ /**
8
+ * The project vc is standing in, if any.
9
+ *
10
+ * The manifest names the root. Where void's commands run follows the layout:
11
+ * the root itself for one app; api/ for a monorepo — from the root or from
12
+ * inside api/; the artifact under .vc/app for strict, generated first if it
13
+ * is not there.
14
+ */
15
+ export interface Project {
16
+ root: string;
17
+ manifest: Manifest;
18
+ /** Where void's commands run. */
19
+ appDir: string;
20
+ /** Strict only: the artifact has not been generated yet. */
21
+ needsGenerate: boolean;
22
+ }
23
+
24
+ export async function findProject(cwd = process.cwd()): Promise<Project | null> {
25
+ // The manifest is here, or a few levels up (a monorepo's api/).
26
+ let root = cwd;
27
+ let manifest: Manifest | null = null;
28
+ for (let hops = 0; hops < 3 && root; hops++) {
29
+ manifest = await readManifest(root);
30
+ if (manifest) break;
31
+ const parent = dirname(root);
32
+ if (parent === root) break;
33
+ root = parent;
34
+ }
35
+ if (!manifest) return null;
36
+
37
+ switch (manifest.layout) {
38
+ case "monorepo": {
39
+ const api = join(root, "api");
40
+ // Inside frontend/ you are on your own: that is a plain Void app.
41
+ if (cwd !== root && cwd !== api) return null;
42
+ return { root, manifest, appDir: api, needsGenerate: false };
43
+ }
44
+ case "strict": {
45
+ if (cwd !== root) return null;
46
+ const appDir = join(root, STRICT_APP);
47
+ return { root, manifest, appDir, needsGenerate: !existsSync(join(appDir, "auth.ts")) };
48
+ }
49
+ default:
50
+ if (cwd !== root) return null;
51
+ return { root, manifest, appDir: root, needsGenerate: false };
52
+ }
53
+ }
54
+
55
+ /** A strict artifact that is not there yet is generated before anything runs in it. */
56
+ export async function ensureGenerated(project: Project): Promise<number> {
57
+ if (!project.needsGenerate) return 0;
58
+ console.log("vc: no app under .vc/app yet — generating it from voidcommerce.json.");
59
+ await generate(project.root, project.manifest);
60
+ const code = await finishStrict(project.root);
61
+ if (code === 0) project.needsGenerate = false;
62
+ return code;
63
+ }
@@ -0,0 +1,51 @@
1
+ import * as p from "@clack/prompts";
2
+ import color from "picocolors";
3
+ import { generate } from "./generate/index";
4
+ import { finishStrict } from "./generate/strict";
5
+ import { box, line, row } from "./help";
6
+ import { validate } from "./manifest";
7
+ import { findProject } from "./project";
8
+
9
+ /**
10
+ * `vc generate` — the files from the manifest, without the form.
11
+ *
12
+ * In one-app and monorepo layouts it rewrites the regenerated files and
13
+ * keeps the owned ones. In strict it is the whole app: every file under
14
+ * .vc/app, then void's own artifacts and the migrations.
15
+ */
16
+ export async function generateCommand(): Promise<number> {
17
+ const project = await findProject();
18
+ if (!project) {
19
+ console.error("vc: no voidcommerce.json here. `vc init` writes one.");
20
+ return 1;
21
+ }
22
+ const problems = validate(project.manifest);
23
+ if (problems.length > 0) {
24
+ console.error("vc: voidcommerce.json contradicts itself:");
25
+ for (const problem of problems) console.error(` ✗ ${problem}`);
26
+ return 1;
27
+ }
28
+ const result = await generate(project.root, project.manifest);
29
+ p.log.step(
30
+ [
31
+ ...result.written.map((file) => `${color.green("+")} ${file}`),
32
+ ...result.kept.map((file) => `${color.dim("=")} ${file} ${color.dim("(kept)")}`),
33
+ ].join("\n"),
34
+ );
35
+ return project.manifest.layout === "strict" ? finishStrict(project.root) : 0;
36
+ }
37
+
38
+ export async function generateHelp(): Promise<number> {
39
+ const width = 80;
40
+ console.log(
41
+ box("vc generate", [
42
+ line("The files from voidcommerce.json, without the form.", width),
43
+ line("", width),
44
+ ...row("one app, monorepo", "rewrites the regenerated files; files you own are kept", width, 2),
45
+ ...row("strict", "rewrites the whole app under .vc/app, then runs void prepare and void db generate", width, 2),
46
+ line("", width),
47
+ line("vc runs this itself when a strict root has no app yet.", width),
48
+ ], width),
49
+ );
50
+ return 0;
51
+ }