@agentuity/cli 0.1.32 → 0.1.34

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 (76) hide show
  1. package/dist/cmd/cloud/deploy.d.ts.map +1 -1
  2. package/dist/cmd/cloud/deploy.js +52 -2
  3. package/dist/cmd/cloud/deploy.js.map +1 -1
  4. package/dist/cmd/cloud/env/delete.d.ts.map +1 -1
  5. package/dist/cmd/cloud/env/delete.js +3 -4
  6. package/dist/cmd/cloud/env/delete.js.map +1 -1
  7. package/dist/cmd/cloud/env/import.d.ts.map +1 -1
  8. package/dist/cmd/cloud/env/import.js +4 -6
  9. package/dist/cmd/cloud/env/import.js.map +1 -1
  10. package/dist/cmd/cloud/env/pull.d.ts.map +1 -1
  11. package/dist/cmd/cloud/env/pull.js +17 -25
  12. package/dist/cmd/cloud/env/pull.js.map +1 -1
  13. package/dist/cmd/cloud/env/set.d.ts.map +1 -1
  14. package/dist/cmd/cloud/env/set.js +3 -6
  15. package/dist/cmd/cloud/env/set.js.map +1 -1
  16. package/dist/cmd/cloud/region-lookup.d.ts +2 -2
  17. package/dist/cmd/cloud/region-lookup.d.ts.map +1 -1
  18. package/dist/cmd/cloud/region-lookup.js +7 -3
  19. package/dist/cmd/cloud/region-lookup.js.map +1 -1
  20. package/dist/cmd/cloud/sandbox/exec.d.ts.map +1 -1
  21. package/dist/cmd/cloud/sandbox/exec.js +10 -35
  22. package/dist/cmd/cloud/sandbox/exec.js.map +1 -1
  23. package/dist/cmd/cloud/scp/download.d.ts.map +1 -1
  24. package/dist/cmd/cloud/scp/download.js +1 -1
  25. package/dist/cmd/cloud/scp/download.js.map +1 -1
  26. package/dist/cmd/cloud/scp/upload.d.ts.map +1 -1
  27. package/dist/cmd/cloud/scp/upload.js +1 -1
  28. package/dist/cmd/cloud/scp/upload.js.map +1 -1
  29. package/dist/cmd/cloud/ssh.d.ts.map +1 -1
  30. package/dist/cmd/cloud/ssh.js +1 -1
  31. package/dist/cmd/cloud/ssh.js.map +1 -1
  32. package/dist/cmd/cloud/storage/create.d.ts.map +1 -1
  33. package/dist/cmd/cloud/storage/create.js +7 -2
  34. package/dist/cmd/cloud/storage/create.js.map +1 -1
  35. package/dist/cmd/cloud/storage/get.d.ts.map +1 -1
  36. package/dist/cmd/cloud/storage/get.js +6 -0
  37. package/dist/cmd/cloud/storage/get.js.map +1 -1
  38. package/dist/cmd/cloud/storage/list.d.ts.map +1 -1
  39. package/dist/cmd/cloud/storage/list.js +6 -0
  40. package/dist/cmd/cloud/storage/list.js.map +1 -1
  41. package/dist/cmd/project/auth/init.d.ts.map +1 -1
  42. package/dist/cmd/project/auth/init.js +10 -21
  43. package/dist/cmd/project/auth/init.js.map +1 -1
  44. package/dist/config.d.ts +2 -1
  45. package/dist/config.d.ts.map +1 -1
  46. package/dist/config.js +7 -2
  47. package/dist/config.js.map +1 -1
  48. package/dist/env-util.d.ts +8 -1
  49. package/dist/env-util.d.ts.map +1 -1
  50. package/dist/env-util.js +12 -3
  51. package/dist/env-util.js.map +1 -1
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/types.js +4 -1
  54. package/dist/types.js.map +1 -1
  55. package/dist/utils/installation-type.d.ts.map +1 -1
  56. package/dist/utils/installation-type.js +54 -16
  57. package/dist/utils/installation-type.js.map +1 -1
  58. package/package.json +6 -6
  59. package/src/cmd/cloud/deploy.ts +71 -1
  60. package/src/cmd/cloud/env/delete.ts +2 -4
  61. package/src/cmd/cloud/env/import.ts +3 -8
  62. package/src/cmd/cloud/env/pull.ts +17 -26
  63. package/src/cmd/cloud/env/set.ts +2 -8
  64. package/src/cmd/cloud/region-lookup.ts +19 -4
  65. package/src/cmd/cloud/sandbox/exec.ts +10 -41
  66. package/src/cmd/cloud/scp/download.ts +2 -1
  67. package/src/cmd/cloud/scp/upload.ts +2 -1
  68. package/src/cmd/cloud/ssh.ts +2 -1
  69. package/src/cmd/cloud/storage/create.ts +7 -2
  70. package/src/cmd/cloud/storage/get.ts +6 -0
  71. package/src/cmd/cloud/storage/list.ts +6 -0
  72. package/src/cmd/project/auth/init.ts +10 -22
  73. package/src/config.ts +10 -2
  74. package/src/env-util.ts +20 -3
  75. package/src/types.ts +4 -1
  76. package/src/utils/installation-type.ts +55 -16
@@ -7,10 +7,8 @@ import {
7
7
  readEnvFile,
8
8
  writeEnvFile,
9
9
  filterAgentuitySdkKeys,
10
- mergeEnvVars,
11
10
  splitEnvAndSecrets,
12
11
  validateNoPublicSecrets,
13
- isReservedAgentuityKey,
14
12
  } from '../../../env-util';
15
13
  import { getCommand } from '../../../command-prefix';
16
14
  import { resolveOrgId, isOrgScope } from './org-util';
@@ -164,12 +162,9 @@ export const importSubcommand = createSubcommand({
164
162
  let localEnvPath: string | undefined;
165
163
  if (projectDir) {
166
164
  localEnvPath = await findExistingEnvFile(projectDir);
167
- const localEnv = await readEnvFile(localEnvPath);
168
- const mergedEnv = mergeEnvVars(localEnv, filteredVars);
169
-
170
- await writeEnvFile(localEnvPath, mergedEnv, {
171
- skipKeys: Object.keys(mergedEnv).filter(isReservedAgentuityKey),
172
- });
165
+ // writeEnvFile preserves existing keys by default, so just write the filtered vars
166
+ // This will merge with existing .env content, preserving AGENTUITY_SDK_KEY and other keys
167
+ await writeEnvFile(localEnvPath, filteredVars);
173
168
  }
174
169
 
175
170
  tui.success(
@@ -1,5 +1,4 @@
1
1
  import { z } from 'zod';
2
- import { join } from 'node:path';
3
2
  import { createSubcommand } from '../../../types';
4
3
  import * as tui from '../../../tui';
5
4
  import { projectGet, orgEnvGet } from '@agentuity/server';
@@ -8,7 +7,6 @@ import {
8
7
  readEnvFile,
9
8
  writeEnvFile,
10
9
  mergeEnvVars,
11
- isReservedAgentuityKey,
12
10
  } from '../../../env-util';
13
11
  import { getCommand } from '../../../command-prefix';
14
12
  import { resolveOrgId, isOrgScope } from './org-util';
@@ -93,7 +91,7 @@ export const pullSubcommand = createSubcommand({
93
91
  const targetEnvPath = await findExistingEnvFile(projectDir);
94
92
  const localEnv = await readEnvFile(targetEnvPath);
95
93
 
96
- // Preserve local AGENTUITY_SDK_KEY before writing (since it will be skipped in the first write)
94
+ // Preserve local AGENTUITY_SDK_KEY
97
95
  const localSdkKey = localEnv.AGENTUITY_SDK_KEY;
98
96
 
99
97
  // Merge: cloud values override local if force=true, otherwise keep local
@@ -106,32 +104,25 @@ export const pullSubcommand = createSubcommand({
106
104
  mergedEnv = mergeEnvVars(cloudEnv, localEnv);
107
105
  }
108
106
 
109
- // Write to .env (skip reserved AGENTUITY_ keys, except AGENTUITY_PUBLIC_)
107
+ // Determine the SDK key to use: cloud api_key is source of truth, fallback to local
108
+ const sdkKeyToWrite = cloudApiKey || localSdkKey;
109
+ if (sdkKeyToWrite) {
110
+ mergedEnv.AGENTUITY_SDK_KEY = sdkKeyToWrite;
111
+ }
112
+
113
+ // Write to .env in a single operation, preserveExisting: false since we have the full merged state
110
114
  await writeEnvFile(targetEnvPath, mergedEnv, {
111
- skipKeys: Object.keys(mergedEnv).filter(isReservedAgentuityKey),
115
+ preserveExisting: false,
116
+ addComment: (key) => {
117
+ if (key === 'AGENTUITY_SDK_KEY') {
118
+ return 'AGENTUITY_SDK_KEY is a sensitive value and should not be committed to version control.';
119
+ }
120
+ return null;
121
+ },
112
122
  });
113
123
 
114
- // Restore AGENTUITY_SDK_KEY to .env (cloud is source of truth, fallback to local)
115
- // The key was removed by the write above since it's in skipKeys, so we need to restore it
116
- const dotEnvPath = join(projectDir, '.env');
117
- const dotEnv = await readEnvFile(dotEnvPath);
118
-
119
- // Cloud is source of truth: use cloud api_key if available, otherwise fallback to local
120
- // For org scope, only restore if local key exists (orgs don't have api_key)
121
- const sdkKeyToWrite = cloudApiKey || localSdkKey;
122
- if (sdkKeyToWrite) {
123
- dotEnv.AGENTUITY_SDK_KEY = sdkKeyToWrite;
124
- await writeEnvFile(dotEnvPath, dotEnv, {
125
- addComment: (key) => {
126
- if (key === 'AGENTUITY_SDK_KEY') {
127
- return 'AGENTUITY_SDK_KEY is a sensitive value and should not be committed to version control.';
128
- }
129
- return null;
130
- },
131
- });
132
- if (cloudApiKey && cloudApiKey !== localSdkKey) {
133
- tui.info(`Wrote AGENTUITY_SDK_KEY to ${dotEnvPath}`);
134
- }
124
+ if (cloudApiKey && cloudApiKey !== localSdkKey) {
125
+ tui.info(`Wrote AGENTUITY_SDK_KEY to ${targetEnvPath}`);
135
126
  }
136
127
 
137
128
  const count = Object.keys(cloudEnv).length;
@@ -4,9 +4,7 @@ import * as tui from '../../../tui';
4
4
  import { projectEnvUpdate, orgEnvUpdate } from '@agentuity/server';
5
5
  import {
6
6
  findExistingEnvFile,
7
- readEnvFile,
8
7
  writeEnvFile,
9
- filterAgentuitySdkKeys,
10
8
  looksLikeSecret,
11
9
  isReservedAgentuityKey,
12
10
  isPublicVarKey,
@@ -145,12 +143,8 @@ export const setSubcommand = createSubcommand({
145
143
  let envFilePath: string | undefined;
146
144
  if (projectDir) {
147
145
  envFilePath = await findExistingEnvFile(projectDir);
148
- const currentEnv = await readEnvFile(envFilePath);
149
- currentEnv[args.key] = args.value;
150
-
151
- // Filter out AGENTUITY_ keys before writing
152
- const filteredEnv = filterAgentuitySdkKeys(currentEnv);
153
- await writeEnvFile(envFilePath, filteredEnv);
146
+ // Write only the new key - writeEnvFile preserves existing keys by default
147
+ await writeEnvFile(envFilePath, { [args.key]: args.value });
154
148
  }
155
149
 
156
150
  const successMsg = envFilePath
@@ -2,7 +2,7 @@ import type { Logger } from '@agentuity/core';
2
2
  import { projectGet, sandboxGet, deploymentGet, type APIClient } from '@agentuity/server';
3
3
  import { getResourceRegion, setResourceRegion } from '../../cache';
4
4
  import { getGlobalCatalystAPIClient } from '../../config';
5
- import type { AuthData } from '../../types';
5
+ import type { AuthData, Config } from '../../types';
6
6
  import * as tui from '../../tui';
7
7
  import { ErrorCode } from '../../errors';
8
8
 
@@ -35,7 +35,8 @@ export async function getIdentifierRegion(
35
35
  apiClient: APIClient,
36
36
  profileName = 'production',
37
37
  identifier: string,
38
- orgId?: string
38
+ orgId?: string,
39
+ config?: Config | null
39
40
  ): Promise<string> {
40
41
  const identifierType = getIdentifierType(identifier);
41
42
 
@@ -57,8 +58,14 @@ export async function getIdentifierRegion(
57
58
  const deployment = await deploymentGet(apiClient, identifier);
58
59
  region = deployment.cloudRegion ?? null;
59
60
  } else {
60
- // sandbox
61
- const globalClient = await getGlobalCatalystAPIClient(logger, auth, profileName);
61
+ // sandbox - pass config to getGlobalCatalystAPIClient for proper region resolution
62
+ const globalClient = await getGlobalCatalystAPIClient(
63
+ logger,
64
+ auth,
65
+ profileName,
66
+ orgId,
67
+ config
68
+ );
62
69
  const sandbox = await sandboxGet(globalClient, { sandboxId: identifier, orgId });
63
70
  region = sandbox.region ?? null;
64
71
  }
@@ -70,6 +77,14 @@ export async function getIdentifierRegion(
70
77
  );
71
78
  }
72
79
 
80
+ // Validate region is a non-empty string
81
+ if (typeof region !== 'string' || region.trim() === '') {
82
+ tui.fatal(
83
+ `Invalid region returned for ${identifierType} '${identifier}': '${region}'. Use --region flag to specify a valid region.`,
84
+ ErrorCode.RESOURCE_NOT_FOUND
85
+ );
86
+ }
87
+
73
88
  // Cache the result
74
89
  await setResourceRegion(identifierType, profileName, identifier, region);
75
90
  logger.trace(`[region-lookup] Cached region for ${identifier}: ${region}`);
@@ -7,8 +7,8 @@ import { getCommand } from '../../../command-prefix';
7
7
  import { sandboxExecute, executionGet, writeAndDrain } from '@agentuity/server';
8
8
  import type { Logger } from '@agentuity/core';
9
9
 
10
- const POLL_INTERVAL_MS = 500;
11
- const MAX_POLL_ATTEMPTS = 7200;
10
+ // Server-side long-poll wait duration (max 5 minutes supported by server)
11
+ const EXECUTION_WAIT_DURATION = '5m';
12
12
 
13
13
  const SandboxExecResponseSchema = z.object({
14
14
  executionId: z.string().describe('Unique execution identifier'),
@@ -115,41 +115,14 @@ export const execSubcommand = createCommand({
115
115
  }
116
116
  }
117
117
 
118
- let attempts = 0;
119
- let finalExecution = execution;
120
-
121
- while (attempts < MAX_POLL_ATTEMPTS) {
122
- if (abortController.signal.aborted) {
123
- throw new Error('Execution cancelled');
124
- }
125
-
126
- await sleep(POLL_INTERVAL_MS);
127
- attempts++;
128
-
129
- try {
130
- const execInfo = await executionGet(client, {
131
- executionId: execution.executionId,
132
- orgId,
133
- });
134
-
135
- if (
136
- execInfo.status === 'completed' ||
137
- execInfo.status === 'failed' ||
138
- execInfo.status === 'timeout' ||
139
- execInfo.status === 'cancelled'
140
- ) {
141
- finalExecution = {
142
- executionId: execInfo.executionId,
143
- status: execInfo.status,
144
- exitCode: execInfo.exitCode,
145
- durationMs: execInfo.durationMs,
146
- };
147
- break;
148
- }
149
- } catch {
150
- continue;
151
- }
152
- }
118
+ // Use server-side long-polling to wait for execution completion
119
+ // This is more efficient than client-side polling and provides immediate
120
+ // error detection if the sandbox is terminated
121
+ const finalExecution = await executionGet(client, {
122
+ executionId: execution.executionId,
123
+ orgId,
124
+ wait: EXECUTION_WAIT_DURATION,
125
+ });
153
126
 
154
127
  // Wait for all streams to reach EOF (Pulse blocks until true EOF)
155
128
  await Promise.all(streamPromises);
@@ -248,8 +221,4 @@ function createCaptureStream(onChunk: (chunk: string) => void): NodeJS.WritableS
248
221
  });
249
222
  }
250
223
 
251
- function sleep(ms: number): Promise<void> {
252
- return new Promise((resolve) => setTimeout(resolve, ms));
253
- }
254
-
255
224
  export default execSubcommand;
@@ -72,7 +72,8 @@ export const downloadCommand = createSubcommand({
72
72
  apiClient,
73
73
  profileName,
74
74
  identifier,
75
- orgId
75
+ orgId,
76
+ config
76
77
  );
77
78
 
78
79
  const hostname = getIONHost(config, region);
@@ -75,7 +75,8 @@ export const uploadCommand = createSubcommand({
75
75
  apiClient,
76
76
  profileName,
77
77
  identifier,
78
- orgId
78
+ orgId,
79
+ config
79
80
  );
80
81
 
81
82
  const hostname = getIONHost(config, region);
@@ -80,7 +80,8 @@ export const sshSubcommand = createSubcommand({
80
80
  apiClient,
81
81
  profileName,
82
82
  targetIdentifier,
83
- orgId
83
+ orgId,
84
+ config
84
85
  );
85
86
 
86
87
  const hostname = getIONHost(config, region);
@@ -29,6 +29,9 @@ export const createSubcommand = defineSubcommand({
29
29
  },
30
30
  ],
31
31
  schema: {
32
+ options: z.object({
33
+ description: z.string().optional().describe('Optional description for the bucket'),
34
+ }),
32
35
  response: z.object({
33
36
  success: z.boolean().describe('Whether creation succeeded'),
34
37
  name: z.string().describe('Created storage bucket name'),
@@ -36,7 +39,7 @@ export const createSubcommand = defineSubcommand({
36
39
  },
37
40
 
38
41
  async handler(ctx) {
39
- const { logger, orgId, region, auth, options } = ctx;
42
+ const { logger, orgId, region, auth, options, opts } = ctx;
40
43
 
41
44
  // Handle dry-run mode
42
45
  if (isDryRunMode(options)) {
@@ -58,7 +61,9 @@ export const createSubcommand = defineSubcommand({
58
61
  message: `Creating storage in ${region}`,
59
62
  clearOnSuccess: true,
60
63
  callback: async () => {
61
- return createResources(catalystClient, orgId, region!, [{ type: 's3' }]);
64
+ return createResources(catalystClient, orgId, region!, [
65
+ { type: 's3', description: opts.description },
66
+ ]);
62
67
  },
63
68
  });
64
69
 
@@ -15,6 +15,9 @@ const StorageGetResponseSchema = z.object({
15
15
  endpoint: z.string().optional().describe('S3 endpoint URL'),
16
16
  org_id: z.string().optional().describe('Organization ID that owns this bucket'),
17
17
  org_name: z.string().optional().describe('Organization name that owns this bucket'),
18
+ bucket_type: z.string().optional().describe('Bucket type (user or snapshots)'),
19
+ internal: z.boolean().optional().describe('Whether this is a system-managed bucket'),
20
+ description: z.string().optional().describe('Optional description of the bucket'),
18
21
  });
19
22
 
20
23
  export const getSubcommand = createSubcommand({
@@ -134,6 +137,9 @@ export const getSubcommand = createSubcommand({
134
137
  endpoint: bucket.endpoint ?? undefined,
135
138
  org_id: bucket.org_id,
136
139
  org_name: bucket.org_name,
140
+ bucket_type: bucket.bucket_type,
141
+ internal: bucket.internal,
142
+ description: bucket.description ?? undefined,
137
143
  };
138
144
  },
139
145
  });
@@ -20,6 +20,9 @@ const StorageListResponseSchema = z.object({
20
20
  cloud_region: z.string().optional().describe('Cloud region where bucket is hosted'),
21
21
  org_id: z.string().optional().describe('Organization ID that owns this bucket'),
22
22
  org_name: z.string().optional().describe('Organization name that owns this bucket'),
23
+ bucket_type: z.string().optional().describe('Bucket type (user or snapshots)'),
24
+ internal: z.boolean().optional().describe('Whether this is a system-managed bucket'),
25
+ description: z.string().optional().describe('Optional description of the bucket'),
23
26
  })
24
27
  )
25
28
  .optional()
@@ -241,6 +244,9 @@ export const listSubcommand = createSubcommand({
241
244
  cloud_region: s3.cloud_region,
242
245
  org_id: s3.org_id,
243
246
  org_name: s3.org_name,
247
+ bucket_type: s3.bucket_type,
248
+ internal: s3.internal,
249
+ description: s3.description ?? undefined,
244
250
  })),
245
251
  };
246
252
  },
@@ -12,6 +12,7 @@ import {
12
12
  generateAuthSchemaSql,
13
13
  getGeneratedSqlDir,
14
14
  } from './shared';
15
+ import { readEnvFile, writeEnvFile } from '../../../env-util';
15
16
  import enquirer from 'enquirer';
16
17
  import * as fs from 'fs';
17
18
  import * as path from 'path';
@@ -96,32 +97,23 @@ export const initSubcommand = createSubcommand({
96
97
 
97
98
  const databaseName = dbInfo.name;
98
99
 
99
- // Update .env with database URL
100
+ // Update .env with database URL using proper parsing
100
101
  const envPath = path.join(projectDir, '.env');
101
- let envContent = '';
102
-
103
- if (fs.existsSync(envPath)) {
104
- envContent = fs.readFileSync(envPath, 'utf-8');
105
- if (!envContent.endsWith('\n') && envContent.length > 0) {
106
- envContent += '\n';
107
- }
108
- }
102
+ const existingEnv = await readEnvFile(envPath);
109
103
 
110
104
  // Check if DATABASE_URL already exists
111
- const hasDatabaseUrl = envContent.match(/^DATABASE_URL=/m);
105
+ const hasDatabaseUrl = 'DATABASE_URL' in existingEnv;
112
106
 
113
107
  if (dbInfo.url !== databaseUrl || !hasDatabaseUrl) {
114
108
  if (hasDatabaseUrl) {
115
109
  // DATABASE_URL exists, use AUTH_DATABASE_URL instead
116
- envContent += `AUTH_DATABASE_URL="${dbInfo.url}"\n`;
117
- fs.writeFileSync(envPath, envContent);
110
+ await writeEnvFile(envPath, { AUTH_DATABASE_URL: dbInfo.url });
118
111
  tui.success('AUTH_DATABASE_URL added to .env');
119
112
  tui.warning(
120
113
  `DATABASE_URL already exists. Update your ${tui.bold('src/auth.ts')} to use AUTH_DATABASE_URL.`
121
114
  );
122
115
  } else {
123
- envContent += `DATABASE_URL="${dbInfo.url}"\n`;
124
- fs.writeFileSync(envPath, envContent);
116
+ await writeEnvFile(envPath, { DATABASE_URL: dbInfo.url });
125
117
  tui.success('DATABASE_URL added to .env');
126
118
  }
127
119
  } else {
@@ -129,18 +121,14 @@ export const initSubcommand = createSubcommand({
129
121
  }
130
122
 
131
123
  // Add AGENTUITY_AUTH_SECRET if not present
132
- // Re-read envContent to get latest state
133
- envContent = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf-8') : '';
134
- if (!envContent.endsWith('\n') && envContent.length > 0) {
135
- envContent += '\n';
136
- }
124
+ // Re-read env to get latest state
125
+ const currentEnv = await readEnvFile(envPath);
137
126
 
138
127
  const hasAuthSecret =
139
- envContent.match(/^AGENTUITY_AUTH_SECRET=/m) || envContent.match(/^BETTER_AUTH_SECRET=/m);
128
+ 'AGENTUITY_AUTH_SECRET' in currentEnv || 'BETTER_AUTH_SECRET' in currentEnv;
140
129
  if (!hasAuthSecret) {
141
130
  const devSecret = `dev-${crypto.randomUUID()}-CHANGE-ME`;
142
- envContent += `AGENTUITY_AUTH_SECRET="${devSecret}"\n`;
143
- fs.writeFileSync(envPath, envContent);
131
+ await writeEnvFile(envPath, { AGENTUITY_AUTH_SECRET: devSecret });
144
132
  tui.success('AGENTUITY_AUTH_SECRET added to .env (development default)');
145
133
  tui.warning(
146
134
  `Replace ${tui.bold('AGENTUITY_AUTH_SECRET')} with a secure value before deploying.`
package/src/config.ts CHANGED
@@ -809,14 +809,16 @@ export async function getDefaultRegion(
809
809
  * @param auth - Authentication data
810
810
  * @param profileName - Profile name (default: 'production')
811
811
  * @param orgId - Optional organization ID for CLI key authentication
812
+ * @param config - Optional config for region preference lookup
812
813
  */
813
814
  export async function getGlobalCatalystAPIClient(
814
815
  logger: Logger,
815
816
  auth: AuthData,
816
817
  profileName = 'production',
817
- orgId?: string
818
+ orgId?: string,
819
+ config?: Config | null
818
820
  ) {
819
- const region = await getDefaultRegion(profileName);
821
+ const region = await getDefaultRegion(profileName, config);
820
822
  return getCatalystAPIClient(logger, auth, region, orgId);
821
823
  }
822
824
 
@@ -828,6 +830,12 @@ export function getIONHost(config: Config | null, region: string) {
828
830
  if (config?.name === 'local' || region === 'local') {
829
831
  return 'ion.agentuity.io';
830
832
  }
833
+ // Validate region is a non-empty string to prevent malformed hostnames
834
+ if (!region || typeof region !== 'string' || region.trim() === '') {
835
+ throw new Error(
836
+ `Invalid region: '${region}'. Region must be a non-empty string. Use --region flag to specify a valid region.`
837
+ );
838
+ }
831
839
  return `ion-${region}.agentuity.cloud`;
832
840
  }
833
841
 
package/src/env-util.ts CHANGED
@@ -149,7 +149,8 @@ export async function readEnvFile(path: string): Promise<EnvVars> {
149
149
 
150
150
  /**
151
151
  * Write environment variables to an .env file
152
- * Optionally skip certain keys (like AGENTUITY_SDK_KEY)
152
+ * By default, preserves existing keys that are not in the new vars.
153
+ * Use preserveExisting: false to completely overwrite the file.
153
154
  */
154
155
  export async function writeEnvFile(
155
156
  path: string,
@@ -157,20 +158,36 @@ export async function writeEnvFile(
157
158
  options?: {
158
159
  skipKeys?: string[];
159
160
  addComment?: (key: string) => string | null;
161
+ /**
162
+ * When true (default), reads existing file first and merges with new vars.
163
+ * New vars take priority for matching keys, but all existing keys are preserved.
164
+ * When false, completely overwrites the file with only the provided vars.
165
+ */
166
+ preserveExisting?: boolean;
160
167
  }
161
168
  ): Promise<void> {
162
169
  const skipKeys = options?.skipKeys || [];
170
+ const preserveExisting = options?.preserveExisting ?? true;
171
+
172
+ // If preserveExisting is true, read existing file and merge
173
+ let finalVars = vars;
174
+ if (preserveExisting) {
175
+ const existing = await readEnvFile(path);
176
+ // Merge: existing as base, new vars override
177
+ finalVars = { ...existing, ...vars };
178
+ }
179
+
163
180
  const lines: string[] = [];
164
181
 
165
182
  // Sort keys for consistent output
166
- const sortedKeys = Object.keys(vars).sort();
183
+ const sortedKeys = Object.keys(finalVars).sort();
167
184
 
168
185
  for (const key of sortedKeys) {
169
186
  if (skipKeys.includes(key)) {
170
187
  continue;
171
188
  }
172
189
 
173
- const value = vars[key];
190
+ const value = finalVars[key];
174
191
 
175
192
  // Add comment if provided
176
193
  if (options?.addComment) {
package/src/types.ts CHANGED
@@ -24,7 +24,10 @@ export const ConfigSchema = zod.object({
24
24
  devmode: zod
25
25
  .object({
26
26
  hostname: zod.string().optional().describe('Development mode hostname'),
27
- privateKey: zod.string().optional().describe('Development mode private key (base64-encoded PEM)'),
27
+ privateKey: zod
28
+ .string()
29
+ .optional()
30
+ .describe('Development mode private key (base64-encoded PEM)'),
28
31
  })
29
32
  .optional()
30
33
  .describe('Development mode configuration'),
@@ -2,10 +2,26 @@
2
2
  * Detects how the CLI was installed and is being run
3
3
  */
4
4
 
5
+ import fs from 'node:fs';
5
6
  import os from 'node:os';
6
7
 
7
8
  export type InstallationType = 'global' | 'local' | 'source';
8
9
 
10
+ /**
11
+ * Resolve a path to its real path (following symlinks) and normalize to POSIX separators.
12
+ * Returns the original path if resolution fails.
13
+ */
14
+ function resolveRealPath(path: string): string {
15
+ if (!path) return '';
16
+ try {
17
+ // fs.realpathSync resolves symlinks (e.g., /tmp -> /private/tmp on macOS)
18
+ return fs.realpathSync(path).replace(/\\/g, '/');
19
+ } catch {
20
+ // If the path doesn't exist or can't be resolved, return normalized original
21
+ return path.replace(/\\/g, '/');
22
+ }
23
+ }
24
+
9
25
  /**
10
26
  * Determines the installation type based on how the CLI is being executed
11
27
  *
@@ -14,34 +30,57 @@ export type InstallationType = 'global' | 'local' | 'source';
14
30
  * @returns 'source' - Running from source code (development)
15
31
  */
16
32
  export function getInstallationType(): InstallationType {
17
- // Normalize paths to POSIX separators for cross-platform compatibility
33
+ // Bun.main already returns the resolved real path, just normalize separators
18
34
  const mainPath = Bun.main.replace(/\\/g, '/');
19
- // Bun.argv[1] contains the original invocation path (before symlink resolution)
20
- const invokedPath = (Bun.argv[1] ?? '').replace(/\\/g, '/');
21
35
 
22
- // Get bun's global bin directory from BUN_INSTALL or default to ~/.bun/bin
23
- // Use os.homedir() as primary fallback to avoid "undefined/.bun" paths
24
- const home = os.homedir() ?? process.env.HOME ?? process.env.USERPROFILE ?? '';
25
- const bunInstall = (process.env.BUN_INSTALL ?? (home ? `${home}/.bun` : '')).replace(/\\/g, '/');
26
- const globalBinDir = bunInstall ? `${bunInstall}/bin/` : '';
36
+ // Get home directory reliably and resolve symlinks
37
+ // On macOS, os.homedir() returns /Users/xxx which is already real
38
+ const home = resolveRealPath(os.homedir() ?? process.env.HOME ?? process.env.USERPROFILE ?? '');
27
39
 
28
- // Global install: invoked from bun's global bin directory (e.g., ~/.bun/bin/agentuity)
29
- // This handles symlinks created by `bun add -g @agentuity/cli`
30
- if (globalBinDir && invokedPath.startsWith(globalBinDir)) {
31
- return 'global';
40
+ // Get bun install directory from BUN_INSTALL or default to ~/.bun
41
+ // Resolve symlinks to handle cases like BUN_INSTALL=/tmp/... on macOS where /tmp -> /private/tmp
42
+ const bunInstallRaw = process.env.BUN_INSTALL ?? (home ? `${home}/.bun` : '');
43
+ const bunInstall = resolveRealPath(bunInstallRaw);
44
+
45
+ // GLOBAL DETECTION: Check if running from bun's global install location
46
+ // When installed via `bun add -g`, the CLI lives at ~/.bun/node_modules/@agentuity/cli/
47
+ // or ~/.bun/install/global/node_modules/@agentuity/cli/
48
+ if (bunInstall) {
49
+ // Check for ~/.bun/node_modules/@agentuity/cli/ (common bun global layout)
50
+ if (mainPath.startsWith(`${bunInstall}/node_modules/@agentuity/cli/`)) {
51
+ return 'global';
52
+ }
53
+ // Check for ~/.bun/install/global/node_modules/@agentuity/cli/ (alternative layout)
54
+ if (mainPath.startsWith(`${bunInstall}/install/global/`)) {
55
+ return 'global';
56
+ }
57
+ }
58
+
59
+ // GLOBAL DETECTION: Check for legacy ~/.agentuity/ installation
60
+ // The install.sh script may install to ~/.agentuity/node_modules/@agentuity/cli/
61
+ // or create a shim at ~/.agentuity/bin/agentuity
62
+ if (home) {
63
+ const agentuityDir = resolveRealPath(`${home}/.agentuity`);
64
+ if (mainPath.startsWith(`${agentuityDir}/`)) {
65
+ return 'global';
66
+ }
32
67
  }
33
68
 
34
- // Also check the resolved path for explicit global install locations
35
- if (mainPath.includes('/.bun/install/global/')) {
69
+ // GLOBAL DETECTION: Fallback check for any path containing /.bun/ before node_modules
70
+ // This catches edge cases where BUN_INSTALL might not match the actual path
71
+ if (mainPath.includes('/.bun/') && mainPath.includes('/node_modules/@agentuity/cli/')) {
36
72
  return 'global';
37
73
  }
38
74
 
39
- // Local project install: ./node_modules/@agentuity/cli/...
75
+ // LOCAL DETECTION: Running from a project's node_modules
76
+ // This is when someone runs `bunx agentuity` or has it as a project dependency
77
+ // At this point, we've ruled out global installs, so any node_modules path is local
40
78
  if (mainPath.includes('/node_modules/@agentuity/cli/')) {
41
79
  return 'local';
42
80
  }
43
81
 
44
- // Source/development: packages/cli/bin/cli.ts or similar
82
+ // SOURCE DETECTION: Running from source code (development)
83
+ // This is when running directly from the monorepo: packages/cli/bin/cli.ts
45
84
  return 'source';
46
85
  }
47
86