@typokit/cli 0.1.4

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 (64) hide show
  1. package/dist/bin.d.ts +3 -0
  2. package/dist/bin.d.ts.map +1 -0
  3. package/dist/bin.js +13 -0
  4. package/dist/bin.js.map +1 -0
  5. package/dist/commands/build.d.ts +42 -0
  6. package/dist/commands/build.d.ts.map +1 -0
  7. package/dist/commands/build.js +302 -0
  8. package/dist/commands/build.js.map +1 -0
  9. package/dist/commands/dev.d.ts +106 -0
  10. package/dist/commands/dev.d.ts.map +1 -0
  11. package/dist/commands/dev.js +536 -0
  12. package/dist/commands/dev.js.map +1 -0
  13. package/dist/commands/generate.d.ts +65 -0
  14. package/dist/commands/generate.d.ts.map +1 -0
  15. package/dist/commands/generate.js +430 -0
  16. package/dist/commands/generate.js.map +1 -0
  17. package/dist/commands/inspect.d.ts +26 -0
  18. package/dist/commands/inspect.d.ts.map +1 -0
  19. package/dist/commands/inspect.js +579 -0
  20. package/dist/commands/inspect.js.map +1 -0
  21. package/dist/commands/migrate.d.ts +70 -0
  22. package/dist/commands/migrate.d.ts.map +1 -0
  23. package/dist/commands/migrate.js +570 -0
  24. package/dist/commands/migrate.js.map +1 -0
  25. package/dist/commands/scaffold.d.ts +70 -0
  26. package/dist/commands/scaffold.d.ts.map +1 -0
  27. package/dist/commands/scaffold.js +483 -0
  28. package/dist/commands/scaffold.js.map +1 -0
  29. package/dist/commands/test.d.ts +56 -0
  30. package/dist/commands/test.d.ts.map +1 -0
  31. package/dist/commands/test.js +248 -0
  32. package/dist/commands/test.js.map +1 -0
  33. package/dist/config.d.ts +20 -0
  34. package/dist/config.d.ts.map +1 -0
  35. package/dist/config.js +69 -0
  36. package/dist/config.js.map +1 -0
  37. package/dist/index.d.ts +30 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +245 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/logger.d.ts +12 -0
  42. package/dist/logger.d.ts.map +1 -0
  43. package/dist/logger.js +33 -0
  44. package/dist/logger.js.map +1 -0
  45. package/package.json +33 -0
  46. package/src/bin.ts +22 -0
  47. package/src/commands/build.ts +433 -0
  48. package/src/commands/dev.ts +822 -0
  49. package/src/commands/generate.ts +640 -0
  50. package/src/commands/inspect.ts +885 -0
  51. package/src/commands/migrate.ts +800 -0
  52. package/src/commands/scaffold.ts +627 -0
  53. package/src/commands/test.ts +353 -0
  54. package/src/config.ts +93 -0
  55. package/src/dev.test.ts +285 -0
  56. package/src/env.d.ts +86 -0
  57. package/src/generate.test.ts +304 -0
  58. package/src/index.test.ts +217 -0
  59. package/src/index.ts +397 -0
  60. package/src/inspect.test.ts +411 -0
  61. package/src/logger.ts +49 -0
  62. package/src/migrate.test.ts +205 -0
  63. package/src/scaffold.test.ts +256 -0
  64. package/src/test.test.ts +230 -0
@@ -0,0 +1,822 @@
1
+ // @typokit/cli — Dev Command
2
+ // Starts build pipeline in watch mode + development server with hot reload
3
+
4
+ import type { CliLogger } from "../logger.js";
5
+ import type { TypoKitConfig } from "../config.js";
6
+ import type { BuildResult, GeneratedOutput } from "@typokit/types";
7
+
8
+ export interface DevCommandOptions {
9
+ /** Project root directory */
10
+ rootDir: string;
11
+ /** Resolved configuration */
12
+ config: Required<TypoKitConfig>;
13
+ /** Logger instance */
14
+ logger: CliLogger;
15
+ /** Whether verbose mode is enabled */
16
+ verbose: boolean;
17
+ /** Debug sidecar port (default: 9800) */
18
+ debugPort: number;
19
+ }
20
+
21
+ /** Tracked file with mtime for change detection */
22
+ interface TrackedFile {
23
+ path: string;
24
+ mtime: number;
25
+ }
26
+
27
+ /** Dependency graph entry: maps a file to the outputs it affects */
28
+ interface DepGraphEntry {
29
+ /** Files that depend on this source file */
30
+ affectedOutputs: string[];
31
+ /** Category: "type" or "route" */
32
+ category: "type" | "route";
33
+ }
34
+
35
+ /** In-memory AST cache entry */
36
+ interface CacheEntry {
37
+ mtime: number;
38
+ hash: string;
39
+ }
40
+
41
+ /** Dev server state */
42
+ export interface DevServerState {
43
+ /** Whether the server is running */
44
+ running: boolean;
45
+ /** File watcher cleanup function */
46
+ stopWatcher: (() => void) | null;
47
+ /** Tracked files with mtimes */
48
+ trackedFiles: Map<string, TrackedFile>;
49
+ /** Dependency graph: source → affected outputs */
50
+ depGraph: Map<string, DepGraphEntry>;
51
+ /** AST cache: file path → cache entry */
52
+ astCache: Map<string, CacheEntry>;
53
+ /** Rebuild count */
54
+ rebuildCount: number;
55
+ /** Last rebuild duration in ms */
56
+ lastRebuildMs: number;
57
+ /** Server child process PID */
58
+ serverPid: number | null;
59
+ }
60
+
61
+ /**
62
+ * Create initial dev server state.
63
+ */
64
+ export function createDevState(): DevServerState {
65
+ return {
66
+ running: false,
67
+ stopWatcher: null,
68
+ trackedFiles: new Map(),
69
+ depGraph: new Map(),
70
+ astCache: new Map(),
71
+ rebuildCount: 0,
72
+ lastRebuildMs: 0,
73
+ serverPid: null,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Resolve glob patterns to actual file paths with their mtimes.
79
+ */
80
+ async function resolveFilesWithMtime(
81
+ rootDir: string,
82
+ patterns: string[],
83
+ ): Promise<TrackedFile[]> {
84
+ const { join, resolve } = (await import(/* @vite-ignore */ "path")) as {
85
+ join: (...args: string[]) => string;
86
+ resolve: (...args: string[]) => string;
87
+ };
88
+ const { readdirSync, statSync, existsSync } = (await import(
89
+ /* @vite-ignore */ "fs"
90
+ )) as {
91
+ readdirSync: (p: string) => string[];
92
+ statSync: (p: string) => {
93
+ isFile(): boolean;
94
+ isDirectory(): boolean;
95
+ mtimeMs: number;
96
+ };
97
+ existsSync: (p: string) => boolean;
98
+ };
99
+
100
+ const files: TrackedFile[] = [];
101
+
102
+ for (const pattern of patterns) {
103
+ if (pattern.includes("*")) {
104
+ const parts = pattern.split("/");
105
+ const hasDoubleGlob = parts.includes("**");
106
+ const lastPart = parts[parts.length - 1];
107
+
108
+ const baseParts: string[] = [];
109
+ for (const part of parts) {
110
+ if (part.includes("*")) break;
111
+ baseParts.push(part);
112
+ }
113
+ const baseDir =
114
+ baseParts.length > 0 ? join(rootDir, ...baseParts) : rootDir;
115
+
116
+ if (!existsSync(baseDir)) continue;
117
+
118
+ const entries = hasDoubleGlob
119
+ ? listFilesRecursive(baseDir, existsSync, readdirSync, statSync, join)
120
+ : readdirSync(baseDir).map((f) => join(baseDir, f));
121
+
122
+ const filePattern = lastPart.replace(/\*/g, ".*");
123
+ const regex = new RegExp(`^${filePattern}$`);
124
+
125
+ for (const entry of entries) {
126
+ const name = entry.split(/[\\/]/).pop() ?? "";
127
+ if (regex.test(name)) {
128
+ const fullPath = resolve(entry);
129
+ try {
130
+ const stat = statSync(fullPath);
131
+ if (stat.isFile()) {
132
+ files.push({ path: fullPath, mtime: stat.mtimeMs });
133
+ }
134
+ } catch {
135
+ // Skip files that can't be stat'd
136
+ }
137
+ }
138
+ }
139
+ } else {
140
+ const fullPath = resolve(join(rootDir, pattern));
141
+ if (existsSync(fullPath)) {
142
+ try {
143
+ const stat = statSync(fullPath);
144
+ if (stat.isFile()) {
145
+ files.push({ path: fullPath, mtime: stat.mtimeMs });
146
+ }
147
+ } catch {
148
+ // Skip
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ // Deduplicate by path
155
+ const seen = new Set<string>();
156
+ return files
157
+ .filter((f) => {
158
+ if (seen.has(f.path)) return false;
159
+ seen.add(f.path);
160
+ return true;
161
+ })
162
+ .sort((a, b) => a.path.localeCompare(b.path));
163
+ }
164
+
165
+ function listFilesRecursive(
166
+ dir: string,
167
+ existsSync: (p: string) => boolean,
168
+ readdirSync: (p: string) => string[],
169
+ statSync: (p: string) => {
170
+ isFile(): boolean;
171
+ isDirectory(): boolean;
172
+ mtimeMs: number;
173
+ },
174
+ join: (...args: string[]) => string,
175
+ ): string[] {
176
+ if (!existsSync(dir)) return [];
177
+ const results: string[] = [];
178
+ const entries = readdirSync(dir);
179
+ for (const entry of entries) {
180
+ const fullPath = join(dir, entry);
181
+ try {
182
+ const stat = statSync(fullPath);
183
+ if (stat.isDirectory()) {
184
+ if (
185
+ entry !== "node_modules" &&
186
+ entry !== "dist" &&
187
+ entry !== ".typokit"
188
+ ) {
189
+ results.push(
190
+ ...listFilesRecursive(
191
+ fullPath,
192
+ existsSync,
193
+ readdirSync,
194
+ statSync,
195
+ join,
196
+ ),
197
+ );
198
+ }
199
+ } else if (stat.isFile()) {
200
+ results.push(fullPath);
201
+ }
202
+ } catch {
203
+ // Skip
204
+ }
205
+ }
206
+ return results;
207
+ }
208
+
209
+ /**
210
+ * Detect which files have changed since last check.
211
+ */
212
+ export function detectChangedFiles(
213
+ state: DevServerState,
214
+ currentFiles: TrackedFile[],
215
+ ): { changed: TrackedFile[]; added: TrackedFile[]; removed: string[] } {
216
+ const changed: TrackedFile[] = [];
217
+ const added: TrackedFile[] = [];
218
+ const removed: string[] = [];
219
+
220
+ const currentPaths = new Set(currentFiles.map((f) => f.path));
221
+
222
+ // Check for changed and added files
223
+ for (const file of currentFiles) {
224
+ const tracked = state.trackedFiles.get(file.path);
225
+ if (!tracked) {
226
+ added.push(file);
227
+ } else if (file.mtime > tracked.mtime) {
228
+ changed.push(file);
229
+ }
230
+ }
231
+
232
+ // Check for removed files
233
+ for (const path of state.trackedFiles.keys()) {
234
+ if (!currentPaths.has(path)) {
235
+ removed.push(path);
236
+ }
237
+ }
238
+
239
+ return { changed, added, removed };
240
+ }
241
+
242
+ /**
243
+ * Update the tracked files in state.
244
+ */
245
+ export function updateTrackedFiles(
246
+ state: DevServerState,
247
+ files: TrackedFile[],
248
+ ): void {
249
+ state.trackedFiles.clear();
250
+ for (const file of files) {
251
+ state.trackedFiles.set(file.path, file);
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Build the dependency graph from type and route files.
257
+ * Maps each source file to the outputs it affects.
258
+ */
259
+ export function buildDepGraph(
260
+ typeFiles: string[],
261
+ routeFiles: string[],
262
+ ): Map<string, DepGraphEntry> {
263
+ const graph = new Map<string, DepGraphEntry>();
264
+
265
+ for (const file of typeFiles) {
266
+ graph.set(file, {
267
+ category: "type",
268
+ affectedOutputs: ["validators", "schemas/openapi.json"],
269
+ });
270
+ }
271
+
272
+ for (const file of routeFiles) {
273
+ graph.set(file, {
274
+ category: "route",
275
+ affectedOutputs: [
276
+ "routes/compiled-router.ts",
277
+ "schemas/openapi.json",
278
+ "tests/contract.test.ts",
279
+ ],
280
+ });
281
+ }
282
+
283
+ return graph;
284
+ }
285
+
286
+ /**
287
+ * Determine which outputs need regeneration based on changed files.
288
+ */
289
+ export function getAffectedOutputs(
290
+ depGraph: Map<string, DepGraphEntry>,
291
+ changedFiles: string[],
292
+ ): Set<string> {
293
+ const affected = new Set<string>();
294
+
295
+ for (const file of changedFiles) {
296
+ const entry = depGraph.get(file);
297
+ if (entry) {
298
+ for (const output of entry.affectedOutputs) {
299
+ affected.add(output);
300
+ }
301
+ }
302
+ }
303
+
304
+ return affected;
305
+ }
306
+
307
+ /**
308
+ * Check if a file's AST cache is still valid.
309
+ */
310
+ export function isCacheValid(
311
+ cache: Map<string, CacheEntry>,
312
+ filePath: string,
313
+ currentMtime: number,
314
+ ): boolean {
315
+ const entry = cache.get(filePath);
316
+ if (!entry) return false;
317
+ return entry.mtime === currentMtime;
318
+ }
319
+
320
+ /**
321
+ * Update the AST cache for a file.
322
+ */
323
+ export function updateCache(
324
+ cache: Map<string, CacheEntry>,
325
+ filePath: string,
326
+ mtime: number,
327
+ ): void {
328
+ cache.set(filePath, {
329
+ mtime,
330
+ hash: `${filePath}:${mtime}`,
331
+ });
332
+ }
333
+
334
+ /**
335
+ * Run an incremental rebuild for changed files only.
336
+ * Returns the files that were actually re-processed.
337
+ */
338
+ export async function incrementalRebuild(
339
+ options: DevCommandOptions,
340
+ state: DevServerState,
341
+ changedPaths: string[],
342
+ ): Promise<{ success: boolean; duration: number; filesProcessed: number }> {
343
+ const startTime = Date.now();
344
+ const { config, logger, verbose } = options;
345
+
346
+ // Determine affected outputs
347
+ const affected = getAffectedOutputs(state.depGraph, changedPaths);
348
+ if (affected.size === 0) {
349
+ logger.verbose("No affected outputs — skipping rebuild");
350
+ return { success: true, duration: 0, filesProcessed: 0 };
351
+ }
352
+
353
+ if (verbose) {
354
+ logger.verbose(`Affected outputs: ${[...affected].join(", ")}`);
355
+ }
356
+
357
+ // Filter to only changed files that aren't cache-valid
358
+ const filesToProcess: string[] = [];
359
+ for (const path of changedPaths) {
360
+ const tracked = state.trackedFiles.get(path);
361
+ if (tracked && !isCacheValid(state.astCache, path, tracked.mtime)) {
362
+ filesToProcess.push(path);
363
+ }
364
+ }
365
+
366
+ if (filesToProcess.length === 0) {
367
+ logger.verbose("All changed files still cached — skipping rebuild");
368
+ return { success: true, duration: 0, filesProcessed: 0 };
369
+ }
370
+
371
+ logger.step(
372
+ "rebuild",
373
+ `Incremental rebuild: ${filesToProcess.length} file(s) changed`,
374
+ );
375
+
376
+ try {
377
+ // Re-run the native transform pipeline with all files
378
+ // (the pipeline is fast enough, and the Rust side handles caching)
379
+ const allTypeFiles = [...state.depGraph.entries()]
380
+ .filter(([, e]) => e.category === "type")
381
+ .map(([p]) => p);
382
+ const allRouteFiles = [...state.depGraph.entries()]
383
+ .filter(([, e]) => e.category === "route")
384
+ .map(([p]) => p);
385
+
386
+ const { buildPipeline } = (await import(
387
+ /* @vite-ignore */ "@typokit/transform-native"
388
+ )) as {
389
+ buildPipeline: (opts: {
390
+ typeFiles: string[];
391
+ routeFiles: string[];
392
+ outputDir?: string;
393
+ }) => Promise<{
394
+ regenerated: boolean;
395
+ contentHash: string;
396
+ filesWritten: string[];
397
+ }>;
398
+ };
399
+
400
+ const result = await buildPipeline({
401
+ typeFiles: allTypeFiles,
402
+ routeFiles: allRouteFiles,
403
+ outputDir: config.outputDir,
404
+ });
405
+
406
+ // Update AST cache for processed files
407
+ for (const path of filesToProcess) {
408
+ const tracked = state.trackedFiles.get(path);
409
+ if (tracked) {
410
+ updateCache(state.astCache, path, tracked.mtime);
411
+ }
412
+ }
413
+
414
+ const duration = Date.now() - startTime;
415
+ state.lastRebuildMs = duration;
416
+ state.rebuildCount++;
417
+
418
+ if (result.regenerated) {
419
+ logger.success(
420
+ `Rebuild complete in ${duration}ms — ${result.filesWritten.length} files written`,
421
+ );
422
+ } else {
423
+ logger.success(`Rebuild complete in ${duration}ms — cache hit`);
424
+ }
425
+
426
+ return { success: true, duration, filesProcessed: filesToProcess.length };
427
+ } catch (err: unknown) {
428
+ const message = err instanceof Error ? err.message : String(err);
429
+ logger.error(`Rebuild failed: ${message}`);
430
+ const duration = Date.now() - startTime;
431
+ return { success: false, duration, filesProcessed: filesToProcess.length };
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Start file watching using fs.watch (recursive where supported).
437
+ * Falls back to polling on platforms that don't support recursive.
438
+ */
439
+ async function startFileWatcher(
440
+ options: DevCommandOptions,
441
+ state: DevServerState,
442
+ onChanges: (changedPaths: string[]) => void,
443
+ ): Promise<() => void> {
444
+ const { rootDir, config, logger, verbose } = options;
445
+
446
+ // Collect directories to watch based on config patterns
447
+ const watchDirs = new Set<string>();
448
+ const { join } = (await import(/* @vite-ignore */ "path")) as {
449
+ join: (...args: string[]) => string;
450
+ };
451
+ const { existsSync } = (await import(/* @vite-ignore */ "fs")) as {
452
+ existsSync: (p: string) => boolean;
453
+ };
454
+
455
+ // Watch the src directory by default
456
+ const srcDir = join(rootDir, "src");
457
+ if (existsSync(srcDir)) {
458
+ watchDirs.add(srcDir);
459
+ } else {
460
+ watchDirs.add(rootDir);
461
+ }
462
+
463
+ if (verbose) {
464
+ logger.verbose(`Watching directories: ${[...watchDirs].join(", ")}`);
465
+ }
466
+
467
+ // Debounce timer to batch rapid changes
468
+ const g = globalThis as unknown as {
469
+ setTimeout: (fn: () => void, ms: number) => number;
470
+ clearTimeout: (id: number) => void;
471
+ };
472
+ let debounceTimer: number | null = null;
473
+ const pendingChanges = new Set<string>();
474
+ const DEBOUNCE_MS = 50;
475
+
476
+ const watchers: Array<{ close(): void }> = [];
477
+
478
+ try {
479
+ const fs = (await import(/* @vite-ignore */ "fs")) as {
480
+ watch: (
481
+ path: string,
482
+ options: { recursive?: boolean },
483
+ listener: (event: string, filename: string | null) => void,
484
+ ) => { close(): void };
485
+ };
486
+
487
+ for (const dir of watchDirs) {
488
+ const watcher = fs.watch(
489
+ dir,
490
+ { recursive: true },
491
+ (_event: string, filename: string | null) => {
492
+ if (!filename) return;
493
+
494
+ const fullPath = join(dir, filename);
495
+
496
+ // Only track .ts files matching our patterns
497
+ if (!fullPath.endsWith(".ts")) return;
498
+
499
+ // Check if this file is in our tracked set
500
+ if (
501
+ state.trackedFiles.has(fullPath) ||
502
+ state.depGraph.has(fullPath)
503
+ ) {
504
+ pendingChanges.add(fullPath);
505
+ } else {
506
+ // Could be a new file matching our patterns — add it
507
+ const isTypePattern = config.typeFiles.some((p) =>
508
+ matchesGlobPattern(fullPath, rootDir, p),
509
+ );
510
+ const isRoutePattern = config.routeFiles.some((p) =>
511
+ matchesGlobPattern(fullPath, rootDir, p),
512
+ );
513
+ if (isTypePattern || isRoutePattern) {
514
+ pendingChanges.add(fullPath);
515
+ }
516
+ }
517
+
518
+ // Debounce
519
+ if (debounceTimer) {
520
+ g.clearTimeout(debounceTimer);
521
+ }
522
+ debounceTimer = g.setTimeout(() => {
523
+ const changes = [...pendingChanges];
524
+ pendingChanges.clear();
525
+ if (changes.length > 0) {
526
+ onChanges(changes);
527
+ }
528
+ }, DEBOUNCE_MS);
529
+ },
530
+ );
531
+
532
+ watchers.push(watcher);
533
+ }
534
+ } catch (err: unknown) {
535
+ const message = err instanceof Error ? err.message : String(err);
536
+ logger.warn(`fs.watch failed: ${message} — falling back to polling`);
537
+ // Polling fallback
538
+ const gTimer = globalThis as unknown as {
539
+ setInterval: (fn: () => void, ms: number) => number;
540
+ clearInterval: (id: number) => void;
541
+ };
542
+ const POLL_INTERVAL = 500;
543
+ const pollTimer = gTimer.setInterval(async () => {
544
+ const typeFiles = await resolveFilesWithMtime(rootDir, config.typeFiles);
545
+ const routeFiles = await resolveFilesWithMtime(
546
+ rootDir,
547
+ config.routeFiles,
548
+ );
549
+ const allFiles = [...typeFiles, ...routeFiles];
550
+
551
+ const { changed, added, removed } = detectChangedFiles(state, allFiles);
552
+ const changedPaths = [
553
+ ...changed.map((f) => f.path),
554
+ ...added.map((f) => f.path),
555
+ ...removed,
556
+ ];
557
+
558
+ if (changedPaths.length > 0) {
559
+ updateTrackedFiles(state, allFiles);
560
+ onChanges(changedPaths);
561
+ }
562
+ }, POLL_INTERVAL);
563
+
564
+ return () => {
565
+ gTimer.clearInterval(pollTimer);
566
+ };
567
+ }
568
+
569
+ logger.step("watch", `File watcher started`);
570
+
571
+ return () => {
572
+ for (const watcher of watchers) {
573
+ watcher.close();
574
+ }
575
+ if (debounceTimer) {
576
+ g.clearTimeout(debounceTimer);
577
+ }
578
+ };
579
+ }
580
+
581
+ /**
582
+ * Simple glob pattern matching for a file against a pattern.
583
+ */
584
+ function matchesGlobPattern(
585
+ filePath: string,
586
+ rootDir: string,
587
+ pattern: string,
588
+ ): boolean {
589
+ // Normalize separators
590
+ const normalized = filePath.replace(/\\/g, "/");
591
+ const normalizedRoot = rootDir.replace(/\\/g, "/");
592
+
593
+ // Get relative path
594
+ let relative = normalized;
595
+ if (normalized.startsWith(normalizedRoot)) {
596
+ relative = normalized.slice(normalizedRoot.length).replace(/^\//, "");
597
+ }
598
+
599
+ // Convert glob to regex
600
+ const regexStr = pattern
601
+ .replace(/\*\*/g, "___DOUBLESTAR___")
602
+ .replace(/\*/g, "[^/]*")
603
+ .replace(/___DOUBLESTAR___/g, ".*");
604
+
605
+ const regex = new RegExp(`^${regexStr}$`);
606
+ return regex.test(relative);
607
+ }
608
+
609
+ /**
610
+ * Execute the dev command.
611
+ *
612
+ * 1. Run initial full build
613
+ * 2. Start file watcher for incremental rebuilds
614
+ * 3. Start the development server (delegates to server adapter)
615
+ * 4. Handle graceful shutdown
616
+ */
617
+ export async function executeDev(
618
+ options: DevCommandOptions,
619
+ ): Promise<{ state: DevServerState; stop: () => void }> {
620
+ const { config, rootDir, logger, verbose, debugPort } = options;
621
+
622
+ logger.step("dev", "Starting development mode...");
623
+ if (verbose) {
624
+ logger.verbose(`Debug port: ${debugPort}`);
625
+ logger.verbose(`Root: ${rootDir}`);
626
+ }
627
+
628
+ const state = createDevState();
629
+
630
+ // Step 1: Resolve all source files
631
+ logger.step("dev", "Resolving source files...");
632
+ const typeFiles = await resolveFilesWithMtime(rootDir, config.typeFiles);
633
+ const routeFiles = await resolveFilesWithMtime(rootDir, config.routeFiles);
634
+ const allFiles = [...typeFiles, ...routeFiles];
635
+
636
+ logger.step(
637
+ "dev",
638
+ `Found ${typeFiles.length} type file(s), ${routeFiles.length} route file(s)`,
639
+ );
640
+
641
+ // Initialize tracked files
642
+ updateTrackedFiles(state, allFiles);
643
+
644
+ // Build dependency graph
645
+ state.depGraph = buildDepGraph(
646
+ typeFiles.map((f) => f.path),
647
+ routeFiles.map((f) => f.path),
648
+ );
649
+
650
+ // Step 2: Run initial full build
651
+ logger.step("dev", "Running initial build...");
652
+ const initialBuild = await runFullBuild(options, state);
653
+
654
+ if (!initialBuild.success) {
655
+ logger.error("Initial build failed — watching for changes to retry...");
656
+ } else {
657
+ logger.success(`Initial build complete in ${initialBuild.duration}ms`);
658
+ }
659
+
660
+ // Step 3: Start file watcher
661
+ state.running = true;
662
+
663
+ const stopWatcher = await startFileWatcher(
664
+ options,
665
+ state,
666
+ async (changedPaths: string[]) => {
667
+ if (!state.running) return;
668
+
669
+ const fileNames = changedPaths
670
+ .map((p) => p.split(/[\\/]/).pop())
671
+ .join(", ");
672
+ logger.step("change", `Detected: ${fileNames}`);
673
+
674
+ // Re-resolve files to get updated mtimes
675
+ const updatedTypeFiles = await resolveFilesWithMtime(
676
+ rootDir,
677
+ config.typeFiles,
678
+ );
679
+ const updatedRouteFiles = await resolveFilesWithMtime(
680
+ rootDir,
681
+ config.routeFiles,
682
+ );
683
+ const updatedFiles = [...updatedTypeFiles, ...updatedRouteFiles];
684
+
685
+ // Update tracked files and dep graph
686
+ updateTrackedFiles(state, updatedFiles);
687
+ state.depGraph = buildDepGraph(
688
+ updatedTypeFiles.map((f) => f.path),
689
+ updatedRouteFiles.map((f) => f.path),
690
+ );
691
+
692
+ // Incremental rebuild
693
+ const result = await incrementalRebuild(options, state, changedPaths);
694
+
695
+ if (result.success) {
696
+ logger.step(
697
+ "ready",
698
+ `Server ready — rebuild #${state.rebuildCount} (${result.duration}ms)`,
699
+ );
700
+ }
701
+ },
702
+ );
703
+
704
+ state.stopWatcher = stopWatcher;
705
+
706
+ // Step 4: Setup graceful shutdown
707
+ const stop = (): void => {
708
+ if (!state.running) return;
709
+ state.running = false;
710
+
711
+ logger.step("dev", "Shutting down...");
712
+
713
+ if (state.stopWatcher) {
714
+ state.stopWatcher();
715
+ state.stopWatcher = null;
716
+ }
717
+
718
+ logger.step("dev", "Dev server stopped");
719
+ };
720
+
721
+ // Register signal handlers for graceful shutdown
722
+ const g = globalThis as Record<string, unknown>;
723
+ const proc = g["process"] as
724
+ | {
725
+ on(event: string, handler: () => void): void;
726
+ removeListener(event: string, handler: () => void): void;
727
+ }
728
+ | undefined;
729
+
730
+ const sigintHandler = (): void => stop();
731
+ const sigtermHandler = (): void => stop();
732
+
733
+ if (proc) {
734
+ proc.on("SIGINT", sigintHandler);
735
+ proc.on("SIGTERM", sigtermHandler);
736
+ }
737
+
738
+ logger.success("Dev mode active — watching for changes (Ctrl+C to stop)");
739
+ logger.step("dev", `Debug sidecar port: ${debugPort}`);
740
+
741
+ return { state, stop };
742
+ }
743
+
744
+ /**
745
+ * Run a full build (used for initial build in dev mode).
746
+ */
747
+ async function runFullBuild(
748
+ options: DevCommandOptions,
749
+ state: DevServerState,
750
+ ): Promise<BuildResult> {
751
+ const startTime = Date.now();
752
+ const { config, logger, verbose } = options;
753
+ const outputs: GeneratedOutput[] = [];
754
+ const errors: string[] = [];
755
+
756
+ const typeFiles = [...state.depGraph.entries()]
757
+ .filter(([, e]) => e.category === "type")
758
+ .map(([p]) => p);
759
+ const routeFiles = [...state.depGraph.entries()]
760
+ .filter(([, e]) => e.category === "route")
761
+ .map(([p]) => p);
762
+
763
+ if (typeFiles.length > 0 || routeFiles.length > 0) {
764
+ try {
765
+ const { buildPipeline } = (await import(
766
+ /* @vite-ignore */ "@typokit/transform-native"
767
+ )) as {
768
+ buildPipeline: (opts: {
769
+ typeFiles: string[];
770
+ routeFiles: string[];
771
+ outputDir?: string;
772
+ }) => Promise<{
773
+ regenerated: boolean;
774
+ contentHash: string;
775
+ filesWritten: string[];
776
+ }>;
777
+ };
778
+
779
+ const result = await buildPipeline({
780
+ typeFiles,
781
+ routeFiles,
782
+ outputDir: config.outputDir,
783
+ });
784
+
785
+ if (result.regenerated) {
786
+ for (const f of result.filesWritten) {
787
+ outputs.push({ filePath: f, content: "", overwrite: true });
788
+ }
789
+ }
790
+
791
+ // Initialize AST cache for all files
792
+ for (const [path, tracked] of state.trackedFiles) {
793
+ updateCache(state.astCache, path, tracked.mtime);
794
+ }
795
+
796
+ if (verbose) {
797
+ logger.verbose(`Content hash: ${result.contentHash}`);
798
+ }
799
+ } catch (err: unknown) {
800
+ const message = err instanceof Error ? err.message : String(err);
801
+ logger.error(`Transform failed: ${message}`);
802
+ errors.push(`Transform error: ${message}`);
803
+ return {
804
+ success: false,
805
+ outputs,
806
+ duration: Date.now() - startTime,
807
+ errors,
808
+ };
809
+ }
810
+ }
811
+
812
+ const duration = Date.now() - startTime;
813
+ state.rebuildCount++;
814
+ state.lastRebuildMs = duration;
815
+
816
+ return {
817
+ success: true,
818
+ outputs,
819
+ duration,
820
+ errors: [],
821
+ };
822
+ }