@codepress/design-kit 0.3.0 → 0.4.1

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,
@@ -377,6 +445,6 @@ remove from markup, such as a mock API's) and a `session` object whose
377
445
  ## Contributing and license
378
446
 
379
447
  The source is on GitHub at
380
- [quantfive/design-kit](https://github.com/quantfive/design-kit), where issues
448
+ [codepressdev/design-kit](https://github.com/codepressdev/design-kit), where issues
381
449
  and pull requests are welcome; see its `CONTRIBUTING.md`. MIT licensed: see
382
450
  [`LICENSE`](./LICENSE).
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.1",
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": {
@@ -63,11 +63,11 @@
63
63
  "license": "MIT",
64
64
  "repository": {
65
65
  "type": "git",
66
- "url": "git+https://github.com/quantfive/design-kit.git"
66
+ "url": "git+https://github.com/codepressdev/design-kit.git"
67
67
  },
68
- "homepage": "https://github.com/quantfive/design-kit#readme",
68
+ "homepage": "https://github.com/codepressdev/design-kit#readme",
69
69
  "bugs": {
70
- "url": "https://github.com/quantfive/design-kit/issues"
70
+ "url": "https://github.com/codepressdev/design-kit/issues"
71
71
  },
72
72
  "publishConfig": {
73
73
  "access": "public"
@@ -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,12 @@ 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
+ // The one line a finished (not dry-run) init ends with. Printed only; init
40
+ // makes no network call.
41
+ export const CALL_TO_ACTION =
42
+ "Want every screen on one canvas, shareable with your team, and agents that build it? → https://codepress.dev";
43
+ /** How long a kit artifact is kept: the most a public repository allows. */
44
+ export const RETENTION_DAYS = 90;
37
45
 
38
46
  const IGNORED_DIRS = new Set([
39
47
  "node_modules",
@@ -549,12 +557,117 @@ function posix(path) {
549
557
  return path.split(sep).join("/");
550
558
  }
551
559
 
560
+ /** Folders at a package root that hold what the UI renders or loads. */
561
+ const ROOT_UI_DIRS = new Set([
562
+ ".storybook",
563
+ "app",
564
+ "assets",
565
+ "components",
566
+ "design-tokens",
567
+ "lib",
568
+ "pages",
569
+ "public",
570
+ "src",
571
+ "stories",
572
+ "styles",
573
+ "theme",
574
+ "tokens",
575
+ ]);
576
+ /** Global styles and tokens that can sit at the repository root. */
577
+ const ROOT_STYLE_DIRS = new Set(["design-tokens", "styles", "theme", "tokens"]);
578
+ const UI_CONFIG_FILE =
579
+ /^(tailwind|postcss|vite|next|astro|remix|svelte|nuxt|uno|panda)\.config\.[cm]?[jt]s$/;
580
+
581
+ /**
582
+ * What to watch for one package folder: the folder itself, or, when the
583
+ * package is the repository root, its UI folders and config files (watching
584
+ * the whole root would rebuild on every commit).
585
+ */
586
+ function packageWatch(dir, repoRoot) {
587
+ if (dir !== repoRoot) return [posix(relative(repoRoot, dir))];
588
+ const paths = [];
589
+ for (const entry of safeReaddir(dir)) {
590
+ if (entry.isDirectory() && ROOT_UI_DIRS.has(entry.name))
591
+ paths.push(entry.name);
592
+ else if (
593
+ entry.isFile() &&
594
+ (UI_CONFIG_FILE.test(entry.name) ||
595
+ entry.name === "package.json" ||
596
+ entry.name === "index.html")
597
+ )
598
+ paths.push(entry.name);
599
+ }
600
+ return paths.sort();
601
+ }
602
+
603
+ /** The workspace packages `dirs` depend on, directly or through each other. */
604
+ function workspaceDependencies(dirs, found) {
605
+ const all = [
606
+ ...new Set([...workspacePackages(found.repoRoot), ...found.workspaces]),
607
+ ];
608
+ const byName = new Map();
609
+ for (const dir of all) {
610
+ const name = readJson(join(dir, "package.json"))?.name;
611
+ if (name && !byName.has(name)) byName.set(name, dir);
612
+ }
613
+ const seen = new Set(dirs);
614
+ const queue = [...dirs];
615
+ const dependencies = [];
616
+ while (queue.length) {
617
+ const dir = queue.shift();
618
+ for (const name of Object.keys(deps(readJson(join(dir, "package.json"))))) {
619
+ const dep = byName.get(name);
620
+ if (dep && !seen.has(dep)) {
621
+ seen.add(dep);
622
+ dependencies.push(dep);
623
+ queue.push(dep);
624
+ }
625
+ }
626
+ }
627
+ return dependencies;
628
+ }
629
+
630
+ /**
631
+ * The config's `watch` list: the paths, relative to the repository root,
632
+ * whose change rebuilds the kit. The Storybook and app folders, the
633
+ * workspace packages they depend on (shared UI usually lives there), global
634
+ * styles and tokens at the root, the config, the lockfile and the workflow.
635
+ */
636
+ export function watchDefaults(found) {
637
+ const rel = (path) => posix(relative(found.repoRoot, path));
638
+ const roots = [
639
+ ...(found.storybook ? [found.storybook.dir] : []),
640
+ found.framework?.dir ?? found.project,
641
+ ];
642
+ const packages = [...new Set(roots)];
643
+ const paths = [];
644
+ for (const dir of [...packages, ...workspaceDependencies(packages, found)])
645
+ paths.push(...packageWatch(dir, found.repoRoot));
646
+ for (const entry of safeReaddir(found.repoRoot))
647
+ if (
648
+ (entry.isDirectory() && ROOT_STYLE_DIRS.has(entry.name)) ||
649
+ (entry.isFile() && UI_CONFIG_FILE.test(entry.name))
650
+ )
651
+ paths.push(entry.name);
652
+ paths.push(rel(join(found.project, CONFIG_NAME)));
653
+ const pm = found.packageManager;
654
+ if (pm.lockfile) paths.push(rel(join(pm.installDir, pm.lockfile)));
655
+ paths.push(WORKFLOW_PATH);
656
+ const unique = [...new Set(paths.filter(Boolean))];
657
+ // A path inside another watched folder adds nothing.
658
+ return unique.filter(
659
+ (path) =>
660
+ !unique.some((other) => other !== path && path.startsWith(`${other}/`)),
661
+ );
662
+ }
663
+
552
664
  /** design-kit.config.json, as an object. */
553
665
  export function buildConfig(found) {
554
666
  const config = {
555
667
  $schema: `https://unpkg.com/${PACKAGE.name}@${PACKAGE.version}/schema/design-kit.config.schema.json`,
556
668
  version: 1,
557
669
  out: "design-kit",
670
+ watch: watchDefaults(found),
558
671
  theme: {
559
672
  attribute: found.theme.attribute,
560
673
  dark: found.theme.dark,
@@ -634,13 +747,18 @@ export function buildWorkflow(found) {
634
747
 
635
748
  return `name: Design kit
636
749
 
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.
750
+ # Captures this repository's design kit: ${found.storybook ? "every Storybook story and " : ""}the pages
751
+ # listed in ${at(CONFIG_NAME)}, as static HTML with the CSS and fonts they
752
+ # load. It uploads the result as the \`design-kit\` artifact, which agents
753
+ # download to draw mockups from the app's real markup.
754
+ #
755
+ # It runs daily, but builds only when a path in the config's "watch" list
756
+ # changed since the newest kit's commit, when there is no kit yet, or when
757
+ # the newest kit is DESIGN_KIT_MAX_AGE_DAYS old, so a kit never expires
758
+ # unreplaced. After an upload it deletes the branch's older kits.
642
759
  #
643
- # Written by \`design-kit init\` (${PACKAGE.name}). Edit it freely.
760
+ # Written by \`design-kit init\` (${PACKAGE.name}); \`init --workflow-only
761
+ # --force\` rewrites just this file. Edit it freely.
644
762
 
645
763
  on:
646
764
  schedule:
@@ -648,11 +766,11 @@ on:
648
766
  workflow_dispatch:
649
767
  inputs:
650
768
  force:
651
- description: Build even when the newest kit is from this commit
769
+ description: Build even when no watched file changed since the newest kit
652
770
  type: boolean
653
771
  default: false
654
772
  package:
655
- description: "The ${PACKAGE.name} to install, as an npm package spec (default: the pinned version below)"
773
+ description: "The ${PACKAGE.name} to build with, as an npm package spec (default: the pinned version below). Always builds."
656
774
  type: string
657
775
  default: ""
658
776
 
@@ -667,30 +785,46 @@ env:
667
785
  DESIGN_KIT_PACKAGE: "${PACKAGE.name}@${PACKAGE.version}"
668
786
  PLAYWRIGHT_PACKAGE: "@playwright/test@${playwrightPin()}"
669
787
  CONFIG: \${{ github.workspace }}/${at(CONFIG_NAME)}
788
+ # Rebuild a kit this many days old even with no UI change. Kits are kept
789
+ # ${RETENTION_DAYS} days (retention-days below), so this refreshes the kit before it
790
+ # expires.
791
+ DESIGN_KIT_MAX_AGE_DAYS: "${DEFAULT_MAX_AGE_DAYS}"
670
792
 
671
793
  jobs:
672
794
  check:
673
- name: Any commits since the newest kit?
795
+ name: Did the watched files change?
674
796
  runs-on: ubuntu-latest
675
- timeout-minutes: 5
797
+ timeout-minutes: 10
676
798
  permissions:
799
+ contents: read
677
800
  actions: read
678
801
  outputs:
679
- build: \${{ steps.last.outputs.build }}
802
+ build: \${{ steps.check.outputs.build }}
680
803
  steps:
681
- - id: last
804
+ # Only the config is checked out and no file contents are fetched: the
805
+ # check compares the git tree and blob ids of the watched paths at the
806
+ # newest kit's commit and at this one.
807
+ - uses: actions/checkout@v5
808
+ with:
809
+ fetch-depth: 1
810
+ filter: blob:none
811
+ sparse-checkout: ${at(CONFIG_NAME)}
812
+ sparse-checkout-cone-mode: false
813
+
814
+ - uses: actions/setup-node@v5
815
+ with:
816
+ node-version: "22"
817
+
818
+ - id: check
682
819
  env:
683
820
  GH_TOKEN: \${{ github.token }}
684
821
  FORCE: \${{ inputs.force || 'false' }}
822
+ PACKAGE_INPUT: \${{ inputs.package || '' }}
685
823
  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
824
+ args=()
825
+ if [ "$FORCE" = "true" ]; then args+=(--force); fi
826
+ if [ -n "$PACKAGE_INPUT" ]; then args+=(--package "$PACKAGE_INPUT"); fi
827
+ npx --yes -p "$DESIGN_KIT_PACKAGE" design-kit ci-check --config "$CONFIG" "\${args[@]}"
694
828
 
695
829
  build:
696
830
  name: Build the design kit
@@ -698,6 +832,8 @@ jobs:
698
832
  if: needs.check.outputs.build == 'true'
699
833
  runs-on: ubuntu-latest
700
834
  timeout-minutes: 60
835
+ outputs:
836
+ artifact-id: \${{ steps.upload.outputs.artifact-id }}
701
837
  steps:
702
838
  - uses: actions/checkout@v5
703
839
  with:
@@ -745,13 +881,37 @@ ${storiesStep}
745
881
  run: |
746
882
  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
883
 
748
- - if: hashFiles('${at("design-kit")}/manifest.json') != ''
884
+ - id: upload
885
+ if: hashFiles('${at("design-kit")}/manifest.json') != ''
749
886
  uses: actions/upload-artifact@v5
750
887
  with:
751
888
  name: design-kit
752
889
  path: ${at("design-kit")}
753
- retention-days: 30
890
+ # The most a public repository allows. A private repository may
891
+ # raise it to 400 (and DESIGN_KIT_MAX_AGE_DAYS with it).
892
+ retention-days: ${RETENTION_DAYS}
754
893
  if-no-files-found: error
894
+
895
+ # Deletes this branch's older kits once the new one is uploaded. The only
896
+ # job that may delete artifacts; a failed delete is a warning.
897
+ cleanup:
898
+ name: Delete older kits
899
+ needs: build
900
+ if: needs.build.outputs.artifact-id != ''
901
+ runs-on: ubuntu-latest
902
+ timeout-minutes: 5
903
+ permissions:
904
+ actions: write
905
+ steps:
906
+ - uses: actions/setup-node@v5
907
+ with:
908
+ node-version: "22"
909
+
910
+ - continue-on-error: true
911
+ env:
912
+ GH_TOKEN: \${{ github.token }}
913
+ KEEP: \${{ needs.build.outputs.artifact-id }}
914
+ run: npx --yes -p "$DESIGN_KIT_PACKAGE" design-kit ci-cleanup --keep "$KEEP"
755
915
  `;
756
916
  }
757
917
 
@@ -759,19 +919,11 @@ ${storiesStep}
759
919
  export function buildSkill(found) {
760
920
  const pm = found.packageManager;
761
921
  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
922
  const cli = `npx --yes -p ${PACKAGE.name}@${PACKAGE.version} -p @playwright/test@${playwrightPin()} design-kit`;
770
923
  const values = {
771
924
  CLI: cli,
772
925
  CONFIG: projectDir === "." ? CONFIG_NAME : `${projectDir}/${CONFIG_NAME}`,
773
926
  PROJECT_DIR: projectDir,
774
- WATCHED_PATHS: watchedPaths,
775
927
  STORIES_LINE: found.storybook
776
928
  ? `$DK capture stories --config "$CONFIG" --out "$KIT" # builds Storybook first`
777
929
  : `# (no Storybook: this kit has pages only)`,
@@ -902,6 +1054,7 @@ function describe(found) {
902
1054
  ? `${found.playwright.name}${found.playwright.version ? ` ${found.playwright.version}` : ""}`
903
1055
  : "none (the workflow installs its own)",
904
1056
  ],
1057
+ ["Rebuilds on", watchDefaults(found).join(", ")],
905
1058
  [
906
1059
  "Theme",
907
1060
  found.theme.evidence
@@ -942,10 +1095,10 @@ function todo(found) {
942
1095
  return items;
943
1096
  }
944
1097
 
945
- function summary(found, plan, { dryRun }) {
1098
+ function summary(found, plan, { dryRun, workflowOnly, notes }) {
946
1099
  const rel = (path) => posix(relative(found.repoRoot, path)) || ".";
947
1100
  const lines = [
948
- `design-kit init${dryRun ? " --dry-run" : ""}: ${found.project}`,
1101
+ `design-kit init${workflowOnly ? " --workflow-only" : ""}${dryRun ? " --dry-run" : ""}: ${found.project}`,
949
1102
  "",
950
1103
  "Found",
951
1104
  ];
@@ -968,24 +1121,58 @@ function summary(found, plan, { dryRun }) {
968
1121
  lines.push(` ${rel(write.path)}${note}`);
969
1122
  }
970
1123
  lines.push("", "Still to decide");
971
- for (const item of todo(found)) lines.push(` - ${item}`);
1124
+ for (const item of notes ?? todo(found)) lines.push(` - ${item}`);
972
1125
  return lines.join("\n");
973
1126
  }
974
1127
 
975
1128
  // ── the command ─────────────────────────────────────────────────────────────
976
1129
 
1130
+ /**
1131
+ * `init --workflow-only`: rewrites .github/workflows/design-kit.yml from
1132
+ * this version and nothing else. The config is left alone; when it has no
1133
+ * `watch` list, the summary prints the one init would write.
1134
+ */
1135
+ function initWorkflowOnly(found, { dryRun, force, log }) {
1136
+ const configFile = join(found.project, CONFIG_NAME);
1137
+ if (!exists(configFile))
1138
+ throw new InitError(
1139
+ `${configFile} does not exist, so there is no kit to build yet. Run init without --workflow-only.`,
1140
+ );
1141
+ const path = join(found.repoRoot, WORKFLOW_PATH);
1142
+ if (!force && exists(path))
1143
+ throw new InitError(
1144
+ `${WORKFLOW_PATH} already exists. Nothing was written; pass --force with --workflow-only to replace it.`,
1145
+ );
1146
+ const plan = {
1147
+ writes: [{ path, kind: "file", content: buildWorkflow(found) }],
1148
+ };
1149
+ if (!dryRun) apply(plan.writes[0]);
1150
+ const notes = [];
1151
+ const raw = readJson(configFile);
1152
+ if (!Array.isArray(raw?.watch) || !raw.watch.length)
1153
+ notes.push(
1154
+ `${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))}`,
1155
+ );
1156
+ else notes.push(`Nothing: ${CONFIG_NAME} already has a "watch" list.`);
1157
+ log(summary(found, plan, { dryRun, workflowOnly: true, notes }));
1158
+ return 0;
1159
+ }
1160
+
977
1161
  /**
978
1162
  * 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.
1163
+ * directory), `dryRun`, `force`, `workflowOnly` (write just the workflow, for
1164
+ * a repository set up by an older version), `log`. Returns the exit code: 0
1165
+ * done, 1 already set up or another file in the way.
981
1166
  */
982
1167
  export function init({
983
1168
  root = ".",
984
1169
  dryRun = false,
985
1170
  force = false,
1171
+ workflowOnly = false,
986
1172
  log = console.log,
987
1173
  } = {}) {
988
1174
  const found = detectProject(root);
1175
+ if (workflowOnly) return initWorkflowOnly(found, { dryRun, force, log });
989
1176
  const plan = planInit(found);
990
1177
  if (!force) {
991
1178
  const config = join(found.project, CONFIG_NAME);
@@ -1013,5 +1200,6 @@ export function init({
1013
1200
  if (write.kind !== "unchanged") apply(write);
1014
1201
  }
1015
1202
  log(summary(found, plan, { dryRun }));
1203
+ if (!dryRun) log(`\n${CALL_TO_ACTION}`);
1016
1204
  return 0;
1017
1205
  }
@@ -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