@saastemly/voidcommerce 0.19.1 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +240 -68
- package/dist/deploy/index.d.ts +9 -0
- package/dist/deploy/teardown.d.ts +62 -0
- package/dist/deploy/token-link.d.ts +15 -0
- package/dist/index-6e4eh08g.js +35 -0
- package/dist/{index-hsc97bf2.js → index-940vpkwa.js} +84 -2
- package/dist/{index-tk46w7yn.js → index-pghh8nnq.js} +18 -5
- package/dist/index.js +3 -2
- package/dist/{keys-knmqsqz1.js → keys-mewxnkwg.js} +1 -1
- package/dist/token-link-wxpp5xzp.js +11 -0
- package/package.json +1 -1
- package/src/cli.ts +3 -0
- package/src/deploy/index.ts +64 -0
- package/src/deploy/secrets.ts +97 -0
- package/src/deploy/teardown.ts +169 -0
- package/src/deploy/token-link.ts +76 -0
- package/src/generate/ci.ts +13 -2
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Project } from "../project";
|
|
2
|
+
/**
|
|
3
|
+
* `vc teardown --cloudflare` — undo a deploy, so the whole thing can be
|
|
4
|
+
* proved again from nothing.
|
|
5
|
+
*
|
|
6
|
+
* ── Why this exists ──────────────────────────────────────────────────────
|
|
7
|
+
*
|
|
8
|
+
* "Push and it goes live" is a claim, and a claim nobody can re-run is a
|
|
9
|
+
* story. Provisioning is the part most likely to break — it is the only
|
|
10
|
+
* step that creates rather than replaces, it runs once per shop, and it runs
|
|
11
|
+
* against an account whose state nobody controls. Being able to destroy and
|
|
12
|
+
* do it again is what turns the claim into something testable.
|
|
13
|
+
*
|
|
14
|
+
* ── This DELETES A PRODUCTION DATABASE ───────────────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* D1 deletion is not recoverable: there is no undo and no snapshot unless
|
|
17
|
+
* one was taken. Every order, customer and address in it goes. So this
|
|
18
|
+
* refuses by default, and the confirmation is TYPING THE SHOP'S DOMAIN —
|
|
19
|
+
* not `y`, because `y` is muscle memory and a domain is not.
|
|
20
|
+
*
|
|
21
|
+
* It also refuses outright when the manifest's domain does not match what
|
|
22
|
+
* the wrangler config points at, since that mismatch is the signal that you
|
|
23
|
+
* are standing in a different shop than you think.
|
|
24
|
+
*
|
|
25
|
+
* ── Idempotent, deliberately ─────────────────────────────────────────────
|
|
26
|
+
*
|
|
27
|
+
* Deleting what is already gone is success, not failure. That is what makes
|
|
28
|
+
* it usable in a loop: teardown, publish, teardown again, without a human
|
|
29
|
+
* reading each result to decide whether the next step is safe.
|
|
30
|
+
*/
|
|
31
|
+
export interface TeardownOptions {
|
|
32
|
+
/** Required. Without it this only ever prints what it would do. */
|
|
33
|
+
confirm: boolean;
|
|
34
|
+
/** Keep the D1 database — the one thing that holds data nobody can recreate. */
|
|
35
|
+
keepData: boolean;
|
|
36
|
+
dryRun: boolean;
|
|
37
|
+
}
|
|
38
|
+
interface WranglerConfig {
|
|
39
|
+
name?: string;
|
|
40
|
+
account_id?: string;
|
|
41
|
+
d1_databases?: Array<{
|
|
42
|
+
binding: string;
|
|
43
|
+
database_name: string;
|
|
44
|
+
database_id: string;
|
|
45
|
+
}>;
|
|
46
|
+
queues?: {
|
|
47
|
+
producers?: Array<{
|
|
48
|
+
queue: string;
|
|
49
|
+
}>;
|
|
50
|
+
consumers?: Array<{
|
|
51
|
+
queue: string;
|
|
52
|
+
}>;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** What a teardown would destroy, read from the config rather than guessed. */
|
|
56
|
+
export declare function teardownPlan(config: WranglerConfig, fallbackWorker: string): {
|
|
57
|
+
worker: string;
|
|
58
|
+
d1: string | null;
|
|
59
|
+
queues: string[];
|
|
60
|
+
};
|
|
61
|
+
export declare function teardownCloudflare(project: Project, opts: TeardownOptions): Promise<number>;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Manifest } from "../manifest";
|
|
2
|
+
/**
|
|
3
|
+
* The link, for this shop.
|
|
4
|
+
*
|
|
5
|
+
* The USER-token form, deliberately: it is the one that accepts the two
|
|
6
|
+
* user-scoped permissions wrangler wants, and account-owned tokens are
|
|
7
|
+
* documented as not taking `accountId`/`zoneId` at all. `zoneId=all` because
|
|
8
|
+
* a shop's zone is not known until the domain is on Cloudflare, and picking
|
|
9
|
+
* the wrong one is a worse failure than picking from a list.
|
|
10
|
+
*/
|
|
11
|
+
export declare function cloudflareTokenLink(manifest: Manifest): string;
|
|
12
|
+
/** The same permissions as a table, for anyone who would rather tick them. */
|
|
13
|
+
export declare function cloudflareTokenTable(): string;
|
|
14
|
+
/** Markdown, for DEPLOY.md. */
|
|
15
|
+
export declare function cloudflareTokenMarkdown(): string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// src/deploy/token-link.ts
|
|
2
|
+
var PERMISSIONS = [
|
|
3
|
+
["Workers Scripts: Edit", "workers_scripts", "edit", "deploy the worker"],
|
|
4
|
+
["D1: Edit", "d1", "edit", "create the database and apply migrations"],
|
|
5
|
+
["Queues: Edit", "queues", "edit", "create the order queue"],
|
|
6
|
+
["Workers KV Storage: Edit", "workers_kv_storage", "edit", "sessions and caches"],
|
|
7
|
+
["Workers R2 Storage: Edit", "workers_r2", "edit", "product images"],
|
|
8
|
+
["Account Settings: Read", "account_settings", "read", "confirm which account this is"],
|
|
9
|
+
["Zone: Read", "zone", "read", "confirm the domain's zone is on this account"],
|
|
10
|
+
["DNS: Read", "dns", "read", "notice a hostname that already answers"],
|
|
11
|
+
["Workers Routes: Edit", "workers_routes", "edit", "answer on your domain"],
|
|
12
|
+
["User Details: Read", "user_details", "read", "wrangler asks at startup"],
|
|
13
|
+
["Memberships: Read", "memberships", "read", "the same"]
|
|
14
|
+
];
|
|
15
|
+
function cloudflareTokenLink(manifest) {
|
|
16
|
+
const groups = PERMISSIONS.map(([, key, type]) => ({ key, type }));
|
|
17
|
+
const params = new URLSearchParams({
|
|
18
|
+
permissionGroupKeys: JSON.stringify(groups),
|
|
19
|
+
accountId: "*",
|
|
20
|
+
zoneId: "all",
|
|
21
|
+
name: `${manifest.shop.domain} deploy`
|
|
22
|
+
});
|
|
23
|
+
return `https://dash.cloudflare.com/profile/api-tokens?${params.toString()}`;
|
|
24
|
+
}
|
|
25
|
+
function cloudflareTokenTable() {
|
|
26
|
+
const width = Math.max(...PERMISSIONS.map(([label]) => label.length));
|
|
27
|
+
return PERMISSIONS.map(([label, , , why]) => ` ${label.padEnd(width)} ${why}`).join(`
|
|
28
|
+
`);
|
|
29
|
+
}
|
|
30
|
+
function cloudflareTokenMarkdown() {
|
|
31
|
+
return ["| permission | for |", "|---|---|", ...PERMISSIONS.map(([label, , , why]) => `| ${label} | ${why} |`)].join(`
|
|
32
|
+
`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export { cloudflareTokenLink, cloudflareTokenTable, cloudflareTokenMarkdown };
|
|
@@ -370,7 +370,7 @@ async function decryptSecrets(project) {
|
|
|
370
370
|
const root = project.root;
|
|
371
371
|
if (!existsSync2(join2(root, SECRETS_FILE)))
|
|
372
372
|
return { error: `${SECRETS_FILE} does not exist — \`vc secrets init\` writes one` };
|
|
373
|
-
const { localPrivateKey } = await import("./keys-
|
|
373
|
+
const { localPrivateKey } = await import("./keys-mewxnkwg.js");
|
|
374
374
|
const privateKey = process.env[PRIVATE_KEY_VAR] || localPrivateKey(root) || "";
|
|
375
375
|
if (!privateKey) {
|
|
376
376
|
return {
|
|
@@ -382,7 +382,7 @@ async function decryptSecrets(project) {
|
|
|
382
382
|
}
|
|
383
383
|
const expected = committedPublicKey(root);
|
|
384
384
|
if (expected) {
|
|
385
|
-
const { publicKeyFor } = await import("./keys-
|
|
385
|
+
const { publicKeyFor } = await import("./keys-mewxnkwg.js");
|
|
386
386
|
const derived = await publicKeyFor(privateKey);
|
|
387
387
|
if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
|
|
388
388
|
return {
|
|
@@ -431,6 +431,8 @@ async function secretsCommand(project, args) {
|
|
|
431
431
|
return setSecretValue(project, args.slice(1));
|
|
432
432
|
if (args.includes("--sync"))
|
|
433
433
|
return syncSecrets(project, args.includes("--prune"));
|
|
434
|
+
if (args.includes("--adopt"))
|
|
435
|
+
return adoptSecrets(project, args.includes("--confirm"));
|
|
434
436
|
if (!existsSync2(join2(root, SECRETS_FILE))) {
|
|
435
437
|
console.log(`
|
|
436
438
|
${color.yellow("No " + SECRETS_FILE + " yet.")} Secrets are set by hand with \`wrangler secret put\`,
|
|
@@ -548,6 +550,17 @@ vc: ${SECRETS_FILE} has no DOTENV_PUBLIC_KEY_SECRETS, so there is nothing to enc
|
|
|
548
550
|
return 1;
|
|
549
551
|
}
|
|
550
552
|
const known = allEnvKeys(project.manifest).find((key) => key.key === name);
|
|
553
|
+
if (name === "CLOUDFLARE_API_TOKEN") {
|
|
554
|
+
const { cloudflareTokenLink, cloudflareTokenTable } = await import("./token-link-wxpp5xzp.js");
|
|
555
|
+
console.log(`
|
|
556
|
+
${color.bold("Open this — the permissions are already selected:")}
|
|
557
|
+
|
|
558
|
+
${color.cyan(cloudflareTokenLink(project.manifest))}
|
|
559
|
+
|
|
560
|
+
${color.dim("It opens your own dashboard; the token is shown only to you. What it grants:")}
|
|
561
|
+
${color.dim(cloudflareTokenTable())}
|
|
562
|
+
`);
|
|
563
|
+
}
|
|
551
564
|
const value = await readValue(name, known?.breaks, known?.where);
|
|
552
565
|
if (value === null)
|
|
553
566
|
return 1;
|
|
@@ -703,6 +716,75 @@ ${color.green("✓")} every secret this shop needs is already declared.
|
|
|
703
716
|
}
|
|
704
717
|
return 0;
|
|
705
718
|
}
|
|
719
|
+
async function adoptSecrets(project, confirmed) {
|
|
720
|
+
const root = project.root;
|
|
721
|
+
const path = join2(root, SECRETS_FILE);
|
|
722
|
+
if (!existsSync2(path)) {
|
|
723
|
+
console.error(`
|
|
724
|
+
vc: no ${SECRETS_FILE} here — \`vc secrets --init\` starts a fresh one.
|
|
725
|
+
`);
|
|
726
|
+
return 1;
|
|
727
|
+
}
|
|
728
|
+
const { localPrivateKey, publicKeyFor, generateKeypair } = await import("./keys-mewxnkwg.js");
|
|
729
|
+
const current = committedPublicKey(root);
|
|
730
|
+
const mine = process.env[PRIVATE_KEY_VAR] || localPrivateKey(root);
|
|
731
|
+
if (mine && current && (await publicKeyFor(mine))?.toLowerCase() === current.toLowerCase()) {
|
|
732
|
+
console.error(`
|
|
733
|
+
vc: you already hold the key to this shop, so this is not a fork.
|
|
734
|
+
Adopting would replace the key and blank every value you can currently read.
|
|
735
|
+
If that is really what you want, remove the key first.
|
|
736
|
+
`);
|
|
737
|
+
return 1;
|
|
738
|
+
}
|
|
739
|
+
const declared = [...declaredSecretNames(root)];
|
|
740
|
+
if (!confirmed) {
|
|
741
|
+
console.log(`
|
|
742
|
+
${color.bold("Adopting this shop would:")}
|
|
743
|
+
|
|
744
|
+
` + ` · make a NEW encryption key, replacing the one in ${SECRETS_FILE}
|
|
745
|
+
` + ` · reset ${declared.length} value${declared.length === 1 ? "" : "s"} to \`unset\`
|
|
746
|
+
|
|
747
|
+
` + ` The current values cannot be read here anyway — they belong to whoever
|
|
748
|
+
` + ` set them. This makes that explicit rather than leaving their ciphertext
|
|
749
|
+
sitting in a shop you now own.
|
|
750
|
+
|
|
751
|
+
${color.cyan("vc secrets --adopt --confirm")}
|
|
752
|
+
`);
|
|
753
|
+
return 1;
|
|
754
|
+
}
|
|
755
|
+
const pair = await generateKeypair();
|
|
756
|
+
if (!pair) {
|
|
757
|
+
console.error("vc: @dotenvx/dotenvx is not installed here.");
|
|
758
|
+
return 1;
|
|
759
|
+
}
|
|
760
|
+
const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
|
|
761
|
+
const body = readFileSync(path, "utf8").split(`
|
|
762
|
+
`).map((line) => {
|
|
763
|
+
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(line);
|
|
764
|
+
if (!match)
|
|
765
|
+
return line;
|
|
766
|
+
if (match[1] === "DOTENV_PUBLIC_KEY_SECRETS")
|
|
767
|
+
return `DOTENV_PUBLIC_KEY_SECRETS="${pair.publicKey}"`;
|
|
768
|
+
return `${match[1]}=unset`;
|
|
769
|
+
}).join(`
|
|
770
|
+
`);
|
|
771
|
+
writeFileSync(path, body);
|
|
772
|
+
writeFileSync(join2(root, ".env.keys"), `${PRIVATE_KEY_VAR}="${pair.privateKey}"
|
|
773
|
+
`, { mode: 384 });
|
|
774
|
+
console.log(`
|
|
775
|
+
${color.green("✓")} adopted: a new key, and ${declared.length} value${declared.length === 1 ? "" : "s"} reset to \`unset\`
|
|
776
|
+
${color.green("+")} .env.keys holds the new private key — gitignored, and yours
|
|
777
|
+
|
|
778
|
+
` + `Now set what this shop needs:
|
|
779
|
+
${required.slice(0, 4).map((key) => ` ${color.cyan(`vc secrets set ${key.key}`)}`).join(`
|
|
780
|
+
`)}
|
|
781
|
+
` + (required.length > 4 ? ` ${color.dim(`…and ${required.length - 4} more — \`vc secrets\` lists them`)}
|
|
782
|
+
` : "") + color.yellow(`
|
|
783
|
+
! The previous owner's ciphertext is still in git history. It is unreadable
|
|
784
|
+
without their key, but a fresh repository is cleaner than a fork.
|
|
785
|
+
`));
|
|
786
|
+
return 0;
|
|
787
|
+
}
|
|
706
788
|
|
|
707
789
|
// src/deploy/keys.ts
|
|
708
790
|
var LOCAL_KEY_FILE = ".env.keys";
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
renderEnvTs,
|
|
14
14
|
secretValue,
|
|
15
15
|
unsetSecretNames
|
|
16
|
-
} from "./index-
|
|
16
|
+
} from "./index-940vpkwa.js";
|
|
17
17
|
import {
|
|
18
18
|
MANIFEST_FILE,
|
|
19
19
|
has,
|
|
@@ -30,6 +30,9 @@ import {
|
|
|
30
30
|
import {
|
|
31
31
|
CHOICES
|
|
32
32
|
} from "./index-xyjhy6kp.js";
|
|
33
|
+
import {
|
|
34
|
+
cloudflareTokenMarkdown
|
|
35
|
+
} from "./index-6e4eh08g.js";
|
|
33
36
|
import {
|
|
34
37
|
__require
|
|
35
38
|
} from "./index-0v6na3yp.js";
|
|
@@ -1380,7 +1383,7 @@ import color from "picocolors";
|
|
|
1380
1383
|
// package.json
|
|
1381
1384
|
var package_default = {
|
|
1382
1385
|
name: "@saastemly/voidcommerce",
|
|
1383
|
-
version: "0.
|
|
1386
|
+
version: "0.21.0",
|
|
1384
1387
|
description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
|
|
1385
1388
|
type: "module",
|
|
1386
1389
|
license: "MIT",
|
|
@@ -1859,6 +1862,7 @@ jobs:
|
|
|
1859
1862
|
}
|
|
1860
1863
|
function renderDeployReadme(manifest, zone2, hosts) {
|
|
1861
1864
|
const worker = manifest.shop.domain.split(".")[0];
|
|
1865
|
+
const TOKEN_TABLE = cloudflareTokenMarkdown();
|
|
1862
1866
|
return `# Going live
|
|
1863
1867
|
|
|
1864
1868
|
\`${manifest.shop.domain}\` on Cloudflare Workers. Generated by \`vc init\`;
|
|
@@ -1913,8 +1917,17 @@ ordinary secret in \`${SECRETS_FILE}\`: encrypted, committed, and read by the
|
|
|
1913
1917
|
deploy out of what you pushed.
|
|
1914
1918
|
|
|
1915
1919
|
The token itself has to be fetched by a person, once, because there is no
|
|
1916
|
-
OIDC between GitHub and Cloudflare and nothing else can issue one.
|
|
1917
|
-
|
|
1920
|
+
OIDC between GitHub and Cloudflare and nothing else can issue one. You do
|
|
1921
|
+
not have to find the permissions yourself:
|
|
1922
|
+
|
|
1923
|
+
\`\`\`sh
|
|
1924
|
+
vc secrets set CLOUDFLARE_API_TOKEN
|
|
1925
|
+
\`\`\`
|
|
1926
|
+
|
|
1927
|
+
prints a Cloudflare link with all of them **already selected**, then takes
|
|
1928
|
+
the token. It opens your own dashboard and the value is shown only to you.
|
|
1929
|
+
|
|
1930
|
+
${TOKEN_TABLE}
|
|
1918
1931
|
|
|
1919
1932
|
### The zone
|
|
1920
1933
|
|
|
@@ -3617,4 +3630,4 @@ async function importHelp() {
|
|
|
3617
3630
|
return 0;
|
|
3618
3631
|
}
|
|
3619
3632
|
|
|
3620
|
-
export { renderAuthTs, FRONTEND_BRANCH, renderFrontendApiTs, renderFrontendEnvProduction, renderFrontendViteConfig, renderStorefrontPage, renderFrontendWorkflow, runVoid, runInherit, captureVoid, isVoidApp, voidAppsIn, STRICT_APP, VOID_VERSION, strictDependencies, renderVoidPatch, renderViteConfig, renderStrictTsconfig, renderVoidJson, renderDbSchema, renderDbSeed, renderLayoutTsx, renderAppCss, renderIndexServer, INDEX_PAGE, renderDashboardPage, CLIENT_ONLY, renderSignInPage, renderAuthClient, renderContentTs, renderCron, renderQueue, renderLiveStream, renderLiveRoute, renderStrictAppPackageJson, DATA_README, BRANDING_README, MIGRATIONS_README, finishStrict, line, row, box, fullHelp, initHelp, version, findProject, ensureGenerated, DIST_DIR, DIST_BRANCH, distCommand, distHelp, renderDistWorkflow, renderDeployReadme, renderRequirementsTs, renderDomainTs, generate, parseJsonc, upsertJsonc, findWrangler, parseWhoAmI, productionEnv, routeProblem, preflight, printPreflight, deployCloudflare, importCommand, importHelp };
|
|
3633
|
+
export { renderAuthTs, FRONTEND_BRANCH, renderFrontendApiTs, renderFrontendEnvProduction, renderFrontendViteConfig, renderStorefrontPage, renderFrontendWorkflow, runVoid, runInherit, captureVoid, isVoidApp, voidAppsIn, STRICT_APP, VOID_VERSION, strictDependencies, renderVoidPatch, renderViteConfig, renderStrictTsconfig, renderVoidJson, renderDbSchema, renderDbSeed, renderLayoutTsx, renderAppCss, renderIndexServer, INDEX_PAGE, renderDashboardPage, CLIENT_ONLY, renderSignInPage, renderAuthClient, renderContentTs, renderCron, renderQueue, renderLiveStream, renderLiveRoute, renderStrictAppPackageJson, DATA_README, BRANDING_README, MIGRATIONS_README, finishStrict, line, row, box, fullHelp, initHelp, version, findProject, ensureGenerated, DIST_DIR, DIST_BRANCH, distCommand, distHelp, renderDistWorkflow, renderDeployReadme, renderRequirementsTs, renderDomainTs, generate, parseJsonc, upsertJsonc, findWrangler, wrangler, parseWhoAmI, productionEnv, routeProblem, preflight, printPreflight, deployCloudflare, importCommand, importHelp };
|
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-pghh8nnq.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-940vpkwa.js";
|
|
64
64
|
import {
|
|
65
65
|
LAYOUTS,
|
|
66
66
|
MANIFEST_FILE,
|
|
@@ -99,6 +99,7 @@ import {
|
|
|
99
99
|
TAX,
|
|
100
100
|
UI
|
|
101
101
|
} from "./index-xyjhy6kp.js";
|
|
102
|
+
import"./index-6e4eh08g.js";
|
|
102
103
|
import"./index-0v6na3yp.js";
|
|
103
104
|
export {
|
|
104
105
|
zoneOf,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saastemly/voidcommerce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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/cli.ts
CHANGED
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
preflightHelp,
|
|
9
9
|
secretsCliCommand,
|
|
10
10
|
secretsHelp,
|
|
11
|
+
teardownCliCommand,
|
|
12
|
+
teardownHelp,
|
|
11
13
|
} from "./deploy/index";
|
|
12
14
|
import { distCommand, distHelp } from "./dist";
|
|
13
15
|
import { importCommand, importHelp } from "./import";
|
|
@@ -50,6 +52,7 @@ export const EXTENDED: Record<string, Extended> = {
|
|
|
50
52
|
preflight: { run: preflightCommand, help: preflightHelp },
|
|
51
53
|
secrets: { run: secretsCliCommand, help: secretsHelp },
|
|
52
54
|
keys: { run: keysCliCommand, help: keysHelp },
|
|
55
|
+
teardown: { run: teardownCliCommand, help: teardownHelp },
|
|
53
56
|
// Called by the generated pre-commit hook; exit code is the interface.
|
|
54
57
|
guard: { run: guardCommand, help: async () => guardCommand() },
|
|
55
58
|
dev: { run: appScript("dev"), help: appScriptHelp("dev") },
|
package/src/deploy/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { existsSync } from "node:fs";
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { PRIVATE_KEY_VAR, SECRETS_FILE, encryptInto, plaintextSecretEntries, plaintextSecretNames, run, secretsCommand } from "./secrets";
|
|
10
10
|
import { LOCAL_KEY_FILE, committedPublicKey, ignoresKeyFile, keysCommand } from "./keys";
|
|
11
|
+
import { teardownCloudflare } from "./teardown";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* `vc deploy` extends `void deploy`: vc's preflight first, then void's
|
|
@@ -310,3 +311,66 @@ export async function guardCommand(): Promise<number> {
|
|
|
310
311
|
|
|
311
312
|
|
|
312
313
|
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* `vc teardown` — destroy this shop's Cloudflare infrastructure.
|
|
318
|
+
*
|
|
319
|
+
* The confirmation is the shop's DOMAIN, not `y`. A `y` is muscle memory;
|
|
320
|
+
* typing `devprints.saastemly.com` is not, and this deletes a database that
|
|
321
|
+
* has no undo.
|
|
322
|
+
*/
|
|
323
|
+
export async function teardownCliCommand(args: string[]): Promise<number> {
|
|
324
|
+
const project = await findProject();
|
|
325
|
+
if (!project) {
|
|
326
|
+
console.error("vc: no voidcommerce.json here.");
|
|
327
|
+
return 1;
|
|
328
|
+
}
|
|
329
|
+
if (!args.includes("--cloudflare")) {
|
|
330
|
+
console.error("vc: teardown only knows how to undo a Cloudflare deploy. `vc teardown --cloudflare`.");
|
|
331
|
+
return 1;
|
|
332
|
+
}
|
|
333
|
+
const typed = args[args.indexOf("--confirm") + 1];
|
|
334
|
+
const confirmed = args.includes("--confirm") && typed === project.manifest.shop.domain;
|
|
335
|
+
if (args.includes("--confirm") && !confirmed) {
|
|
336
|
+
console.error(
|
|
337
|
+
`\nvc: --confirm needs this shop's domain, exactly:\n\n` +
|
|
338
|
+
` vc teardown --cloudflare --confirm ${project.manifest.shop.domain}\n\n` +
|
|
339
|
+
` You typed ${typed ? `"${typed}"` : "nothing"}. Naming the shop is the check — it is\n` +
|
|
340
|
+
" what stops this running in the wrong directory.\n",
|
|
341
|
+
);
|
|
342
|
+
return 1;
|
|
343
|
+
}
|
|
344
|
+
return teardownCloudflare(project, {
|
|
345
|
+
confirm: confirmed,
|
|
346
|
+
keepData: args.includes("--keep-data"),
|
|
347
|
+
dryRun: args.includes("--dry-run"),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function teardownHelp(): Promise<number> {
|
|
352
|
+
const width = 80;
|
|
353
|
+
console.log(
|
|
354
|
+
box("vc teardown", [
|
|
355
|
+
line("Destroy this shop's Cloudflare infrastructure, so the whole", width),
|
|
356
|
+
line("publish-to-live flow can be proved again from nothing.", width),
|
|
357
|
+
line("", width),
|
|
358
|
+
...row("vc teardown --cloudflare --dry-run", "list what would be destroyed, touch nothing", width, 2),
|
|
359
|
+
...row("vc teardown --cloudflare --confirm <domain>", "do it", width, 2),
|
|
360
|
+
...row("--keep-data", "leave the D1 database alone", width, 2),
|
|
361
|
+
line("", width),
|
|
362
|
+
line(color.bold("Read this first"), width),
|
|
363
|
+
line("D1 deletion has NO UNDO. Every order, customer and address in the", width),
|
|
364
|
+
line("database goes with it, and there is no snapshot unless you took one.", width),
|
|
365
|
+
line("", width),
|
|
366
|
+
line("So --confirm takes the shop's DOMAIN, not `y`: a `y` is muscle memory,", width),
|
|
367
|
+
line("and typing the domain is what stops this running in the wrong shop.", width),
|
|
368
|
+
line("", width),
|
|
369
|
+
line("It is idempotent — deleting what is already gone is success — and it", width),
|
|
370
|
+
line("clears the recorded database id, because `wrangler deploy` FAILS on a", width),
|
|
371
|
+
line("dangling id rather than making a new database. Secrets are untouched:", width),
|
|
372
|
+
line("this removes infrastructure, not configuration.", width),
|
|
373
|
+
], width),
|
|
374
|
+
);
|
|
375
|
+
return 0;
|
|
376
|
+
}
|
package/src/deploy/secrets.ts
CHANGED
|
@@ -277,6 +277,7 @@ export async function secretsCommand(project: Project, args: string[]): Promise<
|
|
|
277
277
|
if (args.includes("--init")) return initSecrets(project);
|
|
278
278
|
if (args[0] === "set") return setSecretValue(project, args.slice(1));
|
|
279
279
|
if (args.includes("--sync")) return syncSecrets(project, args.includes("--prune"));
|
|
280
|
+
if (args.includes("--adopt")) return adoptSecrets(project, args.includes("--confirm"));
|
|
280
281
|
|
|
281
282
|
if (!existsSync(join(root, SECRETS_FILE))) {
|
|
282
283
|
console.log(
|
|
@@ -405,6 +406,21 @@ export async function setSecretValue(project: Project, args: string[]): Promise<
|
|
|
405
406
|
// A known key gets its consequence shown, so a person setting one knows
|
|
406
407
|
// what it is for without leaving the terminal.
|
|
407
408
|
const known = allEnvKeys(project.manifest).find((key) => key.key === name);
|
|
409
|
+
|
|
410
|
+
// The one key whose "where" is ten checkboxes in a dashboard. Cloudflare
|
|
411
|
+
// takes the permissions as URL parameters, so this opens the form with all
|
|
412
|
+
// of them already ticked — which removes the failure where one missed row
|
|
413
|
+
// yields a token that authenticates and then fails mid-deploy.
|
|
414
|
+
if (name === "CLOUDFLARE_API_TOKEN") {
|
|
415
|
+
const { cloudflareTokenLink, cloudflareTokenTable } = await import("./token-link");
|
|
416
|
+
console.log(
|
|
417
|
+
`\n${color.bold("Open this — the permissions are already selected:")}\n\n` +
|
|
418
|
+
` ${color.cyan(cloudflareTokenLink(project.manifest))}\n\n` +
|
|
419
|
+
`${color.dim("It opens your own dashboard; the token is shown only to you. What it grants:")}\n` +
|
|
420
|
+
`${color.dim(cloudflareTokenTable())}\n`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
408
424
|
const value = await readValue(name, known?.breaks, known?.where);
|
|
409
425
|
if (value === null) return 1;
|
|
410
426
|
|
|
@@ -583,3 +599,84 @@ async function syncSecrets(project: Project, prune: boolean): Promise<number> {
|
|
|
583
599
|
}
|
|
584
600
|
return 0;
|
|
585
601
|
}
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* `vc secrets --adopt` — take this shop over as a different person.
|
|
606
|
+
*
|
|
607
|
+
* The fork case. Somebody clones a shop, and every value in `.env.secrets`
|
|
608
|
+
* belongs to whoever set it: their Stripe key, their Cloudflare token, their
|
|
609
|
+
* home address. They cannot read any of it — the private key is not theirs —
|
|
610
|
+
* but the ciphertext is right there, and the shop would deploy into somebody
|
|
611
|
+
* else's account if the key ever leaked.
|
|
612
|
+
*
|
|
613
|
+
* So this cuts the connection: a NEW keypair, every value back to `unset`,
|
|
614
|
+
* and the old public key replaced. What was encrypted to the old key stays
|
|
615
|
+
* in git history and is now unreadable by anyone here, which is the point —
|
|
616
|
+
* the new owner starts with a shop that has their secrets or none.
|
|
617
|
+
*
|
|
618
|
+
* It refuses when the current key IS readable here, because then this is not
|
|
619
|
+
* a fork, it is somebody about to destroy their own values by accident.
|
|
620
|
+
*/
|
|
621
|
+
async function adoptSecrets(project: Project, confirmed: boolean): Promise<number> {
|
|
622
|
+
const root = project.root;
|
|
623
|
+
const path = join(root, SECRETS_FILE);
|
|
624
|
+
if (!existsSync(path)) {
|
|
625
|
+
console.error(`\nvc: no ${SECRETS_FILE} here — \`vc secrets --init\` starts a fresh one.\n`);
|
|
626
|
+
return 1;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const { localPrivateKey, publicKeyFor, generateKeypair } = await import("./keys");
|
|
630
|
+
const current = committedPublicKey(root);
|
|
631
|
+
const mine = process.env[PRIVATE_KEY_VAR] || localPrivateKey(root);
|
|
632
|
+
if (mine && current && (await publicKeyFor(mine))?.toLowerCase() === current.toLowerCase()) {
|
|
633
|
+
console.error(
|
|
634
|
+
`\nvc: you already hold the key to this shop, so this is not a fork.\n` +
|
|
635
|
+
` Adopting would replace the key and blank every value you can currently read.\n` +
|
|
636
|
+
` If that is really what you want, remove the key first.\n`,
|
|
637
|
+
);
|
|
638
|
+
return 1;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const declared = [...declaredSecretNames(root)];
|
|
642
|
+
if (!confirmed) {
|
|
643
|
+
console.log(
|
|
644
|
+
`\n${color.bold("Adopting this shop would:")}\n\n` +
|
|
645
|
+
` · make a NEW encryption key, replacing the one in ${SECRETS_FILE}\n` +
|
|
646
|
+
` · reset ${declared.length} value${declared.length === 1 ? "" : "s"} to \`unset\`\n\n` +
|
|
647
|
+
` The current values cannot be read here anyway — they belong to whoever\n` +
|
|
648
|
+
` set them. This makes that explicit rather than leaving their ciphertext\n` +
|
|
649
|
+
` sitting in a shop you now own.\n\n` +
|
|
650
|
+
` ${color.cyan("vc secrets --adopt --confirm")}\n`,
|
|
651
|
+
);
|
|
652
|
+
return 1;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const pair = await generateKeypair();
|
|
656
|
+
if (!pair) {
|
|
657
|
+
console.error("vc: @dotenvx/dotenvx is not installed here.");
|
|
658
|
+
return 1;
|
|
659
|
+
}
|
|
660
|
+
const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
|
|
661
|
+
const body = readFileSync(path, "utf8")
|
|
662
|
+
.split("\n")
|
|
663
|
+
.map((line) => {
|
|
664
|
+
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(line);
|
|
665
|
+
if (!match) return line;
|
|
666
|
+
if (match[1] === "DOTENV_PUBLIC_KEY_SECRETS") return `DOTENV_PUBLIC_KEY_SECRETS="${pair.publicKey}"`;
|
|
667
|
+
return `${match[1]}=unset`;
|
|
668
|
+
})
|
|
669
|
+
.join("\n");
|
|
670
|
+
writeFileSync(path, body);
|
|
671
|
+
writeFileSync(join(root, ".env.keys"), `${PRIVATE_KEY_VAR}="${pair.privateKey}"\n`, { mode: 0o600 });
|
|
672
|
+
|
|
673
|
+
console.log(
|
|
674
|
+
`\n${color.green("✓")} adopted: a new key, and ${declared.length} value${declared.length === 1 ? "" : "s"} reset to \`unset\`\n` +
|
|
675
|
+
`${color.green("+")} .env.keys holds the new private key — gitignored, and yours\n\n` +
|
|
676
|
+
`Now set what this shop needs:\n` +
|
|
677
|
+
`${required.slice(0, 4).map((key) => ` ${color.cyan(`vc secrets set ${key.key}`)}`).join("\n")}\n` +
|
|
678
|
+
(required.length > 4 ? ` ${color.dim(`…and ${required.length - 4} more — \`vc secrets\` lists them`)}\n` : "") +
|
|
679
|
+
color.yellow(`\n! The previous owner's ciphertext is still in git history. It is unreadable\n without their key, but a fresh repository is cleaner than a fork.\n`),
|
|
680
|
+
);
|
|
681
|
+
return 0;
|
|
682
|
+
}
|