@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.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/dist/{chunk-MS2DYACY.js → chunk-OTXWWFAO.js} +42 -3
  3. package/dist/chunk-OTXWWFAO.js.map +1 -0
  4. package/dist/{config-R5KWZSJP.js → config-5JMI3YAR.js} +2 -2
  5. package/dist/index.js +1945 -252
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/commands/comment.test.ts +211 -0
  9. package/src/commands/comment.ts +176 -0
  10. package/src/commands/fork.test.ts +163 -0
  11. package/src/commands/info.test.ts +192 -0
  12. package/src/commands/info.ts +66 -2
  13. package/src/commands/init.test.ts +245 -0
  14. package/src/commands/init.ts +359 -25
  15. package/src/commands/issues.test.ts +382 -0
  16. package/src/commands/issues.ts +436 -0
  17. package/src/commands/login.test.ts +99 -0
  18. package/src/commands/login.ts +2 -6
  19. package/src/commands/logout.test.ts +54 -0
  20. package/src/commands/publish.test.ts +159 -0
  21. package/src/commands/publish.ts +1 -0
  22. package/src/commands/report.test.ts +181 -0
  23. package/src/commands/run.test.ts +419 -0
  24. package/src/commands/run.ts +71 -3
  25. package/src/commands/search.test.ts +147 -0
  26. package/src/commands/validate.test.ts +206 -2
  27. package/src/commands/validate.ts +315 -192
  28. package/src/commands/whoami.test.ts +106 -0
  29. package/src/index.ts +6 -0
  30. package/src/lib/convex-client.ts +6 -2
  31. package/src/lib/format-detection.test.ts +223 -0
  32. package/src/lib/format-detection.ts +172 -0
  33. package/src/lib/meta-instructions.test.ts +340 -0
  34. package/src/lib/meta-instructions.ts +562 -0
  35. package/src/lib/ralph-loop.test.ts +404 -0
  36. package/src/lib/ralph-loop.ts +501 -95
  37. package/src/lib/telemetry.ts +7 -1
  38. package/dist/chunk-MS2DYACY.js.map +0 -1
  39. /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
+ });
@@ -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
+ });