@specmarket/cli 0.0.4 → 0.0.5
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/README.md +1 -1
- package/dist/{chunk-MS2DYACY.js → chunk-DLEMNRTH.js} +19 -2
- package/dist/chunk-DLEMNRTH.js.map +1 -0
- package/dist/{config-R5KWZSJP.js → config-OAU6SJLC.js} +2 -2
- package/dist/index.js +980 -181
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/comment.test.ts +211 -0
- package/src/commands/comment.ts +176 -0
- package/src/commands/fork.test.ts +163 -0
- package/src/commands/info.test.ts +192 -0
- package/src/commands/info.ts +66 -2
- package/src/commands/init.test.ts +106 -0
- package/src/commands/init.ts +12 -10
- package/src/commands/issues.test.ts +377 -0
- package/src/commands/issues.ts +443 -0
- package/src/commands/login.test.ts +99 -0
- package/src/commands/logout.test.ts +54 -0
- package/src/commands/publish.test.ts +146 -0
- package/src/commands/report.test.ts +181 -0
- package/src/commands/run.test.ts +213 -0
- package/src/commands/run.ts +10 -2
- package/src/commands/search.test.ts +147 -0
- package/src/commands/validate.test.ts +129 -2
- package/src/commands/validate.ts +333 -192
- package/src/commands/whoami.test.ts +106 -0
- package/src/index.ts +6 -0
- package/src/lib/convex-client.ts +6 -2
- package/src/lib/format-detection.test.ts +223 -0
- package/src/lib/format-detection.ts +172 -0
- package/src/lib/ralph-loop.ts +49 -20
- package/src/lib/telemetry.ts +2 -1
- package/dist/chunk-MS2DYACY.js.map +0 -1
- /package/dist/{config-R5KWZSJP.js.map → config-OAU6SJLC.js.map} +0 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdir, writeFile, rm } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
|
|
7
|
+
// --- Hoisted mocks ---
|
|
8
|
+
|
|
9
|
+
const { mockQuery, mockMutation, mockClient, mockSpinner } = vi.hoisted(() => {
|
|
10
|
+
const mockQuery = vi.fn();
|
|
11
|
+
const mockMutation = vi.fn();
|
|
12
|
+
const mockClient = { query: mockQuery, mutation: mockMutation, action: vi.fn() };
|
|
13
|
+
const mockSpinner = {
|
|
14
|
+
start: vi.fn().mockReturnThis(),
|
|
15
|
+
stop: vi.fn().mockReturnThis(),
|
|
16
|
+
succeed: vi.fn().mockReturnThis(),
|
|
17
|
+
fail: vi.fn().mockReturnThis(),
|
|
18
|
+
warn: vi.fn().mockReturnThis(),
|
|
19
|
+
text: '',
|
|
20
|
+
};
|
|
21
|
+
return { mockQuery, mockMutation, mockClient, mockSpinner };
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
vi.mock('../lib/convex-client.js', () => ({
|
|
25
|
+
getConvexClient: vi.fn().mockResolvedValue(mockClient),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock('../lib/auth.js', () => ({
|
|
29
|
+
requireAuth: vi.fn().mockResolvedValue({
|
|
30
|
+
token: 'test-token',
|
|
31
|
+
username: 'testuser',
|
|
32
|
+
expiresAt: Date.now() + 3600_000,
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
vi.mock('ora', () => ({
|
|
37
|
+
default: vi.fn().mockReturnValue(mockSpinner),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock('@specmarket/convex/api', () => ({
|
|
41
|
+
api: {
|
|
42
|
+
specs: {
|
|
43
|
+
publish: 'specs.publish',
|
|
44
|
+
generateUploadUrl: 'specs.generateUploadUrl',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
// Mock fetch for upload
|
|
50
|
+
const mockFetch = vi.fn();
|
|
51
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
52
|
+
|
|
53
|
+
const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
54
|
+
throw new Error('process.exit called');
|
|
55
|
+
}) as any);
|
|
56
|
+
|
|
57
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
58
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
59
|
+
|
|
60
|
+
import { handlePublish } from './publish.js';
|
|
61
|
+
|
|
62
|
+
// --- Helpers ---
|
|
63
|
+
|
|
64
|
+
const VALID_SPEC_YAML = `name: test-spec
|
|
65
|
+
display_name: "Test Spec"
|
|
66
|
+
description: "A valid test spec with enough description length to pass."
|
|
67
|
+
output_type: web-app
|
|
68
|
+
primary_stack: nextjs-typescript
|
|
69
|
+
version: "1.0.0"
|
|
70
|
+
runner: claude
|
|
71
|
+
min_model: "claude-opus-4-5"
|
|
72
|
+
estimated_tokens: 50000
|
|
73
|
+
estimated_cost_usd: 2.50
|
|
74
|
+
estimated_time_minutes: 30
|
|
75
|
+
tags: []
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
const VALID_SUCCESS_CRITERIA = `# Success Criteria
|
|
79
|
+
- [ ] Application builds
|
|
80
|
+
- [ ] Tests pass
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
describe('handlePublish', () => {
|
|
84
|
+
let specDir: string;
|
|
85
|
+
|
|
86
|
+
beforeEach(async () => {
|
|
87
|
+
vi.clearAllMocks();
|
|
88
|
+
mockExit.mockImplementation((() => {
|
|
89
|
+
throw new Error('process.exit called');
|
|
90
|
+
}) as any);
|
|
91
|
+
specDir = join(tmpdir(), `publish-test-${randomUUID()}`);
|
|
92
|
+
await mkdir(specDir, { recursive: true });
|
|
93
|
+
await mkdir(join(specDir, 'stdlib'), { recursive: true });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(async () => {
|
|
97
|
+
await rm(specDir, { recursive: true, force: true }).catch(() => {});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('exits with validation error when spec is invalid', async () => {
|
|
101
|
+
// Create spec dir without required files
|
|
102
|
+
await writeFile(join(specDir, 'spec.yaml'), 'invalid');
|
|
103
|
+
|
|
104
|
+
await expect(handlePublish(specDir)).rejects.toThrow('process.exit called');
|
|
105
|
+
|
|
106
|
+
expect(mockSpinner.fail).toHaveBeenCalledWith(
|
|
107
|
+
expect.stringContaining('validation failed')
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('publishes a valid spec successfully', async () => {
|
|
112
|
+
// Write a valid spec
|
|
113
|
+
await Promise.all([
|
|
114
|
+
writeFile(join(specDir, 'spec.yaml'), VALID_SPEC_YAML),
|
|
115
|
+
writeFile(join(specDir, 'PROMPT.md'), '# Prompt\nBuild something.'),
|
|
116
|
+
writeFile(join(specDir, 'SPEC.md'), '# Spec\nDetails here.'),
|
|
117
|
+
writeFile(join(specDir, 'SUCCESS_CRITERIA.md'), VALID_SUCCESS_CRITERIA),
|
|
118
|
+
writeFile(join(specDir, 'stdlib', 'STACK.md'), '# Stack\nNext.js'),
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
// Mock generateUploadUrl
|
|
122
|
+
mockMutation.mockImplementation((fn: string) => {
|
|
123
|
+
if (fn === 'specs.generateUploadUrl') return 'https://upload.example.com/url';
|
|
124
|
+
if (fn === 'specs.publish') return { specId: 'spec123', created: true };
|
|
125
|
+
return null;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Mock upload fetch
|
|
129
|
+
mockFetch.mockResolvedValue({
|
|
130
|
+
ok: true,
|
|
131
|
+
json: async () => ({ storageId: 'storage-id-123' }),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await handlePublish(specDir, { changelog: 'Initial release' });
|
|
135
|
+
|
|
136
|
+
expect(mockMutation).toHaveBeenCalledWith('specs.publish', expect.objectContaining({
|
|
137
|
+
slug: 'test-spec',
|
|
138
|
+
displayName: 'Test Spec',
|
|
139
|
+
version: '1.0.0',
|
|
140
|
+
changelog: 'Initial release',
|
|
141
|
+
}));
|
|
142
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
143
|
+
expect.stringContaining('Spec ID')
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdir, writeFile, rm } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
import { homedir } from 'os';
|
|
7
|
+
|
|
8
|
+
// --- Hoisted mocks ---
|
|
9
|
+
|
|
10
|
+
const { mockQuery, mockClient, mockLoadCreds } = vi.hoisted(() => {
|
|
11
|
+
const mockQuery = vi.fn();
|
|
12
|
+
const mockClient = { query: mockQuery };
|
|
13
|
+
const mockLoadCreds = vi.fn();
|
|
14
|
+
return { mockQuery, mockClient, mockLoadCreds };
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
vi.mock('../lib/convex-client.js', () => ({
|
|
18
|
+
getConvexClient: vi.fn().mockResolvedValue(mockClient),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock('../lib/auth.js', () => ({
|
|
22
|
+
loadCredentials: mockLoadCreds,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('@specmarket/convex/api', () => ({
|
|
26
|
+
api: {
|
|
27
|
+
runs: { getById: 'runs.getById' },
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
32
|
+
throw new Error('process.exit called');
|
|
33
|
+
}) as any);
|
|
34
|
+
|
|
35
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
36
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
37
|
+
|
|
38
|
+
import { handleReport } from './report.js';
|
|
39
|
+
|
|
40
|
+
// --- Test data ---
|
|
41
|
+
|
|
42
|
+
const MOCK_RUN_REPORT = {
|
|
43
|
+
runId: 'run-abc-123',
|
|
44
|
+
specId: 'spec123',
|
|
45
|
+
specVersion: '1.0.0',
|
|
46
|
+
model: 'claude-opus-4-5',
|
|
47
|
+
runner: 'claude',
|
|
48
|
+
loopCount: 5,
|
|
49
|
+
totalTokens: 25000,
|
|
50
|
+
totalCostUsd: 1.2345,
|
|
51
|
+
totalTimeMinutes: 12.5,
|
|
52
|
+
status: 'success',
|
|
53
|
+
successCriteriaResults: [
|
|
54
|
+
{ criterion: 'App builds', passed: true },
|
|
55
|
+
{ criterion: 'Tests pass', passed: false, details: '2 failures' },
|
|
56
|
+
],
|
|
57
|
+
os: 'darwin',
|
|
58
|
+
nodeVersion: 'v20.10.0',
|
|
59
|
+
cliVersion: '0.0.4',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe('handleReport', () => {
|
|
63
|
+
let runsDir: string;
|
|
64
|
+
const runId = `test-run-${randomUUID()}`;
|
|
65
|
+
|
|
66
|
+
beforeEach(async () => {
|
|
67
|
+
vi.clearAllMocks();
|
|
68
|
+
mockExit.mockImplementation((() => {
|
|
69
|
+
throw new Error('process.exit called');
|
|
70
|
+
}) as any);
|
|
71
|
+
runsDir = join(homedir(), '.specmarket', 'runs', runId);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(async () => {
|
|
75
|
+
await rm(runsDir, { recursive: true, force: true }).catch(() => {});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('displays a local run report when file exists', async () => {
|
|
79
|
+
await mkdir(runsDir, { recursive: true });
|
|
80
|
+
await writeFile(
|
|
81
|
+
join(runsDir, 'run-report.json'),
|
|
82
|
+
JSON.stringify({ ...MOCK_RUN_REPORT, runId })
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
await handleReport(runId);
|
|
86
|
+
|
|
87
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
88
|
+
expect.stringContaining('Run Report')
|
|
89
|
+
);
|
|
90
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
91
|
+
expect.stringContaining('[local]')
|
|
92
|
+
);
|
|
93
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
94
|
+
expect.stringContaining('SUCCESS')
|
|
95
|
+
);
|
|
96
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
97
|
+
expect.stringContaining('25,000')
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('displays success criteria results', async () => {
|
|
102
|
+
await mkdir(runsDir, { recursive: true });
|
|
103
|
+
await writeFile(
|
|
104
|
+
join(runsDir, 'run-report.json'),
|
|
105
|
+
JSON.stringify({ ...MOCK_RUN_REPORT, runId })
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
await handleReport(runId);
|
|
109
|
+
|
|
110
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
111
|
+
expect.stringContaining('1/2 criteria passed')
|
|
112
|
+
);
|
|
113
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
114
|
+
expect.stringContaining('App builds')
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('exits with auth error when not found locally and not logged in', async () => {
|
|
119
|
+
// Run ID that doesn't exist locally
|
|
120
|
+
const missingRunId = `missing-${randomUUID()}`;
|
|
121
|
+
mockLoadCreds.mockResolvedValue(null);
|
|
122
|
+
|
|
123
|
+
await expect(handleReport(missingRunId)).rejects.toThrow(
|
|
124
|
+
'process.exit called'
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
128
|
+
expect.stringContaining('not found locally')
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('fetches from platform when not found locally but logged in', async () => {
|
|
133
|
+
const missingRunId = `missing-${randomUUID()}`;
|
|
134
|
+
mockLoadCreds.mockResolvedValue({
|
|
135
|
+
token: 'test-token',
|
|
136
|
+
username: 'alice',
|
|
137
|
+
});
|
|
138
|
+
mockQuery.mockResolvedValue({
|
|
139
|
+
_id: missingRunId,
|
|
140
|
+
specId: 'spec123',
|
|
141
|
+
specVersion: '1.0.0',
|
|
142
|
+
model: 'claude-opus-4-5',
|
|
143
|
+
runner: 'claude',
|
|
144
|
+
loopCount: 3,
|
|
145
|
+
totalTokens: 15000,
|
|
146
|
+
totalCostUsd: 0.75,
|
|
147
|
+
totalTimeMinutes: 8.2,
|
|
148
|
+
status: 'success',
|
|
149
|
+
successCriteriaResults: [],
|
|
150
|
+
os: 'linux',
|
|
151
|
+
nodeVersion: 'v20.10.0',
|
|
152
|
+
cliVersion: '0.0.4',
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await handleReport(missingRunId);
|
|
156
|
+
|
|
157
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
158
|
+
expect.stringContaining('[platform]')
|
|
159
|
+
);
|
|
160
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
161
|
+
expect.stringContaining('SUCCESS')
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('exits with error when run not found on platform', async () => {
|
|
166
|
+
const missingRunId = `missing-${randomUUID()}`;
|
|
167
|
+
mockLoadCreds.mockResolvedValue({
|
|
168
|
+
token: 'test-token',
|
|
169
|
+
username: 'alice',
|
|
170
|
+
});
|
|
171
|
+
mockQuery.mockResolvedValue(null);
|
|
172
|
+
|
|
173
|
+
await expect(handleReport(missingRunId)).rejects.toThrow(
|
|
174
|
+
'process.exit called'
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
178
|
+
expect.stringContaining('Run not found')
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdir, writeFile, rm } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
|
|
7
|
+
// --- Hoisted mocks ---
|
|
8
|
+
|
|
9
|
+
const { mockSpinner, mockRunSpec, mockSubmitTelemetry, mockPromptTelemetry, mockCheckClaude } =
|
|
10
|
+
vi.hoisted(() => {
|
|
11
|
+
const mockSpinner = {
|
|
12
|
+
start: vi.fn().mockReturnThis(),
|
|
13
|
+
stop: vi.fn().mockReturnThis(),
|
|
14
|
+
succeed: vi.fn().mockReturnThis(),
|
|
15
|
+
fail: vi.fn().mockReturnThis(),
|
|
16
|
+
text: '',
|
|
17
|
+
};
|
|
18
|
+
const mockRunSpec = vi.fn();
|
|
19
|
+
const mockSubmitTelemetry = vi.fn();
|
|
20
|
+
const mockPromptTelemetry = vi.fn();
|
|
21
|
+
const mockCheckClaude = vi.fn().mockResolvedValue(undefined);
|
|
22
|
+
return { mockSpinner, mockRunSpec, mockSubmitTelemetry, mockPromptTelemetry, mockCheckClaude };
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
vi.mock('ora', () => ({
|
|
26
|
+
default: vi.fn().mockReturnValue(mockSpinner),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock('../lib/ralph-loop.js', () => ({
|
|
30
|
+
runSpec: mockRunSpec,
|
|
31
|
+
checkClaudeCliInstalled: mockCheckClaude,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock('../lib/telemetry.js', () => ({
|
|
35
|
+
submitTelemetry: mockSubmitTelemetry,
|
|
36
|
+
promptTelemetryOptIn: mockPromptTelemetry,
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
vi.mock('../lib/auth.js', () => ({
|
|
40
|
+
loadCredentials: vi.fn().mockResolvedValue(null),
|
|
41
|
+
isAuthenticated: vi.fn().mockResolvedValue(false),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
vi.mock('../lib/convex-client.js', () => ({
|
|
45
|
+
getConvexClient: vi.fn().mockResolvedValue({ query: vi.fn(), action: vi.fn() }),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
// Mock the module builtin so createRequire can find package.json during tests
|
|
49
|
+
vi.mock('module', () => ({
|
|
50
|
+
createRequire: vi.fn().mockReturnValue(
|
|
51
|
+
vi.fn().mockReturnValue({ version: '0.0.4' })
|
|
52
|
+
),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
56
|
+
throw new Error('process.exit called');
|
|
57
|
+
}) as any);
|
|
58
|
+
|
|
59
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
60
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
61
|
+
|
|
62
|
+
import { handleRun } from './run.js';
|
|
63
|
+
|
|
64
|
+
// --- Helpers ---
|
|
65
|
+
|
|
66
|
+
const VALID_SPEC_YAML = `name: test-spec
|
|
67
|
+
display_name: "Test Spec"
|
|
68
|
+
description: "A valid test spec with enough description length to pass."
|
|
69
|
+
output_type: web-app
|
|
70
|
+
primary_stack: nextjs-typescript
|
|
71
|
+
version: "1.0.0"
|
|
72
|
+
runner: claude
|
|
73
|
+
min_model: "claude-opus-4-5"
|
|
74
|
+
estimated_tokens: 50000
|
|
75
|
+
estimated_cost_usd: 2.50
|
|
76
|
+
estimated_time_minutes: 30
|
|
77
|
+
tags: []
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
const VALID_SUCCESS_CRITERIA = `# Success Criteria
|
|
81
|
+
- [ ] Application builds
|
|
82
|
+
- [ ] Tests pass
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
describe('handleRun', () => {
|
|
86
|
+
let specDir: string;
|
|
87
|
+
|
|
88
|
+
beforeEach(async () => {
|
|
89
|
+
vi.clearAllMocks();
|
|
90
|
+
mockExit.mockImplementation((() => {
|
|
91
|
+
throw new Error('process.exit called');
|
|
92
|
+
}) as any);
|
|
93
|
+
specDir = join(tmpdir(), `run-test-${randomUUID()}`);
|
|
94
|
+
await mkdir(specDir, { recursive: true });
|
|
95
|
+
await mkdir(join(specDir, 'stdlib'), { recursive: true });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterEach(async () => {
|
|
99
|
+
await rm(specDir, { recursive: true, force: true }).catch(() => {});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('exits with validation error when spec is invalid', async () => {
|
|
103
|
+
// Create a spec directory missing required files
|
|
104
|
+
await writeFile(join(specDir, 'spec.yaml'), 'invalid yaml content');
|
|
105
|
+
|
|
106
|
+
await expect(handleRun(specDir, {})).rejects.toThrow(
|
|
107
|
+
'process.exit called'
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
111
|
+
expect.stringContaining('validation failed')
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('runs a valid spec and prints summary', async () => {
|
|
116
|
+
await Promise.all([
|
|
117
|
+
writeFile(join(specDir, 'spec.yaml'), VALID_SPEC_YAML),
|
|
118
|
+
writeFile(join(specDir, 'PROMPT.md'), '# Prompt\nBuild it.'),
|
|
119
|
+
writeFile(join(specDir, 'SPEC.md'), '# Spec\nDetails.'),
|
|
120
|
+
writeFile(join(specDir, 'SUCCESS_CRITERIA.md'), VALID_SUCCESS_CRITERIA),
|
|
121
|
+
writeFile(join(specDir, 'stdlib', 'STACK.md'), '# Stack\nNext.js'),
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
mockRunSpec.mockResolvedValue({
|
|
125
|
+
report: {
|
|
126
|
+
runId: 'run-123',
|
|
127
|
+
status: 'success',
|
|
128
|
+
loopCount: 3,
|
|
129
|
+
totalTokens: 15000,
|
|
130
|
+
totalCostUsd: 1.5,
|
|
131
|
+
totalTimeMinutes: 5.2,
|
|
132
|
+
successCriteriaResults: [
|
|
133
|
+
{ criterion: 'Application builds', passed: true },
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
outputDir: '/tmp/output',
|
|
137
|
+
});
|
|
138
|
+
mockSubmitTelemetry.mockResolvedValue(false);
|
|
139
|
+
|
|
140
|
+
await handleRun(specDir, {});
|
|
141
|
+
|
|
142
|
+
expect(mockRunSpec).toHaveBeenCalledWith(
|
|
143
|
+
specDir,
|
|
144
|
+
expect.objectContaining({ name: 'test-spec', version: '1.0.0' }),
|
|
145
|
+
expect.any(Object),
|
|
146
|
+
expect.any(Function)
|
|
147
|
+
);
|
|
148
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
149
|
+
expect.stringContaining('Run Complete')
|
|
150
|
+
);
|
|
151
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
152
|
+
expect.stringContaining('SUCCESS')
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('prints security warning before running', async () => {
|
|
157
|
+
await Promise.all([
|
|
158
|
+
writeFile(join(specDir, 'spec.yaml'), VALID_SPEC_YAML),
|
|
159
|
+
writeFile(join(specDir, 'PROMPT.md'), '# Prompt\nBuild it.'),
|
|
160
|
+
writeFile(join(specDir, 'SPEC.md'), '# Spec\nDetails.'),
|
|
161
|
+
writeFile(join(specDir, 'SUCCESS_CRITERIA.md'), VALID_SUCCESS_CRITERIA),
|
|
162
|
+
writeFile(join(specDir, 'stdlib', 'STACK.md'), '# Stack\nNext.js'),
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
mockRunSpec.mockResolvedValue({
|
|
166
|
+
report: {
|
|
167
|
+
runId: 'run-123',
|
|
168
|
+
status: 'success',
|
|
169
|
+
loopCount: 1,
|
|
170
|
+
totalTokens: 1000,
|
|
171
|
+
totalCostUsd: 0.1,
|
|
172
|
+
totalTimeMinutes: 1,
|
|
173
|
+
successCriteriaResults: [],
|
|
174
|
+
},
|
|
175
|
+
outputDir: '/tmp/output',
|
|
176
|
+
});
|
|
177
|
+
mockSubmitTelemetry.mockResolvedValue(false);
|
|
178
|
+
|
|
179
|
+
await handleRun(specDir, {});
|
|
180
|
+
|
|
181
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
182
|
+
expect.stringContaining('SECURITY WARNING')
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('exits with budget_exceeded code on budget runs', async () => {
|
|
187
|
+
await Promise.all([
|
|
188
|
+
writeFile(join(specDir, 'spec.yaml'), VALID_SPEC_YAML),
|
|
189
|
+
writeFile(join(specDir, 'PROMPT.md'), '# Prompt\nBuild it.'),
|
|
190
|
+
writeFile(join(specDir, 'SPEC.md'), '# Spec\nDetails.'),
|
|
191
|
+
writeFile(join(specDir, 'SUCCESS_CRITERIA.md'), VALID_SUCCESS_CRITERIA),
|
|
192
|
+
writeFile(join(specDir, 'stdlib', 'STACK.md'), '# Stack\nNext.js'),
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
mockRunSpec.mockResolvedValue({
|
|
196
|
+
report: {
|
|
197
|
+
runId: 'run-123',
|
|
198
|
+
status: 'budget_exceeded',
|
|
199
|
+
loopCount: 50,
|
|
200
|
+
totalTokens: 500000,
|
|
201
|
+
totalCostUsd: 10.0,
|
|
202
|
+
totalTimeMinutes: 60,
|
|
203
|
+
successCriteriaResults: [],
|
|
204
|
+
},
|
|
205
|
+
outputDir: '/tmp/output',
|
|
206
|
+
});
|
|
207
|
+
mockSubmitTelemetry.mockResolvedValue(false);
|
|
208
|
+
|
|
209
|
+
await expect(handleRun(specDir, {})).rejects.toThrow(
|
|
210
|
+
'process.exit called'
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
});
|
package/src/commands/run.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { validateSpec } from './validate.js';
|
|
|
9
9
|
import { loadCredentials, isAuthenticated } from '../lib/auth.js';
|
|
10
10
|
import { getConvexClient } from '../lib/convex-client.js';
|
|
11
11
|
import { submitTelemetry, promptTelemetryOptIn } from '../lib/telemetry.js';
|
|
12
|
-
import { runSpec } from '../lib/ralph-loop.js';
|
|
12
|
+
import { runSpec, checkClaudeCliInstalled } from '../lib/ralph-loop.js';
|
|
13
13
|
import type { LoopIteration } from '@specmarket/shared';
|
|
14
14
|
import createDebug from 'debug';
|
|
15
15
|
import { createRequire } from 'module';
|
|
@@ -94,6 +94,14 @@ export async function handleRun(
|
|
|
94
94
|
await promptTelemetryOptIn();
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// Pre-flight check: Ensure Claude CLI is installed
|
|
98
|
+
try {
|
|
99
|
+
await checkClaudeCliInstalled();
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.log(chalk.red(`\n✗ ${(err as Error).message}`));
|
|
102
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
103
|
+
}
|
|
104
|
+
|
|
97
105
|
const maxLoops = opts.maxLoops ? parseInt(opts.maxLoops, 10) : undefined;
|
|
98
106
|
const maxBudget = opts.maxBudget ? parseFloat(opts.maxBudget) : undefined;
|
|
99
107
|
|
|
@@ -362,7 +370,7 @@ async function resolveSpecPath(pathOrId: string): Promise<ResolvedSpec> {
|
|
|
362
370
|
export function createRunCommand(): Command {
|
|
363
371
|
return new Command('run')
|
|
364
372
|
.description('Execute a spec locally using the Ralph Loop')
|
|
365
|
-
.argument('
|
|
373
|
+
.argument('[path-or-id]', 'Local path to spec directory or registry ID (@user/name[@version])', '.')
|
|
366
374
|
.option('--max-loops <n>', 'Maximum loop iterations (default: 50)')
|
|
367
375
|
.option('--max-budget <usd>', 'Maximum budget in USD (default: 2x estimated)')
|
|
368
376
|
.option('--no-telemetry', 'Disable telemetry submission for this run')
|