demo-this-pr 0.1.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/dist/auto.js ADDED
@@ -0,0 +1,457 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import http from "node:http";
5
+ import https from "node:https";
6
+ import { basename, extname, join } from "node:path";
7
+ import { readGitContext } from "./git.js";
8
+ import { defaultRunOutputDir } from "./runs.js";
9
+ const COMMON_URLS = [
10
+ "http://127.0.0.1:3000",
11
+ "http://localhost:3000",
12
+ "http://127.0.0.1:5173",
13
+ "http://localhost:5173",
14
+ "http://127.0.0.1:3001",
15
+ "http://localhost:3001",
16
+ "http://127.0.0.1:4173",
17
+ "http://localhost:4173",
18
+ "http://127.0.0.1:4321",
19
+ "http://localhost:4321",
20
+ "http://127.0.0.1:8080",
21
+ "http://localhost:8080",
22
+ "http://127.0.0.1:8000",
23
+ "http://localhost:8000"
24
+ ];
25
+ export async function resolveAutoDemo(cwd, overrides) {
26
+ const git = await readGitContext(cwd);
27
+ if (!git.available) {
28
+ throw new Error(`No Git repository detected at ${cwd}. Run \`demo-this-pr\` from the repository with the local changes, or pass \`demo-this-pr --cwd <repo>\`.`);
29
+ }
30
+ const changedPaths = git.changedFiles.map((file) => file.path).filter(Boolean);
31
+ const notes = [];
32
+ let devServer;
33
+ let demoUrl;
34
+ const devCommand = await inferDevCommand(cwd);
35
+ if (devCommand) {
36
+ try {
37
+ notes.push(`Started dev server with: ${devCommand}`);
38
+ const started = await startDevServer(cwd, devCommand);
39
+ devServer = started.process;
40
+ demoUrl = started.url;
41
+ }
42
+ catch (error) {
43
+ notes.pop();
44
+ const message = error instanceof Error ? error.message : String(error);
45
+ notes.push(`Could not start dev server automatically: ${message}`);
46
+ }
47
+ }
48
+ if (!demoUrl) {
49
+ demoUrl = await findRunningUrl();
50
+ if (!demoUrl) {
51
+ throw new Error("No local app detected. Start your app or add a package.json dev script, then run `demo-this-pr` again.");
52
+ }
53
+ notes.push(`Using running local app: ${demoUrl}`);
54
+ }
55
+ const packageInfo = await readPackageInfo(cwd);
56
+ const runLabel = labelForRun(git.branch, packageInfo.name, cwd);
57
+ const startPath = inferStartPath(changedPaths);
58
+ const featureTitle = titleForAutoRun({
59
+ gitAvailable: git.available,
60
+ branch: git.branch,
61
+ commits: git.commits,
62
+ changedFiles: git.changedFiles
63
+ });
64
+ return {
65
+ options: {
66
+ cwd,
67
+ outputDir: overrides.output ?? defaultRunOutputDir(runLabel),
68
+ feature: featureTitle,
69
+ why: "Local pre-PR demo for the current unpushed changes.",
70
+ base: git.base,
71
+ flow: [
72
+ "Open the inferred feature entry point.",
73
+ "Show the current branch changes in isolated Chromium.",
74
+ "Record local-only review evidence before commit or PR."
75
+ ],
76
+ changes: inferChanges(changedPaths),
77
+ technologies: inferTechnologies(changedPaths, packageInfo),
78
+ testCommands: inferTestCommands(cwd, packageInfo),
79
+ runDemo: true,
80
+ headed: !overrides.headless,
81
+ showReport: overrides.open && !overrides.headless,
82
+ demoUrl,
83
+ demoSteps: [`goto:${startPath}`, "screenshot:current-changes"],
84
+ demoPlanPath: undefined
85
+ },
86
+ cleanup: async () => cleanupProcess(devServer),
87
+ notes
88
+ };
89
+ }
90
+ async function findRunningUrl() {
91
+ for (const url of COMMON_URLS) {
92
+ if (await canReach(url, 600)) {
93
+ return url;
94
+ }
95
+ }
96
+ return undefined;
97
+ }
98
+ async function inferDevCommand(cwd) {
99
+ const packageInfo = await readPackageInfo(cwd);
100
+ if (!packageInfo.scripts.dev) {
101
+ return undefined;
102
+ }
103
+ if (existsSync(join(cwd, "pnpm-lock.yaml"))) {
104
+ return "pnpm dev";
105
+ }
106
+ if (existsSync(join(cwd, "yarn.lock"))) {
107
+ return "yarn dev";
108
+ }
109
+ if (existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock"))) {
110
+ return "bun run dev";
111
+ }
112
+ return "npm run dev";
113
+ }
114
+ async function startDevServer(cwd, command) {
115
+ const child = spawn(command, {
116
+ cwd,
117
+ detached: process.platform !== "win32",
118
+ shell: true,
119
+ stdio: ["ignore", "pipe", "pipe"]
120
+ });
121
+ let output = "";
122
+ child.stdout.on("data", (chunk) => {
123
+ output += chunk.toString();
124
+ });
125
+ child.stderr.on("data", (chunk) => {
126
+ output += chunk.toString();
127
+ });
128
+ const startedAt = Date.now();
129
+ while (Date.now() - startedAt < 30_000) {
130
+ const url = extractUrl(output);
131
+ if (url && (await canReach(url, 900))) {
132
+ return { process: child, url };
133
+ }
134
+ if (child.exitCode !== null) {
135
+ throw new Error(`Dev server command exited before a URL was reachable: ${command}`);
136
+ }
137
+ await sleep(500);
138
+ }
139
+ await cleanupProcess(child);
140
+ throw new Error(`Timed out waiting for dev server URL after running: ${command}`);
141
+ }
142
+ async function cleanupProcess(child) {
143
+ if (!child || child.killed || child.exitCode !== null) {
144
+ return;
145
+ }
146
+ try {
147
+ if (process.platform !== "win32" && child.pid) {
148
+ process.kill(-child.pid, "SIGTERM");
149
+ }
150
+ else {
151
+ child.kill("SIGTERM");
152
+ }
153
+ }
154
+ catch {
155
+ child.kill("SIGTERM");
156
+ }
157
+ await Promise.race([
158
+ new Promise((resolve) => child.once("exit", () => resolve())),
159
+ sleep(1500)
160
+ ]);
161
+ }
162
+ async function readPackageInfo(cwd) {
163
+ try {
164
+ const parsed = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
165
+ return {
166
+ name: parsed.name,
167
+ scripts: parsed.scripts ?? {},
168
+ dependencies: parsed.dependencies ?? {},
169
+ devDependencies: parsed.devDependencies ?? {}
170
+ };
171
+ }
172
+ catch {
173
+ return { scripts: {}, dependencies: {}, devDependencies: {} };
174
+ }
175
+ }
176
+ function inferTestCommands(cwd, packageInfo) {
177
+ const scripts = packageInfo.scripts;
178
+ const commands = [];
179
+ const runner = packageManagerCommand(cwd);
180
+ if (scripts["test:ci"]) {
181
+ commands.push(`${runner} run test:ci`);
182
+ }
183
+ else if (scripts.test) {
184
+ const testScript = scripts.test;
185
+ if (/\bvitest\b/u.test(testScript)) {
186
+ commands.push(`${runner} test -- --run`);
187
+ }
188
+ else if (/\bjest\b/u.test(testScript)) {
189
+ commands.push(`${runner} test -- --watch=false`);
190
+ }
191
+ else if (!/\bwatch\b/u.test(testScript)) {
192
+ commands.push(`${runner} test`);
193
+ }
194
+ }
195
+ if (scripts.typecheck) {
196
+ commands.push(`${runner} run typecheck`);
197
+ }
198
+ if (scripts.lint) {
199
+ commands.push(`${runner} run lint`);
200
+ }
201
+ return commands.slice(0, 3);
202
+ }
203
+ function packageManagerCommand(cwd) {
204
+ if (existsSync(join(cwd, "pnpm-lock.yaml"))) {
205
+ return "pnpm";
206
+ }
207
+ if (existsSync(join(cwd, "yarn.lock"))) {
208
+ return "yarn";
209
+ }
210
+ if (existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock"))) {
211
+ return "bun";
212
+ }
213
+ return "npm";
214
+ }
215
+ function inferStartPath(paths) {
216
+ for (const path of paths) {
217
+ const nextAppMatch = path.match(/(?:^|\/)app\/(.+?)\/page\.(?:t|j)sx?$/u);
218
+ if (nextAppMatch?.[1]) {
219
+ return routeFromSegments(nextAppMatch[1]);
220
+ }
221
+ const pagesMatch = path.match(/(?:^|\/)pages\/(.+?)\.(?:t|j)sx?$/u);
222
+ if (pagesMatch?.[1]) {
223
+ return routeFromSegments(pagesMatch[1].replace(/\/index$/u, ""));
224
+ }
225
+ const svelteMatch = path.match(/(?:^|\/)src\/routes\/(.+?)\/\+page\.svelte$/u);
226
+ if (svelteMatch?.[1]) {
227
+ return routeFromSegments(svelteMatch[1]);
228
+ }
229
+ }
230
+ return "/";
231
+ }
232
+ function routeFromSegments(value) {
233
+ const route = value
234
+ .split("/")
235
+ .filter((part) => part && !part.startsWith("("))
236
+ .map((part) => part.replace(/^\[\.{3}(.+)\]$/u, "$1").replace(/^\[(.+)\]$/u, "$1"))
237
+ .join("/");
238
+ return route ? `/${route}` : "/";
239
+ }
240
+ function inferChanges(paths) {
241
+ if (paths.length === 0) {
242
+ return ["Current working tree changes are shown in the local demo."];
243
+ }
244
+ return summarizeByDirectory(paths).map((summary) => `Changes under ${summary}.`);
245
+ }
246
+ function inferTechnologies(paths, packageInfo) {
247
+ const technologies = new Set();
248
+ const deps = { ...packageInfo.dependencies, ...packageInfo.devDependencies };
249
+ for (const name of ["next", "react", "vite", "vue", "svelte", "astro", "nuxt", "remix", "@playwright/test", "typescript", "tailwindcss"]) {
250
+ if (deps[name]) {
251
+ technologies.add(name);
252
+ }
253
+ }
254
+ const extensions = new Set(paths.map((path) => extname(path)).filter(Boolean));
255
+ for (const extension of extensions) {
256
+ if (extension === ".tsx" || extension === ".ts")
257
+ technologies.add("TypeScript");
258
+ if (extension === ".jsx" || extension === ".js")
259
+ technologies.add("JavaScript");
260
+ if (extension === ".css" || extension === ".scss")
261
+ technologies.add("CSS");
262
+ }
263
+ return [...technologies].slice(0, 8);
264
+ }
265
+ function summarizeByDirectory(paths) {
266
+ const buckets = new Map();
267
+ for (const path of paths) {
268
+ const parts = path.split("/");
269
+ const key = parts.length > 1 ? parts.slice(0, 2).join("/") : basename(path);
270
+ buckets.set(key, (buckets.get(key) ?? 0) + 1);
271
+ }
272
+ return [...buckets.entries()]
273
+ .sort((first, second) => second[1] - first[1])
274
+ .slice(0, 5)
275
+ .map(([path, count]) => `${path}${count > 1 ? ` (${count} files)` : ""}`);
276
+ }
277
+ function labelForRun(branch, packageName, cwd) {
278
+ if (isMeaningfulLabel(branch)) {
279
+ return branch;
280
+ }
281
+ if (isMeaningfulLabel(packageName)) {
282
+ return packageName;
283
+ }
284
+ return basename(cwd) || "current-changes";
285
+ }
286
+ export function titleForAutoRun(input) {
287
+ if (input.gitAvailable === false) {
288
+ return "Git context unavailable";
289
+ }
290
+ return subjectFromChangedFiles(input.changedFiles)
291
+ ?? commitSubject(input.commits[0])
292
+ ?? subjectFromBranch(input.branch)
293
+ ?? "No local changes detected";
294
+ }
295
+ function commitSubject(commit) {
296
+ const subject = commit?.replace(/^[a-f0-9]{7,40}\s+/iu, "").trim();
297
+ return subject || undefined;
298
+ }
299
+ function subjectFromBranch(branch) {
300
+ if (!isMeaningfulFeatureBranch(branch)) {
301
+ return undefined;
302
+ }
303
+ const normalized = branch.replace(/^refs\/heads\//u, "");
304
+ const prefixed = normalized.match(/^(feat|feature|fix|bugfix|hotfix|chore|docs|doc|refactor|test|tests|perf|ci|build|style|release)[/_-](.+)$/iu);
305
+ if (prefixed) {
306
+ return branchSubject(prefixed[1], prefixed[2]);
307
+ }
308
+ return `Update ${humanizeSubject(normalized)}`;
309
+ }
310
+ function subjectFromChangedFiles(files) {
311
+ const changed = files.filter((file) => file.path);
312
+ if (changed.length === 0) {
313
+ return undefined;
314
+ }
315
+ const added = changed.filter(isAddedFile);
316
+ const removed = changed.filter(isRemovedFile);
317
+ const modified = changed.filter((file) => !isAddedFile(file) && !isRemovedFile(file));
318
+ const clauses = [];
319
+ const modifiedTopics = topicsForFiles(modified);
320
+ if (modifiedTopics.length > 0) {
321
+ clauses.push(`update ${formatTopicList(modifiedTopics)}`);
322
+ }
323
+ const addedTopics = topicsForFiles(added);
324
+ if (addedTopics.length > 0) {
325
+ clauses.push(`add ${formatTopicList(addedTopics)}`);
326
+ }
327
+ const removedTopics = topicsForFiles(removed);
328
+ if (removedTopics.length > 0) {
329
+ clauses.push(`remove ${formatTopicList(removedTopics)}`);
330
+ }
331
+ if (clauses.length === 0) {
332
+ return undefined;
333
+ }
334
+ return sentenceCase(formatTopicList(clauses));
335
+ }
336
+ function isAddedFile(file) {
337
+ const status = file.status.toUpperCase();
338
+ return status.includes("?") || status.includes("A");
339
+ }
340
+ function isRemovedFile(file) {
341
+ return file.status.toUpperCase().includes("D");
342
+ }
343
+ function topicsForFiles(files) {
344
+ const paths = files.map((file) => file.path);
345
+ const topics = [];
346
+ if (paths.some((path) => /(^|\/)CLAUDE\.md$/iu.test(path))) {
347
+ topics.push("CLAUDE.md for operational context");
348
+ }
349
+ if (paths.some((path) => /(^|\/)AGENTS\.md$/iu.test(path))) {
350
+ topics.push("AGENTS.md project memory");
351
+ }
352
+ if (paths.some((path) => /(^|\/)README\.md$/iu.test(path))) {
353
+ topics.push("README documentation");
354
+ }
355
+ if (paths.some((path) => /(^|\/)LICENSE$/iu.test(path))) {
356
+ topics.push("license metadata");
357
+ }
358
+ if (paths.some((path) => /\.(?:css|scss|sass|less)$/iu.test(path))) {
359
+ topics.push("styles");
360
+ }
361
+ if (paths.some((path) => /demo-this-pr-logo\.svg$/iu.test(path))) {
362
+ topics.push("README wordmark");
363
+ }
364
+ if (paths.length > 0 && paths.every((path) => /^test\//u.test(path) || /\.test\./u.test(path) || /\.spec\./u.test(path))) {
365
+ topics.push("test coverage");
366
+ }
367
+ if (paths.some((path) => /(^|\/)(package|tsconfig|vite|next|playwright|vitest|jest|eslint|prettier)\b/iu.test(path))) {
368
+ topics.push("project configuration");
369
+ }
370
+ if (!paths.some((path) => /\.(?:css|scss|sass|less)$/iu.test(path)) && paths.some((path) => /\.(?:html|tsx|jsx|vue|svelte)$/iu.test(path))) {
371
+ topics.push(uiTopic(paths));
372
+ }
373
+ if (topics.length === 0) {
374
+ topics.push(...summarizeByDirectory(paths).map(humanizeDirectorySummary));
375
+ }
376
+ return unique(topics).slice(0, 2);
377
+ }
378
+ function uiTopic(paths) {
379
+ const landing = paths.some((path) => /(^|\/)landing(\/|$)/iu.test(path));
380
+ return landing ? "landing page" : "UI views";
381
+ }
382
+ function humanizeDirectorySummary(value) {
383
+ return value
384
+ .replace(/\s+\(\d+ files\)$/u, "")
385
+ .replace(/\.[a-z0-9]+$/iu, "")
386
+ .replace(/[-_/]+/gu, " ")
387
+ .trim()
388
+ .toLowerCase() || "local changes";
389
+ }
390
+ function formatTopicList(values) {
391
+ if (values.length <= 1) {
392
+ return values[0] ?? "";
393
+ }
394
+ return `${values.slice(0, -1).join(", ")} and ${values.at(-1)}`;
395
+ }
396
+ function sentenceCase(value) {
397
+ return value.charAt(0).toUpperCase() + value.slice(1);
398
+ }
399
+ function unique(values) {
400
+ return [...new Set(values)];
401
+ }
402
+ function branchSubject(type, value) {
403
+ const subject = humanizeSubject(value);
404
+ const normalized = type.toLowerCase();
405
+ if (normalized === "fix" || normalized === "bugfix" || normalized === "hotfix") {
406
+ return `Fix ${subject}`;
407
+ }
408
+ if (normalized === "docs" || normalized === "doc") {
409
+ return `Update documentation for ${subject}`;
410
+ }
411
+ if (normalized === "test" || normalized === "tests") {
412
+ return `Update tests for ${subject}`;
413
+ }
414
+ if (normalized === "refactor") {
415
+ return `Refactor ${subject}`;
416
+ }
417
+ if (normalized === "release") {
418
+ return `Prepare ${subject} release`;
419
+ }
420
+ return `Update ${subject}`;
421
+ }
422
+ function humanizeSubject(value) {
423
+ return value
424
+ .replace(/^[a-z]+\/+/iu, "")
425
+ .replace(/[_/]+/gu, "-")
426
+ .split(/-+/u)
427
+ .filter(Boolean)
428
+ .join(" ")
429
+ .toLowerCase() || "review local changes";
430
+ }
431
+ function isMeaningfulLabel(value) {
432
+ return Boolean(value && value !== "unknown" && value !== "HEAD");
433
+ }
434
+ function isMeaningfulFeatureBranch(value) {
435
+ return Boolean(isMeaningfulLabel(value) && !/^(main|master|trunk|develop|dev)$/iu.test(value));
436
+ }
437
+ function extractUrl(output) {
438
+ return output.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s]*)?/u)?.[0]?.replace(/\/$/u, "");
439
+ }
440
+ function canReach(url, timeoutMs) {
441
+ return new Promise((resolve) => {
442
+ const client = url.startsWith("https:") ? https : http;
443
+ const request = client.request(url, { method: "GET", timeout: timeoutMs }, (response) => {
444
+ response.resume();
445
+ resolve((response.statusCode ?? 0) < 500);
446
+ });
447
+ request.on("timeout", () => {
448
+ request.destroy();
449
+ resolve(false);
450
+ });
451
+ request.on("error", () => resolve(false));
452
+ request.end();
453
+ });
454
+ }
455
+ function sleep(ms) {
456
+ return new Promise((resolve) => setTimeout(resolve, ms));
457
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { resolve } from "node:path";
4
+ import pc from "picocolors";
5
+ import { resolveAutoDemo } from "./auto.js";
6
+ import { createPrKit } from "./prKit.js";
7
+ import { parseDemoStep } from "./demoPlan.js";
8
+ import { defaultRunOutputDir } from "./runs.js";
9
+ import { openReportViewer } from "./viewer.js";
10
+ const program = new Command();
11
+ program
12
+ .name("demo-this-pr")
13
+ .description("Generate reviewable PR demos from current unpushed changes.")
14
+ .version("0.1.0")
15
+ .argument("[repo]", "repository path; defaults to the current working directory")
16
+ .option("--cwd <path>", "repository path; useful when running from outside the target repo")
17
+ .option("--headless", "run Chromium without a visible window", false)
18
+ .option("--no-open", "do not open the generated report viewer")
19
+ .option("--output <dir>", "output directory; defaults to a unique .demo-this-pr/runs/<run-id> directory")
20
+ .action(async (repo, options) => {
21
+ let cleanup;
22
+ try {
23
+ const targetCwd = resolve(options.cwd ?? repo ?? process.cwd());
24
+ const autoDemo = await resolveAutoDemo(targetCwd, {
25
+ headless: options.headless ?? false,
26
+ output: options.output,
27
+ open: options.open ?? true
28
+ });
29
+ cleanup = autoDemo.cleanup;
30
+ await runResolvedKitCommand(autoDemo.options, "Demo", autoDemo.notes);
31
+ }
32
+ catch (error) {
33
+ const message = error instanceof Error ? error.message : String(error);
34
+ process.stderr.write(`${pc.red("demo-this-pr:")} ${message}\n`);
35
+ process.exitCode = 1;
36
+ }
37
+ finally {
38
+ await cleanup?.();
39
+ }
40
+ });
41
+ program
42
+ .command("ci")
43
+ .description("Run the automatic demo flow for CI and agent runners")
44
+ .argument("[repo]", "repository path; defaults to the current working directory")
45
+ .option("--cwd <path>", "repository path; useful when running from outside the target repo")
46
+ .option("--output <dir>", "output directory", ".demo-this-pr/ci")
47
+ .option("--headed", "run Chromium with a visible window", false)
48
+ .action(async (repo, options) => {
49
+ let cleanup;
50
+ try {
51
+ const targetCwd = resolve(options.cwd ?? repo ?? process.cwd());
52
+ const autoDemo = await resolveAutoDemo(targetCwd, {
53
+ headless: !(options.headed ?? false),
54
+ output: options.output,
55
+ open: false
56
+ });
57
+ cleanup = autoDemo.cleanup;
58
+ await runResolvedKitCommand(autoDemo.options, "CI demo", autoDemo.notes);
59
+ }
60
+ catch (error) {
61
+ const message = error instanceof Error ? error.message : String(error);
62
+ process.stderr.write(`${pc.red("demo-this-pr:")} ${message}\n`);
63
+ process.exitCode = 1;
64
+ }
65
+ finally {
66
+ await cleanup?.();
67
+ }
68
+ });
69
+ program
70
+ .command("demo")
71
+ .description("Launch a local Chromium demo for the current changes and write the PR kit")
72
+ .option("--cwd <path>", "working directory", process.cwd())
73
+ .option("--output <dir>", "output directory; defaults to a unique .demo-this-pr/runs/<run-id> directory")
74
+ .option("--feature <text>", "feature name or PR title")
75
+ .option("--why <text>", "reason this change exists")
76
+ .option("--base <branch>", "base branch for git diff")
77
+ .option("--flow <step>", "review flow step; repeatable", collect, [])
78
+ .option("--change <text>", "user-visible change to show in the demo; repeatable", collect, [])
79
+ .option("--tech <text>", "technology or implementation note to show in the demo; repeatable", collect, [])
80
+ .option("--test <cmd>", "test command to run and capture; repeatable", collect, [])
81
+ .option("--url <url>", "local app URL to open in Chromium")
82
+ .option("--demo-url <url>", "alias for --url")
83
+ .option("--step <step>", "compact demo step; repeatable", collect, [])
84
+ .option("--demo-step <step>", "alias for --step; repeatable", collect, [])
85
+ .option("--demo-plan <path>", "JSON demo plan path")
86
+ .option("--headless", "run Chromium without a visible window", false)
87
+ .option("--no-open", "do not open the generated report viewer")
88
+ .action(async (options) => {
89
+ const demoUrl = options.url ?? options.demoUrl;
90
+ if (!demoUrl && !options.demoPlan) {
91
+ process.stderr.write(`${pc.red("demo-this-pr:")} demo needs --url <local-app-url> or --demo-plan <path>\n`);
92
+ process.exitCode = 1;
93
+ return;
94
+ }
95
+ await runKitCommand({
96
+ ...options,
97
+ demoUrl,
98
+ demoStep: [...(options.demoStep ?? []), ...(options.step ?? [])],
99
+ runDemo: true
100
+ }, "Demo");
101
+ });
102
+ program
103
+ .command("pr")
104
+ .description("Create a PR review kit for the current branch")
105
+ .option("--cwd <path>", "working directory", process.cwd())
106
+ .option("--output <dir>", "output directory; defaults to a unique .demo-this-pr/runs/<run-id> directory")
107
+ .option("--feature <text>", "feature name or PR title")
108
+ .option("--why <text>", "reason this PR exists")
109
+ .option("--base <branch>", "base branch for git diff")
110
+ .option("--flow <step>", "review flow step; repeatable", collect, [])
111
+ .option("--change <text>", "user-visible change to mention in the PR kit; repeatable", collect, [])
112
+ .option("--tech <text>", "technology or implementation note to mention in the PR kit; repeatable", collect, [])
113
+ .option("--test <cmd>", "test command to run and capture; repeatable", collect, [])
114
+ .option("--demo-url <url>", "local app URL used by generated Playwright demo")
115
+ .option("--demo-step <step>", "compact demo step; repeatable", collect, [])
116
+ .option("--demo-plan <path>", "JSON demo plan path")
117
+ .option("--run-demo", "run generated Playwright demo and record video", false)
118
+ .option("--headless", "run Chromium without a visible window when --run-demo is used", false)
119
+ .option("--no-open", "do not open the generated report viewer")
120
+ .action(async (options) => runKitCommand(options, "PR kit"));
121
+ program
122
+ .command("init-demo")
123
+ .description("Write a JSON demo plan template")
124
+ .option("--output <path>", "demo plan path", "demo-this-pr.demo.json")
125
+ .action(async (options) => {
126
+ const { writeFile } = await import("node:fs/promises");
127
+ const template = {
128
+ title: "Feature demo",
129
+ baseUrl: "http://localhost:3000",
130
+ why: "Explain the behavior this PR introduces or protects.",
131
+ steps: [
132
+ { caption: "Open the feature entry point", goto: "/" },
133
+ { caption: "Interact with the changed UI", click: "text=New" },
134
+ { caption: "Verify the expected state", expectText: "Created", screenshot: "expected-state" }
135
+ ]
136
+ };
137
+ await writeFile(options.output, `${JSON.stringify(template, null, 2)}\n`, "utf8");
138
+ process.stdout.write(`Demo plan written to ${options.output}\n`);
139
+ });
140
+ program.parseAsync();
141
+ async function runKitCommand(options, label) {
142
+ try {
143
+ const demoSteps = options.demoStep ?? [];
144
+ for (const step of demoSteps) {
145
+ parseDemoStep(step);
146
+ }
147
+ const resolvedOptions = {
148
+ cwd: options.cwd,
149
+ outputDir: options.output ?? defaultRunOutputDir(options.feature),
150
+ feature: options.feature,
151
+ why: options.why,
152
+ base: options.base,
153
+ flow: options.flow ?? [],
154
+ changes: options.change ?? [],
155
+ technologies: options.tech ?? [],
156
+ testCommands: options.test ?? [],
157
+ runDemo: options.runDemo ?? false,
158
+ headed: !options.headless,
159
+ showReport: (options.open ?? true) && !options.headless,
160
+ demoUrl: options.demoUrl,
161
+ demoSteps,
162
+ demoPlanPath: options.demoPlan
163
+ };
164
+ await runResolvedKitCommand(resolvedOptions, label);
165
+ }
166
+ catch (error) {
167
+ const message = error instanceof Error ? error.message : String(error);
168
+ process.stderr.write(`${pc.red("demo-this-pr:")} ${message}\n`);
169
+ process.exitCode = 1;
170
+ }
171
+ }
172
+ async function runResolvedKitCommand(options, label, notes = []) {
173
+ const result = await createPrKit(options);
174
+ printResult(result, label, notes);
175
+ if (options.showReport && result.demoRun.videoPath) {
176
+ process.stdout.write(`${pc.dim("note:")} opening report viewer; close the Chromium window to finish\n`);
177
+ await openReportViewer(result.htmlPath);
178
+ }
179
+ }
180
+ function printResult(result, label, notes) {
181
+ process.stdout.write(`${pc.green(`${label} generated`)}\n`);
182
+ for (const note of notes) {
183
+ process.stdout.write(`${pc.dim("note:")} ${note}\n`);
184
+ }
185
+ process.stdout.write(`Markdown: ${result.markdownPath}\n`);
186
+ process.stdout.write(`HTML: ${result.htmlPath}\n`);
187
+ process.stdout.write(`MCP: ${result.mcpGuidePath}\n`);
188
+ process.stdout.write(`Demo: ${result.demoSpecPath}\n`);
189
+ if (result.demoRun.attempted && result.demoRun.passed) {
190
+ process.stdout.write(`${pc.green("Chromium demo passed")}\n`);
191
+ if (result.demoRun.videoPath) {
192
+ process.stdout.write(`Video: ${result.outputDir}/${result.demoRun.videoPath}\n`);
193
+ }
194
+ }
195
+ if (result.demoRun.attempted && !result.demoRun.passed) {
196
+ process.stdout.write(`${pc.yellow("Demo needs attention:")} ${result.demoRun.error ?? "Playwright failed"}\n`);
197
+ }
198
+ }
199
+ function collect(value, previous) {
200
+ return [...previous, value];
201
+ }
@@ -0,0 +1,10 @@
1
+ import type { DemoPlan, DemoStep } from "./types.js";
2
+ export declare function buildDemoPlan(input: {
3
+ feature: string;
4
+ why: string;
5
+ demoUrl?: string;
6
+ demoSteps: string[];
7
+ demoPlanPath?: string;
8
+ }): Promise<DemoPlan>;
9
+ export declare function readDemoPlan(path: string): Promise<DemoPlan>;
10
+ export declare function parseDemoStep(step: string): DemoStep;