@studio-foundation/cli 0.3.0-beta.1
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/ARCHITECTURE.md +40 -0
- package/LICENSE +663 -0
- package/README.md +165 -0
- package/dist/commands/api.d.ts +16 -0
- package/dist/commands/api.d.ts.map +1 -0
- package/dist/commands/api.js +118 -0
- package/dist/commands/api.js.map +1 -0
- package/dist/commands/config.d.ts +32 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +390 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/init.d.ts +93 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +728 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/install.d.ts +2 -0
- package/dist/commands/install.d.ts.map +1 -0
- package/dist/commands/install.js +22 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/integrations.d.ts +14 -0
- package/dist/commands/integrations.d.ts.map +1 -0
- package/dist/commands/integrations.js +282 -0
- package/dist/commands/integrations.js.map +1 -0
- package/dist/commands/list.d.ts +9 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +161 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/logs.d.ts +7 -0
- package/dist/commands/logs.d.ts.map +1 -0
- package/dist/commands/logs.js +98 -0
- package/dist/commands/logs.js.map +1 -0
- package/dist/commands/ollama.d.ts +6 -0
- package/dist/commands/ollama.d.ts.map +1 -0
- package/dist/commands/ollama.js +161 -0
- package/dist/commands/ollama.js.map +1 -0
- package/dist/commands/project.d.ts +38 -0
- package/dist/commands/project.d.ts.map +1 -0
- package/dist/commands/project.js +137 -0
- package/dist/commands/project.js.map +1 -0
- package/dist/commands/registry/audit.d.ts +13 -0
- package/dist/commands/registry/audit.d.ts.map +1 -0
- package/dist/commands/registry/audit.js +65 -0
- package/dist/commands/registry/audit.js.map +1 -0
- package/dist/commands/registry/index.d.ts +3 -0
- package/dist/commands/registry/index.d.ts.map +1 -0
- package/dist/commands/registry/index.js +53 -0
- package/dist/commands/registry/index.js.map +1 -0
- package/dist/commands/registry/install.d.ts +17 -0
- package/dist/commands/registry/install.d.ts.map +1 -0
- package/dist/commands/registry/install.js +153 -0
- package/dist/commands/registry/install.js.map +1 -0
- package/dist/commands/registry/publish.d.ts +8 -0
- package/dist/commands/registry/publish.d.ts.map +1 -0
- package/dist/commands/registry/publish.js +94 -0
- package/dist/commands/registry/publish.js.map +1 -0
- package/dist/commands/registry/remove.d.ts +8 -0
- package/dist/commands/registry/remove.d.ts.map +1 -0
- package/dist/commands/registry/remove.js +82 -0
- package/dist/commands/registry/remove.js.map +1 -0
- package/dist/commands/registry/search.d.ts +9 -0
- package/dist/commands/registry/search.d.ts.map +1 -0
- package/dist/commands/registry/search.js +68 -0
- package/dist/commands/registry/search.js.map +1 -0
- package/dist/commands/registry/sync.d.ts +8 -0
- package/dist/commands/registry/sync.d.ts.map +1 -0
- package/dist/commands/registry/sync.js +19 -0
- package/dist/commands/registry/sync.js.map +1 -0
- package/dist/commands/registry/update.d.ts +15 -0
- package/dist/commands/registry/update.d.ts.map +1 -0
- package/dist/commands/registry/update.js +48 -0
- package/dist/commands/registry/update.js.map +1 -0
- package/dist/commands/replay.d.ts +36 -0
- package/dist/commands/replay.d.ts.map +1 -0
- package/dist/commands/replay.js +453 -0
- package/dist/commands/replay.js.map +1 -0
- package/dist/commands/run.d.ts +18 -0
- package/dist/commands/run.d.ts.map +1 -0
- package/dist/commands/run.js +406 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +144 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/template/index.d.ts +2 -0
- package/dist/commands/template/index.d.ts.map +1 -0
- package/dist/commands/template/index.js +57 -0
- package/dist/commands/template/index.js.map +1 -0
- package/dist/commands/template/validate.d.ts +8 -0
- package/dist/commands/template/validate.d.ts.map +1 -0
- package/dist/commands/template/validate.js +135 -0
- package/dist/commands/template/validate.js.map +1 -0
- package/dist/commands/templates.d.ts +14 -0
- package/dist/commands/templates.d.ts.map +1 -0
- package/dist/commands/templates.js +86 -0
- package/dist/commands/templates.js.map +1 -0
- package/dist/commands/tools.d.ts +12 -0
- package/dist/commands/tools.d.ts.map +1 -0
- package/dist/commands/tools.js +200 -0
- package/dist/commands/tools.js.map +1 -0
- package/dist/commands/users.d.ts +4 -0
- package/dist/commands/users.d.ts.map +1 -0
- package/dist/commands/users.js +123 -0
- package/dist/commands/users.js.map +1 -0
- package/dist/commands/validate.d.ts +2 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +39 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/config.d.ts +36 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +64 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +178 -0
- package/dist/index.js.map +1 -0
- package/dist/models-cache.d.ts +13 -0
- package/dist/models-cache.d.ts.map +1 -0
- package/dist/models-cache.js +106 -0
- package/dist/models-cache.js.map +1 -0
- package/dist/output/file-changes.d.ts +15 -0
- package/dist/output/file-changes.d.ts.map +1 -0
- package/dist/output/file-changes.js +77 -0
- package/dist/output/file-changes.js.map +1 -0
- package/dist/output/formatter.d.ts +6 -0
- package/dist/output/formatter.d.ts.map +1 -0
- package/dist/output/formatter.js +70 -0
- package/dist/output/formatter.js.map +1 -0
- package/dist/output/formatters.d.ts +42 -0
- package/dist/output/formatters.d.ts.map +1 -0
- package/dist/output/formatters.js +280 -0
- package/dist/output/formatters.js.map +1 -0
- package/dist/output/logger.d.ts +3 -0
- package/dist/output/logger.d.ts.map +1 -0
- package/dist/output/logger.js +15 -0
- package/dist/output/logger.js.map +1 -0
- package/dist/output/parallel-progress.d.ts +33 -0
- package/dist/output/parallel-progress.d.ts.map +1 -0
- package/dist/output/parallel-progress.js +85 -0
- package/dist/output/parallel-progress.js.map +1 -0
- package/dist/output/progress.d.ts +32 -0
- package/dist/output/progress.d.ts.map +1 -0
- package/dist/output/progress.js +393 -0
- package/dist/output/progress.js.map +1 -0
- package/dist/provider-validator.d.ts +15 -0
- package/dist/provider-validator.d.ts.map +1 -0
- package/dist/provider-validator.js +85 -0
- package/dist/provider-validator.js.map +1 -0
- package/dist/registry/cache.d.ts +10 -0
- package/dist/registry/cache.d.ts.map +1 -0
- package/dist/registry/cache.js +36 -0
- package/dist/registry/cache.js.map +1 -0
- package/dist/registry/client.d.ts +19 -0
- package/dist/registry/client.d.ts.map +1 -0
- package/dist/registry/client.js +63 -0
- package/dist/registry/client.js.map +1 -0
- package/dist/registry/lockfile.d.ts +17 -0
- package/dist/registry/lockfile.d.ts.map +1 -0
- package/dist/registry/lockfile.js +61 -0
- package/dist/registry/lockfile.js.map +1 -0
- package/dist/registry/resolver.d.ts +13 -0
- package/dist/registry/resolver.d.ts.map +1 -0
- package/dist/registry/resolver.js +79 -0
- package/dist/registry/resolver.js.map +1 -0
- package/dist/registry/types.d.ts +59 -0
- package/dist/registry/types.d.ts.map +1 -0
- package/dist/registry/types.js +14 -0
- package/dist/registry/types.js.map +1 -0
- package/dist/run-logger.d.ts +8 -0
- package/dist/run-logger.d.ts.map +1 -0
- package/dist/run-logger.js +53 -0
- package/dist/run-logger.js.map +1 -0
- package/dist/run-store-factory.d.ts +9 -0
- package/dist/run-store-factory.d.ts.map +1 -0
- package/dist/run-store-factory.js +26 -0
- package/dist/run-store-factory.js.map +1 -0
- package/dist/studio-dir.d.ts +7 -0
- package/dist/studio-dir.d.ts.map +1 -0
- package/dist/studio-dir.js +27 -0
- package/dist/studio-dir.js.map +1 -0
- package/dist/utils/input-wizard.d.ts +4 -0
- package/dist/utils/input-wizard.d.ts.map +1 -0
- package/dist/utils/input-wizard.js +56 -0
- package/dist/utils/input-wizard.js.map +1 -0
- package/dist/utils/placeholders.d.ts +6 -0
- package/dist/utils/placeholders.d.ts.map +1 -0
- package/dist/utils/placeholders.js +12 -0
- package/dist/utils/placeholders.js.map +1 -0
- package/package.json +43 -0
- package/src/commands/api.ts +126 -0
- package/src/commands/config.ts +433 -0
- package/src/commands/init.ts +879 -0
- package/src/commands/install.ts +23 -0
- package/src/commands/integrations.ts +332 -0
- package/src/commands/list.ts +197 -0
- package/src/commands/logs.ts +119 -0
- package/src/commands/ollama.ts +168 -0
- package/src/commands/project.ts +167 -0
- package/src/commands/registry/audit.ts +87 -0
- package/src/commands/registry/index.ts +63 -0
- package/src/commands/registry/install.ts +222 -0
- package/src/commands/registry/publish.ts +112 -0
- package/src/commands/registry/remove.ts +89 -0
- package/src/commands/registry/search.ts +93 -0
- package/src/commands/registry/sync.ts +24 -0
- package/src/commands/registry/update.ts +63 -0
- package/src/commands/replay.ts +559 -0
- package/src/commands/run.ts +454 -0
- package/src/commands/status.ts +163 -0
- package/src/commands/template/index.ts +59 -0
- package/src/commands/template/validate.ts +175 -0
- package/src/commands/templates.ts +99 -0
- package/src/commands/tools.ts +227 -0
- package/src/commands/users.ts +127 -0
- package/src/commands/validate.ts +46 -0
- package/src/config.ts +101 -0
- package/src/index.ts +201 -0
- package/src/models-cache.ts +127 -0
- package/src/output/file-changes.ts +97 -0
- package/src/output/formatter.ts +85 -0
- package/src/output/formatters.ts +303 -0
- package/src/output/logger.ts +18 -0
- package/src/output/parallel-progress.ts +103 -0
- package/src/output/progress.ts +414 -0
- package/src/provider-validator.ts +109 -0
- package/src/registry/cache.ts +46 -0
- package/src/registry/client.ts +84 -0
- package/src/registry/lockfile.ts +68 -0
- package/src/registry/resolver.ts +113 -0
- package/src/registry/types.ts +66 -0
- package/src/run-logger.ts +66 -0
- package/src/run-store-factory.ts +30 -0
- package/src/studio-dir.ts +28 -0
- package/src/utils/input-wizard.ts +73 -0
- package/src/utils/placeholders.ts +10 -0
- package/templates/.studiorc.yaml +16 -0
- package/templates/projects/blank/metadata.json +7 -0
- package/templates/studio-config.yaml +14 -0
- package/tests/__stubs__/studio-runner.ts +104 -0
- package/tests/commands/api.test.ts +110 -0
- package/tests/commands/config.test.ts +221 -0
- package/tests/commands/init.test.ts +919 -0
- package/tests/commands/install.test.ts +52 -0
- package/tests/commands/integrations.test.ts +158 -0
- package/tests/commands/ollama.test.ts +139 -0
- package/tests/commands/project.test.ts +179 -0
- package/tests/commands/registry/audit.test.ts +56 -0
- package/tests/commands/registry/install.test.ts +200 -0
- package/tests/commands/registry/publish.test.ts +56 -0
- package/tests/commands/registry/remove.test.ts +103 -0
- package/tests/commands/registry/search.test.ts +44 -0
- package/tests/commands/registry/sync.test.ts +37 -0
- package/tests/commands/registry/update.test.ts +62 -0
- package/tests/commands/replay.test.ts +283 -0
- package/tests/commands/template/validate.test.ts +150 -0
- package/tests/commands/templates.test.ts +42 -0
- package/tests/commands/tools.test.ts +106 -0
- package/tests/config.test.ts +142 -0
- package/tests/formatter.test.ts +158 -0
- package/tests/integration/sigint.test.ts +188 -0
- package/tests/models-cache.test.ts +250 -0
- package/tests/output/file-changes.test.ts +178 -0
- package/tests/output/formatters.test.ts +448 -0
- package/tests/output/progress-spinner.test.ts +232 -0
- package/tests/output/progress-timer.test.ts +230 -0
- package/tests/provider-validator.test.ts +182 -0
- package/tests/registry/cache.test.ts +66 -0
- package/tests/registry/client.test.ts +70 -0
- package/tests/registry/lockfile.test.ts +87 -0
- package/tests/registry/resolver.test.ts +122 -0
- package/tests/run-logger-events.test.ts +326 -0
- package/tests/run-logger.test.ts +64 -0
- package/tests/run-store-factory.test.ts +51 -0
- package/tests/studio-dir.test.ts +31 -0
- package/tests/utils/input-wizard.test.ts +153 -0
- package/tests/utils/placeholders.test.ts +36 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +20 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { writeFile, mkdir, rm } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { loadConfig, resolveEnvVars } from '../src/config.js';
|
|
5
|
+
|
|
6
|
+
const TEST_DIR = resolve(import.meta.dirname, '.tmp-config-test');
|
|
7
|
+
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
await mkdir(TEST_DIR, { recursive: true });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await rm(TEST_DIR, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('loadConfig', () => {
|
|
17
|
+
it('should load a valid .studiorc.yaml', async () => {
|
|
18
|
+
const configPath = resolve(TEST_DIR, '.studiorc.yaml');
|
|
19
|
+
await writeFile(
|
|
20
|
+
configPath,
|
|
21
|
+
`providers:
|
|
22
|
+
anthropic:
|
|
23
|
+
apiKey: sk-test-123
|
|
24
|
+
|
|
25
|
+
paths:
|
|
26
|
+
pipelines: ./my-pipelines
|
|
27
|
+
|
|
28
|
+
defaults:
|
|
29
|
+
provider: anthropic
|
|
30
|
+
`
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const config = await loadConfig(configPath);
|
|
34
|
+
expect(config.providers?.anthropic?.apiKey).toBe('sk-test-123');
|
|
35
|
+
expect(config.paths?.pipelines).toBe('./my-pipelines');
|
|
36
|
+
expect(config.defaults?.provider).toBe('anthropic');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should resolve environment variables', async () => {
|
|
40
|
+
vi.stubEnv('TEST_API_KEY', 'resolved-key-456');
|
|
41
|
+
|
|
42
|
+
const configPath = resolve(TEST_DIR, '.studiorc.yaml');
|
|
43
|
+
await writeFile(
|
|
44
|
+
configPath,
|
|
45
|
+
`providers:
|
|
46
|
+
anthropic:
|
|
47
|
+
apiKey: \${TEST_API_KEY}
|
|
48
|
+
`
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const config = await loadConfig(configPath);
|
|
52
|
+
expect(config.providers?.anthropic?.apiKey).toBe('resolved-key-456');
|
|
53
|
+
|
|
54
|
+
vi.unstubAllEnvs();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should return empty config when file is missing (no explicit path)', async () => {
|
|
58
|
+
// loadConfig with no argument searches cwd — won't find anything in TEST_DIR
|
|
59
|
+
const config = await loadConfig();
|
|
60
|
+
// Should not throw, should return empty-ish config
|
|
61
|
+
expect(config).toBeDefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should throw on explicitly missing config path', async () => {
|
|
65
|
+
await expect(
|
|
66
|
+
loadConfig(resolve(TEST_DIR, 'nonexistent.yaml'))
|
|
67
|
+
).rejects.toThrow('Config file not found');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should throw on malformed YAML', async () => {
|
|
71
|
+
const configPath = resolve(TEST_DIR, 'bad.yaml');
|
|
72
|
+
await writeFile(configPath, '{{{{invalid yaml}}}}::: [[[');
|
|
73
|
+
|
|
74
|
+
await expect(loadConfig(configPath)).rejects.toThrow('Failed to parse config');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should load .studio/config.yaml when present', async () => {
|
|
78
|
+
const studioDir = resolve(TEST_DIR, '.studio');
|
|
79
|
+
await mkdir(studioDir, { recursive: true });
|
|
80
|
+
await writeFile(
|
|
81
|
+
resolve(studioDir, 'config.yaml'),
|
|
82
|
+
`providers:\n anthropic:\n apiKey: studio-key\ndefaults:\n provider: anthropic\n`
|
|
83
|
+
);
|
|
84
|
+
const config = await loadConfig(undefined, TEST_DIR);
|
|
85
|
+
expect(config.providers?.anthropic?.apiKey).toBe('studio-key');
|
|
86
|
+
expect(config.resolvedStudioDir).toBe(studioDir);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should fall back to .studiorc.yaml when no .studio/', async () => {
|
|
90
|
+
// Mock findStudioDir to return null — simulates a project with no .studio/ in any ancestor.
|
|
91
|
+
// This avoids environment-specific failures (e.g. ~/.studio/ existing on the runner).
|
|
92
|
+
vi.doMock('../src/studio-dir.js', () => ({
|
|
93
|
+
findStudioDir: vi.fn().mockResolvedValue(null),
|
|
94
|
+
}));
|
|
95
|
+
vi.resetModules();
|
|
96
|
+
const { loadConfig: loadConfigFresh } = await import('../src/config.js');
|
|
97
|
+
|
|
98
|
+
const isolatedDir = resolve('/tmp', `.studio-config-fallback-${Date.now()}`);
|
|
99
|
+
await mkdir(isolatedDir, { recursive: true });
|
|
100
|
+
try {
|
|
101
|
+
const configPath = resolve(isolatedDir, '.studiorc.yaml');
|
|
102
|
+
await writeFile(configPath, `providers:\n openai:\n apiKey: fallback-key\n`);
|
|
103
|
+
const config = await loadConfigFresh(undefined, isolatedDir);
|
|
104
|
+
expect(config.providers?.openai?.apiKey).toBe('fallback-key');
|
|
105
|
+
expect(config.resolvedStudioDir).toBeUndefined();
|
|
106
|
+
} finally {
|
|
107
|
+
await rm(isolatedDir, { recursive: true, force: true });
|
|
108
|
+
vi.doUnmock('../src/studio-dir.js');
|
|
109
|
+
vi.resetModules();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('loadConfig with explicit path ignores .studio/', async () => {
|
|
114
|
+
const studioDir = resolve(TEST_DIR, '.studio');
|
|
115
|
+
await mkdir(studioDir, { recursive: true });
|
|
116
|
+
await writeFile(resolve(studioDir, 'config.yaml'), `providers:\n anthropic:\n apiKey: studio-key\n`);
|
|
117
|
+
const explicit = resolve(TEST_DIR, 'explicit.yaml');
|
|
118
|
+
await writeFile(explicit, `providers:\n openai:\n apiKey: explicit-key\n`);
|
|
119
|
+
const config = await loadConfig(explicit, TEST_DIR);
|
|
120
|
+
expect(config.providers?.openai?.apiKey).toBe('explicit-key');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('resolveEnvVars', () => {
|
|
125
|
+
it('should replace ${VAR} with env value', () => {
|
|
126
|
+
vi.stubEnv('MY_KEY', 'hello');
|
|
127
|
+
expect(resolveEnvVars('key: ${MY_KEY}')).toBe('key: hello');
|
|
128
|
+
vi.unstubAllEnvs();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should replace missing env vars with empty string', () => {
|
|
132
|
+
delete process.env['NONEXISTENT_VAR_XYZ'];
|
|
133
|
+
expect(resolveEnvVars('key: ${NONEXISTENT_VAR_XYZ}')).toBe('key: ');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should handle multiple env vars', () => {
|
|
137
|
+
vi.stubEnv('A', '1');
|
|
138
|
+
vi.stubEnv('B', '2');
|
|
139
|
+
expect(resolveEnvVars('${A}-${B}')).toBe('1-2');
|
|
140
|
+
vi.unstubAllEnvs();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import type { PipelineRun } from '@studio-foundation/contracts';
|
|
3
|
+
import { formatResult, formatJson, formatError, formatDuration } from '../src/output/formatter.js';
|
|
4
|
+
|
|
5
|
+
let output: string[];
|
|
6
|
+
const originalLog = console.log;
|
|
7
|
+
const originalError = console.error;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
output = [];
|
|
11
|
+
console.log = vi.fn((...args: unknown[]) => {
|
|
12
|
+
output.push(args.map(String).join(' '));
|
|
13
|
+
});
|
|
14
|
+
console.error = vi.fn((...args: unknown[]) => {
|
|
15
|
+
output.push(args.map(String).join(' '));
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
console.log = originalLog;
|
|
21
|
+
console.error = originalError;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function makeRun(overrides: Partial<PipelineRun> = {}): PipelineRun {
|
|
25
|
+
return {
|
|
26
|
+
id: 'run-123',
|
|
27
|
+
pipeline_name: 'test-pipeline',
|
|
28
|
+
status: 'success',
|
|
29
|
+
started_at: '2025-01-01T00:00:00.000Z',
|
|
30
|
+
completed_at: '2025-01-01T00:04:32.000Z',
|
|
31
|
+
stages: [
|
|
32
|
+
{
|
|
33
|
+
id: 'stage-1',
|
|
34
|
+
stage_name: 'analysis',
|
|
35
|
+
status: 'success',
|
|
36
|
+
started_at: '2025-01-01T00:00:00.000Z',
|
|
37
|
+
completed_at: '2025-01-01T00:01:00.000Z',
|
|
38
|
+
tasks: [
|
|
39
|
+
{
|
|
40
|
+
id: 'task-1',
|
|
41
|
+
task_name: 'analysis',
|
|
42
|
+
status: 'success',
|
|
43
|
+
started_at: '2025-01-01T00:00:00.000Z',
|
|
44
|
+
completed_at: '2025-01-01T00:01:00.000Z',
|
|
45
|
+
agent_runs: [
|
|
46
|
+
{
|
|
47
|
+
id: 'agent-1',
|
|
48
|
+
agent_name: 'analyst',
|
|
49
|
+
attempt: 1,
|
|
50
|
+
status: 'success',
|
|
51
|
+
tool_calls: 3,
|
|
52
|
+
started_at: '2025-01-01T00:00:00.000Z',
|
|
53
|
+
completed_at: '2025-01-01T00:01:00.000Z',
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
...overrides,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('formatResult', () => {
|
|
65
|
+
it('should display success pipeline with stages', () => {
|
|
66
|
+
const run = makeRun();
|
|
67
|
+
formatResult(run);
|
|
68
|
+
|
|
69
|
+
const text = output.join('\n');
|
|
70
|
+
expect(text).toContain('test-pipeline');
|
|
71
|
+
expect(text).toContain('success');
|
|
72
|
+
expect(text).toContain('analysis');
|
|
73
|
+
expect(text).toContain('1 attempt');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should display duration', () => {
|
|
77
|
+
const run = makeRun();
|
|
78
|
+
formatResult(run);
|
|
79
|
+
|
|
80
|
+
const text = output.join('\n');
|
|
81
|
+
expect(text).toContain('4m32s');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should display failed pipeline with error info', () => {
|
|
85
|
+
const run = makeRun({
|
|
86
|
+
status: 'failed',
|
|
87
|
+
stages: [
|
|
88
|
+
{
|
|
89
|
+
id: 'stage-1',
|
|
90
|
+
stage_name: 'code-gen',
|
|
91
|
+
status: 'failed',
|
|
92
|
+
started_at: '2025-01-01T00:00:00.000Z',
|
|
93
|
+
completed_at: '2025-01-01T00:01:00.000Z',
|
|
94
|
+
tasks: [
|
|
95
|
+
{
|
|
96
|
+
id: 'task-1',
|
|
97
|
+
task_name: 'code-gen',
|
|
98
|
+
status: 'failed',
|
|
99
|
+
started_at: '2025-01-01T00:00:00.000Z',
|
|
100
|
+
completed_at: '2025-01-01T00:01:00.000Z',
|
|
101
|
+
agent_runs: [
|
|
102
|
+
{
|
|
103
|
+
id: 'agent-1',
|
|
104
|
+
agent_name: 'coder',
|
|
105
|
+
attempt: 1,
|
|
106
|
+
status: 'failed',
|
|
107
|
+
tool_calls: 0,
|
|
108
|
+
started_at: '2025-01-01T00:00:00.000Z',
|
|
109
|
+
completed_at: '2025-01-01T00:01:00.000Z',
|
|
110
|
+
error: 'tool_calls = 0: agent did not make any real tool calls',
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
formatResult(run);
|
|
120
|
+
|
|
121
|
+
const text = output.join('\n');
|
|
122
|
+
expect(text).toContain('failed');
|
|
123
|
+
expect(text).toContain('FAILED');
|
|
124
|
+
expect(text).toContain('tool_calls = 0');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('formatJson', () => {
|
|
129
|
+
it('should output pretty JSON', () => {
|
|
130
|
+
formatJson({ key: 'value' });
|
|
131
|
+
|
|
132
|
+
const text = output.join('\n');
|
|
133
|
+
expect(text).toContain('"key": "value"');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('formatError', () => {
|
|
138
|
+
it('should output error message', () => {
|
|
139
|
+
formatError(new Error('something broke'));
|
|
140
|
+
|
|
141
|
+
const text = output.join('\n');
|
|
142
|
+
expect(text).toContain('something broke');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('formatDuration', () => {
|
|
147
|
+
it('should format sub-second durations', () => {
|
|
148
|
+
expect(formatDuration(500)).toBe('500ms');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should format seconds', () => {
|
|
152
|
+
expect(formatDuration(12000)).toBe('12s');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should format minutes', () => {
|
|
156
|
+
expect(formatDuration(83000)).toBe('1m23s');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for SIGINT handling in the CLI.
|
|
3
|
+
*
|
|
4
|
+
* Strategy:
|
|
5
|
+
* - 2-stage pipeline where stage 1 calls `sleep 60` via the shell tool.
|
|
6
|
+
* - After startup, send SIGINT to the process GROUP (kills node + sleep together).
|
|
7
|
+
* - SIGINT arrives during sleep: shell tool error → MockProvider returns valid output →
|
|
8
|
+
* stage 1 succeeds → engine checks signal.aborted before stage 2 → 'cancelled'.
|
|
9
|
+
* - CLI detects status='cancelled' → process.exit(130).
|
|
10
|
+
*
|
|
11
|
+
* Regression guard: if the SIGINT handler or AbortController wiring is removed, the
|
|
12
|
+
* process would exit with 0 or 1 instead of 130.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
15
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
16
|
+
import {
|
|
17
|
+
mkdirSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
cpSync,
|
|
21
|
+
existsSync,
|
|
22
|
+
} from 'node:fs';
|
|
23
|
+
import { resolve, join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
const CLI_BIN = resolve(import.meta.dirname, '../../dist/index.js');
|
|
26
|
+
|
|
27
|
+
const SHELL_TOOL_SRC = resolve(
|
|
28
|
+
import.meta.dirname,
|
|
29
|
+
'../../../runner/templates/tools/shell.tool.yaml',
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
function setupProject(dir: string): void {
|
|
33
|
+
const studioDir = join(dir, '.studio');
|
|
34
|
+
mkdirSync(join(studioDir, 'pipelines'), { recursive: true });
|
|
35
|
+
mkdirSync(join(studioDir, 'agents'), { recursive: true });
|
|
36
|
+
mkdirSync(join(studioDir, 'contracts'), { recursive: true });
|
|
37
|
+
mkdirSync(join(studioDir, 'tools'), { recursive: true });
|
|
38
|
+
|
|
39
|
+
writeFileSync(join(studioDir, 'config.yaml'), [
|
|
40
|
+
'providers:',
|
|
41
|
+
' anthropic:',
|
|
42
|
+
' apiKey: test-key',
|
|
43
|
+
'defaults:',
|
|
44
|
+
' provider: anthropic',
|
|
45
|
+
' model: claude-sonnet-4-20250514',
|
|
46
|
+
].join('\n') + '\n');
|
|
47
|
+
|
|
48
|
+
writeFileSync(join(studioDir, 'agents', 'test-agent.agent.yaml'), [
|
|
49
|
+
'name: test-agent',
|
|
50
|
+
'provider: anthropic',
|
|
51
|
+
'model: claude-sonnet-4-20250514',
|
|
52
|
+
'tools:',
|
|
53
|
+
' - shell-run_command',
|
|
54
|
+
].join('\n') + '\n');
|
|
55
|
+
|
|
56
|
+
// Two contracts — one per stage
|
|
57
|
+
writeFileSync(join(studioDir, 'contracts', 'slow-stage.contract.yaml'), [
|
|
58
|
+
'name: slow-stage',
|
|
59
|
+
'version: 1',
|
|
60
|
+
'schema:',
|
|
61
|
+
' required_fields:',
|
|
62
|
+
' - done',
|
|
63
|
+
].join('\n') + '\n');
|
|
64
|
+
|
|
65
|
+
writeFileSync(join(studioDir, 'contracts', 'fast-stage.contract.yaml'), [
|
|
66
|
+
'name: fast-stage',
|
|
67
|
+
'version: 1',
|
|
68
|
+
'schema:',
|
|
69
|
+
' required_fields:',
|
|
70
|
+
' - done',
|
|
71
|
+
].join('\n') + '\n');
|
|
72
|
+
|
|
73
|
+
// Two-stage pipeline: slow (sleep 60 via tool) then fast (instant)
|
|
74
|
+
writeFileSync(join(studioDir, 'pipelines', 'two-stage.pipeline.yaml'), [
|
|
75
|
+
'name: two-stage',
|
|
76
|
+
'version: 2',
|
|
77
|
+
'stages:',
|
|
78
|
+
' - name: slow-stage',
|
|
79
|
+
' kind: work',
|
|
80
|
+
' agent: test-agent',
|
|
81
|
+
' contract: slow-stage',
|
|
82
|
+
' ralph:',
|
|
83
|
+
' max_attempts: 1',
|
|
84
|
+
' retry_strategy: none',
|
|
85
|
+
' context:',
|
|
86
|
+
' include:',
|
|
87
|
+
' - input',
|
|
88
|
+
' - name: fast-stage',
|
|
89
|
+
' kind: work',
|
|
90
|
+
' agent: test-agent',
|
|
91
|
+
' contract: fast-stage',
|
|
92
|
+
' ralph:',
|
|
93
|
+
' max_attempts: 1',
|
|
94
|
+
' retry_strategy: none',
|
|
95
|
+
' context:',
|
|
96
|
+
' include:',
|
|
97
|
+
' - input',
|
|
98
|
+
].join('\n') + '\n');
|
|
99
|
+
|
|
100
|
+
// Mock: slow-stage calls sleep 60, fast-stage is instant (never reached)
|
|
101
|
+
writeFileSync(join(studioDir, 'mock.yaml'), [
|
|
102
|
+
'stages:',
|
|
103
|
+
' slow-stage:',
|
|
104
|
+
' output:',
|
|
105
|
+
' done: true',
|
|
106
|
+
' tool_calls:',
|
|
107
|
+
' - name: shell-run_command',
|
|
108
|
+
' arguments:',
|
|
109
|
+
' command: "sleep 60"',
|
|
110
|
+
' fast-stage:',
|
|
111
|
+
' output:',
|
|
112
|
+
' done: true',
|
|
113
|
+
' tool_calls: []',
|
|
114
|
+
].join('\n') + '\n');
|
|
115
|
+
|
|
116
|
+
if (existsSync(SHELL_TOOL_SRC)) {
|
|
117
|
+
cpSync(SHELL_TOOL_SRC, join(studioDir, 'tools', 'shell.tool.yaml'));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function spawnCli(cwd: string): ChildProcess {
|
|
122
|
+
return spawn(
|
|
123
|
+
process.execPath,
|
|
124
|
+
[CLI_BIN, 'run', 'two-stage', '--provider', 'mock', '--input', 'test'],
|
|
125
|
+
{
|
|
126
|
+
cwd,
|
|
127
|
+
// New process group so the parent test runner is NOT affected by SIGINT
|
|
128
|
+
detached: true,
|
|
129
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function waitForExit(child: ChildProcess, timeoutMs: number): Promise<{ code: number | null; signal: string | null }> {
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
const timer = setTimeout(() => {
|
|
137
|
+
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
138
|
+
reject(new Error(`CLI process did not exit within ${timeoutMs}ms`));
|
|
139
|
+
}, timeoutMs);
|
|
140
|
+
|
|
141
|
+
child.on('exit', (code, signal) => {
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
resolve({ code, signal });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
child.on('error', (err) => {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
reject(err);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe('CLI SIGINT handling', () => {
|
|
154
|
+
let projectDir: string;
|
|
155
|
+
|
|
156
|
+
afterEach(() => {
|
|
157
|
+
if (projectDir) rmSync(projectDir, { recursive: true, force: true });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('exits with code 130 on SIGINT during a run', async () => {
|
|
161
|
+
projectDir = `/tmp/.studio-sigint-test-${Date.now()}`;
|
|
162
|
+
setupProject(projectDir);
|
|
163
|
+
|
|
164
|
+
const child = spawnCli(projectDir);
|
|
165
|
+
|
|
166
|
+
// Wait long enough for:
|
|
167
|
+
// • Node.js startup + ESM module loading (~500ms)
|
|
168
|
+
// • runCommand async setup: loadConfig, createRunStore, loadPipelineByName,
|
|
169
|
+
// resolveRepoPath, loadProjectTools, loadPlugins, engine init (~500ms)
|
|
170
|
+
// • SIGINT handler registration + engine.run() start (~1ms)
|
|
171
|
+
// • sleep 60 tool call to begin executing (a few ms)
|
|
172
|
+
// 2500ms is 2.5× the measured worst-case setup time → reliable without being slow.
|
|
173
|
+
await new Promise((r) => setTimeout(r, 2500));
|
|
174
|
+
|
|
175
|
+
// Send SIGINT to the entire process group (negative PID = PGID when detached: true).
|
|
176
|
+
// This kills both node and the sleep subprocess. Node's SIGINT handler fires,
|
|
177
|
+
// aborts the engine, and the pipeline returns 'cancelled' → process.exit(130).
|
|
178
|
+
try {
|
|
179
|
+
process.kill(-(child.pid!), 'SIGINT');
|
|
180
|
+
} catch {
|
|
181
|
+
// Process already exited — captured below
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const { code } = await waitForExit(child, 8000);
|
|
185
|
+
|
|
186
|
+
expect(code).toBe(130);
|
|
187
|
+
}, 15_000);
|
|
188
|
+
});
|