@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.
@@ -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
+ });
@@ -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('<path-or-id>', 'Local path to spec directory or registry ID (@user/name[@version])')
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')