@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 +84 -16
- package/package.json +4 -4
- package/schema/design-kit.config.schema.json +10 -0
- package/skills/design-kit-setup/SKILL.md +8 -5
- package/src/ci.mjs +463 -0
- package/src/cli.mjs +37 -1
- package/src/init.mjs +226 -38
- package/src/templates/design-kit-skill.md +10 -5
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`
|
|
83
|
-
(`.github/workflows/design-kit.yml`)
|
|
84
|
-
|
|
85
|
-
`gh workflow run design-kit.yml`.
|
|
86
|
-
|
|
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
|
|
121
|
-
`pages` section with `TODO` selectors to fill in, and
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
`
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
[
|
|
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
|
+
"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/
|
|
66
|
+
"url": "git+https://github.com/codepressdev/design-kit.git"
|
|
67
67
|
},
|
|
68
|
-
"homepage": "https://github.com/
|
|
68
|
+
"homepage": "https://github.com/codepressdev/design-kit#readme",
|
|
69
69
|
"bugs": {
|
|
70
|
-
"url": "https://github.com/
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
6
|
-
* one line in AGENTS.md or
|
|
7
|
-
* --force and never writes a
|
|
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
|
|
638
|
-
#
|
|
639
|
-
#
|
|
640
|
-
#
|
|
641
|
-
#
|
|
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})
|
|
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
|
|
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
|
|
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:
|
|
795
|
+
name: Did the watched files change?
|
|
674
796
|
runs-on: ubuntu-latest
|
|
675
|
-
timeout-minutes:
|
|
797
|
+
timeout-minutes: 10
|
|
676
798
|
permissions:
|
|
799
|
+
contents: read
|
|
677
800
|
actions: read
|
|
678
801
|
outputs:
|
|
679
|
-
build: \${{ steps.
|
|
802
|
+
build: \${{ steps.check.outputs.build }}
|
|
680
803
|
steps:
|
|
681
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
if [
|
|
689
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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`, `
|
|
980
|
-
*
|
|
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
|
|
29
|
-
|
|
30
|
-
the newest
|
|
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
|
|
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
|