@saastemly/voidcommerce 0.18.0 → 0.19.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 +71 -411
- package/dist/deploy/index.d.ts +0 -6
- package/dist/deploy/secrets.d.ts +10 -0
- package/dist/{index-khzk6a9z.js → index-68m1eg3f.js} +29 -49
- package/dist/{index-3j6jtjmk.js → index-y4dk27kf.js} +45 -64
- package/dist/index.js +2 -2
- package/dist/{keys-fn6wbv40.js → keys-e6w5dcxx.js} +1 -1
- package/package.json +1 -1
- package/src/cli.ts +0 -6
- package/src/deploy/cloudflare.ts +23 -1
- package/src/deploy/index.ts +0 -71
- package/src/deploy/secrets.ts +27 -1
- package/src/generate/ci.ts +30 -62
- package/src/generate/env.ts +22 -0
- package/dist/deploy/link.d.ts +0 -2
- package/dist/deploy/publish.d.ts +0 -32
- package/src/deploy/link.ts +0 -220
- package/src/deploy/publish.ts +0 -145
package/src/generate/ci.ts
CHANGED
|
@@ -47,14 +47,13 @@ export function renderDistWorkflow(manifest: Manifest): string {
|
|
|
47
47
|
# Cloudflare. The check runs FIRST and on its own: nothing is built, created
|
|
48
48
|
# or replaced for a shop that is not ready.
|
|
49
49
|
#
|
|
50
|
-
#
|
|
50
|
+
# ONE thing is configured outside the repository:
|
|
51
51
|
#
|
|
52
|
-
#
|
|
53
|
-
# vars.${PRIVATE_KEY_VAR} opens ${SECRETS_FILE} (a VARIABLE, so it can be read back)
|
|
54
|
-
# vars.CLOUDFLARE_ACCOUNT_ID which account (an id, not a credential)
|
|
52
|
+
# vars.${PRIVATE_KEY_VAR} the key that opens ${SECRETS_FILE}
|
|
55
53
|
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
54
|
+
# Everything else — including the Cloudflare token and account id — is an
|
|
55
|
+
# ordinary secret in that file, encrypted and committed, so it travels with
|
|
56
|
+
# the push. See DEPLOY.md.
|
|
58
57
|
on:
|
|
59
58
|
push:
|
|
60
59
|
branches: [main, master]
|
|
@@ -145,8 +144,8 @@ jobs:
|
|
|
145
144
|
name: production
|
|
146
145
|
url: https://${manifest.shop.domain}
|
|
147
146
|
env:
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
# The only thing this deploy is given. The Cloudflare credentials are
|
|
148
|
+
# read out of .env.secrets, which this key opens.
|
|
150
149
|
${PRIVATE_KEY_VAR}: \${{ vars.${PRIVATE_KEY_VAR} }}
|
|
151
150
|
steps:
|
|
152
151
|
- uses: actions/checkout@v6
|
|
@@ -160,19 +159,15 @@ jobs:
|
|
|
160
159
|
# that is what makes a new laptop or a second person possible — but
|
|
161
160
|
# GitHub only masks SECRETS in logs automatically. Registering it with
|
|
162
161
|
# ::add-mask:: buys back the redaction without giving up recovery.
|
|
163
|
-
- name:
|
|
162
|
+
- name: Is the key here?
|
|
164
163
|
run: |
|
|
165
164
|
set -euo pipefail
|
|
166
|
-
if [ -
|
|
167
|
-
echo "::
|
|
168
|
-
|
|
169
|
-
missing=""
|
|
170
|
-
[ -n "\${CLOUDFLARE_API_TOKEN:-}" ] || missing="$missing CLOUDFLARE_API_TOKEN"
|
|
171
|
-
[ -n "\${${PRIVATE_KEY_VAR}:-}" ] || missing="$missing ${PRIVATE_KEY_VAR}"
|
|
172
|
-
if [ -n "$missing" ]; then
|
|
173
|
-
echo "::error::this repository has no$missing. Run 'vc link' once, from a checkout."
|
|
165
|
+
if [ -z "\${${PRIVATE_KEY_VAR}:-}" ]; then
|
|
166
|
+
echo "::error::this repository has no ${PRIVATE_KEY_VAR} variable, so .env.secrets cannot be opened and nothing can deploy."
|
|
167
|
+
echo "Set it once, from a checkout: gh variable set ${PRIVATE_KEY_VAR}"
|
|
174
168
|
exit 1
|
|
175
169
|
fi
|
|
170
|
+
echo "::add-mask::\${${PRIVATE_KEY_VAR}}"
|
|
176
171
|
|
|
177
172
|
- run: bun install --frozen-lockfile
|
|
178
173
|
|
|
@@ -231,53 +226,26 @@ below.
|
|
|
231
226
|
## Once, to start
|
|
232
227
|
|
|
233
228
|
\`\`\`sh
|
|
234
|
-
|
|
229
|
+
gh repo create --source=. --private # or make it in the browser
|
|
230
|
+
vc secrets --init # writes the key onto the repository
|
|
231
|
+
vc secrets set CLOUDFLARE_API_TOKEN # and every other key it lists
|
|
232
|
+
git push -u origin main
|
|
235
233
|
\`\`\`
|
|
236
234
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
| \`CLOUDFLARE_ACCOUNT_ID\` | repository **variable** | which account. An identifier, not a credential |
|
|
252
|
-
|
|
253
|
-
It will ask you to paste a Cloudflare API token. That is the only manual step
|
|
254
|
-
in the whole setup, and it is worth saying exactly why it cannot be removed:
|
|
255
|
-
|
|
256
|
-
> **GitHub cannot mint a Cloudflare credential.** There is no OIDC or workload
|
|
257
|
-
> identity federation between them — the feature request has been open since
|
|
258
|
-
> 2025 with no commitment, and Cloudflare's own CI guidance still says to store
|
|
259
|
-
> a token in your CI provider's secrets. The Cloudflare GitHub App does not
|
|
260
|
-
> help either: it grants *Cloudflare* access to your *repository*, not the
|
|
261
|
-
> reverse. Something has to authorise creating a database in your account, and
|
|
262
|
-
> only Cloudflare can issue that authorisation.
|
|
263
|
-
|
|
264
|
-
Create the token at **My Profile → API Tokens → Create Token → Custom token**:
|
|
265
|
-
|
|
266
|
-
| scope | permission | for |
|
|
267
|
-
|---|---|---|
|
|
268
|
-
| Account | Workers Scripts: Edit | deploying the worker |
|
|
269
|
-
| Account | D1: Edit | creating the database, applying migrations |
|
|
270
|
-
| Account | Queues: Edit | the order queue |
|
|
271
|
-
| Account | Workers KV Storage: Edit | sessions and caches |
|
|
272
|
-
| Account | Workers R2 Storage: Edit | product images |
|
|
273
|
-
| Account | Account Settings: Read | confirming which account |
|
|
274
|
-
| Zone | Workers Routes: Edit (${zone}) | answering on your domain |
|
|
275
|
-
| Zone | Zone: Read (${zone}) | confirming the zone is on this account |
|
|
276
|
-
| Zone | DNS: Read (${zone}) | noticing a hostname that already answers |
|
|
277
|
-
| User | User Details: Read, Memberships: Read | wrangler asks at startup |
|
|
278
|
-
|
|
279
|
-
The token Cloudflare generates for its own Workers Builds will **not** do: it
|
|
280
|
-
has no D1 and no Queues permission, so it cannot create this shop's database.
|
|
235
|
+
That push is the deploy, and every push after it.
|
|
236
|
+
|
|
237
|
+
**Exactly one thing lives outside the repository**: \`${PRIVATE_KEY_VAR}\`, a
|
|
238
|
+
repository variable holding the key that opens \`${SECRETS_FILE}\`. It is set
|
|
239
|
+
for you the moment the key is made, as long as the repository already exists
|
|
240
|
+
— which is why the repository comes first above.
|
|
241
|
+
|
|
242
|
+
Everything else, including the Cloudflare token and account id, is an
|
|
243
|
+
ordinary secret in \`${SECRETS_FILE}\`: encrypted, committed, and read by the
|
|
244
|
+
deploy out of what you pushed.
|
|
245
|
+
|
|
246
|
+
The token itself has to be fetched by a person, once, because there is no
|
|
247
|
+
OIDC between GitHub and Cloudflare and nothing else can issue one. Create it
|
|
248
|
+
at **My Profile → API Tokens → Create Token → Custom token**:
|
|
281
249
|
|
|
282
250
|
### The zone
|
|
283
251
|
|
package/src/generate/env.ts
CHANGED
|
@@ -13,6 +13,28 @@ import { type Manifest, envKeysOf, has, hasFrontend, isApex, zone , oneOrigin} f
|
|
|
13
13
|
|
|
14
14
|
/** Keys every shop has, whatever else was chosen. */
|
|
15
15
|
const BASE: EnvKey[] = [
|
|
16
|
+
/**
|
|
17
|
+
* The Cloudflare credentials are ORDINARY SECRETS, deliberately.
|
|
18
|
+
*
|
|
19
|
+
* They used to be set on the GitHub repository by a `vc link` step, which
|
|
20
|
+
* existed only because they were treated as special. They are not: they
|
|
21
|
+
* are two more values this shop needs, they belong in `.env.secrets` with
|
|
22
|
+
* everything else, and they are set the same way — `vc secrets set`.
|
|
23
|
+
*
|
|
24
|
+
* That leaves exactly ONE thing that has to be configured outside the
|
|
25
|
+
* repository: the key that opens `.env.secrets`. Everything else, the
|
|
26
|
+
* deploy reads out of what was pushed.
|
|
27
|
+
*/
|
|
28
|
+
{
|
|
29
|
+
key: "CLOUDFLARE_API_TOKEN",
|
|
30
|
+
breaks: "nothing deploys: no worker, no database, no migrations",
|
|
31
|
+
where: "dash.cloudflare.com/profile/api-tokens → Create Token → Custom token",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
key: "CLOUDFLARE_ACCOUNT_ID",
|
|
35
|
+
breaks: "wrangler cannot tell which account to deploy into, and refuses rather than guessing",
|
|
36
|
+
where: "the Cloudflare dashboard sidebar, or `vc secrets set` it after the first provision",
|
|
37
|
+
},
|
|
16
38
|
{
|
|
17
39
|
key: "SHOP_DOMAIN",
|
|
18
40
|
breaks: "everything derived from it is wrong at once — no public origin, the storefront rejected as untrusted, no DNS records",
|
package/dist/deploy/link.d.ts
DELETED
package/dist/deploy/publish.d.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import type { Project } from "../project";
|
|
2
|
-
/**
|
|
3
|
-
* `vc publish` — make the repository, give it what it needs, push.
|
|
4
|
-
*
|
|
5
|
-
* ── Why this exists ──────────────────────────────────────────────────────
|
|
6
|
-
*
|
|
7
|
-
* The goal was always "publishing to GitHub is the deploy". `vc link` sat in
|
|
8
|
-
* front of that as a separate step, and it is worth being exact about what
|
|
9
|
-
* it was actually for, because two of its three jobs never needed a person:
|
|
10
|
-
*
|
|
11
|
-
* DOTENV_PRIVATE_KEY_SECRETS vc already holds it — automatic
|
|
12
|
-
* CLOUDFLARE_ACCOUNT_ID it is in the manifest — automatic
|
|
13
|
-
* CLOUDFLARE_API_TOKEN exists only in Cloudflare's dashboard
|
|
14
|
-
*
|
|
15
|
-
* Only the third needs a human, and it cannot be removed: there is no OIDC
|
|
16
|
-
* or workload identity federation from GitHub to the Cloudflare API, so
|
|
17
|
-
* something has to carry a token across, and only a person can fetch one.
|
|
18
|
-
* Everything else is ceremony that a command can do.
|
|
19
|
-
*
|
|
20
|
-
* So this is the one command. It creates the repository, sets all three,
|
|
21
|
-
* and pushes — and the push is what deploys. After it, `git push` is the
|
|
22
|
-
* whole loop forever, and `vc` is only needed to read or change a secret.
|
|
23
|
-
*
|
|
24
|
-
* ── The order matters ────────────────────────────────────────────────────
|
|
25
|
-
*
|
|
26
|
-
* The repository is created WITHOUT pushing, the credentials go on, and only
|
|
27
|
-
* then does the push happen. `gh repo create --push` would put main there
|
|
28
|
-
* first, which starts a deploy against a repository that has no key and no
|
|
29
|
-
* token — a red run, an email, and a shop that did not deploy, for no
|
|
30
|
-
* reason other than doing two things in the wrong order.
|
|
31
|
-
*/
|
|
32
|
-
export declare function publishCommand(project: Project, args: string[]): Promise<number>;
|
package/src/deploy/link.ts
DELETED
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
import { rmSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import color from "picocolors";
|
|
4
|
-
import { writeManifest } from "../manifest";
|
|
5
|
-
import type { Project } from "../project";
|
|
6
|
-
import { cloudflareAccounts, getVariable, ghAuth, repoSlug, secretNames, setSecret, setVariable, variableNames, verifyCloudflareToken } from "./github";
|
|
7
|
-
import { LOCAL_KEY_FILE, keyState, localPrivateKey, provisionKey, publicKeyFor } from "./keys";
|
|
8
|
-
import { PRIVATE_KEY_VAR, SECRETS_FILE, committedPublicKeyInto, declaredSecretNames, initSecrets } from "./secrets";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* `vc link` — the one manual step, done once, from the terminal.
|
|
12
|
-
*
|
|
13
|
-
* ── What this is for ─────────────────────────────────────────────────────
|
|
14
|
-
*
|
|
15
|
-
* The goal is that publishing to GitHub is the whole deploy. Everything
|
|
16
|
-
* else in voidcommerce gets there: the app is generated from the manifest,
|
|
17
|
-
* the secrets are committed as ciphertext, the workflow builds and deploys.
|
|
18
|
-
* One thing cannot be automated away, and it is worth being exact about
|
|
19
|
-
* why.
|
|
20
|
-
*
|
|
21
|
-
* **GitHub cannot mint a Cloudflare credential.** There is no OIDC or
|
|
22
|
-
* workload identity federation between them — the feature request has sat
|
|
23
|
-
* unanswered since 2025, and Cloudflare's own CI guidance still says to
|
|
24
|
-
* store an API token in your CI provider's secrets. The Cloudflare GitHub
|
|
25
|
-
* App does not help: it grants Cloudflare access to the repository, not the
|
|
26
|
-
* repository access to Cloudflare. Something has to authorise creating a
|
|
27
|
-
* database in someone's account, and only Cloudflare can issue that.
|
|
28
|
-
*
|
|
29
|
-
* So exactly one token is typed, once. This command takes it, checks it
|
|
30
|
-
* against the Cloudflare API before trusting it, and puts it in GitHub's
|
|
31
|
-
* encrypted secrets — never on disk, never in a shell profile, never in
|
|
32
|
-
* `~/.wrangler`. After that, `git push` is the deploy, forever.
|
|
33
|
-
*
|
|
34
|
-
* ── What it deliberately does not do ─────────────────────────────────────
|
|
35
|
-
*
|
|
36
|
-
* It does not create the token. That would need a token. The dashboard is
|
|
37
|
-
* the only place a first one can come from, so this prints exactly which
|
|
38
|
-
* permissions to tick and waits.
|
|
39
|
-
*/
|
|
40
|
-
|
|
41
|
-
const TOKEN_SECRET = "CLOUDFLARE_API_TOKEN";
|
|
42
|
-
const ACCOUNT_VAR = "CLOUDFLARE_ACCOUNT_ID";
|
|
43
|
-
|
|
44
|
-
/** The scopes the deploy actually uses, and why each one is there. */
|
|
45
|
-
const SCOPES: Array<[string, string]> = [
|
|
46
|
-
["Account · Workers Scripts: Edit", "deploy the worker"],
|
|
47
|
-
["Account · D1: Edit", "create the database and apply migrations"],
|
|
48
|
-
["Account · Queues: Edit", "create the order queue"],
|
|
49
|
-
["Account · Workers KV Storage: Edit", "sessions and caches"],
|
|
50
|
-
["Account · Workers R2 Storage: Edit", "product images"],
|
|
51
|
-
["Account · Account Settings: Read", "confirm which account this is"],
|
|
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"],
|
|
55
|
-
["User · User Details: Read", "wrangler asks at startup"],
|
|
56
|
-
];
|
|
57
|
-
|
|
58
|
-
export async function linkCommand(project: Project, args: string[]): Promise<number> {
|
|
59
|
-
const p = await import("@clack/prompts");
|
|
60
|
-
const root = project.root;
|
|
61
|
-
const relink = args.includes("--force");
|
|
62
|
-
|
|
63
|
-
p.intro(color.bgCyan(color.black(" vc link ")));
|
|
64
|
-
|
|
65
|
-
// 1. gh, logged in, scoped.
|
|
66
|
-
const auth = await ghAuth(root);
|
|
67
|
-
if (!auth.ok) {
|
|
68
|
-
p.cancel(auth.reason ?? "gh is unavailable");
|
|
69
|
-
return 1;
|
|
70
|
-
}
|
|
71
|
-
const slug = await repoSlug(root);
|
|
72
|
-
if (!slug) {
|
|
73
|
-
p.cancel(
|
|
74
|
-
"this checkout has no GitHub repository, and everything below is stored on one.\n\n" +
|
|
75
|
-
` ${color.cyan("gh repo create --source=. --private --push")}\n\n` +
|
|
76
|
-
" then run `vc link` again.",
|
|
77
|
-
);
|
|
78
|
-
return 1;
|
|
79
|
-
}
|
|
80
|
-
p.log.success(`${color.green("✓")} ${slug}, as ${auth.user ?? "you"}`);
|
|
81
|
-
|
|
82
|
-
// 2. The encryption key. Generated here, stored on the repository, never on disk.
|
|
83
|
-
const keys = await keyState(project);
|
|
84
|
-
if (keys.inGitHub && !relink) {
|
|
85
|
-
p.log.info(`${PRIVATE_KEY_VAR} is already set — leaving it alone (--force replaces it, which orphans ${SECRETS_FILE})`);
|
|
86
|
-
} else if (keys.inGitHub && relink) {
|
|
87
|
-
p.log.warn(`--force: replacing ${PRIVATE_KEY_VAR} makes every value in ${SECRETS_FILE} unreadable. Use \`vc keys --rotate\` to re-encrypt instead.`);
|
|
88
|
-
return 1;
|
|
89
|
-
} else {
|
|
90
|
-
// A key parked by `vc keys --init` before this repository existed is
|
|
91
|
-
// ADOPTED, not replaced: it may already have encrypted the whole shop.
|
|
92
|
-
const parked = localPrivateKey(root);
|
|
93
|
-
if (parked) {
|
|
94
|
-
const sent = await setVariable(root, PRIVATE_KEY_VAR, parked);
|
|
95
|
-
if (!sent.ok) {
|
|
96
|
-
p.cancel(`GitHub refused the variable: ${sent.error ?? "unknown error"}`);
|
|
97
|
-
return 1;
|
|
98
|
-
}
|
|
99
|
-
const publicKey = await publicKeyFor(parked);
|
|
100
|
-
if (publicKey) committedPublicKeyInto(root, publicKey);
|
|
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
|
-
}
|
|
119
|
-
} else {
|
|
120
|
-
const made = await provisionKey(project);
|
|
121
|
-
if (!made.ok) {
|
|
122
|
-
p.cancel(made.reason);
|
|
123
|
-
return 1;
|
|
124
|
-
}
|
|
125
|
-
committedPublicKeyInto(root, made.publicKey);
|
|
126
|
-
p.log.success(`${color.green("✓")} ${PRIVATE_KEY_VAR} generated and stored on GitHub; public half in ${SECRETS_FILE}`);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// 3. The Cloudflare token — the one thing that cannot be derived.
|
|
131
|
-
const existing = await secretNames(root);
|
|
132
|
-
if (existing.has(TOKEN_SECRET) && !relink) {
|
|
133
|
-
p.log.info(`${TOKEN_SECRET} is already set — pass --force to replace it`);
|
|
134
|
-
} else {
|
|
135
|
-
p.log.step(`A Cloudflare API token, from ${color.cyan("https://dash.cloudflare.com/profile/api-tokens")} → Create Token → Custom token`);
|
|
136
|
-
console.log(SCOPES.map(([scope, why]) => ` ${scope.padEnd(38)} ${color.dim(why)}`).join("\n"));
|
|
137
|
-
console.log(
|
|
138
|
-
color.dim(
|
|
139
|
-
`\n The token Cloudflare generates for its own Workers Builds will NOT do:\n` +
|
|
140
|
-
` it has no D1 and no Queues permission, so it cannot create the database.\n`,
|
|
141
|
-
),
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
const token = await p.password({
|
|
145
|
-
message: "Paste it (nothing is written to disk)",
|
|
146
|
-
validate: (input) => (input && input.length >= 20 ? undefined : "that is too short to be a Cloudflare token"),
|
|
147
|
-
});
|
|
148
|
-
if (p.isCancel(token)) {
|
|
149
|
-
p.cancel("nothing was stored.");
|
|
150
|
-
return 1;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const spinner = p.spinner();
|
|
154
|
-
spinner.start("asking Cloudflare whether that token is real");
|
|
155
|
-
const verified = await verifyCloudflareToken(String(token));
|
|
156
|
-
if (!verified.ok) {
|
|
157
|
-
spinner.stop(`${color.red("✗")} Cloudflare rejected it: ${verified.detail}`);
|
|
158
|
-
p.cancel("nothing was stored.");
|
|
159
|
-
return 1;
|
|
160
|
-
}
|
|
161
|
-
const accounts = await cloudflareAccounts(String(token));
|
|
162
|
-
spinner.stop(`${color.green("✓")} the token is active and sees ${accounts.length} account${accounts.length === 1 ? "" : "s"}`);
|
|
163
|
-
|
|
164
|
-
let accountId = project.manifest.cloudflare?.accountId ?? "";
|
|
165
|
-
if (accounts.length === 1) {
|
|
166
|
-
accountId = accounts[0]!.id;
|
|
167
|
-
p.log.info(`account "${accounts[0]!.name}"`);
|
|
168
|
-
} else if (accounts.length > 1) {
|
|
169
|
-
const picked = await p.select({
|
|
170
|
-
message: "Which account is this shop in?",
|
|
171
|
-
options: accounts.map((account) => ({ value: account.id, label: account.name, hint: account.id })),
|
|
172
|
-
});
|
|
173
|
-
if (p.isCancel(picked)) {
|
|
174
|
-
p.cancel("nothing was stored.");
|
|
175
|
-
return 1;
|
|
176
|
-
}
|
|
177
|
-
accountId = String(picked);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const stored = await setSecret(root, TOKEN_SECRET, String(token));
|
|
181
|
-
if (!stored.ok) {
|
|
182
|
-
p.cancel(`GitHub refused the secret: ${stored.error ?? "unknown error"}`);
|
|
183
|
-
return 1;
|
|
184
|
-
}
|
|
185
|
-
p.log.success(`${color.green("✓")} ${TOKEN_SECRET} stored on ${slug}`);
|
|
186
|
-
|
|
187
|
-
if (accountId) {
|
|
188
|
-
// A VARIABLE, not a secret: an account id is an identifier, and a
|
|
189
|
-
// readable one is easier to debug in a workflow log.
|
|
190
|
-
const varred = await setVariable(root, ACCOUNT_VAR, accountId);
|
|
191
|
-
if (varred.ok) p.log.success(`${color.green("✓")} ${ACCOUNT_VAR} set as a repository variable`);
|
|
192
|
-
project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId };
|
|
193
|
-
await writeManifest(root, project.manifest);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// 4. The shop's own secrets, so `vc link` leaves a repository that is
|
|
198
|
-
// ready rather than one that is half-configured.
|
|
199
|
-
if (declaredSecretNames(root).size === 0) {
|
|
200
|
-
p.log.step(`writing ${SECRETS_FILE}`);
|
|
201
|
-
await initSecrets(project);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// 5. What is left.
|
|
205
|
-
const secrets = await secretNames(root);
|
|
206
|
-
const vars = await variableNames(root);
|
|
207
|
-
p.outro(
|
|
208
|
-
[
|
|
209
|
-
`${color.bold("Ready.")} From here, ${color.cyan("git push")} is the deploy.`,
|
|
210
|
-
"",
|
|
211
|
-
` ${secrets.has(PRIVATE_KEY_VAR) ? color.green("✓") : color.red("✗")} ${PRIVATE_KEY_VAR} ${color.dim("reads the shop's secrets")}`,
|
|
212
|
-
` ${secrets.has(TOKEN_SECRET) ? color.green("✓") : color.red("✗")} ${TOKEN_SECRET} ${color.dim("deploys to Cloudflare")}`,
|
|
213
|
-
` ${vars.has(ACCOUNT_VAR) ? color.green("✓") : color.dim("·")} ${ACCOUNT_VAR} ${color.dim("which account")}`,
|
|
214
|
-
"",
|
|
215
|
-
` Set the shop's own secrets with ${color.cyan("vc secrets set KEY")} — that needs no`,
|
|
216
|
-
` credential at all, because encryption uses the public key in the repository.`,
|
|
217
|
-
].join("\n"),
|
|
218
|
-
);
|
|
219
|
-
return 0;
|
|
220
|
-
}
|
package/src/deploy/publish.ts
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
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
|
-
}
|