@outputai/cli 0.3.3-next.2650161.0 → 0.3.3-next.33928d3.0

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.
@@ -77,7 +77,7 @@ services:
77
77
  condition: service_healthy
78
78
  worker:
79
79
  condition: service_healthy
80
- image: outputai/api:${OUTPUT_API_VERSION:-0.3.3-next.2650161.0}
80
+ image: outputai/api:${OUTPUT_API_VERSION:-0.3.3-next.33928d3.0}
81
81
  init: true
82
82
  networks:
83
83
  - main
@@ -64,7 +64,11 @@ export default class Dev extends Command {
64
64
  this.log('✅ Services started. Run `output dev` without --detached to monitor status.\n');
65
65
  return;
66
66
  }
67
+ const state = {
68
+ cleaningUp: false
69
+ };
67
70
  const cleanup = async () => {
71
+ state.cleaningUp = true;
68
72
  this.log('\n');
69
73
  if (this.dockerProcess) {
70
74
  this.dockerProcess.kill('SIGTERM');
@@ -115,18 +119,34 @@ export default class Dev extends Command {
115
119
  process.on('SIGINT', handleSignal);
116
120
  process.on('SIGTERM', handleSignal);
117
121
  try {
118
- const { process: dockerProc } = await startDockerCompose(dockerComposePath, pullPolicy);
119
- this.dockerProcess = dockerProc;
120
122
  enterAltScreen();
121
123
  const instance = render(React.createElement(DevApp, { dockerComposePath, onCleanup: cleanup }), { exitOnCtrlC: false });
122
124
  instanceRef.current = instance;
123
- dockerProc.on('error', error => {
124
- instance.unmount(new Error(`Docker process error: ${getErrorMessage(error)}`));
125
+ const dockerProc = await startDockerCompose({
126
+ dockerComposePath,
127
+ pullPolicy,
128
+ onError: error => {
129
+ instance.unmount(new Error(`Docker process error: ${getErrorMessage(error)}`));
130
+ },
131
+ onExit: (code, signal, output) => {
132
+ if (state.cleaningUp) {
133
+ return;
134
+ }
135
+ if (code === 0) {
136
+ instance.unmount();
137
+ return;
138
+ }
139
+ const exitReason = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`;
140
+ const detail = output ? `\n\nRecent Docker output:\n${output}` : '';
141
+ instance.unmount(new Error(`Docker compose exited with ${exitReason}.${detail}`));
142
+ }
125
143
  });
144
+ this.dockerProcess = dockerProc;
126
145
  await instance.waitUntilExit();
127
146
  exitAltScreenOnce();
128
147
  }
129
148
  catch (error) {
149
+ instanceRef.current?.unmount();
130
150
  exitAltScreenOnce();
131
151
  this.error(getErrorMessage(error), { exit: 1 });
132
152
  }
@@ -1,6 +1,8 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import React from 'react';
2
3
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
4
  import fs from 'node:fs/promises';
5
+ import { render } from 'ink';
4
6
  import * as dockerService from '#services/docker.js';
5
7
  import * as codingAgentsService from '#services/coding_agents.js';
6
8
  import Dev from './index.js';
@@ -45,13 +47,36 @@ vi.mock('#views/dev/dev_app.js', () => ({
45
47
  DevApp: () => null
46
48
  }));
47
49
  const createMockDockerProcess = () => ({
48
- process: {
49
- on: vi.fn(),
50
- kill: vi.fn(),
51
- stdout: { on: vi.fn() },
52
- stderr: { on: vi.fn() }
53
- }
50
+ on: vi.fn(),
51
+ kill: vi.fn(),
52
+ stdout: { on: vi.fn() },
53
+ stderr: { on: vi.fn() }
54
54
  });
55
+ const getStartDockerComposeOptions = () => {
56
+ const options = vi.mocked(dockerService.startDockerCompose).mock.calls.at(-1)?.[0];
57
+ if (!options) {
58
+ throw new Error('Expected startDockerCompose to receive options');
59
+ }
60
+ return options;
61
+ };
62
+ const createControllableInkInstance = () => {
63
+ const deferred = {};
64
+ deferred.promise = new Promise((resolve, reject) => {
65
+ deferred.resolve = resolve;
66
+ deferred.reject = reject;
67
+ });
68
+ const instance = {
69
+ waitUntilExit: vi.fn(() => deferred.promise),
70
+ unmount: vi.fn((error) => {
71
+ if (error) {
72
+ deferred.reject(error);
73
+ return;
74
+ }
75
+ deferred.resolve();
76
+ })
77
+ };
78
+ return instance;
79
+ };
55
80
  describe('dev command', () => {
56
81
  beforeEach(() => {
57
82
  vi.clearAllMocks();
@@ -182,8 +207,12 @@ describe('dev command', () => {
182
207
  const runPromise = cmd.run();
183
208
  // Wait a tick for startDockerCompose to be called
184
209
  await new Promise(resolve => setImmediate(resolve));
185
- expect(dockerService.startDockerCompose).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml', 'always' // default pull policy
186
- );
210
+ expect(dockerService.startDockerCompose).toHaveBeenCalledWith(expect.objectContaining({
211
+ dockerComposePath: '/path/to/docker-compose-dev.yml',
212
+ pullPolicy: 'always',
213
+ onError: expect.any(Function),
214
+ onExit: expect.any(Function)
215
+ }));
187
216
  // Cancel the promise (it will be rejected but we don't care)
188
217
  runPromise.catch(() => { });
189
218
  });
@@ -207,6 +236,53 @@ describe('dev command', () => {
207
236
  await cmd.run();
208
237
  expect(cmd.error).toHaveBeenCalledWith('Docker error', { exit: 1 });
209
238
  });
239
+ it('should surface docker compose exit failures with recent output', async () => {
240
+ const dockerProcess = createMockDockerProcess();
241
+ vi.mocked(dockerService.startDockerCompose).mockResolvedValue(dockerProcess);
242
+ const inkInstance = createControllableInkInstance();
243
+ vi.mocked(render).mockReturnValue(inkInstance);
244
+ const cmd = new Dev([], {});
245
+ cmd.log = vi.fn();
246
+ cmd.error = vi.fn();
247
+ Object.defineProperty(cmd, 'parse', {
248
+ value: vi.fn().mockResolvedValue({ flags: { 'compose-file': undefined, 'image-pull-policy': 'always' }, args: {} }),
249
+ configurable: true
250
+ });
251
+ const runPromise = cmd.run();
252
+ await new Promise(resolve => setImmediate(resolve));
253
+ getStartDockerComposeOptions().onExit?.(1, null, 'failed to bind host port');
254
+ await runPromise;
255
+ expect(inkInstance.unmount).toHaveBeenCalledWith(expect.objectContaining({
256
+ message: expect.stringContaining('Docker compose exited with code 1')
257
+ }));
258
+ expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('Recent Docker output:\nfailed to bind host port'), { exit: 1 });
259
+ });
260
+ it('should ignore docker compose exits triggered by cleanup', async () => {
261
+ const dockerProcess = createMockDockerProcess();
262
+ vi.mocked(dockerService.startDockerCompose).mockResolvedValue(dockerProcess);
263
+ const inkInstance = createControllableInkInstance();
264
+ vi.mocked(render).mockReturnValue(inkInstance);
265
+ const cmd = new Dev([], {});
266
+ cmd.log = vi.fn();
267
+ cmd.error = vi.fn();
268
+ Object.defineProperty(cmd, 'parse', {
269
+ value: vi.fn().mockResolvedValue({ flags: { 'compose-file': undefined, 'image-pull-policy': 'always' }, args: {} }),
270
+ configurable: true
271
+ });
272
+ const runPromise = cmd.run();
273
+ await new Promise(resolve => setImmediate(resolve));
274
+ const appElement = vi.mocked(render).mock.calls[0]?.[0];
275
+ if (!React.isValidElement(appElement)) {
276
+ throw new Error('Expected render to receive a React element');
277
+ }
278
+ const appProps = appElement.props;
279
+ await appProps.onCleanup();
280
+ getStartDockerComposeOptions().onExit?.(null, 'SIGTERM', 'compose stopped');
281
+ inkInstance.unmount();
282
+ await runPromise;
283
+ expect(inkInstance.unmount).not.toHaveBeenCalledWith(expect.any(Error));
284
+ expect(cmd.error).not.toHaveBeenCalled();
285
+ });
210
286
  });
211
287
  describe('image pull policy', () => {
212
288
  it('should pass pull policy to startDockerCompose', async () => {
@@ -222,7 +298,12 @@ describe('dev command', () => {
222
298
  const runPromise = cmd.run();
223
299
  // Wait a tick for startDockerCompose to be called
224
300
  await new Promise(resolve => setImmediate(resolve));
225
- expect(dockerService.startDockerCompose).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml', 'missing');
301
+ expect(dockerService.startDockerCompose).toHaveBeenCalledWith(expect.objectContaining({
302
+ dockerComposePath: '/path/to/docker-compose-dev.yml',
303
+ pullPolicy: 'missing',
304
+ onError: expect.any(Function),
305
+ onExit: expect.any(Function)
306
+ }));
226
307
  // Cancel the promise (it will be rejected but we don't care)
227
308
  runPromise.catch(() => { });
228
309
  });
@@ -239,7 +320,12 @@ describe('dev command', () => {
239
320
  const runPromise = cmd.run();
240
321
  // Wait a tick for startDockerCompose to be called
241
322
  await new Promise(resolve => setImmediate(resolve));
242
- expect(dockerService.startDockerCompose).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml', 'never');
323
+ expect(dockerService.startDockerCompose).toHaveBeenCalledWith(expect.objectContaining({
324
+ dockerComposePath: '/path/to/docker-compose-dev.yml',
325
+ pullPolicy: 'never',
326
+ onError: expect.any(Function),
327
+ onExit: expect.any(Function)
328
+ }));
243
329
  // Cancel the promise (it will be rejected but we don't care)
244
330
  runPromise.catch(() => { });
245
331
  });
@@ -1,3 +1,3 @@
1
1
  {
2
- "framework": "0.3.3-next.2650161.0"
2
+ "framework": "0.3.3-next.33928d3.0"
3
3
  }
@@ -7,6 +7,7 @@ export declare const SERVICE_HEALTH: {
7
7
  };
8
8
  export declare const SERVICE_STATE: {
9
9
  readonly RUNNING: "running";
10
+ readonly CREATED: "created";
10
11
  readonly EXITED: "exited";
11
12
  };
12
13
  declare class DockerValidationError extends Error {
@@ -28,11 +29,16 @@ export declare function getServiceStatus(dockerComposePath: string): Promise<Ser
28
29
  export declare function isServiceHealthy(service: ServiceStatus): boolean;
29
30
  export declare function isServiceFailed(service: ServiceStatus): boolean;
30
31
  export declare function waitForServicesHealthy(dockerComposePath: string, timeoutMs?: number, pollIntervalMs?: number): Promise<void>;
31
- export interface DockerComposeProcess {
32
- process: ChildProcess;
32
+ export interface DockerComposeHandlers {
33
+ onError?: (error: Error, output: string) => void;
34
+ onExit?: (code: number | null, signal: NodeJS.Signals | null, output: string) => void;
33
35
  }
34
36
  export type PullPolicy = 'always' | 'missing' | 'never';
35
- export declare function startDockerCompose(dockerComposePath: string, pullPolicy?: PullPolicy): Promise<DockerComposeProcess>;
37
+ export interface StartDockerComposeOptions extends DockerComposeHandlers {
38
+ dockerComposePath: string;
39
+ pullPolicy?: PullPolicy;
40
+ }
41
+ export declare function startDockerCompose({ dockerComposePath, pullPolicy, onError, onExit }: StartDockerComposeOptions): Promise<ChildProcess>;
36
42
  export declare function startDockerComposeDetached(dockerComposePath: string, pullPolicy?: PullPolicy): void;
37
43
  export declare function stopDockerCompose(dockerComposePath: string): Promise<void>;
38
44
  export { isDockerInstalled, DockerValidationError };
@@ -13,6 +13,7 @@ export const SERVICE_HEALTH = {
13
13
  };
14
14
  export const SERVICE_STATE = {
15
15
  RUNNING: 'running',
16
+ CREATED: 'created',
16
17
  EXITED: 'exited'
17
18
  };
18
19
  class DockerValidationError extends Error {
@@ -101,11 +102,17 @@ export function parseServiceStatus(jsonOutput) {
101
102
  });
102
103
  }
103
104
  export async function getServiceStatus(dockerComposePath) {
104
- const result = execFileSync('docker', ['compose', '-f', dockerComposePath, '--project-name', config.dockerServiceName, 'ps', '--all', '--format', 'json'], { encoding: 'utf-8', cwd: process.cwd() });
105
+ const result = execFileSync('docker', [
106
+ 'compose',
107
+ '-f', dockerComposePath,
108
+ '--project-directory', process.cwd(),
109
+ '--project-name', config.dockerServiceName,
110
+ 'ps', '--all', '--format', 'json'
111
+ ], { encoding: 'utf-8', cwd: process.cwd() });
105
112
  return parseServiceStatus(result);
106
113
  }
107
114
  export function isServiceHealthy(service) {
108
- return service.state !== SERVICE_STATE.EXITED &&
115
+ return service.state === SERVICE_STATE.RUNNING &&
109
116
  (service.health === SERVICE_HEALTH.HEALTHY || service.health === SERVICE_HEALTH.NONE);
110
117
  }
111
118
  export function isServiceFailed(service) {
@@ -122,7 +129,7 @@ export async function waitForServicesHealthy(dockerComposePath, timeoutMs = 1200
122
129
  }
123
130
  throw new Error('Timeout waiting for services to become healthy');
124
131
  }
125
- export async function startDockerCompose(dockerComposePath, pullPolicy) {
132
+ export async function startDockerCompose({ dockerComposePath, pullPolicy, onError, onExit }) {
126
133
  const args = [
127
134
  'compose',
128
135
  '-f', dockerComposePath,
@@ -133,11 +140,27 @@ export async function startDockerCompose(dockerComposePath, pullPolicy) {
133
140
  if (pullPolicy) {
134
141
  args.push('--pull', pullPolicy);
135
142
  }
143
+ const output = {
144
+ value: ''
145
+ };
146
+ const appendOutput = (chunk) => {
147
+ output.value = `${output.value}${chunk.toString()}`.slice(-20000).trimStart();
148
+ };
136
149
  const dockerProcess = spawn('docker', args, {
137
150
  cwd: process.cwd(),
151
+ // The Ink dev UI owns the terminal. Drain compose output so Docker cannot
152
+ // block on a full pipe, while keeping recent output for startup failures.
138
153
  stdio: ['ignore', 'pipe', 'pipe']
139
154
  });
140
- return { process: dockerProcess };
155
+ dockerProcess.stdout?.on('data', appendOutput);
156
+ dockerProcess.stderr?.on('data', appendOutput);
157
+ if (onError) {
158
+ dockerProcess.on('error', error => onError(error, output.value.trimEnd()));
159
+ }
160
+ if (onExit) {
161
+ dockerProcess.on('exit', (code, signal) => onExit(code, signal, output.value.trimEnd()));
162
+ }
163
+ return dockerProcess;
141
164
  }
142
165
  export function startDockerComposeDetached(dockerComposePath, pullPolicy) {
143
166
  const args = [
@@ -6,6 +6,7 @@ vi.mock('node:child_process', () => ({
6
6
  execFileSync: vi.fn(),
7
7
  spawn: vi.fn()
8
8
  }));
9
+ const mockChildProcess = (process) => process;
9
10
  vi.mock('log-update', () => {
10
11
  const fn = vi.fn();
11
12
  fn.done = vi.fn();
@@ -14,6 +15,11 @@ vi.mock('log-update', () => {
14
15
  describe('docker service', () => {
15
16
  beforeEach(() => {
16
17
  vi.clearAllMocks();
18
+ vi.mocked(spawn).mockReturnValue(mockChildProcess({
19
+ on: vi.fn(),
20
+ stdout: { on: vi.fn() },
21
+ stderr: { on: vi.fn() }
22
+ }));
17
23
  });
18
24
  afterEach(() => {
19
25
  vi.restoreAllMocks();
@@ -73,7 +79,12 @@ describe('docker service', () => {
73
79
  const mockOutput = '{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}';
74
80
  vi.mocked(execFileSync).mockReturnValue(mockOutput);
75
81
  await getServiceStatus('/path/to/docker-compose.yml');
76
- expect(execFileSync).toHaveBeenCalledWith('docker', ['compose', '-f', '/path/to/docker-compose.yml', '--project-name', 'output-sdk', 'ps', '--all', '--format', 'json'], expect.objectContaining({ encoding: 'utf-8' }));
82
+ expect(execFileSync).toHaveBeenCalledWith('docker', [
83
+ 'compose', '-f', '/path/to/docker-compose.yml',
84
+ '--project-directory', process.cwd(),
85
+ '--project-name', 'output-sdk',
86
+ 'ps', '--all', '--format', 'json'
87
+ ], expect.objectContaining({ encoding: 'utf-8' }));
77
88
  });
78
89
  it('should return parsed service status', async () => {
79
90
  const mockOutput = '{"Service":"redis","State":"running","Health":"healthy","Publishers":[{"PublishedPort":6379,"TargetPort":6379}]}';
@@ -91,7 +102,7 @@ describe('docker service', () => {
91
102
  });
92
103
  describe('startDockerCompose', () => {
93
104
  it('should pass --project-name to docker compose up', async () => {
94
- await startDockerCompose('/path/to/docker-compose.yml');
105
+ await startDockerCompose({ dockerComposePath: '/path/to/docker-compose.yml' });
95
106
  expect(spawn).toHaveBeenCalledWith('docker', [
96
107
  'compose', '-f', '/path/to/docker-compose.yml',
97
108
  '--project-directory', process.cwd(),
@@ -100,7 +111,7 @@ describe('docker service', () => {
100
111
  ], expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'], cwd: process.cwd() }));
101
112
  });
102
113
  it('should append --pull when pullPolicy is provided', async () => {
103
- await startDockerCompose('/path/to/docker-compose.yml', 'always');
114
+ await startDockerCompose({ dockerComposePath: '/path/to/docker-compose.yml', pullPolicy: 'always' });
104
115
  expect(spawn).toHaveBeenCalledWith('docker', [
105
116
  'compose', '-f', '/path/to/docker-compose.yml',
106
117
  '--project-directory', process.cwd(),
@@ -108,6 +119,51 @@ describe('docker service', () => {
108
119
  'up', '--pull', 'always'
109
120
  ], expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'], cwd: process.cwd() }));
110
121
  });
122
+ it('should attach docker process handlers and pass captured output', async () => {
123
+ const onError = vi.fn();
124
+ const onExit = vi.fn();
125
+ const processHandlers = {};
126
+ const streamHandlers = {};
127
+ const process = {
128
+ on: vi.fn((event, handler) => {
129
+ if (event === 'error') {
130
+ processHandlers.error = handler;
131
+ }
132
+ else {
133
+ processHandlers.exit = handler;
134
+ }
135
+ return process;
136
+ }),
137
+ stdout: {
138
+ on: vi.fn((event, handler) => {
139
+ streamHandlers.stdout = handler;
140
+ return process.stdout;
141
+ })
142
+ },
143
+ stderr: {
144
+ on: vi.fn((event, handler) => {
145
+ streamHandlers.stderr = handler;
146
+ return process.stderr;
147
+ })
148
+ }
149
+ };
150
+ vi.mocked(spawn).mockReturnValue(mockChildProcess(process));
151
+ const dockerProcess = await startDockerCompose({
152
+ dockerComposePath: '/path/to/docker-compose.yml',
153
+ pullPolicy: 'always',
154
+ onError,
155
+ onExit
156
+ });
157
+ expect(dockerProcess.on).toHaveBeenCalledWith('error', expect.any(Function));
158
+ expect(dockerProcess.on).toHaveBeenCalledWith('exit', expect.any(Function));
159
+ streamHandlers.stdout?.(Buffer.from('starting services\n'));
160
+ streamHandlers.stderr?.(Buffer.from('compose failed\n'));
161
+ const error = new Error('Docker failed');
162
+ processHandlers.error?.(error);
163
+ processHandlers.exit?.(1, null);
164
+ expect(onError).toHaveBeenCalledWith(error, 'starting services\ncompose failed');
165
+ expect(onExit).toHaveBeenCalledWith(1, null, 'starting services\ncompose failed');
166
+ });
111
167
  });
112
168
  describe('startDockerComposeDetached', () => {
113
169
  it('should pass --project-name and -d to docker compose up', () => {
@@ -176,6 +232,9 @@ describe('docker service', () => {
176
232
  it('should return false for an exited service with health: unhealthy', () => {
177
233
  expect(isServiceHealthy({ name: 'worker', state: 'exited', health: 'unhealthy', ports: [] })).toBe(false);
178
234
  });
235
+ it('should return false for a created service with no health check', () => {
236
+ expect(isServiceHealthy({ name: 'api', state: 'created', health: 'none', ports: [] })).toBe(false);
237
+ });
179
238
  it('should return false for a service with health: starting', () => {
180
239
  expect(isServiceHealthy({ name: 'temporal', state: 'running', health: 'starting', ports: [] })).toBe(false);
181
240
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/cli",
3
- "version": "0.3.3-next.2650161.0",
3
+ "version": "0.3.3-next.33928d3.0",
4
4
  "description": "CLI for Output.ai workflow generation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,9 +36,9 @@
36
36
  "semver": "7.7.4",
37
37
  "undici": "8.1.0",
38
38
  "yaml": "^2.8.3",
39
- "@outputai/llm": "0.3.3-next.2650161.0",
40
- "@outputai/evals": "0.3.3-next.2650161.0",
41
- "@outputai/credentials": "0.3.3-next.2650161.0"
39
+ "@outputai/credentials": "0.3.3-next.33928d3.0",
40
+ "@outputai/evals": "0.3.3-next.33928d3.0",
41
+ "@outputai/llm": "0.3.3-next.33928d3.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/cli-progress": "3.11.6",