@codepress/design-kit 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -17,12 +17,19 @@ const SETUP_GUIDE = new URL(
17
17
  import.meta.url,
18
18
  );
19
19
 
20
+ /** The `storybook-setup` skill, printed by `design-kit storybook-guide`. */
21
+ const STORYBOOK_GUIDE = new URL(
22
+ "../skills/storybook-setup/SKILL.md",
23
+ import.meta.url,
24
+ );
25
+
20
26
  const USAGE = `usage: design-kit <command> [options]
21
27
 
22
28
  init set a kit up in this repository: the config, a daily
23
29
  GitHub Actions build, and a skill for its agents
24
- [--root <dir>] [--dry-run] [--force]
25
- capture stories capture every story of a Storybook 7/8 static build
30
+ [--root <dir>] [--dry-run] [--force] [--workflow-only]
31
+ --workflow-only --force rewrites just the workflow
32
+ capture stories capture every story of a Storybook 7-9 static build
26
33
  [--config <file>] [--out <dir>] [--storybook-dir <dir>]
27
34
  [--only <id,...> | --only-file <file>] [--base <previous kit>]
28
35
  capture pages capture the configured pages of a running app
@@ -33,8 +40,20 @@ const USAGE = `usage: design-kit <command> [options]
33
40
  [--kit <dir>] default: DESIGN_KIT_DIR, the config's "out", ./design-kit
34
41
  validate check design-kit.config.json against the v1 schema
35
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>]
36
52
  setup-guide print the guide for setting a kit up in a repository as
37
53
  one reviewable pull request (an agent skill)
54
+ storybook-guide print the guide for adding Storybook, with stories for
55
+ every existing component, as one reviewable pull request
56
+ (an agent skill)
38
57
 
39
58
  The config is the nearest design-kit.config.json at or above the working
40
59
  directory unless --config names one. Exit codes: 0 done, 1 a capture could
@@ -170,19 +189,50 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
170
189
  root: "value",
171
190
  "dry-run": "flag",
172
191
  force: "flag",
192
+ "workflow-only": "flag",
173
193
  });
174
194
  const { init } = await import("./init.mjs");
175
195
  return init({
176
196
  root: options.root ?? ".",
177
197
  dryRun: Boolean(options["dry-run"]),
178
198
  force: Boolean(options.force),
199
+ workflowOnly: Boolean(options["workflow-only"]),
200
+ });
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",
179
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 });
180
225
  }
181
226
  if (command === "setup-guide") {
182
227
  parse(argv.slice(1), {});
183
228
  process.stdout.write(readFileSync(SETUP_GUIDE, "utf8"));
184
229
  return 0;
185
230
  }
231
+ if (command === "storybook-guide") {
232
+ parse(argv.slice(1), {});
233
+ process.stdout.write(readFileSync(STORYBOOK_GUIDE, "utf8"));
234
+ return 0;
235
+ }
186
236
  if (command === "validate") {
187
237
  const options = parse(argv.slice(1), { config: "value" });
188
238
  const config = loadConfig(options.config);