@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.
- package/dist/catalog.js +1 -1
- package/dist/cli.js +98 -29
- package/dist/deploy/index.d.ts +18 -1
- package/dist/deploy/keys.d.ts +16 -0
- package/dist/deploy/secrets.d.ts +11 -0
- package/dist/generate/auth.d.ts +13 -0
- package/dist/{index-2qt2yh3z.js → index-j5xjh6qp.js} +114 -23
- package/dist/{index-844b3qn9.js → index-mnb7fz2t.js} +12 -1
- package/dist/{index-xx5p4b8d.js → index-tjy6yygc.js} +1 -1
- package/dist/{index-f1wds190.js → index-y6qzjd9h.js} +79 -14
- package/dist/index.js +4 -4
- package/dist/{keys-qwvt324e.js → keys-3zjezaxk.js} +10 -4
- package/dist/manifest.js +2 -2
- package/package.json +1 -1
- package/src/catalog.ts +12 -1
- package/src/deploy/index.ts +90 -20
- package/src/deploy/keys.ts +78 -24
- package/src/deploy/link.ts +26 -7
- package/src/deploy/secrets.ts +68 -0
- package/src/generate/auth.ts +34 -2
- package/src/generate/index.ts +16 -6
- package/src/generate/support.ts +67 -3
package/src/deploy/secrets.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/generate/auth.ts
CHANGED
|
@@ -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
|
-
//
|
|
105
|
-
|
|
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.
|
package/src/generate/index.ts
CHANGED
|
@@ -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
|
|
193
|
-
|
|
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\`.
|
|
376
|
-
#
|
|
377
|
-
#
|
|
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,
|
package/src/generate/support.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
283
|
-
*
|
|
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
|
+
`;
|