demo-dev 0.0.1-alpha.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/README.md +174 -0
- package/bin/demo-cli.js +26 -0
- package/bin/demo-dev.js +26 -0
- package/demo.dev.config.example.json +20 -0
- package/dist/index.d.ts +392 -0
- package/dist/index.js +2116 -0
- package/package.json +76 -0
- package/skills/demo-dev/SKILL.md +153 -0
- package/skills/demo-dev/references/configuration.md +102 -0
- package/skills/demo-dev/references/recipes.md +83 -0
- package/src/ai/provider.ts +254 -0
- package/src/auth/bootstrap.ts +72 -0
- package/src/browser/session.ts +43 -0
- package/src/capture/continuous-capture.ts +739 -0
- package/src/cli.ts +337 -0
- package/src/config/project.ts +183 -0
- package/src/github/comment.ts +134 -0
- package/src/index.ts +10 -0
- package/src/lib/data-uri.ts +21 -0
- package/src/lib/fs.ts +7 -0
- package/src/lib/git.ts +59 -0
- package/src/lib/media.ts +23 -0
- package/src/orchestrate.ts +166 -0
- package/src/planner/heuristic.ts +180 -0
- package/src/planner/index.ts +26 -0
- package/src/planner/llm.ts +85 -0
- package/src/planner/openai.ts +77 -0
- package/src/planner/prompt.ts +331 -0
- package/src/planner/refine.ts +155 -0
- package/src/planner/schema.ts +62 -0
- package/src/presentation/polish.ts +84 -0
- package/src/probe/page-probe.ts +225 -0
- package/src/render/browser-frame.ts +176 -0
- package/src/render/ffmpeg-compose.ts +779 -0
- package/src/render/visual-plan.ts +422 -0
- package/src/setup/doctor.ts +158 -0
- package/src/setup/init.ts +90 -0
- package/src/types.ts +105 -0
- package/src/voice/script.ts +42 -0
- package/src/voice/tts.ts +286 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-processing intelligence layer.
|
|
3
|
+
*
|
|
4
|
+
* Analyzes the metadata from a continuous capture session (cursor positions,
|
|
5
|
+
* interaction events, scene markers) and generates a "visual plan" — a timeline
|
|
6
|
+
* of zoom keyframes, speed ramps, and cursor smoothing parameters that can be
|
|
7
|
+
* consumed by the FFmpeg composition pipeline.
|
|
8
|
+
*
|
|
9
|
+
* The goal is to replicate Screen Studio's approach: raw recording + metadata
|
|
10
|
+
* → intelligent post-processing that makes the video feel cinematic.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
CursorSample,
|
|
15
|
+
CaptureInteraction,
|
|
16
|
+
SceneMarker,
|
|
17
|
+
ContinuousCaptureResult,
|
|
18
|
+
} from "../capture/continuous-capture.js";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export interface ZoomKeyframe {
|
|
25
|
+
/** Time offset in the raw recording (ms). */
|
|
26
|
+
atMs: number;
|
|
27
|
+
/** Zoom center X as fraction of viewport width (0–1). */
|
|
28
|
+
centerX: number;
|
|
29
|
+
/** Zoom center Y as fraction of viewport height (0–1). */
|
|
30
|
+
centerY: number;
|
|
31
|
+
/** Zoom scale (1.0 = no zoom, 2.0 = 2x zoom). */
|
|
32
|
+
scale: number;
|
|
33
|
+
/** Duration to hold this zoom (ms) before transitioning. */
|
|
34
|
+
holdMs: number;
|
|
35
|
+
/** Easing for transitioning INTO this keyframe. */
|
|
36
|
+
easing: "ease-in-out" | "ease-out" | "spring";
|
|
37
|
+
/** Transition duration from previous keyframe (ms). */
|
|
38
|
+
transitionMs: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SpeedSegment {
|
|
42
|
+
/** Start time in raw recording (ms). */
|
|
43
|
+
startMs: number;
|
|
44
|
+
/** End time in raw recording (ms). */
|
|
45
|
+
endMs: number;
|
|
46
|
+
/** Playback speed (1.0 = normal, 2.0 = 2x, 0.5 = slow). */
|
|
47
|
+
speed: number;
|
|
48
|
+
/** Reason for this speed change. */
|
|
49
|
+
reason: "normal" | "loading" | "idle" | "transition";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SmoothedCursorPoint {
|
|
53
|
+
/** Time in output video timeline (ms). */
|
|
54
|
+
atMs: number;
|
|
55
|
+
x: number;
|
|
56
|
+
y: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface VisualPlanResult {
|
|
60
|
+
zoomKeyframes: ZoomKeyframe[];
|
|
61
|
+
speedSegments: SpeedSegment[];
|
|
62
|
+
smoothedCursor: SmoothedCursorPoint[];
|
|
63
|
+
/** Total duration after speed adjustments (ms). */
|
|
64
|
+
adjustedDurationMs: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Constants
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/** How close to a click target to zoom (scale factor). */
|
|
72
|
+
const CLICK_ZOOM_SCALE = 1.6;
|
|
73
|
+
const HOVER_ZOOM_SCALE = 1.3;
|
|
74
|
+
const FILL_ZOOM_SCALE = 1.5;
|
|
75
|
+
const DEFAULT_ZOOM_SCALE = 1.0;
|
|
76
|
+
|
|
77
|
+
/** Minimum gap between zoom keyframes (ms). */
|
|
78
|
+
const MIN_ZOOM_GAP_MS = 800;
|
|
79
|
+
|
|
80
|
+
/** If no interaction for this long, consider it "idle" and speed up. */
|
|
81
|
+
const IDLE_THRESHOLD_MS = 1500;
|
|
82
|
+
|
|
83
|
+
/** Speed multiplier for idle segments. */
|
|
84
|
+
const IDLE_SPEED = 6.0;
|
|
85
|
+
|
|
86
|
+
/** Speed for navigating between pages (the loading part). */
|
|
87
|
+
const LOADING_SPEED = 2.5;
|
|
88
|
+
|
|
89
|
+
/** Cursor smoothing window size (samples). */
|
|
90
|
+
const SMOOTH_WINDOW = 5;
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Zoom keyframe generation
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
const zoomScaleForEvent = (type: CaptureInteraction["type"]): number => {
|
|
97
|
+
switch (type) {
|
|
98
|
+
case "click":
|
|
99
|
+
return CLICK_ZOOM_SCALE;
|
|
100
|
+
case "fill":
|
|
101
|
+
return FILL_ZOOM_SCALE;
|
|
102
|
+
case "hover":
|
|
103
|
+
return HOVER_ZOOM_SCALE;
|
|
104
|
+
case "select":
|
|
105
|
+
return CLICK_ZOOM_SCALE;
|
|
106
|
+
case "dragSelect":
|
|
107
|
+
return FILL_ZOOM_SCALE;
|
|
108
|
+
default:
|
|
109
|
+
return DEFAULT_ZOOM_SCALE;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const isInteractive = (type: CaptureInteraction["type"]): boolean =>
|
|
114
|
+
type === "click" ||
|
|
115
|
+
type === "hover" ||
|
|
116
|
+
type === "fill" ||
|
|
117
|
+
type === "select" ||
|
|
118
|
+
type === "dragSelect" ||
|
|
119
|
+
type === "press";
|
|
120
|
+
|
|
121
|
+
const buildZoomKeyframes = (
|
|
122
|
+
interactions: CaptureInteraction[],
|
|
123
|
+
viewport: { width: number; height: number },
|
|
124
|
+
): ZoomKeyframe[] => {
|
|
125
|
+
const keyframes: ZoomKeyframe[] = [];
|
|
126
|
+
|
|
127
|
+
// Start with full view
|
|
128
|
+
keyframes.push({
|
|
129
|
+
atMs: 0,
|
|
130
|
+
centerX: 0.5,
|
|
131
|
+
centerY: 0.5,
|
|
132
|
+
scale: 1.0,
|
|
133
|
+
holdMs: 500,
|
|
134
|
+
easing: "ease-in-out",
|
|
135
|
+
transitionMs: 0,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
let lastKeyframeMs = 0;
|
|
139
|
+
|
|
140
|
+
for (const interaction of interactions) {
|
|
141
|
+
if (!isInteractive(interaction.type)) continue;
|
|
142
|
+
if (interaction.x == null || interaction.y == null) continue;
|
|
143
|
+
if (interaction.atMs - lastKeyframeMs < MIN_ZOOM_GAP_MS) continue;
|
|
144
|
+
|
|
145
|
+
const centerX = interaction.x / viewport.width;
|
|
146
|
+
const centerY = interaction.y / viewport.height;
|
|
147
|
+
const scale = zoomScaleForEvent(interaction.type);
|
|
148
|
+
|
|
149
|
+
// Zoom in to the interaction target
|
|
150
|
+
keyframes.push({
|
|
151
|
+
atMs: interaction.atMs - 300, // Start zooming 300ms before
|
|
152
|
+
centerX,
|
|
153
|
+
centerY,
|
|
154
|
+
scale,
|
|
155
|
+
holdMs: 600,
|
|
156
|
+
easing: "spring",
|
|
157
|
+
transitionMs: 500,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Zoom back out after the interaction
|
|
161
|
+
keyframes.push({
|
|
162
|
+
atMs: interaction.atMs + 800,
|
|
163
|
+
centerX: 0.5,
|
|
164
|
+
centerY: 0.5,
|
|
165
|
+
scale: 1.0,
|
|
166
|
+
holdMs: 200,
|
|
167
|
+
easing: "ease-in-out",
|
|
168
|
+
transitionMs: 600,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
lastKeyframeMs = interaction.atMs + 800;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Merge nearby "zoom out" keyframes to avoid jitter
|
|
175
|
+
return mergeNearbyKeyframes(keyframes);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const mergeNearbyKeyframes = (keyframes: ZoomKeyframe[]): ZoomKeyframe[] => {
|
|
179
|
+
if (keyframes.length <= 2) return keyframes;
|
|
180
|
+
|
|
181
|
+
const merged: ZoomKeyframe[] = [keyframes[0]];
|
|
182
|
+
|
|
183
|
+
for (let i = 1; i < keyframes.length; i++) {
|
|
184
|
+
const prev = merged[merged.length - 1];
|
|
185
|
+
const curr = keyframes[i];
|
|
186
|
+
|
|
187
|
+
// If two keyframes are very close and both zoom out, merge them
|
|
188
|
+
if (
|
|
189
|
+
curr.atMs - prev.atMs < 400 &&
|
|
190
|
+
Math.abs(curr.scale - prev.scale) < 0.2
|
|
191
|
+
) {
|
|
192
|
+
// Keep the one with the higher zoom (more interesting)
|
|
193
|
+
if (curr.scale > prev.scale) {
|
|
194
|
+
merged[merged.length - 1] = curr;
|
|
195
|
+
}
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
merged.push(curr);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return merged;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Speed ramp generation
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
const buildSpeedSegments = (
|
|
210
|
+
interactions: CaptureInteraction[],
|
|
211
|
+
sceneMarkers: SceneMarker[],
|
|
212
|
+
totalDurationMs: number,
|
|
213
|
+
): SpeedSegment[] => {
|
|
214
|
+
const segments: SpeedSegment[] = [];
|
|
215
|
+
|
|
216
|
+
// Find navigation events (page loads are boring → speed up)
|
|
217
|
+
const navigations = interactions.filter((i) => i.type === "navigate");
|
|
218
|
+
const interactiveEvents = interactions.filter((i) => isInteractive(i.type));
|
|
219
|
+
|
|
220
|
+
// Build timeline of "interesting" moments
|
|
221
|
+
// Only include interactive + navigate — scene-start is just a marker, not an activity
|
|
222
|
+
type Moment = { atMs: number; type: "interactive" | "navigate" };
|
|
223
|
+
const moments: Moment[] = [
|
|
224
|
+
...interactiveEvents.map((i) => ({ atMs: i.atMs, type: "interactive" as const })),
|
|
225
|
+
...navigations.map((i) => ({ atMs: i.atMs, type: "navigate" as const })),
|
|
226
|
+
].sort((a, b) => a.atMs - b.atMs);
|
|
227
|
+
|
|
228
|
+
if (moments.length === 0) {
|
|
229
|
+
segments.push({
|
|
230
|
+
startMs: 0,
|
|
231
|
+
endMs: totalDurationMs,
|
|
232
|
+
speed: 1.0,
|
|
233
|
+
reason: "normal",
|
|
234
|
+
});
|
|
235
|
+
return segments;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let cursor = 0;
|
|
239
|
+
|
|
240
|
+
for (let i = 0; i < moments.length; i++) {
|
|
241
|
+
const moment = moments[i];
|
|
242
|
+
const gapMs = moment.atMs - cursor;
|
|
243
|
+
|
|
244
|
+
if (gapMs > IDLE_THRESHOLD_MS) {
|
|
245
|
+
// Long gap before this moment → speed up the idle part
|
|
246
|
+
// But keep a 500ms buffer at normal speed before the interesting moment
|
|
247
|
+
const idleEnd = moment.atMs - 500;
|
|
248
|
+
if (idleEnd > cursor) {
|
|
249
|
+
segments.push({
|
|
250
|
+
startMs: cursor,
|
|
251
|
+
endMs: idleEnd,
|
|
252
|
+
speed: IDLE_SPEED,
|
|
253
|
+
reason: "idle",
|
|
254
|
+
});
|
|
255
|
+
cursor = idleEnd;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// After a navigate, speed through the loading phase
|
|
260
|
+
if (moment.type === "navigate") {
|
|
261
|
+
const nextInteractive = moments.find(
|
|
262
|
+
(m) => m.atMs > moment.atMs && m.type === "interactive",
|
|
263
|
+
);
|
|
264
|
+
const loadEnd = nextInteractive
|
|
265
|
+
? Math.min(nextInteractive.atMs - 300, moment.atMs + 3000)
|
|
266
|
+
: moment.atMs + 2000;
|
|
267
|
+
|
|
268
|
+
if (loadEnd > moment.atMs + 500) {
|
|
269
|
+
segments.push({
|
|
270
|
+
startMs: cursor,
|
|
271
|
+
endMs: moment.atMs,
|
|
272
|
+
speed: 1.0,
|
|
273
|
+
reason: "normal",
|
|
274
|
+
});
|
|
275
|
+
segments.push({
|
|
276
|
+
startMs: moment.atMs,
|
|
277
|
+
endMs: loadEnd,
|
|
278
|
+
speed: LOADING_SPEED,
|
|
279
|
+
reason: "loading",
|
|
280
|
+
});
|
|
281
|
+
cursor = loadEnd;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Normal speed for interesting moments
|
|
287
|
+
const nextMoment = moments[i + 1];
|
|
288
|
+
const endMs = nextMoment ? nextMoment.atMs : totalDurationMs;
|
|
289
|
+
|
|
290
|
+
if (cursor < endMs) {
|
|
291
|
+
segments.push({
|
|
292
|
+
startMs: cursor,
|
|
293
|
+
endMs,
|
|
294
|
+
speed: 1.0,
|
|
295
|
+
reason: "normal",
|
|
296
|
+
});
|
|
297
|
+
cursor = endMs;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Fill remaining time
|
|
302
|
+
if (cursor < totalDurationMs) {
|
|
303
|
+
segments.push({
|
|
304
|
+
startMs: cursor,
|
|
305
|
+
endMs: totalDurationMs,
|
|
306
|
+
speed: 1.0,
|
|
307
|
+
reason: "normal",
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return mergeAdjacentSegments(segments);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const mergeAdjacentSegments = (segments: SpeedSegment[]): SpeedSegment[] => {
|
|
315
|
+
if (segments.length <= 1) return segments;
|
|
316
|
+
|
|
317
|
+
const merged: SpeedSegment[] = [segments[0]];
|
|
318
|
+
|
|
319
|
+
for (let i = 1; i < segments.length; i++) {
|
|
320
|
+
const prev = merged[merged.length - 1];
|
|
321
|
+
const curr = segments[i];
|
|
322
|
+
|
|
323
|
+
if (prev.speed === curr.speed && prev.endMs >= curr.startMs - 10) {
|
|
324
|
+
prev.endMs = curr.endMs;
|
|
325
|
+
} else {
|
|
326
|
+
merged.push(curr);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return merged;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// Cursor smoothing (spring-damper)
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
const smoothCursorLog = (
|
|
338
|
+
raw: CursorSample[],
|
|
339
|
+
windowSize = SMOOTH_WINDOW,
|
|
340
|
+
): SmoothedCursorPoint[] => {
|
|
341
|
+
if (raw.length === 0) return [];
|
|
342
|
+
|
|
343
|
+
const smoothed: SmoothedCursorPoint[] = [];
|
|
344
|
+
|
|
345
|
+
for (let i = 0; i < raw.length; i++) {
|
|
346
|
+
const start = Math.max(0, i - Math.floor(windowSize / 2));
|
|
347
|
+
const end = Math.min(raw.length, i + Math.ceil(windowSize / 2));
|
|
348
|
+
|
|
349
|
+
let sumX = 0;
|
|
350
|
+
let sumY = 0;
|
|
351
|
+
let count = 0;
|
|
352
|
+
|
|
353
|
+
for (let j = start; j < end; j++) {
|
|
354
|
+
// Weight: closer samples get more weight (triangular window)
|
|
355
|
+
const weight = 1 - Math.abs(j - i) / windowSize;
|
|
356
|
+
sumX += raw[j].x * weight;
|
|
357
|
+
sumY += raw[j].y * weight;
|
|
358
|
+
count += weight;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
smoothed.push({
|
|
362
|
+
atMs: raw[i].atMs,
|
|
363
|
+
x: Math.round(sumX / count),
|
|
364
|
+
y: Math.round(sumY / count),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Downsample to ~30fps (one point per ~33ms) to keep data manageable
|
|
369
|
+
const downsampled: SmoothedCursorPoint[] = [];
|
|
370
|
+
let lastMs = -Infinity;
|
|
371
|
+
|
|
372
|
+
for (const pt of smoothed) {
|
|
373
|
+
if (pt.atMs - lastMs >= 33) {
|
|
374
|
+
downsampled.push(pt);
|
|
375
|
+
lastMs = pt.atMs;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return downsampled;
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// Calculate adjusted duration after speed changes
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
const calculateAdjustedDuration = (segments: SpeedSegment[]): number => {
|
|
387
|
+
let total = 0;
|
|
388
|
+
for (const seg of segments) {
|
|
389
|
+
total += (seg.endMs - seg.startMs) / seg.speed;
|
|
390
|
+
}
|
|
391
|
+
return Math.round(total);
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// Public API
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
export const buildVisualPlan = (
|
|
399
|
+
capture: ContinuousCaptureResult,
|
|
400
|
+
): VisualPlanResult => {
|
|
401
|
+
const zoomKeyframes = buildZoomKeyframes(
|
|
402
|
+
capture.interactions,
|
|
403
|
+
capture.viewport,
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
const speedSegments = buildSpeedSegments(
|
|
407
|
+
capture.interactions,
|
|
408
|
+
capture.sceneMarkers,
|
|
409
|
+
capture.totalDurationMs,
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
const smoothedCursor = smoothCursorLog(capture.cursorLog);
|
|
413
|
+
|
|
414
|
+
const adjustedDurationMs = calculateAdjustedDuration(speedSegments);
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
zoomKeyframes,
|
|
418
|
+
speedSegments,
|
|
419
|
+
smoothedCursor,
|
|
420
|
+
adjustedDurationMs,
|
|
421
|
+
};
|
|
422
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { chromium } from "playwright";
|
|
5
|
+
import type { ProjectConfig } from "../config/project.js";
|
|
6
|
+
|
|
7
|
+
interface CheckResult {
|
|
8
|
+
label: string;
|
|
9
|
+
status: "pass" | "warn" | "fail" | "skip";
|
|
10
|
+
detail: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const fileExists = async (path: string) => {
|
|
14
|
+
try {
|
|
15
|
+
await access(path);
|
|
16
|
+
return true;
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const commandExists = (command: string) => {
|
|
23
|
+
const result = spawnSync("bash", ["-lc", `command -v ${command}`], {
|
|
24
|
+
stdio: "ignore",
|
|
25
|
+
});
|
|
26
|
+
return result.status === 0;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const checkUrl = async (url: string) => {
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const timeout = setTimeout(() => controller.abort(), 4000);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetch(url, {
|
|
35
|
+
method: "GET",
|
|
36
|
+
redirect: "follow",
|
|
37
|
+
signal: controller.signal,
|
|
38
|
+
});
|
|
39
|
+
return response.ok || response.status < 500;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
} finally {
|
|
43
|
+
clearTimeout(timeout);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const runDoctor = async (options: {
|
|
48
|
+
configPath?: string;
|
|
49
|
+
config: ProjectConfig;
|
|
50
|
+
}) => {
|
|
51
|
+
const checks: CheckResult[] = [];
|
|
52
|
+
const workflowPath = resolve(process.cwd(), ".github", "workflows", "pr-demo.yml");
|
|
53
|
+
|
|
54
|
+
checks.push(
|
|
55
|
+
options.configPath
|
|
56
|
+
? { label: "Config file", status: "pass", detail: options.configPath }
|
|
57
|
+
: { label: "Config file", status: "warn", detail: "No demo.dev.config.json found." },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
checks.push(
|
|
61
|
+
options.config.baseUrl
|
|
62
|
+
? { label: "baseUrl", status: "pass", detail: options.config.baseUrl }
|
|
63
|
+
: { label: "baseUrl", status: "fail", detail: "Missing baseUrl in config." },
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
checks.push(
|
|
67
|
+
options.config.outputDir
|
|
68
|
+
? { label: "outputDir", status: "pass", detail: options.config.outputDir }
|
|
69
|
+
: { label: "outputDir", status: "warn", detail: "Missing outputDir. Defaults to artifacts." },
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
checks.push(
|
|
73
|
+
options.config.devCommand
|
|
74
|
+
? { label: "devCommand", status: "pass", detail: options.config.devCommand }
|
|
75
|
+
: { label: "devCommand", status: "warn", detail: "No devCommand configured. CI must point at a live app URL." },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
checks.push(
|
|
79
|
+
(await fileExists(workflowPath))
|
|
80
|
+
? { label: "Workflow", status: "pass", detail: workflowPath }
|
|
81
|
+
: { label: "Workflow", status: "warn", detail: "Missing .github/workflows/pr-demo.yml" },
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
checks.push(
|
|
85
|
+
commandExists("git")
|
|
86
|
+
? { label: "git", status: "pass", detail: "git found" }
|
|
87
|
+
: { label: "git", status: "fail", detail: "git is required" },
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
checks.push(
|
|
91
|
+
commandExists("ffmpeg")
|
|
92
|
+
? { label: "ffmpeg", status: "pass", detail: "ffmpeg found" }
|
|
93
|
+
: { label: "ffmpeg", status: "warn", detail: "ffmpeg not found. Local media workflows may fail." },
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
checks.push(
|
|
97
|
+
commandExists("ffprobe")
|
|
98
|
+
? { label: "ffprobe", status: "pass", detail: "ffprobe found" }
|
|
99
|
+
: { label: "ffprobe", status: "warn", detail: "ffprobe not found. Audio timing inspection may fail." },
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const browserBinaryExists = await fileExists(chromium.executablePath()).catch(() => false);
|
|
103
|
+
checks.push(
|
|
104
|
+
browserBinaryExists
|
|
105
|
+
? { label: "Playwright Chromium", status: "pass", detail: chromium.executablePath() }
|
|
106
|
+
: { label: "Playwright Chromium", status: "fail", detail: "Chromium browser is not installed. Run: npx playwright install chromium" },
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
if (options.config.readyUrl ?? options.config.baseUrl) {
|
|
110
|
+
const readyUrl = options.config.readyUrl ?? options.config.baseUrl;
|
|
111
|
+
const reachable = readyUrl ? await checkUrl(readyUrl) : false;
|
|
112
|
+
checks.push(
|
|
113
|
+
readyUrl
|
|
114
|
+
? reachable
|
|
115
|
+
? { label: "readyUrl", status: "pass", detail: `${readyUrl} is reachable` }
|
|
116
|
+
: { label: "readyUrl", status: "warn", detail: `${readyUrl} is not reachable right now` }
|
|
117
|
+
: { label: "readyUrl", status: "skip", detail: "No readyUrl configured" },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const storageStatePath = options.config.storageStatePath ?? process.env.DEMO_STORAGE_STATE;
|
|
122
|
+
if (storageStatePath) {
|
|
123
|
+
checks.push(
|
|
124
|
+
(await fileExists(resolve(process.cwd(), storageStatePath)))
|
|
125
|
+
? { label: "storageState", status: "pass", detail: storageStatePath }
|
|
126
|
+
: { label: "storageState", status: "warn", detail: `${storageStatePath} is configured but does not exist yet` },
|
|
127
|
+
);
|
|
128
|
+
} else {
|
|
129
|
+
checks.push({ label: "storageState", status: "skip", detail: "No storage state configured" });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const bgmPath = process.env.DEMO_BGM_PATH;
|
|
133
|
+
if (bgmPath) {
|
|
134
|
+
checks.push(
|
|
135
|
+
(await fileExists(resolve(process.cwd(), bgmPath)))
|
|
136
|
+
? { label: "bgm", status: "pass", detail: bgmPath }
|
|
137
|
+
: { label: "bgm", status: "warn", detail: `${bgmPath} is configured but does not exist` },
|
|
138
|
+
);
|
|
139
|
+
} else {
|
|
140
|
+
checks.push({ label: "bgm", status: "skip", detail: "No background music configured" });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const failures = checks.filter((check) => check.status === "fail");
|
|
144
|
+
const warnings = checks.filter((check) => check.status === "warn");
|
|
145
|
+
|
|
146
|
+
for (const check of checks) {
|
|
147
|
+
const icon = check.status === "pass" ? "✓" : check.status === "warn" ? "!" : check.status === "fail" ? "✗" : "-";
|
|
148
|
+
console.log(`${icon} ${check.label}: ${check.detail}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log("");
|
|
152
|
+
console.log(`Doctor summary: ${checks.length - warnings.length - failures.length} passed, ${warnings.length} warnings, ${failures.length} failures.`);
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
ok: failures.length === 0,
|
|
156
|
+
checks,
|
|
157
|
+
};
|
|
158
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { access, copyFile, mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { ProjectConfig } from "../config/project.js";
|
|
5
|
+
import { writeJson } from "../lib/fs.js";
|
|
6
|
+
|
|
7
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
8
|
+
const workflowTemplatePath = resolve(packageRoot, ".github", "workflows", "pr-demo.yml");
|
|
9
|
+
const configFilename = "demo.dev.config.json";
|
|
10
|
+
|
|
11
|
+
const fileExists = async (path: string) => {
|
|
12
|
+
try {
|
|
13
|
+
await access(path);
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const readPackageJson = async () => {
|
|
21
|
+
const packageJsonPath = resolve(process.cwd(), "package.json");
|
|
22
|
+
if (!(await fileExists(packageJsonPath))) return undefined;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(await readFile(packageJsonPath, "utf8")) as {
|
|
26
|
+
name?: string;
|
|
27
|
+
scripts?: Record<string, string>;
|
|
28
|
+
};
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const inferDevCommand = (pkg?: { scripts?: Record<string, string> }) => {
|
|
35
|
+
if (!pkg?.scripts) return undefined;
|
|
36
|
+
if (pkg.scripts.dev) return "npm run dev";
|
|
37
|
+
if (pkg.scripts.start) return "npm run start";
|
|
38
|
+
return undefined;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const inferProjectName = (pkg?: { name?: string }) => {
|
|
42
|
+
return pkg?.name ?? basename(process.cwd());
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const buildDefaultConfig = async (existingConfig: ProjectConfig = {}): Promise<ProjectConfig> => {
|
|
46
|
+
const pkg = await readPackageJson();
|
|
47
|
+
const baseUrl = existingConfig.baseUrl ?? "http://localhost:3000";
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
projectName: existingConfig.projectName ?? inferProjectName(pkg),
|
|
51
|
+
baseUrl,
|
|
52
|
+
readyUrl: existingConfig.readyUrl ?? baseUrl,
|
|
53
|
+
devCommand: existingConfig.devCommand ?? inferDevCommand(pkg),
|
|
54
|
+
baseRef: existingConfig.baseRef ?? "origin/main",
|
|
55
|
+
outputDir: existingConfig.outputDir ?? "artifacts",
|
|
56
|
+
storageStatePath: existingConfig.storageStatePath ?? "artifacts/storage-state.json",
|
|
57
|
+
saveStorageStatePath: existingConfig.saveStorageStatePath ?? "artifacts/storage-state.json",
|
|
58
|
+
preferredRoutes: existingConfig.preferredRoutes ?? ["/"],
|
|
59
|
+
featureHints: existingConfig.featureHints ?? [],
|
|
60
|
+
authRequiredRoutes: existingConfig.authRequiredRoutes ?? [],
|
|
61
|
+
auth: existingConfig.auth,
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const initProject = async (options: { force?: boolean; existingConfig?: ProjectConfig }) => {
|
|
66
|
+
const configPath = resolve(process.cwd(), configFilename);
|
|
67
|
+
const workflowPath = resolve(process.cwd(), ".github", "workflows", "pr-demo.yml");
|
|
68
|
+
const configExists = await fileExists(configPath);
|
|
69
|
+
const workflowExists = await fileExists(workflowPath);
|
|
70
|
+
|
|
71
|
+
if (configExists && !options.force) {
|
|
72
|
+
throw new Error(`${configFilename} already exists. Re-run with --force to overwrite.`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (workflowExists && !options.force) {
|
|
76
|
+
throw new Error(`${workflowPath} already exists. Re-run with --force to overwrite.`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const config = await buildDefaultConfig(options.existingConfig);
|
|
80
|
+
await writeJson(configPath, config);
|
|
81
|
+
|
|
82
|
+
await mkdir(dirname(workflowPath), { recursive: true });
|
|
83
|
+
await copyFile(workflowTemplatePath, workflowPath);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
configPath,
|
|
87
|
+
workflowPath,
|
|
88
|
+
config,
|
|
89
|
+
};
|
|
90
|
+
};
|