@outputai/cli 0.1.10 → 0.1.11-next.49171f5.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.
@@ -81,7 +81,7 @@ services:
81
81
  condition: service_healthy
82
82
  worker:
83
83
  condition: service_healthy
84
- image: outputai/api:${OUTPUT_API_VERSION:-0.1.10}
84
+ image: outputai/api:${OUTPUT_API_VERSION:-0.1.11-next.49171f5.0}
85
85
  init: true
86
86
  networks:
87
87
  - main
@@ -105,11 +105,11 @@ services:
105
105
  condition: service_healthy
106
106
  image: node:24.13.0-slim
107
107
  healthcheck:
108
- test: [ 'CMD', 'npx', '--yes', 'output-healthcheck' ]
108
+ test: [ 'CMD-SHELL', 'npx output-healthcheck' ]
109
109
  interval: 3s
110
- timeout: 10s
111
- retries: 20
112
- start_period: 30s
110
+ timeout: 3s
111
+ retries: 2
112
+ start_period: 60s
113
113
  init: true
114
114
  networks:
115
115
  - main
@@ -118,6 +118,7 @@ services:
118
118
  required: false
119
119
  environment:
120
120
  - NODE_ENV=development
121
+ - COREPACK_ENABLE_DOWNLOAD_PROMPT=0
121
122
  - OUTPUT_CATALOG_ID=${OUTPUT_CATALOG_ID:-main}
122
123
  - OUTPUT_REDIS_URL=redis://redis:6379
123
124
  - OUTPUT_TRACE_LOCAL_ON=${OUTPUT_TRACE_LOCAL_ON:-true}
@@ -127,6 +128,7 @@ services:
127
128
  - NODE_OPTIONS=${NODE_OPTIONS:---max-old-space-size=4096}
128
129
  command: >
129
130
  sh -c "
131
+ corepack enable &&
130
132
  npm run output:worker:install &&
131
133
  echo 'Installed dependencies' &&
132
134
  npx nodemon --watch src --watch package.json --ext ts,js,json,prompt --ignore 'dist/**' --ignore '**/*.test.ts' --ignore '**/*.spec.ts' --exec 'npm run output:worker:install && npm run output:worker:build && npm run output:worker:start'
@@ -2,7 +2,7 @@ import { Command, Flags } from '@oclif/core';
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import logUpdate from 'log-update';
5
- import { validateDockerEnvironment, startDockerCompose, startDockerComposeDetached, stopDockerCompose, getServiceStatus, DockerComposeConfigNotFoundError, getDefaultDockerComposePath, SERVICE_HEALTH, SERVICE_STATE } from '#services/docker.js';
5
+ import { validateDockerEnvironment, startDockerCompose, startDockerComposeDetached, stopDockerCompose, getServiceStatus, isServiceFailed, DockerComposeConfigNotFoundError, getDefaultDockerComposePath, SERVICE_HEALTH, SERVICE_STATE } from '#services/docker.js';
6
6
  import { getErrorMessage } from '#utils/error_utils.js';
7
7
  import { getDevSuccessMessage } from '#services/messages.js';
8
8
  import { ensureClaudePlugin } from '#services/coding_agents.js';
@@ -43,7 +43,7 @@ const formatService = (service) => {
43
43
  return ` ${color}${icon}${ANSI.RESET} ${name} ${ANSI.DIM}${statusPadded}${ANSI.RESET} ${ANSI.DIM}${ports}${ANSI.RESET}`;
44
44
  };
45
45
  const getFailedServicesWarning = (services) => {
46
- const failedServices = services.filter(s => s.state === SERVICE_STATE.EXITED);
46
+ const failedServices = services.filter(isServiceFailed);
47
47
  if (failedServices.length === 0) {
48
48
  return [];
49
49
  }
@@ -15,6 +15,7 @@ vi.mock('#services/docker.js', () => ({
15
15
  { name: 'redis', state: 'running', health: 'healthy', ports: ['6379:6379'] },
16
16
  { name: 'temporal', state: 'running', health: 'healthy', ports: ['7233:7233'] }
17
17
  ]),
18
+ isServiceFailed: vi.fn((s) => s.state === 'exited' || s.health === 'unhealthy'),
18
19
  DockerComposeConfigNotFoundError: Error,
19
20
  DockerValidationError: Error,
20
21
  getDefaultDockerComposePath: vi.fn(() => '/path/to/docker-compose-dev.yml'),
@@ -1,3 +1,3 @@
1
1
  {
2
- "framework": "0.1.10"
2
+ "framework": "0.1.11-next.49171f5.0"
3
3
  }
@@ -27,6 +27,8 @@ export declare function validateDockerEnvironment(): void;
27
27
  export declare function getDefaultDockerComposePath(): string;
28
28
  export declare function parseServiceStatus(jsonOutput: string): ServiceStatus[];
29
29
  export declare function getServiceStatus(dockerComposePath: string): Promise<ServiceStatus[]>;
30
+ export declare function isServiceHealthy(service: ServiceStatus): boolean;
31
+ export declare function isServiceFailed(service: ServiceStatus): boolean;
30
32
  export declare function waitForServicesHealthy(dockerComposePath: string, timeoutMs?: number, pollIntervalMs?: number): Promise<void>;
31
33
  export interface DockerComposeProcess {
32
34
  process: ChildProcess;
@@ -103,11 +103,18 @@ const formatServiceStatus = (services) => services.map(s => {
103
103
  const status = s.health === SERVICE_HEALTH.NONE ? s.state : s.health;
104
104
  return ` ${color}${icon}${ANSI_RESET} ${s.name}: ${status}`;
105
105
  }).join('\n');
106
+ export function isServiceHealthy(service) {
107
+ return service.state !== SERVICE_STATE.EXITED &&
108
+ (service.health === SERVICE_HEALTH.HEALTHY || service.health === SERVICE_HEALTH.NONE);
109
+ }
110
+ export function isServiceFailed(service) {
111
+ return service.state === SERVICE_STATE.EXITED || service.health === SERVICE_HEALTH.UNHEALTHY;
112
+ }
106
113
  export async function waitForServicesHealthy(dockerComposePath, timeoutMs = 120000, pollIntervalMs = 2000) {
107
114
  const startTime = Date.now();
108
115
  while (Date.now() - startTime < timeoutMs) {
109
116
  const services = await getServiceStatus(dockerComposePath);
110
- const allHealthy = services.every(s => s.health === SERVICE_HEALTH.HEALTHY || s.health === SERVICE_HEALTH.NONE);
117
+ const allHealthy = services.every(isServiceHealthy);
111
118
  if (services.length > 0) {
112
119
  const statusLines = formatServiceStatus(services);
113
120
  logUpdate(`⏳ Waiting for services to become healthy...\n${statusLines}`);
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { execFileSync } from 'node:child_process';
3
- import { parseServiceStatus, getServiceStatus, waitForServicesHealthy } from './docker.js';
3
+ import { parseServiceStatus, getServiceStatus, waitForServicesHealthy, isServiceHealthy, isServiceFailed } from './docker.js';
4
4
  vi.mock('node:child_process', () => ({
5
5
  execSync: vi.fn(),
6
6
  execFileSync: vi.fn(),
@@ -89,6 +89,46 @@ describe('docker service', () => {
89
89
  await expect(getServiceStatus('/path/to/docker-compose.yml')).rejects.toThrow();
90
90
  });
91
91
  });
92
+ describe('isServiceHealthy', () => {
93
+ it('should return true for a running service with health: healthy', () => {
94
+ expect(isServiceHealthy({ name: 'redis', state: 'running', health: 'healthy', ports: [] })).toBe(true);
95
+ });
96
+ it('should return true for a running service with no health check (health: none)', () => {
97
+ expect(isServiceHealthy({ name: 'api', state: 'running', health: 'none', ports: [] })).toBe(true);
98
+ });
99
+ it('should return false for a running service with health: unhealthy', () => {
100
+ expect(isServiceHealthy({ name: 'worker', state: 'running', health: 'unhealthy', ports: [] })).toBe(false);
101
+ });
102
+ it('should return false for an exited service with health: none', () => {
103
+ expect(isServiceHealthy({ name: 'worker', state: 'exited', health: 'none', ports: [] })).toBe(false);
104
+ });
105
+ it('should return false for an exited service with health: unhealthy', () => {
106
+ expect(isServiceHealthy({ name: 'worker', state: 'exited', health: 'unhealthy', ports: [] })).toBe(false);
107
+ });
108
+ it('should return false for a service with health: starting', () => {
109
+ expect(isServiceHealthy({ name: 'temporal', state: 'running', health: 'starting', ports: [] })).toBe(false);
110
+ });
111
+ });
112
+ describe('isServiceFailed', () => {
113
+ it('should return true for an exited service with health: none', () => {
114
+ expect(isServiceFailed({ name: 'worker', state: 'exited', health: 'none', ports: [] })).toBe(true);
115
+ });
116
+ it('should return true for a running service with health: unhealthy', () => {
117
+ expect(isServiceFailed({ name: 'worker', state: 'running', health: 'unhealthy', ports: [] })).toBe(true);
118
+ });
119
+ it('should return true for an exited service with health: unhealthy', () => {
120
+ expect(isServiceFailed({ name: 'worker', state: 'exited', health: 'unhealthy', ports: [] })).toBe(true);
121
+ });
122
+ it('should return false for a running service with health: healthy', () => {
123
+ expect(isServiceFailed({ name: 'redis', state: 'running', health: 'healthy', ports: [] })).toBe(false);
124
+ });
125
+ it('should return false for a running service with health: none', () => {
126
+ expect(isServiceFailed({ name: 'api', state: 'running', health: 'none', ports: [] })).toBe(false);
127
+ });
128
+ it('should return false for a service with health: starting — not a failure, just in progress', () => {
129
+ expect(isServiceFailed({ name: 'temporal', state: 'running', health: 'starting', ports: [] })).toBe(false);
130
+ });
131
+ });
92
132
  describe('waitForServicesHealthy', () => {
93
133
  it('should resolve when all services are healthy', async () => {
94
134
  const mockOutput = `{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}
@@ -108,6 +148,24 @@ describe('docker service', () => {
108
148
  const promise = waitForServicesHealthy('/path/to/docker-compose.yml', 100);
109
149
  await expect(promise).rejects.toThrow('Timeout waiting for services to become healthy');
110
150
  }, 10000);
151
+ it('should not resolve when a service has exited with no health check — regression OUT-334', async () => {
152
+ // Exited containers have empty Health which parses to 'none'.
153
+ // Previously, state:exited + health:none was incorrectly treated as healthy.
154
+ const mockOutput = `{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}
155
+ {"Service":"worker","State":"exited","Health":"","Publishers":[]}`;
156
+ vi.mocked(execFileSync).mockReturnValue(mockOutput);
157
+ const promise = waitForServicesHealthy('/path/to/docker-compose.yml', 100, 50);
158
+ await expect(promise).rejects.toThrow('Timeout waiting for services to become healthy');
159
+ }, 10000);
160
+ it('should not resolve when a service is running but unhealthy — regression OUT-334', async () => {
161
+ // Nodemon keeps the container running even when the exec'd command fails,
162
+ // so the unhealthy case is state:running + health:unhealthy.
163
+ const mockOutput = `{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}
164
+ {"Service":"worker","State":"running","Health":"unhealthy","Publishers":[]}`;
165
+ vi.mocked(execFileSync).mockReturnValue(mockOutput);
166
+ const promise = waitForServicesHealthy('/path/to/docker-compose.yml', 100, 50);
167
+ await expect(promise).rejects.toThrow('Timeout waiting for services to become healthy');
168
+ }, 10000);
111
169
  it('should poll multiple times until healthy', async () => {
112
170
  const callTracker = { count: 0 };
113
171
  vi.mocked(execFileSync).mockImplementation(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.11-next.49171f5.0",
4
4
  "description": "CLI for Output.ai workflow generation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -33,9 +33,9 @@
33
33
  "log-update": "7.2.0",
34
34
  "semver": "7.7.4",
35
35
  "yaml": "^2.8.3",
36
- "@outputai/credentials": "0.1.10",
37
- "@outputai/llm": "0.1.10",
38
- "@outputai/evals": "0.1.10"
36
+ "@outputai/credentials": "0.1.11-next.49171f5.0",
37
+ "@outputai/llm": "0.1.11-next.49171f5.0",
38
+ "@outputai/evals": "0.1.11-next.49171f5.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/cli-progress": "3.11.6",