@agentuity/cli 2.0.12 → 2.0.13

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 (67) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +15 -8
  3. package/dist/cli.js.map +1 -1
  4. package/dist/cmd/cloud/sandbox/snapshot/create.js +4 -4
  5. package/dist/cmd/cloud/sandbox/snapshot/create.js.map +1 -1
  6. package/dist/cmd/coder/workspace/common.d.ts +29 -0
  7. package/dist/cmd/coder/workspace/common.d.ts.map +1 -0
  8. package/dist/cmd/coder/workspace/common.js +83 -0
  9. package/dist/cmd/coder/workspace/common.js.map +1 -0
  10. package/dist/cmd/coder/workspace/create.d.ts.map +1 -1
  11. package/dist/cmd/coder/workspace/create.js +34 -37
  12. package/dist/cmd/coder/workspace/create.js.map +1 -1
  13. package/dist/cmd/coder/workspace/get.d.ts.map +1 -1
  14. package/dist/cmd/coder/workspace/get.js +2 -5
  15. package/dist/cmd/coder/workspace/get.js.map +1 -1
  16. package/dist/cmd/coder/workspace/index.d.ts.map +1 -1
  17. package/dist/cmd/coder/workspace/index.js +10 -0
  18. package/dist/cmd/coder/workspace/index.js.map +1 -1
  19. package/dist/cmd/coder/workspace/list.d.ts.map +1 -1
  20. package/dist/cmd/coder/workspace/list.js +4 -0
  21. package/dist/cmd/coder/workspace/list.js.map +1 -1
  22. package/dist/cmd/coder/workspace/refresh.d.ts +2 -0
  23. package/dist/cmd/coder/workspace/refresh.d.ts.map +1 -0
  24. package/dist/cmd/coder/workspace/refresh.js +59 -0
  25. package/dist/cmd/coder/workspace/refresh.js.map +1 -0
  26. package/dist/cmd/coder/workspace/update.d.ts +2 -0
  27. package/dist/cmd/coder/workspace/update.d.ts.map +1 -0
  28. package/dist/cmd/coder/workspace/update.js +131 -0
  29. package/dist/cmd/coder/workspace/update.js.map +1 -0
  30. package/dist/cmd/coder/workspace/validate-dependencies.d.ts +2 -0
  31. package/dist/cmd/coder/workspace/validate-dependencies.d.ts.map +1 -0
  32. package/dist/cmd/coder/workspace/validate-dependencies.js +70 -0
  33. package/dist/cmd/coder/workspace/validate-dependencies.js.map +1 -0
  34. package/dist/cmd/project/random-name.d.ts +17 -0
  35. package/dist/cmd/project/random-name.d.ts.map +1 -0
  36. package/dist/cmd/project/random-name.js +144 -0
  37. package/dist/cmd/project/random-name.js.map +1 -0
  38. package/dist/cmd/project/template-flow.d.ts.map +1 -1
  39. package/dist/cmd/project/template-flow.js +181 -153
  40. package/dist/cmd/project/template-flow.js.map +1 -1
  41. package/dist/composite-logger.d.ts.map +1 -1
  42. package/dist/composite-logger.js +19 -0
  43. package/dist/composite-logger.js.map +1 -1
  44. package/dist/config.d.ts +18 -16
  45. package/dist/config.d.ts.map +1 -1
  46. package/dist/config.js +46 -16
  47. package/dist/config.js.map +1 -1
  48. package/dist/tui/prompt.d.ts +29 -0
  49. package/dist/tui/prompt.d.ts.map +1 -1
  50. package/dist/tui/prompt.js +180 -8
  51. package/dist/tui/prompt.js.map +1 -1
  52. package/package.json +7 -7
  53. package/src/cli.ts +30 -8
  54. package/src/cmd/cloud/sandbox/snapshot/create.ts +6 -6
  55. package/src/cmd/coder/workspace/common.ts +103 -0
  56. package/src/cmd/coder/workspace/create.ts +39 -43
  57. package/src/cmd/coder/workspace/get.ts +2 -5
  58. package/src/cmd/coder/workspace/index.ts +10 -0
  59. package/src/cmd/coder/workspace/list.ts +4 -0
  60. package/src/cmd/coder/workspace/refresh.ts +63 -0
  61. package/src/cmd/coder/workspace/update.ts +154 -0
  62. package/src/cmd/coder/workspace/validate-dependencies.ts +75 -0
  63. package/src/cmd/project/random-name.ts +152 -0
  64. package/src/cmd/project/template-flow.ts +199 -161
  65. package/src/composite-logger.ts +20 -0
  66. package/src/config.ts +69 -19
  67. package/src/tui/prompt.ts +214 -8
@@ -32,8 +32,13 @@ import { createPrompt, note } from '../../tui';
32
32
  import type { AuthData, Config } from '../../types';
33
33
  import { getGithubBotIdentity } from '../git/api';
34
34
  import { downloadTemplate, initGitRepo, setupProject } from './download';
35
+ import { suggestBucketName, suggestDatabaseName } from './random-name';
35
36
  import { fetchTemplates, type TemplateInfo } from './templates';
36
37
 
38
+ // Domain validator shared between the multi-select branch and the standalone prompt.
39
+ const DOMAIN_REGEX =
40
+ /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[A-Za-z]{2,63}$/;
41
+
37
42
  interface CreateFlowOptions {
38
43
  projectName?: string;
39
44
  dir?: string;
@@ -281,7 +286,7 @@ export async function runCreateFlow(options: CreateFlowOptions): Promise<CreateF
281
286
  };
282
287
  }
283
288
 
284
- // Add separator bar if we're going to show resource prompts
289
+ // Resource provisioning gates
285
290
  const canProvision = auth && apiClient && catalystClient && orgId && region;
286
291
  // Only count as resource flags if actually requesting provisioning (not explicit skip)
287
292
  const hasResourceFlags =
@@ -306,13 +311,62 @@ export async function runCreateFlow(options: CreateFlowOptions): Promise<CreateF
306
311
  }
307
312
 
308
313
  if (canProvision) {
309
- // Fetch resources for selected org and region using Catalyst API (needed for both interactive and CLI flags)
310
- let resources: Awaited<ReturnType<typeof listResources>> | undefined;
314
+ // CLI flags pre-resolve their respective per-resource decision; the multi-select
315
+ // is only used for resources where the user didn't pass a flag.
316
+ const dbFlagAction = resolveFlagAction(databaseOption, 'database');
317
+ const storageFlagAction = resolveFlagAction(storageOption, 'storage');
318
+ const domainFlagProvided = (domains?.length ?? 0) > 0;
319
+
320
+ // Determine which resources should run through the configuration phase.
321
+ // In interactive mode, ask the user via a single multi-select.
322
+ // In headless / non-interactive mode, only flagged resources are considered.
323
+ let wantDb = dbFlagAction !== undefined && dbFlagAction !== 'Skip';
324
+ let wantStorage = storageFlagAction !== undefined && storageFlagAction !== 'Skip';
325
+ let wantDomain = domainFlagProvided;
326
+
327
+ if (isInteractive) {
328
+ // Build multi-select options dynamically: only show resources the user hasn't
329
+ // already decided about via CLI flags. If all three came from flags, skip the prompt.
330
+ const msOptions: {
331
+ value: 'database' | 'storage' | 'domain';
332
+ label: string;
333
+ hint?: string;
334
+ }[] = [];
335
+ if (dbFlagAction === undefined) {
336
+ msOptions.push({ value: 'database', label: 'SQL Database', hint: 'PostgreSQL' });
337
+ }
338
+ if (storageFlagAction === undefined) {
339
+ msOptions.push({ value: 'storage', label: 'Storage Bucket', hint: 'S3-compatible' });
340
+ }
341
+ if (!domainFlagProvided) {
342
+ msOptions.push({ value: 'domain', label: 'Custom Domain', hint: 'BYO domain' });
343
+ }
344
+
345
+ if (msOptions.length > 0) {
346
+ const picked = await prompt.multiselect<'database' | 'storage' | 'domain'>({
347
+ message: 'What would you like to set up? (all optional)',
348
+ options: msOptions,
349
+ initial: [],
350
+ });
351
+ if (dbFlagAction === undefined) wantDb = picked.includes('database');
352
+ if (storageFlagAction === undefined) wantStorage = picked.includes('storage');
353
+ if (!domainFlagProvided) wantDomain = picked.includes('domain');
354
+ }
355
+ }
311
356
 
357
+ // Fetch existing resources only if we'll actually need them.
358
+ // Need them when:
359
+ // - user wants db/storage in interactive mode (to offer "use existing")
360
+ // - a CLI flag pointed at an existing resource by name
361
+ let resources: Awaited<ReturnType<typeof listResources>> | undefined;
312
362
  const needResources =
313
- isInteractive ||
314
- (databaseOption && databaseOption !== 'skip' && databaseOption !== 'new') ||
315
- (storageOption && storageOption !== 'skip' && storageOption !== 'new');
363
+ (isInteractive && (wantDb || wantStorage)) ||
364
+ (databaseOption !== undefined &&
365
+ dbFlagAction !== 'Create New' &&
366
+ dbFlagAction !== 'Skip') ||
367
+ (storageOption !== undefined &&
368
+ storageFlagAction !== 'Create New' &&
369
+ storageFlagAction !== 'Skip');
316
370
 
317
371
  if (needResources) {
318
372
  resources = await tui.spinner({
@@ -330,212 +384,174 @@ export async function runCreateFlow(options: CreateFlowOptions): Promise<CreateF
330
384
  logger.debug(
331
385
  `Storage buckets: ${resources.s3.map((b) => b.bucket_name).join(', ') || '(none)'}`
332
386
  );
333
- }
334
387
 
335
- // Determine database action: CLI flag > interactive prompt > skip (headless)
336
- let db_action: string;
337
- if (databaseOption !== undefined) {
338
- // CLI flag provided - normalize to expected values
339
- if (databaseOption.toLowerCase() === 'new') {
340
- db_action = 'Create New';
341
- } else if (databaseOption.toLowerCase() === 'skip') {
342
- db_action = 'Skip';
343
- } else {
344
- // Existing database name - validate it exists
345
- const existingDb = resources?.db.find((d) => d.name === databaseOption);
346
- if (!existingDb) {
347
- logger.fatal(
348
- `Database '${databaseOption}' not found. Use 'new' to create a new database or 'skip' to skip.`,
349
- ErrorCode.RESOURCE_NOT_FOUND
350
- );
351
- }
352
- db_action = databaseOption;
388
+ // Validate flag-supplied resource names against the fetched list.
389
+ if (
390
+ databaseOption !== undefined &&
391
+ dbFlagAction !== 'Create New' &&
392
+ dbFlagAction !== 'Skip' &&
393
+ !resources.db.find((d) => d.name === dbFlagAction)
394
+ ) {
395
+ logger.fatal(
396
+ `Database '${databaseOption}' not found. Use 'new' to create a new database or 'skip' to skip.`,
397
+ ErrorCode.RESOURCE_NOT_FOUND
398
+ );
353
399
  }
354
- } else if (isInteractive) {
355
- db_action = await prompt.select({
356
- message: 'Create SQL Database?',
357
- options: [
358
- { value: 'Skip', label: 'Skip or Setup later' },
359
- { value: 'Create New', label: 'Create a new database' },
360
- ...resources!.db.map((db) => ({
361
- value: db.name,
362
- label: `Use database: ${tui.tuiColors.primary(db.name)}`,
363
- })),
364
- ],
365
- });
366
- } else {
367
- // Headless without flag - skip
368
- db_action = 'Skip';
369
- }
370
-
371
- // Determine storage action: CLI flag > interactive prompt > skip (headless)
372
- let s3_action: string;
373
- if (storageOption !== undefined) {
374
- // CLI flag provided - normalize to expected values
375
- if (storageOption.toLowerCase() === 'new') {
376
- s3_action = 'Create New';
377
- } else if (storageOption.toLowerCase() === 'skip') {
378
- s3_action = 'Skip';
379
- } else {
380
- // Existing bucket name - validate it exists
381
- const existingBucket = resources?.s3.find((b) => b.bucket_name === storageOption);
382
- if (!existingBucket) {
383
- logger.fatal(
384
- `Storage bucket '${storageOption}' not found. Use 'new' to create a new bucket or 'skip' to skip.`,
385
- ErrorCode.RESOURCE_NOT_FOUND
386
- );
387
- }
388
- s3_action = storageOption;
400
+ if (
401
+ storageOption !== undefined &&
402
+ storageFlagAction !== 'Create New' &&
403
+ storageFlagAction !== 'Skip' &&
404
+ !resources.s3.find((b) => b.bucket_name === storageFlagAction)
405
+ ) {
406
+ logger.fatal(
407
+ `Storage bucket '${storageOption}' not found. Use 'new' to create a new bucket or 'skip' to skip.`,
408
+ ErrorCode.RESOURCE_NOT_FOUND
409
+ );
389
410
  }
390
- } else if (isInteractive) {
391
- s3_action = await prompt.select({
392
- message: 'Create Storage Bucket?',
393
- options: [
394
- { value: 'Skip', label: 'Skip or Setup later' },
395
- { value: 'Create New', label: 'Create a new bucket' },
396
- ...resources!.s3.map((bucket) => ({
397
- value: bucket.bucket_name,
398
- label: `Use bucket: ${tui.tuiColors.primary(bucket.bucket_name)}`,
399
- })),
400
- ],
401
- });
402
- } else {
403
- // Headless without flag - skip
404
- s3_action = 'Skip';
405
411
  }
406
412
 
407
- // Custom DNS: only prompt in interactive mode if not already provided
408
- if (!domains?.length && isInteractive) {
409
- const customDns = await prompt.text({
410
- message: 'Setup custom DNS?',
411
- hint: 'Enter a domain name or press Enter to skip',
412
- validate: (val: string) =>
413
- val === ''
414
- ? true
415
- : /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[A-Za-z]{2,63}$/.test(
416
- val
417
- ),
418
- });
419
- if (customDns) {
420
- _domains = [customDns];
413
+ // === Configure each selected resource: Database Storage Domain ===
414
+
415
+ // Database
416
+ if (wantDb) {
417
+ let dbAction = dbFlagAction;
418
+ if (dbAction === undefined && isInteractive) {
419
+ const existing = resources?.db ?? [];
420
+ if (existing.length > 0) {
421
+ dbAction = await prompt.select<string>({
422
+ message: 'SQL Database',
423
+ options: [
424
+ { value: 'Create New', label: 'Create a new database' },
425
+ ...existing.map((db) => ({
426
+ value: db.name,
427
+ label: `Use database: ${tui.tuiColors.primary(db.name)}`,
428
+ })),
429
+ ],
430
+ });
431
+ } else {
432
+ // No existing databases — user already opted in via the multi-select, so create new.
433
+ dbAction = 'Create New';
434
+ }
421
435
  }
422
- }
423
436
 
424
- // Process storage action
425
- switch (s3_action) {
426
- case 'Create New': {
427
- let bucketName: string | undefined;
428
- let bucketDescription: string | undefined;
437
+ if (dbAction === 'Create New') {
438
+ let dbName: string | undefined;
439
+ let dbDescription: string | undefined;
429
440
 
430
- // Only prompt for name/description in interactive mode
431
441
  if (isInteractive) {
432
- const bucketNameInput = await prompt.text({
433
- message: 'Bucket name',
434
- hint: 'Optional - lowercase letters, digits, hyphens only',
442
+ const suggestion = suggestDatabaseName(projectName);
443
+ const dbNameInput = await prompt.text({
444
+ message: 'Database name',
445
+ hint: 'Optional · lowercase letters, digits, underscores',
446
+ placeholder: suggestion,
435
447
  validate: (value: string) => {
436
448
  const trimmed = value.trim();
437
449
  if (trimmed === '') return true;
438
- const result = validateBucketName(trimmed);
450
+ const result = validateDatabaseName(trimmed);
439
451
  return result.valid ? true : result.error!;
440
452
  },
441
453
  });
442
- bucketName = bucketNameInput.trim() || undefined;
443
- bucketDescription =
454
+ dbName = dbNameInput.trim() || undefined;
455
+ dbDescription =
444
456
  (await prompt.text({
445
- message: 'Bucket description',
446
- hint: 'Optional - press Enter to skip',
457
+ message: 'Database description',
458
+ hint: 'Optional · press Enter to skip',
447
459
  })) || undefined;
448
460
  }
449
461
 
450
462
  const created = await tui.spinner({
451
- message: 'Provisioning New Bucket',
463
+ message: 'Provisioning New SQL Database',
452
464
  clearOnSuccess: true,
453
465
  callback: async () => {
454
466
  return createResources(catalystClient!, orgId!, region!, [
455
- {
456
- type: 's3',
457
- name: bucketName,
458
- description: bucketDescription,
459
- },
467
+ { type: 'db', name: dbName, description: dbDescription },
460
468
  ]);
461
469
  },
462
470
  });
463
- // Collect env vars from newly created resource
464
- if (created[0]?.env) {
465
- Object.assign(resourceEnvVars, created[0].env);
466
- }
467
- break;
468
- }
469
- case 'Skip': {
470
- break;
471
+ if (created[0]?.env) Object.assign(resourceEnvVars, created[0].env);
472
+ } else if (dbAction && dbAction !== 'Skip') {
473
+ // Existing database selected — reuse its env vars.
474
+ const selectedDb = resources?.db.find((d) => d.name === dbAction);
475
+ if (selectedDb?.env) Object.assign(resourceEnvVars, selectedDb.env);
471
476
  }
472
- default: {
473
- // User selected an existing bucket - get env vars from the resources list
474
- const selectedBucket = resources?.s3.find((b) => b.bucket_name === s3_action);
475
- if (selectedBucket?.env) {
476
- Object.assign(resourceEnvVars, selectedBucket.env);
477
+ }
478
+
479
+ // Storage
480
+ if (wantStorage) {
481
+ let s3Action = storageFlagAction;
482
+ if (s3Action === undefined && isInteractive) {
483
+ const existing = resources?.s3 ?? [];
484
+ if (existing.length > 0) {
485
+ s3Action = await prompt.select<string>({
486
+ message: 'Storage Bucket',
487
+ options: [
488
+ { value: 'Create New', label: 'Create a new bucket' },
489
+ ...existing.map((bucket) => ({
490
+ value: bucket.bucket_name,
491
+ label: `Use bucket: ${tui.tuiColors.primary(bucket.bucket_name)}`,
492
+ })),
493
+ ],
494
+ });
495
+ } else {
496
+ s3Action = 'Create New';
477
497
  }
478
- break;
479
498
  }
480
- }
481
499
 
482
- // Process database action
483
- switch (db_action) {
484
- case 'Create New': {
485
- let dbName: string | undefined;
486
- let dbDescription: string | undefined;
500
+ if (s3Action === 'Create New') {
501
+ let bucketName: string | undefined;
502
+ let bucketDescription: string | undefined;
487
503
 
488
- // Only prompt for name/description in interactive mode
489
504
  if (isInteractive) {
490
- const dbNameInput = await prompt.text({
491
- message: 'Database name',
492
- hint: 'Optional - lowercase letters, digits, underscores only',
505
+ const suggestion = suggestBucketName(projectName);
506
+ const bucketNameInput = await prompt.text({
507
+ message: 'Bucket name',
508
+ hint: 'Optional · lowercase letters, digits, hyphens',
509
+ placeholder: suggestion,
493
510
  validate: (value: string) => {
494
511
  const trimmed = value.trim();
495
512
  if (trimmed === '') return true;
496
- const result = validateDatabaseName(trimmed);
513
+ const result = validateBucketName(trimmed);
497
514
  return result.valid ? true : result.error!;
498
515
  },
499
516
  });
500
- dbName = dbNameInput.trim() || undefined;
501
- dbDescription =
517
+ bucketName = bucketNameInput.trim() || undefined;
518
+ bucketDescription =
502
519
  (await prompt.text({
503
- message: 'Database description',
504
- hint: 'Optional - press Enter to skip',
520
+ message: 'Bucket description',
521
+ hint: 'Optional · press Enter to skip',
505
522
  })) || undefined;
506
523
  }
507
524
 
508
525
  const created = await tui.spinner({
509
- message: 'Provisioning New SQL Database',
526
+ message: 'Provisioning New Bucket',
510
527
  clearOnSuccess: true,
511
528
  callback: async () => {
512
529
  return createResources(catalystClient!, orgId!, region!, [
513
- {
514
- type: 'db',
515
- name: dbName,
516
- description: dbDescription,
517
- },
530
+ { type: 's3', name: bucketName, description: bucketDescription },
518
531
  ]);
519
532
  },
520
533
  });
521
- // Collect env vars from newly created resource
522
- if (created[0]?.env) {
523
- Object.assign(resourceEnvVars, created[0].env);
524
- }
525
- break;
526
- }
527
- case 'Skip': {
528
- break;
529
- }
530
- default: {
531
- // User selected an existing database - get env vars from the resources list
532
- const selectedDb = resources?.db.find((d) => d.name === db_action);
533
- if (selectedDb?.env) {
534
- Object.assign(resourceEnvVars, selectedDb.env);
535
- }
536
- break;
534
+ if (created[0]?.env) Object.assign(resourceEnvVars, created[0].env);
535
+ } else if (s3Action && s3Action !== 'Skip') {
536
+ const selectedBucket = resources?.s3.find((b) => b.bucket_name === s3Action);
537
+ if (selectedBucket?.env) Object.assign(resourceEnvVars, selectedBucket.env);
537
538
  }
538
539
  }
540
+
541
+ // Custom Domain
542
+ if (wantDomain && !domainFlagProvided && isInteractive) {
543
+ const customDns = await prompt.text({
544
+ message: 'Custom domain',
545
+ hint: 'e.g. agents.example.com',
546
+ validate: (val: string) =>
547
+ val === ''
548
+ ? 'Domain is required (or go back and uncheck Custom Domain)'
549
+ : DOMAIN_REGEX.test(val)
550
+ ? true
551
+ : 'Invalid domain',
552
+ });
553
+ if (customDns) _domains = [customDns];
554
+ }
539
555
  }
540
556
 
541
557
  let projectId: string | undefined;
@@ -691,6 +707,28 @@ export async function runCreateFlow(options: CreateFlowOptions): Promise<CreateF
691
707
  };
692
708
  }
693
709
 
710
+ /**
711
+ * Normalize a CLI flag value (`--database` / `--storage`) into the same
712
+ * action vocabulary the interactive flow uses:
713
+ * - 'new' -> 'Create New'
714
+ * - 'skip' -> 'Skip'
715
+ * - any other string -> treated as an existing-resource name (returned as-is)
716
+ * - undefined -> undefined (no flag passed; multi-select decides)
717
+ *
718
+ * The existence check for named resources happens later, after the resource
719
+ * list is fetched.
720
+ */
721
+ function resolveFlagAction(
722
+ flag: string | undefined,
723
+ _kind: 'database' | 'storage'
724
+ ): string | undefined {
725
+ if (flag === undefined) return undefined;
726
+ const lower = flag.toLowerCase();
727
+ if (lower === 'new') return 'Create New';
728
+ if (lower === 'skip') return 'Skip';
729
+ return flag;
730
+ }
731
+
694
732
  /**
695
733
  * Sanitize a project name to create a safe directory/package name
696
734
  * - Converts to lowercase
@@ -7,6 +7,12 @@
7
7
  */
8
8
 
9
9
  import type { Logger } from '@agentuity/core';
10
+ import { format } from 'node:util';
11
+ import { ErrorCode, getExitCode } from './errors';
12
+
13
+ function isErrorCode(value: unknown): value is ErrorCode {
14
+ return typeof value === 'string' && Object.values(ErrorCode).includes(value as ErrorCode);
15
+ }
10
16
 
11
17
  /**
12
18
  * A logger that delegates to multiple child loggers
@@ -45,6 +51,20 @@ export class CompositeLogger implements Logger {
45
51
  }
46
52
 
47
53
  fatal(message: unknown, ...args: unknown[]): never {
54
+ const maybeErrorCode = args[args.length - 1];
55
+ if (isErrorCode(maybeErrorCode)) {
56
+ const formatArgs = args.slice(0, -1);
57
+ const formattedMessage = format(message, ...formatArgs);
58
+ for (const logger of this.loggers) {
59
+ try {
60
+ logger.error(formattedMessage);
61
+ } catch {
62
+ // Keep fatal exits reliable even if one delegate cannot write.
63
+ }
64
+ }
65
+ process.exit(getExitCode(maybeErrorCode));
66
+ }
67
+
48
68
  // Call fatal on all loggers, but only the first one will exit
49
69
  for (const logger of this.loggers) {
50
70
  try {
package/src/config.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs';
2
- import { chmod, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
- import { basename, extname, join, normalize, resolve } from 'node:path';
4
+ import { basename, dirname, extname, join, normalize, resolve } from 'node:path';
5
5
  import { type Logger, StructuredError } from '@agentuity/core';
6
6
  import {
7
7
  type BuildMetadata,
@@ -15,6 +15,7 @@ import { z } from 'zod';
15
15
  import { clearProfileCache } from './cache';
16
16
  import { getCatalystUrl } from './catalyst';
17
17
  import { readEnvFile, writeEnvFile } from './env-util';
18
+ import { ErrorCode } from './errors';
18
19
  import {
19
20
  deleteAuthFromKeychain,
20
21
  getAuthFromKeychain,
@@ -151,10 +152,12 @@ function expandTilde(path: string): string {
151
152
  let cachedConfig: Config | null | undefined;
152
153
  // Track the resolved config path so saveConfig writes back to the same file
153
154
  let cachedConfigPath: string | undefined;
155
+ const loadedProjectConfigPaths = new Map<string, string>();
154
156
 
155
157
  export function resetConfigCache(): void {
156
158
  cachedConfig = undefined;
157
159
  cachedConfigPath = undefined;
160
+ loadedProjectConfigPaths.clear();
158
161
  }
159
162
 
160
163
  export async function loadConfig(
@@ -600,31 +603,76 @@ export function generateYAMLTemplate(name: string): string {
600
603
  return lines.join('\n');
601
604
  }
602
605
 
603
- export const ProjectConfigNotFoundException = StructuredError('ProjectConfigNotFoundException');
606
+ export const ProjectConfigNotFoundException = StructuredError('ProjectConfigNotFoundException')<{
607
+ code?: ErrorCode;
608
+ configPath?: string;
609
+ explicit?: boolean;
610
+ }>();
604
611
 
605
612
  type ProjectConfig = z.infer<typeof ProjectSchema>;
606
613
 
607
- export async function loadProjectConfig(
608
- dir: string,
614
+ export type ResolvedProjectConfigPaths = {
615
+ projectDir: string;
616
+ configPath: string;
617
+ explicitConfigFile: boolean;
618
+ };
619
+
620
+ export async function resolveProjectConfigPaths(
621
+ path: string,
609
622
  config?: Config | null
610
- ): Promise<ProjectConfig> {
611
- let configPath = join(dir, 'agentuity.json');
623
+ ): Promise<ResolvedProjectConfigPaths> {
624
+ const resolvedPath = resolve(expandTilde(path));
625
+ const pathStats = await stat(resolvedPath).catch(() => null);
626
+ const isExplicitJsonFile =
627
+ (pathStats?.isFile() || pathStats === null) && extname(resolvedPath) === '.json';
628
+
629
+ if (isExplicitJsonFile) {
630
+ return {
631
+ projectDir: dirname(resolvedPath),
632
+ configPath: resolvedPath,
633
+ explicitConfigFile: true,
634
+ };
635
+ }
636
+
637
+ const projectDir = resolvedPath;
638
+ let configPath = join(projectDir, 'agentuity.json');
612
639
 
613
- // Check for profile-specific override if config is provided
614
640
  if (config?.name) {
615
- const profileConfigPath = join(dir, `agentuity.${config.name}.json`);
641
+ const profileConfigPath = join(projectDir, `agentuity.${config.name}.json`);
616
642
  if (await Bun.file(profileConfigPath).exists()) {
617
643
  configPath = profileConfigPath;
618
644
  }
619
645
  }
620
646
 
647
+ return {
648
+ projectDir,
649
+ configPath,
650
+ explicitConfigFile: false,
651
+ };
652
+ }
653
+
654
+ export async function loadProjectConfig(
655
+ dir: string,
656
+ config?: Config | null
657
+ ): Promise<ProjectConfig> {
658
+ const { projectDir, configPath, explicitConfigFile } = await resolveProjectConfigPaths(
659
+ dir,
660
+ config
661
+ );
662
+
621
663
  const file = Bun.file(configPath);
622
664
  if (!(await file.exists())) {
623
665
  // TODO: check to see if a valid project that was created unauthenticated
624
666
  // and then if so:
625
667
  // 1. if authentication, offer to import the project
626
668
  // 2. tell them that they need to login to use the command and import the project
627
- throw new ProjectConfigNotFoundException({ message: 'project config not found' });
669
+ throw new ProjectConfigNotFoundException({
670
+ message: explicitConfigFile
671
+ ? `Project config not found at ${configPath}`
672
+ : 'project config not found',
673
+ configPath,
674
+ explicit: explicitConfigFile,
675
+ });
628
676
  }
629
677
  const text = await file.text();
630
678
  const parsedConfig = parseJSONC(text);
@@ -637,6 +685,7 @@ export async function loadProjectConfig(
637
685
  }
638
686
  process.exit(1);
639
687
  }
688
+ loadedProjectConfigPaths.set(projectDir, configPath);
640
689
  return result.data;
641
690
  }
642
691
 
@@ -717,18 +766,19 @@ export async function updateProjectConfig(
717
766
  updates: Partial<z.infer<typeof ProjectSchema>>,
718
767
  config?: Config | null
719
768
  ): Promise<void> {
720
- let configPath = join(dir, 'agentuity.json');
721
-
722
- if (config?.name) {
723
- const profileConfigPath = join(dir, `agentuity.${config.name}.json`);
724
- if (await Bun.file(profileConfigPath).exists()) {
725
- configPath = profileConfigPath;
726
- }
727
- }
769
+ const resolved = await resolveProjectConfigPaths(dir, config);
770
+ const configPath = resolved.explicitConfigFile
771
+ ? resolved.configPath
772
+ : (loadedProjectConfigPaths.get(resolved.projectDir) ?? resolved.configPath);
728
773
 
729
774
  const file = Bun.file(configPath);
730
775
  if (!(await file.exists())) {
731
- throw new Error(`Project config not found at ${configPath}`);
776
+ throw new ProjectConfigNotFoundException({
777
+ code: ErrorCode.PROJECT_NOT_FOUND,
778
+ message: `Project config not found at ${configPath}`,
779
+ configPath: resolved.configPath,
780
+ explicit: resolved.explicitConfigFile,
781
+ });
732
782
  }
733
783
 
734
784
  const text = await file.text();