@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.
@@ -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 deploy.
6
+ * The workflow that turns a push into a live shop.
6
7
  *
7
- * ── Why a branch and not a build ─────────────────────────────────────────
8
+ * ── Why GitHub Actions deploys, and not Cloudflare's own build ───────────
8
9
  *
9
- * Cloudflare's Workers Builds watches a repository and builds what it finds.
10
- * A strict repository has no app in it `.vc/app` is generated and
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
- * So the generator runs in GitHub Actions, where it is ordinary, and pushes
17
- * the result to its own branch. `main` stays the manifest and the data;
18
- * `void-dist` is a plain Void app with a committed `package.json`, lockfile
19
- * and `wrangler.jsonc` that anything can build from a cold checkout.
20
- * Cloudflare is pointed at that branch and needs to know nothing about
21
- * voidcommerce which also means the deploy path is one a person can run by
22
- * hand when CI is not the answer.
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
- const worker = manifest.shop.domain.split(".")[0];
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
- # Every push to main regenerates the Void app from the manifest and force-pushes
31
- # it to \`${DIST_BRANCH}\`. Cloudflare's Workers Builds watches THAT branch and
32
- # deploys it, so a push here is a deploy — and no Cloudflare credential is
33
- # stored in GitHub.
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
- # The Cloudflare side is configured once, in the dashboard, for the Worker
36
- # named \`${worker}\` (Settings Builds). DEPLOY.md has the settings and the
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: ${DIST_BRANCH}-\${{ github.repository }}
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
- build:
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, once it is set up
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
- vc deploy --cloudflare --provision
171
+ git push
131
172
  \`\`\`
132
173
 
133
- Idempotent: it creates nothing that already exists. It pins your account id,
134
- creates the D1 database and the queue, and records both in \`wrangler.jsonc\`
135
- and \`voidcommerce.json\` so every later generate carries the real ids.
136
- **Commit that change.**
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
- ### 3. The secrets
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
- They live in the repository, encrypted:
183
+ ## Once, before the first push
141
184
 
142
185
  \`\`\`sh
143
- vc secrets --init # every required key, as \`unset\`
144
- # put the real values in, then
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
- \`.env.secrets\` is committed and \`.env.keys\` is not. The deploy decrypts it and
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
- **Know the tradeoff.** Ciphertext in git is permanent: if the private key ever
157
- leaks, every secret in the history is readable, including ones you rotated.
158
- \`wrangler secret put\` does not have that property, and stays available for
159
- anything you would rather never commit. \`vc preflight\` counts a secret the
160
- repository declares as present, and refuses any value committed in the clear.
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
- ### 4. An API token the build can use
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
- This is the one non-obvious step. Cloudflare generates an API token for
165
- Workers Builds automatically, and **that token has no D1 permission** its
166
- scopes are Workers Scripts, KV, R2, Workers Routes and account/user reads. The
167
- deploy command below applies database migrations, so it needs more.
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 a token at **My Profile → API Tokens** with:
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 | Workers KV Storage: Edit, Workers R2 Storage: Edit |
176
- | Account | Queues: Edit |
177
- | Account | Account Settings: Read |
178
- | Zone | Workers Routes: Edit (${zone}) |
179
- | User | User Details: Read, Memberships: Read |
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
- Then select it in the build settings below. Use the same token for every
182
- deploy of this Worker; permissions are whatever that token has.
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
- ### 5. Connect the repository
225
+ ### The zone
185
226
 
186
- In the Cloudflare dashboard, on the Worker named \`${worker}\`, under
187
- **Settings Builds Connect**. The Worker's name must equal the \`name\` in
188
- the \`wrangler.jsonc\` at the root directory, or the build fails.
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
- | setting | value |
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
- The deploy command, on one line:
233
+ They live in the repository, encrypted, in \`${SECRETS_FILE}\`:
200
234
 
201
235
  \`\`\`sh
202
- bunx dotenvx decrypt -f .env.secrets --stdout > .vc-secrets.env && bunx wrangler d1 migrations apply DB --remote && bunx wrangler deploy -c dist/ssr/wrangler.json --secrets-file .vc-secrets.env
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
- Plain \`sh\`, so it does not rely on process substitution. The decrypted file
206
- exists only inside the build sandbox, and \`--secrets-file\` applies additively:
207
- a secret it does not name is left alone rather than deleted.
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
- The migration command names the **binding** (\`DB\`), not the database, so it
210
- still points at the right database if the name ever differs.
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
- Cloudflare's build image has Bun; pin its version with a \`BUN_VERSION\` build
213
- variable if you ever need to. The free plan allows 3,000 build minutes a month
214
- and one build at a time.
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
- ### 6. The catalogue
253
+ ## The catalogue
217
254
 
218
- \`vc import\` pushes \`data/\` into the running shop. It is an upsert, so it is
219
- safe on every deploy and costs one pass when nothing changed.
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
- ## Without Cloudflare's build
258
+ ## Doing it by hand
222
259
 
223
- The same thing by hand, from a checkout of either branch:
260
+ The same thing, from a checkout, if CI is ever not the answer:
224
261
 
225
262
  \`\`\`sh
226
- vc deploy --cloudflare
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 of the
230
- worker's vars, applies the remote migrations, and deploys exactly what it
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
  }
@@ -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
- await put(root, ".github/workflows/void-dist.yml", renderDistWorkflow(manifest), result, "regenerate");
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");
@@ -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;