@agentuity/cli 1.0.5 → 1.0.7

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.
@@ -13,20 +13,80 @@ import {
13
13
  import { getCommand } from '../../../command-prefix';
14
14
  import { resolveOrgId, isOrgScope } from './org-util';
15
15
 
16
+ interface ParsedEnvPair {
17
+ key: string;
18
+ value: string;
19
+ }
20
+
21
+ /**
22
+ * Parse env set arguments into key-value pairs.
23
+ * Supports two formats:
24
+ * - Legacy: KEY VALUE (exactly 2 args)
25
+ * - KEY=VALUE format: KEY1=VALUE1 [KEY2=VALUE2 ...]
26
+ */
27
+ function parseEnvArgs(rawArgs: string[]): ParsedEnvPair[] {
28
+ if (rawArgs.length === 0) {
29
+ tui.fatal(
30
+ 'No arguments provided. Usage: env set KEY VALUE or env set KEY=VALUE [KEY2=VALUE2 ...]'
31
+ );
32
+ }
33
+
34
+ // Check if first arg contains '=' — if so, treat ALL args as KEY=VALUE format
35
+ const firstArg = rawArgs[0]!;
36
+ if (firstArg.includes('=')) {
37
+ const pairs: ParsedEnvPair[] = [];
38
+ for (const arg of rawArgs) {
39
+ const eqIndex = arg.indexOf('=');
40
+ if (eqIndex === -1 || eqIndex === 0) {
41
+ tui.fatal(`Invalid format: '${arg}'. Expected KEY=VALUE format.`);
42
+ }
43
+ const key = arg.substring(0, eqIndex);
44
+ const value = arg.substring(eqIndex + 1);
45
+ if (!key) {
46
+ tui.fatal(`Invalid format: '${arg}'. Key cannot be empty.`);
47
+ }
48
+ pairs.push({ key, value });
49
+ }
50
+ return pairs;
51
+ }
52
+
53
+ // Legacy format: exactly 2 args = KEY VALUE
54
+ if (rawArgs.length === 2) {
55
+ const key = rawArgs[0]!.trim();
56
+ if (!key) {
57
+ tui.fatal(
58
+ 'Invalid format: key cannot be empty. Usage: env set KEY VALUE or env set KEY=VALUE'
59
+ );
60
+ }
61
+ return [{ key, value: rawArgs[1]! }];
62
+ }
63
+
64
+ // Ambiguous: 1 arg without '=' or 3+ args without '='
65
+ if (rawArgs.length === 1) {
66
+ tui.fatal(`Missing value for '${rawArgs[0]}'. Usage: env set KEY VALUE or env set KEY=VALUE`);
67
+ }
68
+
69
+ // 3+ args without '=' in first arg
70
+ tui.fatal(
71
+ 'Multiple variables must use KEY=VALUE format. Usage: env set KEY1=VALUE1 KEY2=VALUE2 ...'
72
+ );
73
+ }
74
+
16
75
  const EnvSetResponseSchema = z.object({
17
76
  success: z.boolean().describe('Whether the operation succeeded'),
18
- key: z.string().describe('Environment variable key'),
77
+ keys: z.array(z.string()).describe('Environment variable keys that were set'),
19
78
  path: z
20
79
  .string()
21
80
  .optional()
22
- .describe('Local file path where env var was saved (project scope only)'),
23
- secret: z.boolean().describe('Whether the value was stored as a secret'),
24
- scope: z.enum(['project', 'org']).describe('The scope where the variable was set'),
81
+ .describe('Local file path where env vars were saved (project scope only)'),
82
+ secretKeys: z.array(z.string()).describe('Keys that were stored as secrets'),
83
+ envKeys: z.array(z.string()).describe('Keys that were stored as env vars'),
84
+ scope: z.enum(['project', 'org']).describe('The scope where the variables were set'),
25
85
  });
26
86
 
27
87
  export const setSubcommand = createSubcommand({
28
88
  name: 'set',
29
- description: 'Set an environment variable or secret',
89
+ description: 'Set one or more environment variables or secrets',
30
90
  tags: ['mutating', 'updates-resource', 'slow', 'requires-auth'],
31
91
  idempotent: true,
32
92
  requires: { auth: true, apiClient: true },
@@ -36,7 +96,15 @@ export const setSubcommand = createSubcommand({
36
96
  command: getCommand('env set NODE_ENV production'),
37
97
  description: 'Set environment variable',
38
98
  },
99
+ {
100
+ command: getCommand('env set NODE_ENV=production'),
101
+ description: 'Set using KEY=VALUE format',
102
+ },
39
103
  { command: getCommand('env set PORT 3000'), description: 'Set port number' },
104
+ {
105
+ command: getCommand('env set NODE_ENV=production LOG_LEVEL=info PORT=3000'),
106
+ description: 'Set multiple variables at once',
107
+ },
40
108
  {
41
109
  command: getCommand('env set API_KEY "sk_..." --secret'),
42
110
  description: 'Set a secret value',
@@ -48,8 +116,7 @@ export const setSubcommand = createSubcommand({
48
116
  ],
49
117
  schema: {
50
118
  args: z.object({
51
- key: z.string().describe('the environment variable key'),
52
- value: z.string().describe('the environment variable value'),
119
+ args: z.array(z.string()).describe('KEY VALUE or KEY=VALUE [KEY2=VALUE2 ...]'),
53
120
  }),
54
121
  options: z.object({
55
122
  secret: z
@@ -67,8 +134,9 @@ export const setSubcommand = createSubcommand({
67
134
  },
68
135
 
69
136
  async handler(ctx) {
70
- const { args, opts, apiClient, project, projectDir, config } = ctx;
137
+ const { args: cmdArgs, opts, apiClient, project, projectDir, config } = ctx;
71
138
  const useOrgScope = isOrgScope(opts?.org);
139
+ const forceSecret = opts?.secret ?? false;
72
140
 
73
141
  // Require project context if not using org scope
74
142
  if (!useOrgScope && !project) {
@@ -77,86 +145,145 @@ export const setSubcommand = createSubcommand({
77
145
  );
78
146
  }
79
147
 
80
- let isSecret = opts?.secret ?? false;
81
- const isPublic = isPublicVarKey(args.key);
148
+ const pairs = parseEnvArgs(cmdArgs.args);
82
149
 
83
- // Validate key doesn't start with reserved AGENTUITY_ prefix (except AGENTUITY_PUBLIC_)
84
- if (isReservedAgentuityKey(args.key)) {
85
- tui.fatal('Cannot set AGENTUITY_ prefixed variables. These are reserved for system use.');
150
+ // Validate all keys first
151
+ for (const pair of pairs) {
152
+ if (isReservedAgentuityKey(pair.key)) {
153
+ tui.fatal(
154
+ `Cannot set AGENTUITY_ prefixed variables: '${pair.key}'. These are reserved for system use.`
155
+ );
156
+ }
86
157
  }
87
158
 
88
- // Validate public vars cannot be secrets
89
- if (isSecret && isPublic) {
90
- tui.fatal(
91
- `Cannot set public variables as secrets. Keys with prefixes (${PUBLIC_VAR_PREFIXES.join(', ')}) are exposed to the frontend.`
92
- );
159
+ // Reject duplicate keys
160
+ const seenKeys = new Set<string>();
161
+ for (const pair of pairs) {
162
+ if (seenKeys.has(pair.key)) {
163
+ tui.fatal(`Duplicate key '${pair.key}'. Each variable may only be specified once.`);
164
+ }
165
+ seenKeys.add(pair.key);
93
166
  }
94
167
 
95
- // Auto-detect if this looks like a secret and offer to store as secret
96
- // Skip auto-detect for public vars since they can never be secrets
97
- if (!isSecret && !isPublic && looksLikeSecret(args.key, args.value)) {
98
- tui.warning(`The variable '${args.key}' looks like it should be a secret.`);
168
+ // Classify each pair as env or secret
169
+ const envPairs: Record<string, string> = {};
170
+ const secretPairs: Record<string, string> = {};
171
+ const secretKeysList: string[] = [];
172
+ const envKeysList: string[] = [];
173
+
174
+ for (const pair of pairs) {
175
+ const isPublic = isPublicVarKey(pair.key);
176
+ let isSecret = forceSecret;
99
177
 
100
- const storeAsSecret = await tui.confirm('Store as a secret instead?', true);
178
+ // Validate public vars cannot be secrets
179
+ if (isSecret && isPublic) {
180
+ tui.fatal(
181
+ `Cannot set public variables as secrets. '${pair.key}' (prefix ${PUBLIC_VAR_PREFIXES.join(', ')}) is exposed to the frontend.`
182
+ );
183
+ }
184
+
185
+ // Auto-detect if this looks like a secret and offer to store as secret
186
+ // Skip auto-detect for public vars since they can never be secrets
187
+ if (!isSecret && !isPublic && looksLikeSecret(pair.key, pair.value)) {
188
+ if (pairs.length === 1) {
189
+ // Single pair: offer interactive prompt (existing behavior)
190
+ tui.warning(`The variable '${pair.key}' looks like it should be a secret.`);
191
+ const storeAsSecret = await tui.confirm('Store as a secret instead?', true);
192
+ if (storeAsSecret) {
193
+ isSecret = true;
194
+ }
195
+ } else {
196
+ // Multiple pairs: auto-detect silently
197
+ isSecret = true;
198
+ tui.info(`Auto-detected '${pair.key}' as a secret`);
199
+ }
200
+ }
101
201
 
102
- if (storeAsSecret) {
103
- isSecret = true;
202
+ if (isSecret) {
203
+ secretPairs[pair.key] = pair.value;
204
+ secretKeysList.push(pair.key);
205
+ } else {
206
+ envPairs[pair.key] = pair.value;
207
+ envKeysList.push(pair.key);
104
208
  }
105
209
  }
106
210
 
107
- const label = isSecret ? 'secret' : 'environment variable';
211
+ const totalCount = pairs.length;
212
+ const allKeys = [...envKeysList, ...secretKeysList];
213
+ const secretSuffix =
214
+ secretKeysList.length > 0
215
+ ? ` (${secretKeysList.length} secret${secretKeysList.length !== 1 ? 's' : ''})`
216
+ : '';
108
217
 
109
218
  if (useOrgScope) {
110
219
  // Organization scope
111
220
  const orgId = await resolveOrgId(apiClient, config, opts!.org!);
112
221
 
113
- const updatePayload = isSecret
114
- ? { id: orgId, secrets: { [args.key]: args.value } }
115
- : { id: orgId, env: { [args.key]: args.value } };
222
+ const updatePayload: {
223
+ id: string;
224
+ env?: Record<string, string>;
225
+ secrets?: Record<string, string>;
226
+ } = { id: orgId };
227
+ if (Object.keys(envPairs).length > 0) updatePayload.env = envPairs;
228
+ if (Object.keys(secretPairs).length > 0) updatePayload.secrets = secretPairs;
116
229
 
117
- await tui.spinner(`Setting organization ${label} in cloud`, () => {
118
- return orgEnvUpdate(apiClient, updatePayload);
119
- });
230
+ await tui.spinner(
231
+ `Setting ${totalCount} organization variable${totalCount !== 1 ? 's' : ''} in cloud`,
232
+ () => {
233
+ return orgEnvUpdate(apiClient, updatePayload);
234
+ }
235
+ );
120
236
 
121
237
  tui.success(
122
- `Organization ${isSecret ? 'secret' : 'environment variable'} '${args.key}' set successfully (affects all projects in org)`
238
+ `Organization variable${totalCount !== 1 ? 's' : ''} set successfully: ${allKeys.join(', ')}${secretSuffix}`
123
239
  );
124
240
 
125
241
  return {
126
242
  success: true,
127
- key: args.key,
128
- secret: isSecret,
243
+ keys: allKeys,
244
+ secretKeys: secretKeysList,
245
+ envKeys: envKeysList,
129
246
  scope: 'org' as const,
130
247
  };
131
248
  } else {
132
- // Project scope (existing behavior)
133
- const updatePayload = isSecret
134
- ? { id: project!.projectId, secrets: { [args.key]: args.value } }
135
- : { id: project!.projectId, env: { [args.key]: args.value } };
249
+ // Project scope
250
+ const updatePayload: {
251
+ id: string;
252
+ env?: Record<string, string>;
253
+ secrets?: Record<string, string>;
254
+ } = { id: project!.projectId };
255
+ if (Object.keys(envPairs).length > 0) updatePayload.env = envPairs;
256
+ if (Object.keys(secretPairs).length > 0) updatePayload.secrets = secretPairs;
136
257
 
137
- await tui.spinner(`Setting ${label} in cloud`, () => {
138
- return projectEnvUpdate(apiClient, updatePayload);
139
- });
258
+ await tui.spinner(
259
+ `Setting ${totalCount} variable${totalCount !== 1 ? 's' : ''} in cloud`,
260
+ () => {
261
+ return projectEnvUpdate(apiClient, updatePayload);
262
+ }
263
+ );
140
264
 
141
265
  // Update local .env file only if we have a project directory
142
- // (not when using --project-id without being in a project folder)
143
266
  let envFilePath: string | undefined;
144
267
  if (projectDir) {
145
268
  envFilePath = await findExistingEnvFile(projectDir);
146
- // Write only the new key - writeEnvFile preserves existing keys by default
147
- await writeEnvFile(envFilePath, { [args.key]: args.value });
269
+ const allPairsForLocal: Record<string, string> = {
270
+ ...envPairs,
271
+ ...secretPairs,
272
+ };
273
+ await writeEnvFile(envFilePath, allPairsForLocal);
148
274
  }
149
275
 
150
- const successMsg = envFilePath
151
- ? `${isSecret ? 'Secret' : 'Environment variable'} '${args.key}' set successfully (cloud + ${envFilePath})`
152
- : `${isSecret ? 'Secret' : 'Environment variable'} '${args.key}' set successfully (cloud only)`;
153
- tui.success(successMsg);
276
+ const locationMsg = envFilePath ? ` (cloud + ${envFilePath})` : ' (cloud only)';
277
+ tui.success(
278
+ `Variable${totalCount !== 1 ? 's' : ''} set successfully: ${allKeys.join(', ')}${secretSuffix}${locationMsg}`
279
+ );
154
280
 
155
281
  return {
156
282
  success: true,
157
- key: args.key,
283
+ keys: allKeys,
158
284
  path: envFilePath,
159
- secret: isSecret,
285
+ secretKeys: secretKeysList,
286
+ envKeys: envKeysList,
160
287
  scope: 'project' as const,
161
288
  };
162
289
  }
package/src/domain.ts CHANGED
@@ -110,6 +110,28 @@ export async function checkCustomDomainForDNS(
110
110
 
111
111
  return Promise.all(
112
112
  domains.map(async (domain) => {
113
+ // Detect if user passed a URL instead of a domain name
114
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(domain)) {
115
+ try {
116
+ const url = new URL(domain);
117
+ return {
118
+ domain,
119
+ target: proxy,
120
+ recordType: 'CNAME',
121
+ success: false,
122
+ error: `Invalid domain format: "${domain}" appears to be a URL. Use just the domain name: "${url.hostname}"`,
123
+ } as DNSError;
124
+ } catch {
125
+ return {
126
+ domain,
127
+ target: proxy,
128
+ recordType: 'CNAME',
129
+ success: false,
130
+ error: `Invalid domain format: "${domain}" appears to be a URL. Use just the domain name without the protocol (e.g., "example.com" not "https://example.com")`,
131
+ } as DNSError;
132
+ }
133
+ }
134
+
113
135
  try {
114
136
  let timeoutId: Timer | undefined;
115
137