@codepress/design-kit 0.1.1 → 0.1.3
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 +55 -3
- package/package.json +2 -1
- package/skills/design-kit-setup/SKILL.md +136 -0
- package/src/capture-pages.mjs +22 -5
- package/src/cli.mjs +31 -1
- package/src/init.mjs +1017 -0
- package/src/templates/design-kit-skill.md +156 -0
package/src/init.mjs
ADDED
|
@@ -0,0 +1,1017 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `design-kit init`: sets a design kit up in a repository. It looks at the
|
|
3
|
+
* project (Storybook, framework, package manager, dev script, Playwright,
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
existsSync,
|
|
11
|
+
lstatSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
statSync,
|
|
17
|
+
symlinkSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
cpSync,
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
22
|
+
|
|
23
|
+
import { CONFIG_NAME } from "./config.mjs";
|
|
24
|
+
|
|
25
|
+
const PACKAGE = JSON.parse(
|
|
26
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
27
|
+
);
|
|
28
|
+
const SKILL_TEMPLATE = new URL(
|
|
29
|
+
"./templates/design-kit-skill.md",
|
|
30
|
+
import.meta.url,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export const WORKFLOW_PATH = ".github/workflows/design-kit.yml";
|
|
34
|
+
export const SKILL_DIR = ".claude/skills/design-kit";
|
|
35
|
+
export const AGENTS_SKILL_DIR = ".agents/skills/design-kit";
|
|
36
|
+
const TODO = "TODO";
|
|
37
|
+
|
|
38
|
+
const IGNORED_DIRS = new Set([
|
|
39
|
+
"node_modules",
|
|
40
|
+
".git",
|
|
41
|
+
"dist",
|
|
42
|
+
"build",
|
|
43
|
+
"out",
|
|
44
|
+
".next",
|
|
45
|
+
".nuxt",
|
|
46
|
+
".svelte-kit",
|
|
47
|
+
".astro",
|
|
48
|
+
".turbo",
|
|
49
|
+
".cache",
|
|
50
|
+
"coverage",
|
|
51
|
+
"storybook-static",
|
|
52
|
+
"design-kit",
|
|
53
|
+
"vendor",
|
|
54
|
+
]);
|
|
55
|
+
const SCAN_LIMIT = 4000;
|
|
56
|
+
const SCAN_BYTES = 256 * 1024;
|
|
57
|
+
|
|
58
|
+
/** A problem `init` refuses to work past (exit 1). */
|
|
59
|
+
export class InitError extends Error {}
|
|
60
|
+
|
|
61
|
+
// ── reading the project ─────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function readJson(file) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readText(file) {
|
|
72
|
+
try {
|
|
73
|
+
return readFileSync(file, "utf8");
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isDir(path) {
|
|
80
|
+
try {
|
|
81
|
+
return statSync(path).isDirectory();
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function deps(pkg) {
|
|
88
|
+
return {
|
|
89
|
+
...pkg?.peerDependencies,
|
|
90
|
+
...pkg?.optionalDependencies,
|
|
91
|
+
...pkg?.dependencies,
|
|
92
|
+
...pkg?.devDependencies,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The folder holding `.git` at or above `from`, else `from` itself. */
|
|
97
|
+
export function findRepoRoot(from) {
|
|
98
|
+
let dir = resolve(from);
|
|
99
|
+
for (;;) {
|
|
100
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
101
|
+
const parent = dirname(dir);
|
|
102
|
+
if (parent === dir) return resolve(from);
|
|
103
|
+
dir = parent;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `dir`, then each folder above it up to and including `top`. */
|
|
108
|
+
function upTo(dir, top) {
|
|
109
|
+
const dirs = [];
|
|
110
|
+
let current = resolve(dir);
|
|
111
|
+
for (;;) {
|
|
112
|
+
dirs.push(current);
|
|
113
|
+
if (current === top) break;
|
|
114
|
+
const parent = dirname(current);
|
|
115
|
+
if (parent === current || !`${current}${sep}`.startsWith(`${top}${sep}`))
|
|
116
|
+
break;
|
|
117
|
+
current = parent;
|
|
118
|
+
}
|
|
119
|
+
return dirs;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Workspace package folders declared by pnpm-workspace.yaml or package.json. */
|
|
123
|
+
function workspacePackages(dir) {
|
|
124
|
+
const patterns = [];
|
|
125
|
+
const pnpm = readText(join(dir, "pnpm-workspace.yaml"));
|
|
126
|
+
if (pnpm) {
|
|
127
|
+
let inPackages = false;
|
|
128
|
+
for (const line of pnpm.split("\n")) {
|
|
129
|
+
if (/^packages:\s*$/.test(line)) inPackages = true;
|
|
130
|
+
else if (/^\S/.test(line)) inPackages = false;
|
|
131
|
+
else if (inPackages) {
|
|
132
|
+
const match = line.match(/^\s*-\s*["']?([^"'#]+?)["']?\s*(#.*)?$/);
|
|
133
|
+
if (match) patterns.push(match[1].trim());
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const pkg = readJson(join(dir, "package.json"));
|
|
138
|
+
const declared = Array.isArray(pkg?.workspaces)
|
|
139
|
+
? pkg.workspaces
|
|
140
|
+
: (pkg?.workspaces?.packages ?? []);
|
|
141
|
+
patterns.push(...declared);
|
|
142
|
+
|
|
143
|
+
const found = [];
|
|
144
|
+
for (const pattern of patterns) {
|
|
145
|
+
if (pattern.startsWith("!")) continue;
|
|
146
|
+
const parts = pattern.replace(/\/+$/, "").split("/");
|
|
147
|
+
let dirs = [dir];
|
|
148
|
+
for (const part of parts) {
|
|
149
|
+
const next = [];
|
|
150
|
+
for (const base of dirs) {
|
|
151
|
+
if (part === "*" || part === "**") {
|
|
152
|
+
for (const entry of safeReaddir(base))
|
|
153
|
+
if (entry.isDirectory() && !IGNORED_DIRS.has(entry.name))
|
|
154
|
+
next.push(join(base, entry.name));
|
|
155
|
+
} else if (isDir(join(base, part))) next.push(join(base, part));
|
|
156
|
+
}
|
|
157
|
+
dirs = next;
|
|
158
|
+
}
|
|
159
|
+
for (const candidate of dirs)
|
|
160
|
+
if (
|
|
161
|
+
existsSync(join(candidate, "package.json")) &&
|
|
162
|
+
!found.includes(candidate)
|
|
163
|
+
)
|
|
164
|
+
found.push(candidate);
|
|
165
|
+
}
|
|
166
|
+
return found.sort();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function safeReaddir(dir) {
|
|
170
|
+
try {
|
|
171
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
172
|
+
} catch {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function installedVersion(name, from, top) {
|
|
178
|
+
for (const dir of upTo(from, top)) {
|
|
179
|
+
const pkg = readJson(join(dir, "node_modules", name, "package.json"));
|
|
180
|
+
if (pkg?.version) return pkg.version;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function majorOf(version) {
|
|
186
|
+
const match = String(version ?? "").match(/(\d+)/);
|
|
187
|
+
return match ? Number(match[1]) : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function storybookIn(dir, repoRoot) {
|
|
191
|
+
const pkg = readJson(join(dir, "package.json"));
|
|
192
|
+
const all = deps(pkg);
|
|
193
|
+
const names = Object.keys(all).filter(
|
|
194
|
+
(name) => name === "storybook" || name.startsWith("@storybook/"),
|
|
195
|
+
);
|
|
196
|
+
const hasConfig = isDir(join(dir, ".storybook"));
|
|
197
|
+
if (!names.length && !hasConfig) return null;
|
|
198
|
+
const versionName = names.includes("storybook")
|
|
199
|
+
? "storybook"
|
|
200
|
+
: (names.find((name) =>
|
|
201
|
+
/^@storybook\/(react|vue3|svelte|angular|web-components|html|preact|nextjs|react-vite)/.test(
|
|
202
|
+
name,
|
|
203
|
+
),
|
|
204
|
+
) ?? names[0]);
|
|
205
|
+
const version =
|
|
206
|
+
(versionName && installedVersion(versionName, dir, repoRoot)) ??
|
|
207
|
+
(versionName ? all[versionName] : null);
|
|
208
|
+
return {
|
|
209
|
+
dir,
|
|
210
|
+
hasConfig,
|
|
211
|
+
version: version ?? null,
|
|
212
|
+
major: majorOf(version),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const FRAMEWORKS = [
|
|
217
|
+
{ name: "Next.js", deps: ["next"], port: 3000, configs: ["next.config"] },
|
|
218
|
+
{
|
|
219
|
+
name: "Remix",
|
|
220
|
+
deps: ["@remix-run/dev", "@remix-run/react"],
|
|
221
|
+
port: 3000,
|
|
222
|
+
configs: ["vite.config", "remix.config"],
|
|
223
|
+
},
|
|
224
|
+
{ name: "Astro", deps: ["astro"], port: 4321, configs: ["astro.config"] },
|
|
225
|
+
{
|
|
226
|
+
name: "Create React App",
|
|
227
|
+
deps: ["react-scripts"],
|
|
228
|
+
port: 3000,
|
|
229
|
+
configs: [],
|
|
230
|
+
},
|
|
231
|
+
{ name: "Vite", deps: ["vite"], port: 5173, configs: ["vite.config"] },
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
function frameworkIn(dir) {
|
|
235
|
+
const all = deps(readJson(join(dir, "package.json")));
|
|
236
|
+
return FRAMEWORKS.find((framework) =>
|
|
237
|
+
framework.deps.some((name) => name in all),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function scriptsIn(dir) {
|
|
242
|
+
return readJson(join(dir, "package.json"))?.scripts ?? {};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function devScriptName(scripts) {
|
|
246
|
+
return ["dev", "start", "serve", "preview"].find((name) =>
|
|
247
|
+
Boolean(scripts[name]),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const LOCKFILES = [
|
|
252
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
253
|
+
["yarn.lock", "yarn"],
|
|
254
|
+
["bun.lock", "bun"],
|
|
255
|
+
["bun.lockb", "bun"],
|
|
256
|
+
["package-lock.json", "npm"],
|
|
257
|
+
["npm-shrinkwrap.json", "npm"],
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
function packageManagerOf(project, repoRoot) {
|
|
261
|
+
let lockfile = null;
|
|
262
|
+
for (const dir of upTo(project, repoRoot)) {
|
|
263
|
+
const hit = LOCKFILES.find(([file]) => existsSync(join(dir, file)));
|
|
264
|
+
if (hit) {
|
|
265
|
+
lockfile = { file: hit[0], name: hit[1], dir };
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
let declared = null;
|
|
270
|
+
for (const dir of upTo(project, repoRoot)) {
|
|
271
|
+
const field = readJson(join(dir, "package.json"))?.packageManager;
|
|
272
|
+
if (typeof field === "string") {
|
|
273
|
+
const match = field.match(/^(npm|pnpm|yarn|bun)@([^+\s]+)/);
|
|
274
|
+
if (match) {
|
|
275
|
+
declared = { name: match[1], version: match[2] };
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const name = declared?.name ?? lockfile?.name ?? "npm";
|
|
281
|
+
const installDir = lockfile?.dir ?? project;
|
|
282
|
+
let version = declared?.name === name ? declared.version : null;
|
|
283
|
+
if (!version && name === "pnpm" && lockfile) {
|
|
284
|
+
const lock = readText(join(lockfile.dir, lockfile.file)) ?? "";
|
|
285
|
+
const lockVersion = majorOf(
|
|
286
|
+
lock.match(/lockfileVersion:\s*'?([\d.]+)/)?.[1],
|
|
287
|
+
);
|
|
288
|
+
version = lockVersion >= 9 ? "10" : lockVersion === 6 ? "8" : null;
|
|
289
|
+
}
|
|
290
|
+
const berry =
|
|
291
|
+
name === "yarn" &&
|
|
292
|
+
(existsSync(join(installDir, ".yarnrc.yml")) || majorOf(version) >= 2);
|
|
293
|
+
return {
|
|
294
|
+
name,
|
|
295
|
+
version,
|
|
296
|
+
declared: Boolean(declared),
|
|
297
|
+
lockfile: lockfile?.file ?? null,
|
|
298
|
+
installDir,
|
|
299
|
+
berry,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function playwrightOf(project, repoRoot) {
|
|
304
|
+
for (const dir of upTo(project, repoRoot)) {
|
|
305
|
+
const all = deps(readJson(join(dir, "package.json")));
|
|
306
|
+
for (const name of ["@playwright/test", "playwright"])
|
|
307
|
+
if (all[name])
|
|
308
|
+
return {
|
|
309
|
+
name,
|
|
310
|
+
version: installedVersion(name, dir, repoRoot) ?? all[name],
|
|
311
|
+
dir,
|
|
312
|
+
};
|
|
313
|
+
const config = safeReaddir(dir).find((entry) =>
|
|
314
|
+
/^playwright\.config\.(m?[jt]s|cjs)$/.test(entry.name),
|
|
315
|
+
);
|
|
316
|
+
if (config) return { name: "@playwright/test", version: null, dir };
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The app's local URL: a Playwright baseURL, a configured port, or the framework's default. */
|
|
322
|
+
function appUrlOf(project, appDir, framework, repoRoot) {
|
|
323
|
+
for (const dir of [...new Set([project, appDir, repoRoot])]) {
|
|
324
|
+
for (const entry of safeReaddir(dir)) {
|
|
325
|
+
if (!/^playwright\.config\.(m?[jt]s|cjs)$/.test(entry.name)) continue;
|
|
326
|
+
const text = readText(join(dir, entry.name)) ?? "";
|
|
327
|
+
const match = text.match(/baseURL:\s*["'`](https?:\/\/[^"'`\s/]+)/);
|
|
328
|
+
if (match)
|
|
329
|
+
return {
|
|
330
|
+
url: `${match[1]}/`,
|
|
331
|
+
source: relative(repoRoot, join(dir, entry.name)),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
for (const name of framework?.configs ?? []) {
|
|
336
|
+
for (const entry of safeReaddir(appDir)) {
|
|
337
|
+
if (!entry.name.startsWith(`${name}.`)) continue;
|
|
338
|
+
const text = readText(join(appDir, entry.name)) ?? "";
|
|
339
|
+
const match = text.match(/\bport:\s*(\d{2,5})\b/);
|
|
340
|
+
if (match)
|
|
341
|
+
return {
|
|
342
|
+
url: `http://localhost:${match[1]}/`,
|
|
343
|
+
source: relative(repoRoot, join(appDir, entry.name)),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
url: `http://localhost:${framework?.port ?? 3000}/`,
|
|
349
|
+
source: framework ? `${framework.name}'s default port` : "a guess",
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Class or data-theme: whichever the project's styles and scripts use more. */
|
|
354
|
+
function themeOf(dirs) {
|
|
355
|
+
const score = { class: 0, attribute: 0 };
|
|
356
|
+
let attributeName = "data-theme";
|
|
357
|
+
let darkValue = null;
|
|
358
|
+
let lightValue = null;
|
|
359
|
+
let scanned = 0;
|
|
360
|
+
const visit = (dir) => {
|
|
361
|
+
for (const entry of safeReaddir(dir)) {
|
|
362
|
+
if (scanned >= SCAN_LIMIT) return;
|
|
363
|
+
const path = join(dir, entry.name);
|
|
364
|
+
if (entry.isDirectory()) {
|
|
365
|
+
if (!IGNORED_DIRS.has(entry.name) && !entry.name.startsWith("."))
|
|
366
|
+
visit(path);
|
|
367
|
+
else if (entry.name === ".storybook") visit(path);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (
|
|
371
|
+
!/\.(css|scss|sass|less|[cm]?[jt]sx?|html|vue|svelte|astro)$/.test(
|
|
372
|
+
entry.name,
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
continue;
|
|
376
|
+
scanned += 1;
|
|
377
|
+
let text;
|
|
378
|
+
try {
|
|
379
|
+
if (statSync(path).size > SCAN_BYTES) continue;
|
|
380
|
+
text = readFileSync(path, "utf8");
|
|
381
|
+
} catch {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const attr = text.match(
|
|
385
|
+
/\[(data-(?:theme|mode|color-scheme))=["']?(dark)["']?\]|(data-(?:theme|mode|color-scheme))=["'](dark)["']|setAttribute\(\s*["'](data-(?:theme|mode|color-scheme))["']|attribute=["'](data-[a-z-]+)["']/,
|
|
386
|
+
);
|
|
387
|
+
if (attr) {
|
|
388
|
+
score.attribute += 2;
|
|
389
|
+
attributeName =
|
|
390
|
+
attr[1] ?? attr[3] ?? attr[5] ?? attr[6] ?? attributeName;
|
|
391
|
+
if (attr[2] || attr[4]) darkValue = "dark";
|
|
392
|
+
const light = text.match(
|
|
393
|
+
new RegExp(
|
|
394
|
+
`\\[${attributeName}=["']?(light)["']?\\]|${attributeName}=["'](light)["']`,
|
|
395
|
+
),
|
|
396
|
+
);
|
|
397
|
+
if (light) lightValue = "light";
|
|
398
|
+
}
|
|
399
|
+
if (
|
|
400
|
+
/darkMode:\s*\[?\s*["'](class|selector)["']/.test(text) ||
|
|
401
|
+
/@custom-variant\s+dark\s+\(&:where\(\.dark/.test(text) ||
|
|
402
|
+
/classList\.(add|toggle|remove)\(\s*["']dark["']/.test(text) ||
|
|
403
|
+
/attribute=["']class["']/.test(text) ||
|
|
404
|
+
/(^|[\s,}])\.dark\s*[{,:.\s]/m.test(text)
|
|
405
|
+
)
|
|
406
|
+
score.class += 1;
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
for (const dir of dirs) visit(dir);
|
|
410
|
+
if (score.attribute > score.class)
|
|
411
|
+
return {
|
|
412
|
+
attribute: attributeName,
|
|
413
|
+
dark: darkValue ?? "dark",
|
|
414
|
+
...(lightValue ? { light: lightValue } : {}),
|
|
415
|
+
evidence: `${attributeName} attribute`,
|
|
416
|
+
};
|
|
417
|
+
return {
|
|
418
|
+
attribute: "class",
|
|
419
|
+
dark: "dark",
|
|
420
|
+
evidence: score.class ? "a dark class" : null,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Everything `init` decides from: the project folder (`root`, where the
|
|
426
|
+
* config goes) and the repository around it.
|
|
427
|
+
*/
|
|
428
|
+
export function detectProject(root) {
|
|
429
|
+
const project = resolve(root);
|
|
430
|
+
if (!isDir(project)) throw new InitError(`${project}: no such folder`);
|
|
431
|
+
const repoRoot = findRepoRoot(project);
|
|
432
|
+
const workspaces = workspacePackages(project);
|
|
433
|
+
const packages = [project, ...workspaces];
|
|
434
|
+
|
|
435
|
+
const storybooks = packages
|
|
436
|
+
.map((dir) => storybookIn(dir, repoRoot))
|
|
437
|
+
.filter(Boolean);
|
|
438
|
+
const storybook = storybooks[0] ?? null;
|
|
439
|
+
|
|
440
|
+
// The app: the project itself when it is one, else the first workspace
|
|
441
|
+
// package with a framework and a dev script that is not only a Storybook.
|
|
442
|
+
let app = null;
|
|
443
|
+
for (const dir of packages) {
|
|
444
|
+
const framework = frameworkIn(dir);
|
|
445
|
+
const script = devScriptName(scriptsIn(dir));
|
|
446
|
+
const onlyStorybook =
|
|
447
|
+
storybookIn(dir, repoRoot) &&
|
|
448
|
+
(!script || /storybook/.test(scriptsIn(dir)[script]));
|
|
449
|
+
if (framework && script && !onlyStorybook) {
|
|
450
|
+
app = { dir, framework, script };
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const framework = app?.framework ?? frameworkIn(project) ?? null;
|
|
455
|
+
const appDir = app?.dir ?? project;
|
|
456
|
+
|
|
457
|
+
const pm = packageManagerOf(project, repoRoot);
|
|
458
|
+
const rootScript = devScriptName(scriptsIn(project));
|
|
459
|
+
let dev = null;
|
|
460
|
+
if (rootScript && !/storybook/.test(scriptsIn(project)[rootScript]))
|
|
461
|
+
dev = {
|
|
462
|
+
dir: project,
|
|
463
|
+
script: rootScript,
|
|
464
|
+
command: runScript(pm, rootScript),
|
|
465
|
+
};
|
|
466
|
+
else if (app)
|
|
467
|
+
dev = {
|
|
468
|
+
dir: app.dir,
|
|
469
|
+
script: app.script,
|
|
470
|
+
command: runWorkspaceScript(
|
|
471
|
+
pm,
|
|
472
|
+
readJson(join(app.dir, "package.json"))?.name,
|
|
473
|
+
app.script,
|
|
474
|
+
relative(project, app.dir),
|
|
475
|
+
),
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
project,
|
|
480
|
+
repoRoot,
|
|
481
|
+
workspaces,
|
|
482
|
+
storybook,
|
|
483
|
+
otherStorybooks: storybooks.slice(1).map((sb) => sb.dir),
|
|
484
|
+
framework: framework ? { name: framework.name, dir: appDir } : null,
|
|
485
|
+
packageManager: pm,
|
|
486
|
+
dev,
|
|
487
|
+
appUrl: appUrlOf(project, appDir, framework, repoRoot),
|
|
488
|
+
playwright: playwrightOf(project, repoRoot),
|
|
489
|
+
theme: themeOf(packages),
|
|
490
|
+
nodeVersion: nodeVersionOf(project, repoRoot),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function nodeVersionOf(project, repoRoot) {
|
|
495
|
+
for (const dir of upTo(project, repoRoot)) {
|
|
496
|
+
for (const file of [".nvmrc", ".node-version"])
|
|
497
|
+
if (existsSync(join(dir, file)))
|
|
498
|
+
return { file: relative(repoRoot, join(dir, file)) || file };
|
|
499
|
+
}
|
|
500
|
+
for (const dir of upTo(project, repoRoot)) {
|
|
501
|
+
const engines = readJson(join(dir, "package.json"))?.engines?.node;
|
|
502
|
+
const major = majorOf(engines);
|
|
503
|
+
if (major) return { version: String(Math.max(major, 20)) };
|
|
504
|
+
}
|
|
505
|
+
return { version: "22" };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ── package manager commands ────────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
function runScript(pm, script) {
|
|
511
|
+
return pm.name === "npm" ? `npm run ${script}` : `${pm.name} run ${script}`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function runWorkspaceScript(pm, name, script, dir) {
|
|
515
|
+
if (!name) return `cd ${dir} && ${runScript(pm, script)}`;
|
|
516
|
+
if (pm.name === "pnpm") return `pnpm --filter ${name} run ${script}`;
|
|
517
|
+
if (pm.name === "yarn") return `yarn workspace ${name} run ${script}`;
|
|
518
|
+
if (pm.name === "bun") return `bun run --filter ${name} ${script}`;
|
|
519
|
+
return `npm run ${script} --workspace ${name}`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function execBin(pm) {
|
|
523
|
+
if (pm.name === "pnpm") return "pnpm exec";
|
|
524
|
+
if (pm.name === "yarn") return pm.berry ? "yarn exec" : "npx";
|
|
525
|
+
if (pm.name === "bun") return "bunx";
|
|
526
|
+
return "npx";
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function installCommand(pm) {
|
|
530
|
+
if (!pm.lockfile)
|
|
531
|
+
return pm.name === "npm" ? "npm install" : `${pm.name} install`;
|
|
532
|
+
if (pm.name === "pnpm") return "pnpm install --frozen-lockfile";
|
|
533
|
+
if (pm.name === "yarn")
|
|
534
|
+
return pm.berry
|
|
535
|
+
? "yarn install --immutable"
|
|
536
|
+
: "yarn install --frozen-lockfile";
|
|
537
|
+
if (pm.name === "bun") return "bun install --frozen-lockfile";
|
|
538
|
+
return "npm ci";
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ── what init writes ────────────────────────────────────────────────────────
|
|
542
|
+
|
|
543
|
+
/** The Playwright this package is tested with, pinned in what init writes. */
|
|
544
|
+
function playwrightPin() {
|
|
545
|
+
return PACKAGE.devDependencies?.["@playwright/test"] ?? "1.58.2";
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function posix(path) {
|
|
549
|
+
return path.split(sep).join("/");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** design-kit.config.json, as an object. */
|
|
553
|
+
export function buildConfig(found) {
|
|
554
|
+
const config = {
|
|
555
|
+
$schema: `https://unpkg.com/${PACKAGE.name}@${PACKAGE.version}/schema/design-kit.config.schema.json`,
|
|
556
|
+
version: 1,
|
|
557
|
+
out: "design-kit",
|
|
558
|
+
theme: {
|
|
559
|
+
attribute: found.theme.attribute,
|
|
560
|
+
dark: found.theme.dark,
|
|
561
|
+
...(found.theme.light ? { light: found.theme.light } : {}),
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
if (found.storybook) {
|
|
565
|
+
const root = posix(relative(found.project, found.storybook.dir)) || ".";
|
|
566
|
+
config.stories = {
|
|
567
|
+
root,
|
|
568
|
+
buildCommand: `${execBin(found.packageManager)} storybook build --quiet -o storybook-static`,
|
|
569
|
+
staticDir: "storybook-static",
|
|
570
|
+
...(isDir(join(found.storybook.dir, "src"))
|
|
571
|
+
? { gaps: { componentGlob: "src/**/[A-Z]*.{tsx,jsx,vue,svelte}" } }
|
|
572
|
+
: {}),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
config.pages = {
|
|
576
|
+
startCommand:
|
|
577
|
+
found.dev?.command ?? `${TODO}: the command that starts the app`,
|
|
578
|
+
waitUrl: found.appUrl.url,
|
|
579
|
+
pages: [
|
|
580
|
+
{
|
|
581
|
+
name: "home",
|
|
582
|
+
title: "Home",
|
|
583
|
+
path: "/",
|
|
584
|
+
ready: `${TODO}: a selector that shows once the page has its content, such as "main h1"`,
|
|
585
|
+
slots: {
|
|
586
|
+
main: [
|
|
587
|
+
`${TODO}: the selector of the page's main content region, such as "main"`,
|
|
588
|
+
],
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
],
|
|
592
|
+
};
|
|
593
|
+
return config;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** .github/workflows/design-kit.yml, as text. */
|
|
597
|
+
export function buildWorkflow(found) {
|
|
598
|
+
const pm = found.packageManager;
|
|
599
|
+
const rel = (path) => posix(relative(found.repoRoot, path)) || ".";
|
|
600
|
+
const projectDir = rel(found.project);
|
|
601
|
+
const at = (path) => (projectDir === "." ? path : `${projectDir}/${path}`);
|
|
602
|
+
const installDir = rel(pm.installDir);
|
|
603
|
+
const node = found.nodeVersion.file
|
|
604
|
+
? `node-version-file: ${found.nodeVersion.file}`
|
|
605
|
+
: `node-version: "${found.nodeVersion.version}"`;
|
|
606
|
+
const cache =
|
|
607
|
+
pm.lockfile && pm.name !== "bun" ? `\n cache: ${pm.name}` : "";
|
|
608
|
+
const cachePath =
|
|
609
|
+
cache && installDir !== "."
|
|
610
|
+
? `\n cache-dependency-path: ${installDir}/${pm.lockfile}`
|
|
611
|
+
: "";
|
|
612
|
+
|
|
613
|
+
const setup = [];
|
|
614
|
+
if (pm.name === "pnpm")
|
|
615
|
+
setup.push(
|
|
616
|
+
pm.declared || !pm.version
|
|
617
|
+
? " - uses: pnpm/action-setup@v6"
|
|
618
|
+
: ` - uses: pnpm/action-setup@v6\n with:\n version: ${pm.version}`,
|
|
619
|
+
);
|
|
620
|
+
if (pm.name === "yarn" && pm.berry)
|
|
621
|
+
setup.push(" - run: corepack enable");
|
|
622
|
+
if (pm.name === "bun") setup.push(" - uses: oven-sh/setup-bun@v2");
|
|
623
|
+
setup.push(
|
|
624
|
+
` - uses: actions/setup-node@v5\n with:\n ${node}${cache}${cachePath}`,
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
const storiesStep = found.storybook
|
|
628
|
+
? `
|
|
629
|
+
- name: Build Storybook and capture stories
|
|
630
|
+
working-directory: \${{ runner.temp }}/design-kit-cli
|
|
631
|
+
run: design-kit capture stories --config "$CONFIG"
|
|
632
|
+
`
|
|
633
|
+
: "";
|
|
634
|
+
|
|
635
|
+
return `name: Design kit
|
|
636
|
+
|
|
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.
|
|
642
|
+
#
|
|
643
|
+
# Written by \`design-kit init\` (${PACKAGE.name}). Edit it freely.
|
|
644
|
+
|
|
645
|
+
on:
|
|
646
|
+
schedule:
|
|
647
|
+
- cron: "23 5 * * *"
|
|
648
|
+
workflow_dispatch:
|
|
649
|
+
inputs:
|
|
650
|
+
force:
|
|
651
|
+
description: Build even when the newest kit is from this commit
|
|
652
|
+
type: boolean
|
|
653
|
+
default: false
|
|
654
|
+
package:
|
|
655
|
+
description: "The ${PACKAGE.name} to install, as an npm package spec (default: the pinned version below)"
|
|
656
|
+
type: string
|
|
657
|
+
default: ""
|
|
658
|
+
|
|
659
|
+
permissions:
|
|
660
|
+
contents: read
|
|
661
|
+
|
|
662
|
+
concurrency:
|
|
663
|
+
group: design-kit-\${{ github.ref }}
|
|
664
|
+
cancel-in-progress: false
|
|
665
|
+
|
|
666
|
+
env:
|
|
667
|
+
DESIGN_KIT_PACKAGE: "${PACKAGE.name}@${PACKAGE.version}"
|
|
668
|
+
PLAYWRIGHT_PACKAGE: "@playwright/test@${playwrightPin()}"
|
|
669
|
+
CONFIG: \${{ github.workspace }}/${at(CONFIG_NAME)}
|
|
670
|
+
|
|
671
|
+
jobs:
|
|
672
|
+
check:
|
|
673
|
+
name: Any commits since the newest kit?
|
|
674
|
+
runs-on: ubuntu-latest
|
|
675
|
+
timeout-minutes: 5
|
|
676
|
+
permissions:
|
|
677
|
+
actions: read
|
|
678
|
+
outputs:
|
|
679
|
+
build: \${{ steps.last.outputs.build }}
|
|
680
|
+
steps:
|
|
681
|
+
- id: last
|
|
682
|
+
env:
|
|
683
|
+
GH_TOKEN: \${{ github.token }}
|
|
684
|
+
FORCE: \${{ inputs.force || 'false' }}
|
|
685
|
+
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
|
|
694
|
+
|
|
695
|
+
build:
|
|
696
|
+
name: Build the design kit
|
|
697
|
+
needs: check
|
|
698
|
+
if: needs.check.outputs.build == 'true'
|
|
699
|
+
runs-on: ubuntu-latest
|
|
700
|
+
timeout-minutes: 60
|
|
701
|
+
steps:
|
|
702
|
+
- uses: actions/checkout@v5
|
|
703
|
+
with:
|
|
704
|
+
persist-credentials: false
|
|
705
|
+
|
|
706
|
+
${setup.join("\n\n")}
|
|
707
|
+
|
|
708
|
+
- name: Install dependencies${installDir === "." ? "" : `\n working-directory: ${installDir}`}
|
|
709
|
+
run: ${installCommand(pm)}
|
|
710
|
+
|
|
711
|
+
# The capture runs from this folder so it drives the pinned Playwright
|
|
712
|
+
# and the Chromium installed for it, whatever the project depends on.
|
|
713
|
+
- name: Install design-kit and Chromium
|
|
714
|
+
working-directory: \${{ runner.temp }}
|
|
715
|
+
env:
|
|
716
|
+
PACKAGE: \${{ inputs.package || env.DESIGN_KIT_PACKAGE }}
|
|
717
|
+
run: |
|
|
718
|
+
mkdir -p design-kit-cli && cd design-kit-cli
|
|
719
|
+
npm init -y > /dev/null
|
|
720
|
+
npm install --no-audit --no-fund "$PACKAGE" "$PLAYWRIGHT_PACKAGE"
|
|
721
|
+
npx playwright install --with-deps chromium
|
|
722
|
+
echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH"
|
|
723
|
+
${storiesStep}
|
|
724
|
+
# Signing in: when the pages need a signed-in user, name a sign-in
|
|
725
|
+
# module in pages.auth.setup and have it read these secrets (add them
|
|
726
|
+
# under Settings > Secrets and variables > Actions). Never commit a
|
|
727
|
+
# credential. Until the TODO page settings are filled in, pages are
|
|
728
|
+
# skipped with a warning.
|
|
729
|
+
- name: Capture pages
|
|
730
|
+
working-directory: \${{ runner.temp }}/design-kit-cli
|
|
731
|
+
env:
|
|
732
|
+
DESIGN_KIT_USERNAME: \${{ secrets.DESIGN_KIT_USERNAME }}
|
|
733
|
+
DESIGN_KIT_PASSWORD: \${{ secrets.DESIGN_KIT_PASSWORD }}
|
|
734
|
+
run: |
|
|
735
|
+
if grep -q '"${TODO}' "$CONFIG"; then
|
|
736
|
+
echo "::warning title=Design kit::${at(CONFIG_NAME)} still has ${TODO} page settings, so no pages were captured. Fill them in, or remove the pages section."
|
|
737
|
+
else
|
|
738
|
+
design-kit capture pages --config "$CONFIG"
|
|
739
|
+
fi
|
|
740
|
+
|
|
741
|
+
# Nothing captured yet (no stories, and the pages still have ${TODO}s):
|
|
742
|
+
# the run ends here with a warning instead of failing to upload.
|
|
743
|
+
- name: Nothing to capture yet
|
|
744
|
+
if: hashFiles('${at("design-kit")}/manifest.json') == ''
|
|
745
|
+
run: |
|
|
746
|
+
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
|
+
|
|
748
|
+
- if: hashFiles('${at("design-kit")}/manifest.json') != ''
|
|
749
|
+
uses: actions/upload-artifact@v5
|
|
750
|
+
with:
|
|
751
|
+
name: design-kit
|
|
752
|
+
path: ${at("design-kit")}
|
|
753
|
+
retention-days: 30
|
|
754
|
+
if-no-files-found: error
|
|
755
|
+
`;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** .claude/skills/design-kit/SKILL.md, as text. */
|
|
759
|
+
export function buildSkill(found) {
|
|
760
|
+
const pm = found.packageManager;
|
|
761
|
+
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
|
+
const cli = `npx --yes -p ${PACKAGE.name}@${PACKAGE.version} -p @playwright/test@${playwrightPin()} design-kit`;
|
|
770
|
+
const values = {
|
|
771
|
+
CLI: cli,
|
|
772
|
+
CONFIG: projectDir === "." ? CONFIG_NAME : `${projectDir}/${CONFIG_NAME}`,
|
|
773
|
+
PROJECT_DIR: projectDir,
|
|
774
|
+
WATCHED_PATHS: watchedPaths,
|
|
775
|
+
STORIES_LINE: found.storybook
|
|
776
|
+
? `$DK capture stories --config "$CONFIG" --out "$KIT" # builds Storybook first`
|
|
777
|
+
: `# (no Storybook: this kit has pages only)`,
|
|
778
|
+
PACKAGE: PACKAGE.name,
|
|
779
|
+
INSTALL: installCommand(pm),
|
|
780
|
+
// The capture drives the project's own Playwright when it has one.
|
|
781
|
+
PLAYWRIGHT_INSTALL: found.playwright
|
|
782
|
+
? `${execBin(pm)} playwright install chromium`
|
|
783
|
+
: `npx --yes -p @playwright/test@${playwrightPin()} playwright install chromium`,
|
|
784
|
+
};
|
|
785
|
+
const template = readFileSync(SKILL_TEMPLATE, "utf8");
|
|
786
|
+
return template.replace(/\{\{([A-Z_]+)\}\}/g, (match, key) =>
|
|
787
|
+
key in values ? values[key] : match,
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const AGENTS_LINE =
|
|
792
|
+
"- **Mockups and UI designs:** use the `design-kit` skill (`.claude/skills/design-kit/SKILL.md`). It composes mockups from this app's real pages and components, which `.github/workflows/design-kit.yml` captures every day.";
|
|
793
|
+
|
|
794
|
+
function agentsSkillMode(repoRoot) {
|
|
795
|
+
const dir = join(repoRoot, ".agents", "skills");
|
|
796
|
+
const entries = safeReaddir(dir);
|
|
797
|
+
if (entries.some((entry) => entry.isSymbolicLink())) return "symlink";
|
|
798
|
+
if (entries.some((entry) => entry.isDirectory())) return "copy";
|
|
799
|
+
return "symlink";
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function instructionsFile(repoRoot) {
|
|
803
|
+
for (const name of ["AGENTS.md", "CLAUDE.md"])
|
|
804
|
+
if (existsSync(join(repoRoot, name))) return name;
|
|
805
|
+
return "AGENTS.md";
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* The plan: every file init writes, with its content, and the notes for the
|
|
810
|
+
* summary. Nothing is written here.
|
|
811
|
+
*/
|
|
812
|
+
export function planInit(found) {
|
|
813
|
+
const at = (path) => join(found.repoRoot, path);
|
|
814
|
+
const skill = buildSkill(found);
|
|
815
|
+
const instructions = instructionsFile(found.repoRoot);
|
|
816
|
+
const instructionsText = readText(at(instructions));
|
|
817
|
+
const writes = [
|
|
818
|
+
{
|
|
819
|
+
path: join(found.project, CONFIG_NAME),
|
|
820
|
+
kind: "file",
|
|
821
|
+
content: `${JSON.stringify(buildConfig(found), null, 2)}\n`,
|
|
822
|
+
},
|
|
823
|
+
{ path: at(WORKFLOW_PATH), kind: "file", content: buildWorkflow(found) },
|
|
824
|
+
{ path: at(`${SKILL_DIR}/SKILL.md`), kind: "file", content: skill },
|
|
825
|
+
agentsSkillMode(found.repoRoot) === "symlink"
|
|
826
|
+
? {
|
|
827
|
+
path: at(AGENTS_SKILL_DIR),
|
|
828
|
+
kind: "symlink",
|
|
829
|
+
target: posix(relative(dirname(at(AGENTS_SKILL_DIR)), at(SKILL_DIR))),
|
|
830
|
+
}
|
|
831
|
+
: { path: at(AGENTS_SKILL_DIR), kind: "copy", from: at(SKILL_DIR) },
|
|
832
|
+
];
|
|
833
|
+
if (instructionsText?.includes("skills/design-kit"))
|
|
834
|
+
writes.push({ path: at(instructions), kind: "unchanged" });
|
|
835
|
+
else
|
|
836
|
+
writes.push({
|
|
837
|
+
path: at(instructions),
|
|
838
|
+
kind: instructionsText === null ? "file" : "append",
|
|
839
|
+
content:
|
|
840
|
+
instructionsText === null
|
|
841
|
+
? `# Agent instructions\n\n${AGENTS_LINE}\n`
|
|
842
|
+
: `${instructionsText.endsWith("\n") ? "" : "\n"}\n${AGENTS_LINE}\n`,
|
|
843
|
+
});
|
|
844
|
+
return { writes };
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function exists(path) {
|
|
848
|
+
try {
|
|
849
|
+
lstatSync(path);
|
|
850
|
+
return true;
|
|
851
|
+
} catch {
|
|
852
|
+
return false;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function apply(write) {
|
|
857
|
+
mkdirSync(dirname(write.path), { recursive: true });
|
|
858
|
+
if (write.kind === "file") writeFileSync(write.path, write.content);
|
|
859
|
+
else if (write.kind === "append")
|
|
860
|
+
writeFileSync(write.path, write.content, { flag: "a" });
|
|
861
|
+
else if (write.kind === "symlink") {
|
|
862
|
+
rmSync(write.path, { recursive: true, force: true });
|
|
863
|
+
symlinkSync(write.target, write.path, "dir");
|
|
864
|
+
} else if (write.kind === "copy") {
|
|
865
|
+
rmSync(write.path, { recursive: true, force: true });
|
|
866
|
+
cpSync(write.from, write.path, { recursive: true });
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// ── the summary ─────────────────────────────────────────────────────────────
|
|
871
|
+
|
|
872
|
+
function describe(found) {
|
|
873
|
+
const rel = (path) => posix(relative(found.repoRoot, path)) || ".";
|
|
874
|
+
const pm = found.packageManager;
|
|
875
|
+
const sb = found.storybook;
|
|
876
|
+
return [
|
|
877
|
+
[
|
|
878
|
+
"Storybook",
|
|
879
|
+
sb
|
|
880
|
+
? `${sb.version ?? "version unknown"} in ${rel(sb.dir)}${sb.hasConfig ? " (.storybook)" : " (no .storybook folder)"}${found.otherStorybooks.length ? `; also in ${found.otherStorybooks.map(rel).join(", ")}` : ""}`
|
|
881
|
+
: "none",
|
|
882
|
+
],
|
|
883
|
+
[
|
|
884
|
+
"Framework",
|
|
885
|
+
found.framework
|
|
886
|
+
? `${found.framework.name} in ${rel(found.framework.dir)}`
|
|
887
|
+
: "not recognized (other)",
|
|
888
|
+
],
|
|
889
|
+
[
|
|
890
|
+
"Packages",
|
|
891
|
+
`${pm.name}${pm.version ? ` ${pm.version}` : ""}${pm.lockfile ? ` (${rel(join(pm.installDir, pm.lockfile))})` : " (no lockfile)"}`,
|
|
892
|
+
],
|
|
893
|
+
[
|
|
894
|
+
"Dev script",
|
|
895
|
+
found.dev
|
|
896
|
+
? `${found.dev.command} -> ${found.appUrl.url} (from ${found.appUrl.source})`
|
|
897
|
+
: "none found",
|
|
898
|
+
],
|
|
899
|
+
[
|
|
900
|
+
"Playwright",
|
|
901
|
+
found.playwright
|
|
902
|
+
? `${found.playwright.name}${found.playwright.version ? ` ${found.playwright.version}` : ""}`
|
|
903
|
+
: "none (the workflow installs its own)",
|
|
904
|
+
],
|
|
905
|
+
[
|
|
906
|
+
"Theme",
|
|
907
|
+
found.theme.evidence
|
|
908
|
+
? `${found.theme.attribute === "class" ? `class "${found.theme.dark}"` : `${found.theme.attribute}="${found.theme.dark}"`} (from ${found.theme.evidence})`
|
|
909
|
+
: `class "dark" (a guess: no dark theme found)`,
|
|
910
|
+
],
|
|
911
|
+
];
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function todo(found) {
|
|
915
|
+
const items = [];
|
|
916
|
+
const sb = found.storybook;
|
|
917
|
+
if (!sb)
|
|
918
|
+
items.push(
|
|
919
|
+
"No Storybook: the config captures pages only. Decide whether to add Storybook with stories for the components the main screens use, capture real pages, or both.",
|
|
920
|
+
);
|
|
921
|
+
else if (sb.major && (sb.major < 7 || sb.major > 8))
|
|
922
|
+
items.push(
|
|
923
|
+
`Storybook ${sb.version}: captures are supported for Storybook 7 and 8 static builds; check that \`capture stories\` works, or use page capture.`,
|
|
924
|
+
);
|
|
925
|
+
items.push(
|
|
926
|
+
`Pages: replace the ${TODO} selectors in ${CONFIG_NAME} (pages[].ready and slots.main), add the app's main screens, and check startCommand and waitUrl. Until then the workflow skips page capture.`,
|
|
927
|
+
);
|
|
928
|
+
if (!found.dev)
|
|
929
|
+
items.push(
|
|
930
|
+
"No dev or start script found: set pages.startCommand to the command that serves the app.",
|
|
931
|
+
);
|
|
932
|
+
items.push(
|
|
933
|
+
"Sign-in: if the pages need a signed-in user, use a seeded test user or the app's mock mode, and a sign-in module (pages.auth.setup) that reads repository secrets in CI. Never commit a credential.",
|
|
934
|
+
);
|
|
935
|
+
if (!found.theme.evidence)
|
|
936
|
+
items.push(
|
|
937
|
+
"Theme: no dark theme was found; check the theme section, or remove it if the app has no dark mode.",
|
|
938
|
+
);
|
|
939
|
+
items.push(
|
|
940
|
+
"Build the kit once locally (see the design-kit skill) and say in the pull request what it captured and what it missed.",
|
|
941
|
+
);
|
|
942
|
+
return items;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function summary(found, plan, { dryRun }) {
|
|
946
|
+
const rel = (path) => posix(relative(found.repoRoot, path)) || ".";
|
|
947
|
+
const lines = [
|
|
948
|
+
`design-kit init${dryRun ? " --dry-run" : ""}: ${found.project}`,
|
|
949
|
+
"",
|
|
950
|
+
"Found",
|
|
951
|
+
];
|
|
952
|
+
const rows = describe(found);
|
|
953
|
+
const width = Math.max(...rows.map(([label]) => label.length));
|
|
954
|
+
for (const [label, value] of rows)
|
|
955
|
+
lines.push(` ${label.padEnd(width)} ${value}`);
|
|
956
|
+
lines.push("", dryRun ? "Would write" : "Wrote");
|
|
957
|
+
for (const write of plan.writes) {
|
|
958
|
+
const note =
|
|
959
|
+
write.kind === "symlink"
|
|
960
|
+
? ` -> ${write.target}`
|
|
961
|
+
: write.kind === "copy"
|
|
962
|
+
? ` (a copy of ${rel(write.from)})`
|
|
963
|
+
: write.kind === "append"
|
|
964
|
+
? " (one line added)"
|
|
965
|
+
: write.kind === "unchanged"
|
|
966
|
+
? " (unchanged: it already points at the skill)"
|
|
967
|
+
: "";
|
|
968
|
+
lines.push(` ${rel(write.path)}${note}`);
|
|
969
|
+
}
|
|
970
|
+
lines.push("", "Still to decide");
|
|
971
|
+
for (const item of todo(found)) lines.push(` - ${item}`);
|
|
972
|
+
return lines.join("\n");
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ── the command ─────────────────────────────────────────────────────────────
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* 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.
|
|
981
|
+
*/
|
|
982
|
+
export function init({
|
|
983
|
+
root = ".",
|
|
984
|
+
dryRun = false,
|
|
985
|
+
force = false,
|
|
986
|
+
log = console.log,
|
|
987
|
+
} = {}) {
|
|
988
|
+
const found = detectProject(root);
|
|
989
|
+
const plan = planInit(found);
|
|
990
|
+
if (!force) {
|
|
991
|
+
const config = join(found.project, CONFIG_NAME);
|
|
992
|
+
if (exists(config))
|
|
993
|
+
throw new InitError(
|
|
994
|
+
`already set up: ${config} exists. Nothing was written; pass --force to replace the files init writes.`,
|
|
995
|
+
);
|
|
996
|
+
const inTheWay = plan.writes
|
|
997
|
+
.filter(
|
|
998
|
+
(write) =>
|
|
999
|
+
write.kind === "file" ||
|
|
1000
|
+
write.kind === "symlink" ||
|
|
1001
|
+
write.kind === "copy",
|
|
1002
|
+
)
|
|
1003
|
+
.filter((write) => exists(write.path))
|
|
1004
|
+
.map((write) => posix(relative(found.repoRoot, write.path)));
|
|
1005
|
+
if (inTheWay.length)
|
|
1006
|
+
throw new InitError(
|
|
1007
|
+
`${inTheWay.join(", ")} already exist${inTheWay.length === 1 ? "s" : ""}. Nothing was written; pass --force to replace ${inTheWay.length === 1 ? "it" : "them"}.`,
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
if (!dryRun) {
|
|
1011
|
+
// The skill folder is written before the copy or link that points at it.
|
|
1012
|
+
for (const write of plan.writes)
|
|
1013
|
+
if (write.kind !== "unchanged") apply(write);
|
|
1014
|
+
}
|
|
1015
|
+
log(summary(found, plan, { dryRun }));
|
|
1016
|
+
return 0;
|
|
1017
|
+
}
|