openuispec 0.2.19 → 0.2.20

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 (38) hide show
  1. package/dist/check/audit.js +392 -0
  2. package/dist/check/index.js +216 -0
  3. package/dist/cli/configure-target.js +391 -0
  4. package/dist/cli/index.js +510 -0
  5. package/dist/cli/init.js +1047 -0
  6. package/dist/drift/index.js +903 -0
  7. package/dist/mcp-server/index.js +886 -0
  8. package/dist/mcp-server/preview-render.js +1761 -0
  9. package/dist/mcp-server/preview.js +233 -0
  10. package/dist/mcp-server/screenshot-android.js +458 -0
  11. package/dist/mcp-server/screenshot-ios.js +639 -0
  12. package/dist/mcp-server/screenshot-shared.js +180 -0
  13. package/dist/mcp-server/screenshot.js +459 -0
  14. package/dist/prepare/index.js +1216 -0
  15. package/dist/runtime/package-paths.js +33 -0
  16. package/dist/schema/semantic-lint.js +564 -0
  17. package/dist/schema/validate.js +689 -0
  18. package/dist/status/index.js +194 -0
  19. package/package.json +12 -13
  20. package/check/audit.ts +0 -426
  21. package/check/index.ts +0 -320
  22. package/cli/configure-target.ts +0 -523
  23. package/cli/index.ts +0 -537
  24. package/cli/init.ts +0 -1253
  25. package/drift/index.ts +0 -1165
  26. package/mcp-server/index.ts +0 -1041
  27. package/mcp-server/preview-render.ts +0 -1922
  28. package/mcp-server/preview.ts +0 -292
  29. package/mcp-server/screenshot-android.ts +0 -621
  30. package/mcp-server/screenshot-ios.ts +0 -753
  31. package/mcp-server/screenshot-shared.ts +0 -237
  32. package/mcp-server/screenshot.ts +0 -563
  33. package/prepare/index.ts +0 -1530
  34. package/schema/semantic-lint.ts +0 -692
  35. package/schema/validate.ts +0 -870
  36. package/scripts/regenerate-previews.ts +0 -136
  37. package/scripts/take-all-screenshots.ts +0 -507
  38. package/status/index.ts +0 -275
@@ -1,563 +0,0 @@
1
- /**
2
- * Screenshot tool — launches dev server + headless browser, captures pages.
3
- *
4
- * Both the Vite dev server and the Puppeteer browser are kept alive between
5
- * calls and torn down when the MCP server process exits.
6
- */
7
-
8
- import { spawn, type ChildProcess, execSync } from "node:child_process";
9
- import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
10
- import { join, resolve } from "node:path";
11
- import { createConnection } from "node:net";
12
- import YAML from "yaml";
13
- import { findProjectDir } from "../drift/index.js";
14
- import { getBrowser, closeBrowser, type ScreenshotResult } from "./screenshot-shared.js";
15
-
16
- // ── types ───────────────────────────────────────────────────────────
17
-
18
- export interface ScreenshotOptions {
19
- route: string;
20
- viewport?: { width: number; height: number };
21
- scale?: number;
22
- theme?: "light" | "dark";
23
- wait_for?: number;
24
- full_page?: boolean;
25
- selector?: string;
26
- output_dir?: string;
27
- init_script?: string;
28
- }
29
-
30
- // ── framework config table ───────────────────────────────────────────
31
-
32
- interface FrameworkConfig {
33
- /** Typed discriminant for branching logic */
34
- kind: "node" | "bun" | "deno" | "python-django" | "python-flask" | "ruby" | "php" | "go" | "rust" | "java";
35
- /** Human-readable name */
36
- name: string;
37
- /** Files whose presence identifies this framework (checked in webDir) */
38
- indicators: string[];
39
- /** Default dev port when none is configured */
40
- defaultPort: number;
41
- /** Command + args to start the dev server; PORT placeholder replaced at runtime */
42
- devCommand: string[];
43
- /** Install command to run when dependencies are missing (null = skip) */
44
- installCommand: string[] | null;
45
- /** Package manager env var used to set the port (e.g. PORT, APP_PORT) */
46
- portEnvVar: string;
47
- }
48
-
49
- // Ordered by detection priority (most specific first)
50
- const FRAMEWORKS: FrameworkConfig[] = [
51
- {
52
- kind: "bun",
53
- name: "Bun",
54
- indicators: ["bun.lockb", "bun.lock"],
55
- defaultPort: 3000,
56
- devCommand: ["bun", "run", "dev"],
57
- installCommand: ["bun", "install"],
58
- portEnvVar: "PORT",
59
- },
60
- {
61
- kind: "deno",
62
- name: "Deno",
63
- indicators: ["deno.json", "deno.jsonc"],
64
- defaultPort: 8000,
65
- devCommand: ["deno", "task", "dev"],
66
- installCommand: null,
67
- portEnvVar: "PORT",
68
- },
69
- {
70
- kind: "node",
71
- name: "Node.js (npm/yarn/pnpm)",
72
- indicators: ["package.json"],
73
- defaultPort: 3000,
74
- devCommand: ["npm", "run", "dev"],
75
- installCommand: ["npm", "install"],
76
- portEnvVar: "PORT",
77
- },
78
- {
79
- kind: "python-django",
80
- name: "Django",
81
- indicators: ["manage.py"],
82
- defaultPort: 8000,
83
- devCommand: ["python", "manage.py", "runserver", "PORT"],
84
- installCommand: null,
85
- portEnvVar: "PORT",
86
- },
87
- {
88
- kind: "python-flask",
89
- name: "Flask / FastAPI",
90
- indicators: ["requirements.txt", "Pipfile", "pyproject.toml"],
91
- defaultPort: 5000,
92
- devCommand: ["python", "-m", "flask", "run", "--port", "PORT"],
93
- installCommand: null,
94
- portEnvVar: "PORT",
95
- },
96
- {
97
- kind: "ruby",
98
- name: "Ruby on Rails",
99
- indicators: ["Gemfile", "config/application.rb"],
100
- defaultPort: 3000,
101
- devCommand: ["bin/rails", "server", "-p", "PORT"],
102
- installCommand: ["bundle", "install"],
103
- portEnvVar: "PORT",
104
- },
105
- {
106
- kind: "php",
107
- name: "PHP (Laravel)",
108
- indicators: ["artisan"],
109
- defaultPort: 8000,
110
- devCommand: ["php", "artisan", "serve", "--port=PORT"],
111
- installCommand: null,
112
- portEnvVar: "PORT",
113
- },
114
- {
115
- kind: "go",
116
- name: "Go",
117
- indicators: ["go.mod"],
118
- defaultPort: 8080,
119
- devCommand: ["go", "run", "."],
120
- installCommand: null,
121
- portEnvVar: "PORT",
122
- },
123
- {
124
- kind: "rust",
125
- name: "Rust (Trunk)",
126
- indicators: ["Trunk.toml", "Cargo.toml"],
127
- defaultPort: 8080,
128
- devCommand: ["trunk", "serve", "--port", "PORT"],
129
- installCommand: null,
130
- portEnvVar: "PORT",
131
- },
132
- {
133
- kind: "java",
134
- name: "Java / Spring Boot",
135
- indicators: ["pom.xml", "build.gradle", "build.gradle.kts"],
136
- defaultPort: 8080,
137
- devCommand: ["./mvnw", "spring-boot:run"],
138
- installCommand: null,
139
- portEnvVar: "SERVER_PORT",
140
- },
141
- ];
142
-
143
- // ── framework detection ──────────────────────────────────────────────
144
-
145
- function detectFramework(webDir: string): FrameworkConfig {
146
- for (const fw of FRAMEWORKS) {
147
- if (fw.indicators.some((f) => existsSync(join(webDir, f)))) return fw;
148
- }
149
- // Fallback: generic Node
150
- return FRAMEWORKS.find((f) => f.kind === "node")!;
151
- }
152
-
153
- // ── port resolution ──────────────────────────────────────────────────
154
-
155
- /** Parse --port / -p / --port=N from a script string. */
156
- function parsePortFromScript(script: string): number | null {
157
- const m = script.match(/(?:--port|-p)[=\s]+(\d+)/);
158
- return m ? parseInt(m[1], 10) : null;
159
- }
160
-
161
- /** Read PORT (or custom var) from .env.local / .env.development / .env. */
162
- function readEnvPort(webDir: string, varName = "PORT"): number | null {
163
- for (const name of [".env.local", ".env.development", ".env"]) {
164
- const envPath = join(webDir, name);
165
- if (!existsSync(envPath)) continue;
166
- try {
167
- const re = new RegExp(`^\\s*${varName}\\s*=\\s*(\\d+)`, "m");
168
- const m = readFileSync(envPath, "utf-8").match(re);
169
- if (m) return parseInt(m[1], 10);
170
- } catch { /* skip */ }
171
- }
172
- return null;
173
- }
174
-
175
- /** Resolve the port this project's dev server will use. */
176
- function resolvePort(webDir: string, fw: FrameworkConfig): number {
177
- // 1. .env files
178
- const envPort = readEnvPort(webDir, fw.portEnvVar);
179
- if (envPort) return envPort;
180
-
181
- // 2. package.json dev/start script (Node-like only)
182
- if (existsSync(join(webDir, "package.json"))) {
183
- try {
184
- const pkg = JSON.parse(readFileSync(join(webDir, "package.json"), "utf-8"));
185
- const scripts: Record<string, string> = pkg.scripts ?? {};
186
- for (const name of ["dev", "start", "serve", "develop"]) {
187
- if (scripts[name]) {
188
- const p = parsePortFromScript(scripts[name]);
189
- if (p) return p;
190
- break;
191
- }
192
- }
193
- } catch { /* ignore */ }
194
- }
195
-
196
- // 3. Framework default
197
- return fw.defaultPort;
198
- }
199
-
200
- function isPortListening(port: number, host = "127.0.0.1"): Promise<boolean> {
201
- return new Promise((resolve) => {
202
- const socket = createConnection({ port, host });
203
- socket.setTimeout(500);
204
- socket.on("connect", () => { socket.destroy(); resolve(true); });
205
- socket.on("timeout", () => { socket.destroy(); resolve(false); });
206
- socket.on("error", () => resolve(false));
207
- });
208
- }
209
-
210
- // ── web app directory discovery ─────────────────────────────────────
211
-
212
- export function findWebAppDir(projectCwd: string): string {
213
- const projectDir = findProjectDir(projectCwd);
214
- const manifestPath = join(projectDir, "openuispec.yaml");
215
- const manifest = YAML.parse(readFileSync(manifestPath, "utf-8"));
216
- const projectName = manifest.project?.name ?? "app";
217
-
218
- // Derive indicators from the FRAMEWORKS table so they stay in sync
219
- const isWebDir = (d: string) =>
220
- FRAMEWORKS.some((fw) => fw.indicators.some((f) => existsSync(join(d, f))));
221
-
222
- // Check custom output_dir first
223
- const customDir = manifest.generation?.output_dir?.web;
224
- if (customDir) {
225
- const resolved = resolve(projectDir, customDir);
226
- if (isWebDir(resolved)) return resolved;
227
- }
228
-
229
- // Default: generated/web/<project-name>/
230
- // Try from the project root (parent of openuispec/)
231
- const projectRoot = resolve(projectDir, "..");
232
- const defaultDir = join(projectRoot, "generated", "web", projectName);
233
- if (isWebDir(defaultDir)) return defaultDir;
234
-
235
- throw new Error(
236
- `Web app not found. Checked:\n` +
237
- (customDir ? ` - ${resolve(projectDir, customDir)}\n` : "") +
238
- ` - ${defaultDir}\n` +
239
- `Generate the web target first, then try again.`,
240
- );
241
- }
242
-
243
- // ── dev server manager ──────────────────────────────────────────────
244
-
245
- interface ServerInstance {
246
- process: ChildProcess | null; // null = using an externally running server
247
- port: number;
248
- url: string;
249
- }
250
-
251
- const servers = new Map<string, ServerInstance>();
252
-
253
- function ensureDepsInstalled(webDir: string, fw: FrameworkConfig): void {
254
- if (!fw.installCommand) return;
255
- // For Node.js check node_modules; for others always run
256
- if (fw.kind === "node" && existsSync(join(webDir, "node_modules"))) return;
257
- try {
258
- execSync(fw.installCommand.join(" "), { cwd: webDir, stdio: "pipe", timeout: 120_000 });
259
- } catch (err) {
260
- throw new Error(`Failed to install dependencies in ${webDir}: ${err instanceof Error ? err.message : err}`);
261
- }
262
- }
263
-
264
- const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
265
-
266
- /** Poll until the port accepts connections, or the timeout expires. */
267
- async function waitForPort(port: number, timeoutMs = 60_000): Promise<boolean> {
268
- const deadline = Date.now() + timeoutMs;
269
- while (Date.now() < deadline) {
270
- if (await isPortListening(port)) return true;
271
- await new Promise((r) => setTimeout(r, 500));
272
- }
273
- return false;
274
- }
275
-
276
- async function startDevServer(webDir: string): Promise<ServerInstance> {
277
- const existing = servers.get(webDir);
278
- if (existing) {
279
- const alive = existing.process === null
280
- ? await isPortListening(existing.port) // external server
281
- : existing.process.exitCode === null; // managed process
282
- if (alive) return existing;
283
- servers.delete(webDir);
284
- }
285
-
286
- const fw = detectFramework(webDir);
287
- const port = resolvePort(webDir, fw);
288
-
289
- // Always prefer an already-running server on the expected port
290
- if (await isPortListening(port)) {
291
- const instance: ServerInstance = { process: null, port, url: `http://localhost:${port}` };
292
- servers.set(webDir, instance);
293
- return instance;
294
- }
295
-
296
- // Start the dev server for this framework
297
- ensureDepsInstalled(webDir, fw);
298
-
299
- // Build command: replace "PORT" placeholder with actual port string
300
- const [cmd, ...args] = fw.devCommand.map((part) => part === "PORT" ? String(port) : part);
301
-
302
- // For Node.js, use the project's own dev script from package.json if available
303
- let spawnCmd = cmd;
304
- let spawnArgs = args;
305
- if (fw.kind === "node" && existsSync(join(webDir, "package.json"))) {
306
- try {
307
- const pkg = JSON.parse(readFileSync(join(webDir, "package.json"), "utf-8"));
308
- const scripts: Record<string, string> = pkg.scripts ?? {};
309
- const scriptName = ["dev", "start", "serve", "develop"].find((n) => n in scripts);
310
- if (scriptName) {
311
- spawnCmd = "npm";
312
- spawnArgs = ["run", scriptName];
313
- }
314
- } catch { /* ignore */ }
315
- }
316
-
317
- const child = spawn(spawnCmd, spawnArgs, {
318
- cwd: webDir,
319
- stdio: ["ignore", "pipe", "pipe"],
320
- env: { ...process.env, FORCE_COLOR: "0", BROWSER: "none", [fw.portEnvVar]: String(port) },
321
- });
322
-
323
- // Collect stderr from the start so we have output for error messages
324
- let stderr = "";
325
- child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
326
-
327
- // Wait for the port to open (framework-agnostic — no stdout parsing needed)
328
- const ready = await waitForPort(port, 60_000);
329
-
330
- if (!ready) {
331
- child.kill();
332
- throw new Error(
333
- `${fw.name} dev server did not open port ${port} within 60s.\n` +
334
- (stderr ? `stderr:\n${stripAnsi(stderr).slice(-500)}` : ""),
335
- );
336
- }
337
-
338
- const instance: ServerInstance = { process: child, port, url: `http://localhost:${port}` };
339
- servers.set(webDir, instance);
340
- return instance;
341
- }
342
-
343
- // ── browser manager (imported from screenshot-shared.ts) ────────────
344
-
345
- // ── init_script URL injection ────────────────────────────────────────
346
-
347
- /** Append ?__ous_init=<base64> to a URL, respecting existing query params. */
348
- function appendInitParam(targetUrl: string, initScript: string): string {
349
- const url = new URL(targetUrl);
350
- url.searchParams.set("__ous_init", Buffer.from(initScript).toString("base64"));
351
- return url.toString();
352
- }
353
-
354
- // ── screenshot capture ──────────────────────────────────────────────
355
-
356
- export async function takeScreenshot(
357
- projectCwd: string,
358
- options: ScreenshotOptions,
359
- ): Promise<ScreenshotResult> {
360
- const {
361
- route = "/",
362
- viewport = { width: 1280, height: 800 },
363
- scale = 2,
364
- theme,
365
- wait_for = 1000,
366
- full_page = false,
367
- selector,
368
- output_dir,
369
- init_script,
370
- } = options;
371
-
372
- // 1. Find and start
373
- const webDir = findWebAppDir(projectCwd);
374
- const server = await startDevServer(webDir);
375
- const browser = await getBrowser();
376
-
377
- // 2. Navigate
378
- const page = await browser.newPage();
379
- try {
380
- await page.setViewport({
381
- width: viewport.width,
382
- height: viewport.height,
383
- deviceScaleFactor: scale,
384
- });
385
-
386
- if (theme) {
387
- await page.emulateMediaFeatures([
388
- { name: "prefers-color-scheme", value: theme },
389
- ]);
390
- }
391
-
392
- const base = server.url.replace(/\/+$/, "");
393
- let targetUrl = `${base}${route.startsWith("/") ? "" : "/"}${route}`;
394
- if (init_script) targetUrl = appendInitParam(targetUrl, init_script);
395
- await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
396
-
397
- if (wait_for > 0) {
398
- await new Promise((r) => setTimeout(r, wait_for));
399
- }
400
-
401
- // 3. Screenshot
402
- let buffer: Buffer;
403
- if (selector) {
404
- const element = await page.$(selector);
405
- if (!element) {
406
- return {
407
- content: [{ type: "text", text: `Error: Element not found for selector: ${selector}` }],
408
- isError: true,
409
- };
410
- }
411
- buffer = await element.screenshot({ type: "png" });
412
- } else {
413
- buffer = await page.screenshot({ type: "png", fullPage: full_page });
414
- }
415
-
416
- const base64 = buffer.toString("base64");
417
-
418
- // Save to output_dir if specified
419
- let savedPath: string | undefined;
420
- if (output_dir) {
421
- const outDir = resolve(webDir, output_dir);
422
- mkdirSync(outDir, { recursive: true });
423
- const routeSlug = route.replace(/^\//, "").replace(/\//g, "_") || "index";
424
- const themeLabel = theme ?? "default";
425
- savedPath = join(outDir, `${routeSlug}_${themeLabel}.png`);
426
- writeFileSync(savedPath, buffer);
427
- }
428
-
429
- return {
430
- content: [
431
- { type: "image" as const, data: base64, mimeType: "image/png" },
432
- {
433
- type: "text" as const,
434
- text: JSON.stringify({
435
- route,
436
- url: targetUrl,
437
- viewport,
438
- scale,
439
- theme: theme ?? "default",
440
- full_page,
441
- selector: selector ?? null,
442
- path: savedPath ?? null,
443
- init_script: init_script ?? null,
444
- }, null, 2),
445
- },
446
- ],
447
- };
448
- } finally {
449
- await page.close();
450
- }
451
- }
452
-
453
- // ── batch types ──────────────────────────────────────────────────────
454
-
455
- export interface WebBatchCapture {
456
- screen: string;
457
- route: string;
458
- selector?: string;
459
- full_page?: boolean;
460
- wait_for?: number;
461
- init_script?: string;
462
- }
463
-
464
- export interface WebScreenshotBatchOptions {
465
- captures: WebBatchCapture[];
466
- viewport?: { width: number; height: number };
467
- scale?: number;
468
- theme?: "light" | "dark";
469
- output_dir?: string;
470
- init_script?: string;
471
- }
472
-
473
- // ── batch screenshot ─────────────────────────────────────────────────
474
-
475
- export async function takeScreenshotBatch(
476
- projectCwd: string,
477
- options: WebScreenshotBatchOptions,
478
- ): Promise<ScreenshotResult> {
479
- const { captures, viewport = { width: 1280, height: 800 }, scale = 2, theme, output_dir, init_script: sharedInitScript } = options;
480
-
481
- if (captures.length === 0) {
482
- return { content: [{ type: "text", text: "No web captures specified." }], isError: true };
483
- }
484
-
485
- const webDir = findWebAppDir(projectCwd);
486
- const server = await startDevServer(webDir);
487
- const browser = await getBrowser();
488
- const page = await browser.newPage();
489
-
490
- try {
491
- await page.setViewport({
492
- width: viewport.width,
493
- height: viewport.height,
494
- deviceScaleFactor: scale,
495
- });
496
- if (theme) {
497
- await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: theme }]);
498
- }
499
-
500
- const base = server.url.replace(/\/+$/, "");
501
- const themeLabel = theme ?? "default";
502
- const snapshots: Array<{ screen: string; path: string; data: string; init_script?: string }> = [];
503
-
504
- for (const capture of captures) {
505
- const effectiveInitScript = capture.init_script ?? sharedInitScript;
506
- let targetUrl = `${base}${capture.route.startsWith("/") ? "" : "/"}${capture.route}`;
507
- if (effectiveInitScript) targetUrl = appendInitParam(targetUrl, effectiveInitScript);
508
- await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
509
- await new Promise((r) => setTimeout(r, capture.wait_for ?? 1000));
510
-
511
- let buffer: Buffer;
512
- if (capture.selector) {
513
- const el = await page.$(capture.selector);
514
- buffer = el ? await el.screenshot({ type: "png" }) : await page.screenshot({ type: "png" });
515
- } else {
516
- buffer = await page.screenshot({ type: "png", fullPage: capture.full_page ?? false });
517
- }
518
-
519
- const filename = `${capture.screen}_${themeLabel}.png`;
520
- let savedPath = filename;
521
- if (output_dir) {
522
- const outDir = resolve(webDir, output_dir);
523
- mkdirSync(outDir, { recursive: true });
524
- savedPath = join(outDir, filename);
525
- writeFileSync(savedPath, buffer);
526
- }
527
-
528
- snapshots.push({ screen: capture.screen, path: savedPath, data: buffer.toString("base64"), init_script: effectiveInitScript });
529
- }
530
-
531
- const content: ScreenshotResult["content"] = [];
532
- for (const s of snapshots) {
533
- content.push({ type: "image" as const, data: s.data, mimeType: "image/png" });
534
- content.push({
535
- type: "text" as const,
536
- text: JSON.stringify({ screen: s.screen, path: s.path, viewport, scale, theme: themeLabel, init_script: s.init_script ?? null }, null, 2),
537
- });
538
- }
539
- return { content };
540
- } finally {
541
- await page.close();
542
- }
543
- }
544
-
545
- // ── cleanup ─────────────────────────────────────────────────────────
546
-
547
- function killAllServers() {
548
- for (const [, instance] of servers) {
549
- if (instance.process) {
550
- try { instance.process.kill(); } catch { /* already dead */ }
551
- }
552
- }
553
- servers.clear();
554
- }
555
-
556
- export async function shutdownAll() {
557
- killAllServers();
558
- await closeBrowser();
559
- }
560
-
561
- process.on("exit", killAllServers);
562
- process.on("SIGINT", () => { shutdownAll().then(() => process.exit(0)); });
563
- process.on("SIGTERM", () => { shutdownAll().then(() => process.exit(0)); });