@agentuity/cli 0.1.24 → 0.1.26

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 (64) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +1 -1
  3. package/dist/cli.js.map +1 -1
  4. package/dist/cmd/ai/cadence/index.d.ts.map +1 -1
  5. package/dist/cmd/ai/cadence/index.js +8 -2
  6. package/dist/cmd/ai/cadence/index.js.map +1 -1
  7. package/dist/cmd/ai/index.d.ts.map +1 -1
  8. package/dist/cmd/ai/index.js +8 -1
  9. package/dist/cmd/ai/index.js.map +1 -1
  10. package/dist/cmd/build/patch/index.d.ts.map +1 -1
  11. package/dist/cmd/build/patch/index.js +4 -0
  12. package/dist/cmd/build/patch/index.js.map +1 -1
  13. package/dist/cmd/build/patch/otel-llm.d.ts +10 -0
  14. package/dist/cmd/build/patch/otel-llm.d.ts.map +1 -0
  15. package/dist/cmd/build/patch/otel-llm.js +374 -0
  16. package/dist/cmd/build/patch/otel-llm.js.map +1 -0
  17. package/dist/cmd/cloud/db/create.js +3 -3
  18. package/dist/cmd/cloud/db/create.js.map +1 -1
  19. package/dist/cmd/cloud/deploy.d.ts.map +1 -1
  20. package/dist/cmd/cloud/deploy.js +55 -2
  21. package/dist/cmd/cloud/deploy.js.map +1 -1
  22. package/dist/cmd/cloud/env/pull.d.ts.map +1 -1
  23. package/dist/cmd/cloud/env/pull.js +26 -17
  24. package/dist/cmd/cloud/env/pull.js.map +1 -1
  25. package/dist/cmd/cloud/eval-run/list.d.ts.map +1 -1
  26. package/dist/cmd/cloud/eval-run/list.js +5 -1
  27. package/dist/cmd/cloud/eval-run/list.js.map +1 -1
  28. package/dist/cmd/cloud/queue/dlq.d.ts.map +1 -1
  29. package/dist/cmd/cloud/queue/dlq.js.map +1 -1
  30. package/dist/cmd/cloud/sandbox/download.d.ts.map +1 -1
  31. package/dist/cmd/cloud/sandbox/download.js +8 -3
  32. package/dist/cmd/cloud/sandbox/download.js.map +1 -1
  33. package/dist/cmd/cloud/sandbox/snapshot/build.d.ts.map +1 -1
  34. package/dist/cmd/cloud/sandbox/snapshot/build.js +52 -35
  35. package/dist/cmd/cloud/sandbox/snapshot/build.js.map +1 -1
  36. package/dist/cmd/cloud/sandbox/upload.d.ts.map +1 -1
  37. package/dist/cmd/cloud/sandbox/upload.js +8 -1
  38. package/dist/cmd/cloud/sandbox/upload.js.map +1 -1
  39. package/dist/cmd/profile/create.js +2 -2
  40. package/dist/cmd/profile/create.js.map +1 -1
  41. package/dist/cmd/project/create.d.ts.map +1 -1
  42. package/dist/cmd/project/create.js +6 -3
  43. package/dist/cmd/project/create.js.map +1 -1
  44. package/dist/utils/deps.d.ts +8 -0
  45. package/dist/utils/deps.d.ts.map +1 -0
  46. package/dist/utils/deps.js +36 -0
  47. package/dist/utils/deps.js.map +1 -0
  48. package/package.json +6 -6
  49. package/src/cli.ts +1 -5
  50. package/src/cmd/ai/cadence/index.ts +8 -2
  51. package/src/cmd/ai/index.ts +8 -1
  52. package/src/cmd/build/patch/index.ts +4 -0
  53. package/src/cmd/build/patch/otel-llm.ts +421 -0
  54. package/src/cmd/cloud/db/create.ts +3 -3
  55. package/src/cmd/cloud/deploy.ts +77 -1
  56. package/src/cmd/cloud/env/pull.ts +29 -19
  57. package/src/cmd/cloud/eval-run/list.ts +5 -1
  58. package/src/cmd/cloud/queue/dlq.ts +11 -10
  59. package/src/cmd/cloud/sandbox/download.ts +9 -3
  60. package/src/cmd/cloud/sandbox/snapshot/build.ts +71 -44
  61. package/src/cmd/cloud/sandbox/upload.ts +9 -1
  62. package/src/cmd/profile/create.ts +2 -2
  63. package/src/cmd/project/create.ts +6 -3
  64. package/src/utils/deps.ts +54 -0
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Build-time patches for OpenTelemetry LLM instrumentation.
3
+ *
4
+ * These patches wrap LLM SDK methods (OpenAI, Anthropic, etc.) with OTel spans
5
+ * at build time, since runtime instrumentation (traceloop) doesn't work with
6
+ * bundled code.
7
+ */
8
+ import { type PatchModule } from './_util';
9
+
10
+ interface OtelPatchConfig {
11
+ provider: string;
12
+ className: string;
13
+ inputTokensField: string;
14
+ outputTokensField: string;
15
+ /** Field path for response ID (e.g., 'id' for OpenAI) */
16
+ responseIdField?: string;
17
+ /** Field path for finish reason (e.g., 'choices[0].finish_reason' for OpenAI) */
18
+ finishReasonPath?: string;
19
+ /** Field path for response content (e.g., 'choices[0].message.content' for OpenAI) */
20
+ responseContentPath?: string;
21
+ /** Field name in request for messages (e.g., 'messages' for OpenAI) */
22
+ requestMessagesField?: string;
23
+ /** Field path for streaming delta content (e.g., 'choices[0].delta.content' for OpenAI) */
24
+ streamDeltaContentPath?: string;
25
+ /** Field path for streaming finish reason (e.g., 'choices[0].finish_reason' for OpenAI) */
26
+ streamFinishReasonPath?: string;
27
+ /** Field path for streaming usage in final chunk (e.g., 'usage' for OpenAI with stream_options) */
28
+ streamUsagePath?: string;
29
+ }
30
+
31
+ /**
32
+ * Generate the OTel wrapper code for LLM chat completions.
33
+ * This creates a span with GenAI semantic conventions.
34
+ */
35
+ function generateChatCompletionsWrapper(config: OtelPatchConfig): string {
36
+ const {
37
+ provider,
38
+ className,
39
+ inputTokensField,
40
+ outputTokensField,
41
+ responseIdField = 'id',
42
+ finishReasonPath,
43
+ responseContentPath,
44
+ requestMessagesField = 'messages',
45
+ streamDeltaContentPath,
46
+ streamFinishReasonPath,
47
+ streamUsagePath = 'usage',
48
+ } = config;
49
+
50
+ // Generate code to extract nested field (e.g., 'choices[0].finish_reason')
51
+ const generateFieldAccess = (path: string | undefined, varName: string): string => {
52
+ if (!path) return 'undefined';
53
+ // Convert path like 'choices[0].finish_reason' to safe access
54
+ const parts = path.split('.');
55
+ let code = varName;
56
+ for (const part of parts) {
57
+ const match = part.match(/^(\w+)\[(\d+)\]$/);
58
+ if (match) {
59
+ code = `(${code}?.${match[1]}?.[${match[2]}])`;
60
+ } else {
61
+ code = `(${code}?.${part})`;
62
+ }
63
+ }
64
+ return code;
65
+ };
66
+
67
+ const finishReasonCode = generateFieldAccess(finishReasonPath, 'response');
68
+ const responseContentCode = generateFieldAccess(responseContentPath, 'response');
69
+ const streamDeltaCode = generateFieldAccess(streamDeltaContentPath, 'chunk');
70
+ const streamFinishCode = generateFieldAccess(streamFinishReasonPath, 'chunk');
71
+ const streamUsageCode = generateFieldAccess(streamUsagePath, 'chunk');
72
+
73
+ return `
74
+ import * as _otel_api from '@opentelemetry/api';
75
+
76
+ const _ATTR_GEN_AI_SYSTEM = 'gen_ai.system';
77
+ const _ATTR_GEN_AI_REQUEST_MODEL = 'gen_ai.request.model';
78
+ const _ATTR_GEN_AI_REQUEST_MAX_TOKENS = 'gen_ai.request.max_tokens';
79
+ const _ATTR_GEN_AI_REQUEST_TEMPERATURE = 'gen_ai.request.temperature';
80
+ const _ATTR_GEN_AI_REQUEST_TOP_P = 'gen_ai.request.top_p';
81
+ const _ATTR_GEN_AI_REQUEST_FREQUENCY_PENALTY = 'gen_ai.request.frequency_penalty';
82
+ const _ATTR_GEN_AI_REQUEST_PRESENCE_PENALTY = 'gen_ai.request.presence_penalty';
83
+ const _ATTR_GEN_AI_RESPONSE_MODEL = 'gen_ai.response.model';
84
+ const _ATTR_GEN_AI_RESPONSE_ID = 'gen_ai.response.id';
85
+ const _ATTR_GEN_AI_RESPONSE_FINISH_REASONS = 'gen_ai.response.finish_reasons';
86
+ const _ATTR_GEN_AI_USAGE_INPUT_TOKENS = 'gen_ai.usage.input_tokens';
87
+ const _ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = 'gen_ai.usage.output_tokens';
88
+ const _ATTR_GEN_AI_OPERATION_NAME = 'gen_ai.operation.name';
89
+ const _ATTR_GEN_AI_REQUEST_MESSAGES = 'gen_ai.request.messages';
90
+ const _ATTR_GEN_AI_RESPONSE_TEXT = 'gen_ai.response.text';
91
+
92
+ const _otel_tracer = _otel_api.trace.getTracer('@agentuity/otel-llm', '1.0.0');
93
+
94
+ function _wrapAsyncIterator(iterator, span, inputTokensField, outputTokensField) {
95
+ let contentChunks = [];
96
+ let finishReason = null;
97
+ let usage = null;
98
+ let model = null;
99
+ let responseId = null;
100
+
101
+ return {
102
+ [Symbol.asyncIterator]() {
103
+ return this;
104
+ },
105
+ async next() {
106
+ try {
107
+ const result = await iterator.next();
108
+ if (result.done) {
109
+ // Stream complete - finalize span
110
+ if (contentChunks.length > 0) {
111
+ span.setAttribute(_ATTR_GEN_AI_RESPONSE_TEXT, contentChunks.join(''));
112
+ }
113
+ if (finishReason) {
114
+ span.setAttribute(_ATTR_GEN_AI_RESPONSE_FINISH_REASONS, JSON.stringify([finishReason]));
115
+ }
116
+ if (model) {
117
+ span.setAttribute(_ATTR_GEN_AI_RESPONSE_MODEL, model);
118
+ }
119
+ if (responseId) {
120
+ span.setAttribute(_ATTR_GEN_AI_RESPONSE_ID, responseId);
121
+ }
122
+ if (usage) {
123
+ if (usage[inputTokensField] !== undefined) {
124
+ span.setAttribute(_ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage[inputTokensField]);
125
+ }
126
+ if (usage[outputTokensField] !== undefined) {
127
+ span.setAttribute(_ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage[outputTokensField]);
128
+ }
129
+ }
130
+ span.setStatus({ code: _otel_api.SpanStatusCode.OK });
131
+ span.end();
132
+ return result;
133
+ }
134
+
135
+ const chunk = result.value;
136
+
137
+ // Capture model and id from first chunk
138
+ if (chunk.model && !model) {
139
+ model = chunk.model;
140
+ }
141
+ if (chunk.id && !responseId) {
142
+ responseId = chunk.id;
143
+ }
144
+
145
+ // Capture delta content
146
+ const deltaContent = ${streamDeltaCode};
147
+ if (deltaContent) {
148
+ contentChunks.push(deltaContent);
149
+ }
150
+
151
+ // Capture finish reason
152
+ const chunkFinishReason = ${streamFinishCode};
153
+ if (chunkFinishReason) {
154
+ finishReason = chunkFinishReason;
155
+ }
156
+
157
+ // Capture usage (usually in final chunk with stream_options)
158
+ const chunkUsage = ${streamUsageCode};
159
+ if (chunkUsage) {
160
+ usage = chunkUsage;
161
+ }
162
+
163
+ return result;
164
+ } catch (error) {
165
+ span.setStatus({ code: _otel_api.SpanStatusCode.ERROR, message: error?.message });
166
+ span.recordException(error);
167
+ span.end();
168
+ throw error;
169
+ }
170
+ },
171
+ async return(value) {
172
+ span.setStatus({ code: _otel_api.SpanStatusCode.OK });
173
+ span.end();
174
+ if (iterator.return) {
175
+ return iterator.return(value);
176
+ }
177
+ return { done: true, value };
178
+ },
179
+ async throw(error) {
180
+ span.setStatus({ code: _otel_api.SpanStatusCode.ERROR, message: error?.message });
181
+ span.recordException(error);
182
+ span.end();
183
+ if (iterator.throw) {
184
+ return iterator.throw(error);
185
+ }
186
+ throw error;
187
+ }
188
+ };
189
+ }
190
+
191
+ function _wrapStream(stream, span, inputTokensField, outputTokensField) {
192
+ // Get the original iterator
193
+ const originalIterator = stream[Symbol.asyncIterator]();
194
+ const wrappedIterator = _wrapAsyncIterator(originalIterator, span, inputTokensField, outputTokensField);
195
+
196
+ // Return a proxy that wraps the async iterator but preserves other properties/methods
197
+ return new Proxy(stream, {
198
+ get(target, prop) {
199
+ if (prop === Symbol.asyncIterator) {
200
+ return () => wrappedIterator;
201
+ }
202
+ // Preserve other stream methods like tee(), toReadableStream(), etc.
203
+ const value = target[prop];
204
+ if (typeof value === 'function') {
205
+ return value.bind(target);
206
+ }
207
+ return value;
208
+ }
209
+ });
210
+ }
211
+
212
+ // Safely patch the class if it exists
213
+ let _original_create;
214
+ try {
215
+ if (typeof ${className} === 'undefined' || !${className}.prototype || typeof ${className}.prototype.create !== 'function') {
216
+ console.debug('[Agentuity OTel] Skipping patch: ${className}.prototype.create not found or not a function');
217
+ } else {
218
+ _original_create = ${className}.prototype.create;
219
+ ${className}.prototype.create = _agentuity_otel_create;
220
+ }
221
+ } catch (e) {
222
+ console.debug('[Agentuity OTel] Failed to patch ${className}:', e?.message || e);
223
+ }
224
+
225
+ function _agentuity_otel_create(body, options) {
226
+ // If patching failed, _original_create won't be set - this shouldn't happen but handle gracefully
227
+ if (!_original_create) {
228
+ throw new Error('[Agentuity OTel] ${className}.prototype.create was not properly patched');
229
+ }
230
+ const attributes = {
231
+ [_ATTR_GEN_AI_SYSTEM]: '${provider}',
232
+ [_ATTR_GEN_AI_OPERATION_NAME]: 'chat',
233
+ };
234
+
235
+ if (body.model) {
236
+ attributes[_ATTR_GEN_AI_REQUEST_MODEL] = body.model;
237
+ }
238
+ if (body.max_tokens) {
239
+ attributes[_ATTR_GEN_AI_REQUEST_MAX_TOKENS] = body.max_tokens;
240
+ }
241
+ if (body.temperature !== undefined) {
242
+ attributes[_ATTR_GEN_AI_REQUEST_TEMPERATURE] = body.temperature;
243
+ }
244
+ if (body.top_p !== undefined) {
245
+ attributes[_ATTR_GEN_AI_REQUEST_TOP_P] = body.top_p;
246
+ }
247
+ if (body.frequency_penalty !== undefined) {
248
+ attributes[_ATTR_GEN_AI_REQUEST_FREQUENCY_PENALTY] = body.frequency_penalty;
249
+ }
250
+ if (body.presence_penalty !== undefined) {
251
+ attributes[_ATTR_GEN_AI_REQUEST_PRESENCE_PENALTY] = body.presence_penalty;
252
+ }
253
+
254
+ // Capture request messages
255
+ if (body.${requestMessagesField} && Array.isArray(body.${requestMessagesField})) {
256
+ try {
257
+ attributes[_ATTR_GEN_AI_REQUEST_MESSAGES] = JSON.stringify(body.${requestMessagesField});
258
+ } catch (e) {
259
+ // Ignore serialization errors
260
+ }
261
+ }
262
+
263
+ const spanName = body.model ? \`chat \${body.model}\` : 'chat';
264
+
265
+ return _otel_tracer.startActiveSpan(spanName, { attributes, kind: _otel_api.SpanKind.CLIENT }, (span) => {
266
+ let result;
267
+ try {
268
+ result = _original_create.call(this, body, options);
269
+ } catch (error) {
270
+ span.setStatus({ code: _otel_api.SpanStatusCode.ERROR, message: error?.message });
271
+ span.recordException(error);
272
+ span.end();
273
+ throw error;
274
+ }
275
+
276
+ // Handle streaming responses
277
+ if (body.stream) {
278
+ // Result is a Promise that resolves to a Stream
279
+ if (result && typeof result.then === 'function') {
280
+ return result.then((stream) => {
281
+ try {
282
+ return _wrapStream(stream, span, '${inputTokensField}', '${outputTokensField}');
283
+ } catch (error) {
284
+ span.setStatus({ code: _otel_api.SpanStatusCode.ERROR, message: error?.message });
285
+ span.recordException(error);
286
+ span.end();
287
+ throw error;
288
+ }
289
+ }).catch((error) => {
290
+ span.setStatus({ code: _otel_api.SpanStatusCode.ERROR, message: error?.message });
291
+ span.recordException(error);
292
+ span.end();
293
+ throw error;
294
+ });
295
+ }
296
+ // Result is already a Stream - wrap in try/catch for synchronous failures
297
+ try {
298
+ return _wrapStream(result, span, '${inputTokensField}', '${outputTokensField}');
299
+ } catch (error) {
300
+ span.setStatus({ code: _otel_api.SpanStatusCode.ERROR, message: error?.message });
301
+ span.recordException(error);
302
+ span.end();
303
+ throw error;
304
+ }
305
+ }
306
+
307
+ // Handle non-streaming responses
308
+ if (result && typeof result.then === 'function') {
309
+ return result.then((response) => {
310
+ if (response) {
311
+ if (response.model) {
312
+ span.setAttribute(_ATTR_GEN_AI_RESPONSE_MODEL, response.model);
313
+ }
314
+ if (response.${responseIdField}) {
315
+ span.setAttribute(_ATTR_GEN_AI_RESPONSE_ID, response.${responseIdField});
316
+ }
317
+ if (response.usage) {
318
+ if (response.usage.${inputTokensField} !== undefined) {
319
+ span.setAttribute(_ATTR_GEN_AI_USAGE_INPUT_TOKENS, response.usage.${inputTokensField});
320
+ }
321
+ if (response.usage.${outputTokensField} !== undefined) {
322
+ span.setAttribute(_ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, response.usage.${outputTokensField});
323
+ }
324
+ }
325
+ // Extract finish reason
326
+ const finishReason = ${finishReasonCode};
327
+ if (finishReason) {
328
+ span.setAttribute(_ATTR_GEN_AI_RESPONSE_FINISH_REASONS, JSON.stringify([finishReason]));
329
+ }
330
+ // Extract response content
331
+ const responseContent = ${responseContentCode};
332
+ if (responseContent) {
333
+ span.setAttribute(_ATTR_GEN_AI_RESPONSE_TEXT, responseContent);
334
+ }
335
+ }
336
+ span.setStatus({ code: _otel_api.SpanStatusCode.OK });
337
+ span.end();
338
+ return response;
339
+ }).catch((error) => {
340
+ span.setStatus({ code: _otel_api.SpanStatusCode.ERROR, message: error?.message });
341
+ span.recordException(error);
342
+ span.end();
343
+ throw error;
344
+ });
345
+ }
346
+
347
+ span.end();
348
+ return result;
349
+ });
350
+ }
351
+ `;
352
+ }
353
+
354
+ export function generatePatches(): Map<string, PatchModule> {
355
+ const patches = new Map<string, PatchModule>();
356
+
357
+ // OpenAI Chat Completions - patch resources/chat/completions/completions.mjs
358
+ patches.set('openai:otel', {
359
+ module: 'openai',
360
+ filename: 'resources/chat/completions/completions',
361
+ body: {
362
+ after: generateChatCompletionsWrapper({
363
+ provider: 'openai',
364
+ className: 'Completions',
365
+ inputTokensField: 'prompt_tokens',
366
+ outputTokensField: 'completion_tokens',
367
+ responseIdField: 'id',
368
+ finishReasonPath: 'choices[0].finish_reason',
369
+ responseContentPath: 'choices[0].message.content',
370
+ requestMessagesField: 'messages',
371
+ streamDeltaContentPath: 'choices[0].delta.content',
372
+ streamFinishReasonPath: 'choices[0].finish_reason',
373
+ streamUsagePath: 'usage',
374
+ }),
375
+ },
376
+ });
377
+
378
+ // Anthropic Messages - patch resources/messages.mjs
379
+ patches.set('@anthropic-ai/sdk:otel', {
380
+ module: '@anthropic-ai/sdk',
381
+ filename: 'resources/messages',
382
+ body: {
383
+ after: generateChatCompletionsWrapper({
384
+ provider: 'anthropic',
385
+ className: 'Messages',
386
+ inputTokensField: 'input_tokens',
387
+ outputTokensField: 'output_tokens',
388
+ responseIdField: 'id',
389
+ finishReasonPath: 'stop_reason',
390
+ responseContentPath: 'content[0].text',
391
+ requestMessagesField: 'messages',
392
+ streamDeltaContentPath: 'delta.text',
393
+ streamFinishReasonPath: 'delta.stop_reason',
394
+ streamUsagePath: 'usage',
395
+ }),
396
+ },
397
+ });
398
+
399
+ // Groq Chat Completions - patch resources/chat/completions.mjs
400
+ patches.set('groq-sdk:otel', {
401
+ module: 'groq-sdk',
402
+ filename: 'resources/chat/completions',
403
+ body: {
404
+ after: generateChatCompletionsWrapper({
405
+ provider: 'groq',
406
+ className: 'Completions',
407
+ inputTokensField: 'prompt_tokens',
408
+ outputTokensField: 'completion_tokens',
409
+ responseIdField: 'id',
410
+ finishReasonPath: 'choices[0].finish_reason',
411
+ responseContentPath: 'choices[0].message.content',
412
+ requestMessagesField: 'messages',
413
+ streamDeltaContentPath: 'choices[0].delta.content',
414
+ streamFinishReasonPath: 'choices[0].finish_reason',
415
+ streamUsagePath: 'x_groq.usage',
416
+ }),
417
+ },
418
+ });
419
+
420
+ return patches;
421
+ }
@@ -16,10 +16,10 @@ export const createSubcommand = defineSubcommand({
16
16
  idempotent: false,
17
17
  requires: { auth: true, org: true, region: true },
18
18
  examples: [
19
- { command: getCommand('cloud db create'), description: 'Create new item' },
19
+ { command: getCommand('cloud db create'), description: 'Create new database' },
20
20
  { command: getCommand('cloud db new'), description: 'Run new command' },
21
- { command: getCommand('cloud db create --name my-db'), description: 'Create new item' },
22
- { command: getCommand('--dry-run cloud db create'), description: 'Create new item' },
21
+ { command: getCommand('cloud db create --name my-db'), description: 'Create new database' },
22
+ { command: getCommand('--dry-run cloud db create'), description: 'Create new database' },
23
23
  ],
24
24
  schema: {
25
25
  options: z.object({
@@ -33,12 +33,14 @@ import {
33
33
  projectDeploymentUpdate,
34
34
  projectDeploymentComplete,
35
35
  projectDeploymentStatus,
36
+ projectDeploymentMalwareCheck,
36
37
  validateResources,
37
38
  type Deployment,
38
39
  type BuildMetadata,
39
40
  type DeploymentInstructions,
40
41
  type DeploymentComplete,
41
42
  type DeploymentStatusResult,
43
+ type MalwareCheckResult,
42
44
  getAppBaseURL,
43
45
  } from '@agentuity/server';
44
46
  import {
@@ -51,11 +53,12 @@ import { zipDir } from '../../utils/zip';
51
53
  import { encryptFIPSKEMDEMStream } from '../../crypto/box';
52
54
  import { getCommand } from '../../command-prefix';
53
55
  import * as domain from '../../domain';
54
- import { ErrorCode } from '../../errors';
56
+ import { ErrorCode, getExitCode } from '../../errors';
55
57
  import { typecheck } from '../build/typecheck';
56
58
  import { BuildReportCollector, setGlobalCollector, clearGlobalCollector } from '../../build-report';
57
59
  import { runForkedDeploy } from './deploy-fork';
58
60
  import { validateAptDependencies } from '../../utils/apt-validator';
61
+ import { extractDependencies } from '../../utils/deps';
59
62
 
60
63
  const DeploymentCancelledError = StructuredError(
61
64
  'DeploymentCancelled',
@@ -167,6 +170,7 @@ export const deploySubcommand = createSubcommand({
167
170
  let instructions: DeploymentInstructions | undefined;
168
171
  let complete: DeploymentComplete | undefined;
169
172
  let statusResult: DeploymentStatusResult | undefined;
173
+ let malwareCheckPromise: Promise<MalwareCheckResult | null> | undefined;
170
174
  const logs: string[] = [];
171
175
 
172
176
  const sdkKey = await loadProjectSDKKey(ctx.logger, ctx.projectDir);
@@ -326,6 +330,35 @@ export const deploySubcommand = createSubcommand({
326
330
  }
327
331
  }
328
332
 
333
+ // Start malware check async (runs in parallel with build)
334
+ if (deployment) {
335
+ malwareCheckPromise = (async () => {
336
+ try {
337
+ logger.debug('Starting malware dependency check');
338
+ const packages = await extractDependencies(projectDir, logger);
339
+ if (packages.length === 0) {
340
+ logger.debug('No packages to check for malware');
341
+ return null;
342
+ }
343
+ logger.debug('Checking %d packages for malware', packages.length);
344
+ const result = await projectDeploymentMalwareCheck(
345
+ apiClient,
346
+ deployment!.id,
347
+ packages
348
+ );
349
+ logger.debug(
350
+ 'Malware check complete: action=%s, flagged=%d',
351
+ result.action,
352
+ result.summary.flagged
353
+ );
354
+ return result;
355
+ } catch (error) {
356
+ logger.warn('Malware check failed: %s', error);
357
+ return null;
358
+ }
359
+ })();
360
+ }
361
+
329
362
  try {
330
363
  await saveProjectDir(projectDir);
331
364
 
@@ -528,6 +561,49 @@ export const deploySubcommand = createSubcommand({
528
561
  }
529
562
  },
530
563
  },
564
+ {
565
+ label: 'Security Scan',
566
+ run: async () => {
567
+ if (!malwareCheckPromise) {
568
+ return stepSkipped('malware check not started');
569
+ }
570
+
571
+ const result = await malwareCheckPromise;
572
+ if (!result) {
573
+ return stepSkipped('malware check unavailable');
574
+ }
575
+
576
+ if (result.action === 'block' && result.findings.length > 0) {
577
+ if (opts.reportFile) {
578
+ for (const finding of result.findings) {
579
+ collector.addGeneralError(
580
+ 'deploy',
581
+ `Malicious package: ${finding.name}@${finding.version} (${finding.reason})`
582
+ );
583
+ }
584
+ await collector.forceWrite();
585
+ }
586
+
587
+ const packageList = result.findings
588
+ .map((f) => `• ${f.name}@${f.version} (${f.reason})`)
589
+ .join('\n');
590
+
591
+ // Pause step UI to cleanly render error box
592
+ pauseStepUI(true);
593
+
594
+ tui.newline();
595
+ tui.errorBox(
596
+ 'Malicious Packages Detected',
597
+ `Your deployment was blocked because it contains known malicious packages:\n\n${packageList}\n\nRemove these packages from your project and try again.`
598
+ );
599
+ tui.newline();
600
+
601
+ process.exit(getExitCode(ErrorCode.MALWARE_DETECTED));
602
+ }
603
+
604
+ return stepSuccess([`Scanned ${result.summary.scanned} packages`]);
605
+ },
606
+ },
531
607
  {
532
608
  label: 'Encrypt and Upload Deployment',
533
609
  run: async (stepCtx: StepContext) => {
@@ -56,6 +56,7 @@ export const pullSubcommand = createSubcommand({
56
56
 
57
57
  let cloudEnv: Record<string, string>;
58
58
  let scope: 'project' | 'org';
59
+ let cloudApiKey: string | undefined;
59
60
 
60
61
  if (useOrgScope) {
61
62
  // Organization scope
@@ -70,6 +71,7 @@ export const pullSubcommand = createSubcommand({
70
71
 
71
72
  cloudEnv = { ...orgData.env, ...orgData.secrets };
72
73
  scope = 'org';
74
+ cloudApiKey = undefined; // Orgs don't have api_key
73
75
  } else {
74
76
  // Project scope
75
77
  if (!project) {
@@ -84,31 +86,16 @@ export const pullSubcommand = createSubcommand({
84
86
 
85
87
  cloudEnv = { ...projectData.env, ...projectData.secrets };
86
88
  scope = 'project';
87
-
88
- // Write AGENTUITY_SDK_KEY to .env if present and missing locally (project scope only)
89
- if (projectData.api_key) {
90
- const dotEnvPath = join(projectDir, '.env');
91
- const dotEnv = await readEnvFile(dotEnvPath);
92
-
93
- if (!dotEnv.AGENTUITY_SDK_KEY) {
94
- dotEnv.AGENTUITY_SDK_KEY = projectData.api_key;
95
- await writeEnvFile(dotEnvPath, dotEnv, {
96
- addComment: (key) => {
97
- if (key === 'AGENTUITY_SDK_KEY') {
98
- return 'AGENTUITY_SDK_KEY is a sensitive value and should not be committed to version control.';
99
- }
100
- return null;
101
- },
102
- });
103
- tui.info(`Wrote AGENTUITY_SDK_KEY to ${dotEnvPath}`);
104
- }
105
- }
89
+ cloudApiKey = projectData.api_key;
106
90
  }
107
91
 
108
92
  // Target file is always .env
109
93
  const targetEnvPath = await findExistingEnvFile(projectDir);
110
94
  const localEnv = await readEnvFile(targetEnvPath);
111
95
 
96
+ // Preserve local AGENTUITY_SDK_KEY before writing (since it will be skipped in the first write)
97
+ const localSdkKey = localEnv.AGENTUITY_SDK_KEY;
98
+
112
99
  // Merge: cloud values override local if force=true, otherwise keep local
113
100
  let mergedEnv: Record<string, string>;
114
101
  if (opts?.force) {
@@ -124,6 +111,29 @@ export const pullSubcommand = createSubcommand({
124
111
  skipKeys: Object.keys(mergedEnv).filter(isReservedAgentuityKey),
125
112
  });
126
113
 
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
+ }
135
+ }
136
+
127
137
  const count = Object.keys(cloudEnv).length;
128
138
  const scopeLabel = useOrgScope ? 'organization' : 'project';
129
139
  tui.success(
@@ -127,7 +127,11 @@ export const listSubcommand = createSubcommand({
127
127
  Agent: r.agentIdentifier || '-',
128
128
  Success: r.success ? '✓' : '✗',
129
129
  Pending: r.pending ? '⏳' : '✓',
130
- Reason: reason ? (reason.length > 30 ? reason.substring(0, 27) + '...' : reason) : '-',
130
+ Reason: reason
131
+ ? reason.length > 30
132
+ ? reason.substring(0, 27) + '...'
133
+ : reason
134
+ : '-',
131
135
  Created: new Date(r.createdAt).toLocaleString(),
132
136
  };
133
137
  });
@@ -67,16 +67,17 @@ const listDlqSubcommand = createSubcommand({
67
67
  tui.info('No messages in dead letter queue');
68
68
  } else {
69
69
  const tableData = result.messages.map((m: DeadLetterMessage) => {
70
- const timestamp = m.moved_at ?? m.original_published_at ?? m.published_at ?? m.created_at;
71
- return {
72
- ID: m.id.substring(0, 8) + '...',
73
- Offset: m.offset,
74
- Reason: m.failure_reason?.substring(0, 30) || 'Unknown',
75
- Attempts: m.delivery_attempts,
76
- 'Failed At': timestamp ? new Date(timestamp).toLocaleString() : 'N/A',
77
- };
78
- });
79
- tui.table(tableData, ['ID', 'Offset', 'Reason', 'Attempts', 'Failed At']);
70
+ const timestamp =
71
+ m.moved_at ?? m.original_published_at ?? m.published_at ?? m.created_at;
72
+ return {
73
+ ID: m.id.substring(0, 8) + '...',
74
+ Offset: m.offset,
75
+ Reason: m.failure_reason?.substring(0, 30) || 'Unknown',
76
+ Attempts: m.delivery_attempts,
77
+ 'Failed At': timestamp ? new Date(timestamp).toLocaleString() : 'N/A',
78
+ };
79
+ });
80
+ tui.table(tableData, ['ID', 'Offset', 'Reason', 'Attempts', 'Failed At']);
80
81
  }
81
82
  }
82
83