@agentuity/cli 1.0.38 → 1.0.40

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 (50) hide show
  1. package/dist/cmd/build/app-router-detector.d.ts +42 -0
  2. package/dist/cmd/build/app-router-detector.d.ts.map +1 -0
  3. package/dist/cmd/build/app-router-detector.js +253 -0
  4. package/dist/cmd/build/app-router-detector.js.map +1 -0
  5. package/dist/cmd/build/ast.d.ts +11 -1
  6. package/dist/cmd/build/ast.d.ts.map +1 -1
  7. package/dist/cmd/build/ast.js +273 -29
  8. package/dist/cmd/build/ast.js.map +1 -1
  9. package/dist/cmd/build/entry-generator.d.ts.map +1 -1
  10. package/dist/cmd/build/entry-generator.js +23 -16
  11. package/dist/cmd/build/entry-generator.js.map +1 -1
  12. package/dist/cmd/build/vite/bundle-files.d.ts +12 -0
  13. package/dist/cmd/build/vite/bundle-files.d.ts.map +1 -0
  14. package/dist/cmd/build/vite/bundle-files.js +107 -0
  15. package/dist/cmd/build/vite/bundle-files.js.map +1 -0
  16. package/dist/cmd/build/vite/registry-generator.d.ts.map +1 -1
  17. package/dist/cmd/build/vite/registry-generator.js +37 -13
  18. package/dist/cmd/build/vite/registry-generator.js.map +1 -1
  19. package/dist/cmd/build/vite/route-discovery.d.ts +17 -3
  20. package/dist/cmd/build/vite/route-discovery.d.ts.map +1 -1
  21. package/dist/cmd/build/vite/route-discovery.js +91 -3
  22. package/dist/cmd/build/vite/route-discovery.js.map +1 -1
  23. package/dist/cmd/build/vite/vite-builder.d.ts.map +1 -1
  24. package/dist/cmd/build/vite/vite-builder.js +9 -0
  25. package/dist/cmd/build/vite/vite-builder.js.map +1 -1
  26. package/dist/cmd/build/vite-bundler.d.ts.map +1 -1
  27. package/dist/cmd/build/vite-bundler.js +3 -0
  28. package/dist/cmd/build/vite-bundler.js.map +1 -1
  29. package/dist/cmd/dev/index.d.ts.map +1 -1
  30. package/dist/cmd/dev/index.js +30 -0
  31. package/dist/cmd/dev/index.js.map +1 -1
  32. package/dist/types.d.ts +9 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/types.js.map +1 -1
  35. package/dist/utils/route-migration.d.ts +61 -0
  36. package/dist/utils/route-migration.d.ts.map +1 -0
  37. package/dist/utils/route-migration.js +662 -0
  38. package/dist/utils/route-migration.js.map +1 -0
  39. package/package.json +6 -6
  40. package/src/cmd/build/app-router-detector.ts +350 -0
  41. package/src/cmd/build/ast.ts +339 -36
  42. package/src/cmd/build/entry-generator.ts +23 -16
  43. package/src/cmd/build/vite/bundle-files.ts +135 -0
  44. package/src/cmd/build/vite/registry-generator.ts +38 -13
  45. package/src/cmd/build/vite/route-discovery.ts +151 -3
  46. package/src/cmd/build/vite/vite-builder.ts +11 -0
  47. package/src/cmd/build/vite-bundler.ts +4 -0
  48. package/src/cmd/dev/index.ts +34 -0
  49. package/src/types.ts +9 -0
  50. package/src/utils/route-migration.ts +793 -0
@@ -0,0 +1,793 @@
1
+ /**
2
+ * Route Migration Utility
3
+ *
4
+ * Detects projects using file-based routing (multiple route files in src/api/)
5
+ * and offers to migrate them to explicit routing — a single src/api/index.ts
6
+ * root router that imports and mounts all sub-routers explicitly.
7
+ *
8
+ * Also updates src/app.ts to import the router and pass it to
9
+ * createApp({ router }), completing the migration to explicit routing.
10
+ *
11
+ * Explicit routing will become the default in the next major release.
12
+ *
13
+ * Runs during `dev` and `build` after dependency upgrades.
14
+ */
15
+
16
+ import { join, basename, dirname, relative } from 'node:path';
17
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
18
+ import type { Logger } from '../types';
19
+ import * as tui from '../tui';
20
+
21
+ // Sentinel file that tracks whether the user has been notified or has opted out
22
+ const MIGRATION_SENTINEL = '.agentuity/.route-migration-state';
23
+
24
+ type MigrationState = 'pending' | 'notified' | 'dismissed' | 'migrated';
25
+
26
+ interface MigrationStateFile {
27
+ state: MigrationState;
28
+ timestamp: number;
29
+ version?: string;
30
+ }
31
+
32
+ function getMigrationStatePath(rootDir: string): string {
33
+ return join(rootDir, MIGRATION_SENTINEL);
34
+ }
35
+
36
+ function readMigrationState(rootDir: string): MigrationStateFile | null {
37
+ const statePath = getMigrationStatePath(rootDir);
38
+ if (!existsSync(statePath)) return null;
39
+ try {
40
+ return JSON.parse(readFileSync(statePath, 'utf-8'));
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function writeMigrationState(rootDir: string, state: MigrationState): void {
47
+ const statePath = getMigrationStatePath(rootDir);
48
+ const dir = dirname(statePath);
49
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
50
+ writeFileSync(
51
+ statePath,
52
+ JSON.stringify({
53
+ state,
54
+ timestamp: Date.now(),
55
+ } satisfies MigrationStateFile) + '\n'
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Detect if a project uses file-based routing (has route files in src/api/).
61
+ * Returns the list of discovered route files, or empty array if none found.
62
+ */
63
+ /**
64
+ * Check if a file exports a router as its default export.
65
+ * Matches patterns like:
66
+ * export default router;
67
+ * export default createRouter();
68
+ * export default new Hono();
69
+ * But NOT files that merely import/reference createRouter or Hono without exporting a router.
70
+ */
71
+ function isRouterFile(content: string): boolean {
72
+ // Strip single-line and multi-line comments to avoid false positives
73
+ const stripped = content.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
74
+
75
+ // Must have a default export
76
+ if (!stripped.includes('export default')) return false;
77
+
78
+ // Must actually create a router (not just import the function)
79
+ return /createRouter\s*\(/.test(stripped) || /new\s+Hono\s*[<(]/.test(stripped);
80
+ }
81
+
82
+ export function detectFileBasedRoutes(rootDir: string): string[] {
83
+ const apiDir = join(rootDir, 'src', 'api');
84
+ if (!existsSync(apiDir)) return [];
85
+
86
+ const routeFiles: string[] = [];
87
+ const glob = new Bun.Glob('**/*.ts');
88
+ for (const file of glob.scanSync(apiDir)) {
89
+ const filePath = join(apiDir, file);
90
+ try {
91
+ const content = readFileSync(filePath, 'utf-8');
92
+ if (isRouterFile(content)) {
93
+ routeFiles.push(file);
94
+ }
95
+ } catch {
96
+ // Skip unreadable files
97
+ }
98
+ }
99
+ return routeFiles;
100
+ }
101
+
102
+ /**
103
+ * Check if src/api/index.ts already exists and is a root router that mounts sub-routers.
104
+ * This means the project has already migrated to explicit routing.
105
+ */
106
+ function hasExplicitRootRouter(rootDir: string): boolean {
107
+ const indexPath = join(rootDir, 'src', 'api', 'index.ts');
108
+ if (!existsSync(indexPath)) return false;
109
+ try {
110
+ const content = readFileSync(indexPath, 'utf-8');
111
+ const stripped = content.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
112
+ // An explicit root router both creates a router AND mounts sub-routers via .route()
113
+ return (
114
+ (/createRouter\s*\(/.test(stripped) || /new\s+Hono\s*[<(]/.test(stripped)) &&
115
+ stripped.includes('.route(') &&
116
+ content.includes('export default')
117
+ );
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ export interface MigrationCheckResult {
124
+ /** Whether migration is available for this project */
125
+ available: boolean;
126
+ /** Route files found in src/api/ */
127
+ routeFiles: string[];
128
+ /** Whether the user has already been notified */
129
+ alreadyNotified: boolean;
130
+ }
131
+
132
+ /**
133
+ * Check if the user's app.ts already passes a router to createApp().
134
+ * If so, they've already adopted explicit routing and no migration is needed.
135
+ */
136
+ function hasExplicitRouterInCreateApp(rootDir: string): boolean {
137
+ const rootAppPath = join(rootDir, 'app.ts');
138
+ const srcAppPath = join(rootDir, 'src', 'app.ts');
139
+ const appPath = existsSync(rootAppPath)
140
+ ? rootAppPath
141
+ : existsSync(srcAppPath)
142
+ ? srcAppPath
143
+ : null;
144
+ if (!appPath) return false;
145
+ try {
146
+ const content = readFileSync(appPath, 'utf-8');
147
+ return /createApp\s*\(\s*\{[^}]*\brouter\b/.test(content);
148
+ } catch {
149
+ return false;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Check if a project is eligible for migration to explicit routing.
155
+ * Returns info about the project's routing state without performing any action.
156
+ *
157
+ * A project is eligible when:
158
+ * - createApp() does NOT already have a `router` property
159
+ * - It has multiple route files in src/api/
160
+ * - It does NOT already have an explicit src/api/index.ts root router
161
+ */
162
+ export function checkMigrationEligibility(rootDir: string): MigrationCheckResult {
163
+ // If createApp({ router }) is already present, they've adopted explicit routing
164
+ if (hasExplicitRouterInCreateApp(rootDir)) {
165
+ return { available: false, routeFiles: [], alreadyNotified: false };
166
+ }
167
+
168
+ const routeFiles = detectFileBasedRoutes(rootDir);
169
+
170
+ // Need at least 2 route files for migration to be useful
171
+ // (a single file already acts as an explicit root router)
172
+ if (routeFiles.length < 2) {
173
+ return { available: false, routeFiles: [], alreadyNotified: false };
174
+ }
175
+
176
+ // If there's already an explicit root router, check if all route files
177
+ // are already imported. If so, nothing to do.
178
+ if (hasExplicitRootRouter(rootDir)) {
179
+ const indexPath = join(rootDir, 'src', 'api', 'index.ts');
180
+ const indexContent = readFileSync(indexPath, 'utf-8');
181
+ const filesToMount = routeFiles.filter((f) => f !== 'index.ts');
182
+ const allImported = filesToMount.every((f) => {
183
+ const importPath = `./${f.replace(/\.tsx?$/, '')}`;
184
+ return indexContent.includes(importPath);
185
+ });
186
+ if (allImported) {
187
+ return { available: false, routeFiles: [], alreadyNotified: false };
188
+ }
189
+ }
190
+
191
+ const state = readMigrationState(rootDir);
192
+
193
+ // Already migrated — don't prompt again
194
+ if (state?.state === 'migrated') {
195
+ return { available: false, routeFiles: [], alreadyNotified: true };
196
+ }
197
+
198
+ const alreadyNotified = state?.state === 'notified' || state?.state === 'dismissed';
199
+
200
+ return { available: true, routeFiles, alreadyNotified };
201
+ }
202
+
203
+ /**
204
+ * Convert a string segment into a valid camelCase identifier part.
205
+ * Splits on non-alphanumeric characters (hyphens, dots, underscores, spaces, etc.)
206
+ * and capitalizes each sub-word.
207
+ *
208
+ * e.g. "user-profile" → "userProfile"
209
+ * "my_api" → "myApi"
210
+ * "foo.bar" → "fooBar"
211
+ * "123start" → "_123start" (leading digit gets underscore prefix)
212
+ */
213
+ function sanitizeSegment(segment: string, capitalize: boolean): string {
214
+ // Split on non-alphanumeric characters
215
+ const parts = segment.split(/[^a-zA-Z0-9]+/).filter(Boolean);
216
+ if (parts.length === 0) return '_';
217
+
218
+ const result = parts
219
+ .map((part, i) => {
220
+ if (i === 0 && !capitalize) return part.toLowerCase();
221
+ return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
222
+ })
223
+ .join('');
224
+
225
+ // Prefix with underscore if starts with a digit
226
+ if (/^\d/.test(result)) return `_${result}`;
227
+ return result;
228
+ }
229
+
230
+ /**
231
+ * Derive a descriptive camelCase import name from a route file path.
232
+ * Handles special characters (hyphens, dots, underscores) in file/directory names
233
+ * by converting them to camelCase boundaries.
234
+ *
235
+ * e.g. "auth/route.ts" → "authRouter"
236
+ * "users.ts" → "usersRouter"
237
+ * "users/profile.ts" → "usersProfileRouter"
238
+ * "health.ts" → "healthRouter"
239
+ * "user-profile.ts" → "userProfileRouter"
240
+ * "my-api/v2-routes.ts" → "myApiV2RoutesRouter"
241
+ * "foo_bar/route.ts" → "fooBarRouter"
242
+ */
243
+ function deriveImportName(file: string): string {
244
+ const withoutExt = file.replace(/\.tsx?$/, '');
245
+ const dir = dirname(withoutExt);
246
+ const base = basename(withoutExt);
247
+
248
+ let segments: string[];
249
+ if (dir === '.') {
250
+ // Top-level file: users.ts → ["users"]
251
+ segments = base === 'index' || base === 'route' ? ['root'] : [base];
252
+ } else if (base === 'index' || base === 'route') {
253
+ // Convention file in subdirectory: auth/route.ts → ["auth"]
254
+ segments = dir.split('/');
255
+ } else {
256
+ // Named file in subdirectory: users/profile.ts → ["users", "profile"]
257
+ segments = [...dir.split('/'), base];
258
+ }
259
+
260
+ // Convert to camelCase + "Router" suffix, sanitizing each segment
261
+ const camel = segments.map((s, i) => sanitizeSegment(s, i > 0)).join('');
262
+ return `${camel}Router`;
263
+ }
264
+
265
+ /**
266
+ * Compute the mount path and import path for a route file.
267
+ */
268
+ function computeRouteMountInfo(file: string): {
269
+ importPath: string;
270
+ importName: string;
271
+ mountPath: string;
272
+ } {
273
+ const withoutExt = file.replace(/\.tsx?$/, '');
274
+ const importPath = `./${withoutExt}`;
275
+ const importName = deriveImportName(file);
276
+
277
+ const dir = dirname(file);
278
+ const base = basename(withoutExt);
279
+
280
+ let mountPath: string;
281
+ if (dir === '.') {
282
+ if (base === 'index' || base === 'route') {
283
+ mountPath = '/';
284
+ } else {
285
+ mountPath = `/${base}`;
286
+ }
287
+ } else if (base === 'index' || base === 'route') {
288
+ mountPath = `/${dir}`;
289
+ } else {
290
+ mountPath = `/${dir}/${base}`;
291
+ }
292
+
293
+ return { importPath, importName, mountPath };
294
+ }
295
+
296
+ /**
297
+ * Deduplicate import names by appending a numeric suffix when collisions occur.
298
+ */
299
+ function deduplicateImportNames(
300
+ infos: Array<{ importPath: string; importName: string; mountPath: string }>
301
+ ): Array<{ importPath: string; importName: string; mountPath: string }> {
302
+ const seen = new Map<string, number>();
303
+ return infos.map((info) => {
304
+ const count = seen.get(info.importName) ?? 0;
305
+ seen.set(info.importName, count + 1);
306
+ if (count > 0) {
307
+ return { ...info, importName: `${info.importName}${count}` };
308
+ }
309
+ return info;
310
+ });
311
+ }
312
+
313
+ /**
314
+ * Generate a fresh src/api/index.ts that imports and mounts all route files.
315
+ */
316
+ function generateRootRouter(routeFiles: string[]): string {
317
+ const sorted = [...routeFiles].sort();
318
+ const infos = deduplicateImportNames(sorted.map(computeRouteMountInfo));
319
+
320
+ const imports = infos.map((i) => `import ${i.importName} from '${i.importPath}';`);
321
+ const mounts = infos.map((i) => `router.route('${i.mountPath}', ${i.importName});`);
322
+
323
+ return `import { createRouter } from '@agentuity/runtime';
324
+ ${imports.join('\n')}
325
+
326
+ const router = createRouter();
327
+
328
+ ${mounts.join('\n')}
329
+
330
+ export default router;
331
+ `;
332
+ }
333
+
334
+ /**
335
+ * Detect the router variable name used in an existing file.
336
+ * Looks for patterns like:
337
+ * - `const router = createRouter()` / `const api = createRouter()`
338
+ * - `const router = new Hono()` / `const app = new Hono()`
339
+ * - Falls back to checking what identifier is default-exported
340
+ *
341
+ * Returns the variable name (e.g., 'router', 'api', 'app') or 'router' as fallback.
342
+ */
343
+ function detectRouterVariableName(content: string): string {
344
+ // Look for variable assigned from createRouter() or new Hono()
345
+ const routerAssignMatch = content.match(
346
+ /(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:createRouter\s*\(|new\s+Hono\s*[<(])/
347
+ );
348
+ if (routerAssignMatch?.[1]) {
349
+ return routerAssignMatch[1];
350
+ }
351
+
352
+ // Look for existing .route() calls to infer the variable name
353
+ const routeCallMatch = content.match(/([a-zA-Z_$][a-zA-Z0-9_$]*)\.route\s*\(/);
354
+ if (routeCallMatch?.[1]) {
355
+ return routeCallMatch[1];
356
+ }
357
+
358
+ // Fall back to the default export identifier
359
+ const exportMatch = content.match(/export\s+default\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*;?\s*$/m);
360
+ if (exportMatch?.[1]) {
361
+ return exportMatch[1];
362
+ }
363
+
364
+ return 'router';
365
+ }
366
+
367
+ /**
368
+ * Modify an existing src/api/index.ts to add imports and route mounts for
369
+ * route files that are not already imported. Inserts imports at the top
370
+ * (after existing imports) and mounts just before the `export default` line.
371
+ *
372
+ * Detects the router variable name used in the file (e.g., 'router', 'api', 'app')
373
+ * and uses it in the generated `.route()` calls.
374
+ */
375
+ function mergeIntoExistingIndex(
376
+ existingContent: string,
377
+ routeFiles: string[]
378
+ ): { content: string; added: string[] } {
379
+ const sorted = [...routeFiles].sort();
380
+ const allInfos = deduplicateImportNames(sorted.map(computeRouteMountInfo));
381
+
382
+ // Filter out files already imported in the existing content
383
+ const newInfos = allInfos.filter((info) => !existingContent.includes(info.importPath));
384
+
385
+ if (newInfos.length === 0) {
386
+ return { content: existingContent, added: [] };
387
+ }
388
+
389
+ // Detect the router variable name used in this file
390
+ const routerVar = detectRouterVariableName(existingContent);
391
+
392
+ const newImports = newInfos.map((i) => `import ${i.importName} from '${i.importPath}';`);
393
+ const newMounts = newInfos.map((i) => `${routerVar}.route('${i.mountPath}', ${i.importName});`);
394
+
395
+ const lines = existingContent.split('\n');
396
+
397
+ // Find the last import line to insert new imports after it
398
+ let lastImportIndex = -1;
399
+ for (let i = 0; i < lines.length; i++) {
400
+ const trimmed = lines[i]!.trim();
401
+ if (trimmed.startsWith('import ') || trimmed.startsWith('import{')) {
402
+ lastImportIndex = i;
403
+ }
404
+ }
405
+
406
+ // Find the export default line to insert mounts before it
407
+ let exportDefaultIndex = -1;
408
+ for (let i = lines.length - 1; i >= 0; i--) {
409
+ if (lines[i]!.trim().startsWith('export default')) {
410
+ exportDefaultIndex = i;
411
+ break;
412
+ }
413
+ }
414
+
415
+ if (exportDefaultIndex === -1) {
416
+ // No export default found — append mounts at the end
417
+ exportDefaultIndex = lines.length;
418
+ }
419
+
420
+ // Insert new imports after the last existing import
421
+ const insertImportAt = lastImportIndex === -1 ? 0 : lastImportIndex + 1;
422
+ lines.splice(insertImportAt, 0, ...newImports);
423
+
424
+ // Adjust exportDefaultIndex since we inserted lines above it
425
+ const adjustedExportIndex = exportDefaultIndex + newImports.length;
426
+
427
+ // Insert mounts before export default, with a blank line separator
428
+ lines.splice(adjustedExportIndex, 0, '', ...newMounts, '');
429
+
430
+ return {
431
+ content: lines.join('\n'),
432
+ added: newInfos.map((i) => i.mountPath),
433
+ };
434
+ }
435
+
436
+ /**
437
+ * Detect the default export name from a router file.
438
+ * Returns the identifier name if found (e.g., 'router', 'api', 'app'),
439
+ * or 'router' as a fallback for anonymous/expression default exports.
440
+ */
441
+ function detectDefaultExportName(filePath: string): string {
442
+ if (!existsSync(filePath)) return 'router';
443
+
444
+ try {
445
+ const content = readFileSync(filePath, 'utf-8');
446
+ const lines = content.split('\n');
447
+
448
+ for (const line of lines) {
449
+ const trimmed = line.trim();
450
+
451
+ // export default router;
452
+ const identifierMatch = trimmed.match(
453
+ /^export\s+default\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*;?\s*$/
454
+ );
455
+ if (identifierMatch && identifierMatch[1]) {
456
+ return identifierMatch[1];
457
+ }
458
+ }
459
+
460
+ // Check for `const X = createRouter(); ... export default X;` pattern
461
+ // by finding the variable assigned from createRouter() or new Hono()
462
+ const routerVarMatch = content.match(
463
+ /(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:createRouter\s*\(|new\s+Hono\s*[<(])/
464
+ );
465
+ if (routerVarMatch && routerVarMatch[1]) {
466
+ // Verify this variable is actually the default export
467
+ const exportPattern = new RegExp(`export\\s+default\\s+${routerVarMatch[1]}\\s*;?`);
468
+ if (exportPattern.test(content)) {
469
+ return routerVarMatch[1];
470
+ }
471
+ }
472
+ } catch {
473
+ // Fall through to default
474
+ }
475
+
476
+ return 'router';
477
+ }
478
+
479
+ /**
480
+ * Update src/app.ts to import the explicit router and pass it to createApp().
481
+ *
482
+ * Handles these createApp patterns:
483
+ * - `createApp()` → `createApp({ router })`
484
+ * - `createApp({})` → `createApp({ router })`
485
+ * - `createApp({ ... })` → `createApp({ router, ... })`
486
+ * - `createApp({ router: x })` → already has router, skip
487
+ *
488
+ * The import name is derived from the default export of src/api/index.ts
489
+ * (e.g., if it exports `api`, the import is `import api from './api/index'`
490
+ * and the property is `router: api`).
491
+ *
492
+ * Returns null if app.ts doesn't exist or doesn't need changes.
493
+ */
494
+ function updateAppTs(
495
+ rootDir: string,
496
+ routerExportName: string
497
+ ): { content: string; changed: boolean; appPath: string } | null {
498
+ // Try root app.ts first (standard convention), then src/app.ts
499
+ const rootAppPath = join(rootDir, 'app.ts');
500
+ const srcAppPath = join(rootDir, 'src', 'app.ts');
501
+ const appPath = existsSync(rootAppPath)
502
+ ? rootAppPath
503
+ : existsSync(srcAppPath)
504
+ ? srcAppPath
505
+ : null;
506
+
507
+ if (!appPath) return null;
508
+
509
+ const content = readFileSync(appPath, 'utf-8');
510
+
511
+ // Skip if already has a router property in createApp
512
+ if (/createApp\s*\(\s*\{[^}]*\brouter\b/.test(content)) {
513
+ return { content, changed: false, appPath };
514
+ }
515
+
516
+ // Skip if createApp is not used
517
+ if (!content.includes('createApp')) {
518
+ return { content, changed: false, appPath };
519
+ }
520
+
521
+ const lines = content.split('\n');
522
+
523
+ // Step 1: Add import for the router after the last import
524
+ // Import path depends on where app.ts lives:
525
+ // root app.ts → './src/api/index'
526
+ // src/app.ts → './api/index'
527
+ const isRootAppTs = appPath === rootAppPath;
528
+ const importName = routerExportName;
529
+ const importPath = isRootAppTs ? './src/api/index' : './api/index';
530
+ const importStatement = `import ${importName} from '${importPath}';`;
531
+
532
+ // Check if this import already exists
533
+ const alreadyImported = lines.some(
534
+ (line) =>
535
+ line.includes("from './api/index'") ||
536
+ line.includes("from './api'") ||
537
+ line.includes("from './src/api/index'") ||
538
+ line.includes("from './src/api'")
539
+ );
540
+
541
+ if (!alreadyImported) {
542
+ let lastImportIndex = -1;
543
+ for (let i = 0; i < lines.length; i++) {
544
+ const trimmed = lines[i]!.trim();
545
+ if (trimmed.startsWith('import ') || trimmed.startsWith('import{')) {
546
+ lastImportIndex = i;
547
+ }
548
+ }
549
+ const insertAt = lastImportIndex === -1 ? 0 : lastImportIndex + 1;
550
+ lines.splice(insertAt, 0, importStatement);
551
+ }
552
+
553
+ // Step 2: Add router property to createApp() call
554
+ // Determine the property value: if export name is 'router', use shorthand
555
+ // Otherwise use `router: exportName`
556
+ const routerProp = importName === 'router' ? 'router' : `router: ${importName}`;
557
+ let modified = false;
558
+
559
+ for (let i = 0; i < lines.length; i++) {
560
+ const line = lines[i]!;
561
+
562
+ // Match createApp() with no arguments
563
+ if (/createApp\s*\(\s*\)/.test(line)) {
564
+ lines[i] = line.replace(/createApp\s*\(\s*\)/, `createApp({ ${routerProp} })`);
565
+ modified = true;
566
+ break;
567
+ }
568
+
569
+ // Match createApp({}) with empty object
570
+ if (/createApp\s*\(\s*\{\s*\}\s*\)/.test(line)) {
571
+ lines[i] = line.replace(/createApp\s*\(\s*\{\s*\}\s*\)/, `createApp({ ${routerProp} })`);
572
+ modified = true;
573
+ break;
574
+ }
575
+
576
+ // Match createApp({ ...existing }) with existing properties
577
+ // Insert router as the first property after the opening brace
578
+ if (/createApp\s*\(\s*\{/.test(line)) {
579
+ lines[i] = line.replace(/createApp\s*\(\s*\{/, `createApp({ ${routerProp},`);
580
+ modified = true;
581
+ break;
582
+ }
583
+ }
584
+
585
+ if (!modified && !alreadyImported) {
586
+ // createApp pattern not recognized — don't write partial changes
587
+ return null;
588
+ }
589
+
590
+ return { content: lines.join('\n'), changed: !alreadyImported || modified, appPath };
591
+ }
592
+
593
+ export interface MigrationResult {
594
+ success: boolean;
595
+ filesCreated: string[];
596
+ filesModified: string[];
597
+ message: string;
598
+ }
599
+
600
+ /**
601
+ * Perform the migration to explicit routing.
602
+ *
603
+ * 1. Generates/updates `src/api/index.ts` to import and mount all route files
604
+ * 2. Updates `src/app.ts` to import the router and pass it to `createApp({ router })`
605
+ *
606
+ * Existing route files are NOT modified — they already export routers.
607
+ */
608
+ export function performMigration(rootDir: string, routeFiles: string[]): MigrationResult {
609
+ const filesCreated: string[] = [];
610
+ const filesModified: string[] = [];
611
+
612
+ try {
613
+ const apiIndexPath = join(rootDir, 'src', 'api', 'index.ts');
614
+
615
+ // Filter out index.ts itself from the route files to mount
616
+ const filesToMount = routeFiles.filter((f) => f !== 'index.ts');
617
+ if (filesToMount.length === 0) {
618
+ return {
619
+ success: false,
620
+ filesCreated: [],
621
+ filesModified: [],
622
+ message: 'No route files to migrate (only index.ts found).',
623
+ };
624
+ }
625
+
626
+ let indexMessage: string;
627
+
628
+ if (existsSync(apiIndexPath)) {
629
+ // Existing index.ts — merge new imports and mounts into it
630
+ const existingContent = readFileSync(apiIndexPath, 'utf-8');
631
+ const { content: merged, added } = mergeIntoExistingIndex(existingContent, filesToMount);
632
+
633
+ if (added.length === 0) {
634
+ // All routes already imported — still try to update app.ts
635
+ const routerExportName = detectDefaultExportName(apiIndexPath);
636
+ const appResult = updateAppTs(rootDir, routerExportName);
637
+ if (appResult?.changed) {
638
+ writeFileSync(appResult.appPath, appResult.content);
639
+ const relAppPath = relative(rootDir, appResult.appPath);
640
+ filesModified.push(relAppPath);
641
+ return {
642
+ success: true,
643
+ filesCreated: [],
644
+ filesModified,
645
+ message: `All route files are already imported in src/api/index.ts. Updated ${relAppPath} with explicit router.`,
646
+ };
647
+ }
648
+ return {
649
+ success: true,
650
+ filesCreated: [],
651
+ filesModified: [],
652
+ message: 'All route files are already imported in src/api/index.ts.',
653
+ };
654
+ }
655
+
656
+ writeFileSync(apiIndexPath, merged);
657
+ filesModified.push('src/api/index.ts');
658
+ indexMessage = `Added ${added.length} route mount(s) to existing src/api/index.ts.`;
659
+ } else {
660
+ // No existing index.ts — generate a fresh one
661
+ const rootRouterContent = generateRootRouter(filesToMount);
662
+ const apiDir = join(rootDir, 'src', 'api');
663
+ if (!existsSync(apiDir)) mkdirSync(apiDir, { recursive: true });
664
+ writeFileSync(apiIndexPath, rootRouterContent);
665
+ filesCreated.push('src/api/index.ts');
666
+ indexMessage = 'Migrated to explicit routing in src/api/index.ts.';
667
+ }
668
+
669
+ // Update app.ts to import and use the explicit router
670
+ const routerExportName = detectDefaultExportName(apiIndexPath);
671
+ const appResult = updateAppTs(rootDir, routerExportName);
672
+ if (appResult?.changed) {
673
+ writeFileSync(appResult.appPath, appResult.content);
674
+ const relAppPath = relative(rootDir, appResult.appPath);
675
+ filesModified.push(relAppPath);
676
+ }
677
+
678
+ writeMigrationState(rootDir, 'migrated');
679
+
680
+ return {
681
+ success: true,
682
+ filesCreated,
683
+ filesModified,
684
+ message: indexMessage,
685
+ };
686
+ } catch (error) {
687
+ return {
688
+ success: false,
689
+ filesCreated,
690
+ filesModified: [],
691
+ message: `Migration failed: ${error instanceof Error ? error.message : String(error)}`,
692
+ };
693
+ }
694
+ }
695
+
696
+ /**
697
+ * Show the migration notice and optionally perform migration.
698
+ *
699
+ * Called during `dev` and `build` after dependency upgrades.
700
+ * Shows a banner the first time, then a shorter reminder on subsequent runs.
701
+ *
702
+ * @returns true if migration was performed, false otherwise
703
+ */
704
+ export async function promptRouteMigration(
705
+ rootDir: string,
706
+ logger: Logger,
707
+ options?: { interactive?: boolean }
708
+ ): Promise<boolean> {
709
+ const interactive = options?.interactive ?? process.stdin.isTTY;
710
+ const eligibility = checkMigrationEligibility(rootDir);
711
+
712
+ if (!eligibility.available) {
713
+ return false;
714
+ }
715
+
716
+ const { routeFiles, alreadyNotified } = eligibility;
717
+
718
+ // Non-interactive mode (CI, piped, AI agent): just log a notice
719
+ if (!interactive) {
720
+ if (!alreadyNotified) {
721
+ logger.info(
722
+ '[migration] This project uses file-based routing with %d route files in src/api/. ' +
723
+ 'Agentuity is moving to explicit routing, which will become the default in the next major release. ' +
724
+ 'Run `agentuity dev --migrate-routes` to migrate.',
725
+ routeFiles.length
726
+ );
727
+ writeMigrationState(rootDir, 'notified');
728
+ }
729
+ return false;
730
+ }
731
+
732
+ // First time: show full banner
733
+ if (!alreadyNotified) {
734
+ tui.newline();
735
+ tui.banner(
736
+ '✨ Migrate to Explicit Routing',
737
+ 'Agentuity is moving to explicit routing, which will become the\n' +
738
+ 'default in the next major release. File-based route discovery\n' +
739
+ 'will be deprecated.\n' +
740
+ '\n' +
741
+ `Your project has ${routeFiles.length} route files in src/api/ that are\n` +
742
+ 'auto-discovered at build time. Explicit routing gives you a single\n' +
743
+ 'src/api/index.ts that imports and mounts all sub-routers — just\n' +
744
+ 'like a standard Hono application.\n' +
745
+ '\n' +
746
+ `${tui.muted('Before:')} ${routeFiles.length} files auto-discovered from src/api/**/*.ts\n` +
747
+ `${tui.muted('After:')} One src/api/index.ts that imports and mounts them\n` +
748
+ '\n' +
749
+ 'Your existing route files are not modified. Your app.ts will be\n' +
750
+ 'updated to import the router and pass it to createApp({ router }).',
751
+ { centerTitle: false }
752
+ );
753
+ } else {
754
+ // Subsequent runs: shorter reminder
755
+ tui.newline();
756
+ tui.info(
757
+ `${tui.bold('Explicit routing migration available')} — run with ${tui.muted('--migrate-routes')} or choose below.`
758
+ );
759
+ }
760
+
761
+ tui.newline();
762
+
763
+ const action = await tui.confirm('Would you like to migrate to explicit routing now?', false);
764
+
765
+ if (!action) {
766
+ writeMigrationState(rootDir, 'dismissed');
767
+ tui.info(`You can migrate later by running: ${tui.muted('agentuity dev --migrate-routes')}`);
768
+ tui.newline();
769
+ return false;
770
+ }
771
+
772
+ // Perform migration
773
+ tui.newline();
774
+ const result = performMigration(rootDir, routeFiles);
775
+
776
+ if (result.success) {
777
+ tui.success(result.message);
778
+ if (result.filesCreated.length > 0) {
779
+ tui.info(`Created: ${result.filesCreated.map((f) => tui.muted(f)).join(', ')}`);
780
+ }
781
+ if (result.filesModified.length > 0) {
782
+ tui.info(`Modified: ${result.filesModified.map((f) => tui.muted(f)).join(', ')}`);
783
+ }
784
+ tui.newline();
785
+ tui.info('Your existing route files were not changed — they already export routers.');
786
+ tui.newline();
787
+ } else {
788
+ tui.warning(result.message);
789
+ tui.newline();
790
+ }
791
+
792
+ return result.success;
793
+ }