@saastemly/voidcommerce 0.6.0 → 0.8.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.
@@ -141,6 +141,26 @@ export function plaintextSecretNames(root: string): string[] {
141
141
  return bare;
142
142
  }
143
143
 
144
+ /**
145
+ * The declared values that are NOT encrypted, with their values.
146
+ *
147
+ * Separate from `plaintextSecretNames` on purpose: names are safe to print
148
+ * and values never are, so the function that returns values is the one you
149
+ * have to go looking for.
150
+ */
151
+ export function plaintextSecretEntries(root: string): Array<{ name: string; value: string }> {
152
+ const path = join(root, SECRETS_FILE);
153
+ if (!existsSync(path)) return [];
154
+ const out: Array<{ name: string; value: string }> = [];
155
+ for (const line of readFileSync(path, "utf8").split("\n")) {
156
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
157
+ if (!match || match[1]!.startsWith("DOTENV_")) continue;
158
+ const value = match[2]!.trim().replace(/^['"]|['"]$/g, "");
159
+ if (value && value !== "unset" && !value.startsWith("encrypted:")) out.push({ name: match[1]!, value });
160
+ }
161
+ return out;
162
+ }
163
+
144
164
  export interface DecryptedSecrets {
145
165
  /** A temp file in `.env` format, for `wrangler deploy --secrets-file`. */
146
166
  path: string;
@@ -230,6 +250,7 @@ export async function secretsCommand(project: Project, args: string[]): Promise<
230
250
 
231
251
  if (args.includes("--init")) return initSecrets(project);
232
252
  if (args[0] === "set") return setSecretValue(project, args.slice(1));
253
+ if (args.includes("--sync")) return syncSecrets(project);
233
254
 
234
255
  if (!existsSync(join(root, SECRETS_FILE))) {
235
256
  console.log(
@@ -446,3 +467,50 @@ function upsertLine(path: string, name: string, value: string): void {
446
467
  const pattern = new RegExp(`^\\s*${name}\\s*=.*$`, "m");
447
468
  writeFileSync(path, pattern.test(body) ? body.replace(pattern, `${name}=${value}`) : `${body.replace(/\n*$/, "\n")}${name}=${value}\n`);
448
469
  }
470
+
471
+
472
+ /**
473
+ * `vc secrets --sync` — add the keys this shop needs and does not declare.
474
+ *
475
+ * The manifest changes: a carrier is swapped, a plugin is added, and the set
476
+ * of required secrets moves with it. Without this the only way to declare a
477
+ * new one is to remember its exact name and type it in by hand, which is how
478
+ * a shop ends up deploying without a key it needed.
479
+ *
480
+ * Strictly additive. It never deletes a key that is no longer required,
481
+ * because "no longer required" and "safe to throw away" are different
482
+ * claims — the value may still be wanted, and it is not this command's to
483
+ * destroy. Extras are reported instead.
484
+ */
485
+ async function syncSecrets(project: Project): Promise<number> {
486
+ const root = project.root;
487
+ const path = join(root, SECRETS_FILE);
488
+ if (!existsSync(path)) {
489
+ console.error(`\nvc: no ${SECRETS_FILE} yet. \`vc secrets --init\` writes one.\n`);
490
+ return 1;
491
+ }
492
+ const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
493
+ const declared = declaredSecretNames(root);
494
+ const missing = required.filter((key) => !declared.has(key.key));
495
+ const extra = [...declared].filter((name) => !required.some((key) => key.key === name));
496
+
497
+ if (missing.length > 0) {
498
+ const body = readFileSync(path, "utf8").replace(/\n*$/, "\n");
499
+ const added = missing.flatMap((key) => ["", `# ${key.breaks}${key.where ? ` — from: ${key.where}` : ""}`, `${key.key}=unset`]);
500
+ writeFileSync(path, `${body}${added.join("\n")}\n`);
501
+ console.log(`\n${color.green("+")} ${missing.length} key${missing.length === 1 ? "" : "s"} added to ${SECRETS_FILE}, as \`unset\`:\n`);
502
+ for (const key of missing) console.log(` ${color.cyan(`vc secrets set ${key.key}`)} ${color.dim(key.where ?? "")}`);
503
+ console.log("");
504
+ } else {
505
+ console.log(`\n${color.green("✓")} every secret this shop needs is already declared.\n`);
506
+ }
507
+
508
+ if (extra.length > 0) {
509
+ console.log(
510
+ `${color.yellow("!")} ${extra.length} declared key${extra.length === 1 ? " is" : "s are"} no longer required by this shop:\n` +
511
+ ` ${extra.join("\n ")}\n\n` +
512
+ color.dim(" Left alone — they cost nothing, and deleting a value you may still want\n is not this command's call. Remove them by hand when you are sure.\n"),
513
+ );
514
+ }
515
+ return 0;
516
+ }
@@ -49,6 +49,20 @@ const social = (manifest: Manifest, id: string, envPrefix: string) =>
49
49
  ? ` ${id}: { clientId: must(env, "${envPrefix}_CLIENT_ID"), clientSecret: must(env, "${envPrefix}_CLIENT_SECRET") },`
50
50
  : null;
51
51
 
52
+ /**
53
+ * Carriers with a provider behind them, and whether each needs `lib/shipping.ts`.
54
+ *
55
+ * Exported because `generate/index.ts` has to know which shops get that file,
56
+ * and a second hand-maintained list of carrier ids WILL drift from this one —
57
+ * it already did: adding Royal Mail here left the file ungenerated, so the
58
+ * generated `auth.ts` imported a price table that did not exist.
59
+ *
60
+ * The token needs nothing: no origin, no tariff. Everything else needs one or
61
+ * the other, so the rule is simply "any carrier but the token".
62
+ */
63
+ export const WIRED_CARRIERS = ["royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"] as const;
64
+ export const needsShippingConfig = (id: string): boolean => WIRED_CARRIERS.includes(id as never) && id !== "nft";
65
+
52
66
  export function renderAuthTs(manifest: Manifest): string {
53
67
  const { shop } = manifest;
54
68
  const lines: Line[] = [];
@@ -68,6 +82,14 @@ export function renderAuthTs(manifest: Manifest): string {
68
82
  const CARRIER_CALLS: Record<string, { call: string; label: string; import: string }> = {
69
83
  // Every real carrier needs the ORIGIN it ships from, which is shop data
70
84
  // rather than a credential — so it lives in an owned lib/shipping.ts.
85
+ "royal-mail": {
86
+ label: "Royal Mail",
87
+ // No `from`: Click & Drop posts from the account's registered
88
+ // address, so an origin here would be ignored. `services` is the
89
+ // price table in lib/shipping.ts, because nothing quotes Royal Mail.
90
+ call: `royalMailProvider({ apiKey: must(env, "CLICK_DROP_AUTH_KEY"), services: royalMailServices, currency: "${shop.currency}", carrierName: "Royal Mail" })`,
91
+ import: `import { royalMailProvider } from "@saastemly/better-commerce/providers/royal-mail";`,
92
+ },
71
93
  "dhl-express": {
72
94
  label: "DHL Express",
73
95
  call: `dhlExpressProvider({ apiKey: must(env, "DHL_API_KEY"), apiSecret: must(env, "DHL_API_SECRET"), accountNumber: must(env, "DHL_ACCOUNT_NUMBER"), from: shipFrom })`,
@@ -101,8 +123,18 @@ export function renderAuthTs(manifest: Manifest): string {
101
123
  };
102
124
  const carriers = Object.keys(CARRIER_CALLS).filter((id) => has(manifest, id));
103
125
  const carrierImports = carriers.map((id) => CARRIER_CALLS[id]!.import);
104
- // The token needs no origin; every other carrier does.
105
- if (carriers.some((id) => id !== "nft")) carrierImports.push(`import { shipFrom } from "./lib/shipping.ts";`);
126
+ // One import from lib/shipping.ts, naming only what is actually used —
127
+ // an unused binding is a type error in a shop with `noUnusedLocals`.
128
+ //
129
+ // The token needs no origin. Royal Mail needs no origin either: Click &
130
+ // Drop posts from the address registered on the account, so a `from` here
131
+ // would be silently ignored. It needs the price table instead, because
132
+ // nothing quotes Royal Mail.
133
+ const shipping = [
134
+ ...(carriers.some((id) => id !== "nft" && id !== "royal-mail") ? ["shipFrom"] : []),
135
+ ...(carriers.includes("royal-mail") ? ["royalMailServices"] : []),
136
+ ];
137
+ if (shipping.length > 0) carrierImports.push(`import { ${shipping.join(", ")} } from "./lib/shipping.ts";`);
106
138
  const wantsNft = has(manifest, "nft");
107
139
  // One bridge for every carrier: which ships is decided by whose rate the
108
140
  // buyer paid for, so a token order mints and a parcel order posts.
@@ -3,7 +3,7 @@ import { existsSync, lstatSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { CHOICES } from "../catalog";
5
5
  import { MANIFEST_FILE, type Manifest, has, isSingleApp, oneOrigin, packagesOf, specifier, workerHosts, writeManifest, zone } from "../manifest";
6
- import { renderAuthTs } from "./auth";
6
+ import { renderAuthTs, WIRED_CARRIERS, needsShippingConfig } from "./auth";
7
7
  import {
8
8
  envSummary,
9
9
  renderEnvExample,
@@ -189,8 +189,10 @@ async function generateApi(root: string, dir: string, manifest: Manifest, result
189
189
  if (has(manifest, "nft")) {
190
190
  await put(root, at("lib/mint.ts"), renderMintTs(), result, own);
191
191
  }
192
- // Any carrier but the token quotes against an origin, which is shop data.
193
- if (["dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id))) {
192
+ // Any carrier but the token needs shop data of its own — an origin to quote
193
+ // against, or a price table. The list lives with the carriers themselves so
194
+ // it cannot drift from them.
195
+ if (WIRED_CARRIERS.some((id) => needsShippingConfig(id) && has(manifest, id))) {
194
196
  await put(root, at("lib/shipping.ts"), renderShippingTs(manifest), result, own);
195
197
  }
196
198
  if (has(manifest, "business-central")) {
@@ -372,9 +374,17 @@ async function generateStrictRoot(root: string, manifest: Manifest, result: Gene
372
374
  root,
373
375
  ".husky/pre-commit",
374
376
  `#!/usr/bin/env sh
375
- # Generated by \`vc init\`. Refuses a commit that would put a secret in the
376
- # clear in .env.secrets — which cannot be undone by a later commit,
377
- # because the value stays in the history.
377
+ # Generated by \`vc init\`.
378
+ #
379
+ # Encrypts any value sitting in the clear in .env.secrets, and re-stages the
380
+ # file so the COMMIT carries the ciphertext rather than what you staged.
381
+ # Encryption needs only the public key in that file, so this needs no
382
+ # credential and works on a fresh clone.
383
+ #
384
+ # It refuses only when it cannot fix the problem itself: no key yet
385
+ # (\`vc keys --init\`), or a .env.keys that is not gitignored. A secret
386
+ # committed in the clear cannot be un-committed — the value stays in the
387
+ # history and has to be treated as burned.
378
388
  bunx vc guard
379
389
  `,
380
390
  result,
@@ -274,13 +274,22 @@ export async function destinationFor(orderId: string): Promise<string | null> {
274
274
  * deploy, so both are marked rather than left blank.
275
275
  */
276
276
  export function renderShippingTs(manifest: Manifest): string {
277
- return `import type { DeliveryAddress } from "@saastemly/better-commerce/providers/delivery";
277
+ const royalMail = has(manifest, "royal-mail");
278
+ // Royal Mail posts from the address registered on the Click & Drop account,
279
+ // so an origin here would be ignored. Only carriers that QUOTE need one.
280
+ const origin = ["dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id));
278
281
 
282
+ const imports = [
283
+ ...(origin ? [`import type { DeliveryAddress } from "@saastemly/better-commerce/providers/delivery";`] : []),
284
+ ...(royalMail ? [`import type { RoyalMailService } from "@saastemly/better-commerce/providers/royal-mail";`] : []),
285
+ ];
286
+
287
+ const originBlock = `
279
288
  /**
280
289
  * The address every parcel is quoted and shipped FROM.
281
290
  *
282
- * TODO VERIFY: this is a placeholder. A carrier quotes against it, and DHL
283
- * refuses a shipment whose origin has no \`name\` and \`phone\`.
291
+ * TODO VERIFY: this is a placeholder. A carrier quotes against it, and most
292
+ * refuse a shipment whose origin has no \`name\` and \`phone\`.
284
293
  */
285
294
  export const shipFrom: DeliveryAddress = {
286
295
  name: "${manifest.shop.name}",
@@ -291,6 +300,8 @@ export const shipFrom: DeliveryAddress = {
291
300
  phone: "TODO VERIFY: +44…",
292
301
  };
293
302
  `;
303
+
304
+ return `${imports.join("\n")}\n${origin ? originBlock : ""}${royalMail ? ROYAL_MAIL_SERVICES() : ""}`;
294
305
  }
295
306
 
296
307
  export function renderErpTs(): string {
@@ -320,3 +331,56 @@ export const erpFulfillment = (currency: string): FulfillmentProvider =>
320
331
  }).fulfillment();
321
332
  `;
322
333
  }
334
+
335
+
336
+ /**
337
+ * Royal Mail's price table — YOUR prices, because nothing will quote them.
338
+ *
339
+ * This is the one carrier here that cannot be asked what postage costs. No
340
+ * Royal Mail API quotes rates: not Click & Drop, not Shipping V3, not
341
+ * anything on the developer portal. Click & Drop imports orders and prints
342
+ * labels; postage is charged afterwards at whatever your OBA agreement says.
343
+ *
344
+ * So the shop quotes from this table, and the table has to match the rate
345
+ * card on your account or you will undercharge every order and find out at
346
+ * the invoice. The codes below are real Royal Mail service codes; the prices
347
+ * are NOT — they are placeholders, in pence.
348
+ */
349
+ const ROYAL_MAIL_SERVICES = (): string => `
350
+ /**
351
+ * What this shop charges for Royal Mail, by total parcel weight.
352
+ *
353
+ * TODO VERIFY: every \`amount\` below is a placeholder in pence. Replace them
354
+ * with your own OBA rate card — Click & Drop → Settings → Shipping services
355
+ * lists exactly which services your account has, and the codes here must be
356
+ * among them. Service codes are account-specific: the published list is from
357
+ * 2021 and Royal Mail warn that "available services are unique to your OBA
358
+ * account and individual service agreements".
359
+ *
360
+ * The band whose \`maxGrams\` first covers the parcel wins, so order does not
361
+ * matter. A parcel heavier than every band gets no rate at all rather than a
362
+ * wrong one, and Click & Drop refuses anything over 30 kg.
363
+ */
364
+ export const royalMailServices: RoyalMailService[] = [
365
+ {
366
+ code: "TPN24",
367
+ name: "Tracked 24",
368
+ estimatedDays: 1,
369
+ bands: [
370
+ { maxGrams: 1000, amount: 0 /* TODO VERIFY */ },
371
+ { maxGrams: 2000, amount: 0 /* TODO VERIFY */ },
372
+ { maxGrams: 5000, amount: 0 /* TODO VERIFY */ },
373
+ ],
374
+ },
375
+ {
376
+ code: "TPS48",
377
+ name: "Tracked 48",
378
+ estimatedDays: 2,
379
+ bands: [
380
+ { maxGrams: 1000, amount: 0 /* TODO VERIFY */ },
381
+ { maxGrams: 2000, amount: 0 /* TODO VERIFY */ },
382
+ { maxGrams: 5000, amount: 0 /* TODO VERIFY */ },
383
+ ],
384
+ },
385
+ ];
386
+ `;