@agentuity/cli 1.0.40 → 1.0.42

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 (102) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +9 -1
  3. package/dist/cli.js.map +1 -1
  4. package/dist/cmd/build/ast.d.ts.map +1 -1
  5. package/dist/cmd/build/ast.js +3 -3
  6. package/dist/cmd/build/ast.js.map +1 -1
  7. package/dist/cmd/build/typecheck.d.ts.map +1 -1
  8. package/dist/cmd/build/typecheck.js +52 -1
  9. package/dist/cmd/build/typecheck.js.map +1 -1
  10. package/dist/cmd/build/vite/static-renderer.d.ts.map +1 -1
  11. package/dist/cmd/build/vite/static-renderer.js +22 -8
  12. package/dist/cmd/build/vite/static-renderer.js.map +1 -1
  13. package/dist/cmd/cloud/index.d.ts.map +1 -1
  14. package/dist/cmd/cloud/index.js +4 -0
  15. package/dist/cmd/cloud/index.js.map +1 -1
  16. package/dist/cmd/cloud/monitor.d.ts +3 -0
  17. package/dist/cmd/cloud/monitor.d.ts.map +1 -0
  18. package/dist/cmd/cloud/monitor.js +300 -0
  19. package/dist/cmd/cloud/monitor.js.map +1 -0
  20. package/dist/cmd/cloud/oidc/activity.d.ts +2 -0
  21. package/dist/cmd/cloud/oidc/activity.d.ts.map +1 -0
  22. package/dist/cmd/cloud/oidc/activity.js +54 -0
  23. package/dist/cmd/cloud/oidc/activity.js.map +1 -0
  24. package/dist/cmd/cloud/oidc/create.d.ts +2 -0
  25. package/dist/cmd/cloud/oidc/create.d.ts.map +1 -0
  26. package/dist/cmd/cloud/oidc/create.js +201 -0
  27. package/dist/cmd/cloud/oidc/create.js.map +1 -0
  28. package/dist/cmd/cloud/oidc/delete.d.ts +2 -0
  29. package/dist/cmd/cloud/oidc/delete.d.ts.map +1 -0
  30. package/dist/cmd/cloud/oidc/delete.js +56 -0
  31. package/dist/cmd/cloud/oidc/delete.js.map +1 -0
  32. package/dist/cmd/cloud/oidc/get.d.ts +2 -0
  33. package/dist/cmd/cloud/oidc/get.d.ts.map +1 -0
  34. package/dist/cmd/cloud/oidc/get.js +59 -0
  35. package/dist/cmd/cloud/oidc/get.js.map +1 -0
  36. package/dist/cmd/cloud/oidc/index.d.ts +3 -0
  37. package/dist/cmd/cloud/oidc/index.d.ts.map +1 -0
  38. package/dist/cmd/cloud/oidc/index.js +32 -0
  39. package/dist/cmd/cloud/oidc/index.js.map +1 -0
  40. package/dist/cmd/cloud/oidc/list.d.ts +2 -0
  41. package/dist/cmd/cloud/oidc/list.d.ts.map +1 -0
  42. package/dist/cmd/cloud/oidc/list.js +45 -0
  43. package/dist/cmd/cloud/oidc/list.js.map +1 -0
  44. package/dist/cmd/cloud/oidc/rotate-secret.d.ts +2 -0
  45. package/dist/cmd/cloud/oidc/rotate-secret.d.ts.map +1 -0
  46. package/dist/cmd/cloud/oidc/rotate-secret.js +63 -0
  47. package/dist/cmd/cloud/oidc/rotate-secret.js.map +1 -0
  48. package/dist/cmd/cloud/oidc/users.d.ts +2 -0
  49. package/dist/cmd/cloud/oidc/users.d.ts.map +1 -0
  50. package/dist/cmd/cloud/oidc/users.js +50 -0
  51. package/dist/cmd/cloud/oidc/users.js.map +1 -0
  52. package/dist/cmd/project/import.d.ts.map +1 -1
  53. package/dist/cmd/project/import.js +11 -1
  54. package/dist/cmd/project/import.js.map +1 -1
  55. package/dist/cmd/project/reconcile.d.ts +8 -0
  56. package/dist/cmd/project/reconcile.d.ts.map +1 -1
  57. package/dist/cmd/project/reconcile.js +150 -61
  58. package/dist/cmd/project/reconcile.js.map +1 -1
  59. package/dist/cmd/project/remote-import.d.ts +2 -0
  60. package/dist/cmd/project/remote-import.d.ts.map +1 -1
  61. package/dist/cmd/project/remote-import.js +2 -2
  62. package/dist/cmd/project/remote-import.js.map +1 -1
  63. package/dist/config.d.ts.map +1 -1
  64. package/dist/config.js +22 -6
  65. package/dist/config.js.map +1 -1
  66. package/dist/schema-parser.d.ts.map +1 -1
  67. package/dist/schema-parser.js +6 -2
  68. package/dist/schema-parser.js.map +1 -1
  69. package/dist/utils/jsonc.d.ts +13 -0
  70. package/dist/utils/jsonc.d.ts.map +1 -0
  71. package/dist/utils/jsonc.js +63 -0
  72. package/dist/utils/jsonc.js.map +1 -0
  73. package/dist/utils/route-migration.d.ts +2 -1
  74. package/dist/utils/route-migration.d.ts.map +1 -1
  75. package/dist/utils/route-migration.js +23 -32
  76. package/dist/utils/route-migration.js.map +1 -1
  77. package/dist/utils/zip.d.ts.map +1 -1
  78. package/dist/utils/zip.js +18 -2
  79. package/dist/utils/zip.js.map +1 -1
  80. package/package.json +6 -7
  81. package/src/cli.ts +9 -1
  82. package/src/cmd/build/ast.ts +6 -3
  83. package/src/cmd/build/typecheck.ts +60 -1
  84. package/src/cmd/build/vite/static-renderer.ts +24 -8
  85. package/src/cmd/cloud/index.ts +4 -0
  86. package/src/cmd/cloud/monitor.ts +375 -0
  87. package/src/cmd/cloud/oidc/activity.ts +61 -0
  88. package/src/cmd/cloud/oidc/create.ts +232 -0
  89. package/src/cmd/cloud/oidc/delete.ts +63 -0
  90. package/src/cmd/cloud/oidc/get.ts +65 -0
  91. package/src/cmd/cloud/oidc/index.ts +35 -0
  92. package/src/cmd/cloud/oidc/list.ts +50 -0
  93. package/src/cmd/cloud/oidc/rotate-secret.ts +77 -0
  94. package/src/cmd/cloud/oidc/users.ts +57 -0
  95. package/src/cmd/project/import.ts +11 -1
  96. package/src/cmd/project/reconcile.ts +147 -61
  97. package/src/cmd/project/remote-import.ts +4 -2
  98. package/src/config.ts +24 -6
  99. package/src/schema-parser.ts +8 -2
  100. package/src/utils/jsonc.ts +67 -0
  101. package/src/utils/route-migration.ts +29 -40
  102. package/src/utils/zip.ts +17 -2
@@ -40,6 +40,14 @@ export interface ReconcileOptions {
40
40
  interactive?: boolean;
41
41
  /** If true, skip prompts and just validate */
42
42
  validateOnly?: boolean;
43
+ /** If true, auto-confirm all prompts (--confirm flag) */
44
+ confirm?: boolean;
45
+ /** Pre-selected organization ID (skips org selection prompt) */
46
+ orgId?: string;
47
+ /** Pre-selected region (skips region selection prompt) */
48
+ region?: string;
49
+ /** Project name from --name flag */
50
+ name?: string;
43
51
  }
44
52
 
45
53
  /**
@@ -315,7 +323,7 @@ async function importExistingProject(
315
323
  ): Promise<ReconcileResult> {
316
324
  const { dir, apiClient, config, logger } = opts;
317
325
 
318
- if (!options?.skipPrompt) {
326
+ if (!options?.skipPrompt && opts.interactive !== false) {
319
327
  tui.warning(
320
328
  "You don't have access to this project. It may have been deleted or transferred to another organization."
321
329
  );
@@ -333,29 +341,67 @@ async function importExistingProject(
333
341
  tui.newline();
334
342
  }
335
343
 
336
- // Select org
337
- const orgId = await selectOrg(orgs, config, existingConfig.orgId);
344
+ // Select org - use --org-id if provided, otherwise auto-select or prompt
345
+ let orgId: string;
346
+ if (opts.orgId) {
347
+ orgId = opts.orgId;
348
+ } else if (opts.confirm) {
349
+ orgId = await tui.selectOrganization(orgs, config.preferences?.orgId, true);
350
+ } else {
351
+ orgId = await selectOrg(orgs, config, existingConfig.orgId);
352
+ }
338
353
 
339
- // Fetch regions and select
340
- const regions = await tui.spinner({
341
- message: 'Fetching regions',
342
- clearOnSuccess: true,
343
- callback: () => fetchRegionsWithCache(config.name, apiClient, logger),
344
- });
345
- const region = await selectRegion(regions, existingConfig.region);
354
+ // Select region (use pre-selected if available, otherwise fetch and prompt/auto-select)
355
+ let region: string;
356
+ if (opts.region) {
357
+ region = opts.region;
358
+ } else {
359
+ const regions = await tui.spinner({
360
+ message: 'Fetching regions',
361
+ clearOnSuccess: true,
362
+ callback: () => fetchRegionsWithCache(config.name, apiClient, logger),
363
+ });
364
+
365
+ if (opts.confirm) {
366
+ // Auto-select: use existing config region if valid, otherwise first available
367
+ const defaultRegion = existingConfig.region;
368
+ if (defaultRegion && regions.some((r) => r.region === defaultRegion)) {
369
+ region = defaultRegion;
370
+ } else {
371
+ const firstRegion = regions[0];
372
+ if (!firstRegion) {
373
+ return { status: 'error', message: 'No cloud regions available.' };
374
+ }
375
+ region = firstRegion.region;
376
+ }
377
+ } else {
378
+ region = await selectRegion(regions, existingConfig.region);
379
+ }
380
+ }
346
381
 
347
382
  // Get project name
348
383
  const defaultName = await getDefaultProjectName(dir);
349
- const projectName = await textPrompt({
350
- message: 'Project name:',
351
- initial: defaultName,
352
- validate: (value: string) => {
353
- if (!value || value.trim().length === 0) {
354
- return 'Project name is required';
355
- }
356
- return true;
357
- },
358
- });
384
+ let projectName: string;
385
+ if (opts.name) {
386
+ const trimmed = opts.name.trim();
387
+ if (trimmed.length === 0) {
388
+ return { status: 'error', message: 'Project name is required.' };
389
+ }
390
+ projectName = trimmed;
391
+ } else if (opts.confirm) {
392
+ projectName = defaultName;
393
+ } else {
394
+ projectName = await textPrompt({
395
+ message: 'Project name:',
396
+ initial: defaultName,
397
+ validate: (value: string) => {
398
+ if (!value || value.trim().length === 0) {
399
+ return 'Project name is required';
400
+ }
401
+ return true;
402
+ },
403
+ });
404
+ }
359
405
 
360
406
  // Create the project
361
407
  const newProject = await tui.spinner({
@@ -407,51 +453,87 @@ async function importExistingProject(
407
453
  async function createNewProject(opts: ReconcileOptions): Promise<ReconcileResult> {
408
454
  const { dir, apiClient, config, logger } = opts;
409
455
 
410
- tui.warning('This project is not registered with Agentuity Cloud.');
411
- tui.newline();
456
+ if (opts.interactive !== false) {
457
+ tui.warning('This project is not registered with Agentuity Cloud.');
458
+ tui.newline();
459
+
460
+ const shouldCreate = await tui.confirm('Would you like to register it now?', true);
412
461
 
413
- const shouldCreate = await tui.confirm('Would you like to register it now?', true);
462
+ if (!shouldCreate) {
463
+ return { status: 'skipped', message: 'Project registration cancelled.' };
464
+ }
414
465
 
415
- if (!shouldCreate) {
416
- return { status: 'skipped', message: 'Project registration cancelled.' };
466
+ tui.newline();
417
467
  }
418
468
 
419
- tui.newline();
420
-
421
- // Fetch user's orgs
422
- const orgs = await tui.spinner({
423
- message: 'Fetching organizations',
424
- clearOnSuccess: true,
425
- callback: () => listOrganizations(apiClient),
426
- });
469
+ // Select org - use --org-id if provided, otherwise fetch and select/auto-select
470
+ let orgId: string;
471
+ if (opts.orgId) {
472
+ orgId = opts.orgId;
473
+ } else {
474
+ // Fetch user's orgs
475
+ const orgs = await tui.spinner({
476
+ message: 'Fetching organizations',
477
+ clearOnSuccess: true,
478
+ callback: () => listOrganizations(apiClient),
479
+ });
480
+
481
+ if (orgs.length === 0) {
482
+ return { status: 'error', message: 'No organizations found for your account.' };
483
+ }
427
484
 
428
- if (orgs.length === 0) {
429
- return { status: 'error', message: 'No organizations found for your account.' };
485
+ if (opts.confirm) {
486
+ orgId = await tui.selectOrganization(orgs, config.preferences?.orgId, true);
487
+ } else {
488
+ orgId = await selectOrg(orgs, config);
489
+ }
430
490
  }
431
491
 
432
- // Select org
433
- const orgId = await selectOrg(orgs, config);
434
-
435
- // Fetch regions and select
436
- const regions = await tui.spinner({
437
- message: 'Fetching regions',
438
- clearOnSuccess: true,
439
- callback: () => fetchRegionsWithCache(config.name, apiClient, logger),
440
- });
441
- const region = await selectRegion(regions);
492
+ // Select region (use pre-selected if available, otherwise fetch and prompt/auto-select)
493
+ let region: string;
494
+ if (opts.region) {
495
+ region = opts.region;
496
+ } else {
497
+ const regions = await tui.spinner({
498
+ message: 'Fetching regions',
499
+ clearOnSuccess: true,
500
+ callback: () => fetchRegionsWithCache(config.name, apiClient, logger),
501
+ });
502
+
503
+ if (opts.confirm) {
504
+ const firstRegion = regions[0];
505
+ if (!firstRegion) {
506
+ return { status: 'error', message: 'No cloud regions available.' };
507
+ }
508
+ region = firstRegion.region;
509
+ } else {
510
+ region = await selectRegion(regions);
511
+ }
512
+ }
442
513
 
443
514
  // Get project name from package.json or prompt
444
515
  const defaultName = await getDefaultProjectName(dir);
445
- const projectName = await textPrompt({
446
- message: 'Project name:',
447
- initial: defaultName,
448
- validate: (value: string) => {
449
- if (!value || value.trim().length === 0) {
450
- return 'Project name is required';
451
- }
452
- return true;
453
- },
454
- });
516
+ let projectName: string;
517
+ if (opts.name) {
518
+ const trimmed = opts.name.trim();
519
+ if (trimmed.length === 0) {
520
+ return { status: 'error', message: 'Project name is required.' };
521
+ }
522
+ projectName = trimmed;
523
+ } else if (opts.confirm) {
524
+ projectName = defaultName;
525
+ } else {
526
+ projectName = await textPrompt({
527
+ message: 'Project name:',
528
+ initial: defaultName,
529
+ validate: (value: string) => {
530
+ if (!value || value.trim().length === 0) {
531
+ return 'Project name is required';
532
+ }
533
+ return true;
534
+ },
535
+ });
536
+ }
455
537
 
456
538
  // Create the project
457
539
  const newProject = await tui.spinner({
@@ -627,7 +709,7 @@ export async function runProjectImport(opts: ReconcileOptions): Promise<Reconcil
627
709
  }
628
710
 
629
711
  // Has agentuity.json but no access - offer to import
630
- if (!interactive) {
712
+ if (!interactive && !opts.confirm) {
631
713
  return {
632
714
  status: 'error',
633
715
  message:
@@ -635,10 +717,12 @@ export async function runProjectImport(opts: ReconcileOptions): Promise<Reconcil
635
717
  };
636
718
  }
637
719
 
638
- return await importExistingProject(opts, projectConfig, userOrgs);
720
+ return await importExistingProject(opts, projectConfig, userOrgs, {
721
+ skipPrompt: opts.confirm,
722
+ });
639
723
  } catch {
640
724
  // Project doesn't exist - offer to import
641
- if (!interactive) {
725
+ if (!interactive && !opts.confirm) {
642
726
  return {
643
727
  status: 'error',
644
728
  message: 'Project not found. Run interactively to import it to your organization.',
@@ -646,14 +730,16 @@ export async function runProjectImport(opts: ReconcileOptions): Promise<Reconcil
646
730
  }
647
731
 
648
732
  const userOrgs = await listOrganizations(apiClient);
649
- return await importExistingProject(opts, projectConfig, userOrgs);
733
+ return await importExistingProject(opts, projectConfig, userOrgs, {
734
+ skipPrompt: opts.confirm,
735
+ });
650
736
  }
651
737
  }
652
738
 
653
739
  // No agentuity.json - validate structure and create new project
654
740
  const isValid = await isValidProjectStructure(dir);
655
741
 
656
- if (!isValid) {
742
+ if (!isValid && !opts.confirm) {
657
743
  return {
658
744
  status: 'error',
659
745
  message:
@@ -670,7 +756,7 @@ export async function runProjectImport(opts: ReconcileOptions): Promise<Reconcil
670
756
  };
671
757
  }
672
758
 
673
- if (!interactive) {
759
+ if (!interactive && !opts.confirm) {
674
760
  return {
675
761
  status: 'error',
676
762
  message: 'Project import requires interactive mode.',
@@ -62,6 +62,8 @@ export interface RemoteImportOptions {
62
62
  env?: string[];
63
63
  org?: string;
64
64
  region?: string;
65
+ /** If true, skip confirmation prompts (--confirm flag) */
66
+ confirm?: boolean;
65
67
  apiClient: APIClient;
66
68
  auth: AuthData;
67
69
  config: Config;
@@ -744,7 +746,7 @@ export async function runRemoteImport(options: RemoteImportOptions): Promise<voi
744
746
  optRegion,
745
747
  org
746
748
  );
747
- } else if (isTTY()) {
749
+ } else if (isTTY() && !options.confirm) {
748
750
  // Interactive mode: prompt for org/region/name
749
751
  projectInfo = await createProjectInteractive(apiClient, config, logger, parsed.repo);
750
752
  } else {
@@ -893,7 +895,7 @@ export async function runRemoteImport(options: RemoteImportOptions): Promise<voi
893
895
  }
894
896
 
895
897
  const resourceEnvVars: Record<string, string> = {};
896
- const interactive = isTTY();
898
+ const interactive = isTTY() && !options.confirm;
897
899
  const templateResources = template?.requirements?.resources ?? [];
898
900
  const templateEnvVars = template?.requirements?.env ?? [];
899
901
 
package/src/config.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  APIClient as ServerAPIClient,
11
11
  } from '@agentuity/server';
12
12
  import { YAML } from 'bun';
13
- import JSON5 from 'json5';
13
+ import { parseJSONC } from './utils/jsonc';
14
14
  import { z } from 'zod';
15
15
  import { clearProfileCache } from './cache';
16
16
  import { getCatalystUrl } from './catalyst';
@@ -141,6 +141,8 @@ function expandTilde(path: string): string {
141
141
  }
142
142
 
143
143
  let cachedConfig: Config | null | undefined;
144
+ // Track the resolved config path so saveConfig writes back to the same file
145
+ let cachedConfigPath: string | undefined;
144
146
 
145
147
  export async function loadConfig(
146
148
  customPath?: string,
@@ -217,6 +219,7 @@ export async function loadConfig(
217
219
  // This ensures --config flag is respected across all commands
218
220
  if (!skipCache) {
219
221
  cachedConfig = result.data;
222
+ cachedConfigPath = configPath;
220
223
  }
221
224
  return result.data;
222
225
  } catch (error) {
@@ -227,6 +230,7 @@ export async function loadConfig(
227
230
  // Note: For long-running processes, consider time-based cache expiry for transient failures.
228
231
  if (!skipCache) {
229
232
  cachedConfig = null;
233
+ cachedConfigPath = configPath;
230
234
  }
231
235
  return null;
232
236
  }
@@ -261,8 +265,19 @@ function formatYAML(obj: unknown, indent = 0): string {
261
265
  } else if (typeof value === 'string') {
262
266
  if (value === '') {
263
267
  lines.push(`${spaces}${key}: ""`);
264
- } else if (value.includes(':') || value.includes('#') || value.includes(' ')) {
265
- lines.push(`${spaces}${key}: "${value}"`);
268
+ } else if (
269
+ value.includes(':') ||
270
+ value.includes('#') ||
271
+ value.includes(' ') ||
272
+ value.includes('\\')
273
+ ) {
274
+ // Use single quotes to avoid YAML escape-sequence processing.
275
+ // Double-quoted YAML strings interpret backslash sequences (\n, \t, etc.),
276
+ // which breaks Windows paths like C:\Users\... where \U would be invalid.
277
+ // Single-quoted strings treat backslashes literally.
278
+ // Escape any embedded single quotes by doubling them (YAML spec).
279
+ const escaped = value.replace(/'/g, "''");
280
+ lines.push(`${spaces}${key}: '${escaped}'`);
266
281
  } else {
267
282
  lines.push(`${spaces}${key}: ${value}`);
268
283
  }
@@ -275,7 +290,10 @@ function formatYAML(obj: unknown, indent = 0): string {
275
290
  }
276
291
 
277
292
  export async function saveConfig(config: Config, customPath?: string): Promise<void> {
278
- const configPath = customPath || (await getProfile());
293
+ // Use the path the config was originally loaded from (cachedConfigPath) so that
294
+ // saves go back to the correct profile even when --profile was used to load it.
295
+ // Falls back to getProfile() if no config has been loaded yet.
296
+ const configPath = customPath || cachedConfigPath || (await getProfile());
279
297
  await ensureConfigDir();
280
298
 
281
299
  const content = formatYAML(config);
@@ -595,7 +613,7 @@ export async function loadProjectConfig(
595
613
  throw new ProjectConfigNotFoundException({ message: 'project config not found' });
596
614
  }
597
615
  const text = await file.text();
598
- const parsedConfig = JSON5.parse(text);
616
+ const parsedConfig = parseJSONC(text);
599
617
  const result = ProjectSchema.safeParse(parsedConfig);
600
618
  if (!result.success) {
601
619
  tui.error(`Invalid project config at ${configPath}:`);
@@ -700,7 +718,7 @@ export async function updateProjectConfig(
700
718
  }
701
719
 
702
720
  const text = await file.text();
703
- const existing = JSON5.parse(text);
721
+ const existing = parseJSONC(text) as Record<string, unknown>;
704
722
  const updated = { ...existing, ...updates };
705
723
 
706
724
  const result = ProjectSchema.safeParse(updated);
@@ -443,8 +443,14 @@ export function buildValidationInput(
443
443
  const camelCaseName = opt.name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
444
444
  let value = rawOptions[opt.name] ?? rawOptions[camelCaseName];
445
445
 
446
- // Handle --yes alias for --confirm: if confirm is not set but yes is, use yes value
447
- if (opt.name === 'confirm' && value === undefined && rawOptions.yes === true) {
446
+ // Handle --yes and --force aliases for --confirm: if confirm is not set but yes/force is, use that value
447
+ // Only treat --force as a --confirm alias if the schema does NOT declare a separate 'force' option
448
+ if (
449
+ opt.name === 'confirm' &&
450
+ value === undefined &&
451
+ (rawOptions.yes === true ||
452
+ (rawOptions.force === true && !parsed.some((o) => o.name === 'force')))
453
+ ) {
448
454
  value = true;
449
455
  }
450
456
 
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Parse JSON with Comments (JSONC).
3
+ *
4
+ * Strips single-line (`//`) and block (`/* *​/`) comments as well as trailing
5
+ * commas that appear before `}` or `]`, then delegates to the built-in
6
+ * `JSON.parse`. This covers the comment syntax used by `tsconfig.json` and
7
+ * similar config files without pulling in a full JSON5 parser.
8
+ *
9
+ * String literals are respected — comments and trailing commas inside quoted
10
+ * strings are left untouched.
11
+ */
12
+ export function parseJSONC(text: string): unknown {
13
+ let result = '';
14
+ let i = 0;
15
+ const len = text.length;
16
+
17
+ while (i < len) {
18
+ const ch = text[i];
19
+
20
+ // --- quoted string: copy verbatim, including any escape sequences ---
21
+ if (ch === '"') {
22
+ const start = i;
23
+ i++; // skip opening quote
24
+ while (i < len) {
25
+ if (text[i] === '\\') {
26
+ i += i + 1 < len ? 2 : 1; // skip escaped character (guard end-of-input)
27
+ } else if (text[i] === '"') {
28
+ i++; // skip closing quote
29
+ break;
30
+ } else {
31
+ i++;
32
+ }
33
+ }
34
+ result += text.slice(start, i);
35
+ continue;
36
+ }
37
+
38
+ // --- single-line comment: skip to end of line ---
39
+ if (ch === '/' && text[i + 1] === '/') {
40
+ i += 2;
41
+ while (i < len && text[i] !== '\n') {
42
+ i++;
43
+ }
44
+ continue;
45
+ }
46
+
47
+ // --- block comment: skip to closing *​/ ---
48
+ if (ch === '/' && text[i + 1] === '*') {
49
+ i += 2;
50
+ while (i < len && !(text[i] === '*' && text[i + 1] === '/')) {
51
+ i++;
52
+ }
53
+ if (i < len) {
54
+ i += 2; // skip closing */
55
+ }
56
+ continue;
57
+ }
58
+
59
+ result += ch;
60
+ i++;
61
+ }
62
+
63
+ // Strip trailing commas before } or ] (with optional whitespace between).
64
+ result = result.replace(/,(\s*[}\]])/g, '$1');
65
+
66
+ return JSON.parse(result);
67
+ }
@@ -697,7 +697,8 @@ export function performMigration(rootDir: string, routeFiles: string[]): Migrati
697
697
  * Show the migration notice and optionally perform migration.
698
698
  *
699
699
  * Called during `dev` and `build` after dependency upgrades.
700
- * Shows a banner the first time, then a shorter reminder on subsequent runs.
700
+ * Only prompts in interactive TTY sessions and only once if the user
701
+ * dismisses the prompt, it won't be shown again.
701
702
  *
702
703
  * @returns true if migration was performed, false otherwise
703
704
  */
@@ -707,6 +708,12 @@ export async function promptRouteMigration(
707
708
  options?: { interactive?: boolean }
708
709
  ): Promise<boolean> {
709
710
  const interactive = options?.interactive ?? process.stdin.isTTY;
711
+
712
+ // Only show the interactive migration prompt in TTY sessions
713
+ if (!interactive) {
714
+ return false;
715
+ }
716
+
710
717
  const eligibility = checkMigrationEligibility(rootDir);
711
718
 
712
719
  if (!eligibility.available) {
@@ -715,48 +722,30 @@ export async function promptRouteMigration(
715
722
 
716
723
  const { routeFiles, alreadyNotified } = eligibility;
717
724
 
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
- }
725
+ // Only prompt once if the user has already been notified or dismissed, don't ask again
726
+ if (alreadyNotified) {
729
727
  return false;
730
728
  }
731
729
 
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
- }
730
+ tui.newline();
731
+ tui.banner(
732
+ '✨ Migrate to Explicit Routing',
733
+ 'Agentuity is moving to explicit routing, which will become the\n' +
734
+ 'default in the next major release. File-based route discovery\n' +
735
+ 'will be deprecated.\n' +
736
+ '\n' +
737
+ `Your project has ${routeFiles.length} route files in src/api/ that are\n` +
738
+ 'auto-discovered at build time. Explicit routing gives you a single\n' +
739
+ 'src/api/index.ts that imports and mounts all sub-routers just\n' +
740
+ 'like a standard Hono application.\n' +
741
+ '\n' +
742
+ `${tui.muted('Before:')} ${routeFiles.length} files auto-discovered from src/api/**/*.ts\n` +
743
+ `${tui.muted('After:')} One src/api/index.ts that imports and mounts them\n` +
744
+ '\n' +
745
+ 'Your existing route files are not modified. Your app.ts will be\n' +
746
+ 'updated to import the router and pass it to createApp({ router }).',
747
+ { centerTitle: false }
748
+ );
760
749
 
761
750
  tui.newline();
762
751
 
package/src/utils/zip.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { readFileSync, lstatSync } from 'node:fs';
1
2
  import { relative } from 'node:path';
2
3
  import { Glob } from 'bun';
3
4
  import AdmZip from 'adm-zip';
@@ -11,7 +12,7 @@ interface Options {
11
12
  export async function zipDir(dir: string, outdir: string, options?: Options) {
12
13
  const zip = new AdmZip();
13
14
  const files = await Array.fromAsync(
14
- new Glob('**/*').scan({ cwd: dir, absolute: true, dot: true })
15
+ new Glob('**/*').scan({ cwd: dir, absolute: true, dot: true, followSymlinks: false })
15
16
  );
16
17
  const total = files.length;
17
18
  let count = 0;
@@ -24,7 +25,21 @@ export async function zipDir(dir: string, outdir: string, options?: Options) {
24
25
  }
25
26
  }
26
27
  if (!skip) {
27
- zip.addLocalFile(file, undefined, rel);
28
+ try {
29
+ // Skip symlinks and directories — symlinks are workspace artefacts
30
+ // (e.g. bun's node_modules links) that cannot be resolved portably
31
+ // across machines and would cause EISDIR errors on extraction.
32
+ const stat = lstatSync(file);
33
+ if (!stat.isSymbolicLink() && !stat.isDirectory()) {
34
+ // Use addFile with explicit Unix permissions (0o644) instead of addLocalFile.
35
+ // On Windows, addLocalFile relies on OS file stats which may produce zip entries
36
+ // with incorrect Unix permission bits, causing EACCES errors when extracted on Linux.
37
+ const data = readFileSync(file);
38
+ zip.addFile(rel, data, '', 0o644);
39
+ }
40
+ } catch (err) {
41
+ throw new Error(`Failed to add file to zip: ${rel} (${file})`, { cause: err });
42
+ }
28
43
  }
29
44
  count++;
30
45
  if (options?.progress) {