@specmarket/cli 0.0.3 → 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/CHANGELOG.md +6 -1
- package/README.md +1 -1
- package/dist/api-GIDUNUXG.js +0 -0
- package/dist/{chunk-MS2DYACY.js → chunk-DLEMNRTH.js} +19 -2
- package/dist/chunk-DLEMNRTH.js.map +1 -0
- package/dist/chunk-JEUDDJP7.js +0 -0
- package/dist/{config-R5KWZSJP.js → config-OAU6SJLC.js} +2 -2
- package/dist/exec-K3BOXX3C.js +0 -0
- package/dist/index.js +980 -181
- package/dist/index.js.map +1 -1
- package/package.json +21 -15
- 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,147 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// --- Hoisted mocks ---
|
|
4
|
+
|
|
5
|
+
const { mockQuery, mockClient, mockSpinner } = vi.hoisted(() => {
|
|
6
|
+
const mockQuery = vi.fn();
|
|
7
|
+
const mockClient = { query: mockQuery };
|
|
8
|
+
const mockSpinner = {
|
|
9
|
+
start: vi.fn().mockReturnThis(),
|
|
10
|
+
stop: vi.fn().mockReturnThis(),
|
|
11
|
+
succeed: vi.fn().mockReturnThis(),
|
|
12
|
+
fail: vi.fn().mockReturnThis(),
|
|
13
|
+
};
|
|
14
|
+
return { mockQuery, mockClient, mockSpinner };
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
vi.mock('../lib/convex-client.js', () => ({
|
|
18
|
+
getConvexClient: vi.fn().mockResolvedValue(mockClient),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock('ora', () => ({
|
|
22
|
+
default: vi.fn().mockReturnValue(mockSpinner),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('@specmarket/convex/api', () => ({
|
|
26
|
+
api: {
|
|
27
|
+
specs: { search: 'specs.search' },
|
|
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
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
37
|
+
|
|
38
|
+
import { handleSearch } from './search.js';
|
|
39
|
+
|
|
40
|
+
// --- Test data ---
|
|
41
|
+
|
|
42
|
+
const MOCK_RESULTS = [
|
|
43
|
+
{
|
|
44
|
+
scopedName: '@alice/todo-app',
|
|
45
|
+
description: 'A simple todo application with authentication and dark mode',
|
|
46
|
+
replacesSaas: 'Todoist',
|
|
47
|
+
successRate: 0.85,
|
|
48
|
+
avgCostUsd: 1.5,
|
|
49
|
+
communityRating: 4.2,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
scopedName: '@bob/crm-tool',
|
|
53
|
+
description: 'Customer relationship manager with pipeline tracking',
|
|
54
|
+
replacesSaas: null,
|
|
55
|
+
successRate: 0,
|
|
56
|
+
avgCostUsd: 0,
|
|
57
|
+
communityRating: 0,
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
describe('handleSearch', () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
vi.clearAllMocks();
|
|
64
|
+
mockExit.mockImplementation((() => {
|
|
65
|
+
throw new Error('process.exit called');
|
|
66
|
+
}) as any);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('displays results in table format', async () => {
|
|
70
|
+
mockQuery.mockResolvedValue(MOCK_RESULTS);
|
|
71
|
+
|
|
72
|
+
await handleSearch('todo', {});
|
|
73
|
+
|
|
74
|
+
expect(mockQuery).toHaveBeenCalledWith('specs.search', {
|
|
75
|
+
query: 'todo',
|
|
76
|
+
limit: 20,
|
|
77
|
+
});
|
|
78
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
79
|
+
expect.stringContaining('Found 2 spec(s)')
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('shows no-results message when no specs match', async () => {
|
|
84
|
+
mockQuery.mockResolvedValue([]);
|
|
85
|
+
|
|
86
|
+
await handleSearch('nonexistent', {});
|
|
87
|
+
|
|
88
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
89
|
+
expect.stringContaining('No specs found')
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('passes filter options to backend query', async () => {
|
|
94
|
+
mockQuery.mockResolvedValue([]);
|
|
95
|
+
|
|
96
|
+
await handleSearch('test', {
|
|
97
|
+
outputType: 'web-app',
|
|
98
|
+
primaryStack: 'nextjs-typescript',
|
|
99
|
+
replacesSaas: 'Notion',
|
|
100
|
+
limit: '10',
|
|
101
|
+
tag: ['productivity'],
|
|
102
|
+
freeTierOnly: true,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(mockQuery).toHaveBeenCalledWith('specs.search', {
|
|
106
|
+
query: 'test',
|
|
107
|
+
limit: 10,
|
|
108
|
+
outputType: 'web-app',
|
|
109
|
+
primaryStack: 'nextjs-typescript',
|
|
110
|
+
replacesSaas: 'Notion',
|
|
111
|
+
tags: ['productivity'],
|
|
112
|
+
freeTierOnly: true,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('converts min-success-rate from percentage to decimal', async () => {
|
|
117
|
+
mockQuery.mockResolvedValue([]);
|
|
118
|
+
|
|
119
|
+
await handleSearch('test', { minSuccessRate: '80' });
|
|
120
|
+
|
|
121
|
+
expect(mockQuery).toHaveBeenCalledWith('specs.search', {
|
|
122
|
+
query: 'test',
|
|
123
|
+
limit: 20,
|
|
124
|
+
minSuccessRate: 0.8,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('exits with error for invalid min-success-rate', async () => {
|
|
129
|
+
await expect(
|
|
130
|
+
handleSearch('test', { minSuccessRate: '150' })
|
|
131
|
+
).rejects.toThrow('process.exit called');
|
|
132
|
+
|
|
133
|
+
expect(mockSpinner.fail).toHaveBeenCalledWith(
|
|
134
|
+
expect.stringContaining('--min-success-rate must be between 0 and 100')
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('exits with error for invalid max-cost', async () => {
|
|
139
|
+
await expect(
|
|
140
|
+
handleSearch('test', { maxCost: '-5' })
|
|
141
|
+
).rejects.toThrow('process.exit called');
|
|
142
|
+
|
|
143
|
+
expect(mockSpinner.fail).toHaveBeenCalledWith(
|
|
144
|
+
expect.stringContaining('--max-cost must be a non-negative number')
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -11,7 +11,7 @@ description: "A valid test spec with enough description length to pass."
|
|
|
11
11
|
output_type: web-app
|
|
12
12
|
primary_stack: nextjs-typescript
|
|
13
13
|
version: "1.0.0"
|
|
14
|
-
runner: claude
|
|
14
|
+
runner: claude
|
|
15
15
|
min_model: "claude-opus-4-5"
|
|
16
16
|
estimated_tokens: 50000
|
|
17
17
|
estimated_cost_usd: 2.50
|
|
@@ -105,7 +105,7 @@ describe('validateSpec', () => {
|
|
|
105
105
|
await writeValidSpec();
|
|
106
106
|
await writeFile(
|
|
107
107
|
join(tmpDir, 'spec.yaml'),
|
|
108
|
-
'name: Invalid Name With Spaces\ndisplay_name: "Test"\ndescription: "short"\noutput_type: invalid\nprimary_stack: nextjs-typescript\nversion: "1.0.0"\nrunner: claude
|
|
108
|
+
'name: Invalid Name With Spaces\ndisplay_name: "Test"\ndescription: "short"\noutput_type: invalid\nprimary_stack: nextjs-typescript\nversion: "1.0.0"\nrunner: claude\nmin_model: "model"\nestimated_tokens: 100\nestimated_cost_usd: 0.01\nestimated_time_minutes: 1\n'
|
|
109
109
|
);
|
|
110
110
|
const result = await validateSpec(tmpDir);
|
|
111
111
|
expect(result.valid).toBe(false);
|
|
@@ -276,3 +276,130 @@ describe('detectCircularReferences', () => {
|
|
|
276
276
|
expect(cycles).toHaveLength(0);
|
|
277
277
|
});
|
|
278
278
|
});
|
|
279
|
+
|
|
280
|
+
describe('validateSpec format-aware', () => {
|
|
281
|
+
let tmpDir: string;
|
|
282
|
+
|
|
283
|
+
beforeEach(async () => {
|
|
284
|
+
tmpDir = join(tmpdir(), `spec-format-${randomUUID()}`);
|
|
285
|
+
await mkdir(tmpDir, { recursive: true });
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
afterEach(async () => {
|
|
289
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('reports format and formatDetectedBy in result for legacy spec', async () => {
|
|
293
|
+
await mkdir(join(tmpDir, 'stdlib'), { recursive: true });
|
|
294
|
+
await writeFile(join(tmpDir, 'spec.yaml'), VALID_SPEC_YAML);
|
|
295
|
+
await writeFile(join(tmpDir, 'PROMPT.md'), VALID_PROMPT_MD);
|
|
296
|
+
await writeFile(join(tmpDir, 'SPEC.md'), VALID_SPEC_MD);
|
|
297
|
+
await writeFile(join(tmpDir, 'SUCCESS_CRITERIA.md'), VALID_SUCCESS_CRITERIA);
|
|
298
|
+
await writeFile(join(tmpDir, 'stdlib', 'STACK.md'), VALID_STACK_MD);
|
|
299
|
+
const result = await validateSpec(tmpDir);
|
|
300
|
+
expect(result.valid).toBe(true);
|
|
301
|
+
expect(result.format).toBe('specmarket-legacy');
|
|
302
|
+
expect(result.formatDetectedBy).toBe('heuristic');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('speckit dir validates successfully', async () => {
|
|
306
|
+
await writeFile(join(tmpDir, 'spec.md'), '# Spec\nContent here.');
|
|
307
|
+
await writeFile(join(tmpDir, 'tasks.md'), '# Tasks');
|
|
308
|
+
await mkdir(join(tmpDir, '.specify'), { recursive: true });
|
|
309
|
+
const result = await validateSpec(tmpDir);
|
|
310
|
+
expect(result.valid).toBe(true);
|
|
311
|
+
expect(result.format).toBe('speckit');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('speckit missing tasks.md and plan.md returns error', async () => {
|
|
315
|
+
await writeFile(join(tmpDir, 'spec.md'), '# Spec');
|
|
316
|
+
const result = await validateSpec(tmpDir);
|
|
317
|
+
expect(result.valid).toBe(false);
|
|
318
|
+
expect(result.errors.some((e) => e.includes('tasks.md') || e.includes('plan.md'))).toBe(true);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('bmad dir validates successfully', async () => {
|
|
322
|
+
await writeFile(join(tmpDir, 'prd.md'), '# PRD\nProduct requirements.');
|
|
323
|
+
await writeFile(join(tmpDir, 'story-1.md'), '# Story 1');
|
|
324
|
+
const result = await validateSpec(tmpDir);
|
|
325
|
+
expect(result.valid).toBe(true);
|
|
326
|
+
expect(result.format).toBe('bmad');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('ralph dir validates successfully', async () => {
|
|
330
|
+
await writeFile(
|
|
331
|
+
join(tmpDir, 'prd.json'),
|
|
332
|
+
JSON.stringify({ userStories: [{ title: 'As a user I want X' }] })
|
|
333
|
+
);
|
|
334
|
+
const result = await validateSpec(tmpDir);
|
|
335
|
+
expect(result.valid).toBe(true);
|
|
336
|
+
expect(result.format).toBe('ralph');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('ralph prd.json missing userStories returns error', async () => {
|
|
340
|
+
await writeFile(join(tmpDir, 'prd.json'), JSON.stringify({ other: true }));
|
|
341
|
+
const result = await validateSpec(tmpDir);
|
|
342
|
+
expect(result.valid).toBe(false);
|
|
343
|
+
expect(result.errors.some((e) => e.includes('userStories'))).toBe(true);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('custom dir with sufficient .md validates', async () => {
|
|
347
|
+
const content =
|
|
348
|
+
'# Readme\n\nThis is a spec with enough content to pass the 100-byte minimum for custom format. Extra text here.';
|
|
349
|
+
expect(content.length).toBeGreaterThan(100);
|
|
350
|
+
await writeFile(join(tmpDir, 'readme.md'), content);
|
|
351
|
+
const result = await validateSpec(tmpDir);
|
|
352
|
+
expect(result.valid).toBe(true);
|
|
353
|
+
expect(result.format).toBe('custom');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('custom dir with only tiny .md files fails', async () => {
|
|
357
|
+
await writeFile(join(tmpDir, 'tiny.md'), 'x');
|
|
358
|
+
const result = await validateSpec(tmpDir);
|
|
359
|
+
expect(result.valid).toBe(false);
|
|
360
|
+
expect(result.errors.some((e) => e.includes('100 bytes'))).toBe(true);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('sidecar with invalid schema returns validation error', async () => {
|
|
364
|
+
await writeFile(join(tmpDir, 'spec.md'), '# Spec');
|
|
365
|
+
await writeFile(join(tmpDir, 'tasks.md'), '# Tasks');
|
|
366
|
+
await writeFile(
|
|
367
|
+
join(tmpDir, 'specmarket.yaml'),
|
|
368
|
+
'spec_format: speckit\ndisplay_name: X\ndescription: short'
|
|
369
|
+
);
|
|
370
|
+
const result = await validateSpec(tmpDir);
|
|
371
|
+
expect(result.valid).toBe(false);
|
|
372
|
+
expect(result.errors.some((e) => e.includes('specmarket.yaml'))).toBe(true);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('sidecar with valid schema passes and format is from sidecar', async () => {
|
|
376
|
+
await writeFile(join(tmpDir, 'spec.md'), '# Spec');
|
|
377
|
+
await writeFile(join(tmpDir, 'tasks.md'), '# Tasks');
|
|
378
|
+
await writeFile(
|
|
379
|
+
join(tmpDir, 'specmarket.yaml'),
|
|
380
|
+
`spec_format: speckit
|
|
381
|
+
display_name: My Spec
|
|
382
|
+
description: A long enough description for the sidecar schema.
|
|
383
|
+
output_type: web-app
|
|
384
|
+
primary_stack: nextjs-typescript
|
|
385
|
+
`
|
|
386
|
+
);
|
|
387
|
+
const result = await validateSpec(tmpDir);
|
|
388
|
+
expect(result.valid).toBe(true);
|
|
389
|
+
expect(result.format).toBe('speckit');
|
|
390
|
+
expect(result.formatDetectedBy).toBe('sidecar');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('empty directory fails with universal check', async () => {
|
|
394
|
+
const result = await validateSpec(tmpDir);
|
|
395
|
+
expect(result.valid).toBe(false);
|
|
396
|
+
expect(result.errors.some((e) => e.includes('empty') || e.includes('unreadable'))).toBe(true);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('directory with only subdir and no files fails', async () => {
|
|
400
|
+
await mkdir(join(tmpDir, 'sub'), { recursive: true });
|
|
401
|
+
const result = await validateSpec(tmpDir);
|
|
402
|
+
expect(result.valid).toBe(false);
|
|
403
|
+
expect(result.errors.some((e) => e.includes('empty') || e.includes('readable'))).toBe(true);
|
|
404
|
+
});
|
|
405
|
+
});
|