@navipartner/demo-video 0.4.0 → 0.5.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/bin/init.mjs CHANGED
@@ -1,447 +1,521 @@
1
- import { spawnSync } from "node:child_process";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import { fileURLToPath } from "node:url";
5
-
6
- // `demo-video init` sets a pnpm project up for demo videos: .gitignore,
7
- // package.json, the skill, the label, the demo-video.yml caller (only when
8
- // missing) and a demo.config.ts skeleton. It never edits an existing
9
- // workflow. Safe to re-run; with dryRun it reports what it would change.
10
-
11
- const PKG = "@navipartner/demo-video";
12
- export const IGNORE_LINE = "**/demo/out/";
13
- export const LABEL = "demo video";
14
-
15
- const engineDir = path.resolve(
16
- path.dirname(fileURLToPath(import.meta.url)),
17
- "..",
18
- );
19
- const engineVersion = () =>
20
- JSON.parse(fs.readFileSync(path.join(engineDir, "package.json"), "utf8"))
21
- .version;
22
-
23
- export const CALLER_WORKFLOW = `name: Demo video
24
- # The "${LABEL}" label films the stories a PR adds or changes. A PR comment
25
- # "/demo-video [<story>] [in <brand> branding]", or Actions → Demo video →
26
- # Run workflow, films an existing story with a brand.
27
- on:
28
- pull_request:
29
- types: [labeled, synchronize]
30
- issue_comment:
31
- types: [created]
32
- # Actions → Demo video → Run workflow: film a story with a brand.
33
- workflow_dispatch:
34
- inputs:
35
- app:
36
- description: "App folder, e.g. apps/retail-self-service (empty: the only app)"
37
- default: ""
38
- story:
39
- description: "Story name (empty: the app's primary story)"
40
- default: ""
41
- brand:
42
- description: "Brand (empty: the app's default)"
43
- default: ""
44
- permissions: {}
45
- jobs:
46
- render:
47
- if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, '${LABEL}')
48
- permissions:
49
- contents: read
50
- pull-requests: write
51
- uses: navipartner/demo-video/.github/workflows/render.yml@v0
52
- request:
53
- if: github.event_name == 'workflow_dispatch' || (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/demo-video'))
54
- permissions:
55
- contents: read
56
- pull-requests: write
57
- issues: write
58
- uses: navipartner/demo-video/.github/workflows/request.yml@v0
59
- with:
60
- app: \${{ inputs.app }}
61
- story: \${{ inputs.story }}
62
- brand: \${{ inputs.brand }}
63
- `;
64
-
65
- const read = (file) =>
66
- fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
67
- const eolOf = (text) => (text?.includes("\r\n") ? "\r\n" : "\n");
68
-
69
- const appendLines = (file, lines, dryRun) => {
70
- const old = read(file) ?? "";
71
- const eol = eolOf(old);
72
- const sep = old && !old.endsWith("\n") ? eol : "";
73
- if (!dryRun) {
74
- fs.writeFileSync(file, old + sep + lines.join(eol) + eol);
75
- }
76
- };
77
-
78
- /** Edits a JSON file in place, keeping its indent and line endings. */
79
- const editJson = (file, edit, dryRun) => {
80
- const text = fs.readFileSync(file, "utf8");
81
- const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? " ";
82
- const json = JSON.parse(text);
83
- edit(json);
84
- const eol = eolOf(text);
85
- if (!dryRun) {
86
- fs.writeFileSync(
87
- file,
88
- JSON.stringify(json, null, indent).replace(/\n/g, eol) + eol,
89
- );
90
- }
91
- };
92
-
93
- /** Adds a key in alphabetical place when the object is sorted, else last. */
94
- const withKey = (object, key, value) => {
95
- const keys = Object.keys(object);
96
- const sorted = keys.every((k, i) => i === 0 || keys[i - 1] <= k);
97
- const entries = Object.entries(object);
98
- const at = sorted ? entries.findIndex(([k]) => k > key) : -1;
99
- entries.splice(at === -1 ? entries.length : at, 0, [key, value]);
100
- return Object.fromEntries(entries);
101
- };
102
-
103
- const sameTree = (a, b) => {
104
- if (!fs.existsSync(b)) {
105
- return false;
106
- }
107
- return fs.readdirSync(a, { withFileTypes: true }).every((entry) => {
108
- const [x, y] = [path.join(a, entry.name), path.join(b, entry.name)];
109
- return entry.isDirectory()
110
- ? sameTree(x, y)
111
- : fs.existsSync(y) && fs.readFileSync(x).equals(fs.readFileSync(y));
112
- });
113
- };
114
-
115
- const gitIgnores = (root, file) =>
116
- spawnSync("git", ["check-ignore", "-q", file], { cwd: root }).status === 0;
117
-
118
- const ensureLabel = (root, dryRun) => {
119
- const list = spawnSync(
120
- "gh",
121
- // All labels, not --search: the search index can lag and miss one.
122
- ["label", "list", "--limit", "1000", "--json", "name", "--jq", ".[].name"],
123
- { cwd: root, encoding: "utf8" },
124
- );
125
- if (list.error || list.status !== 0) {
126
- return {
127
- todo: `Create the label: gh label create "${LABEL}" --color 1F5494`,
128
- };
129
- }
130
- // GitHub label names are case-insensitive.
131
- const names = list.stdout.split("\n").map((name) => name.toLowerCase());
132
- if (names.includes(LABEL)) {
133
- return { change: ["ok", "label", LABEL] };
134
- }
135
- if (!dryRun) {
136
- const created = spawnSync(
137
- "gh",
138
- ["label", "create", LABEL, "--color", "1F5494"],
139
- { cwd: root, encoding: "utf8" },
140
- );
141
- if (created.error || created.status !== 0) {
142
- return {
143
- todo: `Create the label (gh could not: ${(created.stderr ?? "").trim() || "no access?"}): gh label create "${LABEL}" --color 1F5494`,
144
- };
145
- }
146
- }
147
- return { change: ["added", "label", LABEL] };
148
- };
149
-
150
- /**
151
- * Once per repo, from its root. `label` (default true) checks or creates the
152
- * GitHub label with gh.
153
- */
154
- export const initRepo = (root, { dryRun = false, label = true } = {}) => {
155
- const changes = [];
156
- const todo = [];
157
-
158
- const gitignore = path.join(root, ".gitignore");
159
- const ignored = read(gitignore);
160
- // Only **/ reaches apps/<x>/demo/out/: a bare demo/out/ is anchored to
161
- // the repo root.
162
- if (/^\*\*\/demo\/out\/?$/m.test(ignored ?? "")) {
163
- changes.push(["ok", ".gitignore", IGNORE_LINE]);
164
- } else {
165
- const comment = "# Demo video renders (@navipartner/demo-video)";
166
- appendLines(
167
- gitignore,
168
- ignored ? ["", comment, IGNORE_LINE] : [comment, IGNORE_LINE],
169
- dryRun,
170
- );
171
- changes.push(["added", ".gitignore", IGNORE_LINE]);
172
- }
173
-
174
- // pnpm 10 runs no install scripts unless allowed; ffmpeg-static needs its.
175
- const pkgFile = path.join(root, "package.json");
176
- const pkg = fs.existsSync(pkgFile)
177
- ? JSON.parse(fs.readFileSync(pkgFile, "utf8"))
178
- : null;
179
- const pnpmMajor = Number(/^pnpm@(\d+)/.exec(pkg?.packageManager ?? "")?.[1]);
180
- const workspaceYaml = read(path.join(root, "pnpm-workspace.yaml")) ?? "";
181
- if (pkg && pnpmMajor >= 10) {
182
- if (/^onlyBuiltDependencies:/m.test(workspaceYaml)) {
183
- if (!/ffmpeg-static/.test(workspaceYaml)) {
184
- todo.push(
185
- "Add ffmpeg-static to onlyBuiltDependencies in pnpm-workspace.yaml.",
186
- );
187
- }
188
- } else if (pkg.pnpm?.onlyBuiltDependencies?.includes("ffmpeg-static")) {
189
- changes.push(["ok", "package.json", "pnpm.onlyBuiltDependencies"]);
190
- } else {
191
- editJson(
192
- pkgFile,
193
- (json) => {
194
- json.pnpm ??= {};
195
- json.pnpm.onlyBuiltDependencies = [
196
- ...(json.pnpm.onlyBuiltDependencies ?? []),
197
- "ffmpeg-static",
198
- ];
199
- },
200
- dryRun,
201
- );
202
- changes.push(["added", "package.json", "pnpm.onlyBuiltDependencies"]);
203
- }
204
- } else if (pkg && !pnpmMajor) {
205
- todo.push(
206
- 'No "packageManager" in package.json: with pnpm 10 or later, allow ffmpeg-static in onlyBuiltDependencies.',
207
- );
208
- }
209
-
210
- // Claude Code loads .claude/skills/<name>/SKILL.md.
211
- const skillSrc = path.join(engineDir, "skills", "np-demo-video");
212
- const skillDest = path.join(root, ".claude", "skills", "np-demo-video");
213
- const skillRel = ".claude/skills/np-demo-video/";
214
- if (sameTree(skillSrc, skillDest)) {
215
- changes.push(["ok", skillRel, "the np-demo-video skill"]);
216
- } else {
217
- const existed = fs.existsSync(skillDest);
218
- if (!dryRun) {
219
- fs.cpSync(skillSrc, skillDest, { recursive: true, force: true });
220
- }
221
- changes.push([
222
- existed ? "updated" : "added",
223
- skillRel,
224
- "the np-demo-video skill",
225
- ]);
226
- }
227
- const stray = read(path.join(root, ".claude", "skills", "SKILL.md"));
228
- if (stray?.includes("name: np-demo-video")) {
229
- todo.push(
230
- "Delete .claude/skills/SKILL.md and look.md: a copy of the skill in the wrong folder.",
231
- );
232
- }
233
- if (gitIgnores(root, skillRel)) {
234
- todo.push(
235
- "Git ignores .claude/ here, so the skill stays on this machine: each developer runs init, or add !.claude/skills/ to .gitignore to share it.",
236
- );
237
- }
238
-
239
- if (label) {
240
- const result = ensureLabel(root, dryRun);
241
- if (result.change) {
242
- changes.push(result.change);
243
- } else {
244
- todo.push(result.todo);
245
- }
246
- }
247
-
248
- // The caller workflow is fixed, so it is written when missing. Existing
249
- // workflows are never touched.
250
- const callerFile = path.join(root, ".github", "workflows", "demo-video.yml");
251
- if (
252
- ["demo-video.yml", "demo-video.yaml"].some((f) =>
253
- fs.existsSync(path.join(root, ".github", "workflows", f)),
254
- )
255
- ) {
256
- changes.push(["ok", ".github/workflows/demo-video.yml", "exists"]);
257
- // Written by an older init: it has the label job only.
258
- const existing = ["demo-video.yml", "demo-video.yaml"]
259
- .map((f) => read(path.join(root, ".github", "workflows", f)))
260
- .find(Boolean);
261
- if (existing && !existing.includes("request.yml@")) {
262
- todo.push(
263
- '.github/workflows/demo-video.yml predates the "request" job (/demo-video comments, the Run workflow form): delete it and re-run init to get the current one.',
264
- );
265
- }
266
- } else {
267
- if (!dryRun) {
268
- fs.mkdirSync(path.dirname(callerFile), { recursive: true });
269
- fs.writeFileSync(callerFile, CALLER_WORKFLOW);
270
- }
271
- changes.push([
272
- "added",
273
- ".github/workflows/demo-video.yml",
274
- "render.yml@v0",
275
- ]);
276
- }
277
-
278
- return { changes, todo };
279
- };
280
-
281
- /** The webServer block of a Playwright config, if one can be read. */
282
- const webServerOf = (configText) => {
283
- const start = configText.indexOf("webServer");
284
- const open = configText.indexOf("{", start);
285
- if (start === -1 || open === -1) {
286
- return {};
287
- }
288
- let depth = 0;
289
- let end = open;
290
- for (; end < configText.length; end++) {
291
- depth += configText[end] === "{" ? 1 : configText[end] === "}" ? -1 : 0;
292
- if (depth === 0) {
293
- break;
294
- }
295
- }
296
- const block = configText.slice(open, end + 1);
297
- const str = (key) =>
298
- new RegExp(`\\b${key}:\\s*["'\`]([^"'\`]+)["'\`]`).exec(block)?.[1];
299
- const envBlock = /\benv:\s*\{([^}]*)\}/.exec(block)?.[1] ?? "";
300
- const env = Object.fromEntries(
301
- [...envBlock.matchAll(/(\w+):\s*["'`]([^"'`]*)["'`]/g)].map((m) => [
302
- m[1],
303
- m[2],
304
- ]),
305
- );
306
- return { command: str("command"), url: str("url"), env };
307
- };
308
-
309
- /**
310
- * e2e helpers that look like the app's mocks: in a file named *mock*, its
311
- * exported class or function (`class ApiMocks`, `setupApiMocks`), not data.
312
- */
313
- const findMocks = (appDir) => {
314
- const found = [];
315
- const walk = (dir, depth) => {
316
- if (depth > 3 || !fs.existsSync(dir)) {
317
- return;
318
- }
319
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
320
- const file = path.join(dir, entry.name);
321
- if (entry.isDirectory() && entry.name !== "node_modules") {
322
- walk(file, depth + 1);
323
- } else if (/mock/i.test(entry.name) && /\.[cm]?[jt]s$/.test(entry.name)) {
324
- const name = /^export\s+(?:class|(?:async\s+)?function)\s+(\w+)/m.exec(
325
- read(file),
326
- )?.[1];
327
- if (name) {
328
- found.push({ name, file });
329
- }
330
- }
331
- }
332
- };
333
- walk(path.join(appDir, "e2e"), 0);
334
- return found;
335
- };
336
-
337
- export const adapterSkeleton = ({ app, command, url, env, mocks, demoDir }) => {
338
- const envLine = Object.keys(env).length
339
- ? `\n env: { ${Object.entries(env)
340
- .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
341
- .join(", ")} },`
342
- : "";
343
- const mockHint = mocks.length
344
- ? mocks
345
- .map(({ name, file }) => {
346
- const rel = path
347
- .relative(demoDir, file)
348
- .replace(/\\/g, "/")
349
- .replace(/\.[cm]?[jt]s$/, "");
350
- return `\n // import { ${name} } from "${rel.startsWith(".") ? rel : `./${rel}`}";`;
351
- })
352
- .join("")
353
- : "";
354
- return `import { defineDemo } from "@navipartner/demo-video";
355
-
356
- // Written by \`demo-video init\`. Finish it with Claude (the np-demo-video
357
- // skill) from the app's e2e tests: their mocks, unchanged, never new ones.
358
-
359
- export default defineDemo({
360
- app: ${JSON.stringify(app)},${command && url ? "" : "\n // TODO: the dev server the e2e tests use (playwright.config's webServer)."}
361
- webServer: {
362
- command: ${JSON.stringify(command ?? "pnpm dev")},
363
- url: ${JSON.stringify(url ?? "http://localhost:5173")},${envLine}
364
- },
365
- // TODO: the path the e2e tests open, e.g. "/?tenant=test".
366
- appPath: "/",
367
- // TODO: a testid that is visible once the mocks have booted the app.
368
- ready: "TODO",
369
-
370
- async setup() {
371
- // TODO: install the e2e mocks unchanged, then lay the brand over the
372
- // response that carries the app's branding (overlayResponse).${mocks.length ? `\n // Mocks found:${mockHint}` : ""}
373
- },
374
- });
375
- `;
376
- };
377
-
378
- /** Once per app: the dependency and a demo.config.ts skeleton. */
379
- export const initApp = (appDir, { dryRun = false } = {}) => {
380
- const changes = [];
381
- const todo = [];
382
- const pkgFile = path.join(appDir, "package.json");
383
- if (!fs.existsSync(pkgFile)) {
384
- throw new Error(`No package.json in ${appDir}`);
385
- }
386
- const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
387
- const version = engineVersion();
388
-
389
- if (pkg.devDependencies?.[PKG] || pkg.dependencies?.[PKG]) {
390
- changes.push(["ok", "package.json", PKG]);
391
- } else {
392
- // A prerelease is pinned exactly. A release gets a caret range, which on
393
- // 0.x takes patch updates only (^0.2.0 is <0.3.0).
394
- const range = version.includes("-") ? version : `^${version}`;
395
- editJson(
396
- pkgFile,
397
- (json) => {
398
- json.devDependencies = withKey(json.devDependencies ?? {}, PKG, range);
399
- },
400
- dryRun,
401
- );
402
- changes.push(["added", "package.json", `devDependencies ${PKG} ${range}`]);
403
- todo.push("Run pnpm install to update the lockfile.");
404
- }
405
- if (
406
- !pkg.devDependencies?.["@playwright/test"] &&
407
- !pkg.dependencies?.["@playwright/test"]
408
- ) {
409
- todo.push(
410
- "The app needs @playwright/test (1.59 or later): the demo runs on its Playwright.",
411
- );
412
- }
413
-
414
- const demoDir = path.join(appDir, "demo");
415
- const adapter = path.join(demoDir, "demo.config.ts");
416
- if (fs.existsSync(adapter)) {
417
- changes.push(["ok", "demo/demo.config.ts", "exists"]);
418
- } else {
419
- const configFile = ["ts", "mts", "js", "mjs"]
420
- .map((ext) => path.join(appDir, `playwright.config.${ext}`))
421
- .find((f) => fs.existsSync(f));
422
- const webServer = configFile ? webServerOf(read(configFile)) : {};
423
- const skeleton = adapterSkeleton({
424
- app: (pkg.name ?? path.basename(appDir)).replace(/^@[^/]+\//, ""),
425
- command: webServer.command,
426
- url: webServer.url,
427
- env: webServer.env ?? {},
428
- mocks: findMocks(appDir),
429
- demoDir,
430
- });
431
- if (!dryRun) {
432
- fs.mkdirSync(demoDir, { recursive: true });
433
- fs.writeFileSync(adapter, skeleton);
434
- }
435
- changes.push([
436
- "added",
437
- "demo/demo.config.ts",
438
- webServer.command && webServer.url
439
- ? `skeleton; webServer from ${path.basename(configFile)}`
440
- : "skeleton; webServer to fill in (TODO)",
441
- ]);
442
- }
443
- todo.push(
444
- "Ask Claude for a demo video of this app: it finishes demo.config.ts and writes the primary story, then runs demo-video check.",
445
- );
446
- return { changes, todo };
447
- };
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ // `demo-video init` sets a pnpm project up for demo videos: .gitignore,
7
+ // package.json, the skill, the label, the demo-video.yml caller (only when
8
+ // missing) and a demo.config.ts skeleton. It never edits an existing
9
+ // workflow. Safe to re-run; with dryRun it reports what it would change.
10
+
11
+ const PKG = "@navipartner/demo-video";
12
+ export const IGNORE_LINE = "**/demo/out/";
13
+ export const LABEL = "demo video";
14
+
15
+ const engineDir = path.resolve(
16
+ path.dirname(fileURLToPath(import.meta.url)),
17
+ "..",
18
+ );
19
+ const engineVersion = () =>
20
+ JSON.parse(fs.readFileSync(path.join(engineDir, "package.json"), "utf8"))
21
+ .version;
22
+
23
+ export const CALLER_WORKFLOW = `name: Demo video
24
+ # The "${LABEL}" label films the stories a PR adds or changes. A comment
25
+ # "/demo-video [<story>] [in <brand> branding]" on a PR (its branch) or an
26
+ # issue (the default branch; also from Linear's synced thread), or Actions →
27
+ # Demo video → Run workflow, films an existing story with a brand.
28
+ # Pitch drafts for a customer without a brand here are @Linear's (README).
29
+ on:
30
+ pull_request:
31
+ types: [labeled, synchronize]
32
+ issue_comment:
33
+ types: [created]
34
+ # Actions → Demo video → Run workflow: film a story with a brand.
35
+ workflow_dispatch:
36
+ inputs:
37
+ app:
38
+ description: "App folder, e.g. apps/retail-self-service (empty: the only app)"
39
+ default: ""
40
+ story:
41
+ description: "Story name (empty: the app's primary story)"
42
+ default: ""
43
+ brand:
44
+ description: "Brand (empty: the app's default)"
45
+ default: ""
46
+ permissions: {}
47
+ jobs:
48
+ render:
49
+ if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, '${LABEL}')
50
+ permissions:
51
+ contents: read
52
+ pull-requests: write
53
+ uses: navipartner/demo-video/.github/workflows/render.yml@v0
54
+ request:
55
+ if: github.event_name == 'workflow_dispatch' || (github.event_name == 'issue_comment' && contains(github.event.comment.body, '/demo-video'))
56
+ permissions:
57
+ contents: read
58
+ pull-requests: write
59
+ issues: write
60
+ uses: navipartner/demo-video/.github/workflows/request.yml@v0
61
+ with:
62
+ app: \${{ inputs.app }}
63
+ story: \${{ inputs.story }}
64
+ brand: \${{ inputs.brand }}
65
+ `;
66
+
67
+ const read = (file) =>
68
+ fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
69
+ const eolOf = (text) => (text?.includes("\r\n") ? "\r\n" : "\n");
70
+
71
+ const appendLines = (file, lines, dryRun) => {
72
+ const old = read(file) ?? "";
73
+ const eol = eolOf(old);
74
+ const sep = old && !old.endsWith("\n") ? eol : "";
75
+ if (!dryRun) {
76
+ fs.writeFileSync(file, old + sep + lines.join(eol) + eol);
77
+ }
78
+ };
79
+
80
+ /** Edits a JSON file in place, keeping its indent and line endings. */
81
+ const editJson = (file, edit, dryRun) => {
82
+ const text = fs.readFileSync(file, "utf8");
83
+ const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? " ";
84
+ const json = JSON.parse(text);
85
+ edit(json);
86
+ const eol = eolOf(text);
87
+ if (!dryRun) {
88
+ fs.writeFileSync(
89
+ file,
90
+ JSON.stringify(json, null, indent).replace(/\n/g, eol) + eol,
91
+ );
92
+ }
93
+ };
94
+
95
+ /** Adds a key in alphabetical place when the object is sorted, else last. */
96
+ const withKey = (object, key, value) => {
97
+ const keys = Object.keys(object);
98
+ const sorted = keys.every((k, i) => i === 0 || keys[i - 1] <= k);
99
+ const entries = Object.entries(object);
100
+ const at = sorted ? entries.findIndex(([k]) => k > key) : -1;
101
+ entries.splice(at === -1 ? entries.length : at, 0, [key, value]);
102
+ return Object.fromEntries(entries);
103
+ };
104
+
105
+ const sameTree = (a, b) => {
106
+ if (!fs.existsSync(b)) {
107
+ return false;
108
+ }
109
+ return fs.readdirSync(a, { withFileTypes: true }).every((entry) => {
110
+ const [x, y] = [path.join(a, entry.name), path.join(b, entry.name)];
111
+ return entry.isDirectory()
112
+ ? sameTree(x, y)
113
+ : fs.existsSync(y) && fs.readFileSync(x).equals(fs.readFileSync(y));
114
+ });
115
+ };
116
+
117
+ const gitIgnores = (root, file) =>
118
+ spawnSync("git", ["check-ignore", "-q", file], { cwd: root }).status === 0;
119
+
120
+ const ensureLabel = (root, dryRun) => {
121
+ const list = spawnSync(
122
+ "gh",
123
+ // All labels, not --search: the search index can lag and miss one.
124
+ ["label", "list", "--limit", "1000", "--json", "name", "--jq", ".[].name"],
125
+ { cwd: root, encoding: "utf8" },
126
+ );
127
+ if (list.error || list.status !== 0) {
128
+ return {
129
+ todo: `Create the label: gh label create "${LABEL}" --color 1F5494`,
130
+ };
131
+ }
132
+ // GitHub label names are case-insensitive.
133
+ const names = list.stdout.split("\n").map((name) => name.toLowerCase());
134
+ if (names.includes(LABEL)) {
135
+ return { change: ["ok", "label", LABEL] };
136
+ }
137
+ if (!dryRun) {
138
+ const created = spawnSync(
139
+ "gh",
140
+ ["label", "create", LABEL, "--color", "1F5494"],
141
+ { cwd: root, encoding: "utf8" },
142
+ );
143
+ if (created.error || created.status !== 0) {
144
+ return {
145
+ todo: `Create the label (gh could not: ${(created.stderr ?? "").trim() || "no access?"}): gh label create "${LABEL}" --color 1F5494`,
146
+ };
147
+ }
148
+ }
149
+ return { change: ["added", "label", LABEL] };
150
+ };
151
+
152
+ /**
153
+ * Once per repo, from its root. `label` (default true) checks or creates the
154
+ * GitHub label with gh.
155
+ */
156
+ export const initRepo = (root, { dryRun = false, label = true } = {}) => {
157
+ const changes = [];
158
+ const todo = [];
159
+
160
+ const gitignore = path.join(root, ".gitignore");
161
+ const ignored = read(gitignore);
162
+ // Only **/ reaches apps/<x>/demo/out/: a bare demo/out/ is anchored to
163
+ // the repo root.
164
+ if (/^\*\*\/demo\/out\/?$/m.test(ignored ?? "")) {
165
+ changes.push(["ok", ".gitignore", IGNORE_LINE]);
166
+ } else {
167
+ const comment = "# Demo video renders (@navipartner/demo-video)";
168
+ appendLines(
169
+ gitignore,
170
+ ignored ? ["", comment, IGNORE_LINE] : [comment, IGNORE_LINE],
171
+ dryRun,
172
+ );
173
+ changes.push(["added", ".gitignore", IGNORE_LINE]);
174
+ }
175
+
176
+ // pnpm 10 runs no install scripts unless allowed; ffmpeg-static needs its.
177
+ const pkgFile = path.join(root, "package.json");
178
+ const pkg = fs.existsSync(pkgFile)
179
+ ? JSON.parse(fs.readFileSync(pkgFile, "utf8"))
180
+ : null;
181
+ const pnpmMajor = Number(/^pnpm@(\d+)/.exec(pkg?.packageManager ?? "")?.[1]);
182
+ const workspaceYaml = read(path.join(root, "pnpm-workspace.yaml")) ?? "";
183
+ if (pkg && pnpmMajor >= 10) {
184
+ if (/^onlyBuiltDependencies:/m.test(workspaceYaml)) {
185
+ if (!/ffmpeg-static/.test(workspaceYaml)) {
186
+ todo.push(
187
+ "Add ffmpeg-static to onlyBuiltDependencies in pnpm-workspace.yaml.",
188
+ );
189
+ }
190
+ } else if (pkg.pnpm?.onlyBuiltDependencies?.includes("ffmpeg-static")) {
191
+ changes.push(["ok", "package.json", "pnpm.onlyBuiltDependencies"]);
192
+ } else {
193
+ editJson(
194
+ pkgFile,
195
+ (json) => {
196
+ json.pnpm ??= {};
197
+ json.pnpm.onlyBuiltDependencies = [
198
+ ...(json.pnpm.onlyBuiltDependencies ?? []),
199
+ "ffmpeg-static",
200
+ ];
201
+ },
202
+ dryRun,
203
+ );
204
+ changes.push(["added", "package.json", "pnpm.onlyBuiltDependencies"]);
205
+ }
206
+ } else if (pkg && !pnpmMajor) {
207
+ todo.push(
208
+ 'No "packageManager" in package.json: with pnpm 10 or later, allow ffmpeg-static in onlyBuiltDependencies.',
209
+ );
210
+ }
211
+
212
+ // Claude Code loads .claude/skills/<name>/SKILL.md.
213
+ const skillSrc = path.join(engineDir, "skills", "np-demo-video");
214
+ const skillDest = path.join(root, ".claude", "skills", "np-demo-video");
215
+ const skillRel = ".claude/skills/np-demo-video/";
216
+ if (sameTree(skillSrc, skillDest)) {
217
+ changes.push(["ok", skillRel, "the np-demo-video skill"]);
218
+ } else {
219
+ const existed = fs.existsSync(skillDest);
220
+ if (!dryRun) {
221
+ fs.cpSync(skillSrc, skillDest, { recursive: true, force: true });
222
+ }
223
+ changes.push([
224
+ existed ? "updated" : "added",
225
+ skillRel,
226
+ "the np-demo-video skill",
227
+ ]);
228
+ }
229
+ const stray = read(path.join(root, ".claude", "skills", "SKILL.md"));
230
+ if (stray?.includes("name: np-demo-video")) {
231
+ todo.push(
232
+ "Delete .claude/skills/SKILL.md and look.md: a copy of the skill in the wrong folder.",
233
+ );
234
+ }
235
+ if (gitIgnores(root, skillRel)) {
236
+ todo.push(
237
+ "Git ignores .claude/ here, so the skill stays on this machine: each developer runs init, or add !.claude/skills/ to .gitignore to share it.",
238
+ );
239
+ }
240
+
241
+ if (label) {
242
+ const result = ensureLabel(root, dryRun);
243
+ if (result.change) {
244
+ changes.push(result.change);
245
+ } else {
246
+ todo.push(result.todo);
247
+ }
248
+ }
249
+
250
+ // The caller workflow is fixed, so it is written when missing. Existing
251
+ // workflows are never touched.
252
+ const callerFile = path.join(root, ".github", "workflows", "demo-video.yml");
253
+ if (
254
+ ["demo-video.yml", "demo-video.yaml"].some((f) =>
255
+ fs.existsSync(path.join(root, ".github", "workflows", f)),
256
+ )
257
+ ) {
258
+ changes.push(["ok", ".github/workflows/demo-video.yml", "exists"]);
259
+ // Written by an older init: it has the label job only.
260
+ const existing = ["demo-video.yml", "demo-video.yaml"]
261
+ .map((f) => read(path.join(root, ".github", "workflows", f)))
262
+ .find(Boolean);
263
+ if (existing && !existing.includes("request.yml@")) {
264
+ todo.push(
265
+ '.github/workflows/demo-video.yml predates the "request" job (/demo-video comments, the Run workflow form): delete it and re-run init to get the current one.',
266
+ );
267
+ }
268
+ } else {
269
+ if (!dryRun) {
270
+ fs.mkdirSync(path.dirname(callerFile), { recursive: true });
271
+ fs.writeFileSync(callerFile, CALLER_WORKFLOW);
272
+ }
273
+ changes.push([
274
+ "added",
275
+ ".github/workflows/demo-video.yml",
276
+ "render.yml@v0",
277
+ ]);
278
+ }
279
+
280
+ return { changes, todo };
281
+ };
282
+
283
+ /** The webServer block of a Playwright config, if one can be read. */
284
+ const webServerOf = (configText) => {
285
+ const start = configText.indexOf("webServer");
286
+ const open = configText.indexOf("{", start);
287
+ if (start === -1 || open === -1) {
288
+ return {};
289
+ }
290
+ let depth = 0;
291
+ let end = open;
292
+ for (; end < configText.length; end++) {
293
+ depth += configText[end] === "{" ? 1 : configText[end] === "}" ? -1 : 0;
294
+ if (depth === 0) {
295
+ break;
296
+ }
297
+ }
298
+ const block = configText.slice(open, end + 1);
299
+ const str = (key) =>
300
+ new RegExp(`\\b${key}:\\s*["'\`]([^"'\`]+)["'\`]`).exec(block)?.[1];
301
+ const envBlock = /\benv:\s*\{([^}]*)\}/.exec(block)?.[1] ?? "";
302
+ const env = Object.fromEntries(
303
+ [...envBlock.matchAll(/(\w+):\s*["'`]([^"'`]*)["'`]/g)].map((m) => [
304
+ m[1],
305
+ m[2],
306
+ ]),
307
+ );
308
+ return { command: str("command"), url: str("url"), env };
309
+ };
310
+
311
+ /**
312
+ * e2e helpers that look like the app's mocks: in a file named *mock*, its
313
+ * exported class or function (`class ApiMocks`, `setupApiMocks`), not data.
314
+ */
315
+ const findMocks = (appDir) => {
316
+ const found = [];
317
+ const walk = (dir, depth) => {
318
+ if (depth > 3 || !fs.existsSync(dir)) {
319
+ return;
320
+ }
321
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
322
+ const file = path.join(dir, entry.name);
323
+ if (entry.isDirectory() && entry.name !== "node_modules") {
324
+ walk(file, depth + 1);
325
+ } else if (/mock/i.test(entry.name) && /\.[cm]?[jt]s$/.test(entry.name)) {
326
+ const name = /^export\s+(?:class|(?:async\s+)?function)\s+(\w+)/m.exec(
327
+ read(file),
328
+ )?.[1];
329
+ if (name) {
330
+ found.push({ name, file });
331
+ }
332
+ }
333
+ }
334
+ };
335
+ walk(path.join(appDir, "e2e"), 0);
336
+ return found;
337
+ };
338
+
339
+ export const adapterSkeleton = ({ app, command, url, env, mocks, demoDir }) => {
340
+ const envLine = Object.keys(env).length
341
+ ? `\n env: { ${Object.entries(env)
342
+ .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
343
+ .join(", ")} },`
344
+ : "";
345
+ const mockHint = mocks.length
346
+ ? mocks
347
+ .map(({ name, file }) => {
348
+ const rel = path
349
+ .relative(demoDir, file)
350
+ .replace(/\\/g, "/")
351
+ .replace(/\.[cm]?[jt]s$/, "");
352
+ return `\n // import { ${name} } from "${rel.startsWith(".") ? rel : `./${rel}`}";`;
353
+ })
354
+ .join("")
355
+ : "";
356
+ return `import { defineDemo } from "@navipartner/demo-video";
357
+
358
+ // Written by \`demo-video init\`. Finish it with Claude (the np-demo-video
359
+ // skill) from the app's e2e tests: their mocks, unchanged, never new ones.
360
+
361
+ export default defineDemo({
362
+ app: ${JSON.stringify(app)},${command && url ? "" : "\n // TODO: the dev server the e2e tests use (playwright.config's webServer)."}
363
+ webServer: {
364
+ command: ${JSON.stringify(command ?? "pnpm dev")},
365
+ url: ${JSON.stringify(url ?? "http://localhost:5173")},${envLine}
366
+ },
367
+ // TODO: the path the e2e tests open, e.g. "/?tenant=test".
368
+ appPath: "/",
369
+ // TODO: a testid that is visible once the mocks have booted the app.
370
+ ready: "TODO",
371
+
372
+ async setup() {
373
+ // TODO: install the e2e mocks unchanged, then lay the brand over the
374
+ // response that carries the app's branding (overlayResponse).${mocks.length ? `\n // Mocks found:${mockHint}` : ""}
375
+ },
376
+ });
377
+ `;
378
+ };
379
+
380
+ /** Once per app: the dependency and a demo.config.ts skeleton. */
381
+ export const initApp = (appDir, { dryRun = false } = {}) => {
382
+ const changes = [];
383
+ const todo = [];
384
+ const pkgFile = path.join(appDir, "package.json");
385
+ if (!fs.existsSync(pkgFile)) {
386
+ throw new Error(`No package.json in ${appDir}`);
387
+ }
388
+ const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
389
+ const version = engineVersion();
390
+
391
+ if (pkg.devDependencies?.[PKG] || pkg.dependencies?.[PKG]) {
392
+ changes.push(["ok", "package.json", PKG]);
393
+ } else {
394
+ // A prerelease is pinned exactly. A release gets a caret range, which on
395
+ // 0.x takes patch updates only (^0.2.0 is <0.3.0).
396
+ const range = version.includes("-") ? version : `^${version}`;
397
+ editJson(
398
+ pkgFile,
399
+ (json) => {
400
+ json.devDependencies = withKey(json.devDependencies ?? {}, PKG, range);
401
+ },
402
+ dryRun,
403
+ );
404
+ changes.push(["added", "package.json", `devDependencies ${PKG} ${range}`]);
405
+ todo.push("Run pnpm install to update the lockfile.");
406
+ }
407
+ if (
408
+ !pkg.devDependencies?.["@playwright/test"] &&
409
+ !pkg.dependencies?.["@playwright/test"]
410
+ ) {
411
+ todo.push(
412
+ "The app needs @playwright/test (1.59 or later): the demo runs on its Playwright.",
413
+ );
414
+ }
415
+
416
+ const demoDir = path.join(appDir, "demo");
417
+ const adapter = path.join(demoDir, "demo.config.ts");
418
+ if (fs.existsSync(adapter)) {
419
+ changes.push(["ok", "demo/demo.config.ts", "exists"]);
420
+ } else {
421
+ const configFile = ["ts", "mts", "js", "mjs"]
422
+ .map((ext) => path.join(appDir, `playwright.config.${ext}`))
423
+ .find((f) => fs.existsSync(f));
424
+ const webServer = configFile ? webServerOf(read(configFile)) : {};
425
+ const skeleton = adapterSkeleton({
426
+ app: (pkg.name ?? path.basename(appDir)).replace(/^@[^/]+\//, ""),
427
+ command: webServer.command,
428
+ url: webServer.url,
429
+ env: webServer.env ?? {},
430
+ mocks: findMocks(appDir),
431
+ demoDir,
432
+ });
433
+ if (!dryRun) {
434
+ fs.mkdirSync(demoDir, { recursive: true });
435
+ fs.writeFileSync(adapter, skeleton);
436
+ }
437
+ changes.push([
438
+ "added",
439
+ "demo/demo.config.ts",
440
+ webServer.command && webServer.url
441
+ ? `skeleton; webServer from ${path.basename(configFile)}`
442
+ : "skeleton; webServer to fill in (TODO)",
443
+ ]);
444
+ }
445
+ todo.push(
446
+ "Ask Claude for a demo video of this app: it finishes demo.config.ts and writes the primary story, then runs demo-video check.",
447
+ );
448
+ return { changes, todo };
449
+ };
450
+
451
+ // Folders never searched for apps.
452
+ const NOT_APPS = new Set(["node_modules", "dist", "build", "out", "coverage"]);
453
+
454
+ /**
455
+ * The repo's apps, as folders relative to `root` ("." for the root): every
456
+ * folder with a playwright.config and a package.json, since the demo films an
457
+ * app on its own e2e setup. Repos differ (one app at the root, apps/<x>/,
458
+ * packages/<x>/), so this looks rather than assumes.
459
+ */
460
+ export const findApps = (root) => {
461
+ const apps = [];
462
+ const walk = (dir, depth) => {
463
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
464
+ const isApp =
465
+ entries.some((e) => /^playwright\.config\.[cm]?[jt]s$/.test(e.name)) &&
466
+ entries.some((e) => e.name === "package.json");
467
+ if (isApp) {
468
+ apps.push(path.relative(root, dir).split(path.sep).join("/") || ".");
469
+ }
470
+ if (depth >= 3) {
471
+ return;
472
+ }
473
+ for (const entry of entries) {
474
+ if (
475
+ entry.isDirectory() &&
476
+ !entry.name.startsWith(".") &&
477
+ !NOT_APPS.has(entry.name)
478
+ ) {
479
+ walk(path.join(dir, entry.name), depth + 1);
480
+ }
481
+ }
482
+ };
483
+ walk(root, 0);
484
+ return apps.sort();
485
+ };
486
+
487
+ /**
488
+ * `init` without --app: the repo, and its app too when it has exactly one
489
+ * (at the root or in a folder). Several apps are listed for `init --app`.
490
+ */
491
+ export const initProject = (root, options = {}) => {
492
+ const repo = initRepo(root, options);
493
+ const apps = findApps(root);
494
+ if (apps.length === 1) {
495
+ const app = initApp(path.resolve(root, apps[0]), options);
496
+ const prefix = apps[0] === "." ? "" : `${apps[0]}/`;
497
+ return {
498
+ apps,
499
+ changes: [
500
+ ...repo.changes,
501
+ ...app.changes.map(([status, what, detail]) => [
502
+ status,
503
+ `${prefix}${what}`,
504
+ detail,
505
+ ]),
506
+ ],
507
+ todo: [...repo.todo, ...app.todo],
508
+ };
509
+ }
510
+ const todo = [...repo.todo];
511
+ if (apps.length > 1) {
512
+ todo.push(
513
+ `This repo has ${apps.length} apps with Playwright e2e tests: ${apps.join(", ")}. Set up each one you want videos of with init --app <folder>.`,
514
+ );
515
+ } else {
516
+ todo.push(
517
+ "No app with a playwright.config found: the demo films an app on its Playwright e2e tests. Add them, then run init again (or init --app <folder>).",
518
+ );
519
+ }
520
+ return { apps, changes: repo.changes, todo };
521
+ };