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.
Files changed (41) hide show
  1. package/README.md +174 -0
  2. package/bin/demo-cli.js +26 -0
  3. package/bin/demo-dev.js +26 -0
  4. package/demo.dev.config.example.json +20 -0
  5. package/dist/index.d.ts +392 -0
  6. package/dist/index.js +2116 -0
  7. package/package.json +76 -0
  8. package/skills/demo-dev/SKILL.md +153 -0
  9. package/skills/demo-dev/references/configuration.md +102 -0
  10. package/skills/demo-dev/references/recipes.md +83 -0
  11. package/src/ai/provider.ts +254 -0
  12. package/src/auth/bootstrap.ts +72 -0
  13. package/src/browser/session.ts +43 -0
  14. package/src/capture/continuous-capture.ts +739 -0
  15. package/src/cli.ts +337 -0
  16. package/src/config/project.ts +183 -0
  17. package/src/github/comment.ts +134 -0
  18. package/src/index.ts +10 -0
  19. package/src/lib/data-uri.ts +21 -0
  20. package/src/lib/fs.ts +7 -0
  21. package/src/lib/git.ts +59 -0
  22. package/src/lib/media.ts +23 -0
  23. package/src/orchestrate.ts +166 -0
  24. package/src/planner/heuristic.ts +180 -0
  25. package/src/planner/index.ts +26 -0
  26. package/src/planner/llm.ts +85 -0
  27. package/src/planner/openai.ts +77 -0
  28. package/src/planner/prompt.ts +331 -0
  29. package/src/planner/refine.ts +155 -0
  30. package/src/planner/schema.ts +62 -0
  31. package/src/presentation/polish.ts +84 -0
  32. package/src/probe/page-probe.ts +225 -0
  33. package/src/render/browser-frame.ts +176 -0
  34. package/src/render/ffmpeg-compose.ts +779 -0
  35. package/src/render/visual-plan.ts +422 -0
  36. package/src/setup/doctor.ts +158 -0
  37. package/src/setup/init.ts +90 -0
  38. package/src/types.ts +105 -0
  39. package/src/voice/script.ts +42 -0
  40. package/src/voice/tts.ts +286 -0
  41. 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
+ };