@saastemly/voidcommerce 0.4.0 → 0.5.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/dist/cli.js CHANGED
@@ -1,13 +1,10 @@
1
1
  import {
2
- PRIVATE_KEY_VAR,
3
- SECRETS_FILE,
4
2
  box,
5
3
  captureVoid,
6
4
  deployCloudflare,
7
5
  distCommand,
8
6
  distHelp,
9
7
  ensureGenerated,
10
- envSummary,
11
8
  findProject,
12
9
  finishStrict,
13
10
  fullHelp,
@@ -16,31 +13,181 @@ import {
16
13
  importHelp,
17
14
  initHelp,
18
15
  isVoidApp,
19
- keysCommand,
20
16
  line,
21
- plaintextSecretNames,
22
17
  preflight,
23
18
  printPreflight,
24
19
  row,
25
20
  runInherit,
26
21
  runVoid,
27
- secretsCommand,
28
22
  version,
29
23
  voidAppsIn
30
- } from "./index-yzezvy5h.js";
24
+ } from "./index-5m3t0zfc.js";
25
+ import {
26
+ PRIVATE_KEY_VAR,
27
+ SECRETS_FILE,
28
+ cloudflareAccounts,
29
+ committedPublicKeyInto,
30
+ declaredSecretNames,
31
+ envSummary,
32
+ ghAuth,
33
+ initSecrets,
34
+ keyState,
35
+ keysCommand,
36
+ plaintextSecretNames,
37
+ provisionKey,
38
+ repoSlug,
39
+ secretNames,
40
+ secretsCommand,
41
+ setSecret,
42
+ setVariable,
43
+ variableNames,
44
+ verifyCloudflareToken
45
+ } from "./index-b9b4dawy.js";
31
46
  import {
32
47
  LAYOUTS,
33
48
  readManifest,
34
49
  validate,
50
+ writeManifest,
35
51
  zoneOf
36
52
  } from "./index-pz6m2hkm.js";
37
53
  import {
38
54
  GROUPS
39
55
  } from "./index-844b3qn9.js";
40
- import"./index-0v6na3yp.js";
56
+ import {
57
+ __require
58
+ } from "./index-0v6na3yp.js";
41
59
 
42
60
  // src/deploy/index.ts
61
+ import color2 from "picocolors";
62
+
63
+ // src/deploy/link.ts
43
64
  import color from "picocolors";
65
+ var TOKEN_SECRET = "CLOUDFLARE_API_TOKEN";
66
+ var ACCOUNT_VAR = "CLOUDFLARE_ACCOUNT_ID";
67
+ var SCOPES = [
68
+ ["Account · Workers Scripts: Edit", "deploy the worker"],
69
+ ["Account · D1: Edit", "create the database and apply migrations"],
70
+ ["Account · Queues: Edit", "create the order queue"],
71
+ ["Account · Workers KV Storage: Edit", "sessions and caches"],
72
+ ["Account · Workers R2 Storage: Edit", "product images"],
73
+ ["Account · Account Settings: Read", "confirm which account this is"],
74
+ ["Zone · Workers Routes: Edit", "answer on your domain"],
75
+ ["User · User Details: Read", "wrangler asks at startup"]
76
+ ];
77
+ async function linkCommand(project, args) {
78
+ const p = await import("@clack/prompts");
79
+ const root = project.root;
80
+ const relink = args.includes("--force");
81
+ p.intro(color.bgCyan(color.black(" vc link ")));
82
+ const auth = await ghAuth(root);
83
+ if (!auth.ok) {
84
+ p.cancel(auth.reason ?? "gh is unavailable");
85
+ return 1;
86
+ }
87
+ const slug = await repoSlug(root);
88
+ if (!slug) {
89
+ p.cancel(`this checkout has no GitHub repository, and everything below is stored on one.
90
+
91
+ ${color.cyan("gh repo create --source=. --private --push")}
92
+
93
+ then run \`vc link\` again.`);
94
+ return 1;
95
+ }
96
+ p.log.success(`${color.green("✓")} ${slug}, as ${auth.user ?? "you"}`);
97
+ const keys = await keyState(project);
98
+ if (keys.inGitHub && !relink) {
99
+ p.log.info(`${PRIVATE_KEY_VAR} is already set — leaving it alone (--force replaces it, which orphans ${SECRETS_FILE})`);
100
+ } else if (keys.inGitHub && relink) {
101
+ p.log.warn(`--force: replacing ${PRIVATE_KEY_VAR} makes every value in ${SECRETS_FILE} unreadable. Use \`vc keys --rotate\` to re-encrypt instead.`);
102
+ return 1;
103
+ } else {
104
+ const made = await provisionKey(project);
105
+ if (!made.ok) {
106
+ p.cancel(made.reason);
107
+ return 1;
108
+ }
109
+ committedPublicKeyInto(root, made.publicKey);
110
+ p.log.success(`${color.green("✓")} ${PRIVATE_KEY_VAR} generated and stored on GitHub; public half in ${SECRETS_FILE}`);
111
+ }
112
+ const existing = await secretNames(root);
113
+ if (existing.has(TOKEN_SECRET) && !relink) {
114
+ p.log.info(`${TOKEN_SECRET} is already set — pass --force to replace it`);
115
+ } else {
116
+ p.log.step(`A Cloudflare API token, from ${color.cyan("https://dash.cloudflare.com/profile/api-tokens")} → Create Token → Custom token`);
117
+ console.log(SCOPES.map(([scope, why]) => ` ${scope.padEnd(38)} ${color.dim(why)}`).join(`
118
+ `));
119
+ console.log(color.dim(`
120
+ The token Cloudflare generates for its own Workers Builds will NOT do:
121
+ it has no D1 and no Queues permission, so it cannot create the database.
122
+ `));
123
+ const token = await p.password({
124
+ message: "Paste it (nothing is written to disk)",
125
+ validate: (input) => input && input.length >= 20 ? undefined : "that is too short to be a Cloudflare token"
126
+ });
127
+ if (p.isCancel(token)) {
128
+ p.cancel("nothing was stored.");
129
+ return 1;
130
+ }
131
+ const spinner = p.spinner();
132
+ spinner.start("asking Cloudflare whether that token is real");
133
+ const verified = await verifyCloudflareToken(String(token));
134
+ if (!verified.ok) {
135
+ spinner.stop(`${color.red("✗")} Cloudflare rejected it: ${verified.detail}`);
136
+ p.cancel("nothing was stored.");
137
+ return 1;
138
+ }
139
+ const accounts = await cloudflareAccounts(String(token));
140
+ spinner.stop(`${color.green("✓")} the token is active and sees ${accounts.length} account${accounts.length === 1 ? "" : "s"}`);
141
+ let accountId = project.manifest.cloudflare?.accountId ?? "";
142
+ if (accounts.length === 1) {
143
+ accountId = accounts[0].id;
144
+ p.log.info(`account "${accounts[0].name}"`);
145
+ } else if (accounts.length > 1) {
146
+ const picked = await p.select({
147
+ message: "Which account is this shop in?",
148
+ options: accounts.map((account) => ({ value: account.id, label: account.name, hint: account.id }))
149
+ });
150
+ if (p.isCancel(picked)) {
151
+ p.cancel("nothing was stored.");
152
+ return 1;
153
+ }
154
+ accountId = String(picked);
155
+ }
156
+ const stored = await setSecret(root, TOKEN_SECRET, String(token));
157
+ if (!stored.ok) {
158
+ p.cancel(`GitHub refused the secret: ${stored.error ?? "unknown error"}`);
159
+ return 1;
160
+ }
161
+ p.log.success(`${color.green("✓")} ${TOKEN_SECRET} stored on ${slug}`);
162
+ if (accountId) {
163
+ const varred = await setVariable(root, ACCOUNT_VAR, accountId);
164
+ if (varred.ok)
165
+ p.log.success(`${color.green("✓")} ${ACCOUNT_VAR} set as a repository variable`);
166
+ project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId };
167
+ await writeManifest(root, project.manifest);
168
+ }
169
+ }
170
+ if (declaredSecretNames(root).size === 0) {
171
+ p.log.step(`writing ${SECRETS_FILE}`);
172
+ await initSecrets(project);
173
+ }
174
+ const secrets = await secretNames(root);
175
+ const vars = await variableNames(root);
176
+ p.outro([
177
+ `${color.bold("Ready.")} From here, ${color.cyan("git push")} is the deploy.`,
178
+ "",
179
+ ` ${secrets.has(PRIVATE_KEY_VAR) ? color.green("✓") : color.red("✗")} ${PRIVATE_KEY_VAR} ${color.dim("reads the shop's secrets")}`,
180
+ ` ${secrets.has(TOKEN_SECRET) ? color.green("✓") : color.red("✗")} ${TOKEN_SECRET} ${color.dim("deploys to Cloudflare")}`,
181
+ ` ${vars.has(ACCOUNT_VAR) ? color.green("✓") : color.dim("·")} ${ACCOUNT_VAR} ${color.dim("which account")}`,
182
+ "",
183
+ ` Set the shop's own secrets with ${color.cyan("vc secrets set KEY")} — that needs no`,
184
+ ` credential at all, because encryption uses the public key in the repository.`
185
+ ].join(`
186
+ `));
187
+ return 0;
188
+ }
189
+
190
+ // src/deploy/index.ts
44
191
  async function deployCommand(args) {
45
192
  const own = new Set(["--cloudflare", "--provision", "--force"]);
46
193
  const cloudflare = args.includes("--cloudflare");
@@ -63,12 +210,12 @@ async function deployCommand(args) {
63
210
  const check = await preflight(project, "void");
64
211
  printPreflight(project, check, "void");
65
212
  if (!check.ready && check.remote !== null && !args.includes("--force")) {
66
- console.error(`${color.red("✗")} refusing to deploy a shop that is not ready for customers. Fix the above, or pass --force.
213
+ console.error(`${color2.red("✗")} refusing to deploy a shop that is not ready for customers. Fix the above, or pass --force.
67
214
  `);
68
215
  return 1;
69
216
  }
70
217
  if (!check.ready && check.remote === null) {
71
- console.log(color.dim(`Secrets could not be verified here; void's own deploy gate is the next check.
218
+ console.log(color2.dim(`Secrets could not be verified here; void's own deploy gate is the next check.
72
219
  `));
73
220
  }
74
221
  return runVoid(["deploy", ...voids], project.appDir);
@@ -91,15 +238,18 @@ async function deployHelp() {
91
238
  line("vc's preflight — every required key set, the hostnames right — then", width),
92
239
  line("void deploy, untouched. Or, with --cloudflare, your own account via wrangler.", width),
93
240
  line("", width),
94
- line(color.bold("Usage"), width),
241
+ line(color2.bold("Usage"), width),
95
242
  ...row("vc deploy [void's flags]", "preflight, then void deploy — the Void platform, void's login", width, 2),
96
243
  ...row("vc deploy --cloudflare", "wrangler, your Cloudflare account, no Void login: preflight, build, scrub baked secrets, migrate D1, deploy", width, 2),
97
244
  ...row("vc deploy --cloudflare --provision", "first time: create the D1 database and the queue, record them", width, 2),
98
245
  ...row("vc deploy --cloudflare --force", "deploy a shop preflight says is not ready — deliberately", width, 2),
99
246
  line("", width),
100
- line(color.bold("Needs, for --cloudflare"), width),
101
- ...row("wrangler login", "or CLOUDFLARE_API_TOKEN; the account is pinned for you when there is one", width, 2),
102
- ...row("wrangler secret put <KEY>", "each secret, on the worker preflight lists them", width, 2)
247
+ line(color2.bold("Needs, for --cloudflare"), width),
248
+ ...row("CLOUDFLARE_API_TOKEN", "in the environment; the account is pinned for you when there is one", width, 2),
249
+ ...row(PRIVATE_KEY_VAR, "to decrypt the shop's own secrets", width, 2),
250
+ line("", width),
251
+ line("Normally neither is on your machine: `vc link` puts both on the GitHub", width),
252
+ line("repository and the workflow runs this command for you on every push.", width)
103
253
  ], width);
104
254
  if (captured)
105
255
  process.stdout.write(captured.out.replace(/\n*$/, `
@@ -133,14 +283,16 @@ async function secretsHelp() {
133
283
  line(`Secrets that live in the repository, encrypted, in ${SECRETS_FILE}.`, width),
134
284
  line("", width),
135
285
  ...row("vc secrets", "what this shop needs, and whether it is declared and encrypted", width, 2),
136
- ...row("vc secrets --init", `write ${SECRETS_FILE} with every required key as \`unset\``, width, 2),
137
- ...row(`dotenvx set KEY '…' -f ${SECRETS_FILE}`, "set one, without decrypting the file", width, 2),
138
- ...row(`dotenvx encrypt -f ${SECRETS_FILE}`, "encrypt anything still in the clear", width, 2),
286
+ ...row("vc secrets --init", `write ${SECRETS_FILE} and make this shop's key`, width, 2),
287
+ ...row("vc secrets set KEY", "set one value prompts, so it never reaches your shell history", width, 2),
288
+ ...row("echo v | vc secrets set KEY", "the same, from a pipe, for scripts", width, 2),
139
289
  line("", width),
140
290
  line("The file is COMMITTED: values are ciphertext, key names are not, so a", width),
141
291
  line("diff shows which secret changed without showing what it changed to.", width),
142
- line(`The private key stays out — .env.keys locally, and ${PRIVATE_KEY_VAR}`, width),
143
- line("as one build variable where the deploy runs.", width),
292
+ line("", width),
293
+ line("Setting one needs NO credential. dotenvx is asymmetric, so encryption uses", width),
294
+ line(`the public key already in ${SECRETS_FILE}. The private key is a GitHub`, width),
295
+ line(`Actions secret, ${PRIVATE_KEY_VAR}, and only the deploy uses it.`, width),
144
296
  line("", width),
145
297
  line("Tradeoff worth knowing: ciphertext in git is permanent, so a leaked key", width),
146
298
  line("exposes rotated secrets too. `wrangler secret put` does not have that", width),
@@ -154,23 +306,27 @@ async function keysCliCommand(args) {
154
306
  console.error("vc: no voidcommerce.json here.");
155
307
  return 1;
156
308
  }
157
- return keysCommand(project.manifest, project.root, args);
309
+ return keysCommand(project, args);
158
310
  }
159
311
  async function keysHelp() {
160
312
  const width = 80;
161
313
  console.log(box("vc keys", [
162
- line("The key that encrypts this repository's secrets is DERIVED from", width),
163
- line("CLOUDFLARE_API_TOKEN, salted with the account id. Nothing is stored.", width),
314
+ line("Secrets are encrypted with a PUBLIC key that is committed, and read with", width),
315
+ line("a PRIVATE key that lives only in GitHub Actions.", width),
316
+ line("", width),
317
+ ...row("vc keys", "where the key is, and what is missing", width, 2),
318
+ ...row("vc keys --init", "generate one and store it on the GitHub repository", width, 2),
319
+ ...row("vc keys --rotate", "re-key and re-encrypt, if you hold the current private key", width, 2),
164
320
  line("", width),
165
- ...row("vc keys", "what this token derives, and whether it matches the repository", width, 2),
166
- ...row("vc keys --rotate", "re-encrypt under a new token, while the old one still works", width, 2),
321
+ line("Because the encryption is asymmetric, SETTING a secret needs no credential", width),
322
+ line("at all: `vc secrets set KEY` encrypts with the public key in the repository.", width),
323
+ line("A contributor with only a clone can rotate the Stripe key and cannot read", width),
324
+ line("the one already there. Only the deploy decrypts.", width),
167
325
  line("", width),
168
- line("Whoever can deploy the worker can read its secrets, and nobody else can.", width),
169
- line("The price: rotating the token orphans every encrypted value, a second", width),
170
- line("admin's token derives a different key, and the build must use the same", width),
171
- line("token the values were encrypted under. `vc keys` checks the derived", width),
172
- line("public key against the committed one, so a mismatch is a clear refusal", width),
173
- line("rather than a decryption error nobody can place.", width)
326
+ line("GitHub will not hand a secret back once set, so re-keying without a local", width),
327
+ line("copy means entering the values again. That is the right trade: a key worth", width),
328
+ line("rotating is a key that may have leaked, and leaked values need replacing", width),
329
+ line("at the source anyway.", width)
174
330
  ], width));
175
331
  return 0;
176
332
  }
@@ -182,28 +338,60 @@ async function guardCommand() {
182
338
  if (bare.length === 0)
183
339
  return 0;
184
340
  console.error(`
185
- ${color.red("✗ refusing the commit")}: ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE} ${bare.length === 1 ? "is" : "are"} not encrypted.
341
+ ${color2.red("✗ refusing the commit")}: ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE} ${bare.length === 1 ? "is" : "are"} not encrypted.
186
342
 
187
343
  ` + ` ${bare.join(`
188
344
  `)}
189
345
 
190
346
  ` + ` bunx dotenvx encrypt -f ${SECRETS_FILE}
191
347
 
192
- ` + color.dim(` Committing a secret in the clear cannot be undone by a later commit;
348
+ ` + color2.dim(` Committing a secret in the clear cannot be undone by a later commit;
193
349
  the value stays in the history and must be treated as burned.
194
350
  `));
195
351
  return 1;
196
352
  }
353
+ async function linkCliCommand(args) {
354
+ const project = await findProject();
355
+ if (!project) {
356
+ console.error("vc: no voidcommerce.json here.");
357
+ return 1;
358
+ }
359
+ return linkCommand(project, args);
360
+ }
361
+ async function linkHelp() {
362
+ const width = 80;
363
+ console.log(box("vc link", [
364
+ line("Put this shop's credentials on its GitHub repository, once, so that", width),
365
+ line("every deploy after this is a `git push`.", width),
366
+ line("", width),
367
+ ...row("vc link", "generate the encryption key, take the Cloudflare token, store both", width, 2),
368
+ ...row("vc link --force", "replace what is already there", width, 2),
369
+ line("", width),
370
+ line(color2.bold("What it stores, and where"), width),
371
+ ...row("DOTENV_PRIVATE_KEY_SECRETS", "generated here, never written to disk — a repository SECRET", width, 2),
372
+ ...row("CLOUDFLARE_API_TOKEN", "yours, checked against the Cloudflare API first — a repository SECRET", width, 2),
373
+ ...row("CLOUDFLARE_ACCOUNT_ID", "an identifier, not a credential — a repository VARIABLE", width, 2),
374
+ line("", width),
375
+ line(color2.bold("Why one token still has to be typed"), width),
376
+ line("GitHub cannot mint a Cloudflare credential. There is no OIDC federation", width),
377
+ line("between them, and the Cloudflare GitHub App runs the other way: it grants", width),
378
+ line("Cloudflare access to your repository, not your repository access to", width),
379
+ line("Cloudflare. Something must authorise creating a database in your account,", width),
380
+ line("and only Cloudflare can issue that. So it is typed once, here, and never", width),
381
+ line("stored on this machine.", width)
382
+ ], width));
383
+ return 0;
384
+ }
197
385
 
198
386
  // src/init.ts
199
387
  import * as p2 from "@clack/prompts";
200
388
  import { mkdir } from "node:fs/promises";
201
389
  import { basename, join } from "node:path";
202
- import color3 from "picocolors";
390
+ import color4 from "picocolors";
203
391
 
204
392
  // src/wizard.ts
205
393
  import * as p from "@clack/prompts";
206
- import color2 from "picocolors";
394
+ import color3 from "picocolors";
207
395
  var bail = () => {
208
396
  p.cancel("Nothing was written.");
209
397
  process.exit(0);
@@ -225,8 +413,8 @@ async function askGroup(group2, previous, alsoRequired = []) {
225
413
  const required = group2.choices.filter((choice) => requiredIds.has(choice.id));
226
414
  const optional = group2.choices.filter((choice) => !requiredIds.has(choice.id));
227
415
  const message = group2.title + (group2.intro ? `
228
- ${color2.dim(group2.intro)}` : "") + (required.length ? `
229
- ${color2.dim(`Included: ${required.map((choice) => choice.label).join(", ")}`)}` : "");
416
+ ${color3.dim(group2.intro)}` : "") + (required.length ? `
417
+ ${color3.dim(`Included: ${required.map((choice) => choice.label).join(", ")}`)}` : "");
230
418
  if (optional.length === 0) {
231
419
  p.log.info(message);
232
420
  return required.map((choice) => choice.id);
@@ -256,7 +444,7 @@ async function runWizard(existing, layout) {
256
444
  validate: (value) => value.trim() ? undefined : "A shop has a name."
257
445
  }),
258
446
  domain: () => p.text({
259
- message: `Domain ${color2.dim(monorepo ? "— the storefront; api.<domain> is the worker; the CORS list and DNS records derive from it" : "— the shop answers here; the worker's custom domain")}`,
447
+ message: `Domain ${color3.dim(monorepo ? "— the storefront; api.<domain> is the worker; the CORS list and DNS records derive from it" : "— the shop answers here; the worker's custom domain")}`,
260
448
  placeholder: "northwind.com",
261
449
  initialValue: existing?.shop.domain ?? "",
262
450
  validate: (value) => /^[a-z0-9-]+(\.[a-z0-9-]+)+\.[a-z]{2,}$|^[a-z0-9-]+\.[a-z]{2,}$/i.test(value.trim()) ? undefined : "A hostname, like northwind.com or shop.northwind.com."
@@ -267,7 +455,7 @@ async function runWizard(existing, layout) {
267
455
  if (!domain || domain === guess)
268
456
  return Promise.resolve(undefined);
269
457
  return p.text({
270
- message: `DNS zone ${color2.dim(`— ${domain} lives inside it; records go here, and this zone must be on your DNS provider`)}`,
458
+ message: `DNS zone ${color3.dim(`— ${domain} lives inside it; records go here, and this zone must be on your DNS provider`)}`,
271
459
  placeholder: guess,
272
460
  initialValue: existing?.shop.zone ?? guess,
273
461
  validate: (value) => {
@@ -279,13 +467,13 @@ async function runWizard(existing, layout) {
279
467
  });
280
468
  },
281
469
  country: () => p.text({
282
- message: `Country ${color2.dim("— sets the declared VAT rate and the default region")}`,
470
+ message: `Country ${color3.dim("— sets the declared VAT rate and the default region")}`,
283
471
  placeholder: "US",
284
472
  initialValue: existing?.shop.country ?? "",
285
473
  validate: (value) => /^[A-Za-z]{2}$/.test(value.trim()) ? undefined : "ISO-3166 alpha-2, like DK."
286
474
  }),
287
475
  taxRegistered: () => p.select({
288
- message: `Registered to charge tax? ${color2.dim("— a business below its registration threshold must NOT add tax to a price, and one above it must")}`,
476
+ message: `Registered to charge tax? ${color3.dim("— a business below its registration threshold must NOT add tax to a price, and one above it must")}`,
289
477
  options: [
290
478
  { value: "no", label: "Not registered", hint: "prices are what the customer pays; no tax is added. The common case for a new shop" },
291
479
  { value: "yes", label: "Registered", hint: "the country's standard rate is declared and added" }
@@ -299,14 +487,14 @@ async function runWizard(existing, layout) {
299
487
  validate: (value) => /^[A-Za-z]{3}$/.test(value.trim()) ? undefined : "ISO-4217, like dkk."
300
488
  }),
301
489
  locale: () => p.text({
302
- message: `Locale ${color2.dim("— the language of emails and launch content")}`,
490
+ message: `Locale ${color3.dim("— the language of emails and launch content")}`,
303
491
  placeholder: "en",
304
492
  initialValue: existing?.shop.locale ?? "",
305
493
  validate: (value) => /^[a-z]{2}(-[A-Z]{2})?$/.test(value.trim()) ? undefined : "like da, or en-GB."
306
494
  }),
307
495
  ...frontend ? {
308
496
  pagesHost: () => p.text({
309
- message: `GitHub Pages host ${color2.dim("— where the storefront is published; leave blank if you serve the branch elsewhere")}`,
497
+ message: `GitHub Pages host ${color3.dim("— where the storefront is published; leave blank if you serve the branch elsewhere")}`,
310
498
  placeholder: "northwind.github.io",
311
499
  initialValue: existing?.shop.pagesHost ?? "",
312
500
  validate: (value) => !value.trim() || /^[a-z0-9-]+\.github\.io$/i.test(value.trim()) ? undefined : "<owner>.github.io, or blank"
@@ -317,7 +505,7 @@ async function runWizard(existing, layout) {
317
505
  for (const group2 of GROUPS) {
318
506
  if (group2.id === "ui" && layout === "strict") {
319
507
  p.log.info(`${group2.title}
320
- ${color2.dim("The generated storefront and panel. Strict has no custom code to host anything else.")}`);
508
+ ${color3.dim("The generated storefront and panel. Strict has no custom code to host anything else.")}`);
321
509
  chosen[group2.id] = ["admin"];
322
510
  continue;
323
511
  }
@@ -342,14 +530,14 @@ ${color2.dim("The generated storefront and panel. Strict has no custom code to h
342
530
  }
343
531
  function summarise(manifest) {
344
532
  const lines = [
345
- `${color2.dim("Layout".padEnd(34))} ${LAYOUTS.find((l) => l.id === manifest.layout)?.label ?? manifest.layout}`
533
+ `${color3.dim("Layout".padEnd(34))} ${LAYOUTS.find((l) => l.id === manifest.layout)?.label ?? manifest.layout}`
346
534
  ];
347
535
  for (const group2 of GROUPS) {
348
536
  const ids = manifest.chosen[group2.id] ?? [];
349
537
  if (ids.length === 0)
350
538
  continue;
351
539
  const labels = ids.map((id) => group2.choices.find((choice) => choice.id === id)?.label ?? id).join(", ");
352
- lines.push(`${color2.dim(group2.title.padEnd(34))} ${labels}`);
540
+ lines.push(`${color3.dim(group2.title.padEnd(34))} ${labels}`);
353
541
  }
354
542
  return lines.join(`
355
543
  `);
@@ -390,10 +578,10 @@ async function init(args) {
390
578
  if (flags.voids.length > 0)
391
579
  return runVoid(["init", ...flags.voids]);
392
580
  let root = process.cwd();
393
- p2.intro(color3.bgCyan(color3.black(" voidcommerce ")));
581
+ p2.intro(color4.bgCyan(color4.black(" voidcommerce ")));
394
582
  const existing = await readManifest(root);
395
583
  if (existing) {
396
- p2.log.info(`Found ${color3.cyan("voidcommerce.json")} for ${color3.bold(existing.shop.name)} — answers are pre-filled; the layout (${existing.layout}) is fixed.`);
584
+ p2.log.info(`Found ${color4.cyan("voidcommerce.json")} for ${color4.bold(existing.shop.name)} — answers are pre-filled; the layout (${existing.layout}) is fixed.`);
397
585
  if (flags.layout && flags.layout !== existing.layout) {
398
586
  p2.log.error(`This shop is laid out as "${existing.layout}". The layout cannot change after init — moving files is not a regeneration.`);
399
587
  return 1;
@@ -401,10 +589,10 @@ async function init(args) {
401
589
  }
402
590
  const layout = existing?.layout ?? flags.layout ?? await askLayout();
403
591
  if (layout === "strict") {
404
- p2.log.step(`Strict: the app will be generated under ${color3.cyan(".vc/app")} — nothing there is yours to edit.`);
592
+ p2.log.step(`Strict: the app will be generated under ${color4.cyan(".vc/app")} — nothing there is yours to edit.`);
405
593
  } else if (layout === "app") {
406
594
  if (!isVoidApp(root)) {
407
- p2.log.step(`No Void app here yet — ${color3.cyan("void init")} first, then the shop.`);
595
+ p2.log.step(`No Void app here yet — ${color4.cyan("void init")} first, then the shop.`);
408
596
  const before = new Set(voidAppsIn(root));
409
597
  const code = await runVoid(["init"]);
410
598
  if (code !== 0)
@@ -417,7 +605,7 @@ async function init(args) {
417
605
  }
418
606
  root = created[0];
419
607
  process.chdir(root);
420
- p2.log.step(`Continuing in ${color3.cyan(`${basename(root)}/`)}`);
608
+ p2.log.step(`Continuing in ${color4.cyan(`${basename(root)}/`)}`);
421
609
  }
422
610
  }
423
611
  } else {
@@ -430,7 +618,7 @@ async function init(args) {
430
618
  if (isVoidApp(target))
431
619
  continue;
432
620
  await mkdir(target, { recursive: true });
433
- p2.log.step(`${color3.cyan("void init")} in ${color3.cyan(`${dir}/`)} — choose ${starter}.`);
621
+ p2.log.step(`${color4.cyan("void init")} in ${color4.cyan(`${dir}/`)} — choose ${starter}.`);
434
622
  const code = await runVoid(["init"], target);
435
623
  if (code !== 0)
436
624
  return code;
@@ -448,7 +636,7 @@ async function init(args) {
448
636
  if (problems.length > 0) {
449
637
  p2.log.error("These answers contradict each other:");
450
638
  for (const problem of problems)
451
- p2.log.message(` ${color3.red("✗")} ${problem}`);
639
+ p2.log.message(` ${color4.red("✗")} ${problem}`);
452
640
  p2.cancel("Nothing was written.");
453
641
  return 1;
454
642
  }
@@ -464,15 +652,16 @@ async function init(args) {
464
652
  spinner2.stop("Generated");
465
653
  const { secrets, plaintext } = envSummary(manifest);
466
654
  p2.note([
467
- ...result.written.map((file) => `${color3.green("+")} ${file}`),
468
- ...result.kept.map((file) => `${color3.dim("=")} ${file} ${color3.dim("(kept — yours)")}`)
655
+ ...result.written.map((file) => `${color4.green("+")} ${file}`),
656
+ ...result.kept.map((file) => `${color4.dim("=")} ${file} ${color4.dim("(kept — yours)")}`),
657
+ ...result.retired.map((file) => `${color4.red("-")} ${file} ${color4.dim("(retired — no longer generated)")}`)
469
658
  ].join(`
470
659
  `), "Files");
471
660
  p2.note([
472
- `${color3.bold(String(secrets.length))} secrets ${color3.dim("→ wrangler secret put <NAME>")}`,
661
+ `${color4.bold(String(secrets.length))} secrets ${color4.dim("→ wrangler secret put <NAME>")}`,
473
662
  ...secrets.map((key) => ` ${key}`),
474
663
  "",
475
- `${color3.bold(String(plaintext.length))} plaintext ${color3.dim("→ .env.production, already written")}`,
664
+ `${color4.bold(String(plaintext.length))} plaintext ${color4.dim("→ .env.production, already written")}`,
476
665
  ...plaintext.map((key) => ` ${key}`)
477
666
  ].join(`
478
667
  `), "Before going live");
@@ -482,21 +671,21 @@ async function init(args) {
482
671
  return code;
483
672
  }
484
673
  const next = layout === "app" ? [
485
- ` ${color3.cyan("bun install")}`,
486
- ` ${color3.cyan("bun run maildev")} ${color3.dim("local inbox at http://localhost:1080")}`,
487
- ` ${color3.cyan("vc dev")}`,
488
- ` ${color3.cyan("vc preflight")} ${color3.dim("what is still missing, and why it matters")}`
674
+ ` ${color4.cyan("bun install")}`,
675
+ ` ${color4.cyan("bun run maildev")} ${color4.dim("local inbox at http://localhost:1080")}`,
676
+ ` ${color4.cyan("vc dev")}`,
677
+ ` ${color4.cyan("vc preflight")} ${color4.dim("what is still missing, and why it matters")}`
489
678
  ] : layout === "strict" ? [
490
- ` ${color3.cyan("bun install")}`,
491
- ` ${color3.cyan("vc generate")} ${color3.dim("the app under .vc/app, void's artifacts, the migrations")}`,
492
- ` ${color3.cyan("bun run maildev")} ${color3.dim("local inbox at http://localhost:1080")}`,
493
- ` ${color3.cyan("vc dev")} ${color3.dim("runs in .vc/app")}`
679
+ ` ${color4.cyan("bun install")}`,
680
+ ` ${color4.cyan("vc generate")} ${color4.dim("the app under .vc/app, void's artifacts, the migrations")}`,
681
+ ` ${color4.cyan("bun run maildev")} ${color4.dim("local inbox at http://localhost:1080")}`,
682
+ ` ${color4.cyan("vc dev")} ${color4.dim("runs in .vc/app")}`
494
683
  ] : [
495
- ` ${color3.cyan("bun install")} ${color3.dim("one install for both workspaces")}`,
496
- ` ${color3.cyan("bun run maildev")} ${color3.dim("local inbox at http://localhost:1080")}`,
497
- ` ${color3.cyan("bun run dev:api")} ${color3.dim("the worker, with the panel")}`,
498
- ` ${color3.cyan("bun run dev:frontend")} ${color3.dim("the storefront, against the local API")}`,
499
- ` ${color3.cyan("bun run preflight")} ${color3.dim("what is still missing, and why it matters")}`
684
+ ` ${color4.cyan("bun install")} ${color4.dim("one install for both workspaces")}`,
685
+ ` ${color4.cyan("bun run maildev")} ${color4.dim("local inbox at http://localhost:1080")}`,
686
+ ` ${color4.cyan("bun run dev:api")} ${color4.dim("the worker, with the panel")}`,
687
+ ` ${color4.cyan("bun run dev:frontend")} ${color4.dim("the storefront, against the local API")}`,
688
+ ` ${color4.cyan("bun run preflight")} ${color4.dim("what is still missing, and why it matters")}`
500
689
  ];
501
690
  p2.outro(["Next:", ...next].join(`
502
691
  `));
@@ -505,7 +694,7 @@ async function init(args) {
505
694
 
506
695
  // src/regenerate.ts
507
696
  import * as p3 from "@clack/prompts";
508
- import color4 from "picocolors";
697
+ import color5 from "picocolors";
509
698
  async function generateCommand() {
510
699
  const project = await findProject();
511
700
  if (!project) {
@@ -521,8 +710,9 @@ async function generateCommand() {
521
710
  }
522
711
  const result = await generate(project.root, project.manifest);
523
712
  p3.log.step([
524
- ...result.written.map((file) => `${color4.green("+")} ${file}`),
525
- ...result.kept.map((file) => `${color4.dim("=")} ${file} ${color4.dim("(kept)")}`)
713
+ ...result.written.map((file) => `${color5.green("+")} ${file}`),
714
+ ...result.kept.map((file) => `${color5.dim("=")} ${file} ${color5.dim("(kept)")}`),
715
+ ...result.retired.map((file) => `${color5.red("-")} ${file} ${color5.dim("(retired — no longer generated)")}`)
526
716
  ].join(`
527
717
  `));
528
718
  return project.manifest.layout === "strict" ? finishStrict(project.root) : 0;
@@ -587,6 +777,7 @@ var EXTENDED = {
587
777
  preflight: { run: preflightCommand, help: preflightHelp },
588
778
  secrets: { run: secretsCliCommand, help: secretsHelp },
589
779
  keys: { run: keysCliCommand, help: keysHelp },
780
+ link: { run: linkCliCommand, help: linkHelp },
590
781
  guard: { run: guardCommand, help: async () => guardCommand() },
591
782
  dev: { run: appScript("dev"), help: appScriptHelp("dev") },
592
783
  build: { run: appScript("build"), help: appScriptHelp("build") },