@codepress/design-kit 0.3.0 → 0.4.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/README.md CHANGED
@@ -79,11 +79,19 @@ components, and draws only what is new.
79
79
 
80
80
  Nothing needs re-running after setup.
81
81
 
82
- - **It rebuilds itself.** The workflow `init` wrote
83
- (`.github/workflows/design-kit.yml`) builds the kit every day, and on
84
- demand from "Run workflow" in the Actions tab or with
85
- `gh workflow run design-kit.yml`. A day with no new commits since the last
86
- kit is skipped. Each kit is kept for 30 days.
82
+ - **It rebuilds itself, only when the UI changed.** The workflow `init`
83
+ wrote (`.github/workflows/design-kit.yml`) checks every day, and on demand
84
+ from "Run workflow" in the Actions tab or with
85
+ `gh workflow run design-kit.yml`. It builds only when a path in the
86
+ config's `watch` list changed since the newest kit's commit, and at least
87
+ every 75 days (`DESIGN_KIT_MAX_AGE_DAYS` in the workflow) so the kit never
88
+ expires. Any other day it stops after a few seconds, and the run's summary
89
+ says why it built or skipped. "Run workflow" with `force` builds anyway.
90
+ - **Kits are kept 90 days, and older ones deleted.** 90 days is the most a
91
+ public repository allows; a private one may raise `retention-days` in the
92
+ workflow to 400 (raise `DESIGN_KIT_MAX_AGE_DAYS` with it). After each
93
+ upload the workflow deletes the branch's older kits, so only the newest is
94
+ stored. A failed delete is a warning, never a failed run.
87
95
  - **It captures what exists at that commit.** Every build captures the
88
96
  stories and pages that exist at the commit it runs on. It never writes
89
97
  stories, so a new component shows up in the kit once it has a story. With
@@ -97,7 +105,46 @@ Nothing needs re-running after setup.
97
105
  - **Re-run `init` only to start over** (`--force`). To pick up a new release
98
106
  of this package, update the pinned version in the workflow
99
107
  (`DESIGN_KIT_PACKAGE`); the "Run workflow" form's `package` input tries
100
- another version for one run.
108
+ another version for one run (and always builds).
109
+
110
+ ### What makes it rebuild: `watch`
111
+
112
+ `watch` in `design-kit.config.json` lists the folders and files, relative to
113
+ the repository root, whose change rebuilds the kit. `init` writes:
114
+
115
+ - the Storybook folder and the app's folder (when either is the repository
116
+ root, its UI folders such as `src`, `app` and `.storybook` and files such
117
+ as `package.json` and `tailwind.config.ts`, since watching the whole root
118
+ would rebuild on every commit);
119
+ - the workspace packages they depend on, directly or through each other,
120
+ since shared UI usually lives there in a monorepo;
121
+ - global styles and tokens at the root (`styles`, `tokens`, `theme`, a
122
+ Tailwind or PostCSS config);
123
+ - `design-kit.config.json` itself, the lockfile and the workflow.
124
+
125
+ A pages-only repository (no Storybook) gets the app's folder and its
126
+ workspace dependencies. Edit the list freely: add a folder the UI reads from
127
+ (fixtures, a shared assets package), or drop one that changes often without
128
+ changing what renders. Paths only, no globs. Without `watch`, any commit
129
+ rebuilds. The installed `design-kit` skill reads the same list when it counts
130
+ the UI commits since a kit, so its caption matches what triggers a rebuild.
131
+
132
+ ### Updating a setup made by an older version
133
+
134
+ An older workflow keeps working as it is. To switch to the one above, run:
135
+
136
+ ```bash
137
+ npx @codepress/design-kit@latest init --workflow-only --force
138
+ ```
139
+
140
+ It rewrites only `.github/workflows/design-kit.yml`, at the new pinned
141
+ version, and leaves the config, the skill and `AGENTS.md` alone. When the
142
+ config has no `watch` list, it prints the one `init` would write; paste it
143
+ into `design-kit.config.json`, or the kit rebuilds after any commit. If you
144
+ edited the old workflow, copy the new `check` and `cleanup` jobs, the `id:
145
+ upload` and `retention-days: 90` on the upload step, the build job's
146
+ `artifact-id` output and `DESIGN_KIT_MAX_AGE_DAYS` into it instead. To
147
+ refresh the skill as well, run `init --force`, which rewrites the config too.
101
148
 
102
149
  ## Set up in your repository
103
150
 
@@ -117,14 +164,18 @@ other), the package manager and lockfile, the dev or start script and the
117
164
  app's local URL, an existing Playwright, and whether the app switches to dark
118
165
  with a class or a `data-theme` attribute. Then it writes:
119
166
 
120
- - `design-kit.config.json`: the `stories` section when Storybook is found, a
121
- `pages` section with `TODO` selectors to fill in, and the `theme`.
122
- - `.github/workflows/design-kit.yml`: a daily build (and `workflow_dispatch`)
123
- on `ubuntu-latest`. It skips when there are no commits since the newest
124
- `design-kit` artifact's commit; otherwise it installs dependencies, this
125
- package at a pinned version and Chromium, builds Storybook and captures the
126
- stories, captures the pages once no `TODO` is left in the config, and
127
- uploads the `design-kit` artifact for 30 days. A run with nothing to
167
+ - `design-kit.config.json`: the `watch` list, the `stories` section when
168
+ Storybook is found, a `pages` section with `TODO` selectors to fill in, and
169
+ the `theme`.
170
+ - `.github/workflows/design-kit.yml`: a daily check (and `workflow_dispatch`)
171
+ on `ubuntu-latest`. A `check` job runs `design-kit ci-check`, which skips
172
+ when no `watch` path changed since the newest `design-kit` artifact's
173
+ commit and that kit is under 75 days old; otherwise the build installs
174
+ dependencies, this package at a pinned version and Chromium, builds
175
+ Storybook and captures the stories, captures the pages once no `TODO` is
176
+ left in the config, and uploads the `design-kit` artifact for 90 days. A
177
+ `cleanup` job, the only one allowed to delete artifacts, then runs
178
+ `design-kit ci-cleanup` to delete the branch's older kits. A run with nothing to
128
179
  capture yet (no stories, pages still `TODO`) ends with a warning and
129
180
  uploads nothing. The page capture gets
130
181
  `DESIGN_KIT_USERNAME` and `DESIGN_KIT_PASSWORD` from repository secrets for
@@ -244,17 +295,34 @@ value>]` then replaces `slots`.
244
295
  ## Commands
245
296
 
246
297
  ```bash
247
- design-kit init [--root dir] [--dry-run] [--force]
298
+ design-kit init [--root dir] [--dry-run] [--force] [--workflow-only]
248
299
  design-kit capture stories [--config f] [--out dir] [--storybook-dir dir]
249
300
  [--only id,... | --only-file f] [--base previous-kit]
250
301
  design-kit capture pages [--config f] [--out dir] [--base-url url] [--only name,...]
251
302
  [--ready name=selector] [--screenshots dir] [--timeout ms]
252
303
  design-kit kit info|find|show|compose ... [--kit dir]
253
304
  design-kit validate [--config f]
305
+ design-kit ci-check [--config f] [--since sha --kit-created iso | --no-kit]
306
+ [--force] [--package spec] [--max-age-days n]
307
+ design-kit ci-cleanup --keep artifact-id
254
308
  design-kit setup-guide
255
309
  design-kit storybook-guide
256
310
  ```
257
311
 
312
+ `ci-check` and `ci-cleanup` are the workflow's steps. `ci-check` finds the
313
+ newest unexpired `design-kit` artifact on the branch (through `gh`, from
314
+ `GITHUB_REPOSITORY` and `GITHUB_REF_NAME`, or `--repo` and `--branch`), or
315
+ takes it from `--since` and `--kit-created`. It fetches that commit (shallow,
316
+ no file contents) and compares the git tree or blob id of each `watch` path
317
+ there and at `HEAD`; a missing path is a value of its own, so a deleted path
318
+ counts as changed. It prints `build=true` or `build=false` and the reason
319
+ (`changed: src`, `watched files unchanged since 3f2a9c1`, `refresh before
320
+ expiry: …`, `no kit yet on this branch`, `forced …`, `an explicit package was
321
+ given …`) and writes them to `$GITHUB_OUTPUT` and the step summary. A kit
322
+ that cannot be looked up or fetched builds. `ci-cleanup` deletes the
323
+ branch's kits older than `--keep` and warns about any it could not delete;
324
+ both always exit 0 unless the config is invalid.
325
+
258
326
  `setup-guide` prints the agent skill for setting a kit up in a repository
259
327
  that has none, as one reviewable pull request: run `init`, decide between
260
328
  Storybook stories and page capture, wire sign-in through repository secrets,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codepress/design-kit",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Capture your Storybook and real app pages as a static design kit, and compose self-contained mockups from it",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,16 @@
21
21
  "minLength": 1,
22
22
  "description": "Where captures write the kit when --out is not given."
23
23
  },
24
+ "watch": {
25
+ "type": "array",
26
+ "items": {
27
+ "type": "string",
28
+ "minLength": 1,
29
+ "pattern": "^(?![/\\\\])(?!.*(^|/)\\.\\.(/|$))(?!.*[*?\\[])",
30
+ "description": "A folder or file, relative to the repository root (not to this file): no leading slash, no .., no glob."
31
+ },
32
+ "description": "The paths whose change rebuilds the kit in CI (`design-kit ci-check`), relative to the repository root. A run where none of them changed since the newest kit's commit builds nothing. Without it, any change in the repository rebuilds. `init` writes the Storybook and app folders, the workspace packages they use, global styles, this file, the lockfile and the workflow."
33
+ },
24
34
  "theme": { "$ref": "#/$defs/theme" },
25
35
  "stories": { "$ref": "#/$defs/stories" },
26
36
  "pages": { "$ref": "#/$defs/pages" }
@@ -28,11 +28,14 @@ npx --yes @codepress/design-kit init
28
28
  It detects Storybook and its version, the framework, the package manager, the
29
29
  dev script, Playwright and the dark-theme mechanism, then writes:
30
30
 
31
- - `design-kit.config.json`: the stories (when Storybook is found), a `pages`
32
- skeleton with `TODO` selectors, and the theme;
33
- - `.github/workflows/design-kit.yml`: a daily build (and manual dispatch) that
34
- skips when nothing was committed since the last kit, and uploads the
35
- `design-kit` artifact for 30 days;
31
+ - `design-kit.config.json`: the `watch` list (the UI folders, the workspace
32
+ packages the app uses, the lockfile and the workflow), the stories (when
33
+ Storybook is found), a `pages` skeleton with `TODO` selectors, and the
34
+ theme;
35
+ - `.github/workflows/design-kit.yml`: a daily check (and manual dispatch) that
36
+ builds only when a `watch` path changed since the last kit or that kit is 75
37
+ days old, uploads the `design-kit` artifact for 90 days and deletes the
38
+ older ones. Check the `watch` list covers every folder the UI renders from;
36
39
  - `.claude/skills/design-kit/SKILL.md` and `.agents/skills/design-kit`: how
37
40
  agents fetch the kit, compose mockups and caption them;
38
41
  - one line in `AGENTS.md` (or `CLAUDE.md`) pointing at that skill.
package/src/ci.mjs ADDED
@@ -0,0 +1,463 @@
1
+ /**
2
+ * The CI side of the workflow `design-kit init` writes:
3
+ *
4
+ * - `design-kit ci-check` decides whether a run builds the kit. It builds
5
+ * only when a path in the config's `watch` list has changed since the
6
+ * newest kit's commit (git tree and blob ids, so no file is read), when
7
+ * there is no kit, when the newest kit is close to expiring, or when the
8
+ * run asked for it (`--force`, `--package`).
9
+ * - `design-kit ci-cleanup` deletes the older kits on the branch once a new
10
+ * one is uploaded. A failed delete is a warning, never a failure.
11
+ *
12
+ * Where kits are stored is behind two functions, `findNewestKit` and
13
+ * `deleteOlderKits` (GitHub Actions artifacts, through `gh`), so another
14
+ * store changes only those and the workflow's upload step.
15
+ */
16
+ import { execFileSync } from "node:child_process";
17
+ import { appendFileSync } from "node:fs";
18
+ import { posix } from "node:path";
19
+
20
+ import { CONFIG_NAME, ConfigError, loadConfig } from "./config.mjs";
21
+
22
+ export const ARTIFACT = "design-kit";
23
+ /** Rebuild a kit this old even with no UI change, so it never expires. */
24
+ export const DEFAULT_MAX_AGE_DAYS = 75;
25
+ /** What `watch` means when a config has none: the whole repository. */
26
+ export const DEFAULT_WATCH = ["."];
27
+ const DAY_MS = 86_400_000;
28
+ const PAGES = 5;
29
+
30
+ // ── storage: GitHub Actions artifacts ───────────────────────────────────────
31
+
32
+ /** Runs `gh` and returns its stdout. */
33
+ export function runGh(args, { env = process.env } = {}) {
34
+ return execFileSync("gh", args, {
35
+ encoding: "utf8",
36
+ env,
37
+ stdio: ["ignore", "pipe", "pipe"],
38
+ maxBuffer: 32 * 1024 * 1024,
39
+ });
40
+ }
41
+
42
+ /** Every `design-kit` artifact of the repository, newest first. */
43
+ function listKits({ repo, gh }) {
44
+ const artifacts = [];
45
+ for (let page = 1; page <= PAGES; page += 1) {
46
+ const body = JSON.parse(
47
+ gh([
48
+ "api",
49
+ `repos/${repo}/actions/artifacts?name=${ARTIFACT}&per_page=100&page=${page}`,
50
+ ]),
51
+ );
52
+ const batch = body?.artifacts ?? [];
53
+ artifacts.push(...batch);
54
+ if (batch.length < 100) break;
55
+ }
56
+ return artifacts
57
+ .filter((artifact) => artifact.name === ARTIFACT)
58
+ .sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
59
+ }
60
+
61
+ /**
62
+ * The newest unexpired kit on `branch`: `{id, sha, createdAt, runId}`, or
63
+ * null when there is none. Throws when the store cannot be read.
64
+ */
65
+ export function findNewestKit({ repo, branch, gh = runGh }) {
66
+ const kit = listKits({ repo, gh }).find(
67
+ (artifact) =>
68
+ !artifact.expired && artifact.workflow_run?.head_branch === branch,
69
+ );
70
+ if (!kit?.workflow_run?.head_sha) return null;
71
+ return {
72
+ id: String(kit.id),
73
+ sha: kit.workflow_run.head_sha,
74
+ createdAt: kit.created_at,
75
+ runId: kit.workflow_run.id ? String(kit.workflow_run.id) : null,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Deletes the kits on `branch` older than the one `keep` names (an artifact
81
+ * id). Returns `{deleted: [ids], failed: [{id, error}], keptFound}`; never
82
+ * throws for a single failed delete. When the kept kit is not in the listing
83
+ * (not listed yet, or on another branch), nothing is deleted: "older" has
84
+ * nothing to be measured against.
85
+ */
86
+ export function deleteOlderKits({ repo, branch, keep, gh = runGh }) {
87
+ const kits = listKits({ repo, gh }).filter(
88
+ (artifact) => artifact.workflow_run?.head_branch === branch,
89
+ );
90
+ const kept = kits.find((artifact) => String(artifact.id) === String(keep));
91
+ const deleted = [];
92
+ const failed = [];
93
+ if (!kept) return { deleted, failed, keptFound: false };
94
+ for (const artifact of kits) {
95
+ if (String(artifact.id) === String(keep)) continue;
96
+ // Only kits uploaded before the one just kept: a newer one, or one
97
+ // uploaded in the same instant, is never removed.
98
+ if (!(Date.parse(artifact.created_at) < Date.parse(kept.created_at)))
99
+ continue;
100
+ try {
101
+ gh([
102
+ "api",
103
+ "--method",
104
+ "DELETE",
105
+ `repos/${repo}/actions/artifacts/${artifact.id}`,
106
+ ]);
107
+ deleted.push(String(artifact.id));
108
+ } catch (error) {
109
+ failed.push({ id: String(artifact.id), error: errorText(error) });
110
+ }
111
+ }
112
+ return { deleted, failed, keptFound: Boolean(kept) };
113
+ }
114
+
115
+ function errorText(error) {
116
+ const stderr = error?.stderr ? String(error.stderr).trim() : "";
117
+ return (stderr || error?.message || String(error)).split("\n")[0];
118
+ }
119
+
120
+ // ── git: ids of the watched paths ───────────────────────────────────────────
121
+
122
+ function git(cwd, args, env = process.env) {
123
+ return execFileSync("git", ["-C", cwd, ...args], {
124
+ encoding: "utf8",
125
+ env,
126
+ stdio: ["ignore", "pipe", "pipe"],
127
+ maxBuffer: 32 * 1024 * 1024,
128
+ });
129
+ }
130
+
131
+ /**
132
+ * A `watch` entry as git names it: forward slashes, `.`/`..` segments and
133
+ * repeated or trailing slashes resolved, "" for the repository root.
134
+ */
135
+ export function normalizeWatchPath(path) {
136
+ const normalized = posix.normalize(String(path).replace(/\\/g, "/"));
137
+ return normalized.replace(/^\.\/?$/, "").replace(/\/+$/, "");
138
+ }
139
+
140
+ /**
141
+ * `{path: id}` for each watched path at `rev`: the git tree id of a folder,
142
+ * the blob id of a file, or "missing" when the path does not exist there.
143
+ * Each path is listed on its own (`git ls-tree <rev> -- <path>`), so
144
+ * overlapping entries such as `src` and `src/theme.css` cannot hide each
145
+ * other. A path that is absent lists nothing; a tree git cannot read throws,
146
+ * so the run builds instead of mistaking it for an absent path. Only trees
147
+ * are read, so it works in a blobless (`--filter=blob:none`) clone.
148
+ */
149
+ export function watchedIds(repoDir, rev, paths) {
150
+ const ids = {};
151
+ for (const path of paths) {
152
+ const key = normalizeWatchPath(path);
153
+ if (key === "") {
154
+ ids[path] = git(repoDir, [
155
+ "rev-parse",
156
+ "--verify",
157
+ `${rev}^{tree}`,
158
+ ]).trim();
159
+ continue;
160
+ }
161
+ const out = git(repoDir, ["ls-tree", "-z", "--full-tree", rev, "--", key]);
162
+ let id = "missing";
163
+ for (const entry of out.split("\0").filter(Boolean)) {
164
+ const tab = entry.indexOf("\t");
165
+ if (entry.slice(tab + 1) === key) id = entry.slice(0, tab).split(" ")[2];
166
+ }
167
+ ids[path] = id;
168
+ }
169
+ return ids;
170
+ }
171
+
172
+ /** True when `sha` is a commit in this clone. */
173
+ function hasCommit(repoDir, sha) {
174
+ try {
175
+ // No lazy fetch from a partial clone's remote: one shallow fetch below
176
+ // brings the commit and its trees together.
177
+ git(repoDir, ["cat-file", "-e", `${sha}^{commit}`], {
178
+ ...process.env,
179
+ GIT_NO_LAZY_FETCH: "1",
180
+ });
181
+ return true;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ /** Makes `sha` available: already here, or a shallow, blobless fetch. */
188
+ export function ensureCommit(repoDir, sha) {
189
+ if (hasCommit(repoDir, sha)) return;
190
+ git(repoDir, [
191
+ "fetch",
192
+ "--quiet",
193
+ "--no-tags",
194
+ "--depth=1",
195
+ "--filter=blob:none",
196
+ "origin",
197
+ sha,
198
+ ]);
199
+ if (!hasCommit(repoDir, sha)) throw new Error(`${sha} is not a commit`);
200
+ }
201
+
202
+ // ── the decision ────────────────────────────────────────────────────────────
203
+
204
+ const short = (sha) => String(sha).slice(0, 7);
205
+
206
+ /**
207
+ * The reasons to build that need no git: `force`, an explicit package, no
208
+ * kit, or a kit close to expiring. Null when none applies.
209
+ */
210
+ function buildWithoutComparing({
211
+ force,
212
+ packageSpec,
213
+ kit,
214
+ lookupError,
215
+ now,
216
+ maxAgeDays,
217
+ }) {
218
+ if (force) return { build: true, reason: "forced (the force input is set)" };
219
+ if (packageSpec)
220
+ return {
221
+ build: true,
222
+ reason: `an explicit package was given (${packageSpec})`,
223
+ };
224
+ if (!kit)
225
+ return {
226
+ build: true,
227
+ reason: lookupError
228
+ ? `could not look up the newest kit (${lookupError}), so building`
229
+ : "no kit yet on this branch",
230
+ };
231
+ const ageDays = (now - Date.parse(kit.createdAt)) / DAY_MS;
232
+ if (!(ageDays < maxAgeDays))
233
+ return {
234
+ build: true,
235
+ reason: Number.isFinite(ageDays)
236
+ ? `refresh before expiry: the newest kit (${short(kit.sha)}) is ${Math.floor(ageDays)} days old, and the limit is ${maxAgeDays}`
237
+ : `refresh before expiry: the newest kit (${short(kit.sha)}) has no usable date`,
238
+ };
239
+ return null;
240
+ }
241
+
242
+ /**
243
+ * Whether to build, and why. Pure: every input is already looked up.
244
+ * `kit` is `{sha, createdAt}` or null; `before` and `after` are
245
+ * `watchedIds` at the kit's commit and at HEAD, unless `fetchError` says
246
+ * why they could not be read.
247
+ */
248
+ export function decide({
249
+ force = false,
250
+ packageSpec = "",
251
+ kit = null,
252
+ lookupError,
253
+ now = Date.now(),
254
+ maxAgeDays = DEFAULT_MAX_AGE_DAYS,
255
+ fetchError,
256
+ before = {},
257
+ after = {},
258
+ }) {
259
+ const early = buildWithoutComparing({
260
+ force,
261
+ packageSpec,
262
+ kit,
263
+ lookupError,
264
+ now,
265
+ maxAgeDays,
266
+ });
267
+ if (early) return early;
268
+ if (fetchError)
269
+ return {
270
+ build: true,
271
+ reason: `could not read the kit's commit ${short(kit.sha)} (${fetchError}), so building`,
272
+ };
273
+ const paths = [...new Set([...Object.keys(before), ...Object.keys(after)])];
274
+ const changed = paths.filter((path) => before[path] !== after[path]);
275
+ if (changed.length)
276
+ return { build: true, reason: `changed: ${changed.join(", ")}` };
277
+ return {
278
+ build: false,
279
+ reason: `watched files unchanged since ${short(kit.sha)}`,
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Looks everything up and decides. `kit` given (`{sha, createdAt}`, or null
285
+ * for "no kit") skips the store lookup; otherwise `findKit()` is called.
286
+ * `head` is the commit being built (default HEAD). Returns
287
+ * `{build, reason, kit}`.
288
+ */
289
+ export function ciCheck({
290
+ repoDir,
291
+ head = "HEAD",
292
+ watch = DEFAULT_WATCH,
293
+ force = false,
294
+ packageSpec = "",
295
+ kit,
296
+ findKit,
297
+ now = Date.now(),
298
+ maxAgeDays = DEFAULT_MAX_AGE_DAYS,
299
+ }) {
300
+ const inputs = { force, packageSpec, now, maxAgeDays };
301
+ let lookupError;
302
+ if (kit === undefined && !force && !packageSpec) {
303
+ try {
304
+ kit = findKit();
305
+ } catch (error) {
306
+ kit = null;
307
+ lookupError = errorText(error);
308
+ }
309
+ }
310
+ kit ??= null;
311
+ const early = buildWithoutComparing({ ...inputs, kit, lookupError });
312
+ if (early) return { ...early, kit };
313
+ let fetchError;
314
+ let before;
315
+ let after;
316
+ try {
317
+ ensureCommit(repoDir, kit.sha);
318
+ before = watchedIds(repoDir, kit.sha, watch);
319
+ after = watchedIds(repoDir, head, watch);
320
+ } catch (error) {
321
+ fetchError = errorText(error);
322
+ }
323
+ const missing = after
324
+ ? Object.keys(after).filter((path) => after[path] === "missing")
325
+ : [];
326
+ return {
327
+ ...decide({ ...inputs, kit, before, after, fetchError }),
328
+ kit,
329
+ missing,
330
+ };
331
+ }
332
+
333
+ // ── the commands ────────────────────────────────────────────────────────────
334
+
335
+ function maxAgeFrom(value, env) {
336
+ const raw = value ?? env.DESIGN_KIT_MAX_AGE_DAYS;
337
+ if (raw === undefined || raw === "") return DEFAULT_MAX_AGE_DAYS;
338
+ const days = Number(raw);
339
+ if (!Number.isFinite(days) || days < 0)
340
+ throw new ConfigError(
341
+ `--max-age-days (or DESIGN_KIT_MAX_AGE_DAYS) takes a number of days, got ${raw}`,
342
+ );
343
+ return days;
344
+ }
345
+
346
+ function repoAndBranch(options, env) {
347
+ const repo = options.repo ?? env.GITHUB_REPOSITORY;
348
+ const branch = options.branch ?? env.GITHUB_REF_NAME;
349
+ if (!repo || !branch)
350
+ throw new ConfigError(
351
+ "--repo <owner/name> and --branch <name> are needed outside GitHub Actions",
352
+ );
353
+ return { repo, branch };
354
+ }
355
+
356
+ function emit(values, env) {
357
+ // One line per value: a newline (from a dispatch input in the reason)
358
+ // must not add a second `build=` line to $GITHUB_OUTPUT.
359
+ const lines = Object.entries(values).map(
360
+ ([key, value]) => `${key}=${String(value).replace(/[\r\n]+/g, " ")}`,
361
+ );
362
+ for (const line of lines) console.log(line);
363
+ if (env.GITHUB_OUTPUT)
364
+ appendFileSync(env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
365
+ }
366
+
367
+ function summary(text, env) {
368
+ if (env.GITHUB_STEP_SUMMARY)
369
+ appendFileSync(env.GITHUB_STEP_SUMMARY, `${text}\n`);
370
+ }
371
+
372
+ /**
373
+ * `design-kit ci-check`: prints `build=true|false` and `reason=…` (and
374
+ * writes them to $GITHUB_OUTPUT and the step summary when set).
375
+ */
376
+ export function ciCheckCommand(options, { env = process.env, gh, now } = {}) {
377
+ const config = loadConfig(options.config);
378
+ const watch = config.raw.watch?.length ? config.raw.watch : DEFAULT_WATCH;
379
+ if ((options.since === undefined) !== (options["kit-created"] === undefined))
380
+ throw new ConfigError("--since and --kit-created go together");
381
+ const kit =
382
+ options.since !== undefined
383
+ ? { sha: options.since, createdAt: options["kit-created"] }
384
+ : options["no-kit"]
385
+ ? null
386
+ : undefined;
387
+ if (kit?.createdAt && Number.isNaN(Date.parse(kit.createdAt)))
388
+ throw new ConfigError(
389
+ `--kit-created takes an ISO 8601 date, got ${kit.createdAt}`,
390
+ );
391
+ const repoDir = git(config.configDir, [
392
+ "rev-parse",
393
+ "--show-toplevel",
394
+ ]).trim();
395
+ const result = ciCheck({
396
+ repoDir,
397
+ watch,
398
+ force: Boolean(options.force),
399
+ packageSpec: options.package ?? "",
400
+ kit,
401
+ findKit: () =>
402
+ findNewestKit({
403
+ ...repoAndBranch(options, env),
404
+ gh: gh ?? ((args) => runGh(args, { env })),
405
+ }),
406
+ now: now ?? Date.now(),
407
+ maxAgeDays: maxAgeFrom(options["max-age-days"], env),
408
+ });
409
+ if (result.missing?.length)
410
+ console.log(
411
+ `::warning title=Design kit::These watch paths do not exist at this commit, so they never trigger a rebuild: ${result.missing.join(", ")}. Fix or remove them in ${CONFIG_NAME}.`,
412
+ );
413
+ emit(
414
+ {
415
+ build: String(result.build),
416
+ reason: result.reason,
417
+ "kit-sha": result.kit?.sha ?? "",
418
+ },
419
+ env,
420
+ );
421
+ summary(
422
+ `Design kit: ${result.build ? "building" : "nothing to build"}, ${result.reason}.`,
423
+ env,
424
+ );
425
+ return 0;
426
+ }
427
+
428
+ /**
429
+ * `design-kit ci-cleanup --keep <artifact id>`: deletes the branch's older
430
+ * kits. Always exits 0; each failed delete is a workflow warning.
431
+ */
432
+ export function ciCleanupCommand(options, { env = process.env, gh } = {}) {
433
+ if (!options.keep) throw new ConfigError("--keep <artifact id> is required");
434
+ const { repo, branch } = repoAndBranch(options, env);
435
+ let result;
436
+ try {
437
+ result = deleteOlderKits({
438
+ repo,
439
+ branch,
440
+ keep: options.keep,
441
+ gh: gh ?? ((args) => runGh(args, { env })),
442
+ });
443
+ } catch (error) {
444
+ console.log(
445
+ `::warning title=Design kit::Could not list the older kits, so none were deleted: ${errorText(error)}`,
446
+ );
447
+ return 0;
448
+ }
449
+ if (!result.keptFound) {
450
+ console.log(
451
+ `::warning title=Design kit::The kit just uploaded (${options.keep}) is not listed on ${branch} yet, so no older kit was deleted. The next build deletes them.`,
452
+ );
453
+ return 0;
454
+ }
455
+ for (const { id, error } of result.failed)
456
+ console.log(
457
+ `::warning title=Design kit::Could not delete the older kit ${id}: ${error}`,
458
+ );
459
+ const text = `Design kit: deleted ${result.deleted.length} older kit${result.deleted.length === 1 ? "" : "s"} on ${branch}${result.failed.length ? `; ${result.failed.length} could not be deleted` : ""}.`;
460
+ console.log(text);
461
+ summary(text, env);
462
+ return 0;
463
+ }
package/src/cli.mjs CHANGED
@@ -27,7 +27,8 @@ const USAGE = `usage: design-kit <command> [options]
27
27
 
28
28
  init set a kit up in this repository: the config, a daily
29
29
  GitHub Actions build, and a skill for its agents
30
- [--root <dir>] [--dry-run] [--force]
30
+ [--root <dir>] [--dry-run] [--force] [--workflow-only]
31
+ --workflow-only --force rewrites just the workflow
31
32
  capture stories capture every story of a Storybook 7-9 static build
32
33
  [--config <file>] [--out <dir>] [--storybook-dir <dir>]
33
34
  [--only <id,...> | --only-file <file>] [--base <previous kit>]
@@ -39,6 +40,15 @@ const USAGE = `usage: design-kit <command> [options]
39
40
  [--kit <dir>] default: DESIGN_KIT_DIR, the config's "out", ./design-kit
40
41
  validate check design-kit.config.json against the v1 schema
41
42
  [--config <file>]
43
+ ci-check in CI: print build=true|false and the reason. Builds
44
+ when a "watch" path changed since the newest kit, there
45
+ is no kit, it is DESIGN_KIT_MAX_AGE_DAYS (75) old, or
46
+ --force or --package is given
47
+ [--config <file>] [--since <sha> --kit-created <iso> | --no-kit]
48
+ [--force] [--package <spec>] [--max-age-days <n>]
49
+ [--repo <owner/name>] [--branch <name>]
50
+ ci-cleanup in CI: delete the branch's kits older than --keep
51
+ --keep <artifact id> [--repo <owner/name>] [--branch <name>]
42
52
  setup-guide print the guide for setting a kit up in a repository as
43
53
  one reviewable pull request (an agent skill)
44
54
  storybook-guide print the guide for adding Storybook, with stories for
@@ -179,14 +189,40 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
179
189
  root: "value",
180
190
  "dry-run": "flag",
181
191
  force: "flag",
192
+ "workflow-only": "flag",
182
193
  });
183
194
  const { init } = await import("./init.mjs");
184
195
  return init({
185
196
  root: options.root ?? ".",
186
197
  dryRun: Boolean(options["dry-run"]),
187
198
  force: Boolean(options.force),
199
+ workflowOnly: Boolean(options["workflow-only"]),
188
200
  });
189
201
  }
202
+ if (command === "ci-check") {
203
+ const options = parse(argv.slice(1), {
204
+ config: "value",
205
+ since: "value",
206
+ "kit-created": "value",
207
+ "no-kit": "flag",
208
+ force: "flag",
209
+ package: "value",
210
+ "max-age-days": "value",
211
+ repo: "value",
212
+ branch: "value",
213
+ });
214
+ const { ciCheckCommand } = await import("./ci.mjs");
215
+ return ciCheckCommand(options, { env });
216
+ }
217
+ if (command === "ci-cleanup") {
218
+ const options = parse(argv.slice(1), {
219
+ keep: "value",
220
+ repo: "value",
221
+ branch: "value",
222
+ });
223
+ const { ciCleanupCommand } = await import("./ci.mjs");
224
+ return ciCleanupCommand(options, { env });
225
+ }
190
226
  if (command === "setup-guide") {
191
227
  parse(argv.slice(1), {});
192
228
  process.stdout.write(readFileSync(SETUP_GUIDE, "utf8"));
package/src/init.mjs CHANGED
@@ -2,9 +2,10 @@
2
2
  * `design-kit init`: sets a design kit up in a repository. It looks at the
3
3
  * project (Storybook, framework, package manager, dev script, Playwright,
4
4
  * theme) and writes design-kit.config.json, a daily GitHub Actions workflow
5
- * that uploads the kit, a design-kit skill for the repository's agents and
6
- * one line in AGENTS.md or CLAUDE.md. It never overwrites a file without
7
- * --force and never writes a secret.
5
+ * that rebuilds the kit when the watched UI files change and uploads it, a
6
+ * design-kit skill for the repository's agents and one line in AGENTS.md or
7
+ * CLAUDE.md. It never overwrites a file without --force and never writes a
8
+ * secret. `--workflow-only` rewrites just the workflow.
8
9
  */
9
10
  import {
10
11
  existsSync,
@@ -20,6 +21,7 @@ import {
20
21
  } from "node:fs";
21
22
  import { dirname, join, relative, resolve, sep } from "node:path";
22
23
 
24
+ import { DEFAULT_MAX_AGE_DAYS } from "./ci.mjs";
23
25
  import { CONFIG_NAME } from "./config.mjs";
24
26
 
25
27
  const PACKAGE = JSON.parse(
@@ -34,6 +36,8 @@ export const WORKFLOW_PATH = ".github/workflows/design-kit.yml";
34
36
  export const SKILL_DIR = ".claude/skills/design-kit";
35
37
  export const AGENTS_SKILL_DIR = ".agents/skills/design-kit";
36
38
  const TODO = "TODO";
39
+ /** How long a kit artifact is kept: the most a public repository allows. */
40
+ export const RETENTION_DAYS = 90;
37
41
 
38
42
  const IGNORED_DIRS = new Set([
39
43
  "node_modules",
@@ -549,12 +553,117 @@ function posix(path) {
549
553
  return path.split(sep).join("/");
550
554
  }
551
555
 
556
+ /** Folders at a package root that hold what the UI renders or loads. */
557
+ const ROOT_UI_DIRS = new Set([
558
+ ".storybook",
559
+ "app",
560
+ "assets",
561
+ "components",
562
+ "design-tokens",
563
+ "lib",
564
+ "pages",
565
+ "public",
566
+ "src",
567
+ "stories",
568
+ "styles",
569
+ "theme",
570
+ "tokens",
571
+ ]);
572
+ /** Global styles and tokens that can sit at the repository root. */
573
+ const ROOT_STYLE_DIRS = new Set(["design-tokens", "styles", "theme", "tokens"]);
574
+ const UI_CONFIG_FILE =
575
+ /^(tailwind|postcss|vite|next|astro|remix|svelte|nuxt|uno|panda)\.config\.[cm]?[jt]s$/;
576
+
577
+ /**
578
+ * What to watch for one package folder: the folder itself, or, when the
579
+ * package is the repository root, its UI folders and config files (watching
580
+ * the whole root would rebuild on every commit).
581
+ */
582
+ function packageWatch(dir, repoRoot) {
583
+ if (dir !== repoRoot) return [posix(relative(repoRoot, dir))];
584
+ const paths = [];
585
+ for (const entry of safeReaddir(dir)) {
586
+ if (entry.isDirectory() && ROOT_UI_DIRS.has(entry.name))
587
+ paths.push(entry.name);
588
+ else if (
589
+ entry.isFile() &&
590
+ (UI_CONFIG_FILE.test(entry.name) ||
591
+ entry.name === "package.json" ||
592
+ entry.name === "index.html")
593
+ )
594
+ paths.push(entry.name);
595
+ }
596
+ return paths.sort();
597
+ }
598
+
599
+ /** The workspace packages `dirs` depend on, directly or through each other. */
600
+ function workspaceDependencies(dirs, found) {
601
+ const all = [
602
+ ...new Set([...workspacePackages(found.repoRoot), ...found.workspaces]),
603
+ ];
604
+ const byName = new Map();
605
+ for (const dir of all) {
606
+ const name = readJson(join(dir, "package.json"))?.name;
607
+ if (name && !byName.has(name)) byName.set(name, dir);
608
+ }
609
+ const seen = new Set(dirs);
610
+ const queue = [...dirs];
611
+ const dependencies = [];
612
+ while (queue.length) {
613
+ const dir = queue.shift();
614
+ for (const name of Object.keys(deps(readJson(join(dir, "package.json"))))) {
615
+ const dep = byName.get(name);
616
+ if (dep && !seen.has(dep)) {
617
+ seen.add(dep);
618
+ dependencies.push(dep);
619
+ queue.push(dep);
620
+ }
621
+ }
622
+ }
623
+ return dependencies;
624
+ }
625
+
626
+ /**
627
+ * The config's `watch` list: the paths, relative to the repository root,
628
+ * whose change rebuilds the kit. The Storybook and app folders, the
629
+ * workspace packages they depend on (shared UI usually lives there), global
630
+ * styles and tokens at the root, the config, the lockfile and the workflow.
631
+ */
632
+ export function watchDefaults(found) {
633
+ const rel = (path) => posix(relative(found.repoRoot, path));
634
+ const roots = [
635
+ ...(found.storybook ? [found.storybook.dir] : []),
636
+ found.framework?.dir ?? found.project,
637
+ ];
638
+ const packages = [...new Set(roots)];
639
+ const paths = [];
640
+ for (const dir of [...packages, ...workspaceDependencies(packages, found)])
641
+ paths.push(...packageWatch(dir, found.repoRoot));
642
+ for (const entry of safeReaddir(found.repoRoot))
643
+ if (
644
+ (entry.isDirectory() && ROOT_STYLE_DIRS.has(entry.name)) ||
645
+ (entry.isFile() && UI_CONFIG_FILE.test(entry.name))
646
+ )
647
+ paths.push(entry.name);
648
+ paths.push(rel(join(found.project, CONFIG_NAME)));
649
+ const pm = found.packageManager;
650
+ if (pm.lockfile) paths.push(rel(join(pm.installDir, pm.lockfile)));
651
+ paths.push(WORKFLOW_PATH);
652
+ const unique = [...new Set(paths.filter(Boolean))];
653
+ // A path inside another watched folder adds nothing.
654
+ return unique.filter(
655
+ (path) =>
656
+ !unique.some((other) => other !== path && path.startsWith(`${other}/`)),
657
+ );
658
+ }
659
+
552
660
  /** design-kit.config.json, as an object. */
553
661
  export function buildConfig(found) {
554
662
  const config = {
555
663
  $schema: `https://unpkg.com/${PACKAGE.name}@${PACKAGE.version}/schema/design-kit.config.schema.json`,
556
664
  version: 1,
557
665
  out: "design-kit",
666
+ watch: watchDefaults(found),
558
667
  theme: {
559
668
  attribute: found.theme.attribute,
560
669
  dark: found.theme.dark,
@@ -634,13 +743,18 @@ export function buildWorkflow(found) {
634
743
 
635
744
  return `name: Design kit
636
745
 
637
- # Captures this repository's design kit once a day: ${found.storybook ? "every Storybook story and " : ""}the
638
- # pages listed in ${at(CONFIG_NAME)}, as static HTML with the CSS and fonts
639
- # they load. It uploads the result as the \`design-kit\` artifact (kept 30
640
- # days), which agents download to draw mockups from the app's real markup.
641
- # A day with no new commits since the newest kit builds nothing.
746
+ # Captures this repository's design kit: ${found.storybook ? "every Storybook story and " : ""}the pages
747
+ # listed in ${at(CONFIG_NAME)}, as static HTML with the CSS and fonts they
748
+ # load. It uploads the result as the \`design-kit\` artifact, which agents
749
+ # download to draw mockups from the app's real markup.
750
+ #
751
+ # It runs daily, but builds only when a path in the config's "watch" list
752
+ # changed since the newest kit's commit, when there is no kit yet, or when
753
+ # the newest kit is DESIGN_KIT_MAX_AGE_DAYS old, so a kit never expires
754
+ # unreplaced. After an upload it deletes the branch's older kits.
642
755
  #
643
- # Written by \`design-kit init\` (${PACKAGE.name}). Edit it freely.
756
+ # Written by \`design-kit init\` (${PACKAGE.name}); \`init --workflow-only
757
+ # --force\` rewrites just this file. Edit it freely.
644
758
 
645
759
  on:
646
760
  schedule:
@@ -648,11 +762,11 @@ on:
648
762
  workflow_dispatch:
649
763
  inputs:
650
764
  force:
651
- description: Build even when the newest kit is from this commit
765
+ description: Build even when no watched file changed since the newest kit
652
766
  type: boolean
653
767
  default: false
654
768
  package:
655
- description: "The ${PACKAGE.name} to install, as an npm package spec (default: the pinned version below)"
769
+ description: "The ${PACKAGE.name} to build with, as an npm package spec (default: the pinned version below). Always builds."
656
770
  type: string
657
771
  default: ""
658
772
 
@@ -667,30 +781,46 @@ env:
667
781
  DESIGN_KIT_PACKAGE: "${PACKAGE.name}@${PACKAGE.version}"
668
782
  PLAYWRIGHT_PACKAGE: "@playwright/test@${playwrightPin()}"
669
783
  CONFIG: \${{ github.workspace }}/${at(CONFIG_NAME)}
784
+ # Rebuild a kit this many days old even with no UI change. Kits are kept
785
+ # ${RETENTION_DAYS} days (retention-days below), so this refreshes the kit before it
786
+ # expires.
787
+ DESIGN_KIT_MAX_AGE_DAYS: "${DEFAULT_MAX_AGE_DAYS}"
670
788
 
671
789
  jobs:
672
790
  check:
673
- name: Any commits since the newest kit?
791
+ name: Did the watched files change?
674
792
  runs-on: ubuntu-latest
675
- timeout-minutes: 5
793
+ timeout-minutes: 10
676
794
  permissions:
795
+ contents: read
677
796
  actions: read
678
797
  outputs:
679
- build: \${{ steps.last.outputs.build }}
798
+ build: \${{ steps.check.outputs.build }}
680
799
  steps:
681
- - id: last
800
+ # Only the config is checked out and no file contents are fetched: the
801
+ # check compares the git tree and blob ids of the watched paths at the
802
+ # newest kit's commit and at this one.
803
+ - uses: actions/checkout@v5
804
+ with:
805
+ fetch-depth: 1
806
+ filter: blob:none
807
+ sparse-checkout: ${at(CONFIG_NAME)}
808
+ sparse-checkout-cone-mode: false
809
+
810
+ - uses: actions/setup-node@v5
811
+ with:
812
+ node-version: "22"
813
+
814
+ - id: check
682
815
  env:
683
816
  GH_TOKEN: \${{ github.token }}
684
817
  FORCE: \${{ inputs.force || 'false' }}
818
+ PACKAGE_INPUT: \${{ inputs.package || '' }}
685
819
  run: |
686
- last=$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=design-kit&per_page=100" \\
687
- --jq "[.artifacts[] | select((.expired | not) and .workflow_run.head_branch == \\"$GITHUB_REF_NAME\\")] | sort_by(.created_at) | last | .workflow_run.head_sha // \\"\\"") || last=""
688
- if [ "$FORCE" != "true" ] && [ -n "$last" ] && [ "$last" = "$GITHUB_SHA" ]; then
689
- echo "The newest design kit is from $GITHUB_SHA and there are no commits since: nothing to build." | tee -a "$GITHUB_STEP_SUMMARY"
690
- echo "build=false" >> "$GITHUB_OUTPUT"
691
- else
692
- echo "build=true" >> "$GITHUB_OUTPUT"
693
- fi
820
+ args=()
821
+ if [ "$FORCE" = "true" ]; then args+=(--force); fi
822
+ if [ -n "$PACKAGE_INPUT" ]; then args+=(--package "$PACKAGE_INPUT"); fi
823
+ npx --yes -p "$DESIGN_KIT_PACKAGE" design-kit ci-check --config "$CONFIG" "\${args[@]}"
694
824
 
695
825
  build:
696
826
  name: Build the design kit
@@ -698,6 +828,8 @@ jobs:
698
828
  if: needs.check.outputs.build == 'true'
699
829
  runs-on: ubuntu-latest
700
830
  timeout-minutes: 60
831
+ outputs:
832
+ artifact-id: \${{ steps.upload.outputs.artifact-id }}
701
833
  steps:
702
834
  - uses: actions/checkout@v5
703
835
  with:
@@ -745,13 +877,37 @@ ${storiesStep}
745
877
  run: |
746
878
  echo "::warning title=Design kit::Nothing to capture yet, so no kit was uploaded: ${at(CONFIG_NAME)} has no stories and its pages still have ${TODO} settings. Fill in the pages (or add Storybook) and run this workflow again."
747
879
 
748
- - if: hashFiles('${at("design-kit")}/manifest.json') != ''
880
+ - id: upload
881
+ if: hashFiles('${at("design-kit")}/manifest.json') != ''
749
882
  uses: actions/upload-artifact@v5
750
883
  with:
751
884
  name: design-kit
752
885
  path: ${at("design-kit")}
753
- retention-days: 30
886
+ # The most a public repository allows. A private repository may
887
+ # raise it to 400 (and DESIGN_KIT_MAX_AGE_DAYS with it).
888
+ retention-days: ${RETENTION_DAYS}
754
889
  if-no-files-found: error
890
+
891
+ # Deletes this branch's older kits once the new one is uploaded. The only
892
+ # job that may delete artifacts; a failed delete is a warning.
893
+ cleanup:
894
+ name: Delete older kits
895
+ needs: build
896
+ if: needs.build.outputs.artifact-id != ''
897
+ runs-on: ubuntu-latest
898
+ timeout-minutes: 5
899
+ permissions:
900
+ actions: write
901
+ steps:
902
+ - uses: actions/setup-node@v5
903
+ with:
904
+ node-version: "22"
905
+
906
+ - continue-on-error: true
907
+ env:
908
+ GH_TOKEN: \${{ github.token }}
909
+ KEEP: \${{ needs.build.outputs.artifact-id }}
910
+ run: npx --yes -p "$DESIGN_KIT_PACKAGE" design-kit ci-cleanup --keep "$KEEP"
755
911
  `;
756
912
  }
757
913
 
@@ -759,19 +915,11 @@ ${storiesStep}
759
915
  export function buildSkill(found) {
760
916
  const pm = found.packageManager;
761
917
  const projectDir = posix(relative(found.repoRoot, found.project)) || ".";
762
- const watched = found.storybook
763
- ? [...new Set([found.storybook.dir, found.framework?.dir ?? found.project])]
764
- : [found.framework?.dir ?? found.project];
765
- const watchedPaths =
766
- watched
767
- .map((dir) => posix(relative(found.repoRoot, dir)) || ".")
768
- .join(" ") || ".";
769
918
  const cli = `npx --yes -p ${PACKAGE.name}@${PACKAGE.version} -p @playwright/test@${playwrightPin()} design-kit`;
770
919
  const values = {
771
920
  CLI: cli,
772
921
  CONFIG: projectDir === "." ? CONFIG_NAME : `${projectDir}/${CONFIG_NAME}`,
773
922
  PROJECT_DIR: projectDir,
774
- WATCHED_PATHS: watchedPaths,
775
923
  STORIES_LINE: found.storybook
776
924
  ? `$DK capture stories --config "$CONFIG" --out "$KIT" # builds Storybook first`
777
925
  : `# (no Storybook: this kit has pages only)`,
@@ -902,6 +1050,7 @@ function describe(found) {
902
1050
  ? `${found.playwright.name}${found.playwright.version ? ` ${found.playwright.version}` : ""}`
903
1051
  : "none (the workflow installs its own)",
904
1052
  ],
1053
+ ["Rebuilds on", watchDefaults(found).join(", ")],
905
1054
  [
906
1055
  "Theme",
907
1056
  found.theme.evidence
@@ -942,10 +1091,10 @@ function todo(found) {
942
1091
  return items;
943
1092
  }
944
1093
 
945
- function summary(found, plan, { dryRun }) {
1094
+ function summary(found, plan, { dryRun, workflowOnly, notes }) {
946
1095
  const rel = (path) => posix(relative(found.repoRoot, path)) || ".";
947
1096
  const lines = [
948
- `design-kit init${dryRun ? " --dry-run" : ""}: ${found.project}`,
1097
+ `design-kit init${workflowOnly ? " --workflow-only" : ""}${dryRun ? " --dry-run" : ""}: ${found.project}`,
949
1098
  "",
950
1099
  "Found",
951
1100
  ];
@@ -968,24 +1117,58 @@ function summary(found, plan, { dryRun }) {
968
1117
  lines.push(` ${rel(write.path)}${note}`);
969
1118
  }
970
1119
  lines.push("", "Still to decide");
971
- for (const item of todo(found)) lines.push(` - ${item}`);
1120
+ for (const item of notes ?? todo(found)) lines.push(` - ${item}`);
972
1121
  return lines.join("\n");
973
1122
  }
974
1123
 
975
1124
  // ── the command ─────────────────────────────────────────────────────────────
976
1125
 
1126
+ /**
1127
+ * `init --workflow-only`: rewrites .github/workflows/design-kit.yml from
1128
+ * this version and nothing else. The config is left alone; when it has no
1129
+ * `watch` list, the summary prints the one init would write.
1130
+ */
1131
+ function initWorkflowOnly(found, { dryRun, force, log }) {
1132
+ const configFile = join(found.project, CONFIG_NAME);
1133
+ if (!exists(configFile))
1134
+ throw new InitError(
1135
+ `${configFile} does not exist, so there is no kit to build yet. Run init without --workflow-only.`,
1136
+ );
1137
+ const path = join(found.repoRoot, WORKFLOW_PATH);
1138
+ if (!force && exists(path))
1139
+ throw new InitError(
1140
+ `${WORKFLOW_PATH} already exists. Nothing was written; pass --force with --workflow-only to replace it.`,
1141
+ );
1142
+ const plan = {
1143
+ writes: [{ path, kind: "file", content: buildWorkflow(found) }],
1144
+ };
1145
+ if (!dryRun) apply(plan.writes[0]);
1146
+ const notes = [];
1147
+ const raw = readJson(configFile);
1148
+ if (!Array.isArray(raw?.watch) || !raw.watch.length)
1149
+ notes.push(
1150
+ `${CONFIG_NAME} has no "watch" list, so the kit rebuilds after any commit. To rebuild only when the UI changes, add:\n "watch": ${JSON.stringify(watchDefaults(found))}`,
1151
+ );
1152
+ else notes.push(`Nothing: ${CONFIG_NAME} already has a "watch" list.`);
1153
+ log(summary(found, plan, { dryRun, workflowOnly: true, notes }));
1154
+ return 0;
1155
+ }
1156
+
977
1157
  /**
978
1158
  * Runs init. Options: `root` (the project folder, default the working
979
- * directory), `dryRun`, `force`, `log`. Returns the exit code: 0 done, 1
980
- * already set up or another file in the way.
1159
+ * directory), `dryRun`, `force`, `workflowOnly` (write just the workflow, for
1160
+ * a repository set up by an older version), `log`. Returns the exit code: 0
1161
+ * done, 1 already set up or another file in the way.
981
1162
  */
982
1163
  export function init({
983
1164
  root = ".",
984
1165
  dryRun = false,
985
1166
  force = false,
1167
+ workflowOnly = false,
986
1168
  log = console.log,
987
1169
  } = {}) {
988
1170
  const found = detectProject(root);
1171
+ if (workflowOnly) return initWorkflowOnly(found, { dryRun, force, log });
989
1172
  const plan = planInit(found);
990
1173
  if (!force) {
991
1174
  const config = join(found.project, CONFIG_NAME);
@@ -25,9 +25,10 @@ is missing from every kit until it gets one.
25
25
  ## 1. Fetch the newest kit
26
26
 
27
27
  The workflow runs once a day and on demand (`workflow_dispatch`), and uploads
28
- the `design-kit` artifact (kept 30 days). A day with no new commits skips and
29
- uploads nothing, yet still succeeds, so look up the newest artifact rather than
30
- the newest run, then download it once per kit commit:
28
+ the `design-kit` artifact (kept 90 days; older kits are deleted). A day when
29
+ none of the config's `watch` paths changed skips and uploads nothing, yet
30
+ still succeeds, so look up the newest artifact rather than the newest run,
31
+ then download it once per kit commit:
31
32
 
32
33
  ```bash
33
34
  REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
@@ -51,11 +52,15 @@ working files in a scratch folder, never in the repository.
51
52
  ## 2. Check staleness
52
53
 
53
54
  The kit is a snapshot of `manifest.sha`, up to a day behind. Check whether UI
54
- code changed since:
55
+ code changed since, over the same `watch` paths that make the workflow
56
+ rebuild (read from the config, so the two always agree):
55
57
 
56
58
  ```bash
57
59
  KIT_SHA=$(node -p 'require(process.argv[1]).sha' "$KIT/manifest.json")
58
- git fetch -q origin "$BRANCH" && git log --oneline "$KIT_SHA..origin/$BRANCH" -- {{WATCHED_PATHS}}
60
+ TOP=$(git rev-parse --show-toplevel)
61
+ git fetch -q origin "$BRANCH" &&
62
+ node -e 'const w = require(process.argv[1]).watch; process.stdout.write((w?.length ? w : ["."]).map((p) => `:(top)${p}`).join("\0"))' "$TOP/{{CONFIG}}" |
63
+ xargs -0 git log --oneline "$KIT_SHA..origin/$BRANCH" --
59
64
  ```
60
65
 
61
66
  When the list is not empty, say so on the mockup: "Kit from `<sha7>`; N UI