@output.ai/cli 0.5.3 → 0.5.5

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.
package/README.md CHANGED
@@ -11,14 +11,31 @@ Command-line interface for creating and running Output Framework workflows.
11
11
 
12
12
  ```bash
13
13
  # Create a new project
14
- output init my-project
15
- cd my-project
14
+ npx @output.ai/cli init
15
+ cd <project-name>
16
16
 
17
17
  # Start development services
18
- output dev
18
+ npx output dev
19
19
 
20
20
  # Run a workflow
21
- output workflow run simple --input '{"question": "who is ada lovelace?"}'
21
+ npx output workflow run simple --input '{"question": "who is ada lovelace?"}'
22
+ ```
23
+
24
+ ## Environment Configuration
25
+
26
+ By default, the CLI loads environment variables from `.env` in the current directory.
27
+
28
+ To use a different env file, set the `OUTPUT_CLI_ENV` environment variable:
29
+
30
+ ```bash
31
+ # Use production environment
32
+ OUTPUT_CLI_ENV=.env.prod npx output workflow list
33
+
34
+ # Use staging environment
35
+ OUTPUT_CLI_ENV=.env.staging npx output workflow run my-workflow
36
+
37
+ # Absolute path
38
+ OUTPUT_CLI_ENV=/etc/output/production.env npx output workflow status wf-123
22
39
  ```
23
40
 
24
41
  ## Command Reference
@@ -47,7 +64,7 @@ Running `output dev` starts:
47
64
  |---------|-----|-------------|
48
65
  | Temporal UI | http://localhost:8080 | Monitor and debug workflows |
49
66
  | API Server | http://localhost:3001 | REST API for workflow execution |
50
- | Worker | | Processes workflows with auto-reload |
67
+ | Worker | - | Processes workflows with auto-reload |
51
68
 
52
69
  ## Documentation
53
70
 
@@ -54,6 +54,60 @@ export interface TraceInfo {
54
54
  /** File destinations for trace data */
55
55
  destinations?: TraceInfoDestinations;
56
56
  }
57
+ /**
58
+ * The workflow input
59
+ */
60
+ export type TraceDataInput = {
61
+ [key: string]: unknown;
62
+ };
63
+ /**
64
+ * The workflow output
65
+ */
66
+ export type TraceDataOutput = {
67
+ [key: string]: unknown;
68
+ };
69
+ export type TraceDataStepsItem = {
70
+ [key: string]: unknown;
71
+ };
72
+ /**
73
+ * Trace data containing workflow execution details
74
+ */
75
+ export interface TraceData {
76
+ /** The workflow execution ID */
77
+ workflowId?: string;
78
+ /** The workflow input */
79
+ input?: TraceDataInput;
80
+ /** The workflow output */
81
+ output?: TraceDataOutput;
82
+ /** The workflow execution steps */
83
+ steps?: TraceDataStepsItem[];
84
+ [key: string]: unknown;
85
+ }
86
+ /**
87
+ * Indicates trace was fetched from remote storage
88
+ */
89
+ export type TraceLogRemoteResponseSource = typeof TraceLogRemoteResponseSource[keyof typeof TraceLogRemoteResponseSource];
90
+ export declare const TraceLogRemoteResponseSource: {
91
+ readonly remote: "remote";
92
+ };
93
+ export interface TraceLogRemoteResponse {
94
+ /** Indicates trace was fetched from remote storage */
95
+ source: TraceLogRemoteResponseSource;
96
+ data: TraceData;
97
+ }
98
+ /**
99
+ * Indicates trace is available locally
100
+ */
101
+ export type TraceLogLocalResponseSource = typeof TraceLogLocalResponseSource[keyof typeof TraceLogLocalResponseSource];
102
+ export declare const TraceLogLocalResponseSource: {
103
+ readonly local: "local";
104
+ };
105
+ export interface TraceLogLocalResponse {
106
+ /** Indicates trace is available locally */
107
+ source: TraceLogLocalResponseSource;
108
+ /** Absolute path to local trace file */
109
+ localPath: string;
110
+ }
57
111
  /**
58
112
  * Current run status
59
113
  */
@@ -149,6 +203,13 @@ export type GetWorkflowIdResult200 = {
149
203
  output?: unknown;
150
204
  trace?: TraceInfo;
151
205
  };
206
+ export type GetWorkflowIdTraceLog200 = TraceLogRemoteResponse | TraceLogLocalResponse;
207
+ export type GetWorkflowIdTraceLog404 = {
208
+ error?: string;
209
+ };
210
+ export type GetWorkflowIdTraceLog500 = {
211
+ error?: string;
212
+ };
152
213
  export type GetWorkflowCatalogId200 = {
153
214
  /** Each workflow available in this catalog */
154
215
  workflows?: Workflow[];
@@ -273,6 +334,31 @@ export type getWorkflowIdResultResponseError = (getWorkflowIdResultResponse404)
273
334
  export type getWorkflowIdResultResponse = (getWorkflowIdResultResponseSuccess | getWorkflowIdResultResponseError);
274
335
  export declare const getGetWorkflowIdResultUrl: (id: string) => string;
275
336
  export declare const getWorkflowIdResult: (id: string, options?: ApiRequestOptions) => Promise<getWorkflowIdResultResponse>;
337
+ /**
338
+ * Returns trace data for a completed workflow. If trace is stored remotely (S3), fetches and returns the data inline. If trace is local only, returns the local path.
339
+ * @summary Get workflow trace log data
340
+ */
341
+ export type getWorkflowIdTraceLogResponse200 = {
342
+ data: GetWorkflowIdTraceLog200;
343
+ status: 200;
344
+ };
345
+ export type getWorkflowIdTraceLogResponse404 = {
346
+ data: GetWorkflowIdTraceLog404;
347
+ status: 404;
348
+ };
349
+ export type getWorkflowIdTraceLogResponse500 = {
350
+ data: GetWorkflowIdTraceLog500;
351
+ status: 500;
352
+ };
353
+ export type getWorkflowIdTraceLogResponseSuccess = (getWorkflowIdTraceLogResponse200) & {
354
+ headers: Headers;
355
+ };
356
+ export type getWorkflowIdTraceLogResponseError = (getWorkflowIdTraceLogResponse404 | getWorkflowIdTraceLogResponse500) & {
357
+ headers: Headers;
358
+ };
359
+ export type getWorkflowIdTraceLogResponse = (getWorkflowIdTraceLogResponseSuccess | getWorkflowIdTraceLogResponseError);
360
+ export declare const getGetWorkflowIdTraceLogUrl: (id: string) => string;
361
+ export declare const getWorkflowIdTraceLog: (id: string, options?: ApiRequestOptions) => Promise<getWorkflowIdTraceLogResponse>;
276
362
  /**
277
363
  * @summary Get a specific workflow catalog by ID
278
364
  */
@@ -7,6 +7,14 @@
7
7
  */
8
8
  import { customFetchInstance } from '../http_client.js';
9
9
  // eslint-disable-next-line @typescript-eslint/no-redeclare
10
+ export const TraceLogRemoteResponseSource = {
11
+ remote: 'remote',
12
+ };
13
+ // eslint-disable-next-line @typescript-eslint/no-redeclare
14
+ export const TraceLogLocalResponseSource = {
15
+ local: 'local',
16
+ };
17
+ // eslint-disable-next-line @typescript-eslint/no-redeclare
10
18
  export const WorkflowRunInfoStatus = {
11
19
  running: 'running',
12
20
  completed: 'completed',
@@ -88,6 +96,15 @@ export const getWorkflowIdResult = async (id, options) => {
88
96
  method: 'GET'
89
97
  });
90
98
  };
99
+ export const getGetWorkflowIdTraceLogUrl = (id) => {
100
+ return `/workflow/${id}/trace-log`;
101
+ };
102
+ export const getWorkflowIdTraceLog = async (id, options) => {
103
+ return customFetchInstance(getGetWorkflowIdTraceLogUrl(id), {
104
+ ...options,
105
+ method: 'GET'
106
+ });
107
+ };
91
108
  ;
92
109
  export const getGetWorkflowCatalogIdUrl = (id) => {
93
110
  return `/workflow/catalog/${id}`;
@@ -9,8 +9,9 @@ const api = ky.create({
9
9
  retry: {
10
10
  limit: 2,
11
11
  methods: ['get', 'put', 'head', 'delete', 'options', 'trace'],
12
- statusCodes: [408, 413, 429, 500, 502, 503, 504]
12
+ statusCodes: [408, 413, 429, 502, 503, 504]
13
13
  },
14
+ throwHttpErrors: false,
14
15
  hooks: {
15
16
  beforeRequest: [
16
17
  request => {
@@ -82,11 +82,15 @@ services:
82
82
  image: growthxteam/output-api:latest
83
83
  networks:
84
84
  - main
85
+ env_file: ./.env
85
86
  environment:
86
87
  - PORT=3001
87
- - CATALOG_ID=main
88
+ - CATALOG_ID=${CATALOG_ID:-main}
88
89
  - TEMPORAL_ADDRESS=temporal:7233
89
90
  - NODE_ENV=development
91
+ - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
92
+ - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
93
+ - AWS_REGION=${AWS_REGION:-us-west-1}
90
94
  ports:
91
95
  - '3001:3001'
92
96
 
@@ -97,14 +101,19 @@ services:
97
101
  image: node:24.3-slim
98
102
  networks:
99
103
  - main
100
- env_file: './.env'
104
+ env_file: ./.env
101
105
  environment:
102
- - CATALOG_ID=main
103
- - LOG_HTTP_VERBOSE=true
106
+ - CATALOG_ID=${CATALOG_ID:-main}
107
+ - LOG_HTTP_VERBOSE=${LOG_HTTP_VERBOSE:-true}
104
108
  - REDIS_URL=redis://redis:6379
105
109
  - TEMPORAL_ADDRESS=temporal:7233
106
- - TRACE_LOCAL_ON=true
110
+ - TRACE_LOCAL_ON=${TRACE_LOCAL_ON:-true}
111
+ - TRACE_REMOTE_ON=${TRACE_REMOTE_ON:-}
112
+ - TRACE_REMOTE_S3_BUCKET=${TRACE_REMOTE_S3_BUCKET:-}
107
113
  - HOST_TRACE_PATH=${PWD}/logs
114
+ - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
115
+ - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
116
+ - AWS_REGION=${AWS_REGION:-us-west-1}
108
117
  command: >
109
118
  sh -c "
110
119
  npm run output:worker:install &&
@@ -1,6 +1,7 @@
1
1
  import { Command, Flags } from '@oclif/core';
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import logUpdate from 'log-update';
4
5
  import { validateDockerEnvironment, startDockerCompose, stopDockerCompose, getServiceStatus, DockerComposeConfigNotFoundError, getDefaultDockerComposePath, SERVICE_HEALTH, SERVICE_STATE } from '#services/docker.js';
5
6
  import { getErrorMessage } from '#utils/error_utils.js';
6
7
  import { getDevSuccessMessage } from '#services/messages.js';
@@ -109,7 +110,6 @@ export default class Dev extends Command {
109
110
  }
110
111
  }
111
112
  async pollServiceStatus(dockerComposePath) {
112
- const state = { lastLineCount: 0 };
113
113
  const outputServiceStatus = async () => {
114
114
  try {
115
115
  const services = await getServiceStatus(dockerComposePath);
@@ -122,11 +122,7 @@ export default class Dev extends Command {
122
122
  '',
123
123
  `${ANSI.DIM}Press Ctrl+C to stop services${ANSI.RESET}`
124
124
  ];
125
- if (state.lastLineCount > 0) {
126
- process.stdout.write(`\x1b[${state.lastLineCount}A\x1b[J`);
127
- }
128
- process.stdout.write(lines.join('\n') + '\n');
129
- state.lastLineCount = lines.length;
125
+ logUpdate(lines.join('\n'));
130
126
  }
131
127
  catch {
132
128
  // silent retry on next poll
@@ -9,6 +9,7 @@ export default class WorkflowDebug extends Command {
9
9
  format: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
10
10
  };
11
11
  run(): Promise<void>;
12
+ private conditionalLog;
12
13
  private outputJson;
13
14
  private displayTextTrace;
14
15
  catch(error: Error): Promise<void>;
@@ -27,18 +27,21 @@ export default class WorkflowDebug extends Command {
27
27
  async run() {
28
28
  const { args, flags } = await this.parse(WorkflowDebug);
29
29
  const isJsonFormat = flags.format === OUTPUT_FORMAT.JSON;
30
- if (!isJsonFormat) {
31
- this.log(`Fetching debug information for workflow: ${args.workflowId}...`);
32
- }
33
- const traceData = await getTrace(args.workflowId);
34
- // Output based on format
30
+ this.conditionalLog(`Fetching debug information for workflow: ${args.workflowId}...`, isJsonFormat);
31
+ const { data: traceData, location } = await getTrace(args.workflowId);
32
+ const source = location.isRemote ? 'remote' : 'local';
33
+ this.conditionalLog(`Trace source: ${source}${!location.isRemote ? ` (${location.path})` : ''}`, isJsonFormat);
35
34
  if (isJsonFormat) {
36
35
  this.outputJson(traceData);
37
36
  return;
38
37
  }
39
- // Display text format
40
38
  this.displayTextTrace(traceData);
41
39
  }
40
+ conditionalLog(message, disabled) {
41
+ if (!disabled) {
42
+ this.log(message);
43
+ }
44
+ }
42
45
  outputJson(data) {
43
46
  this.log(JSON.stringify(data, null, 2));
44
47
  }
@@ -1,8 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  // Mock the TraceReader service
3
3
  vi.mock('../../services/trace_reader.js', () => ({
4
- findTraceFile: vi.fn(),
5
- readTraceFile: vi.fn(),
6
4
  getTrace: vi.fn()
7
5
  }));
8
6
  // Mock the utilities
@@ -2,6 +2,7 @@ import { execFileSync, execSync, spawn } from 'node:child_process';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { ux } from '@oclif/core';
5
+ import logUpdate from 'log-update';
5
6
  const DEFAULT_COMPOSE_PATH = '../assets/docker/docker-compose-dev.yml';
6
7
  export const SERVICE_HEALTH = {
7
8
  HEALTHY: 'healthy',
@@ -108,16 +109,16 @@ export async function waitForServicesHealthy(dockerComposePath, timeoutMs = 1200
108
109
  const services = await getServiceStatus(dockerComposePath);
109
110
  const allHealthy = services.every(s => s.health === SERVICE_HEALTH.HEALTHY || s.health === SERVICE_HEALTH.NONE);
110
111
  if (services.length > 0) {
111
- const lineCount = services.length + 1;
112
- process.stdout.write(`\x1b[${lineCount}A\x1b[J`);
113
- ux.stdout('⏳ Waiting for services to become healthy...\n');
114
- ux.stdout(formatServiceStatus(services) + '\n');
112
+ const statusLines = formatServiceStatus(services);
113
+ logUpdate(`⏳ Waiting for services to become healthy...\n${statusLines}`);
115
114
  }
116
115
  if (allHealthy && services.length > 0) {
116
+ logUpdate.done();
117
117
  return;
118
118
  }
119
119
  await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
120
120
  }
121
+ logUpdate.done();
121
122
  throw new Error('Timeout waiting for services to become healthy');
122
123
  }
123
124
  export async function startDockerCompose(dockerComposePath, enableWatch = false) {
@@ -6,6 +6,11 @@ vi.mock('node:child_process', () => ({
6
6
  execFileSync: vi.fn(),
7
7
  spawn: vi.fn()
8
8
  }));
9
+ vi.mock('log-update', () => {
10
+ const fn = vi.fn();
11
+ fn.done = vi.fn();
12
+ return { default: fn };
13
+ });
9
14
  describe('docker service', () => {
10
15
  beforeEach(() => {
11
16
  vi.clearAllMocks();
@@ -173,11 +173,11 @@ export const getProjectSuccessMessage = (folderName, installSuccess, envConfigur
173
173
  }
174
174
  steps.push({
175
175
  step: 'Start development services',
176
- command: 'output dev',
176
+ command: 'npx output dev',
177
177
  note: 'Launches Temporal, Redis, PostgreSQL, API, Worker, and UI'
178
178
  }, {
179
179
  step: 'Run example workflow',
180
- command: 'output workflow run simple --input src/simple/scenarios/question_ada_lovelace.json',
180
+ command: 'npx output workflow run simple --input src/simple/scenarios/question_ada_lovelace.json',
181
181
  note: 'Execute in a new terminal after services are running'
182
182
  }, {
183
183
  step: 'Monitor workflows',
@@ -219,14 +219,14 @@ ${divider}
219
219
 
220
220
  ${createSectionHeader('QUICK START COMMANDS', '⚡')}
221
221
 
222
- ${bulletPoint} ${ux.colorize('white', 'Plan a workflow:')} ${formatCommand('output workflow plan')}
223
- ${bulletPoint} ${ux.colorize('white', 'Generate from plan:')} ${formatCommand('output workflow generate')}
224
- ${bulletPoint} ${ux.colorize('white', 'List workflows:')} ${formatCommand('output workflow list')}
225
- ${bulletPoint} ${ux.colorize('white', 'View help:')} ${formatCommand('output --help')}
222
+ ${bulletPoint} ${ux.colorize('white', 'Plan a workflow:')} ${formatCommand('npx output workflow plan')}
223
+ ${bulletPoint} ${ux.colorize('white', 'Generate from plan:')} ${formatCommand('npx output workflow generate')}
224
+ ${bulletPoint} ${ux.colorize('white', 'List workflows:')} ${formatCommand('npx output workflow list')}
225
+ ${bulletPoint} ${ux.colorize('white', 'View help:')} ${formatCommand('npx output --help')}
226
226
 
227
227
  ${divider}
228
228
 
229
- ${ux.colorize('dim', '💡 Tip: Use ')}${formatCommand('output workflow plan')}${ux.colorize('dim', ' to design your first custom workflow')}
229
+ ${ux.colorize('dim', '💡 Tip: Use ')}${formatCommand('npx output workflow plan')}${ux.colorize('dim', ' to design your first custom workflow')}
230
230
  ${ux.colorize('dim', ' with AI assistance.')}
231
231
 
232
232
  ${ux.colorize('green', ux.colorize('bold', 'Happy building with Output SDK! 🚀'))}
@@ -321,7 +321,7 @@ ${createSectionHeader('RUN A WORKFLOW', '🚀')}
321
321
 
322
322
  ${ux.colorize('white', 'In a new terminal, execute:')}
323
323
 
324
- ${formatCommand('output workflow run simple --input \'{"question": "Hello!"}\'')}
324
+ ${formatCommand('npx output workflow run simple --input \'{"question": "Hello!"}\'')}
325
325
 
326
326
  ${divider}
327
327
 
@@ -1,14 +1,16 @@
1
1
  import type { TraceData } from '#types/trace.js';
2
2
  export type { TraceData };
3
+ export interface TraceLocation {
4
+ path: string;
5
+ isRemote: boolean;
6
+ }
7
+ export interface TraceResult {
8
+ data: TraceData;
9
+ location: TraceLocation;
10
+ }
3
11
  /**
4
- * Find trace file from workflow metadata
12
+ * Get trace data from workflow ID using the API
13
+ * The API handles S3 fetching - CLI only needs to read local files when necessary
14
+ * @returns Both the trace data and the location it was fetched from
5
15
  */
6
- export declare function findTraceFile(workflowId: string): Promise<string>;
7
- /**
8
- * Read and parse trace file
9
- */
10
- export declare function readTraceFile(path: string): Promise<TraceData>;
11
- /**
12
- * Get trace data from workflow ID
13
- */
14
- export declare function getTrace(workflowId: string): Promise<TraceData>;
16
+ export declare function getTrace(workflowId: string): Promise<TraceResult>;
@@ -1,49 +1,10 @@
1
- import { readFile, stat } from 'node:fs/promises';
2
- import { getWorkflowIdResult } from '#api/generated/api.js';
1
+ import { readFile } from 'node:fs/promises';
2
+ import { getWorkflowIdTraceLog } from '#api/generated/api.js';
3
3
  import { getErrorCode } from '#utils/error_utils.js';
4
4
  /**
5
- * Check if a file exists with detailed error information
5
+ * Read and parse trace file from local path
6
6
  */
7
- async function fileExists(path) {
8
- try {
9
- await stat(path);
10
- return { exists: true };
11
- }
12
- catch (error) {
13
- const code = getErrorCode(error);
14
- if (code === 'ENOENT') {
15
- return { exists: false };
16
- }
17
- if (code === 'EACCES') {
18
- return { exists: false, error: `Permission denied: ${path}` };
19
- }
20
- return { exists: false, error: `Cannot access file: ${path}` };
21
- }
22
- }
23
- /**
24
- * Find trace file from workflow metadata
25
- */
26
- export async function findTraceFile(workflowId) {
27
- const response = await getWorkflowIdResult(workflowId);
28
- // Check if we got a successful response
29
- if (response.status !== 200) {
30
- throw new Error(`Failed to get workflow result for ${workflowId}`);
31
- }
32
- const tracePath = response.data.trace?.destinations?.local;
33
- if (!tracePath) {
34
- throw new Error(`No trace file path found for workflow ${workflowId}`);
35
- }
36
- const fileCheck = await fileExists(tracePath);
37
- if (!fileCheck.exists) {
38
- const errorDetail = fileCheck.error || `Trace file not found at path: ${tracePath}`;
39
- throw new Error(errorDetail);
40
- }
41
- return tracePath;
42
- }
43
- /**
44
- * Read and parse trace file
45
- */
46
- export async function readTraceFile(path) {
7
+ async function readLocalTraceFile(path) {
47
8
  try {
48
9
  const content = await readFile(path, 'utf-8');
49
10
  return JSON.parse(content);
@@ -59,9 +20,38 @@ export async function readTraceFile(path) {
59
20
  }
60
21
  }
61
22
  /**
62
- * Get trace data from workflow ID
23
+ * Get trace data from workflow ID using the API
24
+ * The API handles S3 fetching - CLI only needs to read local files when necessary
25
+ * @returns Both the trace data and the location it was fetched from
63
26
  */
64
27
  export async function getTrace(workflowId) {
65
- const tracePath = await findTraceFile(workflowId);
66
- return readTraceFile(tracePath);
28
+ const response = await getWorkflowIdTraceLog(workflowId);
29
+ if (response.status === 404) {
30
+ throw new Error(`Workflow not found or no trace available: ${workflowId}`);
31
+ }
32
+ if (response.status === 500) {
33
+ const errorData = response.data;
34
+ const errorMessage = errorData?.error || 'Failed to fetch trace from API';
35
+ throw new Error(`API error (500): ${errorMessage}`);
36
+ }
37
+ if (response.status !== 200) {
38
+ const errorResponse = response;
39
+ throw new Error(`Unexpected API response status: ${errorResponse.status}`);
40
+ }
41
+ const data = response.data;
42
+ if (data.source === 'remote') {
43
+ return {
44
+ data: data.data,
45
+ location: { path: 'remote', isRemote: true }
46
+ };
47
+ }
48
+ if (data.source === 'local') {
49
+ const localPath = data.localPath;
50
+ const traceData = await readLocalTraceFile(localPath);
51
+ return {
52
+ data: traceData,
53
+ location: { path: localPath, isRemote: false }
54
+ };
55
+ }
56
+ throw new Error('Invalid trace log response format');
67
57
  }
@@ -1,164 +1,78 @@
1
- import { describe, it, expect, vi, afterEach } from 'vitest';
2
- import { findTraceFile, readTraceFile } from './trace_reader.js';
3
- // Mock file system operations
4
- vi.mock('node:fs/promises', () => ({
5
- readFile: vi.fn(),
6
- stat: vi.fn()
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
2
+ const mockGetWorkflowIdTraceLog = vi.fn();
3
+ vi.mock('#api/generated/api.js', () => ({
4
+ getWorkflowIdTraceLog: (...args) => mockGetWorkflowIdTraceLog(...args)
7
5
  }));
8
- // Mock API
9
- vi.mock('../api/generated/api.js', () => ({
10
- getWorkflowIdResult: vi.fn()
6
+ const mockReadFile = vi.fn();
7
+ vi.mock('node:fs/promises', () => ({
8
+ readFile: (...args) => mockReadFile(...args)
11
9
  }));
12
- describe('TraceReader', () => {
13
- const getMocks = async () => {
14
- const fsModule = await import('node:fs/promises');
15
- const apiModule = await import('../api/generated/api.js');
16
- return {
17
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
- mockReadFile: fsModule.readFile,
19
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
- mockStat: fsModule.stat,
21
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
- mockGetWorkflowIdResult: apiModule.getWorkflowIdResult
23
- };
24
- };
10
+ describe('trace_reader', () => {
11
+ beforeEach(() => {
12
+ mockGetWorkflowIdTraceLog.mockReset();
13
+ mockReadFile.mockReset();
14
+ });
25
15
  afterEach(() => {
26
16
  vi.clearAllMocks();
27
17
  });
28
- describe('findTraceFile', () => {
29
- it('should find trace file from workflow output metadata', async () => {
30
- const { mockGetWorkflowIdResult, mockStat } = await getMocks();
31
- const workflowId = 'test-workflow-123';
32
- const expectedPath = '/app/logs/runs/test/2024-01-01_test-workflow-123.json';
33
- mockGetWorkflowIdResult.mockResolvedValue({
34
- status: 200,
35
- data: {
36
- workflowId,
37
- output: { result: 'test result' },
38
- trace: {
39
- destinations: {
40
- local: expectedPath,
41
- remote: null
42
- }
43
- }
44
- }
45
- });
46
- mockStat.mockResolvedValue({ isFile: () => true });
47
- const result = await findTraceFile(workflowId);
48
- expect(result).toBe(expectedPath);
49
- expect(mockGetWorkflowIdResult).toHaveBeenCalledWith(workflowId);
50
- expect(mockStat).toHaveBeenCalledWith(expectedPath);
51
- });
52
- it('should throw error when no trace path in metadata', async () => {
53
- const { mockGetWorkflowIdResult } = await getMocks();
54
- const workflowId = 'test-workflow-456';
55
- mockGetWorkflowIdResult.mockResolvedValue({
56
- status: 200,
57
- data: {
58
- workflowId,
59
- output: { result: 'test result' },
60
- trace: {
61
- destinations: {
62
- local: null,
63
- remote: null
64
- }
65
- }
66
- }
67
- });
68
- await expect(findTraceFile(workflowId))
69
- .rejects.toThrow(`No trace file path found for workflow ${workflowId}`);
70
- });
71
- it('should throw error when trace file not on disk', async () => {
72
- const { mockGetWorkflowIdResult, mockStat } = await getMocks();
73
- const workflowId = 'test-workflow-789';
74
- const expectedPath = '/app/logs/runs/test/2024-01-01_test-workflow-789.json';
75
- mockGetWorkflowIdResult.mockResolvedValue({
18
+ describe('getTrace', () => {
19
+ it('should return trace data directly for remote source', async () => {
20
+ const mockTraceData = {
21
+ root: { workflowName: 'test', workflowId: 'wf-123', startTime: Date.now() },
22
+ children: []
23
+ };
24
+ mockGetWorkflowIdTraceLog.mockResolvedValue({
76
25
  status: 200,
77
26
  data: {
78
- workflowId,
79
- output: { result: 'test result' },
80
- trace: {
81
- destinations: {
82
- local: expectedPath,
83
- remote: null
84
- }
85
- }
27
+ source: 'remote',
28
+ data: mockTraceData
86
29
  }
87
30
  });
88
- const enoentError = Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' });
89
- mockStat.mockRejectedValue(enoentError);
90
- await expect(findTraceFile(workflowId))
91
- .rejects.toThrow(`Trace file not found at path: ${expectedPath}`);
31
+ const { getTrace } = await import('./trace_reader.js');
32
+ const result = await getTrace('wf-123');
33
+ expect(result.data).toEqual(mockTraceData);
34
+ expect(result.location.isRemote).toBe(true);
35
+ expect(mockReadFile).not.toHaveBeenCalled();
92
36
  });
93
- it('should throw error when API call fails', async () => {
94
- const { mockGetWorkflowIdResult } = await getMocks();
95
- const workflowId = 'non-existent';
96
- mockGetWorkflowIdResult.mockRejectedValue(new Error('Workflow not found'));
97
- await expect(findTraceFile(workflowId))
98
- .rejects.toThrow('Workflow not found');
99
- });
100
- it('should handle missing trace property gracefully', async () => {
101
- const { mockGetWorkflowIdResult } = await getMocks();
102
- const workflowId = 'test-workflow-no-trace';
103
- mockGetWorkflowIdResult.mockResolvedValue({
37
+ it('should read local file for local source', async () => {
38
+ const mockTraceData = {
39
+ root: { workflowName: 'test', workflowId: 'wf-123', startTime: Date.now() },
40
+ children: []
41
+ };
42
+ mockGetWorkflowIdTraceLog.mockResolvedValue({
104
43
  status: 200,
105
44
  data: {
106
- workflowId,
107
- output: { result: 'test result' }
108
- // No trace property at all
45
+ source: 'local',
46
+ localPath: '/path/to/trace.json'
109
47
  }
110
48
  });
111
- await expect(findTraceFile(workflowId))
112
- .rejects.toThrow(`No trace file path found for workflow ${workflowId}`);
49
+ mockReadFile.mockResolvedValue(JSON.stringify(mockTraceData));
50
+ const { getTrace } = await import('./trace_reader.js');
51
+ const result = await getTrace('wf-123');
52
+ expect(result.data).toEqual(mockTraceData);
53
+ expect(result.location.isRemote).toBe(false);
54
+ expect(result.location.path).toBe('/path/to/trace.json');
55
+ expect(mockReadFile).toHaveBeenCalledWith('/path/to/trace.json', 'utf-8');
113
56
  });
114
- it('should throw error when workflow not found (404)', async () => {
115
- const { mockGetWorkflowIdResult } = await getMocks();
116
- const workflowId = 'non-existent-workflow';
117
- mockGetWorkflowIdResult.mockResolvedValue({
57
+ it('should throw error when API returns 404', async () => {
58
+ mockGetWorkflowIdTraceLog.mockResolvedValue({
118
59
  status: 404,
119
- data: void 0
60
+ data: { error: 'Not found' }
120
61
  });
121
- await expect(findTraceFile(workflowId))
122
- .rejects.toThrow(`Failed to get workflow result for ${workflowId}`);
62
+ const { getTrace } = await import('./trace_reader.js');
63
+ await expect(getTrace('wf-123'))
64
+ .rejects
65
+ .toThrow('Workflow not found or no trace available: wf-123');
123
66
  });
124
- });
125
- describe('readTraceFile', () => {
126
- it('should read and parse JSON file successfully', async () => {
127
- const { mockReadFile } = await getMocks();
128
- const path = '/logs/test.json';
129
- const traceData = {
130
- root: { workflowName: 'test' },
131
- events: []
132
- };
133
- mockReadFile.mockResolvedValue(JSON.stringify(traceData));
134
- const result = await readTraceFile(path);
135
- expect(result).toEqual(traceData);
136
- expect(mockReadFile).toHaveBeenCalledWith(path, 'utf-8');
137
- });
138
- it('should throw error for non-existent file', async () => {
139
- const { mockReadFile } = await getMocks();
140
- const path = '/logs/missing.json';
141
- const error = new Error('ENOENT');
142
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
143
- error.code = 'ENOENT';
144
- mockReadFile.mockRejectedValue(error);
145
- await expect(readTraceFile(path))
146
- .rejects.toThrow(`Trace file not found at path: ${path}`);
147
- });
148
- it('should throw error for invalid JSON', async () => {
149
- const { mockReadFile } = await getMocks();
150
- const path = '/logs/invalid.json';
151
- mockReadFile.mockResolvedValue('invalid json {');
152
- await expect(readTraceFile(path))
153
- .rejects.toThrow(`Invalid JSON in trace file: ${path}`);
154
- });
155
- it('should rethrow other errors', async () => {
156
- const { mockReadFile } = await getMocks();
157
- const path = '/logs/test.json';
158
- const error = new Error('Permission denied');
159
- mockReadFile.mockRejectedValue(error);
160
- await expect(readTraceFile(path))
161
- .rejects.toThrow('Permission denied');
67
+ it('should throw error when API returns 500', async () => {
68
+ mockGetWorkflowIdTraceLog.mockResolvedValue({
69
+ status: 500,
70
+ data: { error: 'S3 access denied' }
71
+ });
72
+ const { getTrace } = await import('./trace_reader.js');
73
+ await expect(getTrace('wf-123'))
74
+ .rejects
75
+ .toThrow('S3 access denied');
162
76
  });
163
77
  });
164
78
  });
@@ -1,6 +1 @@
1
- /**
2
- * Load environment variables from .env files in the current working directory
3
- * Loads in order: .env, then .env.local (if exists)
4
- * .env.local overrides values from .env
5
- */
6
1
  export declare function loadEnvironment(): void;
@@ -1,43 +1,19 @@
1
1
  /**
2
2
  * Environment loader utility
3
- * Loads .env files from the current working directory
3
+ * Loads .env file from the current working directory
4
+ * Set OUTPUT_CLI_ENV to specify a custom env file path
4
5
  */
5
- import * as fs from 'node:fs';
6
- import * as path from 'node:path';
6
+ import { existsSync } from 'node:fs';
7
+ import { resolve } from 'node:path';
7
8
  import * as dotenv from 'dotenv';
8
- /**
9
- * Load environment variables from .env files in the current working directory
10
- * Loads in order: .env, then .env.local (if exists)
11
- * .env.local overrides values from .env
12
- */
13
9
  export function loadEnvironment() {
14
10
  const cwd = process.cwd();
15
- // Load .env file
16
- const envPath = path.join(cwd, '.env');
17
- if (fs.existsSync(envPath)) {
18
- try {
19
- const result = dotenv.config({ path: envPath });
20
- if (result.error) {
21
- // Log warning but don't fail - malformed .env shouldn't break the CLI
22
- console.warn(`Warning: Error parsing .env file: ${result.error.message}`);
23
- }
24
- }
25
- catch (error) {
26
- // Silent failure - .env loading is optional
27
- console.warn(`Warning: Could not load .env file: ${error}`);
28
- }
29
- }
30
- // Load .env.local file (overrides .env)
31
- const envLocalPath = path.join(cwd, '.env.local');
32
- if (fs.existsSync(envLocalPath)) {
33
- try {
34
- const result = dotenv.config({ path: envLocalPath });
35
- if (result.error) {
36
- console.warn(`Warning: Error parsing .env.local file: ${result.error.message}`);
37
- }
38
- }
39
- catch (error) {
40
- console.warn(`Warning: Could not load .env.local file: ${error}`);
41
- }
11
+ const envFile = process.env.OUTPUT_CLI_ENV || '.env';
12
+ const envPath = resolve(cwd, envFile);
13
+ if (!existsSync(envPath)) {
14
+ console.warn(`Warning: Env file not found: ${envPath}`);
15
+ return;
42
16
  }
17
+ console.log(`Loading env from: ${envPath}`);
18
+ dotenv.config({ path: envPath });
43
19
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Tests for the env loader utility
3
+ */
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
+ import { existsSync } from 'node:fs';
6
+ import { resolve } from 'node:path';
7
+ import * as dotenv from 'dotenv';
8
+ vi.mock('node:fs');
9
+ vi.mock('dotenv');
10
+ describe('loadEnvironment', () => {
11
+ const originalEnv = { ...process.env };
12
+ const mockCwd = '/mock/project';
13
+ beforeEach(() => {
14
+ vi.resetModules();
15
+ vi.clearAllMocks();
16
+ vi.spyOn(process, 'cwd').mockReturnValue(mockCwd);
17
+ vi.spyOn(console, 'log').mockImplementation(() => { });
18
+ vi.spyOn(console, 'warn').mockImplementation(() => { });
19
+ });
20
+ afterEach(() => {
21
+ process.env = { ...originalEnv };
22
+ vi.restoreAllMocks();
23
+ });
24
+ it('should load from OUTPUT_CLI_ENV when set and file exists', async () => {
25
+ process.env.OUTPUT_CLI_ENV = '.env.prod';
26
+ const expectedPath = resolve(mockCwd, '.env.prod');
27
+ vi.mocked(existsSync).mockReturnValue(true);
28
+ vi.mocked(dotenv.config).mockReturnValue({ parsed: { API_URL: 'https://prod.api.com' } });
29
+ const { loadEnvironment } = await import('./env_loader.js');
30
+ loadEnvironment();
31
+ expect(console.log).toHaveBeenCalledWith(`Loading env from: ${expectedPath}`);
32
+ expect(dotenv.config).toHaveBeenCalledWith({ path: expectedPath });
33
+ });
34
+ it('should warn when OUTPUT_CLI_ENV file does not exist', async () => {
35
+ process.env.OUTPUT_CLI_ENV = '.env.missing';
36
+ const expectedPath = resolve(mockCwd, '.env.missing');
37
+ vi.mocked(existsSync).mockReturnValue(false);
38
+ const { loadEnvironment } = await import('./env_loader.js');
39
+ loadEnvironment();
40
+ expect(console.warn).toHaveBeenCalledWith(`Warning: Env file not found: ${expectedPath}`);
41
+ expect(dotenv.config).not.toHaveBeenCalled();
42
+ });
43
+ it('should load .env by default and log', async () => {
44
+ delete process.env.OUTPUT_CLI_ENV;
45
+ const envPath = resolve(mockCwd, '.env');
46
+ vi.mocked(existsSync).mockImplementation(p => p === envPath);
47
+ vi.mocked(dotenv.config).mockReturnValue({ parsed: {} });
48
+ const { loadEnvironment } = await import('./env_loader.js');
49
+ loadEnvironment();
50
+ expect(console.log).toHaveBeenCalledWith(`Loading env from: ${envPath}`);
51
+ expect(dotenv.config).toHaveBeenCalledTimes(1);
52
+ expect(dotenv.config).toHaveBeenCalledWith({ path: envPath });
53
+ });
54
+ it('should warn when default .env does not exist', async () => {
55
+ delete process.env.OUTPUT_CLI_ENV;
56
+ const envPath = resolve(mockCwd, '.env');
57
+ vi.mocked(existsSync).mockReturnValue(false);
58
+ const { loadEnvironment } = await import('./env_loader.js');
59
+ loadEnvironment();
60
+ expect(console.warn).toHaveBeenCalledWith(`Warning: Env file not found: ${envPath}`);
61
+ expect(dotenv.config).not.toHaveBeenCalled();
62
+ });
63
+ });
@@ -5,21 +5,45 @@ const DEFAULT_MESSAGES = {
5
5
  404: 'Resource not found.',
6
6
  UNKNOWN: 'An unknown error occurred.'
7
7
  };
8
+ /**
9
+ * Extract detailed error information from fetch errors and their causes
10
+ */
11
+ function getDetailedErrorMessage(error) {
12
+ const apiError = error;
13
+ const parts = [];
14
+ if (apiError.message) {
15
+ parts.push(apiError.message);
16
+ }
17
+ if (apiError.cause) {
18
+ const cause = apiError.cause;
19
+ if (cause.message && cause.message !== apiError.message) {
20
+ parts.push(`Cause: ${cause.message}`);
21
+ }
22
+ if (cause.code) {
23
+ parts.push(`Code: ${cause.code}`);
24
+ }
25
+ if (cause.hostname) {
26
+ parts.push(`Host: ${cause.hostname}${cause.port ? ':' + cause.port : ''}`);
27
+ }
28
+ }
29
+ if (apiError.response?.status) {
30
+ parts.push(`HTTP Status: ${apiError.response.status}`);
31
+ }
32
+ return parts.length > 0 ? parts.join(' | ') : 'Unknown error';
33
+ }
8
34
  export function handleApiError(error, errorFn, overrides = {}) {
9
35
  const apiError = error;
10
36
  const errorMessages = { ...DEFAULT_MESSAGES, ...overrides };
11
- if (apiError.code === 'ECONNREFUSED') {
12
- return errorFn(errorMessages.ECONNREFUSED, { exit: 1 });
37
+ if (apiError.code === 'ECONNREFUSED' || apiError.cause?.code === 'ECONNREFUSED') {
38
+ errorFn(errorMessages.ECONNREFUSED, { exit: 1 });
13
39
  }
14
40
  if (apiError.response?.status) {
15
41
  const status = apiError.response.status;
16
42
  const message = errorMessages[status];
17
43
  if (message) {
18
- return errorFn(message, { exit: 1 });
44
+ errorFn(message, { exit: 1 });
19
45
  }
20
46
  }
21
- if (apiError.message) {
22
- return errorFn(apiError.message, { exit: 1 });
23
- }
24
- return errorFn(errorMessages.UNKNOWN, { exit: 1 });
47
+ const detailedMessage = getDetailedErrorMessage(error);
48
+ errorFn(detailedMessage, { exit: 1 });
25
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@output.ai/cli",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "CLI for Output.ai workflow generation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -35,6 +35,7 @@
35
35
  "handlebars": "4.7.8",
36
36
  "json-schema-library": "10.3.0",
37
37
  "ky": "1.12.0",
38
+ "log-update": "7.0.2",
38
39
  "validator": "13.15.22"
39
40
  },
40
41
  "devDependencies": {