@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,192 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // --- Hoisted mocks ---
4
+
5
+ const { mockQuery, mockAction, mockClient, mockSpinner } = vi.hoisted(() => {
6
+ const mockQuery = vi.fn();
7
+ const mockAction = vi.fn();
8
+ const mockClient = { query: mockQuery, action: mockAction };
9
+ const mockSpinner = {
10
+ start: vi.fn().mockReturnThis(),
11
+ stop: vi.fn().mockReturnThis(),
12
+ succeed: vi.fn().mockReturnThis(),
13
+ fail: vi.fn().mockReturnThis(),
14
+ };
15
+ return { mockQuery, mockAction, mockClient, mockSpinner };
16
+ });
17
+
18
+ vi.mock('../lib/convex-client.js', () => ({
19
+ getConvexClient: vi.fn().mockResolvedValue(mockClient),
20
+ }));
21
+
22
+ vi.mock('ora', () => ({
23
+ default: vi.fn().mockReturnValue(mockSpinner),
24
+ }));
25
+
26
+ vi.mock('@specmarket/convex/api', () => ({
27
+ api: {
28
+ specs: {
29
+ get: 'specs.get',
30
+ getVersions: 'specs.getVersions',
31
+ },
32
+ runs: { getStats: 'runs.getStats' },
33
+ users: { getProfile: 'users.getProfile' },
34
+ issues: { list: 'issues.list' },
35
+ specMaintainers: { list: 'specMaintainers.list' },
36
+ comments: { list: 'comments.list' },
37
+ },
38
+ }));
39
+
40
+ const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {
41
+ throw new Error('process.exit called');
42
+ }) as any);
43
+
44
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
45
+ vi.spyOn(console, 'error').mockImplementation(() => {});
46
+
47
+ import { handleInfo } from './info.js';
48
+
49
+ // --- Test data ---
50
+
51
+ const MOCK_SPEC = {
52
+ _id: 'spec123',
53
+ scopedName: '@alice/todo-app',
54
+ displayName: 'Todo App',
55
+ description: 'A full-featured todo application',
56
+ slug: 'todo-app',
57
+ authorId: 'user1',
58
+ outputType: 'web-app',
59
+ primaryStack: 'nextjs-typescript',
60
+ currentVersion: '1.2.0',
61
+ runner: 'claude',
62
+ minModel: 'claude-opus-4-5',
63
+ status: 'published',
64
+ tags: ['productivity', 'todo'],
65
+ estimatedTokens: 50000,
66
+ estimatedCostUsd: 2.5,
67
+ estimatedTimeMinutes: 30,
68
+ totalRuns: 42,
69
+ successfulRuns: 36,
70
+ successRate: 0.857,
71
+ avgCostUsd: 2.1,
72
+ communityRating: 4.3,
73
+ ratingCount: 15,
74
+ forkCount: 3,
75
+ replacesSaas: 'Todoist',
76
+ replacesPricing: '$4/mo',
77
+ forkedFromId: null,
78
+ infrastructure: null,
79
+ };
80
+
81
+ // Return Promises from mockQuery since info.ts chains .catch() on query results
82
+ function setupQueryMock(overrides: Record<string, unknown> = {}) {
83
+ const defaults: Record<string, unknown> = {
84
+ 'specs.get': MOCK_SPEC,
85
+ 'runs.getStats': { totalRuns: 0, modelBreakdown: [], costDistribution: { min: 0, max: 0, p50: 0 } },
86
+ 'specs.getVersions': { page: [] },
87
+ 'issues.list': { page: [], isDone: true },
88
+ 'specMaintainers.list': [],
89
+ 'comments.list': { page: [], isDone: true },
90
+ 'users.getProfile': null,
91
+ };
92
+ const merged = { ...defaults, ...overrides };
93
+ mockQuery.mockImplementation((fn: string) => Promise.resolve(merged[fn] ?? null));
94
+ }
95
+
96
+ describe('handleInfo', () => {
97
+ beforeEach(() => {
98
+ vi.clearAllMocks();
99
+ mockExit.mockImplementation((() => {
100
+ throw new Error('process.exit called');
101
+ }) as any);
102
+ });
103
+
104
+ it('displays full spec information for a scoped name', async () => {
105
+ setupQueryMock({
106
+ 'users.getProfile': { username: 'alice', displayName: 'Alice', bio: 'Developer' },
107
+ });
108
+
109
+ await handleInfo('@alice/todo-app');
110
+
111
+ expect(mockQuery).toHaveBeenCalledWith('specs.get', {
112
+ scopedName: '@alice/todo-app',
113
+ });
114
+ expect(consoleSpy).toHaveBeenCalledWith(
115
+ expect.stringContaining('Todo App')
116
+ );
117
+ expect(consoleSpy).toHaveBeenCalledWith(
118
+ expect.stringContaining('web-app')
119
+ );
120
+ expect(consoleSpy).toHaveBeenCalledWith(
121
+ expect.stringContaining('85.7%')
122
+ );
123
+ expect(consoleSpy).toHaveBeenCalledWith(
124
+ expect.stringContaining('Todoist')
125
+ );
126
+ });
127
+
128
+ it('exits with error when spec not found', async () => {
129
+ setupQueryMock({ 'specs.get': null });
130
+
131
+ await expect(handleInfo('@alice/nonexistent')).rejects.toThrow(
132
+ 'process.exit called'
133
+ );
134
+
135
+ expect(consoleSpy).toHaveBeenCalledWith(
136
+ expect.stringContaining('Spec not found')
137
+ );
138
+ });
139
+
140
+ it('shows version history when versions exist', async () => {
141
+ setupQueryMock({
142
+ 'specs.getVersions': {
143
+ page: [
144
+ { version: '1.2.0', publishedAt: Date.now(), changelog: 'Added dark mode' },
145
+ { version: '1.1.0', publishedAt: Date.now() - 86400_000, changelog: 'Bug fixes' },
146
+ ],
147
+ },
148
+ });
149
+
150
+ await handleInfo('@alice/todo-app');
151
+
152
+ expect(consoleSpy).toHaveBeenCalledWith(
153
+ expect.stringContaining('Versions (2)')
154
+ );
155
+ expect(consoleSpy).toHaveBeenCalledWith(
156
+ expect.stringContaining('v1.2.0')
157
+ );
158
+ });
159
+
160
+ it('displays infrastructure details when present', async () => {
161
+ setupQueryMock({
162
+ 'specs.get': {
163
+ ...MOCK_SPEC,
164
+ infrastructure: {
165
+ monthlyCost: { freeTierUsd: 0, productionUsd: 25 },
166
+ setupTimeMinutes: 15,
167
+ services: [
168
+ {
169
+ category: 'database',
170
+ name: 'PostgreSQL',
171
+ purpose: 'Data store',
172
+ required: true,
173
+ defaultProvider: 'Neon',
174
+ providers: [{ name: 'Neon', freeTier: true }],
175
+ },
176
+ ],
177
+ userProvided: [],
178
+ deploymentTargets: [{ name: 'Vercel', notes: 'Recommended' }],
179
+ },
180
+ },
181
+ });
182
+
183
+ await handleInfo('@alice/todo-app');
184
+
185
+ expect(consoleSpy).toHaveBeenCalledWith(
186
+ expect.stringContaining('Infrastructure')
187
+ );
188
+ expect(consoleSpy).toHaveBeenCalledWith(
189
+ expect.stringContaining('PostgreSQL')
190
+ );
191
+ });
192
+ });
@@ -26,11 +26,56 @@ export async function handleInfo(specId: string): Promise<void> {
26
26
  // Determine if it's a scoped name or an ID
27
27
  const isScopedName = specId.startsWith('@') || specId.includes('/');
28
28
 
29
- const [spec, stats, versions] = await Promise.all([
29
+ const [spec, stats, versionsResult] = await Promise.all([
30
30
  client.query(api.specs.get, isScopedName ? { scopedName: specId } : { specId }),
31
31
  client.query(api.runs.getStats, { specId: specId as any }).catch(() => null),
32
- client.query(api.specs.getVersions, { specId: specId as any }).catch(() => []),
32
+ client.query(api.specs.getVersions, { specId: specId as any, paginationOpts: { numItems: 25, cursor: null } }).catch(() => ({ page: [] })),
33
33
  ]);
34
+ const versions = versionsResult.page;
35
+
36
+ // Fetch collaboration data (best-effort — don't fail if these aren't available)
37
+ let openIssueCount = 0;
38
+ let maintainers: Array<{ user: { username: string } | null }> = [];
39
+ let commentCount = 0;
40
+
41
+ if (spec) {
42
+ const [issuesResult, maintainersResult, commentsResult] = await Promise.all([
43
+ client
44
+ .query(api.issues.list, {
45
+ specId: spec._id,
46
+ status: 'open' as const,
47
+ paginationOpts: { numItems: 1, cursor: null },
48
+ })
49
+ .catch(() => null),
50
+ client
51
+ .query(api.specMaintainers.list, { specId: spec._id })
52
+ .catch(() => []),
53
+ client
54
+ .query(api.comments.list, {
55
+ targetType: 'spec' as const,
56
+ targetId: spec._id,
57
+ paginationOpts: { numItems: 1, cursor: null },
58
+ })
59
+ .catch(() => null),
60
+ ]);
61
+
62
+ // issues.list returns paginated results — use page length as approximation
63
+ // For accurate count we'd need a dedicated count query, but this shows "at least N"
64
+ if (issuesResult) {
65
+ openIssueCount = issuesResult.page.length;
66
+ if (!issuesResult.isDone && openIssueCount > 0) {
67
+ // There are more pages — indicate "N+"
68
+ openIssueCount = -1; // sentinel for "many"
69
+ }
70
+ }
71
+ maintainers = maintainersResult;
72
+ if (commentsResult) {
73
+ commentCount = commentsResult.page.length;
74
+ if (!commentsResult.isDone && commentCount > 0) {
75
+ commentCount = -1; // sentinel for "many"
76
+ }
77
+ }
78
+ }
34
79
 
35
80
  spinner.stop();
36
81
 
@@ -91,6 +136,25 @@ export async function handleInfo(specId: string): Promise<void> {
91
136
  console.log(chalk.gray(` (Forked from v${spec.forkedFromVersion})`));
92
137
  }
93
138
 
139
+ // Collaboration info
140
+ const issueDisplay =
141
+ openIssueCount === -1 ? 'many' : String(openIssueCount);
142
+ const commentDisplay =
143
+ commentCount === -1 ? 'many' : String(commentCount);
144
+ console.log(
145
+ ` Open Issues: ${issueDisplay}`
146
+ );
147
+ console.log(
148
+ ` Comments: ${commentDisplay}`
149
+ );
150
+ if (maintainers.length > 0) {
151
+ const names = maintainers
152
+ .filter((m) => m.user)
153
+ .map((m) => `@${m.user!.username}`)
154
+ .join(', ');
155
+ console.log(` Maintainers: ${names}`);
156
+ }
157
+
94
158
  if (author) {
95
159
  console.log('');
96
160
  console.log(chalk.bold('Creator:'));
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { readFile, rm, access } 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, mockPrompt } = vi.hoisted(() => {
10
+ const mockSpinner = {
11
+ start: vi.fn().mockReturnThis(),
12
+ stop: vi.fn().mockReturnThis(),
13
+ succeed: vi.fn().mockReturnThis(),
14
+ fail: vi.fn().mockReturnThis(),
15
+ warn: vi.fn().mockReturnThis(),
16
+ };
17
+ const mockPrompt = vi.fn();
18
+ return { mockSpinner, mockPrompt };
19
+ });
20
+
21
+ vi.mock('ora', () => ({
22
+ default: vi.fn().mockReturnValue(mockSpinner),
23
+ }));
24
+
25
+ vi.mock('inquirer', () => ({
26
+ default: { prompt: mockPrompt },
27
+ }));
28
+
29
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
30
+
31
+ import { handleInit } from './init.js';
32
+
33
+ describe('handleInit', () => {
34
+ let tmpDir: string;
35
+
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ tmpDir = join(tmpdir(), `init-test-${randomUUID()}`);
39
+ });
40
+
41
+ afterEach(async () => {
42
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
43
+ });
44
+
45
+ it('creates spec directory with all required files', async () => {
46
+ mockPrompt.mockResolvedValue({
47
+ name: 'test-spec',
48
+ displayName: 'Test Spec',
49
+ replacesSaas: '',
50
+ outputType: 'web-app',
51
+ primaryStack: 'nextjs-typescript',
52
+ });
53
+
54
+ await handleInit({ path: tmpDir });
55
+
56
+ // Verify all required files were created
57
+ await expect(access(join(tmpDir, 'spec.yaml'))).resolves.toBeUndefined();
58
+ await expect(access(join(tmpDir, 'PROMPT.md'))).resolves.toBeUndefined();
59
+ await expect(access(join(tmpDir, 'SPEC.md'))).resolves.toBeUndefined();
60
+ await expect(access(join(tmpDir, 'SUCCESS_CRITERIA.md'))).resolves.toBeUndefined();
61
+ await expect(access(join(tmpDir, 'stdlib', 'STACK.md'))).resolves.toBeUndefined();
62
+ await expect(access(join(tmpDir, 'TASKS.md'))).resolves.toBeUndefined();
63
+
64
+ expect(mockSpinner.succeed).toHaveBeenCalledWith(
65
+ expect.stringContaining('Spec created')
66
+ );
67
+ });
68
+
69
+ it('writes correct spec.yaml content based on prompts', async () => {
70
+ mockPrompt.mockResolvedValue({
71
+ name: 'my-project',
72
+ displayName: 'My Project',
73
+ replacesSaas: 'Notion',
74
+ outputType: 'web-app',
75
+ primaryStack: 'nextjs-typescript',
76
+ });
77
+
78
+ await handleInit({ path: tmpDir });
79
+
80
+ const specYaml = await readFile(join(tmpDir, 'spec.yaml'), 'utf-8');
81
+ expect(specYaml).toContain('name: my-project');
82
+ expect(specYaml).toContain('display_name: "My Project"');
83
+ expect(specYaml).toContain('replaces_saas: "Notion"');
84
+ expect(specYaml).toContain('output_type: web-app');
85
+ expect(specYaml).toContain('primary_stack: nextjs-typescript');
86
+ });
87
+
88
+ it('shows next-steps instructions after creation', async () => {
89
+ mockPrompt.mockResolvedValue({
90
+ name: 'test-spec',
91
+ displayName: 'Test Spec',
92
+ replacesSaas: '',
93
+ outputType: 'cli-tool',
94
+ primaryStack: 'go',
95
+ });
96
+
97
+ await handleInit({ path: tmpDir });
98
+
99
+ expect(consoleSpy).toHaveBeenCalledWith(
100
+ expect.stringContaining('Next steps')
101
+ );
102
+ expect(consoleSpy).toHaveBeenCalledWith(
103
+ expect.stringContaining('specmarket validate')
104
+ );
105
+ });
106
+ });
@@ -26,7 +26,7 @@ ${data.replacesSaas ? `replaces_saas: "${data.replacesSaas}"` : '# replaces_saas
26
26
  output_type: ${data.outputType}
27
27
  primary_stack: ${data.primaryStack}
28
28
  version: "1.0.0"
29
- runner: claude-code
29
+ runner: claude
30
30
  min_model: "claude-opus-4-5"
31
31
 
32
32
  estimated_tokens: 50000
@@ -71,9 +71,9 @@ Read the requirements in SPEC.md and implement the application step by step.
71
71
  ## Process
72
72
 
73
73
  1. Read SPEC.md completely before writing any code
74
- 2. Check fix_plan.md for outstanding items
74
+ 2. Check TASKS.md for outstanding items
75
75
  3. Implement features, run tests, iterate
76
- 4. Update fix_plan.md as you complete items
76
+ 4. Update TASKS.md as you complete items
77
77
  5. Verify SUCCESS_CRITERIA.md criteria are met
78
78
 
79
79
  ## Rules
@@ -81,7 +81,7 @@ Read the requirements in SPEC.md and implement the application step by step.
81
81
  - Follow stdlib/STACK.md for technology choices
82
82
  - Write tests for all business logic
83
83
  - Do not skip steps or take shortcuts
84
- - Update fix_plan.md after each significant change
84
+ - Update TASKS.md after each significant change
85
85
  `;
86
86
 
87
87
  const SPEC_MD_TEMPLATE = (data: { displayName: string }) => `# ${data.displayName} — Specification
@@ -144,12 +144,12 @@ ${primaryStack}
144
144
  - Playwright for E2E (optional)
145
145
  `;
146
146
 
147
- const FIX_PLAN_TEMPLATE = (displayName: string) => `# Fix Plan
147
+ const TASKS_MD_TEMPLATE = (displayName: string) => `# Tasks
148
148
 
149
149
  > This file tracks outstanding work. Update it after each change.
150
- > Empty = implementation complete.
150
+ > All items checked = implementation complete.
151
151
 
152
- ## ${displayName} — Initial Implementation
152
+ ## Phase 1: ${displayName} — Initial Implementation
153
153
 
154
154
  - [ ] Set up project structure and dependencies
155
155
  - [ ] Implement core data model
@@ -158,6 +158,8 @@ const FIX_PLAN_TEMPLATE = (displayName: string) => `# Fix Plan
158
158
  - [ ] Implement UI/interface
159
159
  - [ ] Write integration tests
160
160
  - [ ] Update README.md
161
+
162
+ ## Discovered Issues
161
163
  `;
162
164
 
163
165
  export async function handleInit(opts: {
@@ -247,7 +249,7 @@ export async function handleInit(opts: {
247
249
  writeFile(join(targetDir, 'SPEC.md'), SPEC_MD_TEMPLATE(data)),
248
250
  writeFile(join(targetDir, 'SUCCESS_CRITERIA.md'), SUCCESS_CRITERIA_TEMPLATE),
249
251
  writeFile(join(targetDir, 'stdlib', 'STACK.md'), STACK_MD_TEMPLATE(answers.primaryStack)),
250
- writeFile(join(targetDir, 'fix_plan.md'), FIX_PLAN_TEMPLATE(answers.displayName)),
252
+ writeFile(join(targetDir, 'TASKS.md'), TASKS_MD_TEMPLATE(answers.displayName)),
251
253
  ]);
252
254
 
253
255
  spinner.succeed(chalk.green(`Spec created at ${targetDir}`));
@@ -257,8 +259,8 @@ export async function handleInit(opts: {
257
259
  console.log(` 1. ${chalk.cyan(`cd ${answers.name}`)}`);
258
260
  console.log(` 2. Edit ${chalk.cyan('SPEC.md')} with your application requirements`);
259
261
  console.log(` 3. Edit ${chalk.cyan('SUCCESS_CRITERIA.md')} with specific pass/fail criteria`);
260
- console.log(` 4. Run ${chalk.cyan(`specmarket validate ${answers.name}`)} to check your spec`);
261
- console.log(` 5. Run ${chalk.cyan(`specmarket run ${answers.name}`)} to execute the spec`);
262
+ console.log(` 4. Run ${chalk.cyan(`specmarket validate`)} to check your spec`);
263
+ console.log(` 5. Run ${chalk.cyan(`specmarket run`)} to execute the spec`);
262
264
  } catch (err) {
263
265
  spinner.fail(chalk.red(`Failed to create spec: ${(err as Error).message}`));
264
266
  throw err;