@saastemly/voidcommerce 0.13.0 → 0.15.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 +25 -5
- package/dist/deploy/preflight.d.ts +10 -1
- package/dist/deploy/secrets.d.ts +10 -0
- package/dist/{index-727eybzt.js → index-30y19qz5.js} +1 -1
- package/dist/{index-ze4z3n5q.js → index-jycz3x3g.js} +84 -43
- package/dist/{index-cxse24q6.js → index-s8se0x2d.js} +18 -3
- package/dist/{index-0je92d88.js → index-xyjhy6kp.js} +55 -6
- package/dist/index.js +4 -4
- package/dist/{keys-9efk9rdz.js → keys-6neyajsv.js} +3 -3
- package/dist/manifest.js +2 -2
- package/package.json +1 -1
- package/src/catalog.ts +43 -5
- package/src/deploy/index.ts +29 -1
- package/src/deploy/preflight.ts +20 -4
- package/src/deploy/secrets.ts +21 -0
- package/src/generate/index.ts +82 -26
- package/src/generate/support.ts +35 -20
package/dist/catalog.js
CHANGED
package/dist/cli.js
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
runVoid,
|
|
22
22
|
version,
|
|
23
23
|
voidAppsIn
|
|
24
|
-
} from "./index-
|
|
24
|
+
} from "./index-jycz3x3g.js";
|
|
25
25
|
import {
|
|
26
26
|
LOCAL_KEY_FILE,
|
|
27
27
|
PRIVATE_KEY_VAR,
|
|
@@ -50,7 +50,7 @@ import {
|
|
|
50
50
|
setVariable,
|
|
51
51
|
variableNames,
|
|
52
52
|
verifyCloudflareToken
|
|
53
|
-
} from "./index-
|
|
53
|
+
} from "./index-s8se0x2d.js";
|
|
54
54
|
import {
|
|
55
55
|
LAYOUTS,
|
|
56
56
|
oneOrigin,
|
|
@@ -58,10 +58,10 @@ import {
|
|
|
58
58
|
validate,
|
|
59
59
|
writeManifest,
|
|
60
60
|
zoneOf
|
|
61
|
-
} from "./index-
|
|
61
|
+
} from "./index-30y19qz5.js";
|
|
62
62
|
import {
|
|
63
63
|
GROUPS
|
|
64
|
-
} from "./index-
|
|
64
|
+
} from "./index-xyjhy6kp.js";
|
|
65
65
|
import {
|
|
66
66
|
__require
|
|
67
67
|
} from "./index-0v6na3yp.js";
|
|
@@ -255,11 +255,31 @@ async function preflightCommand(args) {
|
|
|
255
255
|
console.error("vc: no voidcommerce.json here.");
|
|
256
256
|
return 1;
|
|
257
257
|
}
|
|
258
|
-
const source = args.includes("--cloudflare") ? "wrangler" : "void";
|
|
258
|
+
const source = args.includes("--repo") ? "repository" : args.includes("--cloudflare") ? "wrangler" : "void";
|
|
259
259
|
const result = await preflight(project, source);
|
|
260
260
|
printPreflight(project, result, source);
|
|
261
|
+
if (source !== "repository")
|
|
262
|
+
return result.ready ? 0 : 1;
|
|
263
|
+
const pending = await uncommittedMigrations(project.root);
|
|
264
|
+
if (pending.length > 0) {
|
|
265
|
+
console.error(`${color2.red("✗")} ${pending.length} migration file${pending.length === 1 ? " is" : "s are"} not committed:
|
|
266
|
+
` + ` ${pending.join(`
|
|
267
|
+
`)}
|
|
268
|
+
|
|
269
|
+
` + ` The deploy applies what is in the repository. Commit these, or the
|
|
270
|
+
` + ` schema the shop runs on is not the one you tested.
|
|
271
|
+
`);
|
|
272
|
+
return 1;
|
|
273
|
+
}
|
|
261
274
|
return result.ready ? 0 : 1;
|
|
262
275
|
}
|
|
276
|
+
async function uncommittedMigrations(root) {
|
|
277
|
+
const status = await run("git", ["status", "--porcelain", "--", "migrations"], root);
|
|
278
|
+
if (status.code !== 0)
|
|
279
|
+
return [];
|
|
280
|
+
return status.out.split(`
|
|
281
|
+
`).map((line2) => line2.trim()).filter(Boolean).map((line2) => line2.replace(/^\S+\s+/, ""));
|
|
282
|
+
}
|
|
263
283
|
async function deployHelp() {
|
|
264
284
|
const captured = await captureVoid(["deploy", "--help"]);
|
|
265
285
|
const width = 80;
|
|
@@ -21,7 +21,16 @@ export interface Preflight {
|
|
|
21
21
|
bareSecrets: string[];
|
|
22
22
|
ready: boolean;
|
|
23
23
|
}
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Where a secret's presence is established.
|
|
26
|
+
*
|
|
27
|
+
* `repository` asks only what is committed, and is the right question before
|
|
28
|
+
* a PUSH: the deploy happens off this machine from what was pushed, so a
|
|
29
|
+
* check that needs local credentials is checking the wrong thing. It is also
|
|
30
|
+
* the only mode that works on a machine which has none — which, if config is
|
|
31
|
+
* properly in the environment, is every machine.
|
|
32
|
+
*/
|
|
33
|
+
export type SecretSource = "wrangler" | "void" | "repository";
|
|
25
34
|
/** Keys committed in .env.production — safe values only, by construction. */
|
|
26
35
|
export declare function productionEnv(appDir: string): Map<string, string>;
|
|
27
36
|
/** The worker's hostnames must be what the layout says. */
|
package/dist/deploy/secrets.d.ts
CHANGED
|
@@ -66,6 +66,16 @@ export declare function committedPublicKeyInto(root: string, publicKey: string):
|
|
|
66
66
|
* can say "all five are declared" on a machine that cannot read any of them.
|
|
67
67
|
*/
|
|
68
68
|
export declare function declaredSecretNames(root: string): Set<string>;
|
|
69
|
+
/**
|
|
70
|
+
* Declared names whose value is still the `unset` placeholder.
|
|
71
|
+
*
|
|
72
|
+
* Readable WITHOUT the key, because `unset` is plaintext — which is what
|
|
73
|
+
* lets a push-time gate tell "this shop has a Stripe key" from "this shop
|
|
74
|
+
* has a line that says STRIPE_SECRET_KEY". Preflight used to count the
|
|
75
|
+
* second as present, so a shop could pass every check and then fail to take
|
|
76
|
+
* money in production.
|
|
77
|
+
*/
|
|
78
|
+
export declare function unsetSecretNames(root: string): string[];
|
|
69
79
|
/** Is a declared value actually encrypted, or was it committed in the clear? */
|
|
70
80
|
export declare function plaintextSecretNames(root: string): string[];
|
|
71
81
|
/**
|
|
@@ -10,8 +10,9 @@ import {
|
|
|
10
10
|
renderEnvExample,
|
|
11
11
|
renderEnvLocal,
|
|
12
12
|
renderEnvProduction,
|
|
13
|
-
renderEnvTs
|
|
14
|
-
|
|
13
|
+
renderEnvTs,
|
|
14
|
+
unsetSecretNames
|
|
15
|
+
} from "./index-s8se0x2d.js";
|
|
15
16
|
import {
|
|
16
17
|
MANIFEST_FILE,
|
|
17
18
|
has,
|
|
@@ -24,10 +25,10 @@ import {
|
|
|
24
25
|
workerHosts,
|
|
25
26
|
writeManifest,
|
|
26
27
|
zone
|
|
27
|
-
} from "./index-
|
|
28
|
+
} from "./index-30y19qz5.js";
|
|
28
29
|
import {
|
|
29
30
|
CHOICES
|
|
30
|
-
} from "./index-
|
|
31
|
+
} from "./index-xyjhy6kp.js";
|
|
31
32
|
import {
|
|
32
33
|
__require
|
|
33
34
|
} from "./index-0v6na3yp.js";
|
|
@@ -1378,7 +1379,7 @@ import color from "picocolors";
|
|
|
1378
1379
|
// package.json
|
|
1379
1380
|
var package_default = {
|
|
1380
1381
|
name: "@saastemly/voidcommerce",
|
|
1381
|
-
version: "0.
|
|
1382
|
+
version: "0.15.0",
|
|
1382
1383
|
description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
|
|
1383
1384
|
type: "module",
|
|
1384
1385
|
license: "MIT",
|
|
@@ -2232,44 +2233,57 @@ function renderShippingTs(manifest) {
|
|
|
2232
2233
|
const parcel2go = has(manifest, "parcel2go");
|
|
2233
2234
|
const origin = ["parcel2go", "dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id));
|
|
2234
2235
|
const imports = [
|
|
2236
|
+
...origin || parcel2go ? [`import { env } from "void/env";`] : [],
|
|
2235
2237
|
...origin ? [`import type { DeliveryAddress } from "@saastemly/better-commerce/providers/delivery";`] : [],
|
|
2236
2238
|
...royalMail ? [`import type { RoyalMailService } from "@saastemly/better-commerce/providers/royal-mail";`] : []
|
|
2237
2239
|
];
|
|
2238
|
-
const
|
|
2240
|
+
const header = `
|
|
2239
2241
|
/**
|
|
2240
|
-
*
|
|
2242
|
+
* Where parcels come FROM, and who the carrier account belongs to.
|
|
2243
|
+
*
|
|
2244
|
+
* These are CONFIG, not code: they vary per deploy, they are nobody's
|
|
2245
|
+
* business but this shop's, and hard-coding them here would mean a fresh
|
|
2246
|
+
* clone of this repository shipped somebody else's address. So they come
|
|
2247
|
+
* from the environment — declared in env.ts, encrypted in .env.secrets, and
|
|
2248
|
+
* set with \`vc secrets set\`.
|
|
2241
2249
|
*
|
|
2242
|
-
*
|
|
2243
|
-
*
|
|
2250
|
+
* This file's job is to read them and fail loudly when one is missing, which
|
|
2251
|
+
* is the difference between a shop that refuses to quote and a shop that
|
|
2252
|
+
* quotes against the wrong postcode and loses money on every order.
|
|
2244
2253
|
*/
|
|
2254
|
+
const must = (key: string): string => {
|
|
2255
|
+
const value = (env as Record<string, unknown>)[key];
|
|
2256
|
+
if (typeof value !== "string" || !value || value === "unset") {
|
|
2257
|
+
throw new Error(\`shipping: \${key} is not set — \\\`vc secrets set \${key}\\\`\`);
|
|
2258
|
+
}
|
|
2259
|
+
return value;
|
|
2260
|
+
};
|
|
2261
|
+
`;
|
|
2262
|
+
const originBlock = `
|
|
2263
|
+
/** The address every parcel is quoted against, and the door a courier is sent to. */
|
|
2245
2264
|
export const shipFrom: DeliveryAddress = {
|
|
2246
|
-
name: "
|
|
2247
|
-
line1: "
|
|
2248
|
-
city: "
|
|
2249
|
-
postalCode: "
|
|
2265
|
+
name: must("SHIP_FROM_NAME"),
|
|
2266
|
+
line1: must("SHIP_FROM_LINE1"),
|
|
2267
|
+
city: must("SHIP_FROM_CITY"),
|
|
2268
|
+
postalCode: must("SHIP_FROM_POSTCODE"),
|
|
2250
2269
|
country: "${manifest.shop.country}",
|
|
2251
|
-
phone: "
|
|
2270
|
+
phone: must("SHIP_FROM_PHONE"),
|
|
2252
2271
|
};
|
|
2253
2272
|
`;
|
|
2254
2273
|
const parcel2goBlock = `
|
|
2255
2274
|
/**
|
|
2256
|
-
* Who the Parcel2Go account belongs to.
|
|
2257
|
-
*
|
|
2258
|
-
* Every order carries it: Parcel2Go books a courier to come to a real person
|
|
2259
|
-
* at a real door, and needs to know whose door. This is the ACCOUNT holder,
|
|
2260
|
-
* not the buyer — the buyer's details ride on the parcel.
|
|
2261
|
-
*
|
|
2262
|
-
* TODO VERIFY: these are placeholders.
|
|
2275
|
+
* Who the Parcel2Go account belongs to — the ACCOUNT holder, not the buyer.
|
|
2276
|
+
* Parcel2Go books a courier for a named person and rejects an order without.
|
|
2263
2277
|
*/
|
|
2264
2278
|
export const parcel2goCustomer = {
|
|
2265
|
-
email: "
|
|
2266
|
-
forename: "
|
|
2267
|
-
surname: "
|
|
2279
|
+
email: must("PARCEL2GO_CUSTOMER_EMAIL"),
|
|
2280
|
+
forename: must("PARCEL2GO_CUSTOMER_FORENAME"),
|
|
2281
|
+
surname: must("PARCEL2GO_CUSTOMER_SURNAME"),
|
|
2268
2282
|
};
|
|
2269
2283
|
`;
|
|
2270
2284
|
return `${imports.join(`
|
|
2271
2285
|
`)}
|
|
2272
|
-
${origin ? originBlock : ""}${parcel2go ? parcel2goBlock : ""}${royalMail ? ROYAL_MAIL_SERVICES() : ""}`;
|
|
2286
|
+
${origin || parcel2go ? header : ""}${origin ? originBlock : ""}${parcel2go ? parcel2goBlock : ""}${royalMail ? ROYAL_MAIL_SERVICES() : ""}`;
|
|
2273
2287
|
}
|
|
2274
2288
|
function renderErpTs() {
|
|
2275
2289
|
return `import { businessCentralProvider } from "@saastemly/better-commerce/providers/business-central";
|
|
@@ -2420,6 +2434,41 @@ async function link(root, file, target, result) {
|
|
|
2420
2434
|
}
|
|
2421
2435
|
}
|
|
2422
2436
|
var sorted = (record) => Object.fromEntries(Object.entries(record).sort());
|
|
2437
|
+
async function generateHooks(root, result) {
|
|
2438
|
+
await put(root, ".husky/pre-commit", `#!/usr/bin/env sh
|
|
2439
|
+
# Generated by \`vc init\`.
|
|
2440
|
+
#
|
|
2441
|
+
# Encrypts any value sitting in the clear in .env.secrets, and re-stages the
|
|
2442
|
+
# file so the COMMIT carries the ciphertext rather than what you staged.
|
|
2443
|
+
# Encryption needs only the public key in that file, so this needs no
|
|
2444
|
+
# credential and works on a fresh clone.
|
|
2445
|
+
#
|
|
2446
|
+
# It refuses only when it cannot fix the problem itself: no key yet
|
|
2447
|
+
# (\`vc keys --init\`), or a .env.keys that is not gitignored. A secret
|
|
2448
|
+
# committed in the clear cannot be un-committed — the value stays in the
|
|
2449
|
+
# history and has to be treated as burned.
|
|
2450
|
+
bunx vc guard
|
|
2451
|
+
`, result, "regenerate");
|
|
2452
|
+
await put(root, ".husky/pre-push", `#!/usr/bin/env sh
|
|
2453
|
+
# Generated by \`vc init\`.
|
|
2454
|
+
#
|
|
2455
|
+
# Pushing main deploys this shop, so the same checks the deploy would make
|
|
2456
|
+
# run here first — where you can read the error and fix it, rather than in a
|
|
2457
|
+
# build log after the shop is already updating.
|
|
2458
|
+
#
|
|
2459
|
+
# Needs no credentials: it reads only what is committed.
|
|
2460
|
+
# To push anyway, deliberately: git push --no-verify
|
|
2461
|
+
|
|
2462
|
+
while read -r _local_ref _local_sha remote_ref _remote_sha; do
|
|
2463
|
+
case "$remote_ref" in
|
|
2464
|
+
refs/heads/main|refs/heads/master)
|
|
2465
|
+
echo "vc: main is the deploy branch — checking what you are about to publish"
|
|
2466
|
+
bunx vc preflight --repo || exit 1
|
|
2467
|
+
;;
|
|
2468
|
+
esac
|
|
2469
|
+
done
|
|
2470
|
+
`, result, "regenerate");
|
|
2471
|
+
}
|
|
2423
2472
|
async function generate(root, manifest) {
|
|
2424
2473
|
const result = { written: [], kept: [], retired: [], packages: packagesOf(manifest) };
|
|
2425
2474
|
await writeManifest(root, manifest);
|
|
@@ -2440,6 +2489,7 @@ async function generate(root, manifest) {
|
|
|
2440
2489
|
await generateStrictApp(root, STRICT_APP, manifest, result);
|
|
2441
2490
|
break;
|
|
2442
2491
|
}
|
|
2492
|
+
await generateHooks(root, result);
|
|
2443
2493
|
return result;
|
|
2444
2494
|
}
|
|
2445
2495
|
async function generateApi(root, dir, manifest, result, opts) {
|
|
@@ -2460,7 +2510,7 @@ async function generateApi(root, dir, manifest, result, opts) {
|
|
|
2460
2510
|
await put(root, at("lib/mint.ts"), renderMintTs(), result, own);
|
|
2461
2511
|
}
|
|
2462
2512
|
if (WIRED_CARRIERS.some((id) => needsShippingConfig(id) && has(manifest, id))) {
|
|
2463
|
-
await put(root, at("lib/shipping.ts"), renderShippingTs(manifest), result,
|
|
2513
|
+
await put(root, at("lib/shipping.ts"), renderShippingTs(manifest), result, "regenerate");
|
|
2464
2514
|
}
|
|
2465
2515
|
if (has(manifest, "business-central")) {
|
|
2466
2516
|
await put(root, at("lib/erp.ts"), renderErpTs(), result, own);
|
|
@@ -2588,20 +2638,6 @@ dist
|
|
|
2588
2638
|
.DS_Store
|
|
2589
2639
|
`, result, "own");
|
|
2590
2640
|
await put(root, "patches/void@0.10.13.patch", renderVoidPatch(), result, "regenerate");
|
|
2591
|
-
await put(root, ".husky/pre-commit", `#!/usr/bin/env sh
|
|
2592
|
-
# Generated by \`vc init\`.
|
|
2593
|
-
#
|
|
2594
|
-
# Encrypts any value sitting in the clear in .env.secrets, and re-stages the
|
|
2595
|
-
# file so the COMMIT carries the ciphertext rather than what you staged.
|
|
2596
|
-
# Encryption needs only the public key in that file, so this needs no
|
|
2597
|
-
# credential and works on a fresh clone.
|
|
2598
|
-
#
|
|
2599
|
-
# It refuses only when it cannot fix the problem itself: no key yet
|
|
2600
|
-
# (\`vc keys --init\`), or a .env.keys that is not gitignored. A secret
|
|
2601
|
-
# committed in the clear cannot be un-committed — the value stays in the
|
|
2602
|
-
# history and has to be treated as burned.
|
|
2603
|
-
bunx vc guard
|
|
2604
|
-
`, result, "regenerate");
|
|
2605
2641
|
await put(root, ".github/workflows/deploy.yml", renderDistWorkflow(manifest), result, "regenerate");
|
|
2606
2642
|
await retire(root, ".github/workflows/void-dist.yml", result);
|
|
2607
2643
|
await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
|
|
@@ -3023,7 +3059,9 @@ function routeProblem(project) {
|
|
|
3023
3059
|
async function preflight(project, source) {
|
|
3024
3060
|
const present = productionEnv(project.appDir);
|
|
3025
3061
|
let remote = null;
|
|
3026
|
-
if (source === "
|
|
3062
|
+
if (source === "repository") {
|
|
3063
|
+
remote = null;
|
|
3064
|
+
} else if (source === "wrangler") {
|
|
3027
3065
|
const bin = findWrangler(project.appDir);
|
|
3028
3066
|
remote = bin ? await secretNames(bin, project.appDir) : null;
|
|
3029
3067
|
} else {
|
|
@@ -3031,8 +3069,11 @@ async function preflight(project, source) {
|
|
|
3031
3069
|
}
|
|
3032
3070
|
for (const name of remote ?? [])
|
|
3033
3071
|
present.set(name, "<secret>");
|
|
3034
|
-
|
|
3035
|
-
|
|
3072
|
+
const stillUnset = new Set(unsetSecretNames(project.root));
|
|
3073
|
+
for (const name of declaredSecretNames(project.root)) {
|
|
3074
|
+
if (!stillUnset.has(name))
|
|
3075
|
+
present.set(name, "<in the repository>");
|
|
3076
|
+
}
|
|
3036
3077
|
const missing = allEnvKeys(project.manifest).filter((key) => {
|
|
3037
3078
|
const value = present.get(key.key);
|
|
3038
3079
|
return value === undefined || value === "" || value === UNSET;
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
isApex,
|
|
5
5
|
oneOrigin,
|
|
6
6
|
zone
|
|
7
|
-
} from "./index-
|
|
7
|
+
} from "./index-30y19qz5.js";
|
|
8
8
|
import {
|
|
9
9
|
__require,
|
|
10
10
|
__toESM
|
|
@@ -342,6 +342,21 @@ function declaredSecretNames(root) {
|
|
|
342
342
|
}
|
|
343
343
|
return names;
|
|
344
344
|
}
|
|
345
|
+
function unsetSecretNames(root) {
|
|
346
|
+
const path = join2(root, SECRETS_FILE);
|
|
347
|
+
if (!existsSync2(path))
|
|
348
|
+
return [];
|
|
349
|
+
const out = [];
|
|
350
|
+
for (const line of readFileSync(path, "utf8").split(`
|
|
351
|
+
`)) {
|
|
352
|
+
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
353
|
+
if (!match || match[1].startsWith("DOTENV_"))
|
|
354
|
+
continue;
|
|
355
|
+
if (match[2].trim().replace(/^['"]|['"]$/g, "") === "unset")
|
|
356
|
+
out.push(match[1]);
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
345
360
|
function plaintextSecretNames(root) {
|
|
346
361
|
const path = join2(root, SECRETS_FILE);
|
|
347
362
|
if (!existsSync2(path))
|
|
@@ -389,7 +404,7 @@ async function decryptSecrets(project) {
|
|
|
389
404
|
}
|
|
390
405
|
const expected = committedPublicKey(root);
|
|
391
406
|
if (expected) {
|
|
392
|
-
const { publicKeyFor } = await import("./keys-
|
|
407
|
+
const { publicKeyFor } = await import("./keys-6neyajsv.js");
|
|
393
408
|
const derived = await publicKeyFor(privateKey);
|
|
394
409
|
if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
|
|
395
410
|
return {
|
|
@@ -916,4 +931,4 @@ ${color2.green("✓")} re-keyed under ${made.publicKey.slice(0, 20)}… and ${PR
|
|
|
916
931
|
return 0;
|
|
917
932
|
}
|
|
918
933
|
|
|
919
|
-
export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary, apiToken, ghAuth, repoSlug, setSecret, secretNames, setVariable, variableNames, verifyCloudflareToken, cloudflareAccounts, LOCAL_KEY_FILE, localPrivateKey, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, ignoresKeyFile, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, run2 as run, committedPublicKeyInto, declaredSecretNames, plaintextSecretNames, plaintextSecretEntries, decryptSecrets, secretsCommand, initSecrets, encryptInto };
|
|
934
|
+
export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary, apiToken, ghAuth, repoSlug, setSecret, secretNames, setVariable, variableNames, verifyCloudflareToken, cloudflareAccounts, LOCAL_KEY_FILE, localPrivateKey, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, ignoresKeyFile, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, run2 as run, committedPublicKeyInto, declaredSecretNames, unsetSecretNames, plaintextSecretNames, plaintextSecretEntries, decryptSecrets, secretsCommand, initSecrets, encryptInto };
|
|
@@ -447,14 +447,63 @@ var CARRIERS = {
|
|
|
447
447
|
recommended: true,
|
|
448
448
|
env: [
|
|
449
449
|
{ key: "PARCEL2GO_CLIENT_ID", breaks: "no rates are quoted and no collection is ever booked", where: "parcel2go.com/myaccount/api → Client Credentials" },
|
|
450
|
-
{ key: "PARCEL2GO_CLIENT_SECRET", breaks: "the same — and Parcel2Go will not show it twice, so keep it when you make it", where: "you choose this value when creating the credentials" }
|
|
450
|
+
{ key: "PARCEL2GO_CLIENT_SECRET", breaks: "the same — and Parcel2Go will not show it twice, so keep it when you make it", where: "you choose this value when creating the credentials" },
|
|
451
|
+
{ key: "PARCEL2GO_CUSTOMER_EMAIL", breaks: "Parcel2Go rejects every order: it books a courier for a named person and will not accept one without", where: "the email on your Parcel2Go account" },
|
|
452
|
+
{ key: "PARCEL2GO_CUSTOMER_FORENAME", breaks: "the same" },
|
|
453
|
+
{ key: "PARCEL2GO_CUSTOMER_SURNAME", breaks: "the same" },
|
|
454
|
+
{ key: "SHIP_FROM_NAME", breaks: "a courier has no one to ask for at the door, and most carriers refuse an origin with no name" },
|
|
455
|
+
{ key: "SHIP_FROM_LINE1", breaks: "the courier is sent to an incomplete address" },
|
|
456
|
+
{ key: "SHIP_FROM_CITY", breaks: "the same" },
|
|
457
|
+
{ key: "SHIP_FROM_POSTCODE", breaks: "every rate is quoted against the wrong place, so every order is mispriced" },
|
|
458
|
+
{ key: "SHIP_FROM_PHONE", breaks: "a driver who cannot find the door has no one to ring" }
|
|
451
459
|
]
|
|
452
460
|
},
|
|
453
|
-
{ id: "shippo", label: "Shippo", hint: "many carriers through one API", env: [
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
461
|
+
{ id: "shippo", label: "Shippo", hint: "many carriers through one API", env: [
|
|
462
|
+
{ key: "SHIP_FROM_NAME", breaks: "a courier has no one to ask for at the door, and most carriers refuse an origin with no name" },
|
|
463
|
+
{ key: "SHIP_FROM_LINE1", breaks: "the courier is sent to an incomplete address" },
|
|
464
|
+
{ key: "SHIP_FROM_CITY", breaks: "the same" },
|
|
465
|
+
{ key: "SHIP_FROM_POSTCODE", breaks: "every rate is quoted against the wrong place, so every order is mispriced" },
|
|
466
|
+
{ key: "SHIP_FROM_PHONE", breaks: "a driver who cannot find the door has no one to ring" },
|
|
467
|
+
{ key: "SHIPPO_API_TOKEN", breaks: "calculated rates fall back to flat" }
|
|
468
|
+
] },
|
|
469
|
+
{ id: "easypost", label: "EasyPost", hint: "the same, US-leaning", env: [
|
|
470
|
+
{ key: "SHIP_FROM_NAME", breaks: "a courier has no one to ask for at the door, and most carriers refuse an origin with no name" },
|
|
471
|
+
{ key: "SHIP_FROM_LINE1", breaks: "the courier is sent to an incomplete address" },
|
|
472
|
+
{ key: "SHIP_FROM_CITY", breaks: "the same" },
|
|
473
|
+
{ key: "SHIP_FROM_POSTCODE", breaks: "every rate is quoted against the wrong place, so every order is mispriced" },
|
|
474
|
+
{ key: "SHIP_FROM_PHONE", breaks: "a driver who cannot find the door has no one to ring" },
|
|
475
|
+
{ key: "EASYPOST_API_KEY", breaks: "calculated rates fall back to flat" }
|
|
476
|
+
] },
|
|
477
|
+
{ id: "ups", label: "UPS", hint: "your own UPS account, for negotiated rates an aggregator cannot see", env: [
|
|
478
|
+
{ key: "SHIP_FROM_NAME", breaks: "a courier has no one to ask for at the door, and most carriers refuse an origin with no name" },
|
|
479
|
+
{ key: "SHIP_FROM_LINE1", breaks: "the courier is sent to an incomplete address" },
|
|
480
|
+
{ key: "SHIP_FROM_CITY", breaks: "the same" },
|
|
481
|
+
{ key: "SHIP_FROM_POSTCODE", breaks: "every rate is quoted against the wrong place, so every order is mispriced" },
|
|
482
|
+
{ key: "SHIP_FROM_PHONE", breaks: "a driver who cannot find the door has no one to ring" },
|
|
483
|
+
{ key: "UPS_CLIENT_ID", breaks: "UPS rates are not quoted" },
|
|
484
|
+
{ key: "UPS_CLIENT_SECRET", breaks: "the same" },
|
|
485
|
+
{ key: "UPS_ACCOUNT_NUMBER", breaks: "UPS has no account to bill the transport to" }
|
|
486
|
+
] },
|
|
487
|
+
{ id: "fedex", label: "FedEx", hint: "your own FedEx account, for negotiated rates an aggregator cannot see", env: [
|
|
488
|
+
{ key: "SHIP_FROM_NAME", breaks: "a courier has no one to ask for at the door, and most carriers refuse an origin with no name" },
|
|
489
|
+
{ key: "SHIP_FROM_LINE1", breaks: "the courier is sent to an incomplete address" },
|
|
490
|
+
{ key: "SHIP_FROM_CITY", breaks: "the same" },
|
|
491
|
+
{ key: "SHIP_FROM_POSTCODE", breaks: "every rate is quoted against the wrong place, so every order is mispriced" },
|
|
492
|
+
{ key: "SHIP_FROM_PHONE", breaks: "a driver who cannot find the door has no one to ring" },
|
|
493
|
+
{ key: "FEDEX_CLIENT_ID", breaks: "FedEx rates are not quoted" },
|
|
494
|
+
{ key: "FEDEX_CLIENT_SECRET", breaks: "the same" },
|
|
495
|
+
{ key: "FEDEX_ACCOUNT_NUMBER", breaks: "FedEx has no account to bill the transport to" }
|
|
496
|
+
] },
|
|
497
|
+
{ id: "dhl-express", label: "DHL Express", hint: "international express, and the one worth having for a shop that posts abroad", env: [
|
|
498
|
+
{ key: "SHIP_FROM_NAME", breaks: "a courier has no one to ask for at the door, and most carriers refuse an origin with no name" },
|
|
499
|
+
{ key: "SHIP_FROM_LINE1", breaks: "the courier is sent to an incomplete address" },
|
|
500
|
+
{ key: "SHIP_FROM_CITY", breaks: "the same" },
|
|
501
|
+
{ key: "SHIP_FROM_POSTCODE", breaks: "every rate is quoted against the wrong place, so every order is mispriced" },
|
|
502
|
+
{ key: "SHIP_FROM_PHONE", breaks: "a driver who cannot find the door has no one to ring" },
|
|
503
|
+
{ key: "DHL_API_KEY", breaks: "DHL rates are not quoted" },
|
|
504
|
+
{ key: "DHL_API_SECRET", breaks: "the same" },
|
|
505
|
+
{ key: "DHL_ACCOUNT_NUMBER", breaks: "DHL has no account to bill the transport to" }
|
|
506
|
+
] },
|
|
458
507
|
{ id: "usps", label: "USPS", hint: "US domestic post — the cheapest way to ship a small parcel inside the US", env: [{ key: "USPS_CLIENT_ID", breaks: "USPS rates are not quoted" }, { key: "USPS_CLIENT_SECRET", breaks: "the same" }] },
|
|
459
508
|
{
|
|
460
509
|
id: "royal-mail",
|
package/dist/index.js
CHANGED
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
routeProblem,
|
|
53
53
|
strictDependencies,
|
|
54
54
|
upsertJsonc
|
|
55
|
-
} from "./index-
|
|
55
|
+
} from "./index-jycz3x3g.js";
|
|
56
56
|
import {
|
|
57
57
|
allEnvKeys,
|
|
58
58
|
envSummary,
|
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
renderEnvLocal,
|
|
61
61
|
renderEnvProduction,
|
|
62
62
|
renderEnvTs
|
|
63
|
-
} from "./index-
|
|
63
|
+
} from "./index-s8se0x2d.js";
|
|
64
64
|
import {
|
|
65
65
|
LAYOUTS,
|
|
66
66
|
MANIFEST_FILE,
|
|
@@ -81,7 +81,7 @@ import {
|
|
|
81
81
|
writeManifest,
|
|
82
82
|
zone,
|
|
83
83
|
zoneOf
|
|
84
|
-
} from "./index-
|
|
84
|
+
} from "./index-30y19qz5.js";
|
|
85
85
|
import {
|
|
86
86
|
AUTH_PLUGINS,
|
|
87
87
|
CARRIERS,
|
|
@@ -98,7 +98,7 @@ import {
|
|
|
98
98
|
SIGN_IN,
|
|
99
99
|
TAX,
|
|
100
100
|
UI
|
|
101
|
-
} from "./index-
|
|
101
|
+
} from "./index-xyjhy6kp.js";
|
|
102
102
|
import"./index-0v6na3yp.js";
|
|
103
103
|
export {
|
|
104
104
|
zoneOf,
|
|
@@ -8,9 +8,9 @@ import {
|
|
|
8
8
|
localPrivateKey,
|
|
9
9
|
provisionKey,
|
|
10
10
|
publicKeyFor
|
|
11
|
-
} from "./index-
|
|
12
|
-
import"./index-
|
|
13
|
-
import"./index-
|
|
11
|
+
} from "./index-s8se0x2d.js";
|
|
12
|
+
import"./index-30y19qz5.js";
|
|
13
|
+
import"./index-xyjhy6kp.js";
|
|
14
14
|
import"./index-0v6na3yp.js";
|
|
15
15
|
export {
|
|
16
16
|
publicKeyFor,
|
package/dist/manifest.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saastemly/voidcommerce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/catalog.ts
CHANGED
|
@@ -521,13 +521,51 @@ export const CARRIERS: Group = {
|
|
|
521
521
|
env: [
|
|
522
522
|
{ key: "PARCEL2GO_CLIENT_ID", breaks: "no rates are quoted and no collection is ever booked", where: "parcel2go.com/myaccount/api → Client Credentials" },
|
|
523
523
|
{ key: "PARCEL2GO_CLIENT_SECRET", breaks: "the same — and Parcel2Go will not show it twice, so keep it when you make it", where: "you choose this value when creating the credentials" },
|
|
524
|
+
{ key: "PARCEL2GO_CUSTOMER_EMAIL", breaks: "Parcel2Go rejects every order: it books a courier for a named person and will not accept one without", where: "the email on your Parcel2Go account" },
|
|
525
|
+
{ key: "PARCEL2GO_CUSTOMER_FORENAME", breaks: "the same" },
|
|
526
|
+
{ key: "PARCEL2GO_CUSTOMER_SURNAME", breaks: "the same" },
|
|
527
|
+
{ key: "SHIP_FROM_NAME", breaks: "a courier has no one to ask for at the door, and most carriers refuse an origin with no name" },
|
|
528
|
+
{ key: "SHIP_FROM_LINE1", breaks: "the courier is sent to an incomplete address" },
|
|
529
|
+
{ key: "SHIP_FROM_CITY", breaks: "the same" },
|
|
530
|
+
{ key: "SHIP_FROM_POSTCODE", breaks: "every rate is quoted against the wrong place, so every order is mispriced" },
|
|
531
|
+
{ key: "SHIP_FROM_PHONE", breaks: "a driver who cannot find the door has no one to ring" },
|
|
524
532
|
],
|
|
525
533
|
},
|
|
526
|
-
{ id: "shippo", label: "Shippo", hint: "many carriers through one API", env: [
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
534
|
+
{ id: "shippo", label: "Shippo", hint: "many carriers through one API", env: [
|
|
535
|
+
{ key: "SHIP_FROM_NAME", breaks: "a courier has no one to ask for at the door, and most carriers refuse an origin with no name" },
|
|
536
|
+
{ key: "SHIP_FROM_LINE1", breaks: "the courier is sent to an incomplete address" },
|
|
537
|
+
{ key: "SHIP_FROM_CITY", breaks: "the same" },
|
|
538
|
+
{ key: "SHIP_FROM_POSTCODE", breaks: "every rate is quoted against the wrong place, so every order is mispriced" },
|
|
539
|
+
{ key: "SHIP_FROM_PHONE", breaks: "a driver who cannot find the door has no one to ring" },
|
|
540
|
+
{ key: "SHIPPO_API_TOKEN", breaks: "calculated rates fall back to flat" }] },
|
|
541
|
+
{ id: "easypost", label: "EasyPost", hint: "the same, US-leaning", env: [
|
|
542
|
+
{ key: "SHIP_FROM_NAME", breaks: "a courier has no one to ask for at the door, and most carriers refuse an origin with no name" },
|
|
543
|
+
{ key: "SHIP_FROM_LINE1", breaks: "the courier is sent to an incomplete address" },
|
|
544
|
+
{ key: "SHIP_FROM_CITY", breaks: "the same" },
|
|
545
|
+
{ key: "SHIP_FROM_POSTCODE", breaks: "every rate is quoted against the wrong place, so every order is mispriced" },
|
|
546
|
+
{ key: "SHIP_FROM_PHONE", breaks: "a driver who cannot find the door has no one to ring" },
|
|
547
|
+
{ key: "EASYPOST_API_KEY", breaks: "calculated rates fall back to flat" }] },
|
|
548
|
+
{ id: "ups", label: "UPS", hint: "your own UPS account, for negotiated rates an aggregator cannot see", env: [
|
|
549
|
+
{ key: "SHIP_FROM_NAME", breaks: "a courier has no one to ask for at the door, and most carriers refuse an origin with no name" },
|
|
550
|
+
{ key: "SHIP_FROM_LINE1", breaks: "the courier is sent to an incomplete address" },
|
|
551
|
+
{ key: "SHIP_FROM_CITY", breaks: "the same" },
|
|
552
|
+
{ key: "SHIP_FROM_POSTCODE", breaks: "every rate is quoted against the wrong place, so every order is mispriced" },
|
|
553
|
+
{ key: "SHIP_FROM_PHONE", breaks: "a driver who cannot find the door has no one to ring" },
|
|
554
|
+
{ key: "UPS_CLIENT_ID", breaks: "UPS rates are not quoted" }, { key: "UPS_CLIENT_SECRET", breaks: "the same" }, { key: "UPS_ACCOUNT_NUMBER", breaks: "UPS has no account to bill the transport to" }] },
|
|
555
|
+
{ id: "fedex", label: "FedEx", hint: "your own FedEx account, for negotiated rates an aggregator cannot see", env: [
|
|
556
|
+
{ key: "SHIP_FROM_NAME", breaks: "a courier has no one to ask for at the door, and most carriers refuse an origin with no name" },
|
|
557
|
+
{ key: "SHIP_FROM_LINE1", breaks: "the courier is sent to an incomplete address" },
|
|
558
|
+
{ key: "SHIP_FROM_CITY", breaks: "the same" },
|
|
559
|
+
{ key: "SHIP_FROM_POSTCODE", breaks: "every rate is quoted against the wrong place, so every order is mispriced" },
|
|
560
|
+
{ key: "SHIP_FROM_PHONE", breaks: "a driver who cannot find the door has no one to ring" },
|
|
561
|
+
{ key: "FEDEX_CLIENT_ID", breaks: "FedEx rates are not quoted" }, { key: "FEDEX_CLIENT_SECRET", breaks: "the same" }, { key: "FEDEX_ACCOUNT_NUMBER", breaks: "FedEx has no account to bill the transport to" }] },
|
|
562
|
+
{ id: "dhl-express", label: "DHL Express", hint: "international express, and the one worth having for a shop that posts abroad", env: [
|
|
563
|
+
{ key: "SHIP_FROM_NAME", breaks: "a courier has no one to ask for at the door, and most carriers refuse an origin with no name" },
|
|
564
|
+
{ key: "SHIP_FROM_LINE1", breaks: "the courier is sent to an incomplete address" },
|
|
565
|
+
{ key: "SHIP_FROM_CITY", breaks: "the same" },
|
|
566
|
+
{ key: "SHIP_FROM_POSTCODE", breaks: "every rate is quoted against the wrong place, so every order is mispriced" },
|
|
567
|
+
{ key: "SHIP_FROM_PHONE", breaks: "a driver who cannot find the door has no one to ring" },
|
|
568
|
+
{ key: "DHL_API_KEY", breaks: "DHL rates are not quoted" }, { key: "DHL_API_SECRET", breaks: "the same" }, { key: "DHL_ACCOUNT_NUMBER", breaks: "DHL has no account to bill the transport to" }] },
|
|
531
569
|
{ id: "usps", label: "USPS", hint: "US domestic post — the cheapest way to ship a small parcel inside the US", env: [{ key: "USPS_CLIENT_ID", breaks: "USPS rates are not quoted" }, { key: "USPS_CLIENT_SECRET", breaks: "the same" }] },
|
|
532
570
|
{
|
|
533
571
|
id: "royal-mail",
|
package/src/deploy/index.ts
CHANGED
|
@@ -61,12 +61,40 @@ export async function preflightCommand(args: string[]): Promise<number> {
|
|
|
61
61
|
console.error("vc: no voidcommerce.json here.");
|
|
62
62
|
return 1;
|
|
63
63
|
}
|
|
64
|
-
const source = args.includes("--cloudflare") ? "wrangler" : "void";
|
|
64
|
+
const source = args.includes("--repo") ? "repository" : args.includes("--cloudflare") ? "wrangler" : "void";
|
|
65
65
|
const result = await preflight(project, source);
|
|
66
66
|
printPreflight(project, result, source);
|
|
67
|
+
if (source !== "repository") return result.ready ? 0 : 1;
|
|
68
|
+
|
|
69
|
+
// A repository check is a check on what will be PUSHED, so it also asks
|
|
70
|
+
// the question CI would ask and cannot answer any earlier: is there a
|
|
71
|
+
// migration that was generated and never committed? Applied under a fresh
|
|
72
|
+
// name on the next run, against tables that already exist, it fails the
|
|
73
|
+
// deploy — and it fails it after the shop is already down for the update.
|
|
74
|
+
const pending = await uncommittedMigrations(project.root);
|
|
75
|
+
if (pending.length > 0) {
|
|
76
|
+
console.error(
|
|
77
|
+
`${color.red("✗")} ${pending.length} migration file${pending.length === 1 ? " is" : "s are"} not committed:\n` +
|
|
78
|
+
` ${pending.join("\n ")}\n\n` +
|
|
79
|
+
" The deploy applies what is in the repository. Commit these, or the\n" +
|
|
80
|
+
" schema the shop runs on is not the one you tested.\n",
|
|
81
|
+
);
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
67
84
|
return result.ready ? 0 : 1;
|
|
68
85
|
}
|
|
69
86
|
|
|
87
|
+
/** Migration files git does not have — untracked or modified. */
|
|
88
|
+
async function uncommittedMigrations(root: string): Promise<string[]> {
|
|
89
|
+
const status = await run("git", ["status", "--porcelain", "--", "migrations"], root);
|
|
90
|
+
if (status.code !== 0) return [];
|
|
91
|
+
return status.out
|
|
92
|
+
.split("\n")
|
|
93
|
+
.map((line) => line.trim())
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.map((line) => line.replace(/^\S+\s+/, ""));
|
|
96
|
+
}
|
|
97
|
+
|
|
70
98
|
/** void's deploy help, then what vc adds. */
|
|
71
99
|
export async function deployHelp(): Promise<number> {
|
|
72
100
|
const captured = await captureVoid(["deploy", "--help"]);
|
package/src/deploy/preflight.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { workerHosts } from "../manifest";
|
|
|
7
7
|
import type { Project } from "../project";
|
|
8
8
|
import { captureVoid } from "../void";
|
|
9
9
|
import { parseJsonc } from "./jsonc";
|
|
10
|
-
import { SECRETS_FILE, declaredSecretNames, plaintextSecretNames } from "./secrets";
|
|
10
|
+
import { SECRETS_FILE, declaredSecretNames, plaintextSecretNames, unsetSecretNames } from "./secrets";
|
|
11
11
|
import { findWrangler, secretNames } from "./wrangler";
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -34,7 +34,16 @@ export interface Preflight {
|
|
|
34
34
|
ready: boolean;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Where a secret's presence is established.
|
|
39
|
+
*
|
|
40
|
+
* `repository` asks only what is committed, and is the right question before
|
|
41
|
+
* a PUSH: the deploy happens off this machine from what was pushed, so a
|
|
42
|
+
* check that needs local credentials is checking the wrong thing. It is also
|
|
43
|
+
* the only mode that works on a machine which has none — which, if config is
|
|
44
|
+
* properly in the environment, is every machine.
|
|
45
|
+
*/
|
|
46
|
+
export type SecretSource = "wrangler" | "void" | "repository";
|
|
38
47
|
|
|
39
48
|
/** Keys committed in .env.production — safe values only, by construction. */
|
|
40
49
|
export function productionEnv(appDir: string): Map<string, string> {
|
|
@@ -81,7 +90,9 @@ export function routeProblem(project: Project): string | null {
|
|
|
81
90
|
export async function preflight(project: Project, source: SecretSource): Promise<Preflight> {
|
|
82
91
|
const present = productionEnv(project.appDir);
|
|
83
92
|
let remote: Set<string> | null = null;
|
|
84
|
-
if (source === "
|
|
93
|
+
if (source === "repository") {
|
|
94
|
+
remote = null;
|
|
95
|
+
} else if (source === "wrangler") {
|
|
85
96
|
const bin = findWrangler(project.appDir);
|
|
86
97
|
remote = bin ? await secretNames(bin, project.appDir) : null;
|
|
87
98
|
} else {
|
|
@@ -94,7 +105,12 @@ export async function preflight(project: Project, source: SecretSource): Promise
|
|
|
94
105
|
* uploads it. The names are readable without the key — dotenvx encrypts
|
|
95
106
|
* values, not keys — so this works on a machine that cannot decrypt.
|
|
96
107
|
*/
|
|
97
|
-
|
|
108
|
+
const stillUnset = new Set(unsetSecretNames(project.root));
|
|
109
|
+
for (const name of declaredSecretNames(project.root)) {
|
|
110
|
+
// A name is not a value. `unset` is the documented placeholder, so a
|
|
111
|
+
// key carrying it is declared and absent at the same time.
|
|
112
|
+
if (!stillUnset.has(name)) present.set(name, "<in the repository>");
|
|
113
|
+
}
|
|
98
114
|
|
|
99
115
|
const missing = allEnvKeys(project.manifest).filter((key) => {
|
|
100
116
|
const value = present.get(key.key);
|
package/src/deploy/secrets.ts
CHANGED
|
@@ -126,6 +126,27 @@ export function declaredSecretNames(root: string): Set<string> {
|
|
|
126
126
|
return names;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Declared names whose value is still the `unset` placeholder.
|
|
131
|
+
*
|
|
132
|
+
* Readable WITHOUT the key, because `unset` is plaintext — which is what
|
|
133
|
+
* lets a push-time gate tell "this shop has a Stripe key" from "this shop
|
|
134
|
+
* has a line that says STRIPE_SECRET_KEY". Preflight used to count the
|
|
135
|
+
* second as present, so a shop could pass every check and then fail to take
|
|
136
|
+
* money in production.
|
|
137
|
+
*/
|
|
138
|
+
export function unsetSecretNames(root: string): string[] {
|
|
139
|
+
const path = join(root, SECRETS_FILE);
|
|
140
|
+
if (!existsSync(path)) return [];
|
|
141
|
+
const out: string[] = [];
|
|
142
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
143
|
+
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
144
|
+
if (!match || match[1]!.startsWith("DOTENV_")) continue;
|
|
145
|
+
if (match[2]!.trim().replace(/^['"]|['"]$/g, "") === "unset") out.push(match[1]!);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
129
150
|
/** Is a declared value actually encrypted, or was it committed in the clear? */
|
|
130
151
|
export function plaintextSecretNames(root: string): string[] {
|
|
131
152
|
const path = join(root, SECRETS_FILE);
|
package/src/generate/index.ts
CHANGED
|
@@ -195,6 +195,82 @@ async function link(root: string, file: string, target: string, result: Generate
|
|
|
195
195
|
|
|
196
196
|
const sorted = (record: Record<string, string>) => Object.fromEntries(Object.entries(record).sort());
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Git hooks, for every layout.
|
|
200
|
+
*
|
|
201
|
+
* They guard `.env.secrets`, which exists whatever the layout, so making
|
|
202
|
+
* them strict-only left an `app` shop able to commit a credential in the
|
|
203
|
+
* clear with nothing to stop it. Installed by husky on `bun install`, so a
|
|
204
|
+
* fresh clone is guarded without anyone remembering.
|
|
205
|
+
*/
|
|
206
|
+
async function generateHooks(root: string, result: GenerateResult) {
|
|
207
|
+
/**
|
|
208
|
+
* The hook lives in `.husky/`, which is COMMITTED, so the guard travels
|
|
209
|
+
* with the repository instead of being something each clone remembers to
|
|
210
|
+
* install. A secret committed in the clear cannot be un-committed.
|
|
211
|
+
*/
|
|
212
|
+
await put(
|
|
213
|
+
root,
|
|
214
|
+
".husky/pre-commit",
|
|
215
|
+
`#!/usr/bin/env sh
|
|
216
|
+
# Generated by \`vc init\`.
|
|
217
|
+
#
|
|
218
|
+
# Encrypts any value sitting in the clear in .env.secrets, and re-stages the
|
|
219
|
+
# file so the COMMIT carries the ciphertext rather than what you staged.
|
|
220
|
+
# Encryption needs only the public key in that file, so this needs no
|
|
221
|
+
# credential and works on a fresh clone.
|
|
222
|
+
#
|
|
223
|
+
# It refuses only when it cannot fix the problem itself: no key yet
|
|
224
|
+
# (\`vc keys --init\`), or a .env.keys that is not gitignored. A secret
|
|
225
|
+
# committed in the clear cannot be un-committed — the value stays in the
|
|
226
|
+
# history and has to be treated as burned.
|
|
227
|
+
bunx vc guard
|
|
228
|
+
`,
|
|
229
|
+
result,
|
|
230
|
+
"regenerate",
|
|
231
|
+
);
|
|
232
|
+
/**
|
|
233
|
+
* Pushing main IS the deploy, so this is the last moment anything is
|
|
234
|
+
* checked by someone who can read the error.
|
|
235
|
+
*
|
|
236
|
+
* Everything the deploy needs is in the repository — that is the whole
|
|
237
|
+
* point of keeping config in the environment — so the check needs no
|
|
238
|
+
* credentials and runs anywhere. It asks the questions CI cannot answer
|
|
239
|
+
* until it is too late to matter: is every required key actually SET
|
|
240
|
+
* rather than merely declared, is anything committed in the clear, do the
|
|
241
|
+
* hostnames match the manifest, is a migration missing.
|
|
242
|
+
*
|
|
243
|
+
* Only for main. Pushing a branch is not a deploy, and gating it would
|
|
244
|
+
* teach people to reach for --no-verify, which turns the gate off for the
|
|
245
|
+
* push that does matter.
|
|
246
|
+
*/
|
|
247
|
+
await put(
|
|
248
|
+
root,
|
|
249
|
+
".husky/pre-push",
|
|
250
|
+
`#!/usr/bin/env sh
|
|
251
|
+
# Generated by \`vc init\`.
|
|
252
|
+
#
|
|
253
|
+
# Pushing main deploys this shop, so the same checks the deploy would make
|
|
254
|
+
# run here first — where you can read the error and fix it, rather than in a
|
|
255
|
+
# build log after the shop is already updating.
|
|
256
|
+
#
|
|
257
|
+
# Needs no credentials: it reads only what is committed.
|
|
258
|
+
# To push anyway, deliberately: git push --no-verify
|
|
259
|
+
|
|
260
|
+
while read -r _local_ref _local_sha remote_ref _remote_sha; do
|
|
261
|
+
case "$remote_ref" in
|
|
262
|
+
refs/heads/main|refs/heads/master)
|
|
263
|
+
echo "vc: main is the deploy branch — checking what you are about to publish"
|
|
264
|
+
bunx vc preflight --repo || exit 1
|
|
265
|
+
;;
|
|
266
|
+
esac
|
|
267
|
+
done
|
|
268
|
+
`,
|
|
269
|
+
result,
|
|
270
|
+
"regenerate",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
198
274
|
export async function generate(root: string, manifest: Manifest): Promise<GenerateResult> {
|
|
199
275
|
const result: GenerateResult = { written: [], kept: [], retired: [], packages: packagesOf(manifest) };
|
|
200
276
|
|
|
@@ -218,6 +294,7 @@ export async function generate(root: string, manifest: Manifest): Promise<Genera
|
|
|
218
294
|
await generateStrictApp(root, STRICT_APP, manifest, result);
|
|
219
295
|
break;
|
|
220
296
|
}
|
|
297
|
+
await generateHooks(root, result);
|
|
221
298
|
return result;
|
|
222
299
|
}
|
|
223
300
|
|
|
@@ -248,7 +325,11 @@ async function generateApi(root: string, dir: string, manifest: Manifest, result
|
|
|
248
325
|
// against, or a price table. The list lives with the carriers themselves so
|
|
249
326
|
// it cannot drift from them.
|
|
250
327
|
if (WIRED_CARRIERS.some((id) => needsShippingConfig(id) && has(manifest, id))) {
|
|
251
|
-
|
|
328
|
+
// REGENERATED, not owned. It used to carry the shop's address as
|
|
329
|
+
// constants, which made it config and so nobody's to overwrite. Now it
|
|
330
|
+
// only READS that config from the environment, so it is ordinary code
|
|
331
|
+
// and should receive fixes like any other generated file.
|
|
332
|
+
await put(root, at("lib/shipping.ts"), renderShippingTs(manifest), result, "regenerate");
|
|
252
333
|
}
|
|
253
334
|
if (has(manifest, "business-central")) {
|
|
254
335
|
await put(root, at("lib/erp.ts"), renderErpTs(), result, own);
|
|
@@ -420,31 +501,6 @@ async function generateStrictRoot(root: string, manifest: Manifest, result: Gene
|
|
|
420
501
|
"own",
|
|
421
502
|
);
|
|
422
503
|
await put(root, "patches/void@0.10.13.patch", renderVoidPatch(), result, "regenerate");
|
|
423
|
-
/**
|
|
424
|
-
* The hook lives in `.husky/`, which is COMMITTED, so the guard travels
|
|
425
|
-
* with the repository instead of being something each clone remembers to
|
|
426
|
-
* install. A secret committed in the clear cannot be un-committed.
|
|
427
|
-
*/
|
|
428
|
-
await put(
|
|
429
|
-
root,
|
|
430
|
-
".husky/pre-commit",
|
|
431
|
-
`#!/usr/bin/env sh
|
|
432
|
-
# Generated by \`vc init\`.
|
|
433
|
-
#
|
|
434
|
-
# Encrypts any value sitting in the clear in .env.secrets, and re-stages the
|
|
435
|
-
# file so the COMMIT carries the ciphertext rather than what you staged.
|
|
436
|
-
# Encryption needs only the public key in that file, so this needs no
|
|
437
|
-
# credential and works on a fresh clone.
|
|
438
|
-
#
|
|
439
|
-
# It refuses only when it cannot fix the problem itself: no key yet
|
|
440
|
-
# (\`vc keys --init\`), or a .env.keys that is not gitignored. A secret
|
|
441
|
-
# committed in the clear cannot be un-committed — the value stays in the
|
|
442
|
-
# history and has to be treated as burned.
|
|
443
|
-
bunx vc guard
|
|
444
|
-
`,
|
|
445
|
-
result,
|
|
446
|
-
"regenerate",
|
|
447
|
-
);
|
|
448
504
|
await put(root, ".github/workflows/deploy.yml", renderDistWorkflow(manifest), result, "regenerate");
|
|
449
505
|
// Renamed when GitHub Actions took over the deploy from Cloudflare's own
|
|
450
506
|
// build. Two workflows on the same push would deploy the shop twice.
|
package/src/generate/support.ts
CHANGED
|
@@ -281,44 +281,59 @@ export function renderShippingTs(manifest: Manifest): string {
|
|
|
281
281
|
const origin = ["parcel2go", "dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id));
|
|
282
282
|
|
|
283
283
|
const imports = [
|
|
284
|
+
...(origin || parcel2go ? [`import { env } from "void/env";`] : []),
|
|
284
285
|
...(origin ? [`import type { DeliveryAddress } from "@saastemly/better-commerce/providers/delivery";`] : []),
|
|
285
286
|
...(royalMail ? [`import type { RoyalMailService } from "@saastemly/better-commerce/providers/royal-mail";`] : []),
|
|
286
287
|
];
|
|
287
288
|
|
|
288
|
-
const
|
|
289
|
+
const header = `
|
|
289
290
|
/**
|
|
290
|
-
*
|
|
291
|
+
* Where parcels come FROM, and who the carrier account belongs to.
|
|
292
|
+
*
|
|
293
|
+
* These are CONFIG, not code: they vary per deploy, they are nobody's
|
|
294
|
+
* business but this shop's, and hard-coding them here would mean a fresh
|
|
295
|
+
* clone of this repository shipped somebody else's address. So they come
|
|
296
|
+
* from the environment — declared in env.ts, encrypted in .env.secrets, and
|
|
297
|
+
* set with \`vc secrets set\`.
|
|
291
298
|
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
299
|
+
* This file's job is to read them and fail loudly when one is missing, which
|
|
300
|
+
* is the difference between a shop that refuses to quote and a shop that
|
|
301
|
+
* quotes against the wrong postcode and loses money on every order.
|
|
294
302
|
*/
|
|
303
|
+
const must = (key: string): string => {
|
|
304
|
+
const value = (env as Record<string, unknown>)[key];
|
|
305
|
+
if (typeof value !== "string" || !value || value === "unset") {
|
|
306
|
+
throw new Error(\`shipping: \${key} is not set — \\\`vc secrets set \${key}\\\`\`);
|
|
307
|
+
}
|
|
308
|
+
return value;
|
|
309
|
+
};
|
|
310
|
+
`;
|
|
311
|
+
|
|
312
|
+
const originBlock = `
|
|
313
|
+
/** The address every parcel is quoted against, and the door a courier is sent to. */
|
|
295
314
|
export const shipFrom: DeliveryAddress = {
|
|
296
|
-
name: "
|
|
297
|
-
line1: "
|
|
298
|
-
city: "
|
|
299
|
-
postalCode: "
|
|
315
|
+
name: must("SHIP_FROM_NAME"),
|
|
316
|
+
line1: must("SHIP_FROM_LINE1"),
|
|
317
|
+
city: must("SHIP_FROM_CITY"),
|
|
318
|
+
postalCode: must("SHIP_FROM_POSTCODE"),
|
|
300
319
|
country: "${manifest.shop.country}",
|
|
301
|
-
phone: "
|
|
320
|
+
phone: must("SHIP_FROM_PHONE"),
|
|
302
321
|
};
|
|
303
322
|
`;
|
|
304
323
|
|
|
305
324
|
const parcel2goBlock = `
|
|
306
325
|
/**
|
|
307
|
-
* Who the Parcel2Go account belongs to.
|
|
308
|
-
*
|
|
309
|
-
* Every order carries it: Parcel2Go books a courier to come to a real person
|
|
310
|
-
* at a real door, and needs to know whose door. This is the ACCOUNT holder,
|
|
311
|
-
* not the buyer — the buyer's details ride on the parcel.
|
|
312
|
-
*
|
|
313
|
-
* TODO VERIFY: these are placeholders.
|
|
326
|
+
* Who the Parcel2Go account belongs to — the ACCOUNT holder, not the buyer.
|
|
327
|
+
* Parcel2Go books a courier for a named person and rejects an order without.
|
|
314
328
|
*/
|
|
315
329
|
export const parcel2goCustomer = {
|
|
316
|
-
email: "
|
|
317
|
-
forename: "
|
|
318
|
-
surname: "
|
|
330
|
+
email: must("PARCEL2GO_CUSTOMER_EMAIL"),
|
|
331
|
+
forename: must("PARCEL2GO_CUSTOMER_FORENAME"),
|
|
332
|
+
surname: must("PARCEL2GO_CUSTOMER_SURNAME"),
|
|
319
333
|
};
|
|
320
334
|
`;
|
|
321
|
-
|
|
335
|
+
|
|
336
|
+
return `${imports.join("\n")}\n${origin || parcel2go ? header : ""}${origin ? originBlock : ""}${parcel2go ? parcel2goBlock : ""}${royalMail ? ROYAL_MAIL_SERVICES() : ""}`;
|
|
322
337
|
}
|
|
323
338
|
|
|
324
339
|
export function renderErpTs(): string {
|