@saastemly/voidcommerce 0.11.0 → 0.13.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 +6 -4
- package/dist/deploy/github.d.ts +2 -0
- package/dist/deploy/zone.d.ts +51 -0
- package/dist/generate/auth.d.ts +1 -1
- package/dist/{index-mnb7fz2t.js → index-0je92d88.js} +19 -4
- package/dist/{index-tjy6yygc.js → index-727eybzt.js} +1 -1
- package/dist/{index-62q6669q.js → index-cxse24q6.js} +6 -3
- package/dist/{index-964eqtx0.js → index-ze4z3n5q.js} +150 -37
- package/dist/index.js +4 -4
- package/dist/{keys-1cqepd69.js → keys-9efk9rdz.js} +3 -3
- package/dist/manifest.js +2 -2
- package/package.json +1 -1
- package/src/catalog.ts +19 -4
- package/src/deploy/cloudflare.ts +20 -1
- package/src/deploy/github.ts +5 -0
- package/src/deploy/link.ts +2 -0
- package/src/deploy/zone.ts +147 -0
- package/src/generate/auth.ts +9 -1
- package/src/generate/ci.ts +2 -0
- package/src/generate/support.ts +19 -2
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-ze4z3n5q.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-cxse24q6.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-727eybzt.js";
|
|
62
62
|
import {
|
|
63
63
|
GROUPS
|
|
64
|
-
} from "./index-
|
|
64
|
+
} from "./index-0je92d88.js";
|
|
65
65
|
import {
|
|
66
66
|
__require
|
|
67
67
|
} from "./index-0v6na3yp.js";
|
|
@@ -85,6 +85,8 @@ var SCOPES = [
|
|
|
85
85
|
["Account · Workers R2 Storage: Edit", "product images"],
|
|
86
86
|
["Account · Account Settings: Read", "confirm which account this is"],
|
|
87
87
|
["Zone · Workers Routes: Edit", "answer on your domain"],
|
|
88
|
+
["Zone · Zone: Read", "confirm the domain's zone is on this account"],
|
|
89
|
+
["Zone · DNS: Read", "notice a hostname that already answers, before taking it over"],
|
|
88
90
|
["User · User Details: Read", "wrangler asks at startup"]
|
|
89
91
|
];
|
|
90
92
|
async function linkCommand(project, args) {
|
package/dist/deploy/github.d.ts
CHANGED
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
* @see https://developers.cloudflare.com/workers/ci-cd/external-cicd/github-actions/
|
|
31
31
|
* @see https://github.com/cloudflare/workers-sdk/discussions/11434
|
|
32
32
|
*/
|
|
33
|
+
/** The Cloudflare token, from wherever wrangler itself would read it. */
|
|
34
|
+
export declare function apiToken(): string | null;
|
|
33
35
|
export interface GhAuth {
|
|
34
36
|
/** Logged in, with a token that can write repository secrets. */
|
|
35
37
|
ok: boolean;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Same zone": the shop's DNS zone is on the SAME Cloudflare account as the
|
|
3
|
+
* worker — and checking that it really is.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this is worth a check rather than a sentence ─────────────────────
|
|
6
|
+
*
|
|
7
|
+
* Everything about the deploy rests on it. A Worker custom domain is a
|
|
8
|
+
* record Cloudflare writes in its own zone, so it works only when the zone
|
|
9
|
+
* is on the account the token belongs to. Cloudflare's own words: *"You
|
|
10
|
+
* cannot create a Custom Domain on a hostname with an existing CNAME DNS
|
|
11
|
+
* record or on a zone you do not own."* Until now that was a line in
|
|
12
|
+
* DEPLOY.md and nothing verified it, so getting it wrong surfaced as a
|
|
13
|
+
* wrangler error in the middle of a deploy that had already created a
|
|
14
|
+
* database.
|
|
15
|
+
*
|
|
16
|
+
* ── And the failure that is worse than an error ──────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* Wrangler resolves a conflicting hostname interactively — it asks whether
|
|
19
|
+
* to move the record onto this script. In CI there is nobody to ask, and it
|
|
20
|
+
* does not fail: its own source sets
|
|
21
|
+
*
|
|
22
|
+
* override_existing_origin = true
|
|
23
|
+
* override_existing_dns_record = true
|
|
24
|
+
*
|
|
25
|
+
* whenever stdout is not a TTY. So a hostname already serving something
|
|
26
|
+
* else — another worker, a real site — is taken over silently, by a push.
|
|
27
|
+
* That is not a thing to discover afterwards, so this refuses first.
|
|
28
|
+
*
|
|
29
|
+
* ── What it deliberately does not do ─────────────────────────────────────
|
|
30
|
+
*
|
|
31
|
+
* It writes nothing. `cloudflare-samezone` needs no DNS records and no
|
|
32
|
+
* second credential: the worker's hostname is a custom domain Cloudflare
|
|
33
|
+
* creates itself, the storefront rides in that same worker, and Cloudflare
|
|
34
|
+
* Email writes its own SPF, DKIM and DMARC when the domain is onboarded.
|
|
35
|
+
* The value here is the precondition, not the plumbing.
|
|
36
|
+
*/
|
|
37
|
+
export interface ZoneCheck {
|
|
38
|
+
ok: boolean;
|
|
39
|
+
/** What to tell the operator. Empty when everything is as it should be. */
|
|
40
|
+
problems: string[];
|
|
41
|
+
notes: string[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Is `zone` on this account, and is `hostname` free to become a custom domain?
|
|
45
|
+
*
|
|
46
|
+
* `accountId` is optional: without it any zone the token can see counts,
|
|
47
|
+
* which is the right answer for a token scoped to one account anyway.
|
|
48
|
+
*/
|
|
49
|
+
export declare function checkSameZone(token: string, zone: string, hostname: string, accountId?: string): Promise<ZoneCheck>;
|
|
50
|
+
/** Print a check, in the shape the rest of the deploy uses. */
|
|
51
|
+
export declare function printZoneCheck(check: ZoneCheck, zone: string): void;
|
package/dist/generate/auth.d.ts
CHANGED
|
@@ -10,6 +10,6 @@ import { type Manifest } from "../manifest";
|
|
|
10
10
|
* The token needs nothing: no origin, no tariff. Everything else needs one or
|
|
11
11
|
* the other, so the rule is simply "any carrier but the token".
|
|
12
12
|
*/
|
|
13
|
-
export declare const WIRED_CARRIERS: readonly ["royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"];
|
|
13
|
+
export declare const WIRED_CARRIERS: readonly ["parcel2go", "royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"];
|
|
14
14
|
export declare const needsShippingConfig: (id: string) => boolean;
|
|
15
15
|
export declare function renderAuthTs(manifest: Manifest): string;
|
|
@@ -440,6 +440,16 @@ var CARRIERS = {
|
|
|
440
440
|
intro: "Live rates at checkout, quoted per carrier. Flat-rate shipping needs none of these.",
|
|
441
441
|
kind: "multiselect",
|
|
442
442
|
choices: [
|
|
443
|
+
{
|
|
444
|
+
id: "parcel2go",
|
|
445
|
+
label: "Parcel2Go — a courier collects from your door",
|
|
446
|
+
hint: "the only one here a private individual can use end to end: live prices, a collection booked by API, and label-free services where the driver brings it. No business account, no volume minimum, no monthly fee",
|
|
447
|
+
recommended: true,
|
|
448
|
+
env: [
|
|
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" }
|
|
451
|
+
]
|
|
452
|
+
},
|
|
443
453
|
{ id: "shippo", label: "Shippo", hint: "many carriers through one API", env: [{ key: "SHIPPO_API_TOKEN", breaks: "calculated rates fall back to flat" }] },
|
|
444
454
|
{ id: "easypost", label: "EasyPost", hint: "the same, US-leaning", env: [{ key: "EASYPOST_API_KEY", breaks: "calculated rates fall back to flat" }] },
|
|
445
455
|
{ id: "ups", label: "UPS", hint: "your own UPS account, for negotiated rates an aggregator cannot see", env: [{ 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" }] },
|
|
@@ -525,15 +535,20 @@ var EMAIL = {
|
|
|
525
535
|
var DNS = {
|
|
526
536
|
id: "dns",
|
|
527
537
|
title: "DNS",
|
|
528
|
-
intro: "
|
|
538
|
+
intro: "Where the shop's domain lives, and whether the deploy may touch it. The worker's own hostname is a Cloudflare custom domain and needs no record here — Cloudflare writes that one itself.",
|
|
529
539
|
kind: "select",
|
|
530
540
|
choices: [
|
|
541
|
+
{
|
|
542
|
+
id: "cloudflare-samezone",
|
|
543
|
+
label: "Cloudflare, same account",
|
|
544
|
+
hint: "the zone is on the same Cloudflare account as the worker — the deploy checks that before it changes anything, and needs no extra credential",
|
|
545
|
+
recommended: true
|
|
546
|
+
},
|
|
531
547
|
{
|
|
532
548
|
id: "cloudflare",
|
|
533
|
-
label: "Cloudflare",
|
|
534
|
-
hint: "
|
|
549
|
+
label: "Cloudflare, another account or a second zone",
|
|
550
|
+
hint: "a separate DNS token, for a zone the worker's own token cannot see — the deploy writes the mail records so confirmations are not spam",
|
|
535
551
|
packages: ["@saastemly/better-dns"],
|
|
536
|
-
recommended: true,
|
|
537
552
|
env: [
|
|
538
553
|
{ key: "CLOUDFLARE_DNS_TOKEN", breaks: "the storefront records are never written; the domain does not reach the shop", where: "a token with Zone:DNS:Edit and Zone:Zone:Read" }
|
|
539
554
|
]
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
isApex,
|
|
5
5
|
oneOrigin,
|
|
6
6
|
zone
|
|
7
|
-
} from "./index-
|
|
7
|
+
} from "./index-727eybzt.js";
|
|
8
8
|
import {
|
|
9
9
|
__require,
|
|
10
10
|
__toESM
|
|
@@ -19,6 +19,9 @@ import color2 from "picocolors";
|
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
20
|
import { delimiter, join } from "node:path";
|
|
21
21
|
import { existsSync } from "node:fs";
|
|
22
|
+
function apiToken() {
|
|
23
|
+
return process.env["CLOUDFLARE_API_TOKEN"] || process.env["CF_API_TOKEN"] || null;
|
|
24
|
+
}
|
|
22
25
|
var REPO_SCOPE = "repo";
|
|
23
26
|
function findGh() {
|
|
24
27
|
for (const entry of (process.env["PATH"] ?? "").split(delimiter).filter(Boolean)) {
|
|
@@ -386,7 +389,7 @@ async function decryptSecrets(project) {
|
|
|
386
389
|
}
|
|
387
390
|
const expected = committedPublicKey(root);
|
|
388
391
|
if (expected) {
|
|
389
|
-
const { publicKeyFor } = await import("./keys-
|
|
392
|
+
const { publicKeyFor } = await import("./keys-9efk9rdz.js");
|
|
390
393
|
const derived = await publicKeyFor(privateKey);
|
|
391
394
|
if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
|
|
392
395
|
return {
|
|
@@ -913,4 +916,4 @@ ${color2.green("✓")} re-keyed under ${made.publicKey.slice(0, 20)}… and ${PR
|
|
|
913
916
|
return 0;
|
|
914
917
|
}
|
|
915
918
|
|
|
916
|
-
export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary, 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 };
|
|
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 };
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
PRIVATE_KEY_VAR,
|
|
4
4
|
SECRETS_FILE,
|
|
5
5
|
allEnvKeys,
|
|
6
|
+
apiToken,
|
|
6
7
|
declaredSecretNames,
|
|
7
8
|
decryptSecrets,
|
|
8
9
|
plaintextSecretNames,
|
|
@@ -10,7 +11,7 @@ import {
|
|
|
10
11
|
renderEnvLocal,
|
|
11
12
|
renderEnvProduction,
|
|
12
13
|
renderEnvTs
|
|
13
|
-
} from "./index-
|
|
14
|
+
} from "./index-cxse24q6.js";
|
|
14
15
|
import {
|
|
15
16
|
MANIFEST_FILE,
|
|
16
17
|
has,
|
|
@@ -23,10 +24,10 @@ import {
|
|
|
23
24
|
workerHosts,
|
|
24
25
|
writeManifest,
|
|
25
26
|
zone
|
|
26
|
-
} from "./index-
|
|
27
|
+
} from "./index-727eybzt.js";
|
|
27
28
|
import {
|
|
28
29
|
CHOICES
|
|
29
|
-
} from "./index-
|
|
30
|
+
} from "./index-0je92d88.js";
|
|
30
31
|
import {
|
|
31
32
|
__require
|
|
32
33
|
} from "./index-0v6na3yp.js";
|
|
@@ -49,12 +50,17 @@ var VAT = {
|
|
|
49
50
|
PL: { rate: 2300, name: "VAT" }
|
|
50
51
|
};
|
|
51
52
|
var social = (manifest, id, envPrefix) => has(manifest, id) ? ` ${id}: { clientId: must(env, "${envPrefix}_CLIENT_ID"), clientSecret: must(env, "${envPrefix}_CLIENT_SECRET") },` : null;
|
|
52
|
-
var WIRED_CARRIERS = ["royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"];
|
|
53
|
+
var WIRED_CARRIERS = ["parcel2go", "royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"];
|
|
53
54
|
var needsShippingConfig = (id) => WIRED_CARRIERS.includes(id) && id !== "nft";
|
|
54
55
|
function renderAuthTs(manifest) {
|
|
55
56
|
const { shop } = manifest;
|
|
56
57
|
const lines = [];
|
|
57
58
|
const CARRIER_CALLS = {
|
|
59
|
+
parcel2go: {
|
|
60
|
+
label: "Parcel2Go",
|
|
61
|
+
call: `parcel2goProvider({ clientId: must(env, "PARCEL2GO_CLIENT_ID"), clientSecret: must(env, "PARCEL2GO_CLIENT_SECRET"), from: shipFrom, customer: parcel2goCustomer, currency: "${shop.currency}" })`,
|
|
62
|
+
import: `import { parcel2goProvider } from "@saastemly/better-commerce/providers/parcel2go";`
|
|
63
|
+
},
|
|
58
64
|
"royal-mail": {
|
|
59
65
|
label: "Royal Mail",
|
|
60
66
|
call: `royalMailProvider({ apiKey: must(env, "CLICK_DROP_AUTH_KEY"), services: royalMailServices, currency: "${shop.currency}", carrierName: "Royal Mail" })`,
|
|
@@ -96,7 +102,8 @@ import { mintProvider, destinationFor } from "./lib/mint.ts";`
|
|
|
96
102
|
const carrierImports = carriers.map((id) => CARRIER_CALLS[id].import);
|
|
97
103
|
const shipping = [
|
|
98
104
|
...carriers.some((id) => id !== "nft" && id !== "royal-mail") ? ["shipFrom"] : [],
|
|
99
|
-
...carriers.includes("royal-mail") ? ["royalMailServices"] : []
|
|
105
|
+
...carriers.includes("royal-mail") ? ["royalMailServices"] : [],
|
|
106
|
+
...carriers.includes("parcel2go") ? ["parcel2goCustomer"] : []
|
|
100
107
|
];
|
|
101
108
|
if (shipping.length > 0)
|
|
102
109
|
carrierImports.push(`import { ${shipping.join(", ")} } from "./lib/shipping.ts";`);
|
|
@@ -1371,7 +1378,7 @@ import color from "picocolors";
|
|
|
1371
1378
|
// package.json
|
|
1372
1379
|
var package_default = {
|
|
1373
1380
|
name: "@saastemly/voidcommerce",
|
|
1374
|
-
version: "0.
|
|
1381
|
+
version: "0.13.0",
|
|
1375
1382
|
description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
|
|
1376
1383
|
type: "module",
|
|
1377
1384
|
license: "MIT",
|
|
@@ -1885,6 +1892,8 @@ Create the token at **My Profile → API Tokens → Create Token → Custom toke
|
|
|
1885
1892
|
| Account | Workers R2 Storage: Edit | product images |
|
|
1886
1893
|
| Account | Account Settings: Read | confirming which account |
|
|
1887
1894
|
| Zone | Workers Routes: Edit (${zone2}) | answering on your domain |
|
|
1895
|
+
| Zone | Zone: Read (${zone2}) | confirming the zone is on this account |
|
|
1896
|
+
| Zone | DNS: Read (${zone2}) | noticing a hostname that already answers |
|
|
1888
1897
|
| User | User Details: Read, Memberships: Read | wrangler asks at startup |
|
|
1889
1898
|
|
|
1890
1899
|
The token Cloudflare generates for its own Workers Builds will **not** do: it
|
|
@@ -2220,7 +2229,8 @@ export async function destinationFor(orderId: string): Promise<string | null> {
|
|
|
2220
2229
|
}
|
|
2221
2230
|
function renderShippingTs(manifest) {
|
|
2222
2231
|
const royalMail = has(manifest, "royal-mail");
|
|
2223
|
-
const
|
|
2232
|
+
const parcel2go = has(manifest, "parcel2go");
|
|
2233
|
+
const origin = ["parcel2go", "dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id));
|
|
2224
2234
|
const imports = [
|
|
2225
2235
|
...origin ? [`import type { DeliveryAddress } from "@saastemly/better-commerce/providers/delivery";`] : [],
|
|
2226
2236
|
...royalMail ? [`import type { RoyalMailService } from "@saastemly/better-commerce/providers/royal-mail";`] : []
|
|
@@ -2240,10 +2250,26 @@ export const shipFrom: DeliveryAddress = {
|
|
|
2240
2250
|
country: "${manifest.shop.country}",
|
|
2241
2251
|
phone: "TODO VERIFY: +44…",
|
|
2242
2252
|
};
|
|
2253
|
+
`;
|
|
2254
|
+
const parcel2goBlock = `
|
|
2255
|
+
/**
|
|
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.
|
|
2263
|
+
*/
|
|
2264
|
+
export const parcel2goCustomer = {
|
|
2265
|
+
email: "TODO VERIFY: the email on your Parcel2Go account",
|
|
2266
|
+
forename: "TODO VERIFY",
|
|
2267
|
+
surname: "TODO VERIFY",
|
|
2268
|
+
};
|
|
2243
2269
|
`;
|
|
2244
2270
|
return `${imports.join(`
|
|
2245
2271
|
`)}
|
|
2246
|
-
${origin ? originBlock : ""}${royalMail ? ROYAL_MAIL_SERVICES() : ""}`;
|
|
2272
|
+
${origin ? originBlock : ""}${parcel2go ? parcel2goBlock : ""}${royalMail ? ROYAL_MAIL_SERVICES() : ""}`;
|
|
2247
2273
|
}
|
|
2248
2274
|
function renderErpTs() {
|
|
2249
2275
|
return `import { businessCentralProvider } from "@saastemly/better-commerce/providers/business-central";
|
|
@@ -3080,10 +3106,84 @@ ${color3.red("NOT ready to go live.")}
|
|
|
3080
3106
|
// src/deploy/cloudflare.ts
|
|
3081
3107
|
import { copyFileSync, existsSync as existsSync8, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync } from "node:fs";
|
|
3082
3108
|
import { dirname as dirname5, join as join8 } from "node:path";
|
|
3109
|
+
import color5 from "picocolors";
|
|
3110
|
+
|
|
3111
|
+
// src/deploy/zone.ts
|
|
3083
3112
|
import color4 from "picocolors";
|
|
3113
|
+
async function cf(token, path) {
|
|
3114
|
+
try {
|
|
3115
|
+
const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
|
|
3116
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
3117
|
+
});
|
|
3118
|
+
const body = await response.json();
|
|
3119
|
+
if (!body.success) {
|
|
3120
|
+
const first = body.errors?.[0];
|
|
3121
|
+
return { ok: false, error: first?.message ? `${first.message}${first.code ? ` (${first.code})` : ""}` : `HTTP ${response.status}` };
|
|
3122
|
+
}
|
|
3123
|
+
return { ok: true, result: body.result };
|
|
3124
|
+
} catch (error) {
|
|
3125
|
+
return { ok: false, error: `could not reach the Cloudflare API: ${String(error)}` };
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
async function checkSameZone(token, zone2, hostname, accountId) {
|
|
3129
|
+
const problems = [];
|
|
3130
|
+
const notes = [];
|
|
3131
|
+
const zones = await cf(token, `/zones?name=${encodeURIComponent(zone2)}`);
|
|
3132
|
+
if (!zones.ok) {
|
|
3133
|
+
return {
|
|
3134
|
+
ok: true,
|
|
3135
|
+
problems: [],
|
|
3136
|
+
notes: [`the zone could not be checked: ${zones.error}. Add ${color4.cyan("Zone → Zone: Read")} to the API token to have this verified.`]
|
|
3137
|
+
};
|
|
3138
|
+
}
|
|
3139
|
+
const found = zones.result.find((entry) => entry.name.toLowerCase() === zone2.toLowerCase());
|
|
3140
|
+
if (!found) {
|
|
3141
|
+
problems.push(`the zone ${color4.bold(zone2)} is not on this Cloudflare account.
|
|
3142
|
+
` + ` A Worker custom domain is a record Cloudflare writes in its own zone, so a
|
|
3143
|
+
` + ` domain hosted anywhere else cannot have one. Move the zone to this account,
|
|
3144
|
+
` + " or point the shop at a domain that is already on it.");
|
|
3145
|
+
return { ok: false, problems, notes };
|
|
3146
|
+
}
|
|
3147
|
+
if (accountId && found.account?.id && found.account.id !== accountId) {
|
|
3148
|
+
problems.push(`${color4.bold(zone2)} is on Cloudflare, but under a different account (${found.account.name ?? found.account.id})
|
|
3149
|
+
` + " than the one this worker deploys to. The custom domain would be refused.");
|
|
3150
|
+
return { ok: false, problems, notes };
|
|
3151
|
+
}
|
|
3152
|
+
if (found.status !== "active") {
|
|
3153
|
+
notes.push(`${zone2} is on this account but its status is "${found.status}" — a custom domain needs an active zone.`);
|
|
3154
|
+
}
|
|
3155
|
+
const records = await cf(token, `/zones/${found.id}/dns_records?name.exact=${encodeURIComponent(hostname)}`);
|
|
3156
|
+
if (!records.ok) {
|
|
3157
|
+
notes.push(`existing records for ${hostname} could not be listed: ${records.error}. Add ${color4.cyan("Zone → DNS: Read")} to have this checked.`);
|
|
3158
|
+
return { ok: problems.length === 0, problems, notes };
|
|
3159
|
+
}
|
|
3160
|
+
const foreign = records.result.filter((record) => !(record.proxied && (record.content === "100::" || record.content === "192.0.2.1")));
|
|
3161
|
+
if (foreign.length > 0) {
|
|
3162
|
+
problems.push(`${color4.bold(hostname)} already has ${foreign.length} DNS record${foreign.length === 1 ? "" : "s"}:
|
|
3163
|
+
` + foreign.map((record) => ` ${record.type.padEnd(6)} ${record.content}`).join(`
|
|
3164
|
+
`) + `
|
|
3165
|
+
` + ` Deploying would take the hostname over. Wrangler asks about this at a
|
|
3166
|
+
` + ` terminal, but in CI it is not a prompt — it overrides the record silently.
|
|
3167
|
+
` + " Delete them first if the hostname really is meant to be this shop's.");
|
|
3168
|
+
}
|
|
3169
|
+
return { ok: problems.length === 0, problems, notes };
|
|
3170
|
+
}
|
|
3171
|
+
function printZoneCheck(check, zone2) {
|
|
3172
|
+
for (const note of check.notes)
|
|
3173
|
+
console.log(`${color4.yellow("!")} ${note}`);
|
|
3174
|
+
if (check.ok) {
|
|
3175
|
+
if (check.notes.length === 0)
|
|
3176
|
+
console.log(`${color4.green("✓")} ${zone2} is on this Cloudflare account, and the hostname is free`);
|
|
3177
|
+
return;
|
|
3178
|
+
}
|
|
3179
|
+
for (const problem of check.problems)
|
|
3180
|
+
console.error(`${color4.red("✗")} ${problem}`);
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
// src/deploy/cloudflare.ts
|
|
3084
3184
|
var fail = (message) => {
|
|
3085
3185
|
console.error(`
|
|
3086
|
-
${
|
|
3186
|
+
${color5.red("✗")} ${message}
|
|
3087
3187
|
`);
|
|
3088
3188
|
return 1;
|
|
3089
3189
|
};
|
|
@@ -3123,7 +3223,7 @@ async function deployCloudflare(project, opts) {
|
|
|
3123
3223
|
${who.raw.trim().split(`
|
|
3124
3224
|
`).slice(-4).join(`
|
|
3125
3225
|
`)}`);
|
|
3126
|
-
console.log(`${
|
|
3226
|
+
console.log(`${color5.green("✓")} wrangler is logged in`);
|
|
3127
3227
|
let config = readConfig(configPath);
|
|
3128
3228
|
let accountId = config.account_id || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
|
|
3129
3229
|
if (!accountId) {
|
|
@@ -3132,19 +3232,32 @@ ${who.raw.trim().split(`
|
|
|
3132
3232
|
writeFileSync(configPath, upsertJsonc(readFileSync3(configPath, "utf8"), "account_id", accountId));
|
|
3133
3233
|
project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId };
|
|
3134
3234
|
await writeManifest(project.root, project.manifest);
|
|
3135
|
-
console.log(`${
|
|
3235
|
+
console.log(`${color5.green("✓")} pinned the account "${who.accounts[0].name}" in wrangler.jsonc`);
|
|
3136
3236
|
} else {
|
|
3137
3237
|
return fail(`the account is not pinned and wrangler sees ${who.accounts.length}. Set account_id in wrangler.jsonc to one of:
|
|
3138
3238
|
${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
3139
3239
|
`)}`);
|
|
3140
3240
|
}
|
|
3141
3241
|
}
|
|
3242
|
+
if (has(project.manifest, "cloudflare-samezone")) {
|
|
3243
|
+
const token = apiToken();
|
|
3244
|
+
if (token) {
|
|
3245
|
+
const shopZone = zone(project.manifest);
|
|
3246
|
+
const check2 = await checkSameZone(token, shopZone, workerHosts(project.manifest)[0], accountId);
|
|
3247
|
+
printZoneCheck(check2, shopZone);
|
|
3248
|
+
if (!check2.ok && !opts.force) {
|
|
3249
|
+
return fail("the zone is not ready for this shop. Fix the above, or pass --force to deploy anyway.");
|
|
3250
|
+
}
|
|
3251
|
+
} else {
|
|
3252
|
+
console.log(color5.dim("! CLOUDFLARE_API_TOKEN is not set here, so the zone could not be checked."));
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3142
3255
|
const check = await preflight(project, "wrangler");
|
|
3143
3256
|
printPreflight(project, check, "wrangler");
|
|
3144
3257
|
if (!check.ready) {
|
|
3145
3258
|
if (!opts.force)
|
|
3146
3259
|
return fail("refusing to deploy a shop that is not ready for customers. Fix the above, or pass --force for a deliberate partial deploy.");
|
|
3147
|
-
console.error(
|
|
3260
|
+
console.error(color5.yellow(`--force: deploying a shop that is NOT ready for customers.
|
|
3148
3261
|
`));
|
|
3149
3262
|
}
|
|
3150
3263
|
const worker = config.name || project.manifest.shop.domain.split(".")[0];
|
|
@@ -3158,12 +3271,12 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3158
3271
|
writeFileSync(configPath, upsertJsonc(readFileSync3(configPath, "utf8"), "d1_databases", [entry]));
|
|
3159
3272
|
project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId, d1: { name: db.name, id: db.uuid } };
|
|
3160
3273
|
await writeManifest(project.root, project.manifest);
|
|
3161
|
-
console.log(`${
|
|
3274
|
+
console.log(`${color5.green("✓")} D1 "${db.name}" recorded in wrangler.jsonc and voidcommerce.json`);
|
|
3162
3275
|
config = readConfig(configPath);
|
|
3163
3276
|
}
|
|
3164
3277
|
if (opts.provision) {
|
|
3165
3278
|
await ensureQueue(bin, app, "commerce");
|
|
3166
|
-
console.log(`${
|
|
3279
|
+
console.log(`${color5.green("✓")} queue "commerce"`);
|
|
3167
3280
|
}
|
|
3168
3281
|
const builder = findBuilder(app);
|
|
3169
3282
|
if (!builder)
|
|
@@ -3171,13 +3284,13 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3171
3284
|
const merging = oneOrigin(project.manifest.layout) && !isSingleApp(project.manifest.layout);
|
|
3172
3285
|
if (merging) {
|
|
3173
3286
|
console.log(`
|
|
3174
|
-
▸ ${builder.label} ${
|
|
3287
|
+
▸ ${builder.label} ${color5.dim("(the storefront)")}`);
|
|
3175
3288
|
const storefront = await wrangler(builder.cmd, ["build"], project.root, true);
|
|
3176
3289
|
if (storefront.code !== 0)
|
|
3177
3290
|
return fail(`the storefront build failed (exit ${storefront.code}).`);
|
|
3178
3291
|
}
|
|
3179
3292
|
console.log(`
|
|
3180
|
-
▸ ${builder.label}${merging ?
|
|
3293
|
+
▸ ${builder.label}${merging ? color5.dim(" (the worker)") : ""}`);
|
|
3181
3294
|
const built = await wrangler(builder.cmd, ["build"], app, true);
|
|
3182
3295
|
if (built.code !== 0)
|
|
3183
3296
|
return fail(`the build failed (exit ${built.code}).`);
|
|
@@ -3192,7 +3305,7 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3192
3305
|
if (!existsSync8(join8(into, "index.html"))) {
|
|
3193
3306
|
return fail("the storefront produced no index.html, so the shop has no front page.");
|
|
3194
3307
|
}
|
|
3195
|
-
console.log(`${
|
|
3308
|
+
console.log(`${color5.green("✓")} folded ${merged} storefront file${merged === 1 ? "" : "s"} into the worker's assets`);
|
|
3196
3309
|
}
|
|
3197
3310
|
const emittedPath = join8(app, "dist", "ssr", "wrangler.json");
|
|
3198
3311
|
if (!existsSync8(emittedPath))
|
|
@@ -3210,7 +3323,7 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3210
3323
|
if (!emittedD1 || emittedD1.database_id === "local")
|
|
3211
3324
|
return fail("the emitted config still carries a placeholder D1 id; wrangler.jsonc's DB binding was not picked up by the build.");
|
|
3212
3325
|
writeFileSync(emittedPath, JSON.stringify(emitted, null, 2));
|
|
3213
|
-
console.log(`${
|
|
3326
|
+
console.log(`${color5.green("✓")} scrubbed ${scrubbed.length} baked value${scrubbed.length === 1 ? "" : "s"} from the worker's vars${scrubbed.length ? `: ${scrubbed.join(", ")}` : ""}`);
|
|
3214
3327
|
console.log(`
|
|
3215
3328
|
▸ wrangler d1 migrations apply ${emittedD1.database_name} --remote`);
|
|
3216
3329
|
const migrated = await wrangler(bin, ["d1", "migrations", "apply", emittedD1.database_name, "--remote"], app, true);
|
|
@@ -3224,7 +3337,7 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3224
3337
|
return fail(`the repository's secrets could not be read: ${decrypted.error}`);
|
|
3225
3338
|
secretArgs.push("--secrets-file", decrypted.path);
|
|
3226
3339
|
cleanupSecrets = decrypted.cleanup;
|
|
3227
|
-
console.log(`${
|
|
3340
|
+
console.log(`${color5.green("✓")} ${decrypted.names.length} secrets from ${SECRETS_FILE}, uploaded with this version`);
|
|
3228
3341
|
}
|
|
3229
3342
|
console.log(`
|
|
3230
3343
|
▸ wrangler deploy -c dist/ssr/wrangler.json`);
|
|
@@ -3235,9 +3348,9 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3235
3348
|
const domain = project.manifest.shop.domain;
|
|
3236
3349
|
const url = `https://${workerHosts(project.manifest)[0]}`;
|
|
3237
3350
|
console.log(`
|
|
3238
|
-
${
|
|
3351
|
+
${color5.green("Live:")} ${url}`);
|
|
3239
3352
|
if (hasFrontend(project.manifest.layout) && !oneOrigin(project.manifest.layout)) {
|
|
3240
|
-
console.log(
|
|
3353
|
+
console.log(color5.dim("The storefront deploys itself from GitHub Actions on push."));
|
|
3241
3354
|
}
|
|
3242
3355
|
return 0;
|
|
3243
3356
|
}
|
|
@@ -3263,7 +3376,7 @@ function mergeTree(from, into, skip) {
|
|
|
3263
3376
|
import { readFile as readFile3 } from "node:fs/promises";
|
|
3264
3377
|
import { existsSync as existsSync9 } from "node:fs";
|
|
3265
3378
|
import { join as join9 } from "node:path";
|
|
3266
|
-
import
|
|
3379
|
+
import color6 from "picocolors";
|
|
3267
3380
|
function describe(counts) {
|
|
3268
3381
|
if (!counts)
|
|
3269
3382
|
return "";
|
|
@@ -3330,25 +3443,25 @@ async function importCommand(args) {
|
|
|
3330
3443
|
}
|
|
3331
3444
|
const { url, how } = resolveTarget(project, args);
|
|
3332
3445
|
if (!dry)
|
|
3333
|
-
console.log(`${
|
|
3446
|
+
console.log(`${color6.dim("→")} ${url} ${color6.dim(`(${how})`)}
|
|
3334
3447
|
`);
|
|
3335
3448
|
if (!dry) {
|
|
3336
3449
|
let whoami2;
|
|
3337
3450
|
try {
|
|
3338
3451
|
whoami2 = await fetch(`${url}/api/auth/system/whoami`, { headers: { "x-system-key": key } });
|
|
3339
3452
|
} catch (error) {
|
|
3340
|
-
console.error(`${
|
|
3453
|
+
console.error(`${color6.red("✗")} could not reach ${url}: ${error instanceof Error ? error.message : String(error)}
|
|
3341
3454
|
` + ` Is the shop running? \`vc dev\` serves it locally; --local points here.
|
|
3342
3455
|
`);
|
|
3343
3456
|
return 1;
|
|
3344
3457
|
}
|
|
3345
3458
|
if (!whoami2.ok) {
|
|
3346
|
-
console.error(`${
|
|
3459
|
+
console.error(`${color6.red("✗")} the shop refused the system key (HTTP ${whoami2.status}).
|
|
3347
3460
|
` + ` Is SYSTEM_API_KEY the one this deployment was given?
|
|
3348
3461
|
`);
|
|
3349
3462
|
return 1;
|
|
3350
3463
|
}
|
|
3351
|
-
console.log(`${
|
|
3464
|
+
console.log(`${color6.green("✓")} authenticated as the system identity`);
|
|
3352
3465
|
}
|
|
3353
3466
|
const root = project.root;
|
|
3354
3467
|
const products = await readJson(join9(root, "data", "catalog.json"));
|
|
@@ -3357,14 +3470,14 @@ async function importCommand(args) {
|
|
|
3357
3470
|
const posts = has(project.manifest, "blogs") ? await readJson(join9(root, "content", "posts.json")) : null;
|
|
3358
3471
|
if (!products && !faqs?.length && !posts?.length) {
|
|
3359
3472
|
console.log(`
|
|
3360
|
-
${
|
|
3473
|
+
${color6.yellow("Nothing to import.")} data/catalog.json is absent and the content files are empty.
|
|
3361
3474
|
` + `Products go in data/catalog.json as a JSON array; \`vc import --dry-run\` checks it without pushing.
|
|
3362
3475
|
`);
|
|
3363
3476
|
return 0;
|
|
3364
3477
|
}
|
|
3365
3478
|
if (dry) {
|
|
3366
3479
|
console.log(`
|
|
3367
|
-
${
|
|
3480
|
+
${color6.dim("--dry-run: nothing was pushed.")}`);
|
|
3368
3481
|
console.log(` ${products?.length ?? 0} products, ${categories?.length ?? 0} categories`);
|
|
3369
3482
|
console.log(` ${faqs?.length ?? 0} FAQ entries, ${posts?.length ?? 0} posts
|
|
3370
3483
|
`);
|
|
@@ -3380,15 +3493,15 @@ ${color5.dim("--dry-run: nothing was pushed.")}`);
|
|
|
3380
3493
|
});
|
|
3381
3494
|
if (result.ok) {
|
|
3382
3495
|
const report = result.body.report ?? {};
|
|
3383
|
-
console.log(`${
|
|
3496
|
+
console.log(`${color6.green("✓")} products: ${describe(report.products)}`);
|
|
3384
3497
|
if (report.categories)
|
|
3385
|
-
console.log(`${
|
|
3498
|
+
console.log(`${color6.green("✓")} categories: ${describe(report.categories)}`);
|
|
3386
3499
|
if (report.prices)
|
|
3387
|
-
console.log(`${
|
|
3500
|
+
console.log(`${color6.green("✓")} prices: ${describe(report.prices)}`);
|
|
3388
3501
|
if (report.addons)
|
|
3389
|
-
console.log(`${
|
|
3502
|
+
console.log(`${color6.green("✓")} addons: ${describe(report.addons)}`);
|
|
3390
3503
|
} else {
|
|
3391
|
-
console.error(`${
|
|
3504
|
+
console.error(`${color6.red("✗")} catalogue: ${result.body.error ?? `HTTP ${result.status}`}`);
|
|
3392
3505
|
failed = true;
|
|
3393
3506
|
}
|
|
3394
3507
|
}
|
|
@@ -3400,20 +3513,20 @@ ${color5.dim("--dry-run: nothing was pushed.")}`);
|
|
|
3400
3513
|
continue;
|
|
3401
3514
|
const result = await post(`${url}${path}`, key, { entries: rows, posts: rows });
|
|
3402
3515
|
if (result.ok) {
|
|
3403
|
-
console.log(`${
|
|
3516
|
+
console.log(`${color6.green("✓")} ${label}: ${describe(result.body)}`);
|
|
3404
3517
|
} else {
|
|
3405
|
-
console.error(`${
|
|
3518
|
+
console.error(`${color6.red("✗")} ${label}: ${result.body.error ?? `HTTP ${result.status}`}`);
|
|
3406
3519
|
failed = true;
|
|
3407
3520
|
}
|
|
3408
3521
|
}
|
|
3409
3522
|
if (failed) {
|
|
3410
3523
|
console.error(`
|
|
3411
|
-
${
|
|
3524
|
+
${color6.red("Some of it did not land.")} The import is an upsert, so fixing the cause and running it again is safe.
|
|
3412
3525
|
`);
|
|
3413
3526
|
return 1;
|
|
3414
3527
|
}
|
|
3415
3528
|
console.log(`
|
|
3416
|
-
${
|
|
3529
|
+
${color6.green("Imported.")} It is an upsert, so running it again costs one pass and changes nothing.
|
|
3417
3530
|
`);
|
|
3418
3531
|
return 0;
|
|
3419
3532
|
}
|
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-ze4z3n5q.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-cxse24q6.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-727eybzt.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-0je92d88.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-cxse24q6.js";
|
|
12
|
+
import"./index-727eybzt.js";
|
|
13
|
+
import"./index-0je92d88.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.13.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
|
@@ -513,6 +513,16 @@ export const CARRIERS: Group = {
|
|
|
513
513
|
intro: "Live rates at checkout, quoted per carrier. Flat-rate shipping needs none of these.",
|
|
514
514
|
kind: "multiselect",
|
|
515
515
|
choices: [
|
|
516
|
+
{
|
|
517
|
+
id: "parcel2go",
|
|
518
|
+
label: "Parcel2Go — a courier collects from your door",
|
|
519
|
+
hint: "the only one here a private individual can use end to end: live prices, a collection booked by API, and label-free services where the driver brings it. No business account, no volume minimum, no monthly fee",
|
|
520
|
+
recommended: true,
|
|
521
|
+
env: [
|
|
522
|
+
{ key: "PARCEL2GO_CLIENT_ID", breaks: "no rates are quoted and no collection is ever booked", where: "parcel2go.com/myaccount/api → Client Credentials" },
|
|
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
|
+
],
|
|
525
|
+
},
|
|
516
526
|
{ id: "shippo", label: "Shippo", hint: "many carriers through one API", env: [{ key: "SHIPPO_API_TOKEN", breaks: "calculated rates fall back to flat" }] },
|
|
517
527
|
{ id: "easypost", label: "EasyPost", hint: "the same, US-leaning", env: [{ key: "EASYPOST_API_KEY", breaks: "calculated rates fall back to flat" }] },
|
|
518
528
|
{ id: "ups", label: "UPS", hint: "your own UPS account, for negotiated rates an aggregator cannot see", env: [{ 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" }] },
|
|
@@ -605,15 +615,20 @@ export const EMAIL: Group = {
|
|
|
605
615
|
export const DNS: Group = {
|
|
606
616
|
id: "dns",
|
|
607
617
|
title: "DNS",
|
|
608
|
-
intro: "
|
|
618
|
+
intro: "Where the shop's domain lives, and whether the deploy may touch it. The worker's own hostname is a Cloudflare custom domain and needs no record here — Cloudflare writes that one itself.",
|
|
609
619
|
kind: "select",
|
|
610
620
|
choices: [
|
|
621
|
+
{
|
|
622
|
+
id: "cloudflare-samezone",
|
|
623
|
+
label: "Cloudflare, same account",
|
|
624
|
+
hint: "the zone is on the same Cloudflare account as the worker — the deploy checks that before it changes anything, and needs no extra credential",
|
|
625
|
+
recommended: true,
|
|
626
|
+
},
|
|
611
627
|
{
|
|
612
628
|
id: "cloudflare",
|
|
613
|
-
label: "Cloudflare",
|
|
614
|
-
hint: "
|
|
629
|
+
label: "Cloudflare, another account or a second zone",
|
|
630
|
+
hint: "a separate DNS token, for a zone the worker's own token cannot see — the deploy writes the mail records so confirmations are not spam",
|
|
615
631
|
packages: ["@saastemly/better-dns"],
|
|
616
|
-
recommended: true,
|
|
617
632
|
env: [
|
|
618
633
|
{ key: "CLOUDFLARE_DNS_TOKEN", breaks: "the storefront records are never written; the domain does not reach the shop", where: "a token with Zone:DNS:Edit and Zone:Zone:Read" },
|
|
619
634
|
],
|
package/src/deploy/cloudflare.ts
CHANGED
|
@@ -2,10 +2,12 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFi
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import color from "picocolors";
|
|
4
4
|
import { allEnvKeys } from "../generate/env";
|
|
5
|
-
import { hasFrontend, isSingleApp, oneOrigin, workerHosts, writeManifest } from "../manifest";
|
|
5
|
+
import { has, hasFrontend, isSingleApp, oneOrigin, workerHosts, writeManifest, zone } from "../manifest";
|
|
6
6
|
import type { Project } from "../project";
|
|
7
7
|
import { parseJsonc, upsertJsonc } from "./jsonc";
|
|
8
|
+
import { apiToken } from "./github";
|
|
8
9
|
import { preflight, printPreflight } from "./preflight";
|
|
10
|
+
import { checkSameZone, printZoneCheck } from "./zone";
|
|
9
11
|
import { SECRETS_FILE, decryptSecrets } from "./secrets";
|
|
10
12
|
import { ensureD1, ensureQueue, findWrangler, whoami, wrangler } from "./wrangler";
|
|
11
13
|
|
|
@@ -99,6 +101,23 @@ export async function deployCloudflare(project: Project, opts: CloudflareOptions
|
|
|
99
101
|
}
|
|
100
102
|
}
|
|
101
103
|
|
|
104
|
+
// 1b. Same zone. Before anything is created, because the answer decides
|
|
105
|
+
// whether the hostname can work at all — and because a conflicting record
|
|
106
|
+
// would be taken over silently by the deploy below.
|
|
107
|
+
if (has(project.manifest, "cloudflare-samezone")) {
|
|
108
|
+
const token = apiToken();
|
|
109
|
+
if (token) {
|
|
110
|
+
const shopZone = zone(project.manifest);
|
|
111
|
+
const check = await checkSameZone(token, shopZone, workerHosts(project.manifest)[0]!, accountId);
|
|
112
|
+
printZoneCheck(check, shopZone);
|
|
113
|
+
if (!check.ok && !opts.force) {
|
|
114
|
+
return fail("the zone is not ready for this shop. Fix the above, or pass --force to deploy anyway.");
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
console.log(color.dim("! CLOUDFLARE_API_TOKEN is not set here, so the zone could not be checked."));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
102
121
|
// 2. preflight.
|
|
103
122
|
const check = await preflight(project, "wrangler");
|
|
104
123
|
printPreflight(project, check, "wrangler");
|
package/src/deploy/github.ts
CHANGED
|
@@ -35,6 +35,11 @@ import { existsSync } from "node:fs";
|
|
|
35
35
|
* @see https://github.com/cloudflare/workers-sdk/discussions/11434
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
+
/** The Cloudflare token, from wherever wrangler itself would read it. */
|
|
39
|
+
export function apiToken(): string | null {
|
|
40
|
+
return process.env["CLOUDFLARE_API_TOKEN"] || process.env["CF_API_TOKEN"] || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
38
43
|
export interface GhAuth {
|
|
39
44
|
/** Logged in, with a token that can write repository secrets. */
|
|
40
45
|
ok: boolean;
|
package/src/deploy/link.ts
CHANGED
|
@@ -50,6 +50,8 @@ const SCOPES: Array<[string, string]> = [
|
|
|
50
50
|
["Account · Workers R2 Storage: Edit", "product images"],
|
|
51
51
|
["Account · Account Settings: Read", "confirm which account this is"],
|
|
52
52
|
["Zone · Workers Routes: Edit", "answer on your domain"],
|
|
53
|
+
["Zone · Zone: Read", "confirm the domain's zone is on this account"],
|
|
54
|
+
["Zone · DNS: Read", "notice a hostname that already answers, before taking it over"],
|
|
53
55
|
["User · User Details: Read", "wrangler asks at startup"],
|
|
54
56
|
];
|
|
55
57
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import color from "picocolors";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* "Same zone": the shop's DNS zone is on the SAME Cloudflare account as the
|
|
5
|
+
* worker — and checking that it really is.
|
|
6
|
+
*
|
|
7
|
+
* ── Why this is worth a check rather than a sentence ─────────────────────
|
|
8
|
+
*
|
|
9
|
+
* Everything about the deploy rests on it. A Worker custom domain is a
|
|
10
|
+
* record Cloudflare writes in its own zone, so it works only when the zone
|
|
11
|
+
* is on the account the token belongs to. Cloudflare's own words: *"You
|
|
12
|
+
* cannot create a Custom Domain on a hostname with an existing CNAME DNS
|
|
13
|
+
* record or on a zone you do not own."* Until now that was a line in
|
|
14
|
+
* DEPLOY.md and nothing verified it, so getting it wrong surfaced as a
|
|
15
|
+
* wrangler error in the middle of a deploy that had already created a
|
|
16
|
+
* database.
|
|
17
|
+
*
|
|
18
|
+
* ── And the failure that is worse than an error ──────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* Wrangler resolves a conflicting hostname interactively — it asks whether
|
|
21
|
+
* to move the record onto this script. In CI there is nobody to ask, and it
|
|
22
|
+
* does not fail: its own source sets
|
|
23
|
+
*
|
|
24
|
+
* override_existing_origin = true
|
|
25
|
+
* override_existing_dns_record = true
|
|
26
|
+
*
|
|
27
|
+
* whenever stdout is not a TTY. So a hostname already serving something
|
|
28
|
+
* else — another worker, a real site — is taken over silently, by a push.
|
|
29
|
+
* That is not a thing to discover afterwards, so this refuses first.
|
|
30
|
+
*
|
|
31
|
+
* ── What it deliberately does not do ─────────────────────────────────────
|
|
32
|
+
*
|
|
33
|
+
* It writes nothing. `cloudflare-samezone` needs no DNS records and no
|
|
34
|
+
* second credential: the worker's hostname is a custom domain Cloudflare
|
|
35
|
+
* creates itself, the storefront rides in that same worker, and Cloudflare
|
|
36
|
+
* Email writes its own SPF, DKIM and DMARC when the domain is onboarded.
|
|
37
|
+
* The value here is the precondition, not the plumbing.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export interface ZoneCheck {
|
|
41
|
+
ok: boolean;
|
|
42
|
+
/** What to tell the operator. Empty when everything is as it should be. */
|
|
43
|
+
problems: string[];
|
|
44
|
+
notes: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface CfZone {
|
|
48
|
+
id: string;
|
|
49
|
+
name: string;
|
|
50
|
+
status: string;
|
|
51
|
+
account?: { id?: string; name?: string };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function cf<T>(token: string, path: string): Promise<{ ok: true; result: T } | { ok: false; error: string }> {
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
|
|
57
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
58
|
+
});
|
|
59
|
+
const body = (await response.json()) as { success?: boolean; result?: T; errors?: Array<{ message?: string; code?: number }> };
|
|
60
|
+
if (!body.success) {
|
|
61
|
+
const first = body.errors?.[0];
|
|
62
|
+
return { ok: false, error: first?.message ? `${first.message}${first.code ? ` (${first.code})` : ""}` : `HTTP ${response.status}` };
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, result: body.result as T };
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return { ok: false, error: `could not reach the Cloudflare API: ${String(error)}` };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Is `zone` on this account, and is `hostname` free to become a custom domain?
|
|
72
|
+
*
|
|
73
|
+
* `accountId` is optional: without it any zone the token can see counts,
|
|
74
|
+
* which is the right answer for a token scoped to one account anyway.
|
|
75
|
+
*/
|
|
76
|
+
export async function checkSameZone(token: string, zone: string, hostname: string, accountId?: string): Promise<ZoneCheck> {
|
|
77
|
+
const problems: string[] = [];
|
|
78
|
+
const notes: string[] = [];
|
|
79
|
+
|
|
80
|
+
const zones = await cf<CfZone[]>(token, `/zones?name=${encodeURIComponent(zone)}`);
|
|
81
|
+
if (!zones.ok) {
|
|
82
|
+
// A token without Zone:Read cannot answer the question. That is a gap
|
|
83
|
+
// in the check, not a failed shop, so it is reported and not fatal.
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
problems: [],
|
|
87
|
+
notes: [`the zone could not be checked: ${zones.error}. Add ${color.cyan("Zone → Zone: Read")} to the API token to have this verified.`],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const found = zones.result.find((entry) => entry.name.toLowerCase() === zone.toLowerCase());
|
|
92
|
+
if (!found) {
|
|
93
|
+
problems.push(
|
|
94
|
+
`the zone ${color.bold(zone)} is not on this Cloudflare account.\n` +
|
|
95
|
+
" A Worker custom domain is a record Cloudflare writes in its own zone, so a\n" +
|
|
96
|
+
" domain hosted anywhere else cannot have one. Move the zone to this account,\n" +
|
|
97
|
+
" or point the shop at a domain that is already on it.",
|
|
98
|
+
);
|
|
99
|
+
return { ok: false, problems, notes };
|
|
100
|
+
}
|
|
101
|
+
if (accountId && found.account?.id && found.account.id !== accountId) {
|
|
102
|
+
problems.push(
|
|
103
|
+
`${color.bold(zone)} is on Cloudflare, but under a different account (${found.account.name ?? found.account.id})\n` +
|
|
104
|
+
" than the one this worker deploys to. The custom domain would be refused.",
|
|
105
|
+
);
|
|
106
|
+
return { ok: false, problems, notes };
|
|
107
|
+
}
|
|
108
|
+
if (found.status !== "active") {
|
|
109
|
+
notes.push(`${zone} is on this account but its status is "${found.status}" — a custom domain needs an active zone.`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The silent-takeover check. A record already answering for this hostname
|
|
113
|
+
// is somebody's, and in CI wrangler would move it without asking.
|
|
114
|
+
const records = await cf<Array<{ id: string; type: string; name: string; content: string; proxied?: boolean }>>(
|
|
115
|
+
token,
|
|
116
|
+
`/zones/${found.id}/dns_records?name.exact=${encodeURIComponent(hostname)}`,
|
|
117
|
+
);
|
|
118
|
+
if (!records.ok) {
|
|
119
|
+
notes.push(`existing records for ${hostname} could not be listed: ${records.error}. Add ${color.cyan("Zone → DNS: Read")} to have this checked.`);
|
|
120
|
+
return { ok: problems.length === 0, problems, notes };
|
|
121
|
+
}
|
|
122
|
+
// A Worker custom domain shows up as a proxied AAAA/A at 100:: — that is
|
|
123
|
+
// this shop's own record from a previous deploy, not a conflict.
|
|
124
|
+
const foreign = records.result.filter((record) => !(record.proxied && (record.content === "100::" || record.content === "192.0.2.1")));
|
|
125
|
+
if (foreign.length > 0) {
|
|
126
|
+
problems.push(
|
|
127
|
+
`${color.bold(hostname)} already has ${foreign.length} DNS record${foreign.length === 1 ? "" : "s"}:\n` +
|
|
128
|
+
foreign.map((record) => ` ${record.type.padEnd(6)} ${record.content}`).join("\n") +
|
|
129
|
+
"\n" +
|
|
130
|
+
" Deploying would take the hostname over. Wrangler asks about this at a\n" +
|
|
131
|
+
" terminal, but in CI it is not a prompt — it overrides the record silently.\n" +
|
|
132
|
+
" Delete them first if the hostname really is meant to be this shop's.",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { ok: problems.length === 0, problems, notes };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Print a check, in the shape the rest of the deploy uses. */
|
|
140
|
+
export function printZoneCheck(check: ZoneCheck, zone: string): void {
|
|
141
|
+
for (const note of check.notes) console.log(`${color.yellow("!")} ${note}`);
|
|
142
|
+
if (check.ok) {
|
|
143
|
+
if (check.notes.length === 0) console.log(`${color.green("✓")} ${zone} is on this Cloudflare account, and the hostname is free`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
for (const problem of check.problems) console.error(`${color.red("✗")} ${problem}`);
|
|
147
|
+
}
|
package/src/generate/auth.ts
CHANGED
|
@@ -60,7 +60,7 @@ const social = (manifest: Manifest, id: string, envPrefix: string) =>
|
|
|
60
60
|
* The token needs nothing: no origin, no tariff. Everything else needs one or
|
|
61
61
|
* the other, so the rule is simply "any carrier but the token".
|
|
62
62
|
*/
|
|
63
|
-
export const WIRED_CARRIERS = ["royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"] as const;
|
|
63
|
+
export const WIRED_CARRIERS = ["parcel2go", "royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"] as const;
|
|
64
64
|
export const needsShippingConfig = (id: string): boolean => WIRED_CARRIERS.includes(id as never) && id !== "nft";
|
|
65
65
|
|
|
66
66
|
export function renderAuthTs(manifest: Manifest): string {
|
|
@@ -82,6 +82,13 @@ export function renderAuthTs(manifest: Manifest): string {
|
|
|
82
82
|
const CARRIER_CALLS: Record<string, { call: string; label: string; import: string }> = {
|
|
83
83
|
// Every real carrier needs the ORIGIN it ships from, which is shop data
|
|
84
84
|
// rather than a credential — so it lives in an owned lib/shipping.ts.
|
|
85
|
+
parcel2go: {
|
|
86
|
+
label: "Parcel2Go",
|
|
87
|
+
// `from` is the collection address: Parcel2Go requires one on every
|
|
88
|
+
// order, because a courier is being sent to it.
|
|
89
|
+
call: `parcel2goProvider({ clientId: must(env, "PARCEL2GO_CLIENT_ID"), clientSecret: must(env, "PARCEL2GO_CLIENT_SECRET"), from: shipFrom, customer: parcel2goCustomer, currency: "${shop.currency}" })`,
|
|
90
|
+
import: `import { parcel2goProvider } from "@saastemly/better-commerce/providers/parcel2go";`,
|
|
91
|
+
},
|
|
85
92
|
"royal-mail": {
|
|
86
93
|
label: "Royal Mail",
|
|
87
94
|
// No `from`: Click & Drop posts from the account's registered
|
|
@@ -133,6 +140,7 @@ export function renderAuthTs(manifest: Manifest): string {
|
|
|
133
140
|
const shipping = [
|
|
134
141
|
...(carriers.some((id) => id !== "nft" && id !== "royal-mail") ? ["shipFrom"] : []),
|
|
135
142
|
...(carriers.includes("royal-mail") ? ["royalMailServices"] : []),
|
|
143
|
+
...(carriers.includes("parcel2go") ? ["parcel2goCustomer"] : []),
|
|
136
144
|
];
|
|
137
145
|
if (shipping.length > 0) carrierImports.push(`import { ${shipping.join(", ")} } from "./lib/shipping.ts";`);
|
|
138
146
|
const wantsNft = has(manifest, "nft");
|
package/src/generate/ci.ts
CHANGED
|
@@ -225,6 +225,8 @@ Create the token at **My Profile → API Tokens → Create Token → Custom toke
|
|
|
225
225
|
| Account | Workers R2 Storage: Edit | product images |
|
|
226
226
|
| Account | Account Settings: Read | confirming which account |
|
|
227
227
|
| Zone | Workers Routes: Edit (${zone}) | answering on your domain |
|
|
228
|
+
| Zone | Zone: Read (${zone}) | confirming the zone is on this account |
|
|
229
|
+
| Zone | DNS: Read (${zone}) | noticing a hostname that already answers |
|
|
228
230
|
| User | User Details: Read, Memberships: Read | wrangler asks at startup |
|
|
229
231
|
|
|
230
232
|
The token Cloudflare generates for its own Workers Builds will **not** do: it
|
package/src/generate/support.ts
CHANGED
|
@@ -275,9 +275,10 @@ export async function destinationFor(orderId: string): Promise<string | null> {
|
|
|
275
275
|
*/
|
|
276
276
|
export function renderShippingTs(manifest: Manifest): string {
|
|
277
277
|
const royalMail = has(manifest, "royal-mail");
|
|
278
|
+
const parcel2go = has(manifest, "parcel2go");
|
|
278
279
|
// Royal Mail posts from the address registered on the Click & Drop account,
|
|
279
280
|
// 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));
|
|
281
|
+
const origin = ["parcel2go", "dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id));
|
|
281
282
|
|
|
282
283
|
const imports = [
|
|
283
284
|
...(origin ? [`import type { DeliveryAddress } from "@saastemly/better-commerce/providers/delivery";`] : []),
|
|
@@ -301,7 +302,23 @@ export const shipFrom: DeliveryAddress = {
|
|
|
301
302
|
};
|
|
302
303
|
`;
|
|
303
304
|
|
|
304
|
-
|
|
305
|
+
const parcel2goBlock = `
|
|
306
|
+
/**
|
|
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.
|
|
314
|
+
*/
|
|
315
|
+
export const parcel2goCustomer = {
|
|
316
|
+
email: "TODO VERIFY: the email on your Parcel2Go account",
|
|
317
|
+
forename: "TODO VERIFY",
|
|
318
|
+
surname: "TODO VERIFY",
|
|
319
|
+
};
|
|
320
|
+
`;
|
|
321
|
+
return `${imports.join("\n")}\n${origin ? originBlock : ""}${parcel2go ? parcel2goBlock : ""}${royalMail ? ROYAL_MAIL_SERVICES() : ""}`;
|
|
305
322
|
}
|
|
306
323
|
|
|
307
324
|
export function renderErpTs(): string {
|