@outputai/cli 0.1.10 → 0.1.11-next.7b8340c.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 +2 -0
- package/dist/assets/docker/docker-compose-dev.yml +7 -5
- package/dist/commands/dev/index.js +2 -2
- package/dist/commands/dev/index.spec.js +1 -0
- package/dist/commands/init.js +2 -2
- package/dist/commands/workflow/generate.js +27 -23
- package/dist/commands/workflow/plan.js +3 -1
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/claude_client.d.ts +10 -1
- package/dist/services/claude_client.js +18 -15
- package/dist/services/docker.d.ts +2 -0
- package/dist/services/docker.js +8 -1
- package/dist/services/docker.spec.js +59 -1
- package/dist/services/project_scaffold.js +1 -2
- package/dist/services/workflow_builder.js +6 -3
- package/dist/services/workflow_builder.spec.js +14 -5
- package/dist/services/workflow_generator.js +3 -3
- package/dist/types/domain.d.ts +0 -1
- package/package.json +4 -4
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.
|
|
84
|
+
image: outputai/api:${OUTPUT_API_VERSION:-0.1.11-next.7b8340c.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
|
|
108
|
+
test: [ 'CMD-SHELL', 'npx output-healthcheck' ]
|
|
109
109
|
interval: 3s
|
|
110
|
-
timeout:
|
|
111
|
-
retries:
|
|
112
|
-
start_period:
|
|
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(
|
|
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'),
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
}
|
|
@@ -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,
|
|
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
|
|
14
|
+
2. Please respond with only the final version of the plan content.
|
|
14
15
|
|
|
15
|
-
3.
|
|
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
|
-
|
|
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(
|
|
106
|
-
|
|
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,
|
|
187
|
-
return singleQuery(applyInstructions(message), { continue: true, ...
|
|
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}
|
|
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,
|
|
217
|
+
return singleQuery(applyInstructions(fullCommand, ADDITIONAL_INSTRUCTIONS.BUILD), BUILD_COMMAND_OPTIONS);
|
|
215
218
|
}
|
|
@@ -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;
|
package/dist/services/docker.js
CHANGED
|
@@ -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(
|
|
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(() => {
|
|
@@ -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
|
-
|
|
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,
|
|
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.
|
|
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',
|
|
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',
|
|
115
|
-
|
|
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.
|
|
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(
|
|
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);
|
package/dist/types/domain.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11-next.7b8340c.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/
|
|
37
|
-
"@outputai/
|
|
38
|
-
"@outputai/
|
|
36
|
+
"@outputai/evals": "0.1.11-next.7b8340c.0",
|
|
37
|
+
"@outputai/credentials": "0.1.11-next.7b8340c.0",
|
|
38
|
+
"@outputai/llm": "0.1.11-next.7b8340c.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/cli-progress": "3.11.6",
|