@outputai/cli 0.1.10 → 0.1.11-next.42e84b1.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.
package/bin/run.js CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  import { execute } from '@oclif/core';
4
4
  import { loadEnvironment } from '../dist/utils/env_loader.js';
5
+ import { resolveCredentialRefs } from '@outputai/credentials';
5
6
 
6
7
  // Load environment variables from .env files before executing CLI
7
8
  loadEnvironment();
9
+ resolveCredentialRefs();
8
10
 
9
11
  await execute( { dir: import.meta.url } );
@@ -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.42e84b1.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'
@@ -10,5 +10,4 @@ export default class Dev extends Command {
10
10
  };
11
11
  private dockerProcess;
12
12
  run(): Promise<void>;
13
- private pollServiceStatus;
14
13
  }
@@ -1,74 +1,12 @@
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';
5
- import { validateDockerEnvironment, startDockerCompose, startDockerComposeDetached, stopDockerCompose, getServiceStatus, DockerComposeConfigNotFoundError, getDefaultDockerComposePath, SERVICE_HEALTH, SERVICE_STATE } from '#services/docker.js';
4
+ import { render } from 'ink';
5
+ import React from 'react';
6
+ import { validateDockerEnvironment, startDockerCompose, startDockerComposeDetached, stopDockerCompose, DockerComposeConfigNotFoundError, getDefaultDockerComposePath } from '#services/docker.js';
6
7
  import { getErrorMessage } from '#utils/error_utils.js';
7
- import { getDevSuccessMessage } from '#services/messages.js';
8
8
  import { ensureClaudePlugin } from '#services/coding_agents.js';
9
- const ANSI = {
10
- RESET: '\x1b[0m',
11
- DIM: '\x1b[2m',
12
- BOLD: '\x1b[1m',
13
- CYAN: '\x1b[36m',
14
- RED: '\x1b[31m',
15
- YELLOW: '\x1b[33m',
16
- BG_RED: '\x1b[41m',
17
- WHITE: '\x1b[37m'
18
- };
19
- const STATUS_ICONS = {
20
- [SERVICE_HEALTH.HEALTHY]: '●',
21
- [SERVICE_HEALTH.UNHEALTHY]: '○',
22
- [SERVICE_HEALTH.STARTING]: '◐',
23
- [SERVICE_HEALTH.NONE]: '●',
24
- [SERVICE_STATE.RUNNING]: '●',
25
- [SERVICE_STATE.EXITED]: '✗'
26
- };
27
- const STATUS_COLORS = {
28
- [SERVICE_HEALTH.HEALTHY]: '\x1b[32m',
29
- [SERVICE_HEALTH.UNHEALTHY]: '\x1b[31m',
30
- [SERVICE_HEALTH.STARTING]: '\x1b[33m',
31
- [SERVICE_HEALTH.NONE]: '\x1b[34m',
32
- [SERVICE_STATE.RUNNING]: '\x1b[34m',
33
- [SERVICE_STATE.EXITED]: '\x1b[31m'
34
- };
35
- const formatService = (service) => {
36
- const healthKey = service.health === SERVICE_HEALTH.NONE ? service.state : service.health;
37
- const icon = STATUS_ICONS[healthKey] || '?';
38
- const color = STATUS_COLORS[healthKey] || '';
39
- const ports = service.ports.length ? service.ports.join(', ') : '-';
40
- const status = service.health === SERVICE_HEALTH.NONE ? service.state : service.health;
41
- const name = service.name.padEnd(15);
42
- const statusPadded = status.padEnd(10);
43
- return ` ${color}${icon}${ANSI.RESET} ${name} ${ANSI.DIM}${statusPadded}${ANSI.RESET} ${ANSI.DIM}${ports}${ANSI.RESET}`;
44
- };
45
- const getFailedServicesWarning = (services) => {
46
- const failedServices = services.filter(s => s.state === SERVICE_STATE.EXITED);
47
- if (failedServices.length === 0) {
48
- return [];
49
- }
50
- const failedNames = failedServices.map(s => s.name);
51
- const hasWorkerFailed = failedNames.some(name => name.toLowerCase().includes('worker'));
52
- const warningLines = [
53
- '',
54
- `${ANSI.BG_RED}${ANSI.WHITE}${ANSI.BOLD} ⚠️ SERVICE FAILURE DETECTED ${ANSI.RESET}`,
55
- '',
56
- `${ANSI.RED}${ANSI.BOLD}Failed services:${ANSI.RESET} ${failedNames.join(', ')}`
57
- ];
58
- if (hasWorkerFailed) {
59
- warningLines.push('', `${ANSI.YELLOW}${ANSI.BOLD}⚡ The worker is not running!${ANSI.RESET}`, `${ANSI.YELLOW} Workflows will fail until the worker is restarted.${ANSI.RESET}`, '', `${ANSI.DIM}Check the logs with: docker compose logs worker${ANSI.RESET}`);
60
- }
61
- else {
62
- warningLines.push('', `${ANSI.DIM}Check the logs with: docker compose logs <service-name>${ANSI.RESET}`);
63
- }
64
- return warningLines;
65
- };
66
- const poll = async (fn, intervalMs) => {
67
- for (;;) {
68
- await fn();
69
- await new Promise(resolve => setTimeout(resolve, intervalMs));
70
- }
71
- };
9
+ import { DevApp } from '#views/dev.js';
72
10
  export default class Dev extends Command {
73
11
  static description = 'Start Output development services (auto-restarts worker on file changes)';
74
12
  static examples = [
@@ -127,47 +65,24 @@ export default class Dev extends Command {
127
65
  this.dockerProcess.kill('SIGTERM');
128
66
  }
129
67
  await stopDockerCompose(dockerComposePath);
130
- process.exit(0);
131
68
  };
132
- process.on('SIGINT', cleanup);
133
- process.on('SIGTERM', cleanup);
134
69
  try {
135
- const { process: dockerProc, waitForHealthy } = await startDockerCompose(dockerComposePath, pullPolicy);
70
+ const { process: dockerProc } = await startDockerCompose(dockerComposePath, pullPolicy);
136
71
  this.dockerProcess = dockerProc;
72
+ const instance = render(React.createElement(DevApp, { dockerComposePath, onCleanup: cleanup }), { exitOnCtrlC: false });
137
73
  dockerProc.on('error', error => {
138
- this.error(`Docker process error: ${getErrorMessage(error)}`, { exit: 1 });
74
+ instance.unmount(new Error(`Docker process error: ${getErrorMessage(error)}`));
139
75
  });
140
- this.log('⏳ Waiting for services to become healthy...\n');
141
- await waitForHealthy();
142
- const services = await getServiceStatus(dockerComposePath);
143
- this.log(getDevSuccessMessage(services));
144
- await this.pollServiceStatus(dockerComposePath);
76
+ const handleSignal = async () => {
77
+ await cleanup();
78
+ instance.unmount();
79
+ };
80
+ process.on('SIGINT', handleSignal);
81
+ process.on('SIGTERM', handleSignal);
82
+ await instance.waitUntilExit();
145
83
  }
146
84
  catch (error) {
147
85
  this.error(getErrorMessage(error), { exit: 1 });
148
86
  }
149
87
  }
150
- async pollServiceStatus(dockerComposePath) {
151
- const outputServiceStatus = async () => {
152
- try {
153
- const services = await getServiceStatus(dockerComposePath);
154
- const failureWarning = getFailedServicesWarning(services);
155
- const lines = [
156
- `${ANSI.BOLD}📊 Service Status${ANSI.RESET}`,
157
- '',
158
- ...services.map(formatService),
159
- ...failureWarning,
160
- '',
161
- `${ANSI.CYAN}🌐 Temporal UI:${ANSI.RESET} ${ANSI.BOLD}http://localhost:8080${ANSI.RESET}`,
162
- '',
163
- `${ANSI.DIM}Press Ctrl+C to stop services${ANSI.RESET}`
164
- ];
165
- logUpdate(lines.join('\n'));
166
- }
167
- catch {
168
- // silent retry on next poll
169
- }
170
- };
171
- await poll(outputServiceStatus, 2000);
172
- }
173
88
  }
@@ -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'),
@@ -34,14 +35,22 @@ vi.mock('node:fs/promises', () => ({
34
35
  access: vi.fn()
35
36
  }
36
37
  }));
38
+ vi.mock('ink', () => ({
39
+ render: vi.fn().mockReturnValue({
40
+ waitUntilExit: vi.fn().mockResolvedValue(undefined),
41
+ unmount: vi.fn()
42
+ })
43
+ }));
44
+ vi.mock('#views/dev.js', () => ({
45
+ DevApp: () => null
46
+ }));
37
47
  const createMockDockerProcess = () => ({
38
48
  process: {
39
49
  on: vi.fn(),
40
50
  kill: vi.fn(),
41
51
  stdout: { on: vi.fn() },
42
52
  stderr: { on: vi.fn() }
43
- },
44
- waitForHealthy: vi.fn().mockResolvedValue(undefined)
53
+ }
45
54
  });
46
55
  describe('dev command', () => {
47
56
  beforeEach(() => {
@@ -1,6 +1,7 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
2
  import { UserCancelledError } from '#types/errors.js';
3
3
  import { runInit } from '#services/project_scaffold.js';
4
+ import { getErrorMessage } from '#utils/error_utils.js';
4
5
  export default class Init extends Command {
5
6
  static description = 'Initialize a new Output project by scaffolding the complete project structure';
6
7
  static examples = [
@@ -30,8 +31,7 @@ export default class Init extends Command {
30
31
  return;
31
32
  }
32
33
  // runInit handles cleanup internally and throws Error with message
33
- const errorMessage = error instanceof Error ? error.message : String(error);
34
- this.error(errorMessage);
34
+ this.error(getErrorMessage(error));
35
35
  }
36
36
  }
37
37
  }
@@ -5,6 +5,8 @@ import { ensureOutputAISystem } from '#services/coding_agents.js';
5
5
  import { getWorkflowGenerateSuccessMessage } from '#services/messages.js';
6
6
  import { DEFAULT_OUTPUT_DIRS } from '#utils/paths.js';
7
7
  import path from 'node:path';
8
+ import * as fsSync from 'node:fs';
9
+ import { getErrorMessage } from '#utils/error_utils.js';
8
10
  export default class Generate extends Command {
9
11
  static description = 'Generate a new Output workflow from a skeleton or plan file';
10
12
  static examples = [
@@ -52,31 +54,33 @@ export default class Generate extends Command {
52
54
  if (!flags.skeleton && !planFile) {
53
55
  this.error('Full workflow generation not implemented yet. Please use --skeleton flag or --plan-file');
54
56
  }
55
- try {
56
- const result = await generateWorkflow({
57
- name: args.name,
58
- description: flags.description,
59
- outputDir: flags['output-dir'],
60
- skeleton: flags.skeleton,
61
- force: flags.force
62
- });
63
- if (planFile) {
64
- this.log('\nStarting AI-assisted workflow implementation...\n');
65
- const projectRoot = process.cwd();
66
- await ensureOutputAISystem(projectRoot);
67
- const absolutePlanPath = path.resolve(projectRoot, planFile);
68
- const buildOutput = await buildWorkflow(absolutePlanPath, result.targetDir, args.name);
69
- await buildWorkflowInteractiveLoop(buildOutput);
70
- this.log(ux.colorize('green', '\nWorkflow implementation complete!\n'));
71
- }
72
- this.displaySuccess(result);
57
+ const projectRoot = process.cwd();
58
+ const absolutePlanPath = planFile ? path.resolve(projectRoot, planFile) : '';
59
+ if (planFile && !fsSync.existsSync(absolutePlanPath)) {
60
+ this.error(`Plan file not found: ${absolutePlanPath}`);
73
61
  }
74
- catch (error) {
75
- if (error instanceof Error) {
76
- this.error(error.message);
77
- }
78
- throw error;
62
+ const result = await generateWorkflow({
63
+ name: args.name,
64
+ description: flags.description,
65
+ outputDir: flags['output-dir'],
66
+ skeleton: flags.skeleton,
67
+ force: flags.force
68
+ }).catch((error) => {
69
+ this.error(getErrorMessage(error));
70
+ });
71
+ if (planFile) {
72
+ this.log('\nStarting AI-assisted workflow implementation...\n');
73
+ await ensureOutputAISystem(projectRoot);
74
+ const buildOutput = await buildWorkflow(absolutePlanPath, result.targetDir, args.name)
75
+ .catch((error) => {
76
+ fsSync.rmSync(result.targetDir, { recursive: true, force: true });
77
+ const message = getErrorMessage(error);
78
+ this.error(`Workflow implementation failed, created files have been rolled back: ${message}`);
79
+ });
80
+ await buildWorkflowInteractiveLoop(buildOutput);
81
+ this.log(ux.colorize('green', '\nWorkflow implementation complete!\n'));
79
82
  }
83
+ this.displaySuccess(result);
80
84
  }
81
85
  displaySuccess(result) {
82
86
  const message = getWorkflowGenerateSuccessMessage(result.workflowName, result.targetDir, result.filesCreated);
@@ -52,7 +52,7 @@ export default class WorkflowPlan extends Command {
52
52
  if (modifications === acceptKey) {
53
53
  return originalPlanContent;
54
54
  }
55
- const modifiedPlanContent = await replyToClaude(modifications, PLAN_COMMAND_OPTIONS);
55
+ const modifiedPlanContent = await replyToClaude(modifications, { anthropicOpts: PLAN_COMMAND_OPTIONS });
56
56
  return this.planModificationLoop(modifiedPlanContent);
57
57
  }
58
58
  async planGenerationLoop(promptDescription, planName, projectRoot) {
@@ -62,5 +62,7 @@ export default class WorkflowPlan extends Command {
62
62
  const modifiedPlanContent = await this.planModificationLoop(planContent);
63
63
  const modifiedSavedPath = await writePlanFile(planName, modifiedPlanContent, projectRoot);
64
64
  this.log(`✅ Plan saved to: ${modifiedSavedPath}\n`);
65
+ const generateCmd = ux.colorize('cyan', `npx output workflow generate <WORKFLOW_NAME> --plan-file=${modifiedSavedPath}`);
66
+ this.log(`⏭️ To execute this plan run: ${generateCmd}\n`);
65
67
  }
66
68
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "framework": "0.1.10"
2
+ "framework": "0.1.11-next.42e84b1.0"
3
3
  }
@@ -2,13 +2,21 @@
2
2
  * Claude Agent SDK client for workflow planning
3
3
  */
4
4
  import { Options } from '@anthropic-ai/claude-agent-sdk';
5
+ export declare const ADDITIONAL_INSTRUCTIONS: {
6
+ readonly PLAN: "\n! IMPORTANT !\n1. Use TodoWrite to track your progress through plan creation.\n\n2. Please respond with only the final version of the plan content.\n\n3. Respond in a markdown format with these metadata headers:\n\n---\ntitle: <plan-title>\ndescription: <plan-description>\ndate: <plan-date>\n---\n\n<plan-content>\n\n4. After you mark all todos as complete, you must respond with the final version of the plan.\n\n5. DO NOT write the plan to disk — the CLI will handle saving the file to the plans directory.\n\n6. DO NOT suggest any next steps, follow-up commands, or instructions for the user — the CLI will inform the user of next steps after saving.\n";
7
+ readonly BUILD: "\n! IMPORTANT !\n1. Use TodoWrite to track your progress through workflow implementation.\n\n2. Follow the implementation plan exactly as specified in the plan file.\n\n3. Implement all workflow files following Output.ai patterns and best practices.\n\n4. After you mark all todos as complete, provide a summary of what was implemented.\n";
8
+ };
5
9
  export declare const PLAN_COMMAND_OPTIONS: Options;
10
+ interface ReplyToClaudeOptions {
11
+ anthropicOpts?: Options;
12
+ applyAdditionalInstructions?: string;
13
+ }
6
14
  export declare const BUILD_COMMAND_OPTIONS: Options;
7
15
  export declare class ClaudeInvocationError extends Error {
8
16
  cause?: Error | undefined;
9
17
  constructor(message: string, cause?: Error | undefined);
10
18
  }
11
- export declare function replyToClaude(message: string, options?: Options): Promise<string>;
19
+ export declare function replyToClaude(message: string, { anthropicOpts, applyAdditionalInstructions }?: ReplyToClaudeOptions): Promise<string>;
12
20
  /**
13
21
  * Invoke claude-code with /outputai:plan_workflow slash command
14
22
  * The SDK loads custom commands from .claude/commands/ when settingSources includes 'project'.
@@ -28,3 +36,4 @@ export declare function invokePlanWorkflow(description: string): Promise<string>
28
36
  * @returns Implementation output from claude-code
29
37
  */
30
38
  export declare function invokeBuildWorkflow(planFilePath: string, workflowDir: string, workflowName: string, additionalInstructions?: string): Promise<string>;
39
+ export {};
@@ -6,13 +6,14 @@ import { ux } from '@oclif/core';
6
6
  import * as cliProgress from 'cli-progress';
7
7
  import { getErrorMessage, toError } from '#utils/error_utils.js';
8
8
  import { config } from '#config.js';
9
- const ADDITIONAL_INSTRUCTIONS = `
9
+ export const ADDITIONAL_INSTRUCTIONS = {
10
+ PLAN: `
10
11
  ! IMPORTANT !
11
12
  1. Use TodoWrite to track your progress through plan creation.
12
13
 
13
- 2. Please response with only the final version of the plan.
14
+ 2. Please respond with only the final version of the plan content.
14
15
 
15
- 3. Response in a markdown format with these metadata headers:
16
+ 3. Respond in a markdown format with these metadata headers:
16
17
 
17
18
  ---
18
19
  title: <plan-title>
@@ -23,8 +24,12 @@ date: <plan-date>
23
24
  <plan-content>
24
25
 
25
26
  4. After you mark all todos as complete, you must respond with the final version of the plan.
26
- `;
27
- const ADDITIONAL_INSTRUCTIONS_BUILD = `
27
+
28
+ 5. DO NOT write the plan to disk — the CLI will handle saving the file to the plans directory.
29
+
30
+ 6. DO NOT suggest any next steps, follow-up commands, or instructions for the user — the CLI will inform the user of next steps after saving.
31
+ `,
32
+ BUILD: `
28
33
  ! IMPORTANT !
29
34
  1. Use TodoWrite to track your progress through workflow implementation.
30
35
 
@@ -33,7 +38,8 @@ const ADDITIONAL_INSTRUCTIONS_BUILD = `
33
38
  3. Implement all workflow files following Output.ai patterns and best practices.
34
39
 
35
40
  4. After you mark all todos as complete, provide a summary of what was implemented.
36
- `;
41
+ `
42
+ };
37
43
  const PLAN_COMMAND = 'outputai:plan_workflow';
38
44
  const BUILD_COMMAND = 'outputai:build_workflow';
39
45
  const GLOBAL_CLAUDE_OPTIONS = {
@@ -102,11 +108,8 @@ function getTodoWriteMessage(message) {
102
108
  const todoWriteMessage = message.message.content.find((c) => c?.type === 'tool_use' && c.name === 'TodoWrite');
103
109
  return todoWriteMessage ?? null;
104
110
  }
105
- function applyInstructions(initialMessage, instructionsType = 'plan') {
106
- const instructions = instructionsType === 'build' ?
107
- ADDITIONAL_INSTRUCTIONS_BUILD :
108
- ADDITIONAL_INSTRUCTIONS;
109
- return `${initialMessage}\n\n${instructions}`;
111
+ function applyInstructions(message, instructions) {
112
+ return `${message}\n\n${instructions}`;
110
113
  }
111
114
  function createProgressBar() {
112
115
  return new cliProgress.SingleBar({
@@ -183,8 +186,8 @@ async function singleQuery(prompt, options = {}) {
183
186
  throw new ClaudeInvocationError(`Failed to invoke claude-code: ${getErrorMessage(error)}`, toError(error));
184
187
  }
185
188
  }
186
- export async function replyToClaude(message, options = {}) {
187
- return singleQuery(applyInstructions(message), { continue: true, ...options });
189
+ export async function replyToClaude(message, { anthropicOpts, applyAdditionalInstructions = ADDITIONAL_INSTRUCTIONS.PLAN } = {}) {
190
+ return singleQuery(applyInstructions(message, applyAdditionalInstructions), { continue: true, ...anthropicOpts });
188
191
  }
189
192
  /**
190
193
  * Invoke claude-code with /outputai:plan_workflow slash command
@@ -194,7 +197,7 @@ export async function replyToClaude(message, options = {}) {
194
197
  * @returns Plan output from claude-code
195
198
  */
196
199
  export async function invokePlanWorkflow(description) {
197
- return singleQuery(applyInstructions(`/${PLAN_COMMAND} ${description}`), PLAN_COMMAND_OPTIONS);
200
+ return singleQuery(applyInstructions(`/${PLAN_COMMAND} ${description}`, ADDITIONAL_INSTRUCTIONS.PLAN), PLAN_COMMAND_OPTIONS);
198
201
  }
199
202
  /**
200
203
  * Invoke claude-code with /outputai:build_workflow slash command
@@ -211,5 +214,5 @@ export async function invokeBuildWorkflow(planFilePath, workflowDir, workflowNam
211
214
  const fullCommand = additionalInstructions ?
212
215
  `/${BUILD_COMMAND} ${commandArgs} ${additionalInstructions}` :
213
216
  `/${BUILD_COMMAND} ${commandArgs}`;
214
- return singleQuery(applyInstructions(fullCommand, 'build'), BUILD_COMMAND_OPTIONS);
217
+ return singleQuery(applyInstructions(fullCommand, ADDITIONAL_INSTRUCTIONS.BUILD), BUILD_COMMAND_OPTIONS);
215
218
  }
@@ -27,10 +27,11 @@ 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;
33
- waitForHealthy: () => Promise<void>;
34
35
  }
35
36
  export type PullPolicy = 'always' | 'missing' | 'never';
36
37
  export declare function startDockerCompose(dockerComposePath: string, pullPolicy?: PullPolicy): Promise<DockerComposeProcess>;
@@ -2,7 +2,6 @@ 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
5
  const DEFAULT_COMPOSE_PATH = '../assets/docker/docker-compose-dev.yml';
7
6
  export const SERVICE_HEALTH = {
8
7
  HEALTHY: 'healthy',
@@ -79,46 +78,22 @@ export async function getServiceStatus(dockerComposePath) {
79
78
  const result = execFileSync('docker', ['compose', '-f', dockerComposePath, 'ps', '--all', '--format', 'json'], { encoding: 'utf-8', cwd: process.cwd() });
80
79
  return parseServiceStatus(result);
81
80
  }
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');
81
+ export function isServiceHealthy(service) {
82
+ return service.state !== SERVICE_STATE.EXITED &&
83
+ (service.health === SERVICE_HEALTH.HEALTHY || service.health === SERVICE_HEALTH.NONE);
84
+ }
85
+ export function isServiceFailed(service) {
86
+ return service.state === SERVICE_STATE.EXITED || service.health === SERVICE_HEALTH.UNHEALTHY;
87
+ }
106
88
  export async function waitForServicesHealthy(dockerComposePath, timeoutMs = 120000, pollIntervalMs = 2000) {
107
89
  const startTime = Date.now();
108
90
  while (Date.now() - startTime < timeoutMs) {
109
91
  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();
92
+ if (services.length > 0 && services.every(isServiceHealthy)) {
117
93
  return;
118
94
  }
119
95
  await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
120
96
  }
121
- logUpdate.done();
122
97
  throw new Error('Timeout waiting for services to become healthy');
123
98
  }
124
99
  export async function startDockerCompose(dockerComposePath, pullPolicy) {
@@ -136,10 +111,7 @@ export async function startDockerCompose(dockerComposePath, pullPolicy) {
136
111
  cwd: process.cwd(),
137
112
  stdio: ['ignore', 'pipe', 'pipe']
138
113
  });
139
- return {
140
- process: dockerProcess,
141
- waitForHealthy: () => waitForServicesHealthy(dockerComposePath)
142
- };
114
+ return { process: dockerProcess };
143
115
  }
144
116
  export function startDockerComposeDetached(dockerComposePath, pullPolicy) {
145
117
  const args = [
@@ -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(() => {
@@ -4,6 +4,3 @@
4
4
  export declare const getEjectSuccessMessage: (destPath: string, outputFile: string, binName: string) => string;
5
5
  export declare const getProjectSuccessMessage: (folderName: string, installSuccess: boolean, credentialsConfigured?: 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,7 +2,6 @@
2
2
  * Success and informational messages for project initialization
3
3
  */
4
4
  import { ux } from '@oclif/core';
5
- import { config } from '#config.js';
6
5
  /**
7
6
  * Creates a colored ASCII art banner for Output.ai
8
7
  */
@@ -299,43 +298,3 @@ ${ux.colorize('dim', '💡 Tip: Check the README.md in your workflow directory f
299
298
  ${ux.colorize('green', ux.colorize('bold', 'Happy building! 🛠️'))}
300
299
  `;
301
300
  };
302
- export const getDevSuccessMessage = (services) => {
303
- const divider = ux.colorize('dim', '─'.repeat(80));
304
- const bulletPoint = ux.colorize('green', '▸');
305
- const serviceNames = services.map(s => s.name).sort().join('|');
306
- const logsCommand = `docker compose -p ${config.dockerServiceName} logs -f <${serviceNames}>`;
307
- return `
308
- ${divider}
309
-
310
- ${ux.colorize('bold', ux.colorize('green', '✅ SUCCESS!'))} ${ux.colorize('bold', 'Development services are running')}
311
-
312
- ${divider}
313
-
314
- ${createSectionHeader('SERVICES', '🐳')}
315
-
316
- ${bulletPoint} ${ux.colorize('white', 'Temporal:')} ${formatPath('localhost:7233')}
317
- ${bulletPoint} ${ux.colorize('white', 'Temporal UI:')} ${formatCommand('http://localhost:8080')}
318
- ${bulletPoint} ${ux.colorize('white', 'API Server:')} ${formatPath('localhost:3001')}
319
- ${bulletPoint} ${ux.colorize('white', 'Redis:')} ${formatPath('localhost:6379')}
320
-
321
- ${divider}
322
-
323
- ${createSectionHeader('RUN A WORKFLOW', '🚀')}
324
-
325
- ${ux.colorize('white', 'In a new terminal, execute:')}
326
-
327
- ${formatCommand('npx output workflow run blog_evaluator paulgraham_hwh')}
328
-
329
- ${divider}
330
-
331
- ${createSectionHeader('USEFUL COMMANDS', '⚡')}
332
-
333
- ${bulletPoint} ${ux.colorize('white', 'Open Temporal UI:')} ${formatCommand('open http://localhost:8080')}
334
- ${bulletPoint} ${ux.colorize('white', 'View logs:')} ${formatCommand(logsCommand)}
335
- ${bulletPoint} ${ux.colorize('white', 'Stop services:')} ${formatCommand('Press Ctrl+C')}
336
-
337
- ${divider}
338
-
339
- ${ux.colorize('dim', '💡 Tip: The Temporal UI lets you monitor workflow executions in real-time')}
340
- `;
341
- };
@@ -139,8 +139,7 @@ function formatInitError(error, projectPath) {
139
139
  case 'EPERM': return `Operation not permitted${pathSuffix}`;
140
140
  case 'ENOENT': return `Required file or directory not found${pathSuffix}`;
141
141
  default: {
142
- const originalMessage = error instanceof Error ? error.message : String(error);
143
- return `Failed to create project${pathSuffix}: ${originalMessage}`;
142
+ return `Failed to create project${pathSuffix}: ${getErrorMessage(error)}`;
144
143
  }
145
144
  }
146
145
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Workflow builder service for implementing workflows from plan files
3
3
  */
4
- import { BUILD_COMMAND_OPTIONS, invokeBuildWorkflow as invokeBuildWorkflowFromClient, replyToClaude } from './claude_client.js';
4
+ import { ADDITIONAL_INSTRUCTIONS, BUILD_COMMAND_OPTIONS, invokeBuildWorkflow as invokeBuildWorkflowFromClient, replyToClaude } from './claude_client.js';
5
5
  import { input } from '@inquirer/prompts';
6
6
  import { ux } from '@oclif/core';
7
7
  import fs from 'node:fs/promises';
@@ -56,12 +56,15 @@ async function processModification(modification, currentOutput) {
56
56
  return currentOutput;
57
57
  }
58
58
  try {
59
- const updatedOutput = await replyToClaude(modification, BUILD_COMMAND_OPTIONS);
59
+ const updatedOutput = await replyToClaude(modification, {
60
+ anthropicOpts: BUILD_COMMAND_OPTIONS,
61
+ applyAdditionalInstructions: ADDITIONAL_INSTRUCTIONS.BUILD
62
+ });
60
63
  displayImplementationOutput(updatedOutput, '✓ Implementation updated!');
61
64
  return updatedOutput;
62
65
  }
63
66
  catch (error) {
64
- ux.error(`Failed to apply modifications: ${getErrorMessage(error)}`);
67
+ ux.stdout(ux.colorize('red', `Failed to apply modifications: ${getErrorMessage(error)}`));
65
68
  ux.stdout('Continuing with previous version...\n');
66
69
  return currentOutput;
67
70
  }
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import { buildWorkflow, buildWorkflowInteractiveLoop } from './workflow_builder.js';
3
- import { BUILD_COMMAND_OPTIONS, invokeBuildWorkflow, replyToClaude } from './claude_client.js';
3
+ import { ADDITIONAL_INSTRUCTIONS, BUILD_COMMAND_OPTIONS, invokeBuildWorkflow, replyToClaude } from './claude_client.js';
4
4
  import { input } from '@inquirer/prompts';
5
5
  import { ux } from '@oclif/core';
6
6
  import fs from 'node:fs/promises';
@@ -97,7 +97,10 @@ describe('workflow-builder service', () => {
97
97
  .mockResolvedValueOnce('ACCEPT');
98
98
  vi.mocked(replyToClaude).mockResolvedValue('Updated implementation with error handling');
99
99
  const result = await buildWorkflowInteractiveLoop('Initial implementation');
100
- expect(replyToClaude).toHaveBeenCalledWith('Add error handling', BUILD_COMMAND_OPTIONS);
100
+ expect(replyToClaude).toHaveBeenCalledWith('Add error handling', {
101
+ anthropicOpts: BUILD_COMMAND_OPTIONS,
102
+ applyAdditionalInstructions: ADDITIONAL_INSTRUCTIONS.BUILD
103
+ });
101
104
  expect(result).toBe('Updated implementation with error handling');
102
105
  expect(input).toHaveBeenCalledTimes(2);
103
106
  });
@@ -111,8 +114,14 @@ describe('workflow-builder service', () => {
111
114
  .mockResolvedValueOnce('Implementation with logging and validation');
112
115
  const result = await buildWorkflowInteractiveLoop('Initial implementation');
113
116
  expect(replyToClaude).toHaveBeenCalledTimes(2);
114
- expect(replyToClaude).toHaveBeenNthCalledWith(1, 'Add logging', BUILD_COMMAND_OPTIONS);
115
- expect(replyToClaude).toHaveBeenNthCalledWith(2, 'Add validation', BUILD_COMMAND_OPTIONS);
117
+ expect(replyToClaude).toHaveBeenNthCalledWith(1, 'Add logging', {
118
+ anthropicOpts: BUILD_COMMAND_OPTIONS,
119
+ applyAdditionalInstructions: ADDITIONAL_INSTRUCTIONS.BUILD
120
+ });
121
+ expect(replyToClaude).toHaveBeenNthCalledWith(2, 'Add validation', {
122
+ anthropicOpts: BUILD_COMMAND_OPTIONS,
123
+ applyAdditionalInstructions: ADDITIONAL_INSTRUCTIONS.BUILD
124
+ });
116
125
  expect(result).toBe('Implementation with logging and validation');
117
126
  });
118
127
  it('should prompt again when user provides empty input', async () => {
@@ -146,7 +155,7 @@ describe('workflow-builder service', () => {
146
155
  const result = await buildWorkflowInteractiveLoop('Original implementation');
147
156
  // Should return original implementation after error
148
157
  expect(result).toBe('Original implementation');
149
- expect(ux.error).toHaveBeenCalledWith(expect.stringContaining('Failed to apply modifications'));
158
+ expect(ux.stdout).toHaveBeenCalledWith(expect.stringContaining('Failed to apply modifications'));
150
159
  expect(ux.stdout).toHaveBeenCalledWith(expect.stringContaining('Continuing with previous version'));
151
160
  });
152
161
  it('should continue looping after handling error', async () => {
@@ -15,9 +15,9 @@ function validateConfig(config) {
15
15
  * Check if target directory exists and handle accordingly
16
16
  */
17
17
  import * as fsSync from 'node:fs';
18
- async function checkTargetDirectory(targetDir, force) {
18
+ async function checkTargetDirectory(name, targetDir, force) {
19
19
  if (fsSync.existsSync(targetDir) && !force) {
20
- throw new WorkflowExistsError('', targetDir);
20
+ throw new WorkflowExistsError(name, targetDir);
21
21
  }
22
22
  }
23
23
  /**
@@ -27,7 +27,7 @@ export async function generateWorkflow(config) {
27
27
  validateConfig(config);
28
28
  const targetDir = createTargetDir(config.outputDir, config.name);
29
29
  const templatesDir = getTemplateDir('workflow');
30
- await checkTargetDirectory(targetDir, config.force);
30
+ await checkTargetDirectory(config.name, targetDir, config.force);
31
31
  await fs.mkdir(targetDir, { recursive: true });
32
32
  const variables = prepareTemplateVariables(config.name, config.description || '');
33
33
  const templateFiles = await getTemplateFiles(templatesDir);
@@ -3,7 +3,6 @@
3
3
  */
4
4
  export type TodoStatus = 'pending' | 'in_progress' | 'completed';
5
5
  export type FileMappingType = 'template' | 'symlink' | 'copy';
6
- export type InstructionsType = 'plan' | 'build';
7
6
  export interface Todo {
8
7
  content: string;
9
8
  activeForm: string;
@@ -0,0 +1,5 @@
1
+ import React from 'react';
2
+ export declare const DevApp: React.FC<{
3
+ dockerComposePath: string;
4
+ onCleanup: () => Promise<void>;
5
+ }>;
@@ -0,0 +1,144 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState, useEffect, useRef } from 'react';
3
+ import { Box, Text, Static, useApp, useInput } from 'ink';
4
+ import Spinner from 'ink-spinner';
5
+ import { getServiceStatus, isServiceHealthy, isServiceFailed, SERVICE_HEALTH, SERVICE_STATE } from '#services/docker.js';
6
+ import { config } from '#config.js';
7
+ const POLL_INTERVAL_MS = 2000;
8
+ const HEALTH_TIMEOUT_MS = 120_000;
9
+ const STATUS_ICONS = {
10
+ [SERVICE_HEALTH.HEALTHY]: '●',
11
+ [SERVICE_HEALTH.UNHEALTHY]: '○',
12
+ [SERVICE_HEALTH.STARTING]: '◐',
13
+ [SERVICE_HEALTH.NONE]: '●',
14
+ [SERVICE_STATE.RUNNING]: '●',
15
+ [SERVICE_STATE.EXITED]: '✗'
16
+ };
17
+ const STATUS_COLORS = {
18
+ [SERVICE_HEALTH.HEALTHY]: 'green',
19
+ [SERVICE_HEALTH.UNHEALTHY]: 'red',
20
+ [SERVICE_HEALTH.STARTING]: 'yellow',
21
+ [SERVICE_HEALTH.NONE]: 'blue',
22
+ [SERVICE_STATE.RUNNING]: 'blue',
23
+ [SERVICE_STATE.EXITED]: 'red'
24
+ };
25
+ const resolveServiceDisplay = (service) => {
26
+ const key = service.health === SERVICE_HEALTH.NONE ? service.state : service.health;
27
+ return { icon: STATUS_ICONS[key] ?? '?', color: STATUS_COLORS[key] ?? 'white', status: key };
28
+ };
29
+ const fetchServices = async (dockerComposePath) => {
30
+ try {
31
+ return await getServiceStatus(dockerComposePath);
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ };
37
+ const usePoll = (enabled, onTick) => {
38
+ const onTickRef = useRef(onTick);
39
+ onTickRef.current = onTick;
40
+ useEffect(() => {
41
+ const state = {
42
+ active: true,
43
+ timeout: undefined
44
+ };
45
+ const run = async () => {
46
+ if (!state.active) {
47
+ return;
48
+ }
49
+ const result = await onTickRef.current();
50
+ if (!state.active || result === 'done') {
51
+ return;
52
+ }
53
+ state.timeout = setTimeout(run, POLL_INTERVAL_MS);
54
+ };
55
+ if (enabled) {
56
+ void run();
57
+ }
58
+ return () => {
59
+ state.active = false;
60
+ clearTimeout(state.timeout);
61
+ };
62
+ }, [enabled]);
63
+ };
64
+ const useHealthPolling = (dockerComposePath, enabled, callbacks) => {
65
+ const callbacksRef = useRef(callbacks);
66
+ callbacksRef.current = callbacks;
67
+ const startTimeRef = useRef(Date.now());
68
+ usePoll(enabled, async () => {
69
+ if (Date.now() - startTimeRef.current > HEALTH_TIMEOUT_MS) {
70
+ callbacksRef.current.onTimeout();
71
+ return 'done';
72
+ }
73
+ const svcs = await fetchServices(dockerComposePath);
74
+ if (svcs === null) {
75
+ return 'continue';
76
+ }
77
+ callbacksRef.current.onServices(svcs);
78
+ if (svcs.length > 0 && svcs.every(isServiceHealthy)) {
79
+ callbacksRef.current.onAllHealthy(svcs);
80
+ return 'done';
81
+ }
82
+ return 'continue';
83
+ });
84
+ };
85
+ const useStatusRefresh = (dockerComposePath, enabled, onServices) => {
86
+ const onServicesRef = useRef(onServices);
87
+ onServicesRef.current = onServices;
88
+ usePoll(enabled, async () => {
89
+ const svcs = await fetchServices(dockerComposePath);
90
+ if (svcs !== null) {
91
+ onServicesRef.current(svcs);
92
+ }
93
+ return 'continue';
94
+ });
95
+ };
96
+ const useCtrlC = (onCleanup) => {
97
+ const { exit } = useApp();
98
+ const isExitingRef = useRef(false);
99
+ useInput((input, key) => {
100
+ if (key.ctrl && input === 'c' && !isExitingRef.current) {
101
+ isExitingRef.current = true;
102
+ void onCleanup().then(() => exit()).catch(err => exit(err instanceof Error ? err : new Error(String(err))));
103
+ }
104
+ });
105
+ };
106
+ const ServiceRow = ({ service }) => {
107
+ const { icon, color, status } = resolveServiceDisplay(service);
108
+ const ports = service.ports.length ? service.ports.join(', ') : '-';
109
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: color, children: [icon, " "] }), _jsx(Box, { width: 16, children: _jsx(Text, { children: service.name }) }), _jsx(Text, { dimColor: true, children: status.padEnd(10) }), _jsx(Text, { dimColor: true, children: ports })] }));
110
+ };
111
+ const FailureWarning = ({ services }) => {
112
+ const failed = services.filter(isServiceFailed);
113
+ if (failed.length === 0) {
114
+ return null;
115
+ }
116
+ const failedNames = failed.map(s => s.name).join(', ');
117
+ const hasWorker = failed.some(s => s.name.toLowerCase().includes('worker'));
118
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Box, { children: _jsx(Text, { backgroundColor: "red", color: "white", bold: true, children: " \u26A0\uFE0F SERVICE FAILURE DETECTED " }) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "red", bold: true, children: "Failed services: " }), _jsx(Text, { children: failedNames })] }), hasWorker ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", bold: true, children: "\u26A1 The worker is not running!" }), _jsx(Text, { color: "yellow", children: ' Workflows will fail until the worker is restarted.' }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Check the logs with: docker compose logs worker" }) })] })) : (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Check the logs with: docker compose logs <service-name>' }) }))] }));
119
+ };
120
+ const DevSuccessMessage = ({ services }) => {
121
+ const divider = '─'.repeat(80);
122
+ const sortedNames = services.map(s => s.name).sort().join('|');
123
+ const logsCommand = `docker compose -p ${config.dockerServiceName} logs -f <${sortedNames}>`;
124
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsxs(Box, { children: [_jsx(Text, { color: "green", bold: true, children: '✅ SUCCESS! ' }), _jsx(Text, { bold: true, children: "Development services are running" })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "\uD83D\uDC33 SERVICES" }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Temporal: ' }), _jsx(Text, { color: "yellow", children: "localhost:7233" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Temporal UI: ' }), _jsx(Text, { color: "cyan", children: "http://localhost:8080" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'API Server: ' }), _jsx(Text, { color: "yellow", children: "localhost:3001" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Redis: ' }), _jsx(Text, { color: "yellow", children: "localhost:6379" })] })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "\uD83D\uDE80 RUN A WORKFLOW" }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsx(Text, { color: "white", children: "In a new terminal, execute:" }), _jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "cyan", children: "npx output workflow run blog_evaluator paulgraham_hwh" }) })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "\u26A1 USEFUL COMMANDS" }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Open Temporal UI: ' }), _jsx(Text, { color: "cyan", children: "open http://localhost:8080" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'View logs: ' }), _jsx(Text, { color: "cyan", children: logsCommand })] }), _jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Stop services: ' }), _jsx(Text, { color: "cyan", children: "Press Ctrl+C" })] })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsx(Text, { dimColor: true, children: "\uD83D\uDCA1 Tip: The Temporal UI lets you monitor workflow executions in real-time" })] }));
125
+ };
126
+ const WaitingView = ({ services }) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Waiting for services to become healthy..." })] }), services.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: services.map(s => _jsx(ServiceRow, { service: s }, s.name)) }))] }));
127
+ const RunningView = ({ services }) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "\uD83D\uDCCA Service Status" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: services.map(s => _jsx(ServiceRow, { service: s }, s.name)) }), _jsx(FailureWarning, { services: services }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: '🌐 Temporal UI: ' }), _jsx(Text, { bold: true, children: "http://localhost:8080" })] }), _jsx(Text, { dimColor: true, children: "Press Ctrl+C to stop services" })] }));
128
+ export const DevApp = ({ dockerComposePath, onCleanup }) => {
129
+ const { exit } = useApp();
130
+ const [phase, setPhase] = useState('waiting');
131
+ const [services, setServices] = useState([]);
132
+ const [successItems, setSuccessItems] = useState([]);
133
+ useHealthPolling(dockerComposePath, phase === 'waiting', {
134
+ onServices: setServices,
135
+ onAllHealthy: svcs => {
136
+ setSuccessItems([{ id: 'success', services: svcs }]);
137
+ setPhase('running');
138
+ },
139
+ onTimeout: () => exit(new Error('Timeout waiting for services to become healthy'))
140
+ });
141
+ useStatusRefresh(dockerComposePath, phase === 'running', setServices);
142
+ useCtrlC(onCleanup);
143
+ return (_jsxs(_Fragment, { children: [_jsx(Static, { items: successItems, children: item => _jsx(DevSuccessMessage, { services: item.services }, item.id) }), phase === 'waiting' && _jsx(WaitingView, { services: services }), phase === 'running' && _jsx(RunningView, { services: services })] }));
144
+ };
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.42e84b1.0",
4
4
  "description": "CLI for Output.ai workflow generation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,19 +28,22 @@
28
28
  "dotenv": "17.3.1",
29
29
  "handlebars": "4.7.9",
30
30
  "js-yaml": "4.1.1",
31
+ "ink": "6.8.0",
32
+ "ink-spinner": "5.0.0",
31
33
  "json-schema-library": "11.0.5",
32
34
  "ky": "1.14.3",
33
- "log-update": "7.2.0",
35
+ "react": "19.2.4",
34
36
  "semver": "7.7.4",
35
37
  "yaml": "^2.8.3",
36
- "@outputai/credentials": "0.1.10",
37
- "@outputai/llm": "0.1.10",
38
- "@outputai/evals": "0.1.10"
38
+ "@outputai/credentials": "0.1.11-next.42e84b1.0",
39
+ "@outputai/evals": "0.1.11-next.42e84b1.0",
40
+ "@outputai/llm": "0.1.11-next.42e84b1.0"
39
41
  },
40
42
  "devDependencies": {
41
43
  "@types/cli-progress": "3.11.6",
42
44
  "@types/debug": "4.1.13",
43
45
  "@types/js-yaml": "4.0.9",
46
+ "@types/react": "19.2.14",
44
47
  "@types/semver": "7.7.1",
45
48
  "orval": "8.6.2"
46
49
  },
@@ -1 +0,0 @@
1
- export {};
@@ -1,55 +0,0 @@
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
- });