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