@output.ai/cli 0.5.2 → 0.5.4

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.
@@ -7,5 +7,7 @@ export default class Dev extends Command {
7
7
  'compose-file': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
8
8
  'no-watch': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
9
9
  };
10
+ private dockerProcess;
10
11
  run(): Promise<void>;
12
+ private pollServiceStatus;
11
13
  }
@@ -1,8 +1,46 @@
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 { validateDockerEnvironment, startDockerCompose, DockerComposeConfigNotFoundError, getDefaultDockerComposePath } from '#services/docker.js';
4
+ import logUpdate from 'log-update';
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';
7
+ import { getDevSuccessMessage } from '#services/messages.js';
8
+ const ANSI = {
9
+ RESET: '\x1b[0m',
10
+ DIM: '\x1b[2m',
11
+ BOLD: '\x1b[1m',
12
+ CYAN: '\x1b[36m'
13
+ };
14
+ const STATUS_ICONS = {
15
+ [SERVICE_HEALTH.HEALTHY]: '●',
16
+ [SERVICE_HEALTH.UNHEALTHY]: '○',
17
+ [SERVICE_HEALTH.STARTING]: '◐',
18
+ [SERVICE_HEALTH.NONE]: '●',
19
+ [SERVICE_STATE.RUNNING]: '●'
20
+ };
21
+ const STATUS_COLORS = {
22
+ [SERVICE_HEALTH.HEALTHY]: '\x1b[32m',
23
+ [SERVICE_HEALTH.UNHEALTHY]: '\x1b[31m',
24
+ [SERVICE_HEALTH.STARTING]: '\x1b[33m',
25
+ [SERVICE_HEALTH.NONE]: '\x1b[34m',
26
+ [SERVICE_STATE.RUNNING]: '\x1b[34m'
27
+ };
28
+ const formatService = (service) => {
29
+ const healthKey = service.health === SERVICE_HEALTH.NONE ? service.state : service.health;
30
+ const icon = STATUS_ICONS[healthKey] || '?';
31
+ const color = STATUS_COLORS[healthKey] || '';
32
+ const ports = service.ports.length ? service.ports.join(', ') : '-';
33
+ const status = service.health === SERVICE_HEALTH.NONE ? service.state : service.health;
34
+ const name = service.name.padEnd(15);
35
+ const statusPadded = status.padEnd(10);
36
+ return ` ${color}${icon}${ANSI.RESET} ${name} ${ANSI.DIM}${statusPadded}${ANSI.RESET} ${ANSI.DIM}${ports}${ANSI.RESET}`;
37
+ };
38
+ const poll = async (fn, intervalMs) => {
39
+ for (;;) {
40
+ await fn();
41
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
42
+ }
43
+ };
6
44
  export default class Dev extends Command {
7
45
  static description = 'Start Output development services (auto-restarts worker on file changes)';
8
46
  static examples = [
@@ -22,6 +60,7 @@ export default class Dev extends Command {
22
60
  default: false
23
61
  })
24
62
  };
63
+ dockerProcess = null;
25
64
  async run() {
26
65
  const { flags } = await this.parse(Dev);
27
66
  validateDockerEnvironment();
@@ -44,11 +83,51 @@ export default class Dev extends Command {
44
83
  else {
45
84
  this.log('ℹ️ File watching disabled (--no-watch flag used)\n');
46
85
  }
86
+ const cleanup = async () => {
87
+ this.log('\n');
88
+ if (this.dockerProcess) {
89
+ this.dockerProcess.kill('SIGTERM');
90
+ }
91
+ await stopDockerCompose(dockerComposePath);
92
+ process.exit(0);
93
+ };
94
+ process.on('SIGINT', cleanup);
95
+ process.on('SIGTERM', cleanup);
47
96
  try {
48
- await startDockerCompose(dockerComposePath, !flags['no-watch']);
97
+ const { process: dockerProc, waitForHealthy } = await startDockerCompose(dockerComposePath, !flags['no-watch']);
98
+ this.dockerProcess = dockerProc;
99
+ dockerProc.on('error', error => {
100
+ this.error(`Docker process error: ${getErrorMessage(error)}`, { exit: 1 });
101
+ });
102
+ this.log('⏳ Waiting for services to become healthy...\n');
103
+ await waitForHealthy();
104
+ const services = await getServiceStatus(dockerComposePath);
105
+ this.log(getDevSuccessMessage(services));
106
+ await this.pollServiceStatus(dockerComposePath);
49
107
  }
50
108
  catch (error) {
51
109
  this.error(getErrorMessage(error), { exit: 1 });
52
110
  }
53
111
  }
112
+ async pollServiceStatus(dockerComposePath) {
113
+ const outputServiceStatus = async () => {
114
+ try {
115
+ const services = await getServiceStatus(dockerComposePath);
116
+ const lines = [
117
+ `${ANSI.BOLD}📊 Service Status${ANSI.RESET}`,
118
+ '',
119
+ ...services.map(formatService),
120
+ '',
121
+ `${ANSI.CYAN}🌐 Temporal UI:${ANSI.RESET} ${ANSI.BOLD}http://localhost:8080${ANSI.RESET}`,
122
+ '',
123
+ `${ANSI.DIM}Press Ctrl+C to stop services${ANSI.RESET}`
124
+ ];
125
+ logUpdate(lines.join('\n'));
126
+ }
127
+ catch {
128
+ // silent retry on next poll
129
+ }
130
+ };
131
+ await poll(outputServiceStatus, 2000);
132
+ }
54
133
  }
@@ -5,21 +5,47 @@ import * as dockerService from '#services/docker.js';
5
5
  import Dev from './index.js';
6
6
  vi.mock('#services/docker.js', () => ({
7
7
  validateDockerEnvironment: vi.fn(),
8
- startDockerCompose: vi.fn().mockResolvedValue(undefined),
8
+ startDockerCompose: vi.fn(),
9
+ stopDockerCompose: vi.fn().mockResolvedValue(undefined),
10
+ getServiceStatus: vi.fn().mockResolvedValue([
11
+ { name: 'redis', state: 'running', health: 'healthy', ports: ['6379:6379'] },
12
+ { name: 'temporal', state: 'running', health: 'healthy', ports: ['7233:7233'] }
13
+ ]),
9
14
  DockerComposeConfigNotFoundError: Error,
10
15
  DockerValidationError: Error,
11
- getDefaultDockerComposePath: vi.fn(() => '/path/to/docker-compose-dev.yml')
16
+ getDefaultDockerComposePath: vi.fn(() => '/path/to/docker-compose-dev.yml'),
17
+ SERVICE_HEALTH: {
18
+ HEALTHY: 'healthy',
19
+ UNHEALTHY: 'unhealthy',
20
+ STARTING: 'starting',
21
+ NONE: 'none'
22
+ },
23
+ SERVICE_STATE: {
24
+ RUNNING: 'running',
25
+ EXITED: 'exited'
26
+ }
12
27
  }));
13
28
  vi.mock('node:fs/promises', () => ({
14
29
  default: {
15
30
  access: vi.fn()
16
31
  }
17
32
  }));
33
+ const createMockDockerProcess = () => ({
34
+ process: {
35
+ on: vi.fn(),
36
+ kill: vi.fn(),
37
+ stdout: { on: vi.fn() },
38
+ stderr: { on: vi.fn() }
39
+ },
40
+ waitForHealthy: vi.fn().mockResolvedValue(undefined)
41
+ });
18
42
  describe('dev command', () => {
19
43
  beforeEach(() => {
20
44
  vi.clearAllMocks();
21
45
  // By default, docker validation succeeds
22
46
  vi.mocked(dockerService.validateDockerEnvironment).mockResolvedValue(undefined);
47
+ // By default, startDockerCompose returns a mock process
48
+ vi.mocked(dockerService.startDockerCompose).mockResolvedValue(createMockDockerProcess());
23
49
  // By default, fs.access succeeds (file exists)
24
50
  vi.mocked(fs).access.mockResolvedValue(undefined);
25
51
  });
@@ -107,10 +133,15 @@ describe('dev command', () => {
107
133
  value: vi.fn().mockResolvedValue({ flags: { 'no-watch': false, 'compose-file': undefined }, args: {} }),
108
134
  configurable: true
109
135
  });
110
- await cmd.run();
136
+ // Run the command but don't await it since it waits forever after startup
137
+ const runPromise = cmd.run();
138
+ // Wait a tick for startDockerCompose to be called
139
+ await new Promise(resolve => setImmediate(resolve));
111
140
  expect(dockerService.startDockerCompose).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml', true // enableWatch should be true
112
141
  );
113
142
  expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('File watching enabled'));
143
+ // Cancel the promise (it will be rejected but we don't care)
144
+ runPromise.catch(() => { });
114
145
  });
115
146
  it('should disable watch with --no-watch flag', async () => {
116
147
  const cmd = new Dev(['--no-watch'], {});
@@ -121,10 +152,15 @@ describe('dev command', () => {
121
152
  value: vi.fn().mockResolvedValue({ flags: { 'no-watch': true, 'compose-file': undefined }, args: {} }),
122
153
  configurable: true
123
154
  });
124
- await cmd.run();
155
+ // Run the command but don't await it since it waits forever after startup
156
+ const runPromise = cmd.run();
157
+ // Wait a tick for startDockerCompose to be called
158
+ await new Promise(resolve => setImmediate(resolve));
125
159
  expect(dockerService.startDockerCompose).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml', false // enableWatch should be false
126
160
  );
127
161
  expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('File watching disabled'));
162
+ // Cancel the promise (it will be rejected but we don't care)
163
+ runPromise.catch(() => { });
128
164
  });
129
165
  it('should handle docker compose configuration not found', async () => {
130
166
  vi.mocked(fs).access.mockRejectedValue(new Error('File not found'));
package/dist/config.d.ts CHANGED
@@ -16,6 +16,11 @@ export declare const config: {
16
16
  * Default timeout for API requests (in milliseconds)
17
17
  */
18
18
  requestTimeout: number;
19
+ /**
20
+ * Docker Compose project name
21
+ * Can be overridden with DOCKER_SERVICE_NAME environment variable
22
+ */
23
+ dockerServiceName: string;
19
24
  };
20
25
  /**
21
26
  * Agent configuration directory name
package/dist/config.js CHANGED
@@ -15,7 +15,12 @@ export const config = {
15
15
  /**
16
16
  * Default timeout for API requests (in milliseconds)
17
17
  */
18
- requestTimeout: 30000
18
+ requestTimeout: 30000,
19
+ /**
20
+ * Docker Compose project name
21
+ * Can be overridden with DOCKER_SERVICE_NAME environment variable
22
+ */
23
+ dockerServiceName: process.env.DOCKER_SERVICE_NAME || 'output-sdk'
19
24
  };
20
25
  /**
21
26
  * Agent configuration directory name
@@ -1,5 +1,22 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ export declare const SERVICE_HEALTH: {
3
+ readonly HEALTHY: "healthy";
4
+ readonly UNHEALTHY: "unhealthy";
5
+ readonly STARTING: "starting";
6
+ readonly NONE: "none";
7
+ };
8
+ export declare const SERVICE_STATE: {
9
+ readonly RUNNING: "running";
10
+ readonly EXITED: "exited";
11
+ };
1
12
  declare class DockerValidationError extends Error {
2
13
  }
14
+ export interface ServiceStatus {
15
+ name: string;
16
+ state: string;
17
+ health: string;
18
+ ports: string[];
19
+ }
3
20
  export declare class DockerComposeConfigNotFoundError extends Error {
4
21
  constructor(dockerComposePath: string);
5
22
  }
@@ -7,6 +24,14 @@ declare const isDockerInstalled: () => boolean;
7
24
  declare const isDockerComposeAvailable: () => boolean;
8
25
  declare const isDockerDaemonRunning: () => boolean;
9
26
  export declare function validateDockerEnvironment(): void;
10
- export declare function startDockerCompose(dockerComposePath: string, enableWatch?: boolean): Promise<void>;
11
27
  export declare function getDefaultDockerComposePath(): string;
28
+ export declare function parseServiceStatus(jsonOutput: string): ServiceStatus[];
29
+ export declare function getServiceStatus(dockerComposePath: string): Promise<ServiceStatus[]>;
30
+ export declare function waitForServicesHealthy(dockerComposePath: string, timeoutMs?: number, pollIntervalMs?: number): Promise<void>;
31
+ export interface DockerComposeProcess {
32
+ process: ChildProcess;
33
+ waitForHealthy: () => Promise<void>;
34
+ }
35
+ export declare function startDockerCompose(dockerComposePath: string, enableWatch?: boolean): Promise<DockerComposeProcess>;
36
+ export declare function stopDockerCompose(dockerComposePath: string): Promise<void>;
12
37
  export { isDockerInstalled, isDockerComposeAvailable, isDockerDaemonRunning, DockerValidationError };
@@ -1,7 +1,19 @@
1
- import { execSync, spawn } from 'node:child_process';
1
+ 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';
6
+ const DEFAULT_COMPOSE_PATH = '../assets/docker/docker-compose-dev.yml';
7
+ export const SERVICE_HEALTH = {
8
+ HEALTHY: 'healthy',
9
+ UNHEALTHY: 'unhealthy',
10
+ STARTING: 'starting',
11
+ NONE: 'none'
12
+ };
13
+ export const SERVICE_STATE = {
14
+ RUNNING: 'running',
15
+ EXITED: 'exited'
16
+ };
5
17
  class DockerValidationError extends Error {
6
18
  }
7
19
  export class DockerComposeConfigNotFoundError extends Error {
@@ -42,6 +54,73 @@ export function validateDockerEnvironment() {
42
54
  throw new DockerValidationError(failedValidation.error);
43
55
  }
44
56
  }
57
+ export function getDefaultDockerComposePath() {
58
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), DEFAULT_COMPOSE_PATH);
59
+ }
60
+ export function parseServiceStatus(jsonOutput) {
61
+ if (!jsonOutput.trim()) {
62
+ return [];
63
+ }
64
+ return jsonOutput
65
+ .trim()
66
+ .split('\n')
67
+ .filter(Boolean)
68
+ .map(line => {
69
+ const data = JSON.parse(line);
70
+ return {
71
+ name: data.Service || data.Name || 'unknown',
72
+ state: data.State,
73
+ health: data.Health || SERVICE_HEALTH.NONE,
74
+ ports: data.Publishers?.map(p => `${p.PublishedPort}:${p.TargetPort}`) || []
75
+ };
76
+ });
77
+ }
78
+ export async function getServiceStatus(dockerComposePath) {
79
+ const result = execFileSync('docker', ['compose', '-f', dockerComposePath, 'ps', '--format', 'json'], { encoding: 'utf-8', cwd: process.cwd() });
80
+ return parseServiceStatus(result);
81
+ }
82
+ const STATUS_ICONS = {
83
+ [SERVICE_HEALTH.HEALTHY]: '✓',
84
+ [SERVICE_HEALTH.UNHEALTHY]: '✗',
85
+ [SERVICE_HEALTH.STARTING]: '◐',
86
+ [SERVICE_HEALTH.NONE]: '✓',
87
+ [SERVICE_STATE.RUNNING]: '●',
88
+ [SERVICE_STATE.EXITED]: '✗'
89
+ };
90
+ const STATUS_COLORS = {
91
+ [SERVICE_HEALTH.HEALTHY]: '\x1b[32m',
92
+ [SERVICE_HEALTH.UNHEALTHY]: '\x1b[31m',
93
+ [SERVICE_HEALTH.STARTING]: '\x1b[33m',
94
+ [SERVICE_HEALTH.NONE]: '\x1b[32m',
95
+ [SERVICE_STATE.RUNNING]: '\x1b[34m',
96
+ [SERVICE_STATE.EXITED]: '\x1b[31m'
97
+ };
98
+ const ANSI_RESET = '\x1b[0m';
99
+ const formatServiceStatus = (services) => services.map(s => {
100
+ const healthKey = s.health === SERVICE_HEALTH.NONE ? s.state : s.health;
101
+ const icon = STATUS_ICONS[healthKey] || '?';
102
+ const color = STATUS_COLORS[healthKey] || '';
103
+ const status = s.health === SERVICE_HEALTH.NONE ? s.state : s.health;
104
+ return ` ${color}${icon}${ANSI_RESET} ${s.name}: ${status}`;
105
+ }).join('\n');
106
+ export async function waitForServicesHealthy(dockerComposePath, timeoutMs = 120000, pollIntervalMs = 2000) {
107
+ const startTime = Date.now();
108
+ while (Date.now() - startTime < timeoutMs) {
109
+ const services = await getServiceStatus(dockerComposePath);
110
+ const allHealthy = services.every(s => s.health === SERVICE_HEALTH.HEALTHY || s.health === SERVICE_HEALTH.NONE);
111
+ if (services.length > 0) {
112
+ const statusLines = formatServiceStatus(services);
113
+ logUpdate(`⏳ Waiting for services to become healthy...\n${statusLines}`);
114
+ }
115
+ if (allHealthy && services.length > 0) {
116
+ logUpdate.done();
117
+ return;
118
+ }
119
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
120
+ }
121
+ logUpdate.done();
122
+ throw new Error('Timeout waiting for services to become healthy');
123
+ }
45
124
  export async function startDockerCompose(dockerComposePath, enableWatch = false) {
46
125
  const args = [
47
126
  'compose',
@@ -52,28 +131,18 @@ export async function startDockerCompose(dockerComposePath, enableWatch = false)
52
131
  if (enableWatch) {
53
132
  args.push('--watch');
54
133
  }
55
- const childProcess = spawn('docker', args, {
56
- stdio: 'inherit',
57
- cwd: process.cwd()
134
+ ux.stdout('🐳 Starting Docker services...\n');
135
+ const dockerProcess = spawn('docker', args, {
136
+ cwd: process.cwd(),
137
+ stdio: ['ignore', 'pipe', 'pipe']
58
138
  });
59
- const cleanup = () => {
60
- ux.stdout('⏹️ Stopping services...\n');
61
- childProcess.kill('SIGTERM');
139
+ return {
140
+ process: dockerProcess,
141
+ waitForHealthy: () => waitForServicesHealthy(dockerComposePath)
62
142
  };
63
- process.on('SIGINT', cleanup);
64
- process.on('SIGTERM', cleanup);
65
- return new Promise((resolve, reject) => {
66
- childProcess.on('exit', _code => {
67
- process.removeListener('SIGINT', cleanup);
68
- process.removeListener('SIGTERM', cleanup);
69
- resolve();
70
- });
71
- childProcess.on('error', error => {
72
- reject(new Error(`Failed to start services: ${error.message}`));
73
- });
74
- });
75
143
  }
76
- export function getDefaultDockerComposePath() {
77
- return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../assets/docker/docker-compose-dev.yml');
144
+ export async function stopDockerCompose(dockerComposePath) {
145
+ ux.stdout('⏹️ Stopping services...\n');
146
+ execFileSync('docker', ['compose', '-f', dockerComposePath, 'down'], { stdio: 'inherit', cwd: process.cwd() });
78
147
  }
79
148
  export { isDockerInstalled, isDockerComposeAvailable, isDockerDaemonRunning, DockerValidationError };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { parseServiceStatus, getServiceStatus, waitForServicesHealthy } from './docker.js';
4
+ vi.mock('node:child_process', () => ({
5
+ execSync: vi.fn(),
6
+ execFileSync: vi.fn(),
7
+ spawn: vi.fn()
8
+ }));
9
+ vi.mock('log-update', () => {
10
+ const fn = vi.fn();
11
+ fn.done = vi.fn();
12
+ return { default: fn };
13
+ });
14
+ describe('docker service', () => {
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ });
18
+ afterEach(() => {
19
+ vi.restoreAllMocks();
20
+ });
21
+ describe('parseServiceStatus', () => {
22
+ it('should parse single service JSON output', () => {
23
+ const jsonOutput = '{"Service":"redis","State":"running","Health":"healthy","Publishers":[{"PublishedPort":6379,"TargetPort":6379}]}';
24
+ const result = parseServiceStatus(jsonOutput);
25
+ expect(result).toHaveLength(1);
26
+ expect(result[0]).toEqual({
27
+ name: 'redis',
28
+ state: 'running',
29
+ health: 'healthy',
30
+ ports: ['6379:6379']
31
+ });
32
+ });
33
+ it('should parse multiple services from JSON lines output', () => {
34
+ const jsonOutput = `{"Service":"redis","State":"running","Health":"healthy","Publishers":[{"PublishedPort":6379,"TargetPort":6379}]}
35
+ {"Service":"temporal","State":"running","Health":"healthy","Publishers":[{"PublishedPort":7233,"TargetPort":7233}]}
36
+ {"Service":"temporal-ui","State":"running","Health":"","Publishers":[{"PublishedPort":8080,"TargetPort":8080}]}`;
37
+ const result = parseServiceStatus(jsonOutput);
38
+ expect(result).toHaveLength(3);
39
+ expect(result[0].name).toBe('redis');
40
+ expect(result[1].name).toBe('temporal');
41
+ expect(result[2].name).toBe('temporal-ui');
42
+ });
43
+ it('should handle empty health status', () => {
44
+ const jsonOutput = '{"Service":"api","State":"running","Health":"","Publishers":[]}';
45
+ const result = parseServiceStatus(jsonOutput);
46
+ expect(result[0].health).toBe('none');
47
+ });
48
+ it('should handle missing Publishers array', () => {
49
+ const jsonOutput = '{"Service":"worker","State":"running","Health":"healthy"}';
50
+ const result = parseServiceStatus(jsonOutput);
51
+ expect(result[0].ports).toEqual([]);
52
+ });
53
+ it('should handle empty output', () => {
54
+ const result = parseServiceStatus('');
55
+ expect(result).toEqual([]);
56
+ });
57
+ it('should filter out empty lines', () => {
58
+ const jsonOutput = `{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}
59
+
60
+ {"Service":"api","State":"running","Health":"","Publishers":[]}
61
+ `;
62
+ const result = parseServiceStatus(jsonOutput);
63
+ expect(result).toHaveLength(2);
64
+ });
65
+ it('should use Name field as fallback when Service is missing', () => {
66
+ const jsonOutput = '{"Name":"output-sdk-redis-1","State":"running","Health":"healthy","Publishers":[]}';
67
+ const result = parseServiceStatus(jsonOutput);
68
+ expect(result[0].name).toBe('output-sdk-redis-1');
69
+ });
70
+ });
71
+ describe('getServiceStatus', () => {
72
+ it('should call docker compose ps with correct arguments', async () => {
73
+ const mockOutput = '{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}';
74
+ vi.mocked(execFileSync).mockReturnValue(mockOutput);
75
+ await getServiceStatus('/path/to/docker-compose.yml');
76
+ expect(execFileSync).toHaveBeenCalledWith('docker', ['compose', '-f', '/path/to/docker-compose.yml', 'ps', '--format', 'json'], expect.objectContaining({ encoding: 'utf-8' }));
77
+ });
78
+ it('should return parsed service status', async () => {
79
+ const mockOutput = '{"Service":"redis","State":"running","Health":"healthy","Publishers":[{"PublishedPort":6379,"TargetPort":6379}]}';
80
+ vi.mocked(execFileSync).mockReturnValue(mockOutput);
81
+ const result = await getServiceStatus('/path/to/docker-compose.yml');
82
+ expect(result).toHaveLength(1);
83
+ expect(result[0].name).toBe('redis');
84
+ });
85
+ it('should throw error when docker compose command fails', async () => {
86
+ vi.mocked(execFileSync).mockImplementation(() => {
87
+ throw new Error('Docker command failed');
88
+ });
89
+ await expect(getServiceStatus('/path/to/docker-compose.yml')).rejects.toThrow();
90
+ });
91
+ });
92
+ describe('waitForServicesHealthy', () => {
93
+ it('should resolve when all services are healthy', async () => {
94
+ const mockOutput = `{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}
95
+ {"Service":"temporal","State":"running","Health":"healthy","Publishers":[]}`;
96
+ vi.mocked(execFileSync).mockReturnValue(mockOutput);
97
+ await expect(waitForServicesHealthy('/path/to/docker-compose.yml', 5000)).resolves.toBeUndefined();
98
+ });
99
+ it('should resolve when services have no health check (health: none)', async () => {
100
+ const mockOutput = `{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}
101
+ {"Service":"api","State":"running","Health":"","Publishers":[]}`;
102
+ vi.mocked(execFileSync).mockReturnValue(mockOutput);
103
+ await expect(waitForServicesHealthy('/path/to/docker-compose.yml', 5000)).resolves.toBeUndefined();
104
+ });
105
+ it('should timeout when services remain unhealthy', async () => {
106
+ const mockOutput = '{"Service":"redis","State":"running","Health":"starting","Publishers":[]}';
107
+ vi.mocked(execFileSync).mockReturnValue(mockOutput);
108
+ const promise = waitForServicesHealthy('/path/to/docker-compose.yml', 100);
109
+ await expect(promise).rejects.toThrow('Timeout waiting for services to become healthy');
110
+ }, 10000);
111
+ it('should poll multiple times until healthy', async () => {
112
+ const callTracker = { count: 0 };
113
+ vi.mocked(execFileSync).mockImplementation(() => {
114
+ callTracker.count++;
115
+ if (callTracker.count < 3) {
116
+ return '{"Service":"redis","State":"running","Health":"starting","Publishers":[]}';
117
+ }
118
+ return '{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}';
119
+ });
120
+ await waitForServicesHealthy('/path/to/docker-compose.yml', 10000, 50);
121
+ expect(callTracker.count).toBeGreaterThanOrEqual(3);
122
+ });
123
+ });
124
+ });
@@ -4,3 +4,6 @@
4
4
  export declare const getEjectSuccessMessage: (destPath: string, outputFile: string, binName: string) => string;
5
5
  export declare const getProjectSuccessMessage: (folderName: string, installSuccess: boolean, envConfigured?: boolean) => string;
6
6
  export declare const getWorkflowGenerateSuccessMessage: (workflowName: string, targetDir: string, filesCreated: string[]) => string;
7
+ export declare const getDevSuccessMessage: (services: Array<{
8
+ name: string;
9
+ }>) => string;
@@ -2,6 +2,7 @@
2
2
  * Success and informational messages for project initialization
3
3
  */
4
4
  import { ux } from '@oclif/core';
5
+ import { config } from '#config.js';
5
6
  /**
6
7
  * Creates a colored ASCII art banner for Output.ai
7
8
  */
@@ -295,3 +296,43 @@ ${ux.colorize('dim', '💡 Tip: Check the README.md in your workflow directory f
295
296
  ${ux.colorize('green', ux.colorize('bold', 'Happy building! 🛠️'))}
296
297
  `;
297
298
  };
299
+ export const getDevSuccessMessage = (services) => {
300
+ const divider = ux.colorize('dim', '─'.repeat(80));
301
+ const bulletPoint = ux.colorize('green', '▸');
302
+ const serviceNames = services.map(s => s.name).sort().join('|');
303
+ const logsCommand = `docker compose -p ${config.dockerServiceName} logs -f <${serviceNames}>`;
304
+ return `
305
+ ${divider}
306
+
307
+ ${ux.colorize('bold', ux.colorize('green', '✅ SUCCESS!'))} ${ux.colorize('bold', 'Development services are running')}
308
+
309
+ ${divider}
310
+
311
+ ${createSectionHeader('SERVICES', '🐳')}
312
+
313
+ ${bulletPoint} ${ux.colorize('white', 'Temporal:')} ${formatPath('localhost:7233')}
314
+ ${bulletPoint} ${ux.colorize('white', 'Temporal UI:')} ${formatCommand('http://localhost:8080')}
315
+ ${bulletPoint} ${ux.colorize('white', 'API Server:')} ${formatPath('localhost:3001')}
316
+ ${bulletPoint} ${ux.colorize('white', 'Redis:')} ${formatPath('localhost:6379')}
317
+
318
+ ${divider}
319
+
320
+ ${createSectionHeader('RUN A WORKFLOW', '🚀')}
321
+
322
+ ${ux.colorize('white', 'In a new terminal, execute:')}
323
+
324
+ ${formatCommand('output workflow run simple --input \'{"question": "Hello!"}\'')}
325
+
326
+ ${divider}
327
+
328
+ ${createSectionHeader('USEFUL COMMANDS', '⚡')}
329
+
330
+ ${bulletPoint} ${ux.colorize('white', 'Open Temporal UI:')} ${formatCommand('open http://localhost:8080')}
331
+ ${bulletPoint} ${ux.colorize('white', 'View logs:')} ${formatCommand(logsCommand)}
332
+ ${bulletPoint} ${ux.colorize('white', 'Stop services:')} ${formatCommand('Press Ctrl+C')}
333
+
334
+ ${divider}
335
+
336
+ ${ux.colorize('dim', '💡 Tip: The Temporal UI lets you monitor workflow executions in real-time')}
337
+ `;
338
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getDevSuccessMessage } from './messages.js';
3
+ const mockServices = [
4
+ { name: 'api' },
5
+ { name: 'postgresql' },
6
+ { name: 'redis' },
7
+ { name: 'temporal' },
8
+ { name: 'temporal-ui' },
9
+ { name: 'worker' }
10
+ ];
11
+ describe('messages', () => {
12
+ describe('getDevSuccessMessage', () => {
13
+ it('should return a string', () => {
14
+ const message = getDevSuccessMessage(mockServices);
15
+ expect(typeof message).toBe('string');
16
+ });
17
+ it('should include the Temporal UI URL', () => {
18
+ const message = getDevSuccessMessage(mockServices);
19
+ expect(message).toContain('http://localhost:8080');
20
+ });
21
+ it('should include the Temporal server address', () => {
22
+ const message = getDevSuccessMessage(mockServices);
23
+ expect(message).toContain('localhost:7233');
24
+ });
25
+ it('should include the API server address', () => {
26
+ const message = getDevSuccessMessage(mockServices);
27
+ expect(message).toContain('localhost:3001');
28
+ });
29
+ it('should include workflow run example', () => {
30
+ const message = getDevSuccessMessage(mockServices);
31
+ expect(message).toContain('output workflow run');
32
+ });
33
+ it('should include success indicator', () => {
34
+ const message = getDevSuccessMessage(mockServices);
35
+ expect(message).toContain('SUCCESS');
36
+ });
37
+ it('should include services section', () => {
38
+ const message = getDevSuccessMessage(mockServices);
39
+ expect(message).toContain('Temporal UI');
40
+ expect(message).toContain('API Server');
41
+ expect(message).toContain('Redis');
42
+ });
43
+ it('should include helpful tip about Temporal UI', () => {
44
+ const message = getDevSuccessMessage(mockServices);
45
+ expect(message).toContain('Temporal UI');
46
+ expect(message).toContain('workflow');
47
+ });
48
+ it('should include dynamic docker logs command with service names', () => {
49
+ const message = getDevSuccessMessage(mockServices);
50
+ expect(message).toContain('docker compose -p');
51
+ expect(message).toContain('logs -f');
52
+ expect(message).toContain('api|postgresql|redis|temporal|temporal-ui|worker');
53
+ });
54
+ });
55
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@output.ai/cli",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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": {