ai-spec-dev 0.17.0 → 0.25.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.
@@ -1,5 +1,6 @@
1
1
  import * as path from "path";
2
2
  import * as fs from "fs-extra";
3
+ import { spawn } from "child_process";
3
4
  import { SpecDSL, ApiEndpoint, FieldMap } from "./dsl-types";
4
5
 
5
6
  // ─── Types ────────────────────────────────────────────────────────────────────
@@ -434,6 +435,215 @@ export const worker = setupWorker(...handlers);
434
435
  `;
435
436
  }
436
437
 
438
+ // ─── Proxy Patching (applyMockProxy / restoreMockProxy) ──────────────────────
439
+
440
+ const MOCK_LOCK_FILE = ".ai-spec-mock.lock.json";
441
+
442
+ interface MockLock {
443
+ framework: string;
444
+ mockPort: number;
445
+ frontendDir: string;
446
+ mockServerPid?: number;
447
+ actions: Array<
448
+ | { type: "wrote-file"; filePath: string }
449
+ | { type: "patched-pkg-proxy"; originalProxy?: string | null }
450
+ | { type: "added-pkg-script"; key: string; originalValue?: string | null }
451
+ >;
452
+ }
453
+
454
+ export interface ProxyApplyResult {
455
+ framework: string;
456
+ applied: boolean;
457
+ devCommand: string | null;
458
+ note?: string;
459
+ }
460
+
461
+ function findViteConfigFile(projectDir: string): string | null {
462
+ for (const f of ["vite.config.ts", "vite.config.mts", "vite.config.js", "vite.config.mjs"]) {
463
+ if (fs.existsSync(path.join(projectDir, f))) return f;
464
+ }
465
+ return null;
466
+ }
467
+
468
+ function buildViteProxyEntries(endpoints: ApiEndpoint[], mockPort: number): string {
469
+ const prefixes = new Set<string>();
470
+ for (const ep of endpoints) {
471
+ const parts = ep.path.split("/").filter(Boolean);
472
+ if (parts.length > 0) prefixes.add(`/${parts[0]}`);
473
+ }
474
+ if (prefixes.size === 0) prefixes.add("/api");
475
+ const target = `http://localhost:${mockPort}`;
476
+ return Array.from(prefixes)
477
+ .map((p) => ` '${p}': { target: '${target}', changeOrigin: true },`)
478
+ .join("\n");
479
+ }
480
+
481
+ function generateViteMockConfigTs(baseConfigFile: string, mockPort: number, endpoints: ApiEndpoint[]): string {
482
+ const importPath = `./${baseConfigFile.replace(/\.(ts|mts|js|mjs)$/, "")}`;
483
+ const proxyEntries = buildViteProxyEntries(endpoints, mockPort);
484
+ return `// Auto-generated by ai-spec mock --serve
485
+ // LOCAL DEVELOPMENT ONLY — do not commit this file
486
+ // Remove with: ai-spec mock --restore
487
+ import { defineConfig, mergeConfig } from 'vite';
488
+
489
+ export default defineConfig(async (env) => {
490
+ const mod = await import('${importPath}');
491
+ const baseConfigOrFn = mod.default;
492
+ const baseConfig =
493
+ typeof baseConfigOrFn === 'function'
494
+ ? await baseConfigOrFn(env)
495
+ : baseConfigOrFn;
496
+
497
+ return mergeConfig(baseConfig ?? {}, {
498
+ server: {
499
+ proxy: {
500
+ ${proxyEntries}
501
+ },
502
+ },
503
+ });
504
+ });
505
+ `;
506
+ }
507
+
508
+ /**
509
+ * Patch the frontend project's proxy config to point to the mock server.
510
+ * Vite: writes vite.config.ai-spec-mock.ts + adds "dev:mock" npm script.
511
+ * CRA : patches package.json "proxy" field (original backed up in lock file).
512
+ * Saves .ai-spec-mock.lock.json so restoreMockProxy() can undo all changes.
513
+ */
514
+ export async function applyMockProxy(
515
+ frontendDir: string,
516
+ mockPort: number,
517
+ endpoints: ApiEndpoint[] = []
518
+ ): Promise<ProxyApplyResult> {
519
+ const framework = detectFrontendFramework(frontendDir);
520
+ const actions: MockLock["actions"] = [];
521
+
522
+ if (framework === "vite") {
523
+ const viteConfigFile = findViteConfigFile(frontendDir) ?? "vite.config.ts";
524
+ const mockConfigContent = generateViteMockConfigTs(viteConfigFile, mockPort, endpoints);
525
+ const mockConfigPath = path.join(frontendDir, "vite.config.ai-spec-mock.ts");
526
+ await fs.writeFile(mockConfigPath, mockConfigContent, "utf-8");
527
+ actions.push({ type: "wrote-file", filePath: "vite.config.ai-spec-mock.ts" });
528
+
529
+ const pkgPath = path.join(frontendDir, "package.json");
530
+ if (await fs.pathExists(pkgPath)) {
531
+ const pkg = await fs.readJson(pkgPath);
532
+ pkg.scripts = pkg.scripts ?? {};
533
+ const originalValue: string | null = pkg.scripts["dev:mock"] ?? null;
534
+ pkg.scripts["dev:mock"] = "vite --config vite.config.ai-spec-mock.ts";
535
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
536
+ actions.push({ type: "added-pkg-script", key: "dev:mock", originalValue });
537
+ }
538
+
539
+ const lock: MockLock = { framework, mockPort, frontendDir, actions };
540
+ await fs.writeJson(path.join(frontendDir, MOCK_LOCK_FILE), lock, { spaces: 2 });
541
+ return { framework, applied: true, devCommand: "npm run dev:mock" };
542
+ }
543
+
544
+ if (framework === "cra") {
545
+ const pkgPath = path.join(frontendDir, "package.json");
546
+ if (await fs.pathExists(pkgPath)) {
547
+ const pkg = await fs.readJson(pkgPath);
548
+ const originalProxy: string | null = pkg.proxy ?? null;
549
+ pkg.proxy = `http://localhost:${mockPort}`;
550
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
551
+ actions.push({ type: "patched-pkg-proxy", originalProxy });
552
+ const lock: MockLock = { framework, mockPort, frontendDir, actions };
553
+ await fs.writeJson(path.join(frontendDir, MOCK_LOCK_FILE), lock, { spaces: 2 });
554
+ return { framework, applied: true, devCommand: "npm start" };
555
+ }
556
+ return { framework, applied: false, devCommand: null, note: "No package.json found." };
557
+ }
558
+
559
+ // next / webpack / unknown — save lock but no auto-patch
560
+ const lock: MockLock = { framework, mockPort, frontendDir, actions };
561
+ await fs.writeJson(path.join(frontendDir, MOCK_LOCK_FILE), lock, { spaces: 2 });
562
+ const manualNote =
563
+ framework === "next"
564
+ ? `Add rewrites in next.config.js to proxy API calls to http://localhost:${mockPort}`
565
+ : `Add proxy in webpack.config.js devServer to target http://localhost:${mockPort}`;
566
+ return { framework, applied: false, devCommand: null, note: manualNote };
567
+ }
568
+
569
+ /**
570
+ * Undo all proxy changes made by applyMockProxy().
571
+ * Also kills the mock server if its PID was stored in the lock file.
572
+ */
573
+ export async function restoreMockProxy(
574
+ frontendDir: string
575
+ ): Promise<{ restored: boolean; note?: string }> {
576
+ const lockPath = path.join(frontendDir, MOCK_LOCK_FILE);
577
+ if (!(await fs.pathExists(lockPath))) {
578
+ return { restored: false, note: "No lock file found — nothing to restore." };
579
+ }
580
+
581
+ const lock: MockLock = await fs.readJson(lockPath);
582
+
583
+ for (const action of lock.actions) {
584
+ if (action.type === "wrote-file") {
585
+ const fp = path.join(frontendDir, action.filePath);
586
+ if (await fs.pathExists(fp)) await fs.remove(fp);
587
+ } else if (action.type === "added-pkg-script") {
588
+ const pkgPath = path.join(frontendDir, "package.json");
589
+ if (await fs.pathExists(pkgPath)) {
590
+ const pkg = await fs.readJson(pkgPath);
591
+ if (action.originalValue == null) {
592
+ delete pkg.scripts?.[action.key];
593
+ } else {
594
+ pkg.scripts = pkg.scripts ?? {};
595
+ pkg.scripts[action.key] = action.originalValue;
596
+ }
597
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
598
+ }
599
+ } else if (action.type === "patched-pkg-proxy") {
600
+ const pkgPath = path.join(frontendDir, "package.json");
601
+ if (await fs.pathExists(pkgPath)) {
602
+ const pkg = await fs.readJson(pkgPath);
603
+ if (action.originalProxy == null) {
604
+ delete pkg.proxy;
605
+ } else {
606
+ pkg.proxy = action.originalProxy;
607
+ }
608
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
609
+ }
610
+ }
611
+ }
612
+
613
+ if (lock.mockServerPid) {
614
+ try { process.kill(lock.mockServerPid, "SIGTERM"); } catch { /* already dead */ }
615
+ }
616
+
617
+ await fs.remove(lockPath);
618
+ return { restored: true };
619
+ }
620
+
621
+ /**
622
+ * Start mock/server.js as a detached background process.
623
+ * Returns the spawned PID so it can be stored for later cleanup.
624
+ */
625
+ export function startMockServerBackground(serverJsPath: string, port: number): number {
626
+ const child = spawn("node", [serverJsPath], {
627
+ detached: true,
628
+ stdio: "ignore",
629
+ env: { ...process.env, MOCK_PORT: String(port) },
630
+ });
631
+ child.unref();
632
+ return child.pid!;
633
+ }
634
+
635
+ /**
636
+ * Save the mock server PID into an existing lock file.
637
+ */
638
+ export async function saveMockServerPid(frontendDir: string, pid: number): Promise<void> {
639
+ const lockPath = path.join(frontendDir, MOCK_LOCK_FILE);
640
+ if (await fs.pathExists(lockPath)) {
641
+ const lock: MockLock = await fs.readJson(lockPath);
642
+ lock.mockServerPid = pid;
643
+ await fs.writeJson(lockPath, lock, { spaces: 2 });
644
+ }
645
+ }
646
+
437
647
  // ─── Public API ───────────────────────────────────────────────────────────────
438
648
 
439
649
  /**
@@ -65,7 +65,7 @@ export function buildTaskPrompt(spec: string, context?: ProjectContext): string
65
65
  return parts.join("\n");
66
66
  }
67
67
 
68
- export type TaskLayer = "data" | "infra" | "service" | "api" | "test";
68
+ export type TaskLayer = "data" | "infra" | "service" | "api" | "view" | "route" | "test";
69
69
  export type TaskPriority = "high" | "medium" | "low";
70
70
  export type TaskStatus = "pending" | "done" | "failed";
71
71
 
@@ -87,7 +87,9 @@ const LAYER_ORDER: Record<TaskLayer, number> = {
87
87
  infra: 1,
88
88
  service: 2,
89
89
  api: 3,
90
- test: 4,
90
+ view: 4,
91
+ route: 5,
92
+ test: 6,
91
93
  };
92
94
 
93
95
  export class TaskGenerator {
@@ -135,6 +137,8 @@ export function printTasks(tasks: SpecTask[]): void {
135
137
  infra: chalk.gray,
136
138
  service: chalk.blue,
137
139
  api: chalk.cyan,
140
+ view: chalk.yellow,
141
+ route: chalk.white,
138
142
  test: chalk.green,
139
143
  };
140
144