@outputai/cli 0.1.12-next.ecd4dda.0 → 0.1.13-dev.01b8dea.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/config/costs.yml +6 -0
- package/dist/assets/docker/docker-compose-dev.yml +2 -8
- package/dist/commands/fix.d.ts +10 -0
- package/dist/commands/fix.js +58 -0
- package/dist/commands/fix.spec.d.ts +1 -0
- package/dist/commands/fix.spec.js +77 -0
- 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/fix_package.d.ts +34 -0
- package/dist/services/fix_package.js +105 -0
- package/dist/services/fix_package.spec.d.ts +1 -0
- package/dist/services/fix_package.spec.js +63 -0
- 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 -3
- package/dist/templates/project/README.md.template +2 -2
- package/dist/templates/project/package.json.template +8 -7
- 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 +70 -27
- package/dist/views/workflow/list.d.ts +6 -0
- package/dist/views/workflow/list.js +127 -0
- package/package.json +12 -11
|
@@ -4,16 +4,50 @@ This is an **Output.ai** project - a framework for building reliable, production
|
|
|
4
4
|
|
|
5
5
|
## Getting Started
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Install Claude Code plugins for full framework documentation and AI-assisted development:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
claude plugin marketplace add growthxai/output
|
|
11
11
|
claude plugin install outputai@outputai --scope project
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm run output:dev # Start dev environment (worker + Temporal)
|
|
18
|
+
npm run output:worker:build # Build TypeScript to dist/
|
|
19
|
+
npm run output:worker:watch # Build + restart on file changes
|
|
20
|
+
npm run output:worker # Install, build, and start worker
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Project Structure
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
src/
|
|
27
|
+
workflows/ # Each subfolder is one workflow
|
|
28
|
+
<name>/
|
|
29
|
+
workflow.ts # Workflow orchestration (must be deterministic - no I/O)
|
|
30
|
+
steps.ts # Step functions (all I/O: HTTP, LLM, DB)
|
|
31
|
+
evaluators.ts # Evaluator functions (LLM-based quality assessment)
|
|
32
|
+
types.ts # Zod schemas and TypeScript types
|
|
33
|
+
prompts/ # .prompt files (Liquid.js templates with YAML frontmatter)
|
|
34
|
+
scenarios/ # Test scenario JSON files
|
|
35
|
+
clients/ # Shared HTTP clients (use @outputai/http, not fetch/axios)
|
|
36
|
+
shared/ # Shared utilities across workflows
|
|
37
|
+
config/
|
|
38
|
+
costs.yml # Token/API pricing overrides
|
|
39
|
+
credentials.yml.enc # Encrypted secrets (edit via: output credentials edit)
|
|
40
|
+
credentials.yml.template # Credential structure reference
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Key Conventions
|
|
44
|
+
|
|
45
|
+
- **Workflows are deterministic**: No I/O, no `Date.now()`, no `Math.random()` in `workflow.ts`. All side effects go in steps or evaluators.
|
|
46
|
+
- **HTTP clients**: Always use `httpClient` from `@outputai/http` -- never raw `fetch` or `axios`. This enables automatic tracing and cost tracking.
|
|
47
|
+
- **LLM calls**: Use `generateText` from `@outputai/llm` with `.prompt` files. Never call LLM APIs directly.
|
|
48
|
+
|
|
14
49
|
---
|
|
15
50
|
|
|
16
51
|
## Project-Specific Instructions
|
|
17
52
|
|
|
18
53
|
<!-- Add your project-specific instructions below -->
|
|
19
|
-
|
|
@@ -72,7 +72,7 @@ Add your keys in the editor:
|
|
|
72
72
|
### 3. Start Output Services
|
|
73
73
|
|
|
74
74
|
```bash
|
|
75
|
-
npm run dev
|
|
75
|
+
npm run output:dev
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
This starts:
|
|
@@ -91,7 +91,7 @@ npx output workflow run blog_evaluator paulgraham_hwh
|
|
|
91
91
|
|
|
92
92
|
### 5. Stop Services
|
|
93
93
|
|
|
94
|
-
Press `Ctrl+C` in the terminal running `npm run dev` to stop all services gracefully.
|
|
94
|
+
Press `Ctrl+C` in the terminal running `npm run output:dev` to stop all services gracefully.
|
|
95
95
|
|
|
96
96
|
### 6. View Logs
|
|
97
97
|
|
|
@@ -6,18 +6,19 @@
|
|
|
6
6
|
"main": "dist/worker.js",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"output:worker:install": "npm install",
|
|
9
|
-
"output:worker:build": "rm -rf dist/* && tsc -p ./ &&
|
|
9
|
+
"output:worker:build": "rm -rf dist/* && tsc -p ./ && output-copy-assets",
|
|
10
10
|
"output:worker:start": "output-worker",
|
|
11
|
-
"
|
|
11
|
+
"output:worker": "npm run output:worker:install && npm run output:worker:build && npm run output:worker:start",
|
|
12
|
+
"output:worker:watch": "npx nodemon --watch src --watch package.json --ext ts,js,json,prompt,md --ignore 'dist/**' --ignore '**/*.spec.*' --ignore '**/*.test.*' --exec 'npm run output:worker'",
|
|
13
|
+
"output:dev": "output dev"
|
|
12
14
|
},
|
|
13
15
|
"dependencies": {
|
|
14
|
-
"@outputai/output": "
|
|
16
|
+
"@outputai/output": "{{frameworkVersion}}"
|
|
15
17
|
},
|
|
16
18
|
"devDependencies": {
|
|
17
|
-
"@types/node": "
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"typescript": "^5.7.0"
|
|
19
|
+
"@types/node": "24.5.2",
|
|
20
|
+
"nodemon": "3.1.14",
|
|
21
|
+
"typescript": "5.9.3"
|
|
21
22
|
},
|
|
22
23
|
"engines": {
|
|
23
24
|
"node": ">=24.3.0"
|
|
@@ -14,7 +14,17 @@ export declare function formatDuration(ms: number): string;
|
|
|
14
14
|
*/
|
|
15
15
|
export declare function formatDate(isoString: string | null | undefined): string;
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
17
|
+
* Calculate elapsed milliseconds between two ISO timestamps.
|
|
18
|
+
* If completedAt is null/undefined, uses current time (for in-progress durations).
|
|
19
|
+
*/
|
|
20
|
+
export declare function elapsedMs(startedAt: string, completedAt?: string | null): number;
|
|
21
|
+
/**
|
|
22
|
+
* Format a duration in milliseconds to a compact string that fits in narrow columns.
|
|
23
|
+
* Always returns a short single-token string (e.g., "150ms", "7.56s", "24.2m", "1.3h").
|
|
24
|
+
*/
|
|
25
|
+
export declare function formatDurationCompact(ms: number): string;
|
|
26
|
+
/**
|
|
27
|
+
* Format a duration between two ISO timestamps.
|
|
18
28
|
*
|
|
19
29
|
* @param startedAt - ISO 8601 start timestamp
|
|
20
30
|
* @param completedAt - ISO 8601 end timestamp (or null if still running)
|
|
@@ -33,7 +33,32 @@ export function formatDate(isoString) {
|
|
|
33
33
|
return format(parseISO(isoString), 'MMM d, yyyy h:mm a');
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
36
|
+
* Calculate elapsed milliseconds between two ISO timestamps.
|
|
37
|
+
* If completedAt is null/undefined, uses current time (for in-progress durations).
|
|
38
|
+
*/
|
|
39
|
+
export function elapsedMs(startedAt, completedAt) {
|
|
40
|
+
const start = parseISO(startedAt).getTime();
|
|
41
|
+
const end = completedAt ? parseISO(completedAt).getTime() : Date.now();
|
|
42
|
+
return end - start;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Format a duration in milliseconds to a compact string that fits in narrow columns.
|
|
46
|
+
* Always returns a short single-token string (e.g., "150ms", "7.56s", "24.2m", "1.3h").
|
|
47
|
+
*/
|
|
48
|
+
export function formatDurationCompact(ms) {
|
|
49
|
+
if (ms < 1000) {
|
|
50
|
+
return `${ms}ms`;
|
|
51
|
+
}
|
|
52
|
+
if (ms < 60_000) {
|
|
53
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
54
|
+
}
|
|
55
|
+
if (ms < 3_600_000) {
|
|
56
|
+
return `${(ms / 60_000).toFixed(1)}m`;
|
|
57
|
+
}
|
|
58
|
+
return `${(ms / 3_600_000).toFixed(1)}h`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Format a duration between two ISO timestamps.
|
|
37
62
|
*
|
|
38
63
|
* @param startedAt - ISO 8601 start timestamp
|
|
39
64
|
* @param completedAt - ISO 8601 end timestamp (or null if still running)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
describe('interactive', () => {
|
|
3
|
+
beforeEach(async () => {
|
|
4
|
+
// Re-import to reset singleton state
|
|
5
|
+
const mod = await import('./interactive.js');
|
|
6
|
+
mod.setNonInteractive(false);
|
|
7
|
+
});
|
|
8
|
+
it('isInteractive returns true by default when TTY is available', async () => {
|
|
9
|
+
const originalIsTTY = process.stdin.isTTY;
|
|
10
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
|
11
|
+
const { isInteractive } = await import('./interactive.js');
|
|
12
|
+
expect(isInteractive()).toBe(true);
|
|
13
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
|
|
14
|
+
});
|
|
15
|
+
it('isInteractive returns false when no TTY', async () => {
|
|
16
|
+
const originalIsTTY = process.stdin.isTTY;
|
|
17
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true });
|
|
18
|
+
const { isInteractive } = await import('./interactive.js');
|
|
19
|
+
expect(isInteractive()).toBe(false);
|
|
20
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
|
|
21
|
+
});
|
|
22
|
+
it('isInteractive returns false after setNonInteractive(true)', async () => {
|
|
23
|
+
const originalIsTTY = process.stdin.isTTY;
|
|
24
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
|
25
|
+
const { isInteractive, setNonInteractive } = await import('./interactive.js');
|
|
26
|
+
setNonInteractive(true);
|
|
27
|
+
expect(isInteractive()).toBe(false);
|
|
28
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
|
|
29
|
+
});
|
|
30
|
+
it('setNonInteractive(false) restores interactive mode', async () => {
|
|
31
|
+
const originalIsTTY = process.stdin.isTTY;
|
|
32
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
|
33
|
+
const { isInteractive, setNonInteractive } = await import('./interactive.js');
|
|
34
|
+
setNonInteractive(true);
|
|
35
|
+
expect(isInteractive()).toBe(false);
|
|
36
|
+
setNonInteractive(false);
|
|
37
|
+
expect(isInteractive()).toBe(true);
|
|
38
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const openUrl: (url: string) => void;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
export const openUrl = (url) => {
|
|
3
|
+
if (process.platform === 'darwin') {
|
|
4
|
+
execFile('open', [url]);
|
|
5
|
+
}
|
|
6
|
+
else if (process.platform === 'win32') {
|
|
7
|
+
execFile('cmd', ['/c', 'start', url]);
|
|
8
|
+
}
|
|
9
|
+
else {
|
|
10
|
+
execFile('xdg-open', [url]);
|
|
11
|
+
}
|
|
12
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
type ConfirmOptions = {
|
|
2
|
+
message: string;
|
|
3
|
+
default?: boolean;
|
|
4
|
+
};
|
|
5
|
+
type InputOptions = {
|
|
6
|
+
message: string;
|
|
7
|
+
default?: string;
|
|
8
|
+
validate?: (value: string) => boolean | string;
|
|
9
|
+
};
|
|
10
|
+
type PasswordOptions = {
|
|
11
|
+
message: string;
|
|
12
|
+
mask?: boolean;
|
|
13
|
+
};
|
|
14
|
+
export declare const confirm: (options: ConfirmOptions) => Promise<boolean>;
|
|
15
|
+
export declare const input: (options: InputOptions) => Promise<string>;
|
|
16
|
+
export declare const password: (options: PasswordOptions) => Promise<string>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { confirm as inquirerConfirm, input as inquirerInput, password as inquirerPassword } from '@inquirer/prompts';
|
|
2
|
+
import { isInteractive } from './interactive.js';
|
|
3
|
+
export const confirm = async (options) => {
|
|
4
|
+
if (!isInteractive()) {
|
|
5
|
+
return options.default ?? true;
|
|
6
|
+
}
|
|
7
|
+
return inquirerConfirm(options);
|
|
8
|
+
};
|
|
9
|
+
export const input = async (options) => {
|
|
10
|
+
if (!isInteractive()) {
|
|
11
|
+
return options.default ?? '';
|
|
12
|
+
}
|
|
13
|
+
return inquirerInput(options);
|
|
14
|
+
};
|
|
15
|
+
export const password = async (options) => {
|
|
16
|
+
if (!isInteractive()) {
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
19
|
+
return inquirerPassword(options);
|
|
20
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
});
|