@saastemly/voidcommerce 0.16.1 → 0.18.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 +230 -81
- package/dist/deploy/github.d.ts +8 -0
- package/dist/deploy/index.d.ts +3 -0
- package/dist/deploy/keys.d.ts +22 -11
- package/dist/deploy/publish.d.ts +32 -0
- package/dist/{index-w0c96dxv.js → index-3j6jtjmk.js} +44 -10
- package/dist/{index-s8se0x2d.js → index-khzk6a9z.js} +69 -5
- package/dist/index.js +2 -2
- package/dist/{keys-6neyajsv.js → keys-fn6wbv40.js} +1 -1
- package/package.json +1 -1
- package/src/cli.ts +3 -0
- package/src/deploy/github.ts +16 -0
- package/src/deploy/index.ts +49 -4
- package/src/deploy/keys.ts +85 -15
- package/src/deploy/link.ts +21 -7
- package/src/deploy/publish.ts +145 -0
- package/src/generate/ci.ts +42 -8
package/src/deploy/link.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import color from "picocolors";
|
|
4
4
|
import { writeManifest } from "../manifest";
|
|
5
5
|
import type { Project } from "../project";
|
|
6
|
-
import { cloudflareAccounts, ghAuth, repoSlug, secretNames, setSecret, setVariable, variableNames, verifyCloudflareToken } from "./github";
|
|
6
|
+
import { cloudflareAccounts, getVariable, ghAuth, repoSlug, secretNames, setSecret, setVariable, variableNames, verifyCloudflareToken } from "./github";
|
|
7
7
|
import { LOCAL_KEY_FILE, keyState, localPrivateKey, provisionKey, publicKeyFor } from "./keys";
|
|
8
8
|
import { PRIVATE_KEY_VAR, SECRETS_FILE, committedPublicKeyInto, declaredSecretNames, initSecrets } from "./secrets";
|
|
9
9
|
|
|
@@ -91,17 +91,31 @@ export async function linkCommand(project: Project, args: string[]): Promise<num
|
|
|
91
91
|
// ADOPTED, not replaced: it may already have encrypted the whole shop.
|
|
92
92
|
const parked = localPrivateKey(root);
|
|
93
93
|
if (parked) {
|
|
94
|
-
const sent = await
|
|
94
|
+
const sent = await setVariable(root, PRIVATE_KEY_VAR, parked);
|
|
95
95
|
if (!sent.ok) {
|
|
96
|
-
p.cancel(`GitHub refused the
|
|
96
|
+
p.cancel(`GitHub refused the variable: ${sent.error ?? "unknown error"}`);
|
|
97
97
|
return 1;
|
|
98
98
|
}
|
|
99
99
|
const publicKey = await publicKeyFor(parked);
|
|
100
100
|
if (publicKey) committedPublicKeyInto(root, publicKey);
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
|
|
102
|
+
// READ IT BACK before deleting the only other copy. "The write
|
|
103
|
+
// returned success" is not the same claim as "the key can be
|
|
104
|
+
// recovered", and it is the second one this file is about to bet
|
|
105
|
+
// the shop on. If it cannot be read, the local copy stays.
|
|
106
|
+
const readBack = await getVariable(root, PRIVATE_KEY_VAR);
|
|
107
|
+
if (readBack !== parked) {
|
|
108
|
+
p.log.warn(
|
|
109
|
+
`${color.yellow("!")} ${PRIVATE_KEY_VAR} was written to ${slug} but could not be read back,\n` +
|
|
110
|
+
` so ${LOCAL_KEY_FILE} has been LEFT IN PLACE. It is currently the only copy\n` +
|
|
111
|
+
" of the key that opens this shop — do not delete it until `vc keys --restore` works.",
|
|
112
|
+
);
|
|
113
|
+
} else {
|
|
114
|
+
rmSync(join(root, LOCAL_KEY_FILE), { force: true });
|
|
115
|
+
p.log.success(
|
|
116
|
+
`${color.green("✓")} the key moved to ${slug} and ${LOCAL_KEY_FILE} deleted — verified readable, so \`vc keys --restore\` can bring it back`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
105
119
|
} else {
|
|
106
120
|
const made = await provisionKey(project);
|
|
107
121
|
if (!made.ok) {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import color from "picocolors";
|
|
4
|
+
import type { Project } from "../project";
|
|
5
|
+
import { findGh, ghAuth, repoSlug, run } from "./github";
|
|
6
|
+
import { linkCommand } from "./link";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `vc publish` — make the repository, give it what it needs, push.
|
|
10
|
+
*
|
|
11
|
+
* ── Why this exists ──────────────────────────────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* The goal was always "publishing to GitHub is the deploy". `vc link` sat in
|
|
14
|
+
* front of that as a separate step, and it is worth being exact about what
|
|
15
|
+
* it was actually for, because two of its three jobs never needed a person:
|
|
16
|
+
*
|
|
17
|
+
* DOTENV_PRIVATE_KEY_SECRETS vc already holds it — automatic
|
|
18
|
+
* CLOUDFLARE_ACCOUNT_ID it is in the manifest — automatic
|
|
19
|
+
* CLOUDFLARE_API_TOKEN exists only in Cloudflare's dashboard
|
|
20
|
+
*
|
|
21
|
+
* Only the third needs a human, and it cannot be removed: there is no OIDC
|
|
22
|
+
* or workload identity federation from GitHub to the Cloudflare API, so
|
|
23
|
+
* something has to carry a token across, and only a person can fetch one.
|
|
24
|
+
* Everything else is ceremony that a command can do.
|
|
25
|
+
*
|
|
26
|
+
* So this is the one command. It creates the repository, sets all three,
|
|
27
|
+
* and pushes — and the push is what deploys. After it, `git push` is the
|
|
28
|
+
* whole loop forever, and `vc` is only needed to read or change a secret.
|
|
29
|
+
*
|
|
30
|
+
* ── The order matters ────────────────────────────────────────────────────
|
|
31
|
+
*
|
|
32
|
+
* The repository is created WITHOUT pushing, the credentials go on, and only
|
|
33
|
+
* then does the push happen. `gh repo create --push` would put main there
|
|
34
|
+
* first, which starts a deploy against a repository that has no key and no
|
|
35
|
+
* token — a red run, an email, and a shop that did not deploy, for no
|
|
36
|
+
* reason other than doing two things in the wrong order.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export async function publishCommand(project: Project, args: string[]): Promise<number> {
|
|
40
|
+
const p = await import("@clack/prompts");
|
|
41
|
+
const root = project.root;
|
|
42
|
+
const gh = findGh();
|
|
43
|
+
|
|
44
|
+
p.intro(color.bgCyan(color.black(" vc publish ")));
|
|
45
|
+
|
|
46
|
+
const auth = await ghAuth(root);
|
|
47
|
+
if (!auth.ok || !gh) {
|
|
48
|
+
p.cancel(auth.reason ?? "the GitHub CLI is not installed. https://cli.github.com");
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
if (!existsSync(join(root, ".git"))) {
|
|
52
|
+
p.cancel(`${root} is not a git repository. \`git init\` first, and commit what you have.`);
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 1. The repository. Created without pushing: see above.
|
|
57
|
+
let slug = await repoSlug(root);
|
|
58
|
+
if (slug) {
|
|
59
|
+
p.log.info(`${slug} already exists — using it`);
|
|
60
|
+
} else {
|
|
61
|
+
const suggested = project.manifest.shop.domain.split(".")[0] ?? "shop";
|
|
62
|
+
const name = await p.text({
|
|
63
|
+
message: "Repository name",
|
|
64
|
+
initialValue: suggested,
|
|
65
|
+
validate: (value) => (/^[A-Za-z0-9._-]+$/.test(value.trim()) ? undefined : "letters, digits, dot, dash or underscore"),
|
|
66
|
+
});
|
|
67
|
+
if (p.isCancel(name)) {
|
|
68
|
+
p.cancel("nothing was created.");
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
// PRIVATE by default and deliberately: the encryption key becomes a
|
|
72
|
+
// repository variable, so anyone who can read this repository can
|
|
73
|
+
// decrypt its secrets.
|
|
74
|
+
const visibility = await p.select({
|
|
75
|
+
message: "Visibility",
|
|
76
|
+
options: [
|
|
77
|
+
{ value: "--private", label: "Private", hint: "the key that opens your secrets is readable to anyone with access" },
|
|
78
|
+
{ value: "--public", label: "Public", hint: "only if this shop keeps no secrets at all" },
|
|
79
|
+
],
|
|
80
|
+
initialValue: "--private",
|
|
81
|
+
});
|
|
82
|
+
if (p.isCancel(visibility)) {
|
|
83
|
+
p.cancel("nothing was created.");
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const spinner = p.spinner();
|
|
88
|
+
spinner.start(`creating ${String(name)}`);
|
|
89
|
+
const created = await run(gh, ["repo", "create", String(name).trim(), "--source=.", String(visibility)], root);
|
|
90
|
+
if (created.code !== 0) {
|
|
91
|
+
spinner.stop(`${color.red("✗")} could not create it`);
|
|
92
|
+
p.cancel(created.out.trim().split("\n").slice(-2).join(" "));
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
slug = await repoSlug(root);
|
|
96
|
+
spinner.stop(`${color.green("✓")} ${slug ?? String(name)} created, and set as origin`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 2. The credentials. Two are automatic; one is asked for.
|
|
100
|
+
p.log.step("giving the repository what the deploy needs");
|
|
101
|
+
const linked = await linkCommand(project, args);
|
|
102
|
+
if (linked !== 0) {
|
|
103
|
+
p.cancel("the repository exists but is not configured, so a push would not deploy. Fix the above and run `vc publish` again.");
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 3. Anything `vc link` wrote — the public key, a fresh .env.secrets, the
|
|
108
|
+
// pinned account id — has to be IN the push, or the deploy reads a
|
|
109
|
+
// repository that disagrees with the one on this machine.
|
|
110
|
+
const dirty = (await run("git", ["status", "--porcelain"], root)).out.trim();
|
|
111
|
+
if (dirty) {
|
|
112
|
+
const staged = await run("git", ["add", "-A"], root);
|
|
113
|
+
const committed = await run("git", ["commit", "-m", "vc publish: link this shop to its repository"], root);
|
|
114
|
+
if (staged.code !== 0 || committed.code !== 0) {
|
|
115
|
+
p.cancel(`could not commit the changes vc link made:\n${committed.out.trim().split("\n").slice(-3).join("\n")}`);
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
p.log.success(`${color.green("✓")} committed what linking changed`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 4. Push. This is the deploy.
|
|
122
|
+
const branch = (await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], root)).out.trim() || "main";
|
|
123
|
+
const spinner = p.spinner();
|
|
124
|
+
spinner.start(`pushing ${branch}`);
|
|
125
|
+
const pushed = await run("git", ["push", "-u", "origin", `${branch}:main`], root);
|
|
126
|
+
if (pushed.code !== 0) {
|
|
127
|
+
spinner.stop(`${color.red("✗")} the push failed`);
|
|
128
|
+
p.cancel(pushed.out.trim().split("\n").slice(-4).join("\n"));
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
spinner.stop(`${color.green("✓")} pushed to main`);
|
|
132
|
+
|
|
133
|
+
p.outro(
|
|
134
|
+
[
|
|
135
|
+
`${color.bold("Published.")} The deploy is running now.`,
|
|
136
|
+
"",
|
|
137
|
+
` ${color.cyan(`https://github.com/${slug}/actions`)}`,
|
|
138
|
+
"",
|
|
139
|
+
` From here ${color.cyan("git push")} is the whole loop. \`vc\` is only needed to`,
|
|
140
|
+
` change a secret (${color.cyan("vc secrets set KEY")}) or read one back`,
|
|
141
|
+
` (${color.cyan("vc keys --restore")}).`,
|
|
142
|
+
].join("\n"),
|
|
143
|
+
);
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
package/src/generate/ci.ts
CHANGED
|
@@ -50,7 +50,7 @@ export function renderDistWorkflow(manifest: Manifest): string {
|
|
|
50
50
|
# The credentials come from the repository, set once by \`vc link\`:
|
|
51
51
|
#
|
|
52
52
|
# secrets.CLOUDFLARE_API_TOKEN deploys, and creates D1 + the queue
|
|
53
|
-
#
|
|
53
|
+
# vars.${PRIVATE_KEY_VAR} opens ${SECRETS_FILE} (a VARIABLE, so it can be read back)
|
|
54
54
|
# vars.CLOUDFLARE_ACCOUNT_ID which account (an id, not a credential)
|
|
55
55
|
#
|
|
56
56
|
# Nothing here needs a Cloudflare dashboard visit and nothing needs wrangler
|
|
@@ -147,16 +147,25 @@ jobs:
|
|
|
147
147
|
env:
|
|
148
148
|
CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
149
149
|
CLOUDFLARE_ACCOUNT_ID: \${{ vars.CLOUDFLARE_ACCOUNT_ID }}
|
|
150
|
-
${PRIVATE_KEY_VAR}: \${{
|
|
150
|
+
${PRIVATE_KEY_VAR}: \${{ vars.${PRIVATE_KEY_VAR} }}
|
|
151
151
|
steps:
|
|
152
152
|
- uses: actions/checkout@v6
|
|
153
153
|
- uses: oven-sh/setup-bun@v2
|
|
154
154
|
|
|
155
155
|
# Checked before anything is built, so a repository that was never
|
|
156
156
|
# linked says so in five seconds rather than four minutes.
|
|
157
|
+
#
|
|
158
|
+
# And ${PRIVATE_KEY_VAR} is masked here, first thing. It is a
|
|
159
|
+
# VARIABLE rather than a secret so that a human can read it back —
|
|
160
|
+
# that is what makes a new laptop or a second person possible — but
|
|
161
|
+
# GitHub only masks SECRETS in logs automatically. Registering it with
|
|
162
|
+
# ::add-mask:: buys back the redaction without giving up recovery.
|
|
157
163
|
- name: Are the credentials here?
|
|
158
164
|
run: |
|
|
159
165
|
set -euo pipefail
|
|
166
|
+
if [ -n "\${${PRIVATE_KEY_VAR}:-}" ]; then
|
|
167
|
+
echo "::add-mask::\${${PRIVATE_KEY_VAR}}"
|
|
168
|
+
fi
|
|
160
169
|
missing=""
|
|
161
170
|
[ -n "\${CLOUDFLARE_API_TOKEN:-}" ] || missing="$missing CLOUDFLARE_API_TOKEN"
|
|
162
171
|
[ -n "\${${PRIVATE_KEY_VAR}:-}" ] || missing="$missing ${PRIVATE_KEY_VAR}"
|
|
@@ -219,18 +228,25 @@ You do not need wrangler on your machine, and you do not need to be logged
|
|
|
219
228
|
into it. You do not need to open the Cloudflare dashboard after the one step
|
|
220
229
|
below.
|
|
221
230
|
|
|
222
|
-
## Once,
|
|
231
|
+
## Once, to start
|
|
223
232
|
|
|
224
233
|
\`\`\`sh
|
|
225
|
-
|
|
226
|
-
vc link
|
|
234
|
+
vc publish
|
|
227
235
|
\`\`\`
|
|
228
236
|
|
|
229
|
-
|
|
237
|
+
One command: it creates the repository, gives it what the deploy needs, and
|
|
238
|
+
pushes — and the push is the deploy. After it, \`git push\` is the whole loop.
|
|
239
|
+
|
|
240
|
+
Two of the three things it sets need nothing from you. The encryption key vc
|
|
241
|
+
already holds; the account id is in \`voidcommerce.json\`. It asks only for a
|
|
242
|
+
Cloudflare API token, because there is no OIDC between GitHub and Cloudflare
|
|
243
|
+
and only a person can fetch one from the dashboard.
|
|
244
|
+
|
|
245
|
+
It stores three things on the GitHub repository:
|
|
230
246
|
|
|
231
247
|
| what | where | why |
|
|
232
248
|
|---|---|---|
|
|
233
|
-
| \`${PRIVATE_KEY_VAR}\` | repository **
|
|
249
|
+
| \`${PRIVATE_KEY_VAR}\` | repository **variable** | opens \`${SECRETS_FILE}\`. A variable, not a secret, so you can get it back — see below |
|
|
234
250
|
| \`CLOUDFLARE_API_TOKEN\` | repository **secret** | deploys, and creates D1 and the queue |
|
|
235
251
|
| \`CLOUDFLARE_ACCOUNT_ID\` | repository **variable** | which account. An identifier, not a credential |
|
|
236
252
|
|
|
@@ -278,6 +294,24 @@ vc secrets set STRIPE_SECRET_KEY # prompts; never touches your shell history
|
|
|
278
294
|
vc secrets # what is set, what is missing
|
|
279
295
|
\`\`\`
|
|
280
296
|
|
|
297
|
+
### Getting the key back
|
|
298
|
+
|
|
299
|
+
\`\`\`sh
|
|
300
|
+
vc keys --restore
|
|
301
|
+
\`\`\`
|
|
302
|
+
|
|
303
|
+
\`${PRIVATE_KEY_VAR}\` is a repository **variable**, not a secret. GitHub
|
|
304
|
+
secrets are write-only — \`gh secret\` has no read command — so a key kept
|
|
305
|
+
there is gone the moment your local copy is, and with it every value in
|
|
306
|
+
\`${SECRETS_FILE}\`. As a variable it can be read back, which is what makes a
|
|
307
|
+
new laptop, a rebuild, or a second person possible at all.
|
|
308
|
+
|
|
309
|
+
**That means anyone who can read this repository can decrypt its secrets.**
|
|
310
|
+
Keep it private. It is also the answer to sharing: a colleague who is a
|
|
311
|
+
collaborator can run \`vc keys --restore\` and work; removing them removes
|
|
312
|
+
their access. The deploy workflow masks the value with \`::add-mask::\` the
|
|
313
|
+
moment it reads it, because GitHub does not mask variables on its own.
|
|
314
|
+
|
|
281
315
|
**This needs no credential.** dotenvx is asymmetric: encryption uses the public
|
|
282
316
|
key committed at the top of the file, so anyone with a clone can set or rotate
|
|
283
317
|
a secret. Nobody with a clone can read one. Only the deploy decrypts, with the
|
|
@@ -302,7 +336,7 @@ The same thing, from a checkout, if CI is ever not the answer:
|
|
|
302
336
|
|
|
303
337
|
\`\`\`sh
|
|
304
338
|
export CLOUDFLARE_API_TOKEN=…
|
|
305
|
-
export ${PRIVATE_KEY_VAR}=… #
|
|
339
|
+
export ${PRIVATE_KEY_VAR}=… # or run \`vc keys --restore\` first
|
|
306
340
|
vc deploy --cloudflare --provision
|
|
307
341
|
\`\`\`
|
|
308
342
|
|