@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.
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +13 -0
- package/dist/bin.js.map +1 -0
- package/dist/commands/build.d.ts +42 -0
- package/dist/commands/build.d.ts.map +1 -0
- package/dist/commands/build.js +302 -0
- package/dist/commands/build.js.map +1 -0
- package/dist/commands/dev.d.ts +106 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +536 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/generate.d.ts +65 -0
- package/dist/commands/generate.d.ts.map +1 -0
- package/dist/commands/generate.js +430 -0
- package/dist/commands/generate.js.map +1 -0
- package/dist/commands/inspect.d.ts +26 -0
- package/dist/commands/inspect.d.ts.map +1 -0
- package/dist/commands/inspect.js +579 -0
- package/dist/commands/inspect.js.map +1 -0
- package/dist/commands/migrate.d.ts +70 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +570 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/scaffold.d.ts +70 -0
- package/dist/commands/scaffold.d.ts.map +1 -0
- package/dist/commands/scaffold.js +483 -0
- package/dist/commands/scaffold.js.map +1 -0
- package/dist/commands/test.d.ts +56 -0
- package/dist/commands/test.d.ts.map +1 -0
- package/dist/commands/test.js +248 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +69 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +245 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +33 -0
- package/dist/logger.js.map +1 -0
- package/package.json +33 -0
- package/src/bin.ts +22 -0
- package/src/commands/build.ts +433 -0
- package/src/commands/dev.ts +822 -0
- package/src/commands/generate.ts +640 -0
- package/src/commands/inspect.ts +885 -0
- package/src/commands/migrate.ts +800 -0
- package/src/commands/scaffold.ts +627 -0
- package/src/commands/test.ts +353 -0
- package/src/config.ts +93 -0
- package/src/dev.test.ts +285 -0
- package/src/env.d.ts +86 -0
- package/src/generate.test.ts +304 -0
- package/src/index.test.ts +217 -0
- package/src/index.ts +397 -0
- package/src/inspect.test.ts +411 -0
- package/src/logger.ts +49 -0
- package/src/migrate.test.ts +205 -0
- package/src/scaffold.test.ts +256 -0
- 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
|
+
}
|