@saastemly/voidcommerce 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/catalog.js +2 -1
- package/dist/cli.js +300 -58
- package/dist/deploy/github.d.ts +85 -0
- package/dist/deploy/index.d.ts +12 -0
- package/dist/deploy/keys.d.ts +77 -0
- package/dist/deploy/link.d.ts +2 -0
- package/dist/deploy/secrets.d.ts +59 -10
- package/dist/generate/ci.d.ts +33 -18
- package/dist/generate/index.d.ts +2 -0
- package/dist/index-0v6na3yp.js +35 -0
- package/dist/{index-84wq4qp8.js → index-5m3t0zfc.js} +244 -503
- package/dist/{index-z4qazajn.js → index-844b3qn9.js} +1 -4
- package/dist/index-b9b4dawy.js +797 -0
- package/dist/index-mpr7gm6k.js +4597 -0
- package/dist/{index-zh3tmj08.js → index-pz6m2hkm.js} +1 -1
- package/dist/index.js +12 -9
- package/dist/keys-b91c72zp.js +19 -0
- package/dist/manifest.js +3 -2
- package/package.json +2 -1
- package/src/cli.ts +17 -1
- package/src/deploy/github.ts +198 -0
- package/src/deploy/index.ts +109 -8
- package/src/deploy/keys.ts +276 -0
- package/src/deploy/link.ts +185 -0
- package/src/deploy/secrets.ts +230 -26
- package/src/dotenvx-primitives.d.ts +14 -0
- package/src/generate/ci.ts +168 -121
- package/src/generate/index.ts +43 -3
- package/src/generate/strict.ts +2 -0
- package/src/init.ts +1 -0
- package/src/regenerate.ts +1 -0
package/src/generate/ci.ts
CHANGED
|
@@ -1,57 +1,75 @@
|
|
|
1
1
|
import { DIST_BRANCH, DIST_DIR } from "../dist";
|
|
2
2
|
import type { Manifest } from "../manifest";
|
|
3
|
+
import { PRIVATE_KEY_VAR, SECRETS_FILE } from "../deploy/secrets";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
* The workflow that turns a push into a
|
|
6
|
+
* The workflow that turns a push into a live shop.
|
|
6
7
|
*
|
|
7
|
-
* ── Why
|
|
8
|
+
* ── Why GitHub Actions deploys, and not Cloudflare's own build ───────────
|
|
8
9
|
*
|
|
9
|
-
* Cloudflare's Workers Builds
|
|
10
|
-
*
|
|
11
|
-
* gitignored — and Cloudflare documents nothing about a build command that
|
|
12
|
-
* writes the source it then builds. The install step's ordering against a
|
|
13
|
-
* generated `package.json` is undocumented, which is not a thing to guess at
|
|
14
|
-
* in the path that puts a shop on the internet.
|
|
10
|
+
* Cloudflare's Workers Builds can watch a repository, and for a while that
|
|
11
|
+
* was the plan. Three things make it the wrong end to drive from.
|
|
15
12
|
*
|
|
16
|
-
*
|
|
17
|
-
* the
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
13
|
+
* The build secret. `.env.secrets` is ciphertext, so something must hold
|
|
14
|
+
* the key that opens it. Workers Builds takes build variables ONLY from the
|
|
15
|
+
* dashboard — there is no repository file for them — so the one credential
|
|
16
|
+
* that makes push-to-deploy work would have to be pasted into a web page.
|
|
17
|
+
*
|
|
18
|
+
* The token. The API token Cloudflare generates for its own builds has no
|
|
19
|
+
* D1 and no Queues permission, so it cannot create this shop's database.
|
|
20
|
+
* Replacing it is another dashboard visit.
|
|
21
|
+
*
|
|
22
|
+
* The bootstrap. Connecting the repository is itself a dashboard step, on a
|
|
23
|
+
* Worker that has to exist first.
|
|
24
|
+
*
|
|
25
|
+
* GitHub already has what is needed. A person who can push is logged into
|
|
26
|
+
* `gh`, and `gh` can write repository secrets from the terminal — so
|
|
27
|
+
* `vc link` puts the key and the token there in one command, and this
|
|
28
|
+
* workflow spends them. Nothing is typed into a web page except the token
|
|
29
|
+
* itself, once, because Cloudflare will not issue one any other way.
|
|
30
|
+
*
|
|
31
|
+
* ── The `void-dist` branch is still built ────────────────────────────────
|
|
32
|
+
*
|
|
33
|
+
* It is no longer the deploy path, but it stays: a plain Void app with a
|
|
34
|
+
* committed lockfile and `wrangler.jsonc` that anything can build from a
|
|
35
|
+
* cold checkout. It is what makes the deploy reproducible by hand, and it
|
|
36
|
+
* is the escape hatch for anyone who does want Cloudflare's build after all.
|
|
37
|
+
*
|
|
38
|
+
* @see https://developers.cloudflare.com/workers/ci-cd/builds/configuration/#api-token
|
|
23
39
|
*/
|
|
24
40
|
export function renderDistWorkflow(manifest: Manifest): string {
|
|
25
|
-
|
|
26
|
-
return `name: Build the deployable app
|
|
41
|
+
return `name: Deploy the shop
|
|
27
42
|
|
|
28
43
|
# Generated by \`vc init\` from voidcommerce.json.
|
|
29
44
|
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
45
|
+
# A push to main regenerates the Void app from the manifest, publishes it to
|
|
46
|
+
# \`${DIST_BRANCH}\` as a standalone tree, and deploys it to Cloudflare.
|
|
47
|
+
#
|
|
48
|
+
# The credentials come from the repository, set once by \`vc link\`:
|
|
49
|
+
#
|
|
50
|
+
# secrets.CLOUDFLARE_API_TOKEN deploys, and creates D1 + the queue
|
|
51
|
+
# secrets.${PRIVATE_KEY_VAR} opens ${SECRETS_FILE}
|
|
52
|
+
# vars.CLOUDFLARE_ACCOUNT_ID which account (an id, not a credential)
|
|
34
53
|
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
# one non-obvious part: the build's API token needs D1:Edit, which the token
|
|
38
|
-
# Cloudflare generates for you does not have.
|
|
54
|
+
# Nothing here needs a Cloudflare dashboard visit and nothing needs wrangler
|
|
55
|
+
# on your machine. See DEPLOY.md.
|
|
39
56
|
on:
|
|
40
57
|
push:
|
|
41
58
|
branches: [main, master]
|
|
42
59
|
workflow_dispatch:
|
|
43
60
|
|
|
44
|
-
# A later push wins: an older tree must never overwrite a newer one
|
|
61
|
+
# A later push wins: an older tree must never overwrite a newer one, and two
|
|
62
|
+
# deploys must never race for the same worker.
|
|
45
63
|
concurrency:
|
|
46
|
-
group:
|
|
64
|
+
group: deploy-\${{ github.repository }}
|
|
47
65
|
cancel-in-progress: true
|
|
48
66
|
|
|
49
|
-
# contents: write is what lets GITHUB_TOKEN force-push the branch. Nothing else.
|
|
50
67
|
permissions:
|
|
51
68
|
contents: write
|
|
52
69
|
|
|
53
70
|
jobs:
|
|
54
|
-
|
|
71
|
+
# ── Regenerate, and publish the standalone tree ────────────────────────
|
|
72
|
+
dist:
|
|
55
73
|
runs-on: ubuntu-latest
|
|
56
74
|
steps:
|
|
57
75
|
- uses: actions/checkout@v6
|
|
@@ -92,6 +110,50 @@ jobs:
|
|
|
92
110
|
git add -A
|
|
93
111
|
git commit -q -m "\${{ github.sha }} — ${manifest.shop.domain}"
|
|
94
112
|
git push -f "https://x-access-token:\${GITHUB_TOKEN}@github.com/\${{ github.repository }}.git" ${DIST_BRANCH}
|
|
113
|
+
|
|
114
|
+
# ── Deploy it ──────────────────────────────────────────────────────────
|
|
115
|
+
deploy:
|
|
116
|
+
needs: dist
|
|
117
|
+
runs-on: ubuntu-latest
|
|
118
|
+
environment:
|
|
119
|
+
name: production
|
|
120
|
+
url: https://${manifest.shop.domain}
|
|
121
|
+
env:
|
|
122
|
+
CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
123
|
+
CLOUDFLARE_ACCOUNT_ID: \${{ vars.CLOUDFLARE_ACCOUNT_ID }}
|
|
124
|
+
${PRIVATE_KEY_VAR}: \${{ secrets.${PRIVATE_KEY_VAR} }}
|
|
125
|
+
steps:
|
|
126
|
+
- uses: actions/checkout@v6
|
|
127
|
+
- uses: oven-sh/setup-bun@v2
|
|
128
|
+
|
|
129
|
+
# Checked before anything is built, so a repository that was never
|
|
130
|
+
# linked says so in five seconds rather than four minutes.
|
|
131
|
+
- name: Are the credentials here?
|
|
132
|
+
run: |
|
|
133
|
+
set -euo pipefail
|
|
134
|
+
missing=""
|
|
135
|
+
[ -n "\${CLOUDFLARE_API_TOKEN:-}" ] || missing="$missing CLOUDFLARE_API_TOKEN"
|
|
136
|
+
[ -n "\${${PRIVATE_KEY_VAR}:-}" ] || missing="$missing ${PRIVATE_KEY_VAR}"
|
|
137
|
+
if [ -n "$missing" ]; then
|
|
138
|
+
echo "::error::this repository has no$missing. Run 'vc link' once, from a checkout."
|
|
139
|
+
exit 1
|
|
140
|
+
fi
|
|
141
|
+
|
|
142
|
+
- run: bun install --frozen-lockfile
|
|
143
|
+
|
|
144
|
+
# One command: preflight, provision D1 and the queue if they are not
|
|
145
|
+
# there, build, strip the values Void bakes into the worker's plaintext
|
|
146
|
+
# vars, apply the committed migrations, and deploy with the decrypted
|
|
147
|
+
# secrets attached to the version. Provisioning is idempotent — it looks
|
|
148
|
+
# a resource up by name before creating it — so this is safe every run.
|
|
149
|
+
- name: vc deploy --cloudflare --provision
|
|
150
|
+
run: bunx vc deploy --cloudflare --provision
|
|
151
|
+
|
|
152
|
+
# An upsert, so it is safe on every deploy and costs one pass when
|
|
153
|
+
# nothing in data/ has changed. A failure here does not un-deploy a
|
|
154
|
+
# working shop, so it warns rather than failing the run.
|
|
155
|
+
- name: Push the catalogue
|
|
156
|
+
run: bunx vc import || echo "::warning::the catalogue did not import; the shop is up but its products may be stale"
|
|
95
157
|
`;
|
|
96
158
|
}
|
|
97
159
|
|
|
@@ -103,131 +165,116 @@ export function renderDeployReadme(manifest: Manifest, zone: string, hosts: stri
|
|
|
103
165
|
\`${manifest.shop.domain}\` on Cloudflare Workers. Generated by \`vc init\`;
|
|
104
166
|
regenerate with \`vc generate\`.
|
|
105
167
|
|
|
106
|
-
## The loop
|
|
107
|
-
|
|
108
|
-
Push to \`main\`. GitHub Actions regenerates the app from \`voidcommerce.json\`
|
|
109
|
-
and force-pushes it to \`${DIST_BRANCH}\`; Cloudflare's Workers Builds builds that
|
|
110
|
-
branch and deploys it. Nothing else runs.
|
|
111
|
-
|
|
112
|
-
Your secrets survive every deploy — \`wrangler deploy\` never deletes a secret.
|
|
113
|
-
Plaintext \`vars\` are replaced from \`.env.production\` on each deploy, which is
|
|
114
|
-
why no credential is allowed in that file.
|
|
115
|
-
|
|
116
|
-
## Once, before the first push
|
|
117
|
-
|
|
118
|
-
Each of these either creates something in your Cloudflare account or holds a
|
|
119
|
-
credential, so none of them can live in a repository.
|
|
120
|
-
|
|
121
|
-
### 1. The zone
|
|
122
|
-
|
|
123
|
-
\`${zone}\` must be on Cloudflare. A Worker custom domain is a record Cloudflare
|
|
124
|
-
creates in its own zone, so a domain hosted anywhere else cannot have one. The
|
|
125
|
-
worker answers on ${hosts.map((h) => `\`${h}\``).join(" and ")}.
|
|
126
|
-
|
|
127
|
-
### 2. The database, the queue, and the account id
|
|
168
|
+
## The loop
|
|
128
169
|
|
|
129
170
|
\`\`\`sh
|
|
130
|
-
|
|
171
|
+
git push
|
|
131
172
|
\`\`\`
|
|
132
173
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
and
|
|
136
|
-
|
|
174
|
+
That is the deploy. GitHub Actions regenerates the app from
|
|
175
|
+
\`voidcommerce.json\`, publishes a standalone copy to \`${DIST_BRANCH}\`, creates the
|
|
176
|
+
database and queue if they are not there, applies the migrations, and deploys
|
|
177
|
+
the worker with this repository's secrets attached.
|
|
137
178
|
|
|
138
|
-
|
|
179
|
+
You do not need wrangler on your machine, and you do not need to be logged
|
|
180
|
+
into it. You do not need to open the Cloudflare dashboard after the one step
|
|
181
|
+
below.
|
|
139
182
|
|
|
140
|
-
|
|
183
|
+
## Once, before the first push
|
|
141
184
|
|
|
142
185
|
\`\`\`sh
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
bunx dotenvx encrypt -f .env.secrets # ciphertext; commit this
|
|
186
|
+
gh repo create --source=. --private --push # if there is no repo yet
|
|
187
|
+
vc link
|
|
146
188
|
\`\`\`
|
|
147
189
|
|
|
148
|
-
|
|
149
|
-
hands the values to \`wrangler deploy --secrets-file\`, which stores them as
|
|
150
|
-
real Worker secrets — not as plaintext \`vars\`, which anyone with dashboard
|
|
151
|
-
access can read.
|
|
152
|
-
|
|
153
|
-
That turns one \`wrangler secret put\` per value into one build variable, set
|
|
154
|
-
once in step 5. It also means the shop rebuilds from a checkout.
|
|
190
|
+
\`vc link\` does three things and stores all of them on the GitHub repository:
|
|
155
191
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
\`
|
|
159
|
-
|
|
160
|
-
repository
|
|
192
|
+
| what | where | why |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| \`${PRIVATE_KEY_VAR}\` | repository **secret** | opens \`${SECRETS_FILE}\`. Generated by \`vc link\`, never written to disk |
|
|
195
|
+
| \`CLOUDFLARE_API_TOKEN\` | repository **secret** | deploys, and creates D1 and the queue |
|
|
196
|
+
| \`CLOUDFLARE_ACCOUNT_ID\` | repository **variable** | which account. An identifier, not a credential |
|
|
161
197
|
|
|
162
|
-
|
|
198
|
+
It will ask you to paste a Cloudflare API token. That is the only manual step
|
|
199
|
+
in the whole setup, and it is worth saying exactly why it cannot be removed:
|
|
163
200
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
201
|
+
> **GitHub cannot mint a Cloudflare credential.** There is no OIDC or workload
|
|
202
|
+
> identity federation between them — the feature request has been open since
|
|
203
|
+
> 2025 with no commitment, and Cloudflare's own CI guidance still says to store
|
|
204
|
+
> a token in your CI provider's secrets. The Cloudflare GitHub App does not
|
|
205
|
+
> help either: it grants *Cloudflare* access to your *repository*, not the
|
|
206
|
+
> reverse. Something has to authorise creating a database in your account, and
|
|
207
|
+
> only Cloudflare can issue that authorisation.
|
|
168
208
|
|
|
169
|
-
Create
|
|
209
|
+
Create the token at **My Profile → API Tokens → Create Token → Custom token**:
|
|
170
210
|
|
|
171
|
-
| scope | permission |
|
|
172
|
-
|
|
173
|
-
| Account | Workers Scripts: Edit |
|
|
174
|
-
| Account | D1: Edit |
|
|
175
|
-
| Account |
|
|
176
|
-
| Account |
|
|
177
|
-
| Account |
|
|
178
|
-
|
|
|
179
|
-
|
|
|
211
|
+
| scope | permission | for |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| Account | Workers Scripts: Edit | deploying the worker |
|
|
214
|
+
| Account | D1: Edit | creating the database, applying migrations |
|
|
215
|
+
| Account | Queues: Edit | the order queue |
|
|
216
|
+
| Account | Workers KV Storage: Edit | sessions and caches |
|
|
217
|
+
| Account | Workers R2 Storage: Edit | product images |
|
|
218
|
+
| Account | Account Settings: Read | confirming which account |
|
|
219
|
+
| Zone | Workers Routes: Edit (${zone}) | answering on your domain |
|
|
220
|
+
| User | User Details: Read, Memberships: Read | wrangler asks at startup |
|
|
180
221
|
|
|
181
|
-
|
|
182
|
-
|
|
222
|
+
The token Cloudflare generates for its own Workers Builds will **not** do: it
|
|
223
|
+
has no D1 and no Queues permission, so it cannot create this shop's database.
|
|
183
224
|
|
|
184
|
-
###
|
|
225
|
+
### The zone
|
|
185
226
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
227
|
+
\`${zone}\` must be on Cloudflare. A Worker custom domain is a record Cloudflare
|
|
228
|
+
creates in its own zone, so a domain hosted anywhere else cannot have one. The
|
|
229
|
+
worker answers on ${hosts.map((h) => `\`${h}\``).join(" and ")}.
|
|
189
230
|
|
|
190
|
-
|
|
191
|
-
|---|---|
|
|
192
|
-
| branch | \`${DIST_BRANCH}\` |
|
|
193
|
-
| root directory | \`/\` |
|
|
194
|
-
| build command | \`bun install && bunx void prepare && bunx vp build\` |
|
|
195
|
-
| deploy command | see below |
|
|
196
|
-
| build variable | \`DOTENV_PRIVATE_KEY_SECRETS\`, marked as a secret — the one value that is not in the repository |
|
|
197
|
-
| API token | the one from step 4 |
|
|
231
|
+
## Secrets
|
|
198
232
|
|
|
199
|
-
|
|
233
|
+
They live in the repository, encrypted, in \`${SECRETS_FILE}\`:
|
|
200
234
|
|
|
201
235
|
\`\`\`sh
|
|
202
|
-
|
|
236
|
+
vc secrets set STRIPE_SECRET_KEY # prompts; never touches your shell history
|
|
237
|
+
vc secrets # what is set, what is missing
|
|
203
238
|
\`\`\`
|
|
204
239
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
a secret
|
|
240
|
+
**This needs no credential.** dotenvx is asymmetric: encryption uses the public
|
|
241
|
+
key committed at the top of the file, so anyone with a clone can set or rotate
|
|
242
|
+
a secret. Nobody with a clone can read one. Only the deploy decrypts, with the
|
|
243
|
+
private key that lives in GitHub Actions.
|
|
208
244
|
|
|
209
|
-
|
|
210
|
-
|
|
245
|
+
A pre-commit hook refuses a commit that would put a value in the clear, because
|
|
246
|
+
that cannot be undone by a later commit — the value stays in the history.
|
|
211
247
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
248
|
+
**Know the tradeoff.** Ciphertext in git is permanent: if the private key ever
|
|
249
|
+
leaks, every secret in the history is readable, including ones you rotated.
|
|
250
|
+
\`wrangler secret put\` does not have that property, and stays available for
|
|
251
|
+
anything you would rather never commit at all.
|
|
215
252
|
|
|
216
|
-
|
|
253
|
+
## The catalogue
|
|
217
254
|
|
|
218
|
-
\`vc import\` pushes \`data/\` into the running shop
|
|
219
|
-
|
|
255
|
+
\`vc import\` pushes \`data/\` into the running shop, and the workflow runs it on
|
|
256
|
+
every deploy. It is an upsert, so it costs one pass when nothing has changed.
|
|
220
257
|
|
|
221
|
-
##
|
|
258
|
+
## Doing it by hand
|
|
222
259
|
|
|
223
|
-
The same thing
|
|
260
|
+
The same thing, from a checkout, if CI is ever not the answer:
|
|
224
261
|
|
|
225
262
|
\`\`\`sh
|
|
226
|
-
|
|
263
|
+
export CLOUDFLARE_API_TOKEN=…
|
|
264
|
+
export ${PRIVATE_KEY_VAR}=… # only if you kept a copy
|
|
265
|
+
vc deploy --cloudflare --provision
|
|
227
266
|
\`\`\`
|
|
228
267
|
|
|
229
|
-
which preflights, builds, strips the baked development values out
|
|
230
|
-
worker's vars, applies the remote migrations, and deploys exactly what
|
|
231
|
-
verified.
|
|
268
|
+
which preflights, provisions, builds, strips the baked development values out
|
|
269
|
+
of the worker's vars, applies the remote migrations, and deploys exactly what
|
|
270
|
+
it verified.
|
|
271
|
+
|
|
272
|
+
## If you would rather use Cloudflare's own build
|
|
273
|
+
|
|
274
|
+
Point Workers Builds at the \`${DIST_BRANCH}\` branch — it is a plain Void app with
|
|
275
|
+
a committed lockfile. The Worker must be named \`${worker}\`, matching the \`name\`
|
|
276
|
+
in its \`wrangler.jsonc\`. You will need to set \`${PRIVATE_KEY_VAR}\` as a
|
|
277
|
+
build secret in the dashboard, and replace the auto-generated API token with
|
|
278
|
+
one that has D1 and Queues. That is the path \`vc link\` exists to avoid.
|
|
232
279
|
`;
|
|
233
280
|
}
|
package/src/generate/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cp, mkdir, readFile, readdir, symlink, writeFile } from "node:fs/promises";
|
|
1
|
+
import { cp, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { existsSync, lstatSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { CHOICES } from "../catalog";
|
|
@@ -75,6 +75,8 @@ import { renderDomainTs, renderErpTs, renderMintTs, renderNotificationsTs, rende
|
|
|
75
75
|
export interface GenerateResult {
|
|
76
76
|
written: string[];
|
|
77
77
|
kept: string[];
|
|
78
|
+
/** Generated files that no longer belong, and were deleted. */
|
|
79
|
+
retired: string[];
|
|
78
80
|
packages: string[];
|
|
79
81
|
}
|
|
80
82
|
|
|
@@ -98,6 +100,20 @@ async function put(root: string, file: string, content: string, result: Generate
|
|
|
98
100
|
result.written.push(file);
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Delete a file this generator used to write and no longer should.
|
|
105
|
+
*
|
|
106
|
+
* Renaming a generated file leaves the old one behind, and a stale GitHub
|
|
107
|
+
* workflow is not inert — it still runs on every push. So a rename has to
|
|
108
|
+
* be a write AND a delete, and the delete has to be reported.
|
|
109
|
+
*/
|
|
110
|
+
async function retire(root: string, file: string, result: GenerateResult) {
|
|
111
|
+
const path = join(root, file);
|
|
112
|
+
if (!(await exists(path))) return;
|
|
113
|
+
await rm(path, { force: true });
|
|
114
|
+
result.retired.push(file);
|
|
115
|
+
}
|
|
116
|
+
|
|
101
117
|
/** Read a JSON file if present, let the caller add to it, write it back. Everything else in it is kept. */
|
|
102
118
|
async function mergeJson(root: string, file: string, fallback: Record<string, unknown>, mutate: (json: Record<string, unknown>) => void, result: GenerateResult) {
|
|
103
119
|
const path = join(root, file);
|
|
@@ -125,7 +141,7 @@ async function link(root: string, file: string, target: string, result: Generate
|
|
|
125
141
|
const sorted = (record: Record<string, string>) => Object.fromEntries(Object.entries(record).sort());
|
|
126
142
|
|
|
127
143
|
export async function generate(root: string, manifest: Manifest): Promise<GenerateResult> {
|
|
128
|
-
const result: GenerateResult = { written: [], kept: [], packages: packagesOf(manifest) };
|
|
144
|
+
const result: GenerateResult = { written: [], kept: [], retired: [], packages: packagesOf(manifest) };
|
|
129
145
|
|
|
130
146
|
await writeManifest(root, manifest);
|
|
131
147
|
result.written.push(MANIFEST_FILE);
|
|
@@ -320,6 +336,10 @@ async function generateStrictRoot(root: string, manifest: Manifest, result: Gene
|
|
|
320
336
|
scripts["deploy"] ??= "vc deploy";
|
|
321
337
|
scripts["import:catalog"] ??= "vc import";
|
|
322
338
|
scripts["maildev"] ??= "maildev --smtp 1025 --web 1080";
|
|
339
|
+
scripts["secrets"] ??= "vc secrets";
|
|
340
|
+
// husky installs the hook on `bun install`, so a fresh clone is
|
|
341
|
+
// guarded without anybody remembering to run anything.
|
|
342
|
+
scripts["prepare"] ??= "husky";
|
|
323
343
|
pkg["scripts"] = scripts;
|
|
324
344
|
},
|
|
325
345
|
result,
|
|
@@ -336,7 +356,27 @@ async function generateStrictRoot(root: string, manifest: Manifest, result: Gene
|
|
|
336
356
|
"own",
|
|
337
357
|
);
|
|
338
358
|
await put(root, "patches/void@0.10.13.patch", renderVoidPatch(), result, "regenerate");
|
|
339
|
-
|
|
359
|
+
/**
|
|
360
|
+
* The hook lives in `.husky/`, which is COMMITTED, so the guard travels
|
|
361
|
+
* with the repository instead of being something each clone remembers to
|
|
362
|
+
* install. A secret committed in the clear cannot be un-committed.
|
|
363
|
+
*/
|
|
364
|
+
await put(
|
|
365
|
+
root,
|
|
366
|
+
".husky/pre-commit",
|
|
367
|
+
`#!/usr/bin/env sh
|
|
368
|
+
# Generated by \`vc init\`. Refuses a commit that would put a secret in the
|
|
369
|
+
# clear in .env.secrets — which cannot be undone by a later commit,
|
|
370
|
+
# because the value stays in the history.
|
|
371
|
+
bunx vc guard
|
|
372
|
+
`,
|
|
373
|
+
result,
|
|
374
|
+
"regenerate",
|
|
375
|
+
);
|
|
376
|
+
await put(root, ".github/workflows/deploy.yml", renderDistWorkflow(manifest), result, "regenerate");
|
|
377
|
+
// Renamed when GitHub Actions took over the deploy from Cloudflare's own
|
|
378
|
+
// build. Two workflows on the same push would deploy the shop twice.
|
|
379
|
+
await retire(root, ".github/workflows/void-dist.yml", result);
|
|
340
380
|
await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
|
|
341
381
|
await put(root, ".env", renderEnvLocal(manifest), result, "own");
|
|
342
382
|
await put(root, "data/README.md", DATA_README, result, "own");
|
package/src/generate/strict.ts
CHANGED
|
@@ -66,6 +66,8 @@ export function strictDependencies(manifest: Manifest): {
|
|
|
66
66
|
// The repository's secrets are encrypted with this; a build needs it to
|
|
67
67
|
// hand them to wrangler.
|
|
68
68
|
"@dotenvx/dotenvx",
|
|
69
|
+
// Installs the committed pre-commit hook on every fresh clone.
|
|
70
|
+
"husky",
|
|
69
71
|
"@rolldown/plugin-babel",
|
|
70
72
|
"@tailwindcss/vite",
|
|
71
73
|
"@types/node",
|
package/src/init.ts
CHANGED
|
@@ -141,6 +141,7 @@ export async function init(args: string[]): Promise<number> {
|
|
|
141
141
|
[
|
|
142
142
|
...result.written.map((file) => `${color.green("+")} ${file}`),
|
|
143
143
|
...result.kept.map((file) => `${color.dim("=")} ${file} ${color.dim("(kept — yours)")}`),
|
|
144
|
+
...result.retired.map((file) => `${color.red("-")} ${file} ${color.dim("(retired — no longer generated)")}`),
|
|
144
145
|
].join("\n"),
|
|
145
146
|
"Files",
|
|
146
147
|
);
|
package/src/regenerate.ts
CHANGED
|
@@ -30,6 +30,7 @@ export async function generateCommand(): Promise<number> {
|
|
|
30
30
|
[
|
|
31
31
|
...result.written.map((file) => `${color.green("+")} ${file}`),
|
|
32
32
|
...result.kept.map((file) => `${color.dim("=")} ${file} ${color.dim("(kept)")}`),
|
|
33
|
+
...result.retired.map((file) => `${color.red("-")} ${file} ${color.dim("(retired — no longer generated)")}`),
|
|
33
34
|
].join("\n"),
|
|
34
35
|
);
|
|
35
36
|
return project.manifest.layout === "strict" ? finishStrict(project.root) : 0;
|