@outputai/cli 0.1.12 → 0.1.13-dev.98dfd72.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/api/generated/api.d.ts +4 -0
- package/dist/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/commands/credentials/set.d.ts +14 -0
- package/dist/commands/credentials/set.js +57 -0
- package/dist/commands/credentials/set.spec.d.ts +1 -0
- package/dist/commands/credentials/set.spec.js +95 -0
- package/dist/commands/fix.js +1 -1
- package/dist/commands/fix.spec.js +2 -2
- package/dist/commands/update.js +1 -1
- package/dist/commands/update.spec.js +2 -2
- package/dist/commands/workflow/generate.js +3 -1
- package/dist/commands/workflow/generate.spec.js +12 -0
- package/dist/commands/workflow/list.d.ts +1 -0
- package/dist/commands/workflow/list.js +12 -6
- package/dist/commands/workflow/list.spec.js +21 -0
- package/dist/commands/workflow/plan.js +5 -1
- package/dist/commands/workflow/plan.spec.js +3 -2
- package/dist/commands/workflow/run.js +2 -1
- package/dist/commands/workflow/run.spec.js +1 -0
- package/dist/commands/workflow/start.js +2 -1
- package/dist/commands/workflow/start.spec.js +1 -0
- package/dist/components/command_footer.d.ts +8 -0
- package/dist/components/command_footer.js +4 -0
- package/dist/components/status_icon.d.ts +11 -0
- package/dist/components/status_icon.js +25 -0
- package/dist/components/workflow_summary.d.ts +10 -0
- package/dist/components/workflow_summary.js +4 -0
- package/dist/generated/framework_version.json +1 -1
- package/dist/hooks/init.js +4 -0
- package/dist/services/claude_client.js +4 -1
- package/dist/services/coding_agents.js +1 -1
- package/dist/services/coding_agents.spec.js +6 -6
- package/dist/services/credentials_configurator.js +1 -1
- package/dist/services/docker.d.ts +1 -3
- package/dist/services/docker.js +38 -13
- package/dist/services/env_configurator.js +1 -1
- package/dist/services/env_configurator.spec.js +12 -12
- package/dist/services/messages.d.ts +1 -1
- package/dist/services/messages.js +2 -2
- package/dist/services/project_scaffold.js +2 -2
- package/dist/services/project_scaffold.spec.js +6 -6
- package/dist/services/workflow_builder.js +5 -1
- package/dist/services/workflow_builder.spec.js +3 -2
- package/dist/templates/agent_instructions/CLAUDE.md.template +36 -2
- package/dist/templates/agent_instructions/dotclaude/settings.json.template +2 -2
- package/dist/utils/date_formatter.d.ts +11 -1
- package/dist/utils/date_formatter.js +26 -1
- package/dist/utils/interactive.d.ts +2 -0
- package/dist/utils/interactive.js +5 -0
- package/dist/utils/interactive.spec.d.ts +1 -0
- package/dist/utils/interactive.spec.js +40 -0
- package/dist/utils/open_url.d.ts +1 -0
- package/dist/utils/open_url.js +12 -0
- package/dist/utils/prompt.d.ts +17 -0
- package/dist/utils/prompt.js +20 -0
- package/dist/utils/prompt.spec.d.ts +1 -0
- package/dist/utils/prompt.spec.js +74 -0
- package/dist/utils/proxy.d.ts +1 -0
- package/dist/utils/proxy.js +9 -0
- package/dist/utils/proxy.spec.d.ts +1 -0
- package/dist/utils/proxy.spec.js +39 -0
- package/dist/utils/workflow_dir_parser.d.ts +5 -0
- package/dist/utils/workflow_dir_parser.js +39 -0
- package/dist/utils/workflow_dir_parser.spec.d.ts +1 -0
- package/dist/utils/workflow_dir_parser.spec.js +74 -0
- package/dist/views/dev.js +62 -26
- package/dist/views/workflow/list.d.ts +6 -0
- package/dist/views/workflow/list.js +127 -0
- package/package.json +12 -11
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { confirm as inquirerConfirm, input as inquirerInput, password as inquirerPassword } from '@inquirer/prompts';
|
|
3
|
+
vi.mock('@inquirer/prompts', () => ({
|
|
4
|
+
confirm: vi.fn(),
|
|
5
|
+
input: vi.fn(),
|
|
6
|
+
password: vi.fn()
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('./interactive.js', () => ({
|
|
9
|
+
isInteractive: vi.fn()
|
|
10
|
+
}));
|
|
11
|
+
describe('prompt wrapper', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.clearAllMocks();
|
|
14
|
+
});
|
|
15
|
+
describe('when interactive', () => {
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
const { isInteractive } = await import('./interactive.js');
|
|
18
|
+
vi.mocked(isInteractive).mockReturnValue(true);
|
|
19
|
+
});
|
|
20
|
+
it('confirm delegates to inquirer', async () => {
|
|
21
|
+
vi.mocked(inquirerConfirm).mockResolvedValue(false);
|
|
22
|
+
const { confirm } = await import('./prompt.js');
|
|
23
|
+
const result = await confirm({ message: 'Continue?', default: true });
|
|
24
|
+
expect(inquirerConfirm).toHaveBeenCalledWith({ message: 'Continue?', default: true });
|
|
25
|
+
expect(result).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
it('input delegates to inquirer', async () => {
|
|
28
|
+
vi.mocked(inquirerInput).mockResolvedValue('user input');
|
|
29
|
+
const { input } = await import('./prompt.js');
|
|
30
|
+
const result = await input({ message: 'Name?', default: 'default' });
|
|
31
|
+
expect(inquirerInput).toHaveBeenCalledWith({ message: 'Name?', default: 'default' });
|
|
32
|
+
expect(result).toBe('user input');
|
|
33
|
+
});
|
|
34
|
+
it('password delegates to inquirer', async () => {
|
|
35
|
+
vi.mocked(inquirerPassword).mockResolvedValue('secret');
|
|
36
|
+
const { password } = await import('./prompt.js');
|
|
37
|
+
const result = await password({ message: 'Token?' });
|
|
38
|
+
expect(inquirerPassword).toHaveBeenCalledWith({ message: 'Token?' });
|
|
39
|
+
expect(result).toBe('secret');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('when non-interactive', () => {
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
const { isInteractive } = await import('./interactive.js');
|
|
45
|
+
vi.mocked(isInteractive).mockReturnValue(false);
|
|
46
|
+
});
|
|
47
|
+
it('confirm returns default value', async () => {
|
|
48
|
+
const { confirm } = await import('./prompt.js');
|
|
49
|
+
expect(await confirm({ message: 'Continue?', default: false })).toBe(false);
|
|
50
|
+
expect(await confirm({ message: 'Continue?', default: true })).toBe(true);
|
|
51
|
+
expect(inquirerConfirm).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
it('confirm defaults to true when no default specified', async () => {
|
|
54
|
+
const { confirm } = await import('./prompt.js');
|
|
55
|
+
expect(await confirm({ message: 'Continue?' })).toBe(true);
|
|
56
|
+
expect(inquirerConfirm).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
it('input returns default value', async () => {
|
|
59
|
+
const { input } = await import('./prompt.js');
|
|
60
|
+
expect(await input({ message: 'Name?', default: 'fallback' })).toBe('fallback');
|
|
61
|
+
expect(inquirerInput).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
it('input returns empty string when no default', async () => {
|
|
64
|
+
const { input } = await import('./prompt.js');
|
|
65
|
+
expect(await input({ message: 'Name?' })).toBe('');
|
|
66
|
+
expect(inquirerInput).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
it('password returns empty string', async () => {
|
|
69
|
+
const { password } = await import('./prompt.js');
|
|
70
|
+
expect(await password({ message: 'Token?' })).toBe('');
|
|
71
|
+
expect(inquirerPassword).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const bootstrapProxy: () => void;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
|
|
2
|
+
export const bootstrapProxy = () => {
|
|
3
|
+
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
|
|
4
|
+
process.env.HTTP_PROXY || process.env.http_proxy;
|
|
5
|
+
if (!proxyUrl) {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
setGlobalDispatcher(new EnvHttpProxyAgent());
|
|
9
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
const mockSetGlobalDispatcher = vi.fn();
|
|
3
|
+
const MockEnvHttpProxyAgent = vi.fn();
|
|
4
|
+
vi.mock('undici', () => ({
|
|
5
|
+
EnvHttpProxyAgent: MockEnvHttpProxyAgent,
|
|
6
|
+
setGlobalDispatcher: mockSetGlobalDispatcher
|
|
7
|
+
}));
|
|
8
|
+
describe('proxy bootstrap', () => {
|
|
9
|
+
const originalEnv = { ...process.env };
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
vi.clearAllMocks();
|
|
12
|
+
delete process.env.HTTPS_PROXY;
|
|
13
|
+
delete process.env.https_proxy;
|
|
14
|
+
delete process.env.HTTP_PROXY;
|
|
15
|
+
delete process.env.http_proxy;
|
|
16
|
+
});
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
process.env = { ...originalEnv };
|
|
19
|
+
});
|
|
20
|
+
it('does nothing when no proxy env vars are set', async () => {
|
|
21
|
+
const { bootstrapProxy } = await import('./proxy.js');
|
|
22
|
+
bootstrapProxy();
|
|
23
|
+
expect(mockSetGlobalDispatcher).not.toHaveBeenCalled();
|
|
24
|
+
});
|
|
25
|
+
it('sets global dispatcher when HTTPS_PROXY is set', async () => {
|
|
26
|
+
process.env.HTTPS_PROXY = 'http://proxy:8080';
|
|
27
|
+
const { bootstrapProxy } = await import('./proxy.js');
|
|
28
|
+
bootstrapProxy();
|
|
29
|
+
expect(MockEnvHttpProxyAgent).toHaveBeenCalled();
|
|
30
|
+
expect(mockSetGlobalDispatcher).toHaveBeenCalledTimes(1);
|
|
31
|
+
});
|
|
32
|
+
it('sets global dispatcher when HTTP_PROXY is set', async () => {
|
|
33
|
+
process.env.HTTP_PROXY = 'http://proxy:8080';
|
|
34
|
+
const { bootstrapProxy } = await import('./proxy.js');
|
|
35
|
+
bootstrapProxy();
|
|
36
|
+
expect(MockEnvHttpProxyAgent).toHaveBeenCalled();
|
|
37
|
+
expect(mockSetGlobalDispatcher).toHaveBeenCalledTimes(1);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const WORKFLOW_FILE_NAMES = ['workflow.ts', 'workflow.js'];
|
|
4
|
+
const SCENARIOS_DIR = 'scenarios';
|
|
5
|
+
const WORKFLOW_NAME_PATTERN = /name:\s*['"]([^'"]+)['"]/;
|
|
6
|
+
function safeReadFile(filePath) {
|
|
7
|
+
try {
|
|
8
|
+
return readFileSync(filePath, 'utf-8');
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function safeReadDir(dirPath) {
|
|
15
|
+
try {
|
|
16
|
+
return readdirSync(dirPath);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function parseWorkflowId(targetDir) {
|
|
23
|
+
const workflowFile = WORKFLOW_FILE_NAMES
|
|
24
|
+
.map(name => join(targetDir, name))
|
|
25
|
+
.find(existsSync);
|
|
26
|
+
const content = workflowFile ? safeReadFile(workflowFile) : '';
|
|
27
|
+
return WORKFLOW_NAME_PATTERN.exec(content)?.[1];
|
|
28
|
+
}
|
|
29
|
+
function listScenarioNames(targetDir) {
|
|
30
|
+
return safeReadDir(join(targetDir, SCENARIOS_DIR))
|
|
31
|
+
.filter(f => f.endsWith('.json'))
|
|
32
|
+
.map(f => f.replace(/\.json$/, ''));
|
|
33
|
+
}
|
|
34
|
+
export function parseWorkflowDir(targetDir) {
|
|
35
|
+
return {
|
|
36
|
+
workflowId: parseWorkflowId(targetDir),
|
|
37
|
+
scenarioNames: listScenarioNames(targetDir)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/* eslint-disable no-restricted-syntax, init-declarations */
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { parseWorkflowDir } from './workflow_dir_parser.js';
|
|
7
|
+
describe('parseWorkflowDir', () => {
|
|
8
|
+
let tempDir;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tempDir = mkdtempSync(join(tmpdir(), 'workflow-dir-parser-'));
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
it('should extract workflow ID from workflow.ts', () => {
|
|
16
|
+
writeFileSync(join(tempDir, 'workflow.ts'), 'export default workflow( { name: \'myWorkflow\', description: \'test\' } );');
|
|
17
|
+
const result = parseWorkflowDir(tempDir);
|
|
18
|
+
expect(result.workflowId).toBe('myWorkflow');
|
|
19
|
+
});
|
|
20
|
+
it('should extract workflow ID from workflow.js', () => {
|
|
21
|
+
writeFileSync(join(tempDir, 'workflow.js'), 'export default workflow( { name: "blogGenerator", description: "test" } );');
|
|
22
|
+
const result = parseWorkflowDir(tempDir);
|
|
23
|
+
expect(result.workflowId).toBe('blogGenerator');
|
|
24
|
+
});
|
|
25
|
+
it('should prefer workflow.ts over workflow.js', () => {
|
|
26
|
+
writeFileSync(join(tempDir, 'workflow.ts'), 'export default workflow( { name: \'fromTs\' } );');
|
|
27
|
+
writeFileSync(join(tempDir, 'workflow.js'), 'export default workflow( { name: \'fromJs\' } );');
|
|
28
|
+
const result = parseWorkflowDir(tempDir);
|
|
29
|
+
expect(result.workflowId).toBe('fromTs');
|
|
30
|
+
});
|
|
31
|
+
it('should return undefined workflowId when no workflow file exists', () => {
|
|
32
|
+
const result = parseWorkflowDir(tempDir);
|
|
33
|
+
expect(result.workflowId).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
it('should return undefined workflowId when workflow file has no name property', () => {
|
|
36
|
+
writeFileSync(join(tempDir, 'workflow.ts'), 'export default workflow( { description: \'no name here\' } );');
|
|
37
|
+
const result = parseWorkflowDir(tempDir);
|
|
38
|
+
expect(result.workflowId).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
it('should list scenario names from scenarios directory', () => {
|
|
41
|
+
const scenariosDir = join(tempDir, 'scenarios');
|
|
42
|
+
mkdirSync(scenariosDir);
|
|
43
|
+
writeFileSync(join(scenariosDir, 'test_input.json'), '{}');
|
|
44
|
+
writeFileSync(join(scenariosDir, 'edge_case.json'), '{}');
|
|
45
|
+
const result = parseWorkflowDir(tempDir);
|
|
46
|
+
expect(result.scenarioNames).toEqual(expect.arrayContaining(['test_input', 'edge_case']));
|
|
47
|
+
expect(result.scenarioNames).toHaveLength(2);
|
|
48
|
+
});
|
|
49
|
+
it('should return empty scenarioNames when no scenarios directory exists', () => {
|
|
50
|
+
const result = parseWorkflowDir(tempDir);
|
|
51
|
+
expect(result.scenarioNames).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
it('should ignore non-json files in scenarios directory', () => {
|
|
54
|
+
const scenariosDir = join(tempDir, 'scenarios');
|
|
55
|
+
mkdirSync(scenariosDir);
|
|
56
|
+
writeFileSync(join(scenariosDir, 'test_input.json'), '{}');
|
|
57
|
+
writeFileSync(join(scenariosDir, 'README.md'), '# Scenarios');
|
|
58
|
+
const result = parseWorkflowDir(tempDir);
|
|
59
|
+
expect(result.scenarioNames).toEqual(['test_input']);
|
|
60
|
+
});
|
|
61
|
+
it('should handle multiline workflow files', () => {
|
|
62
|
+
writeFileSync(join(tempDir, 'workflow.ts'), `import { workflow } from '@outputai/core';
|
|
63
|
+
|
|
64
|
+
export default workflow( {
|
|
65
|
+
name: 'complexWorkflow',
|
|
66
|
+
description: 'A complex workflow',
|
|
67
|
+
fn: async ( input ) => {
|
|
68
|
+
return input;
|
|
69
|
+
}
|
|
70
|
+
} );`);
|
|
71
|
+
const result = parseWorkflowDir(tempDir);
|
|
72
|
+
expect(result.workflowId).toBe('complexWorkflow');
|
|
73
|
+
});
|
|
74
|
+
});
|
package/dist/views/dev.js
CHANGED
|
@@ -1,31 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect, useRef } from 'react';
|
|
3
3
|
import { Box, Text, Static, useApp, useInput } from 'ink';
|
|
4
4
|
import Spinner from 'ink-spinner';
|
|
5
|
-
import { getServiceStatus, isServiceHealthy, isServiceFailed, SERVICE_HEALTH
|
|
5
|
+
import { getServiceStatus, isServiceHealthy, isServiceFailed, SERVICE_HEALTH } from '#services/docker.js';
|
|
6
6
|
import { config } from '#config.js';
|
|
7
|
+
import { fetchWorkflowRuns } from '#services/workflow_runs.js';
|
|
8
|
+
import { openUrl } from '#utils/open_url.js';
|
|
9
|
+
import { StatusIcon } from '#components/status_icon.js';
|
|
10
|
+
import { WorkflowSummarySection } from '#components/workflow_summary.js';
|
|
11
|
+
import { CommandFooter } from '#components/command_footer.js';
|
|
12
|
+
import { WorkflowListView } from '#views/workflow/list.js';
|
|
7
13
|
const POLL_INTERVAL_MS = 2000;
|
|
8
14
|
const HEALTH_TIMEOUT_MS = 120_000;
|
|
9
|
-
const
|
|
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
|
-
};
|
|
15
|
+
const resolveServiceStatus = (service) => service.health === SERVICE_HEALTH.NONE ? service.state : service.health;
|
|
29
16
|
const fetchServices = async (dockerComposePath) => {
|
|
30
17
|
try {
|
|
31
18
|
return await getServiceStatus(dockerComposePath);
|
|
@@ -97,6 +84,32 @@ const useStatusRefresh = (dockerComposePath, enabled, onServices) => {
|
|
|
97
84
|
return 'continue';
|
|
98
85
|
});
|
|
99
86
|
};
|
|
87
|
+
const useWorkflowPolling = (enabled, onRuns) => {
|
|
88
|
+
const onRunsRef = useRef(onRuns);
|
|
89
|
+
onRunsRef.current = onRuns;
|
|
90
|
+
usePoll(enabled, async () => {
|
|
91
|
+
try {
|
|
92
|
+
const { runs } = await fetchWorkflowRuns({ limit: 100 });
|
|
93
|
+
onRunsRef.current(runs);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// API may not be ready yet
|
|
97
|
+
}
|
|
98
|
+
return 'continue';
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
const useMainViewInput = (isActive, callbacks) => {
|
|
102
|
+
const callbacksRef = useRef(callbacks);
|
|
103
|
+
callbacksRef.current = callbacks;
|
|
104
|
+
useInput(input => {
|
|
105
|
+
if (input === 'o') {
|
|
106
|
+
callbacksRef.current.onOpenTemporal();
|
|
107
|
+
}
|
|
108
|
+
if (input === 'w') {
|
|
109
|
+
callbacksRef.current.onOpenWorkflows();
|
|
110
|
+
}
|
|
111
|
+
}, { isActive });
|
|
112
|
+
};
|
|
100
113
|
const useCtrlC = (onCleanup) => {
|
|
101
114
|
const { exit } = useApp();
|
|
102
115
|
const isExitingRef = useRef(false);
|
|
@@ -108,9 +121,9 @@ const useCtrlC = (onCleanup) => {
|
|
|
108
121
|
});
|
|
109
122
|
};
|
|
110
123
|
const ServiceRow = ({ service }) => {
|
|
111
|
-
const
|
|
124
|
+
const status = resolveServiceStatus(service);
|
|
112
125
|
const ports = service.ports.length ? service.ports.join(', ') : '-';
|
|
113
|
-
return (_jsxs(Box, { children: [
|
|
126
|
+
return (_jsxs(Box, { children: [_jsx(Box, { width: 3, children: _jsx(StatusIcon, { status: status }) }), _jsx(Box, { width: 16, children: _jsx(Text, { children: service.name }) }), _jsx(Text, { dimColor: true, children: status.padEnd(10) }), _jsx(Text, { dimColor: true, children: ports })] }));
|
|
114
127
|
};
|
|
115
128
|
const FailureWarning = ({ services }) => {
|
|
116
129
|
const failed = services.filter(isServiceFailed);
|
|
@@ -128,12 +141,24 @@ const DevSuccessMessage = ({ services }) => {
|
|
|
128
141
|
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" })] }));
|
|
129
142
|
};
|
|
130
143
|
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)) }))] }));
|
|
131
|
-
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(
|
|
144
|
+
const RunningView = ({ services, workflowSummary }) => (_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 }), workflowSummary && _jsx(WorkflowSummarySection, { summary: workflowSummary }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: '🌐 Temporal UI: ' }), _jsx(Text, { bold: true, children: "http://localhost:8080" })] }), _jsx(CommandFooter, { hints: [
|
|
145
|
+
{ key: 'o', label: 'open ui' },
|
|
146
|
+
{ key: 'w', label: 'view workflow runs' },
|
|
147
|
+
{ key: 'ctrl+c', label: 'stop' }
|
|
148
|
+
] })] }));
|
|
149
|
+
const MainDevView = ({ phase, services, workflowSummary }) => {
|
|
150
|
+
if (phase === 'waiting') {
|
|
151
|
+
return _jsx(WaitingView, { services: services });
|
|
152
|
+
}
|
|
153
|
+
return _jsx(RunningView, { services: services, workflowSummary: workflowSummary });
|
|
154
|
+
};
|
|
132
155
|
export const DevApp = ({ dockerComposePath, onCleanup }) => {
|
|
133
156
|
const { exit } = useApp();
|
|
134
157
|
const [phase, setPhase] = useState('waiting');
|
|
135
158
|
const [services, setServices] = useState([]);
|
|
136
159
|
const [successItems, setSuccessItems] = useState([]);
|
|
160
|
+
const [activeView, setActiveView] = useState('main');
|
|
161
|
+
const [workflowRuns, setWorkflowRuns] = useState([]);
|
|
137
162
|
useHealthPolling(dockerComposePath, phase === 'waiting', {
|
|
138
163
|
onServices: setServices,
|
|
139
164
|
onAllHealthy: svcs => {
|
|
@@ -146,6 +171,17 @@ export const DevApp = ({ dockerComposePath, onCleanup }) => {
|
|
|
146
171
|
onTimeout: () => exit(new Error('Timeout waiting for services to become healthy'))
|
|
147
172
|
});
|
|
148
173
|
useStatusRefresh(dockerComposePath, phase === 'running', setServices);
|
|
174
|
+
useWorkflowPolling(phase === 'running' || phase === 'failed', setWorkflowRuns);
|
|
175
|
+
useMainViewInput(activeView === 'main' && phase !== 'waiting', {
|
|
176
|
+
onOpenTemporal: () => openUrl('http://localhost:8080'),
|
|
177
|
+
onOpenWorkflows: () => setActiveView('workflows')
|
|
178
|
+
});
|
|
149
179
|
useCtrlC(onCleanup);
|
|
150
|
-
|
|
180
|
+
const workflowSummary = workflowRuns.length > 0 ? {
|
|
181
|
+
running: workflowRuns.filter(r => r.status === 'running').length,
|
|
182
|
+
completed: workflowRuns.filter(r => r.status === 'completed').length,
|
|
183
|
+
failed: workflowRuns.filter(r => r.status === 'failed').length,
|
|
184
|
+
total: workflowRuns.length
|
|
185
|
+
} : null;
|
|
186
|
+
return (_jsxs(_Fragment, { children: [_jsx(Static, { items: successItems, children: item => _jsx(DevSuccessMessage, { services: item.services }, item.id) }), _jsxs(Box, { flexDirection: "column", children: [activeView === 'main' && (_jsx(MainDevView, { phase: phase, services: services, workflowSummary: workflowSummary })), activeView === 'workflows' && (_jsx(WorkflowListView, { runs: workflowRuns, onBack: () => setActiveView('main') }))] })] }));
|
|
151
187
|
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useRef, useMemo } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import Spinner from 'ink-spinner';
|
|
5
|
+
import { getWorkflowIdResult } from '#api/generated/api.js';
|
|
6
|
+
import { StatusIcon, statusColor } from '#components/status_icon.js';
|
|
7
|
+
import { elapsedMs, formatDurationCompact } from '#utils/date_formatter.js';
|
|
8
|
+
import { CommandFooter } from '#components/command_footer.js';
|
|
9
|
+
import { openUrl } from '#utils/open_url.js';
|
|
10
|
+
const TEMPORAL_UI_BASE = 'http://localhost:8080';
|
|
11
|
+
const VISIBLE_ROWS = 15;
|
|
12
|
+
const STATUS_ORDER = {
|
|
13
|
+
running: 0,
|
|
14
|
+
failed: 1,
|
|
15
|
+
timed_out: 2,
|
|
16
|
+
terminated: 3,
|
|
17
|
+
canceled: 4,
|
|
18
|
+
continued: 5,
|
|
19
|
+
completed: 6
|
|
20
|
+
};
|
|
21
|
+
const sortRuns = (runs) => [...runs].sort((a, b) => {
|
|
22
|
+
const statusDiff = (STATUS_ORDER[a.status ?? ''] ?? Infinity) - (STATUS_ORDER[b.status ?? ''] ?? Infinity);
|
|
23
|
+
if (statusDiff !== 0) {
|
|
24
|
+
return statusDiff;
|
|
25
|
+
}
|
|
26
|
+
const aTime = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
|
27
|
+
const bTime = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
|
28
|
+
return bTime - aTime;
|
|
29
|
+
});
|
|
30
|
+
const truncate = (str, max) => str.length > max ? str.slice(0, max - 1) + '…' : str;
|
|
31
|
+
const COL = {
|
|
32
|
+
indicator: 2,
|
|
33
|
+
icon: 3,
|
|
34
|
+
status: 12,
|
|
35
|
+
type: 20,
|
|
36
|
+
id: 32,
|
|
37
|
+
duration: 10
|
|
38
|
+
};
|
|
39
|
+
const WorkflowRow = ({ run, selected }) => {
|
|
40
|
+
const status = run.status ?? 'running';
|
|
41
|
+
const color = statusColor(status);
|
|
42
|
+
const duration = run.startedAt ? formatDurationCompact(elapsedMs(run.startedAt, run.completedAt)) : '-';
|
|
43
|
+
return (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(Text, { color: selected ? 'cyan' : undefined, bold: selected, children: selected ? '▸' : ' ' }) }), _jsx(Box, { width: COL.icon, children: _jsx(StatusIcon, { status: status }) }), _jsx(Box, { width: COL.status, children: _jsx(Text, { color: color, children: status }) }), _jsx(Box, { width: COL.type, children: _jsx(Text, { bold: selected, children: truncate(run.workflowType ?? '-', COL.type - 2) }) }), _jsx(Box, { width: COL.id, children: _jsx(Text, { dimColor: !selected, children: truncate(run.workflowId ?? '-', COL.id - 2) }) }), _jsx(Box, { width: COL.duration, justifyContent: "flex-end", children: _jsx(Text, { dimColor: true, children: duration }) })] }));
|
|
44
|
+
};
|
|
45
|
+
const HeaderRow = () => (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: COL.icon, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: COL.status, children: _jsx(Text, { dimColor: true, bold: true, children: "STATUS" }) }), _jsx(Box, { width: COL.type, children: _jsx(Text, { dimColor: true, bold: true, children: "TYPE" }) }), _jsx(Box, { width: COL.id, children: _jsx(Text, { dimColor: true, bold: true, children: "WORKFLOW ID" }) }), _jsx(Box, { width: COL.duration, justifyContent: "flex-end", children: _jsx(Text, { dimColor: true, bold: true, children: "DURATION" }) })] }));
|
|
46
|
+
const WorkflowDetailPane = ({ detail, loading }) => {
|
|
47
|
+
if (loading) {
|
|
48
|
+
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading details..." })] }));
|
|
49
|
+
}
|
|
50
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Status: " }), _jsx(Text, { color: statusColor(detail.status ?? ''), children: detail.status })] }), detail.error && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "red", children: "Error:" }), _jsx(Text, { color: "red", children: truncate(detail.error, 300) })] })), detail.output !== undefined && detail.output !== null && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Output:" }), _jsx(Text, { children: truncate(JSON.stringify(detail.output, null, 2), 400) })] }))] }));
|
|
51
|
+
};
|
|
52
|
+
export const WorkflowListView = ({ runs, onBack }) => {
|
|
53
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
54
|
+
const [detail, setDetail] = useState(null);
|
|
55
|
+
const [detailLoading, setDetailLoading] = useState(false);
|
|
56
|
+
const cacheRef = useRef(new Map());
|
|
57
|
+
const fetchIdRef = useRef(0);
|
|
58
|
+
const sortedRuns = useMemo(() => sortRuns(runs), [runs]);
|
|
59
|
+
const clampedIndex = Math.min(selectedIndex, Math.max(0, sortedRuns.length - 1));
|
|
60
|
+
const selectedRun = sortedRuns[clampedIndex];
|
|
61
|
+
const selectedWorkflowId = selectedRun?.workflowId;
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (clampedIndex !== selectedIndex) {
|
|
64
|
+
setSelectedIndex(clampedIndex);
|
|
65
|
+
}
|
|
66
|
+
}, [clampedIndex, selectedIndex]);
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!selectedWorkflowId) {
|
|
69
|
+
setDetail(null);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const cached = cacheRef.current.get(selectedWorkflowId);
|
|
73
|
+
if (cached) {
|
|
74
|
+
setDetail(cached);
|
|
75
|
+
setDetailLoading(false);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const currentFetchId = ++fetchIdRef.current;
|
|
79
|
+
setDetailLoading(true);
|
|
80
|
+
getWorkflowIdResult(selectedWorkflowId)
|
|
81
|
+
.then(response => {
|
|
82
|
+
if (fetchIdRef.current !== currentFetchId) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const data = response.data;
|
|
86
|
+
cacheRef.current.set(selectedWorkflowId, data);
|
|
87
|
+
setDetail(data);
|
|
88
|
+
setDetailLoading(false);
|
|
89
|
+
})
|
|
90
|
+
.catch(() => {
|
|
91
|
+
if (fetchIdRef.current !== currentFetchId) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
setDetail(null);
|
|
95
|
+
setDetailLoading(false);
|
|
96
|
+
});
|
|
97
|
+
}, [selectedWorkflowId]);
|
|
98
|
+
useInput((input, key) => {
|
|
99
|
+
if (key.upArrow) {
|
|
100
|
+
setSelectedIndex(i => Math.max(0, i - 1));
|
|
101
|
+
}
|
|
102
|
+
else if (key.downArrow) {
|
|
103
|
+
setSelectedIndex(i => Math.min(sortedRuns.length - 1, i + 1));
|
|
104
|
+
}
|
|
105
|
+
else if (key.escape || input === 'q') {
|
|
106
|
+
onBack();
|
|
107
|
+
}
|
|
108
|
+
else if (input === 'o' && selectedWorkflowId) {
|
|
109
|
+
openUrl(`${TEMPORAL_UI_BASE}/namespaces/default/workflows/${selectedWorkflowId}`);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
const windowStart = useMemo(() => {
|
|
113
|
+
const half = Math.floor(VISIBLE_ROWS / 2);
|
|
114
|
+
const start = Math.max(0, clampedIndex - half);
|
|
115
|
+
const maxStart = Math.max(0, sortedRuns.length - VISIBLE_ROWS);
|
|
116
|
+
return Math.min(start, maxStart);
|
|
117
|
+
}, [clampedIndex, sortedRuns.length]);
|
|
118
|
+
const visibleRuns = sortedRuns.slice(windowStart, windowStart + VISIBLE_ROWS);
|
|
119
|
+
if (sortedRuns.length === 0) {
|
|
120
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Workflow Runs" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "No workflow runs found." }) }), _jsx(CommandFooter, { hints: [{ key: 'q', label: 'back' }] })] }));
|
|
121
|
+
}
|
|
122
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: ["Workflow Runs (", sortedRuns.length, ")"] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(HeaderRow, {}), windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more above"] }), visibleRuns.map((run, i) => (_jsx(WorkflowRow, { run: run, selected: windowStart + i === clampedIndex }, `${run.workflowId}-${run.startedAt}-${windowStart + i}`))), windowStart + VISIBLE_ROWS < sortedRuns.length && (_jsxs(Text, { dimColor: true, children: [" \u2193 ", sortedRuns.length - windowStart - VISIBLE_ROWS, " more below"] }))] }), detail && _jsx(WorkflowDetailPane, { detail: detail, loading: detailLoading }), detailLoading && !detail && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading details..." })] })), _jsx(CommandFooter, { hints: [
|
|
123
|
+
{ key: '↑/↓', label: 'navigate' },
|
|
124
|
+
{ key: 'o', label: 'open in temporal' },
|
|
125
|
+
{ key: 'q', label: 'back' }
|
|
126
|
+
] })] }));
|
|
127
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13-dev.98dfd72.0",
|
|
4
4
|
"description": "CLI for Output.ai workflow generation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -14,30 +14,31 @@
|
|
|
14
14
|
"output": "./bin/run.js"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@anthropic-ai/claude-agent-sdk": "0.2.
|
|
18
|
-
"@aws-sdk/client-s3": "3.
|
|
17
|
+
"@anthropic-ai/claude-agent-sdk": "0.2.92",
|
|
18
|
+
"@aws-sdk/client-s3": "3.1024.0",
|
|
19
19
|
"@hackylabs/deep-redact": "3.0.5",
|
|
20
20
|
"@inquirer/prompts": "8.3.2",
|
|
21
|
-
"@oclif/core": "4.10.
|
|
22
|
-
"@oclif/plugin-help": "6.2.
|
|
21
|
+
"@oclif/core": "4.10.5",
|
|
22
|
+
"@oclif/plugin-help": "6.2.42",
|
|
23
23
|
"change-case": "5.4.4",
|
|
24
24
|
"cli-progress": "3.12.0",
|
|
25
25
|
"cli-table3": "0.6.5",
|
|
26
26
|
"date-fns": "4.1.0",
|
|
27
27
|
"debug": "4.4.3",
|
|
28
|
-
"dotenv": "17.
|
|
28
|
+
"dotenv": "17.4.0",
|
|
29
29
|
"handlebars": "4.7.9",
|
|
30
|
-
"js-yaml": "4.1.1",
|
|
31
30
|
"ink": "6.8.0",
|
|
32
31
|
"ink-spinner": "5.0.0",
|
|
33
|
-
"
|
|
32
|
+
"js-yaml": "4.1.1",
|
|
33
|
+
"json-schema-library": "11.1.0",
|
|
34
34
|
"ky": "1.14.3",
|
|
35
35
|
"react": "19.2.4",
|
|
36
36
|
"semver": "7.7.4",
|
|
37
|
+
"undici": "8.0.2",
|
|
37
38
|
"yaml": "^2.8.3",
|
|
38
|
-
"@outputai/credentials": "0.1.
|
|
39
|
-
"@outputai/evals": "0.1.
|
|
40
|
-
"@outputai/llm": "0.1.
|
|
39
|
+
"@outputai/credentials": "0.1.13-dev.98dfd72.0",
|
|
40
|
+
"@outputai/evals": "0.1.13-dev.98dfd72.0",
|
|
41
|
+
"@outputai/llm": "0.1.13-dev.98dfd72.0"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
44
|
"@types/cli-progress": "3.11.6",
|