@outputai/cli 0.1.13-dev.98dfd72.0 → 0.1.13-dev.e3e3291.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 +0 -2
- package/dist/api/generated/api.d.ts +319 -85
- package/dist/api/generated/api.js +66 -8
- package/dist/assets/docker/docker-compose-dev.yml +1 -1
- 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/plan.js +1 -5
- package/dist/commands/workflow/plan.spec.js +2 -3
- package/dist/commands/workflow/run.js +1 -2
- package/dist/commands/workflow/run.spec.js +0 -1
- package/dist/commands/workflow/start.js +1 -2
- package/dist/commands/workflow/start.spec.js +0 -1
- package/dist/commands/workflow/test_eval.js +2 -2
- package/dist/generated/framework_version.json +1 -1
- package/dist/hooks/init.js +0 -4
- 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/datasets.d.ts +1 -1
- package/dist/services/datasets.js +41 -37
- package/dist/services/datasets.test.js +202 -0
- package/dist/services/env_configurator.js +1 -1
- package/dist/services/env_configurator.spec.js +12 -12
- package/dist/services/project_scaffold.js +2 -2
- package/dist/services/project_scaffold.spec.js +6 -6
- package/dist/services/workflow_builder.js +1 -5
- package/dist/services/workflow_builder.spec.js +2 -3
- package/dist/utils/format_workflow_result.d.ts +3 -3
- package/package.json +4 -5
- package/dist/commands/credentials/set.d.ts +0 -14
- package/dist/commands/credentials/set.js +0 -57
- package/dist/commands/credentials/set.spec.js +0 -95
- package/dist/utils/interactive.d.ts +0 -2
- package/dist/utils/interactive.js +0 -5
- package/dist/utils/interactive.spec.d.ts +0 -1
- package/dist/utils/interactive.spec.js +0 -40
- package/dist/utils/prompt.d.ts +0 -17
- package/dist/utils/prompt.js +0 -20
- package/dist/utils/prompt.spec.d.ts +0 -1
- package/dist/utils/prompt.spec.js +0 -74
- package/dist/utils/proxy.d.ts +0 -1
- package/dist/utils/proxy.js +0 -9
- package/dist/utils/proxy.spec.d.ts +0 -1
- package/dist/utils/proxy.spec.js +0 -39
- /package/dist/{commands/credentials/set.spec.d.ts → services/datasets.test.d.ts} +0 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtemp, rm, writeFile, readFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
import { readDatasetFile, readAllDatasets, writeDataset, listDatasets } from './datasets.js';
|
|
7
|
+
const ctx = { tmpDir: '' };
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
ctx.tmpDir = await mkdtemp(join(tmpdir(), 'output-datasets-test-'));
|
|
10
|
+
});
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await rm(ctx.tmpDir, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
function writeYaml(filePath, obj) {
|
|
15
|
+
return writeFile(filePath, yaml.dump(obj, { lineWidth: 120, noRefs: true, sortKeys: false }), 'utf-8');
|
|
16
|
+
}
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// readDatasetFile
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
describe('readDatasetFile', () => {
|
|
21
|
+
it('parses a multi-case file and returns all cases', async () => {
|
|
22
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
23
|
+
await writeYaml(filePath, {
|
|
24
|
+
case_a: { input: { query: 'foo' }, ground_truth: { expected: 1 } },
|
|
25
|
+
case_b: { input: { query: 'bar' } },
|
|
26
|
+
case_c: { input: { query: 'baz' }, ground_truth: { expected: 3 } }
|
|
27
|
+
});
|
|
28
|
+
const datasets = await readDatasetFile(filePath);
|
|
29
|
+
expect(datasets).toHaveLength(3);
|
|
30
|
+
expect(datasets.map(d => d.name)).toEqual(['case_a', 'case_b', 'case_c']);
|
|
31
|
+
expect(datasets[0].input).toEqual({ query: 'foo' });
|
|
32
|
+
expect(datasets[0].ground_truth).toEqual({ expected: 1 });
|
|
33
|
+
});
|
|
34
|
+
it('attaches _source with the absolute file path to each dataset', async () => {
|
|
35
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
36
|
+
await writeYaml(filePath, {
|
|
37
|
+
my_case: { input: { x: 1 } }
|
|
38
|
+
});
|
|
39
|
+
const [dataset] = await readDatasetFile(filePath);
|
|
40
|
+
expect(dataset._source).toBe(filePath);
|
|
41
|
+
});
|
|
42
|
+
it('throws when a case is missing input', async () => {
|
|
43
|
+
const filePath = join(ctx.tmpDir, 'bad.yml');
|
|
44
|
+
await writeYaml(filePath, {
|
|
45
|
+
good_case: { input: { x: 1 } },
|
|
46
|
+
bad_case: { ground_truth: { expected: 42 } }
|
|
47
|
+
});
|
|
48
|
+
await expect(readDatasetFile(filePath)).rejects.toThrow('Dataset case "bad_case" in');
|
|
49
|
+
});
|
|
50
|
+
it('throws when file content is not an object', async () => {
|
|
51
|
+
const filePath = join(ctx.tmpDir, 'bad.yml');
|
|
52
|
+
await writeFile(filePath, 'just a string', 'utf-8');
|
|
53
|
+
await expect(readDatasetFile(filePath)).rejects.toThrow('Invalid dataset file');
|
|
54
|
+
});
|
|
55
|
+
it('throws with clear message when file content is a YAML array', async () => {
|
|
56
|
+
const filePath = join(ctx.tmpDir, 'bad.yml');
|
|
57
|
+
await writeFile(filePath, '- foo\n- bar\n', 'utf-8');
|
|
58
|
+
await expect(readDatasetFile(filePath)).rejects.toThrow('Invalid dataset file');
|
|
59
|
+
});
|
|
60
|
+
it('preserves last_output and last_eval fields', async () => {
|
|
61
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
62
|
+
await writeYaml(filePath, {
|
|
63
|
+
cached_case: {
|
|
64
|
+
input: { q: 'hello' },
|
|
65
|
+
last_output: { output: { result: 42 }, executionTimeMs: 100, date: '2026-01-01T00:00:00.000Z' }
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
const [dataset] = await readDatasetFile(filePath);
|
|
69
|
+
expect(dataset.last_output?.output).toEqual({ result: 42 });
|
|
70
|
+
expect(dataset.last_output?.executionTimeMs).toBe(100);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// readAllDatasets
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
describe('readAllDatasets', () => {
|
|
77
|
+
it('flattens cases from multiple files', async () => {
|
|
78
|
+
const datasetsDir = join(ctx.tmpDir, 'src', 'workflows', 'my_workflow', 'tests', 'datasets');
|
|
79
|
+
await mkdir(datasetsDir, { recursive: true });
|
|
80
|
+
await writeYaml(join(datasetsDir, 'group_a.yml'), {
|
|
81
|
+
case_1: { input: { x: 1 } },
|
|
82
|
+
case_2: { input: { x: 2 } }
|
|
83
|
+
});
|
|
84
|
+
await writeYaml(join(datasetsDir, 'group_b.yml'), {
|
|
85
|
+
case_3: { input: { x: 3 } }
|
|
86
|
+
});
|
|
87
|
+
const { datasets } = await readAllDatasets('my_workflow', undefined, ctx.tmpDir);
|
|
88
|
+
expect(datasets).toHaveLength(3);
|
|
89
|
+
expect(datasets.map(d => d.name).sort()).toEqual(['case_1', 'case_2', 'case_3']);
|
|
90
|
+
});
|
|
91
|
+
it('filters by case name across files', async () => {
|
|
92
|
+
const datasetsDir = join(ctx.tmpDir, 'src', 'workflows', 'my_workflow', 'tests', 'datasets');
|
|
93
|
+
await mkdir(datasetsDir, { recursive: true });
|
|
94
|
+
await writeYaml(join(datasetsDir, 'group_a.yml'), {
|
|
95
|
+
case_1: { input: { x: 1 } },
|
|
96
|
+
case_2: { input: { x: 2 } }
|
|
97
|
+
});
|
|
98
|
+
await writeYaml(join(datasetsDir, 'group_b.yml'), {
|
|
99
|
+
case_3: { input: { x: 3 } }
|
|
100
|
+
});
|
|
101
|
+
const { datasets } = await readAllDatasets('my_workflow', ['case_2', 'case_3'], ctx.tmpDir);
|
|
102
|
+
expect(datasets).toHaveLength(2);
|
|
103
|
+
expect(datasets.map(d => d.name).sort()).toEqual(['case_2', 'case_3']);
|
|
104
|
+
});
|
|
105
|
+
it('returns empty datasets and a default dir when workflow has no datasets dir', async () => {
|
|
106
|
+
const { datasets, dir } = await readAllDatasets('nonexistent_workflow', undefined, ctx.tmpDir);
|
|
107
|
+
expect(datasets).toHaveLength(0);
|
|
108
|
+
expect(dir).toContain('nonexistent_workflow');
|
|
109
|
+
});
|
|
110
|
+
it('throws when the same case name appears in two different files', async () => {
|
|
111
|
+
const datasetsDir = join(ctx.tmpDir, 'src', 'workflows', 'my_workflow', 'tests', 'datasets');
|
|
112
|
+
await mkdir(datasetsDir, { recursive: true });
|
|
113
|
+
await writeYaml(join(datasetsDir, 'group_a.yml'), { case_1: { input: { x: 1 } } });
|
|
114
|
+
await writeYaml(join(datasetsDir, 'group_b.yml'), { case_1: { input: { x: 2 } } });
|
|
115
|
+
await expect(readAllDatasets('my_workflow', undefined, ctx.tmpDir)).rejects.toThrow('Duplicate dataset case name "case_1"');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// writeDataset
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
describe('writeDataset', () => {
|
|
122
|
+
it('creates a new file with one case keyed by name', async () => {
|
|
123
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
124
|
+
const dataset = { name: 'new_case', input: { q: 'hello' } };
|
|
125
|
+
await writeDataset(dataset, filePath);
|
|
126
|
+
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
127
|
+
expect(raw).toHaveProperty('new_case');
|
|
128
|
+
expect(raw.new_case.input).toEqual({ q: 'hello' });
|
|
129
|
+
expect(raw.new_case).not.toHaveProperty('name');
|
|
130
|
+
});
|
|
131
|
+
it('does not write _source into the file', async () => {
|
|
132
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
133
|
+
const dataset = { name: 'my_case', input: { q: 'x' }, _source: '/some/path.yml' };
|
|
134
|
+
await writeDataset(dataset, filePath);
|
|
135
|
+
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
136
|
+
expect(raw.my_case).not.toHaveProperty('_source');
|
|
137
|
+
});
|
|
138
|
+
it('updates only the target case, leaving other cases untouched', async () => {
|
|
139
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
140
|
+
await writeYaml(filePath, {
|
|
141
|
+
case_a: { input: { x: 1 }, ground_truth: { expected: 1 } },
|
|
142
|
+
case_b: { input: { x: 2 }, ground_truth: { expected: 2 } }
|
|
143
|
+
});
|
|
144
|
+
await writeDataset({ name: 'case_a', input: { x: 1 }, last_output: { output: { result: 1 }, date: '2026-01-01T00:00:00.000Z' } }, filePath);
|
|
145
|
+
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
146
|
+
expect(raw).toHaveProperty('case_b');
|
|
147
|
+
expect(raw.case_b.ground_truth).toEqual({ expected: 2 });
|
|
148
|
+
});
|
|
149
|
+
it('preserves existing fields when writing last_output then last_eval', async () => {
|
|
150
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
151
|
+
await writeYaml(filePath, {
|
|
152
|
+
my_case: { input: { q: 'hello' }, ground_truth: { expected: 42 } }
|
|
153
|
+
});
|
|
154
|
+
await writeDataset({ name: 'my_case', input: { q: 'hello' }, last_output: { output: { result: 42 }, executionTimeMs: 50, date: '2026-01-01T00:00:00.000Z' } }, filePath);
|
|
155
|
+
await writeDataset({
|
|
156
|
+
name: 'my_case', input: { q: 'hello' },
|
|
157
|
+
last_eval: { output: { datasetName: 'my_case', verdict: 'pass', evaluators: [] }, date: '2026-01-01T00:01:00.000Z' }
|
|
158
|
+
}, filePath);
|
|
159
|
+
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
160
|
+
const caseObj = raw.my_case;
|
|
161
|
+
expect(caseObj).toHaveProperty('last_output');
|
|
162
|
+
expect(caseObj).toHaveProperty('last_eval');
|
|
163
|
+
expect(caseObj).toHaveProperty('ground_truth');
|
|
164
|
+
});
|
|
165
|
+
it('creates parent directories if they do not exist', async () => {
|
|
166
|
+
const filePath = join(ctx.tmpDir, 'deep', 'nested', 'cases.yml');
|
|
167
|
+
await writeDataset({ name: 'my_case', input: { q: 'x' } }, filePath);
|
|
168
|
+
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
169
|
+
expect(raw).toHaveProperty('my_case');
|
|
170
|
+
});
|
|
171
|
+
it('recovers gracefully when existing file contains non-object YAML', async () => {
|
|
172
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
173
|
+
await writeFile(filePath, 'just a string', 'utf-8');
|
|
174
|
+
await writeDataset({ name: 'my_case', input: { q: 'x' } }, filePath);
|
|
175
|
+
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
176
|
+
expect(raw).toHaveProperty('my_case');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// listDatasets
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
describe('listDatasets', () => {
|
|
183
|
+
it('returns one DatasetInfo per case across all files', async () => {
|
|
184
|
+
const datasetsDir = join(ctx.tmpDir, 'src', 'workflows', 'my_workflow', 'tests', 'datasets');
|
|
185
|
+
await mkdir(datasetsDir, { recursive: true });
|
|
186
|
+
await writeYaml(join(datasetsDir, 'core.yml'), {
|
|
187
|
+
case_1: { input: { x: 1 }, last_output: { output: { r: 1 }, date: '2026-01-01T00:00:00.000Z' } },
|
|
188
|
+
case_2: { input: { x: 2 } }
|
|
189
|
+
});
|
|
190
|
+
const infos = await listDatasets('my_workflow', ctx.tmpDir);
|
|
191
|
+
expect(infos).toHaveLength(2);
|
|
192
|
+
const case1 = infos.find(i => i.name === 'case_1');
|
|
193
|
+
expect(case1.hasLastOutput).toBe(true);
|
|
194
|
+
expect(case1.path).toContain('core.yml');
|
|
195
|
+
const case2 = infos.find(i => i.name === 'case_2');
|
|
196
|
+
expect(case2.hasLastOutput).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
it('returns empty array when no datasets directory exists', async () => {
|
|
199
|
+
const infos = await listDatasets('nonexistent_workflow', ctx.tmpDir);
|
|
200
|
+
expect(infos).toHaveLength(0);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { configureEnvironmentVariables } from './env_configurator.js';
|
|
5
5
|
// Mock inquirer prompts
|
|
6
|
-
vi.mock('
|
|
6
|
+
vi.mock('@inquirer/prompts', () => ({
|
|
7
7
|
input: vi.fn(),
|
|
8
8
|
confirm: vi.fn(),
|
|
9
9
|
password: vi.fn()
|
|
@@ -45,7 +45,7 @@ describe('configureEnvironmentVariables', () => {
|
|
|
45
45
|
expect(result).toBe(false);
|
|
46
46
|
});
|
|
47
47
|
it('should return false if user declines configuration', async () => {
|
|
48
|
-
const { confirm } = await import('
|
|
48
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
49
49
|
vi.mocked(confirm).mockResolvedValue(false);
|
|
50
50
|
await fs.writeFile(testState.envExamplePath, '# API key\nAPIKEY=');
|
|
51
51
|
const result = await configureEnvironmentVariables(testState.tempDir, false);
|
|
@@ -53,14 +53,14 @@ describe('configureEnvironmentVariables', () => {
|
|
|
53
53
|
expect(vi.mocked(confirm)).toHaveBeenCalled();
|
|
54
54
|
});
|
|
55
55
|
it('should return false if no empty variables exist', async () => {
|
|
56
|
-
const { confirm } = await import('
|
|
56
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
57
57
|
vi.mocked(confirm).mockResolvedValue(true);
|
|
58
58
|
await fs.writeFile(testState.envExamplePath, 'APIKEY=my-secret-key');
|
|
59
59
|
const result = await configureEnvironmentVariables(testState.tempDir, false);
|
|
60
60
|
expect(result).toBe(false);
|
|
61
61
|
});
|
|
62
62
|
it('should copy .env.example to .env when user confirms configuration', async () => {
|
|
63
|
-
const { input, confirm } = await import('
|
|
63
|
+
const { input, confirm } = await import('@inquirer/prompts');
|
|
64
64
|
vi.mocked(confirm).mockResolvedValue(true);
|
|
65
65
|
vi.mocked(input).mockResolvedValueOnce('sk-proj-123');
|
|
66
66
|
const originalContent = `# API key
|
|
@@ -72,7 +72,7 @@ APIKEY=`;
|
|
|
72
72
|
await expect(fs.access(testState.envPath)).resolves.toBeUndefined();
|
|
73
73
|
});
|
|
74
74
|
it('should write configured values to .env while leaving .env.example unchanged', async () => {
|
|
75
|
-
const { input, confirm } = await import('
|
|
75
|
+
const { input, confirm } = await import('@inquirer/prompts');
|
|
76
76
|
vi.mocked(confirm).mockResolvedValue(true);
|
|
77
77
|
vi.mocked(input).mockResolvedValueOnce('sk-proj-123');
|
|
78
78
|
const originalContent = `# API key
|
|
@@ -88,7 +88,7 @@ APIKEY=`;
|
|
|
88
88
|
expect(envExampleContent).toBe(originalContent);
|
|
89
89
|
});
|
|
90
90
|
it('should prompt for empty variables and update .env', async () => {
|
|
91
|
-
const { input, confirm } = await import('
|
|
91
|
+
const { input, confirm } = await import('@inquirer/prompts');
|
|
92
92
|
vi.mocked(confirm).mockResolvedValue(true);
|
|
93
93
|
vi.mocked(input).mockResolvedValueOnce('sk-proj-123');
|
|
94
94
|
vi.mocked(input).mockResolvedValueOnce('');
|
|
@@ -105,7 +105,7 @@ OPENAI_API_KEY=`);
|
|
|
105
105
|
expect(content).toContain('OPENAI_API_KEY=');
|
|
106
106
|
});
|
|
107
107
|
it('should preserve comments in .env file', async () => {
|
|
108
|
-
const { input, confirm } = await import('
|
|
108
|
+
const { input, confirm } = await import('@inquirer/prompts');
|
|
109
109
|
vi.mocked(confirm).mockResolvedValue(true);
|
|
110
110
|
vi.mocked(input).mockResolvedValueOnce('test-key');
|
|
111
111
|
const originalContent = `# This is a comment
|
|
@@ -123,7 +123,7 @@ OTHER=value`;
|
|
|
123
123
|
expect(content).toContain('OTHER=value');
|
|
124
124
|
});
|
|
125
125
|
it('should skip placeholder values and only prompt for truly empty variables', async () => {
|
|
126
|
-
const { input, confirm } = await import('
|
|
126
|
+
const { input, confirm } = await import('@inquirer/prompts');
|
|
127
127
|
vi.mocked(confirm).mockResolvedValue(true);
|
|
128
128
|
vi.mocked(input).mockResolvedValueOnce('new-key');
|
|
129
129
|
await fs.writeFile(testState.envExamplePath, `APIKEY=your_api_key_here
|
|
@@ -136,7 +136,7 @@ EMPTY_KEY=`);
|
|
|
136
136
|
}));
|
|
137
137
|
});
|
|
138
138
|
it('should skip variables with existing values', async () => {
|
|
139
|
-
const { input, confirm } = await import('
|
|
139
|
+
const { input, confirm } = await import('@inquirer/prompts');
|
|
140
140
|
vi.mocked(confirm).mockResolvedValue(true);
|
|
141
141
|
vi.mocked(input).mockResolvedValueOnce('new-key');
|
|
142
142
|
await fs.writeFile(testState.envExamplePath, `EXISTING_KEY=existing-value
|
|
@@ -147,7 +147,7 @@ EMPTY_KEY=`);
|
|
|
147
147
|
expect(vi.mocked(input)).toHaveBeenCalledTimes(1);
|
|
148
148
|
});
|
|
149
149
|
it('should handle case where .env already exists (overwrite with copy)', async () => {
|
|
150
|
-
const { input, confirm } = await import('
|
|
150
|
+
const { input, confirm } = await import('@inquirer/prompts');
|
|
151
151
|
vi.mocked(confirm).mockResolvedValue(true);
|
|
152
152
|
vi.mocked(input).mockResolvedValueOnce('new-configured-value');
|
|
153
153
|
// Create existing .env with old content
|
|
@@ -162,7 +162,7 @@ EMPTY_KEY=`);
|
|
|
162
162
|
expect(envContent).not.toContain('OLD_KEY');
|
|
163
163
|
});
|
|
164
164
|
it('should return false if an error occurs during parsing', async () => {
|
|
165
|
-
const { confirm } = await import('
|
|
165
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
166
166
|
vi.mocked(confirm).mockResolvedValue(true);
|
|
167
167
|
await fs.writeFile(testState.envExamplePath, 'KEY=');
|
|
168
168
|
// Delete the .env.example file after access check but before parsing would happen
|
|
@@ -178,7 +178,7 @@ EMPTY_KEY=`);
|
|
|
178
178
|
vi.mocked(fs.copyFile).mockImplementation(originalCopyFile);
|
|
179
179
|
});
|
|
180
180
|
it('should prompt for SECRET marker values with password input', async () => {
|
|
181
|
-
const { password, confirm } = await import('
|
|
181
|
+
const { password, confirm } = await import('@inquirer/prompts');
|
|
182
182
|
vi.mocked(confirm).mockResolvedValue(true);
|
|
183
183
|
vi.mocked(password).mockResolvedValueOnce('my-secret-api-key');
|
|
184
184
|
await fs.writeFile(testState.envExamplePath, `# API Key
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { input, confirm } from '
|
|
1
|
+
import { input, confirm } from '@inquirer/prompts';
|
|
2
2
|
import { ux } from '@oclif/core';
|
|
3
3
|
import { kebabCase, pascalCase } from 'change-case';
|
|
4
4
|
import fs from 'node:fs/promises';
|
|
@@ -41,7 +41,7 @@ export async function checkDependencies() {
|
|
|
41
41
|
try {
|
|
42
42
|
const shouldProceed = await confirm({
|
|
43
43
|
message: 'Would you like to proceed anyway?',
|
|
44
|
-
default:
|
|
44
|
+
default: false
|
|
45
45
|
});
|
|
46
46
|
if (!shouldProceed) {
|
|
47
47
|
throw new UserCancelledError();
|
|
@@ -8,7 +8,7 @@ vi.mock('#utils/framework_version.js', () => ({
|
|
|
8
8
|
})
|
|
9
9
|
}));
|
|
10
10
|
// Mock other dependencies
|
|
11
|
-
vi.mock('
|
|
11
|
+
vi.mock('@inquirer/prompts', () => ({
|
|
12
12
|
input: vi.fn(),
|
|
13
13
|
confirm: vi.fn()
|
|
14
14
|
}));
|
|
@@ -47,7 +47,7 @@ describe('project_scaffold', () => {
|
|
|
47
47
|
});
|
|
48
48
|
describe('getProjectConfig', () => {
|
|
49
49
|
it('should skip all prompts when folderName is provided', async () => {
|
|
50
|
-
const { input } = await import('
|
|
50
|
+
const { input } = await import('@inquirer/prompts');
|
|
51
51
|
const config = await getProjectConfig('my-project');
|
|
52
52
|
expect(config.folderName).toBe('my-project');
|
|
53
53
|
expect(config.projectName).toBe('my-project');
|
|
@@ -58,7 +58,7 @@ describe('project_scaffold', () => {
|
|
|
58
58
|
expect(config.description).toBe('AI Agents & Workflows built with Output.ai for test-folder');
|
|
59
59
|
});
|
|
60
60
|
it('should prompt for project name and folder name when not provided', async () => {
|
|
61
|
-
const { input } = await import('
|
|
61
|
+
const { input } = await import('@inquirer/prompts');
|
|
62
62
|
vi.mocked(input)
|
|
63
63
|
.mockResolvedValueOnce('Test Project')
|
|
64
64
|
.mockResolvedValueOnce('test-project');
|
|
@@ -73,7 +73,7 @@ describe('project_scaffold', () => {
|
|
|
73
73
|
it('should not prompt when all dependencies are available', async () => {
|
|
74
74
|
const { isDockerInstalled } = await import('#services/docker.js');
|
|
75
75
|
const { isClaudeCliAvailable } = await import('#utils/claude.js');
|
|
76
|
-
const { confirm } = await import('
|
|
76
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
77
77
|
vi.mocked(isDockerInstalled).mockReturnValue(true);
|
|
78
78
|
vi.mocked(isClaudeCliAvailable).mockReturnValue(true);
|
|
79
79
|
await checkDependencies();
|
|
@@ -82,7 +82,7 @@ describe('project_scaffold', () => {
|
|
|
82
82
|
it('should prompt user when docker is missing', async () => {
|
|
83
83
|
const { isDockerInstalled } = await import('#services/docker.js');
|
|
84
84
|
const { isClaudeCliAvailable } = await import('#utils/claude.js');
|
|
85
|
-
const { confirm } = await import('
|
|
85
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
86
86
|
vi.mocked(isDockerInstalled).mockReturnValue(false);
|
|
87
87
|
vi.mocked(isClaudeCliAvailable).mockReturnValue(true);
|
|
88
88
|
vi.mocked(confirm).mockResolvedValue(true);
|
|
@@ -94,7 +94,7 @@ describe('project_scaffold', () => {
|
|
|
94
94
|
it('should throw UserCancelledError when user declines to proceed', async () => {
|
|
95
95
|
const { isDockerInstalled } = await import('#services/docker.js');
|
|
96
96
|
const { isClaudeCliAvailable } = await import('#utils/claude.js');
|
|
97
|
-
const { confirm } = await import('
|
|
97
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
98
98
|
vi.mocked(isDockerInstalled).mockReturnValue(false);
|
|
99
99
|
vi.mocked(isClaudeCliAvailable).mockReturnValue(true);
|
|
100
100
|
vi.mocked(confirm).mockResolvedValue(false);
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Workflow builder service for implementing workflows from plan files
|
|
3
3
|
*/
|
|
4
4
|
import { ADDITIONAL_INSTRUCTIONS, BUILD_COMMAND_OPTIONS, invokeBuildWorkflow as invokeBuildWorkflowFromClient, replyToClaude } from './claude_client.js';
|
|
5
|
-
import { input } from '
|
|
6
|
-
import { isInteractive } from '#utils/interactive.js';
|
|
5
|
+
import { input } from '@inquirer/prompts';
|
|
7
6
|
import { ux } from '@oclif/core';
|
|
8
7
|
import fs from 'node:fs/promises';
|
|
9
8
|
import path from 'node:path';
|
|
@@ -71,9 +70,6 @@ async function processModification(modification, currentOutput) {
|
|
|
71
70
|
}
|
|
72
71
|
}
|
|
73
72
|
async function interactiveRefinementLoop(currentOutput) {
|
|
74
|
-
if (!isInteractive()) {
|
|
75
|
-
return currentOutput;
|
|
76
|
-
}
|
|
77
73
|
const modification = await promptForModification();
|
|
78
74
|
if (isAcceptCommand(modification)) {
|
|
79
75
|
return currentOutput;
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
2
|
import { buildWorkflow, buildWorkflowInteractiveLoop } from './workflow_builder.js';
|
|
3
3
|
import { ADDITIONAL_INSTRUCTIONS, BUILD_COMMAND_OPTIONS, invokeBuildWorkflow, replyToClaude } from './claude_client.js';
|
|
4
|
-
import { input } from '
|
|
4
|
+
import { input } from '@inquirer/prompts';
|
|
5
5
|
import { ux } from '@oclif/core';
|
|
6
6
|
import fs from 'node:fs/promises';
|
|
7
7
|
vi.mock('./claude_client.js');
|
|
8
|
-
vi.mock('
|
|
9
|
-
vi.mock('#utils/interactive.js', () => ({ isInteractive: () => true }));
|
|
8
|
+
vi.mock('@inquirer/prompts');
|
|
10
9
|
vi.mock('@oclif/core', () => ({
|
|
11
10
|
ux: {
|
|
12
11
|
stdout: vi.fn(),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
type WorkflowResult = Pick<
|
|
3
|
-
export declare const ERROR_STATUSES: ReadonlySet<
|
|
1
|
+
import type { WorkflowResultResponse, WorkflowResultResponseStatus } from '../api/generated/api.js';
|
|
2
|
+
type WorkflowResult = Pick<WorkflowResultResponse, 'workflowId' | 'output' | 'status' | 'error'>;
|
|
3
|
+
export declare const ERROR_STATUSES: ReadonlySet<WorkflowResultResponseStatus | undefined>;
|
|
4
4
|
export declare function formatWorkflowResult(result: WorkflowResult): string;
|
|
5
5
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/cli",
|
|
3
|
-
"version": "0.1.13-dev.
|
|
3
|
+
"version": "0.1.13-dev.e3e3291.0",
|
|
4
4
|
"description": "CLI for Output.ai workflow generation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -34,11 +34,10 @@
|
|
|
34
34
|
"ky": "1.14.3",
|
|
35
35
|
"react": "19.2.4",
|
|
36
36
|
"semver": "7.7.4",
|
|
37
|
-
"undici": "8.0.2",
|
|
38
37
|
"yaml": "^2.8.3",
|
|
39
|
-
"@outputai/
|
|
40
|
-
"@outputai/
|
|
41
|
-
"@outputai/llm": "0.1.13-dev.
|
|
38
|
+
"@outputai/evals": "0.1.13-dev.e3e3291.0",
|
|
39
|
+
"@outputai/credentials": "0.1.13-dev.e3e3291.0",
|
|
40
|
+
"@outputai/llm": "0.1.13-dev.e3e3291.0"
|
|
42
41
|
},
|
|
43
42
|
"devDependencies": {
|
|
44
43
|
"@types/cli-progress": "3.11.6",
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { Command } from '@oclif/core';
|
|
2
|
-
export default class CredentialsSet extends Command {
|
|
3
|
-
static description: string;
|
|
4
|
-
static examples: string[];
|
|
5
|
-
static args: {
|
|
6
|
-
path: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
7
|
-
value: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
8
|
-
};
|
|
9
|
-
static flags: {
|
|
10
|
-
environment: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
-
workflow: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
-
};
|
|
13
|
-
run(): Promise<void>;
|
|
14
|
-
}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { Args, Command, Flags } from '@oclif/core';
|
|
2
|
-
import { load as parseYaml, dump as stringifyYaml } from 'js-yaml';
|
|
3
|
-
import { decryptCredentials, credentialsExist, writeEncrypted, resolveCredentialsPath } from '#services/credentials_service.js';
|
|
4
|
-
const setNestedValue = (obj, dotPath, value) => {
|
|
5
|
-
const parts = dotPath.split('.');
|
|
6
|
-
const parent = parts.slice(0, -1).reduce((current, key) => {
|
|
7
|
-
if (!current[key] || typeof current[key] !== 'object') {
|
|
8
|
-
current[key] = {};
|
|
9
|
-
}
|
|
10
|
-
return current[key];
|
|
11
|
-
}, obj);
|
|
12
|
-
parent[parts[parts.length - 1]] = value;
|
|
13
|
-
};
|
|
14
|
-
export default class CredentialsSet extends Command {
|
|
15
|
-
static description = 'Set a credential value by dot-notation path';
|
|
16
|
-
static examples = [
|
|
17
|
-
'<%= config.bin %> <%= command.id %> anthropic.api_key sk-ant-...',
|
|
18
|
-
'<%= config.bin %> <%= command.id %> openai.api_key sk-... --environment production',
|
|
19
|
-
'<%= config.bin %> <%= command.id %> stripe.key sk_live_... --workflow my_workflow'
|
|
20
|
-
];
|
|
21
|
-
static args = {
|
|
22
|
-
path: Args.string({
|
|
23
|
-
description: 'Dot-notation path to the credential (e.g. anthropic.api_key)',
|
|
24
|
-
required: true
|
|
25
|
-
}),
|
|
26
|
-
value: Args.string({
|
|
27
|
-
description: 'Value to set',
|
|
28
|
-
required: true
|
|
29
|
-
})
|
|
30
|
-
};
|
|
31
|
-
static flags = {
|
|
32
|
-
environment: Flags.string({
|
|
33
|
-
char: 'e',
|
|
34
|
-
description: 'Target environment (e.g. production, development)'
|
|
35
|
-
}),
|
|
36
|
-
workflow: Flags.string({
|
|
37
|
-
char: 'w',
|
|
38
|
-
description: 'Target a specific workflow directory'
|
|
39
|
-
})
|
|
40
|
-
};
|
|
41
|
-
async run() {
|
|
42
|
-
const { args, flags } = await this.parse(CredentialsSet);
|
|
43
|
-
const environment = flags.environment;
|
|
44
|
-
const workflow = flags.workflow;
|
|
45
|
-
if (environment && workflow) {
|
|
46
|
-
this.error('Cannot specify both --environment and --workflow.');
|
|
47
|
-
}
|
|
48
|
-
if (!credentialsExist(environment, workflow)) {
|
|
49
|
-
this.error(`No credentials file found at ${resolveCredentialsPath(environment, workflow)}. Run "output credentials init" first.`);
|
|
50
|
-
}
|
|
51
|
-
const plaintext = decryptCredentials(environment, workflow);
|
|
52
|
-
const data = (parseYaml(plaintext) || {});
|
|
53
|
-
setNestedValue(data, args.path, args.value);
|
|
54
|
-
writeEncrypted(environment, stringifyYaml(data), workflow);
|
|
55
|
-
this.log(`Set ${args.path}`);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
3
|
-
import * as credentialsService from '#services/credentials_service.js';
|
|
4
|
-
import CredentialsSet from './set.js';
|
|
5
|
-
vi.mock('#services/credentials_service.js');
|
|
6
|
-
vi.mock('js-yaml', () => ({
|
|
7
|
-
load: vi.fn((yaml) => {
|
|
8
|
-
if (yaml.includes('sk-existing')) {
|
|
9
|
-
return { anthropic: { api_key: 'sk-existing' } };
|
|
10
|
-
}
|
|
11
|
-
return {};
|
|
12
|
-
}),
|
|
13
|
-
dump: vi.fn((obj) => JSON.stringify(obj))
|
|
14
|
-
}));
|
|
15
|
-
describe('credentials set command', () => {
|
|
16
|
-
beforeEach(() => {
|
|
17
|
-
vi.clearAllMocks();
|
|
18
|
-
vi.mocked(credentialsService.credentialsExist).mockReturnValue(true);
|
|
19
|
-
vi.mocked(credentialsService.decryptCredentials).mockReturnValue('anthropic:\n api_key: sk-existing\n');
|
|
20
|
-
vi.mocked(credentialsService.writeEncrypted).mockImplementation(() => { });
|
|
21
|
-
});
|
|
22
|
-
afterEach(() => {
|
|
23
|
-
vi.restoreAllMocks();
|
|
24
|
-
});
|
|
25
|
-
const createTestCommand = (parsedArgs = {}, flags = {}) => {
|
|
26
|
-
const cmd = new CredentialsSet([], {});
|
|
27
|
-
cmd.log = vi.fn();
|
|
28
|
-
cmd.error = vi.fn((msg) => {
|
|
29
|
-
throw new Error(msg);
|
|
30
|
-
});
|
|
31
|
-
Object.defineProperty(cmd, 'parse', {
|
|
32
|
-
value: vi.fn().mockResolvedValue({
|
|
33
|
-
args: { path: 'anthropic.api_key', value: 'sk-new-key', ...parsedArgs },
|
|
34
|
-
flags: { environment: undefined, workflow: undefined, ...flags }
|
|
35
|
-
}),
|
|
36
|
-
configurable: true
|
|
37
|
-
});
|
|
38
|
-
return cmd;
|
|
39
|
-
};
|
|
40
|
-
describe('command structure', () => {
|
|
41
|
-
it('should have correct description', () => {
|
|
42
|
-
expect(CredentialsSet.description).toContain('credential value');
|
|
43
|
-
});
|
|
44
|
-
it('should have required path and value arguments', () => {
|
|
45
|
-
expect(CredentialsSet.args.path).toBeDefined();
|
|
46
|
-
expect(CredentialsSet.args.path.required).toBe(true);
|
|
47
|
-
expect(CredentialsSet.args.value).toBeDefined();
|
|
48
|
-
expect(CredentialsSet.args.value.required).toBe(true);
|
|
49
|
-
});
|
|
50
|
-
it('should have environment and workflow flags', () => {
|
|
51
|
-
expect(CredentialsSet.flags.environment).toBeDefined();
|
|
52
|
-
expect(CredentialsSet.flags.workflow).toBeDefined();
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
describe('command execution', () => {
|
|
56
|
-
it('should decrypt, update, and re-encrypt credentials', async () => {
|
|
57
|
-
const cmd = createTestCommand();
|
|
58
|
-
await cmd.run();
|
|
59
|
-
expect(credentialsService.decryptCredentials).toHaveBeenCalledWith(undefined, undefined);
|
|
60
|
-
expect(credentialsService.writeEncrypted).toHaveBeenCalledWith(undefined, expect.any(String), undefined);
|
|
61
|
-
expect(cmd.log).toHaveBeenCalledWith('Set anthropic.api_key');
|
|
62
|
-
});
|
|
63
|
-
it('should create nested keys that do not exist', async () => {
|
|
64
|
-
vi.mocked(credentialsService.decryptCredentials).mockReturnValue('');
|
|
65
|
-
const cmd = createTestCommand({ path: 'new.nested.key', value: 'my-value' });
|
|
66
|
-
await cmd.run();
|
|
67
|
-
expect(credentialsService.writeEncrypted).toHaveBeenCalledTimes(1);
|
|
68
|
-
expect(cmd.log).toHaveBeenCalledWith('Set new.nested.key');
|
|
69
|
-
});
|
|
70
|
-
it('should pass environment flag to service functions', async () => {
|
|
71
|
-
const cmd = createTestCommand({}, { environment: 'production' });
|
|
72
|
-
await cmd.run();
|
|
73
|
-
expect(credentialsService.credentialsExist).toHaveBeenCalledWith('production', undefined);
|
|
74
|
-
expect(credentialsService.decryptCredentials).toHaveBeenCalledWith('production', undefined);
|
|
75
|
-
expect(credentialsService.writeEncrypted).toHaveBeenCalledWith('production', expect.any(String), undefined);
|
|
76
|
-
});
|
|
77
|
-
it('should pass workflow flag to service functions', async () => {
|
|
78
|
-
const cmd = createTestCommand({}, { workflow: 'my_workflow' });
|
|
79
|
-
await cmd.run();
|
|
80
|
-
expect(credentialsService.credentialsExist).toHaveBeenCalledWith(undefined, 'my_workflow');
|
|
81
|
-
expect(credentialsService.decryptCredentials).toHaveBeenCalledWith(undefined, 'my_workflow');
|
|
82
|
-
expect(credentialsService.writeEncrypted).toHaveBeenCalledWith(undefined, expect.any(String), 'my_workflow');
|
|
83
|
-
});
|
|
84
|
-
it('should error when both environment and workflow are specified', async () => {
|
|
85
|
-
const cmd = createTestCommand({}, { environment: 'production', workflow: 'my_workflow' });
|
|
86
|
-
await expect(cmd.run()).rejects.toThrow('Cannot specify both');
|
|
87
|
-
});
|
|
88
|
-
it('should error when credentials file does not exist', async () => {
|
|
89
|
-
vi.mocked(credentialsService.credentialsExist).mockReturnValue(false);
|
|
90
|
-
vi.mocked(credentialsService.resolveCredentialsPath).mockReturnValue('/project/config/credentials.yml.enc');
|
|
91
|
-
const cmd = createTestCommand();
|
|
92
|
-
await expect(cmd.run()).rejects.toThrow('No credentials file found');
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|