@saastemly/voidcommerce 0.20.0 → 0.22.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.d.ts +13 -0
- package/dist/cli.js +239 -68
- package/dist/deploy/index.d.ts +9 -0
- package/dist/deploy/teardown.d.ts +62 -0
- package/dist/{index-07ppzmgt.js → index-2kqz80nx.js} +3 -3
- package/dist/{index-vpcnasre.js → index-t1ggktg8.js} +86 -8
- package/dist/index.js +2 -2
- package/dist/{keys-fbw9fdby.js → keys-1h4zpvd7.js} +1 -1
- package/package.json +1 -1
- package/src/catalog.ts +13 -0
- package/src/cli.ts +3 -0
- package/src/deploy/index.ts +64 -0
- package/src/deploy/secrets.ts +97 -5
- package/src/deploy/teardown.ts +169 -0
- package/src/generate/env.ts +10 -4
|
@@ -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 {};
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
renderEnvTs,
|
|
14
14
|
secretValue,
|
|
15
15
|
unsetSecretNames
|
|
16
|
-
} from "./index-
|
|
16
|
+
} from "./index-t1ggktg8.js";
|
|
17
17
|
import {
|
|
18
18
|
MANIFEST_FILE,
|
|
19
19
|
has,
|
|
@@ -1383,7 +1383,7 @@ import color from "picocolors";
|
|
|
1383
1383
|
// package.json
|
|
1384
1384
|
var package_default = {
|
|
1385
1385
|
name: "@saastemly/voidcommerce",
|
|
1386
|
-
version: "0.
|
|
1386
|
+
version: "0.22.0",
|
|
1387
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`.",
|
|
1388
1388
|
type: "module",
|
|
1389
1389
|
license: "MIT",
|
|
@@ -3630,4 +3630,4 @@ async function importHelp() {
|
|
|
3630
3630
|
return 0;
|
|
3631
3631
|
}
|
|
3632
3632
|
|
|
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, 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 };
|
|
@@ -118,12 +118,14 @@ var BASE = [
|
|
|
118
118
|
{
|
|
119
119
|
key: "CLOUDFLARE_API_TOKEN",
|
|
120
120
|
breaks: "nothing deploys: no worker, no database, no migrations",
|
|
121
|
-
where: "dash.cloudflare.com/profile/api-tokens → Create Token → Custom token"
|
|
121
|
+
where: "dash.cloudflare.com/profile/api-tokens → Create Token → Custom token",
|
|
122
|
+
deployOnly: true
|
|
122
123
|
},
|
|
123
124
|
{
|
|
124
125
|
key: "CLOUDFLARE_ACCOUNT_ID",
|
|
125
126
|
breaks: "wrangler cannot tell which account to deploy into, and refuses rather than guessing",
|
|
126
|
-
where: "the Cloudflare dashboard sidebar, or
|
|
127
|
+
where: "the Cloudflare dashboard sidebar, or read from the token on first deploy",
|
|
128
|
+
deployOnly: true
|
|
127
129
|
},
|
|
128
130
|
{
|
|
129
131
|
key: "SHOP_DOMAIN",
|
|
@@ -174,7 +176,7 @@ function allEnvKeys(manifest) {
|
|
|
174
176
|
}
|
|
175
177
|
var NUMERIC = new Set(["SHIPPING_DOMESTIC_MINOR", "SHIPPING_FREE_FROM_MINOR"]);
|
|
176
178
|
function renderEnvTs(manifest) {
|
|
177
|
-
const keys = allEnvKeys(manifest);
|
|
179
|
+
const keys = allEnvKeys(manifest).filter((key) => !key.deployOnly);
|
|
178
180
|
const lines = keys.map((key) => {
|
|
179
181
|
const helper = NUMERIC.has(key.key) ? "number()" : "string()";
|
|
180
182
|
const doc = [` /** ${key.breaks}${key.where ? ` — from: ${key.where}` : ""} */`];
|
|
@@ -203,7 +205,7 @@ ${lines.join(`
|
|
|
203
205
|
`;
|
|
204
206
|
}
|
|
205
207
|
function renderEnvExample(manifest) {
|
|
206
|
-
const keys = allEnvKeys(manifest);
|
|
208
|
+
const keys = allEnvKeys(manifest).filter((key) => !key.deployOnly);
|
|
207
209
|
return [
|
|
208
210
|
"# Every key is required. Copy to .env for local development.",
|
|
209
211
|
"# `unset` is the one value that reads as absent — for a credential you do not have yet.",
|
|
@@ -215,7 +217,7 @@ function renderEnvExample(manifest) {
|
|
|
215
217
|
}
|
|
216
218
|
var MANIFEST_OWNED_ENV = new Set(["SHOP_DOMAIN", "SHOP_ZONE", "EMAIL_FROM", "GITHUB_PAGES_HOST"]);
|
|
217
219
|
function renderEnvLocal(manifest) {
|
|
218
|
-
const keys = allEnvKeys(manifest);
|
|
220
|
+
const keys = allEnvKeys(manifest).filter((key) => !key.deployOnly);
|
|
219
221
|
const local = {
|
|
220
222
|
SHOP_DOMAIN: `${manifest.shop.domain.split(".")[0]}.test`,
|
|
221
223
|
GITHUB_PAGES_HOST: manifest.shop.pagesHost ?? "",
|
|
@@ -370,7 +372,7 @@ async function decryptSecrets(project) {
|
|
|
370
372
|
const root = project.root;
|
|
371
373
|
if (!existsSync2(join2(root, SECRETS_FILE)))
|
|
372
374
|
return { error: `${SECRETS_FILE} does not exist — \`vc secrets init\` writes one` };
|
|
373
|
-
const { localPrivateKey } = await import("./keys-
|
|
375
|
+
const { localPrivateKey } = await import("./keys-1h4zpvd7.js");
|
|
374
376
|
const privateKey = process.env[PRIVATE_KEY_VAR] || localPrivateKey(root) || "";
|
|
375
377
|
if (!privateKey) {
|
|
376
378
|
return {
|
|
@@ -382,7 +384,7 @@ async function decryptSecrets(project) {
|
|
|
382
384
|
}
|
|
383
385
|
const expected = committedPublicKey(root);
|
|
384
386
|
if (expected) {
|
|
385
|
-
const { publicKeyFor } = await import("./keys-
|
|
387
|
+
const { publicKeyFor } = await import("./keys-1h4zpvd7.js");
|
|
386
388
|
const derived = await publicKeyFor(privateKey);
|
|
387
389
|
if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
|
|
388
390
|
return {
|
|
@@ -402,8 +404,13 @@ async function decryptSecrets(project) {
|
|
|
402
404
|
if (result.code !== 0)
|
|
403
405
|
return { error: `dotenvx could not decrypt ${SECRETS_FILE}: ${result.out.trim().split(`
|
|
404
406
|
`).slice(-2).join(" ")}` };
|
|
407
|
+
const deployOnly = new Set(allEnvKeys(project.manifest).filter((key) => key.deployOnly).map((key) => key.key));
|
|
405
408
|
const lines = result.out.split(`
|
|
406
|
-
`).filter((line) =>
|
|
409
|
+
`).filter((line) => {
|
|
410
|
+
if (!/^\s*[A-Z][A-Z0-9_]*\s*=/.test(line) || line.trimStart().startsWith("DOTENV_"))
|
|
411
|
+
return false;
|
|
412
|
+
return !deployOnly.has(line.slice(0, line.indexOf("=")).trim());
|
|
413
|
+
});
|
|
407
414
|
const names = lines.map((line) => line.slice(0, line.indexOf("=")).trim());
|
|
408
415
|
const path = join2(tmpdir(), `vc-secrets-${process.pid}-${Date.now()}.env`);
|
|
409
416
|
writeFileSync(path, `${lines.join(`
|
|
@@ -431,6 +438,8 @@ async function secretsCommand(project, args) {
|
|
|
431
438
|
return setSecretValue(project, args.slice(1));
|
|
432
439
|
if (args.includes("--sync"))
|
|
433
440
|
return syncSecrets(project, args.includes("--prune"));
|
|
441
|
+
if (args.includes("--adopt"))
|
|
442
|
+
return adoptSecrets(project, args.includes("--confirm"));
|
|
434
443
|
if (!existsSync2(join2(root, SECRETS_FILE))) {
|
|
435
444
|
console.log(`
|
|
436
445
|
${color.yellow("No " + SECRETS_FILE + " yet.")} Secrets are set by hand with \`wrangler secret put\`,
|
|
@@ -714,6 +723,75 @@ ${color.green("✓")} every secret this shop needs is already declared.
|
|
|
714
723
|
}
|
|
715
724
|
return 0;
|
|
716
725
|
}
|
|
726
|
+
async function adoptSecrets(project, confirmed) {
|
|
727
|
+
const root = project.root;
|
|
728
|
+
const path = join2(root, SECRETS_FILE);
|
|
729
|
+
if (!existsSync2(path)) {
|
|
730
|
+
console.error(`
|
|
731
|
+
vc: no ${SECRETS_FILE} here — \`vc secrets --init\` starts a fresh one.
|
|
732
|
+
`);
|
|
733
|
+
return 1;
|
|
734
|
+
}
|
|
735
|
+
const { localPrivateKey, publicKeyFor, generateKeypair } = await import("./keys-1h4zpvd7.js");
|
|
736
|
+
const current = committedPublicKey(root);
|
|
737
|
+
const mine = process.env[PRIVATE_KEY_VAR] || localPrivateKey(root);
|
|
738
|
+
if (mine && current && (await publicKeyFor(mine))?.toLowerCase() === current.toLowerCase()) {
|
|
739
|
+
console.error(`
|
|
740
|
+
vc: you already hold the key to this shop, so this is not a fork.
|
|
741
|
+
Adopting would replace the key and blank every value you can currently read.
|
|
742
|
+
If that is really what you want, remove the key first.
|
|
743
|
+
`);
|
|
744
|
+
return 1;
|
|
745
|
+
}
|
|
746
|
+
const declared = [...declaredSecretNames(root)];
|
|
747
|
+
if (!confirmed) {
|
|
748
|
+
console.log(`
|
|
749
|
+
${color.bold("Adopting this shop would:")}
|
|
750
|
+
|
|
751
|
+
` + ` · make a NEW encryption key, replacing the one in ${SECRETS_FILE}
|
|
752
|
+
` + ` · reset ${declared.length} value${declared.length === 1 ? "" : "s"} to \`unset\`
|
|
753
|
+
|
|
754
|
+
` + ` The current values cannot be read here anyway — they belong to whoever
|
|
755
|
+
` + ` set them. This makes that explicit rather than leaving their ciphertext
|
|
756
|
+
sitting in a shop you now own.
|
|
757
|
+
|
|
758
|
+
${color.cyan("vc secrets --adopt --confirm")}
|
|
759
|
+
`);
|
|
760
|
+
return 1;
|
|
761
|
+
}
|
|
762
|
+
const pair = await generateKeypair();
|
|
763
|
+
if (!pair) {
|
|
764
|
+
console.error("vc: @dotenvx/dotenvx is not installed here.");
|
|
765
|
+
return 1;
|
|
766
|
+
}
|
|
767
|
+
const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
|
|
768
|
+
const body = readFileSync(path, "utf8").split(`
|
|
769
|
+
`).map((line) => {
|
|
770
|
+
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(line);
|
|
771
|
+
if (!match)
|
|
772
|
+
return line;
|
|
773
|
+
if (match[1] === "DOTENV_PUBLIC_KEY_SECRETS")
|
|
774
|
+
return `DOTENV_PUBLIC_KEY_SECRETS="${pair.publicKey}"`;
|
|
775
|
+
return `${match[1]}=unset`;
|
|
776
|
+
}).join(`
|
|
777
|
+
`);
|
|
778
|
+
writeFileSync(path, body);
|
|
779
|
+
writeFileSync(join2(root, ".env.keys"), `${PRIVATE_KEY_VAR}="${pair.privateKey}"
|
|
780
|
+
`, { mode: 384 });
|
|
781
|
+
console.log(`
|
|
782
|
+
${color.green("✓")} adopted: a new key, and ${declared.length} value${declared.length === 1 ? "" : "s"} reset to \`unset\`
|
|
783
|
+
${color.green("+")} .env.keys holds the new private key — gitignored, and yours
|
|
784
|
+
|
|
785
|
+
` + `Now set what this shop needs:
|
|
786
|
+
${required.slice(0, 4).map((key) => ` ${color.cyan(`vc secrets set ${key.key}`)}`).join(`
|
|
787
|
+
`)}
|
|
788
|
+
` + (required.length > 4 ? ` ${color.dim(`…and ${required.length - 4} more — \`vc secrets\` lists them`)}
|
|
789
|
+
` : "") + color.yellow(`
|
|
790
|
+
! The previous owner's ciphertext is still in git history. It is unreadable
|
|
791
|
+
without their key, but a fresh repository is cleaner than a fork.
|
|
792
|
+
`));
|
|
793
|
+
return 0;
|
|
794
|
+
}
|
|
717
795
|
|
|
718
796
|
// src/deploy/keys.ts
|
|
719
797
|
var LOCAL_KEY_FILE = ".env.keys";
|
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-2kqz80nx.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-t1ggktg8.js";
|
|
64
64
|
import {
|
|
65
65
|
LAYOUTS,
|
|
66
66
|
MANIFEST_FILE,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saastemly/voidcommerce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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
|
@@ -25,6 +25,19 @@ export interface EnvKey {
|
|
|
25
25
|
dev?: string | undefined;
|
|
26
26
|
/** Where the real value comes from. */
|
|
27
27
|
where?: string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Needed to DEPLOY the shop, not to run it.
|
|
30
|
+
*
|
|
31
|
+
* The Cloudflare credentials are the case. They belong in `.env.secrets`
|
|
32
|
+
* so a push carries them, and preflight must insist on them — but the
|
|
33
|
+
* worker has no business seeing one. Left unmarked they ended up in three
|
|
34
|
+
* wrong places at once: `env.ts`, so the worker refused to boot without a
|
|
35
|
+
* deploy token; `.env`, where wrangler read `CLOUDFLARE_ACCOUNT_ID=unset`
|
|
36
|
+
* and addressed `accounts/unset/...`; and `--secrets-file`, which would
|
|
37
|
+
* have uploaded the deploy credential INTO the worker for any code
|
|
38
|
+
* running there to read.
|
|
39
|
+
*/
|
|
40
|
+
deployOnly?: boolean | undefined;
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
export interface Choice {
|
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
|
@@ -242,11 +242,21 @@ export async function decryptSecrets(project: Project): Promise<DecryptedSecrets
|
|
|
242
242
|
});
|
|
243
243
|
if (result.code !== 0) return { error: `dotenvx could not decrypt ${SECRETS_FILE}: ${result.out.trim().split("\n").slice(-2).join(" ")}` };
|
|
244
244
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
245
|
+
/**
|
|
246
|
+
* Keep only the secrets the WORKER needs.
|
|
247
|
+
*
|
|
248
|
+
* dotenvx echoes its own public key back, which has no business being a
|
|
249
|
+
* worker secret. And the deploy-only keys are the important exclusion:
|
|
250
|
+
* `--secrets-file` uploads what it is given, so leaving CLOUDFLARE_API_TOKEN
|
|
251
|
+
* in here would put the credential that can deploy, delete and re-create
|
|
252
|
+
* this shop INSIDE the shop, readable by any code running in the worker.
|
|
253
|
+
* A compromised dependency would have found it there.
|
|
254
|
+
*/
|
|
255
|
+
const deployOnly = new Set(allEnvKeys(project.manifest).filter((key) => key.deployOnly).map((key) => key.key));
|
|
256
|
+
const lines = result.out.split("\n").filter((line) => {
|
|
257
|
+
if (!/^\s*[A-Z][A-Z0-9_]*\s*=/.test(line) || line.trimStart().startsWith("DOTENV_")) return false;
|
|
258
|
+
return !deployOnly.has(line.slice(0, line.indexOf("=")).trim());
|
|
259
|
+
});
|
|
250
260
|
const names = lines.map((line) => line.slice(0, line.indexOf("=")).trim());
|
|
251
261
|
|
|
252
262
|
const path = join(tmpdir(), `vc-secrets-${process.pid}-${Date.now()}.env`);
|
|
@@ -277,6 +287,7 @@ export async function secretsCommand(project: Project, args: string[]): Promise<
|
|
|
277
287
|
if (args.includes("--init")) return initSecrets(project);
|
|
278
288
|
if (args[0] === "set") return setSecretValue(project, args.slice(1));
|
|
279
289
|
if (args.includes("--sync")) return syncSecrets(project, args.includes("--prune"));
|
|
290
|
+
if (args.includes("--adopt")) return adoptSecrets(project, args.includes("--confirm"));
|
|
280
291
|
|
|
281
292
|
if (!existsSync(join(root, SECRETS_FILE))) {
|
|
282
293
|
console.log(
|
|
@@ -598,3 +609,84 @@ async function syncSecrets(project: Project, prune: boolean): Promise<number> {
|
|
|
598
609
|
}
|
|
599
610
|
return 0;
|
|
600
611
|
}
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* `vc secrets --adopt` — take this shop over as a different person.
|
|
616
|
+
*
|
|
617
|
+
* The fork case. Somebody clones a shop, and every value in `.env.secrets`
|
|
618
|
+
* belongs to whoever set it: their Stripe key, their Cloudflare token, their
|
|
619
|
+
* home address. They cannot read any of it — the private key is not theirs —
|
|
620
|
+
* but the ciphertext is right there, and the shop would deploy into somebody
|
|
621
|
+
* else's account if the key ever leaked.
|
|
622
|
+
*
|
|
623
|
+
* So this cuts the connection: a NEW keypair, every value back to `unset`,
|
|
624
|
+
* and the old public key replaced. What was encrypted to the old key stays
|
|
625
|
+
* in git history and is now unreadable by anyone here, which is the point —
|
|
626
|
+
* the new owner starts with a shop that has their secrets or none.
|
|
627
|
+
*
|
|
628
|
+
* It refuses when the current key IS readable here, because then this is not
|
|
629
|
+
* a fork, it is somebody about to destroy their own values by accident.
|
|
630
|
+
*/
|
|
631
|
+
async function adoptSecrets(project: Project, confirmed: boolean): Promise<number> {
|
|
632
|
+
const root = project.root;
|
|
633
|
+
const path = join(root, SECRETS_FILE);
|
|
634
|
+
if (!existsSync(path)) {
|
|
635
|
+
console.error(`\nvc: no ${SECRETS_FILE} here — \`vc secrets --init\` starts a fresh one.\n`);
|
|
636
|
+
return 1;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const { localPrivateKey, publicKeyFor, generateKeypair } = await import("./keys");
|
|
640
|
+
const current = committedPublicKey(root);
|
|
641
|
+
const mine = process.env[PRIVATE_KEY_VAR] || localPrivateKey(root);
|
|
642
|
+
if (mine && current && (await publicKeyFor(mine))?.toLowerCase() === current.toLowerCase()) {
|
|
643
|
+
console.error(
|
|
644
|
+
`\nvc: you already hold the key to this shop, so this is not a fork.\n` +
|
|
645
|
+
` Adopting would replace the key and blank every value you can currently read.\n` +
|
|
646
|
+
` If that is really what you want, remove the key first.\n`,
|
|
647
|
+
);
|
|
648
|
+
return 1;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const declared = [...declaredSecretNames(root)];
|
|
652
|
+
if (!confirmed) {
|
|
653
|
+
console.log(
|
|
654
|
+
`\n${color.bold("Adopting this shop would:")}\n\n` +
|
|
655
|
+
` · make a NEW encryption key, replacing the one in ${SECRETS_FILE}\n` +
|
|
656
|
+
` · reset ${declared.length} value${declared.length === 1 ? "" : "s"} to \`unset\`\n\n` +
|
|
657
|
+
` The current values cannot be read here anyway — they belong to whoever\n` +
|
|
658
|
+
` set them. This makes that explicit rather than leaving their ciphertext\n` +
|
|
659
|
+
` sitting in a shop you now own.\n\n` +
|
|
660
|
+
` ${color.cyan("vc secrets --adopt --confirm")}\n`,
|
|
661
|
+
);
|
|
662
|
+
return 1;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const pair = await generateKeypair();
|
|
666
|
+
if (!pair) {
|
|
667
|
+
console.error("vc: @dotenvx/dotenvx is not installed here.");
|
|
668
|
+
return 1;
|
|
669
|
+
}
|
|
670
|
+
const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
|
|
671
|
+
const body = readFileSync(path, "utf8")
|
|
672
|
+
.split("\n")
|
|
673
|
+
.map((line) => {
|
|
674
|
+
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(line);
|
|
675
|
+
if (!match) return line;
|
|
676
|
+
if (match[1] === "DOTENV_PUBLIC_KEY_SECRETS") return `DOTENV_PUBLIC_KEY_SECRETS="${pair.publicKey}"`;
|
|
677
|
+
return `${match[1]}=unset`;
|
|
678
|
+
})
|
|
679
|
+
.join("\n");
|
|
680
|
+
writeFileSync(path, body);
|
|
681
|
+
writeFileSync(join(root, ".env.keys"), `${PRIVATE_KEY_VAR}="${pair.privateKey}"\n`, { mode: 0o600 });
|
|
682
|
+
|
|
683
|
+
console.log(
|
|
684
|
+
`\n${color.green("✓")} adopted: a new key, and ${declared.length} value${declared.length === 1 ? "" : "s"} reset to \`unset\`\n` +
|
|
685
|
+
`${color.green("+")} .env.keys holds the new private key — gitignored, and yours\n\n` +
|
|
686
|
+
`Now set what this shop needs:\n` +
|
|
687
|
+
`${required.slice(0, 4).map((key) => ` ${color.cyan(`vc secrets set ${key.key}`)}`).join("\n")}\n` +
|
|
688
|
+
(required.length > 4 ? ` ${color.dim(`…and ${required.length - 4} more — \`vc secrets\` lists them`)}\n` : "") +
|
|
689
|
+
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`),
|
|
690
|
+
);
|
|
691
|
+
return 0;
|
|
692
|
+
}
|