@specmarket/cli 0.0.4 → 0.0.6
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-OTXWWFAO.js} +42 -3
- package/dist/chunk-OTXWWFAO.js.map +1 -0
- package/dist/{config-R5KWZSJP.js → config-5JMI3YAR.js} +2 -2
- package/dist/index.js +1945 -252
- 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 +245 -0
- package/src/commands/init.ts +359 -25
- package/src/commands/issues.test.ts +382 -0
- package/src/commands/issues.ts +436 -0
- package/src/commands/login.test.ts +99 -0
- package/src/commands/login.ts +2 -6
- package/src/commands/logout.test.ts +54 -0
- package/src/commands/publish.test.ts +159 -0
- package/src/commands/publish.ts +1 -0
- package/src/commands/report.test.ts +181 -0
- package/src/commands/run.test.ts +419 -0
- package/src/commands/run.ts +71 -3
- package/src/commands/search.test.ts +147 -0
- package/src/commands/validate.test.ts +206 -2
- package/src/commands/validate.ts +315 -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/meta-instructions.test.ts +340 -0
- package/src/lib/meta-instructions.ts +562 -0
- package/src/lib/ralph-loop.test.ts +404 -0
- package/src/lib/ralph-loop.ts +501 -95
- package/src/lib/telemetry.ts +7 -1
- package/dist/chunk-MS2DYACY.js.map +0 -1
- /package/dist/{config-R5KWZSJP.js.map → config-5JMI3YAR.js.map} +0 -0
|
@@ -0,0 +1,159 @@
|
|
|
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
|
+
import { SIDECAR_FILENAME } from '@specmarket/shared';
|
|
62
|
+
|
|
63
|
+
// --- Helpers ---
|
|
64
|
+
|
|
65
|
+
const VALID_SPECMARKET_YAML = `spec_format: specmarket
|
|
66
|
+
display_name: "Test Spec"
|
|
67
|
+
description: "A valid test spec with enough description length to pass."
|
|
68
|
+
output_type: web-app
|
|
69
|
+
primary_stack: nextjs-typescript
|
|
70
|
+
tags: []
|
|
71
|
+
estimated_tokens: 50000
|
|
72
|
+
estimated_cost_usd: 2.50
|
|
73
|
+
estimated_time_minutes: 30
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
const VALID_SPEC_YAML = `name: test-spec
|
|
77
|
+
display_name: "Test Spec"
|
|
78
|
+
description: "A valid test spec with enough description length to pass."
|
|
79
|
+
output_type: web-app
|
|
80
|
+
primary_stack: nextjs-typescript
|
|
81
|
+
version: "1.0.0"
|
|
82
|
+
runner: claude
|
|
83
|
+
min_model: "claude-opus-4-5"
|
|
84
|
+
estimated_tokens: 50000
|
|
85
|
+
estimated_cost_usd: 2.50
|
|
86
|
+
estimated_time_minutes: 30
|
|
87
|
+
tags: []
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
const VALID_SUCCESS_CRITERIA = `# Success Criteria
|
|
91
|
+
- [ ] Application builds
|
|
92
|
+
- [ ] Tests pass
|
|
93
|
+
`;
|
|
94
|
+
|
|
95
|
+
describe('handlePublish', () => {
|
|
96
|
+
let specDir: string;
|
|
97
|
+
|
|
98
|
+
beforeEach(async () => {
|
|
99
|
+
vi.clearAllMocks();
|
|
100
|
+
mockExit.mockImplementation((() => {
|
|
101
|
+
throw new Error('process.exit called');
|
|
102
|
+
}) as any);
|
|
103
|
+
specDir = join(tmpdir(), `publish-test-${randomUUID()}`);
|
|
104
|
+
await mkdir(specDir, { recursive: true });
|
|
105
|
+
await mkdir(join(specDir, 'stdlib'), { recursive: true });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
afterEach(async () => {
|
|
109
|
+
await rm(specDir, { recursive: true, force: true }).catch(() => {});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('exits with validation error when spec is invalid', async () => {
|
|
113
|
+
// Create spec dir without required files
|
|
114
|
+
await writeFile(join(specDir, 'spec.yaml'), 'invalid');
|
|
115
|
+
|
|
116
|
+
await expect(handlePublish(specDir)).rejects.toThrow('process.exit called');
|
|
117
|
+
|
|
118
|
+
expect(mockSpinner.fail).toHaveBeenCalledWith(
|
|
119
|
+
expect.stringContaining('validation failed')
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('publishes a valid spec successfully', async () => {
|
|
124
|
+
// Write a valid spec (specmarket.yaml required)
|
|
125
|
+
await Promise.all([
|
|
126
|
+
writeFile(join(specDir, SIDECAR_FILENAME), VALID_SPECMARKET_YAML),
|
|
127
|
+
writeFile(join(specDir, 'spec.yaml'), VALID_SPEC_YAML),
|
|
128
|
+
writeFile(join(specDir, 'PROMPT.md'), '# Prompt\nBuild something.'),
|
|
129
|
+
writeFile(join(specDir, 'SPEC.md'), '# Spec\nDetails here.'),
|
|
130
|
+
writeFile(join(specDir, 'SUCCESS_CRITERIA.md'), VALID_SUCCESS_CRITERIA),
|
|
131
|
+
writeFile(join(specDir, 'stdlib', 'STACK.md'), '# Stack\nNext.js'),
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
// Mock generateUploadUrl
|
|
135
|
+
mockMutation.mockImplementation((fn: string) => {
|
|
136
|
+
if (fn === 'specs.generateUploadUrl') return 'https://upload.example.com/url';
|
|
137
|
+
if (fn === 'specs.publish') return { specId: 'spec123', created: true };
|
|
138
|
+
return null;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Mock upload fetch
|
|
142
|
+
mockFetch.mockResolvedValue({
|
|
143
|
+
ok: true,
|
|
144
|
+
json: async () => ({ storageId: 'storage-id-123' }),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await handlePublish(specDir, { changelog: 'Initial release' });
|
|
148
|
+
|
|
149
|
+
expect(mockMutation).toHaveBeenCalledWith('specs.publish', expect.objectContaining({
|
|
150
|
+
slug: 'test-spec',
|
|
151
|
+
displayName: 'Test Spec',
|
|
152
|
+
version: '1.0.0',
|
|
153
|
+
changelog: 'Initial release',
|
|
154
|
+
}));
|
|
155
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
156
|
+
expect.stringContaining('Spec ID')
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
});
|
package/src/commands/publish.ts
CHANGED
|
@@ -102,6 +102,7 @@ export async function handlePublish(specPath: string, opts: { changelog?: string
|
|
|
102
102
|
specStorageId: storageId,
|
|
103
103
|
readme,
|
|
104
104
|
runner: specYaml.runner,
|
|
105
|
+
specFormat: validation.format,
|
|
105
106
|
minModel: specYaml.min_model,
|
|
106
107
|
estimatedTokens: specYaml.estimated_tokens,
|
|
107
108
|
estimatedCostUsd: specYaml.estimated_cost_usd,
|
|
@@ -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
|
+
});
|