@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specmarket/cli",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "type": "module",
5
5
  "description": "SpecMarket CLI - discover, validate, execute, and publish AI specs",
6
6
  "bin": {
@@ -0,0 +1,211 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // --- Hoisted mocks ---
4
+
5
+ const { mockQuery, mockMutation, mockClient, mockSpinner } = vi.hoisted(() => {
6
+ const mockQuery = vi.fn();
7
+ const mockMutation = vi.fn();
8
+ const mockClient = { query: mockQuery, mutation: mockMutation };
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, mockMutation, mockClient, mockSpinner };
16
+ });
17
+
18
+ vi.mock('../lib/convex-client.js', () => ({
19
+ getConvexClient: vi.fn().mockResolvedValue(mockClient),
20
+ }));
21
+
22
+ vi.mock('../lib/auth.js', () => ({
23
+ requireAuth: vi.fn().mockResolvedValue({
24
+ token: 'test-token',
25
+ username: 'testuser',
26
+ expiresAt: Date.now() + 3600_000,
27
+ }),
28
+ }));
29
+
30
+ vi.mock('ora', () => ({
31
+ default: vi.fn().mockReturnValue(mockSpinner),
32
+ }));
33
+
34
+ vi.mock('@specmarket/convex/api', () => ({
35
+ api: {
36
+ specs: { get: 'specs.get' },
37
+ issues: { get: 'issues.get' },
38
+ comments: { create: 'comments.create' },
39
+ },
40
+ }));
41
+
42
+ const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {
43
+ throw new Error('process.exit called');
44
+ }) as any);
45
+
46
+ vi.spyOn(console, 'error').mockImplementation(() => {});
47
+
48
+ import { handleComment } from './comment.js';
49
+
50
+ // --- Test data ---
51
+
52
+ const MOCK_SPEC = {
53
+ _id: 'spec123',
54
+ scopedName: '@alice/my-spec',
55
+ };
56
+
57
+ const MOCK_ISSUE = {
58
+ _id: 'issue456',
59
+ specId: 'spec123',
60
+ number: 3,
61
+ };
62
+
63
+ describe('handleComment', () => {
64
+ beforeEach(() => {
65
+ vi.clearAllMocks();
66
+ mockExit.mockImplementation((() => {
67
+ throw new Error('process.exit called');
68
+ }) as any);
69
+ });
70
+
71
+ it('posts a comment on a spec using scoped name', async () => {
72
+ mockQuery.mockImplementation((fn: string) => {
73
+ if (fn === 'specs.get') return MOCK_SPEC;
74
+ });
75
+ mockMutation.mockResolvedValue('comment789');
76
+
77
+ await handleComment('spec', '@alice/my-spec', 'Great spec!', {});
78
+
79
+ expect(mockQuery).toHaveBeenCalledWith('specs.get', {
80
+ scopedName: '@alice/my-spec',
81
+ });
82
+ expect(mockMutation).toHaveBeenCalledWith('comments.create', {
83
+ targetType: 'spec',
84
+ targetId: 'spec123',
85
+ body: 'Great spec!',
86
+ });
87
+ expect(mockSpinner.succeed).toHaveBeenCalledWith(
88
+ expect.stringContaining('Comment posted')
89
+ );
90
+ });
91
+
92
+ it('posts a comment on an issue using @user/spec#N format', async () => {
93
+ mockQuery.mockImplementation((fn: string) => {
94
+ if (fn === 'specs.get') return MOCK_SPEC;
95
+ if (fn === 'issues.get') return MOCK_ISSUE;
96
+ });
97
+ mockMutation.mockResolvedValue('comment789');
98
+
99
+ await handleComment(
100
+ 'issue',
101
+ '@alice/my-spec#3',
102
+ 'I can reproduce this',
103
+ {}
104
+ );
105
+
106
+ expect(mockQuery).toHaveBeenCalledWith('specs.get', {
107
+ scopedName: '@alice/my-spec',
108
+ });
109
+ expect(mockQuery).toHaveBeenCalledWith('issues.get', {
110
+ specId: 'spec123',
111
+ number: 3,
112
+ });
113
+ expect(mockMutation).toHaveBeenCalledWith('comments.create', {
114
+ targetType: 'issue',
115
+ targetId: 'issue456',
116
+ body: 'I can reproduce this',
117
+ });
118
+ });
119
+
120
+ it('posts a comment on a bounty', async () => {
121
+ mockMutation.mockResolvedValue('comment789');
122
+
123
+ await handleComment(
124
+ 'bounty',
125
+ 'bounty999',
126
+ 'I am working on this',
127
+ {}
128
+ );
129
+
130
+ expect(mockMutation).toHaveBeenCalledWith('comments.create', {
131
+ targetType: 'bounty',
132
+ targetId: 'bounty999',
133
+ body: 'I am working on this',
134
+ });
135
+ });
136
+
137
+ it('passes reply comment ID when --reply flag is set', async () => {
138
+ mockQuery.mockImplementation((fn: string) => {
139
+ if (fn === 'specs.get') return MOCK_SPEC;
140
+ });
141
+ mockMutation.mockResolvedValue('comment790');
142
+
143
+ await handleComment('spec', '@alice/my-spec', 'Reply text', {
144
+ reply: 'parentComment123',
145
+ });
146
+
147
+ expect(mockMutation).toHaveBeenCalledWith('comments.create', {
148
+ targetType: 'spec',
149
+ targetId: 'spec123',
150
+ body: 'Reply text',
151
+ parentId: 'parentComment123',
152
+ });
153
+ });
154
+
155
+ it('exits with error for invalid target type', async () => {
156
+ await expect(
157
+ handleComment('invalid', 'ref', 'text', {})
158
+ ).rejects.toThrow('process.exit called');
159
+
160
+ expect(mockSpinner.fail).toHaveBeenCalledWith(
161
+ expect.stringContaining('Invalid target type')
162
+ );
163
+ });
164
+
165
+ it('exits with error for missing # in issue reference', async () => {
166
+ await expect(
167
+ handleComment('issue', '@alice/my-spec', 'text', {})
168
+ ).rejects.toThrow('process.exit called');
169
+
170
+ expect(mockSpinner.fail).toHaveBeenCalledWith(
171
+ expect.stringContaining('Invalid issue reference')
172
+ );
173
+ });
174
+
175
+ it('exits with error for non-numeric issue number', async () => {
176
+ await expect(
177
+ handleComment('issue', '@alice/my-spec#abc', 'text', {})
178
+ ).rejects.toThrow('process.exit called');
179
+
180
+ expect(mockSpinner.fail).toHaveBeenCalledWith(
181
+ expect.stringContaining('Invalid issue number')
182
+ );
183
+ });
184
+
185
+ it('exits with error when spec not found', async () => {
186
+ mockQuery.mockResolvedValue(null);
187
+
188
+ await expect(
189
+ handleComment('spec', '@alice/nonexistent', 'text', {})
190
+ ).rejects.toThrow('process.exit called');
191
+
192
+ expect(mockSpinner.fail).toHaveBeenCalledWith(
193
+ expect.stringContaining('Spec not found')
194
+ );
195
+ });
196
+
197
+ it('exits with error when issue not found', async () => {
198
+ mockQuery.mockImplementation((fn: string) => {
199
+ if (fn === 'specs.get') return MOCK_SPEC;
200
+ if (fn === 'issues.get') return null;
201
+ });
202
+
203
+ await expect(
204
+ handleComment('issue', '@alice/my-spec#99', 'text', {})
205
+ ).rejects.toThrow('process.exit called');
206
+
207
+ expect(mockSpinner.fail).toHaveBeenCalledWith(
208
+ expect.stringContaining('Issue #99 not found')
209
+ );
210
+ });
211
+ });
@@ -0,0 +1,176 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { getConvexClient } from '../lib/convex-client.js';
4
+ import { requireAuth } from '../lib/auth.js';
5
+ import { EXIT_CODES } from '@specmarket/shared';
6
+
7
+ /**
8
+ * Posts a comment on a spec, issue, or bounty.
9
+ *
10
+ * Target reference formats:
11
+ * - spec: @user/spec or specId
12
+ * - issue: @user/spec#3 (scoped name + issue number)
13
+ * - bounty: bountyId
14
+ *
15
+ * Requires authentication.
16
+ */
17
+ export async function handleComment(
18
+ targetType: string,
19
+ targetRef: string,
20
+ body: string,
21
+ opts: { reply?: string }
22
+ ): Promise<void> {
23
+ const creds = await requireAuth();
24
+
25
+ let api: any;
26
+ try {
27
+ api = (await import('@specmarket/convex/api')).api;
28
+ } catch {
29
+ console.error(
30
+ chalk.red('Error: Could not load Convex API bindings. Is CONVEX_URL configured?')
31
+ );
32
+ process.exit(EXIT_CODES.NETWORK_ERROR);
33
+ }
34
+
35
+ const client = await getConvexClient(creds.token);
36
+ const spinner = (await import('ora')).default('Posting comment...').start();
37
+
38
+ try {
39
+ let resolvedTargetType: 'spec' | 'bounty' | 'issue';
40
+ let resolvedTargetId: string;
41
+
42
+ if (targetType === 'spec') {
43
+ resolvedTargetType = 'spec';
44
+ const isScopedName = targetRef.startsWith('@') || targetRef.includes('/');
45
+ const spec = await client.query(
46
+ api.specs.get,
47
+ isScopedName ? { scopedName: targetRef } : { specId: targetRef }
48
+ );
49
+ if (!spec) {
50
+ spinner.fail(chalk.red(`Spec not found: ${targetRef}`));
51
+ process.exit(EXIT_CODES.VALIDATION_ERROR);
52
+ }
53
+ resolvedTargetId = spec._id;
54
+ } else if (targetType === 'issue') {
55
+ resolvedTargetType = 'issue';
56
+ // Parse @user/spec#3 format
57
+ const hashIndex = targetRef.lastIndexOf('#');
58
+ if (hashIndex === -1) {
59
+ spinner.fail(
60
+ chalk.red(
61
+ 'Invalid issue reference. Use format: @user/spec#<number>'
62
+ )
63
+ );
64
+ process.exit(EXIT_CODES.VALIDATION_ERROR);
65
+ }
66
+
67
+ const specRef = targetRef.slice(0, hashIndex);
68
+ const issueNumber = parseInt(targetRef.slice(hashIndex + 1), 10);
69
+
70
+ if (isNaN(issueNumber) || issueNumber < 1) {
71
+ spinner.fail(
72
+ chalk.red(`Invalid issue number in "${targetRef}". Use format: @user/spec#<number>`)
73
+ );
74
+ process.exit(EXIT_CODES.VALIDATION_ERROR);
75
+ }
76
+
77
+ // Resolve spec
78
+ const isScopedName = specRef.startsWith('@') || specRef.includes('/');
79
+ const spec = await client.query(
80
+ api.specs.get,
81
+ isScopedName ? { scopedName: specRef } : { specId: specRef }
82
+ );
83
+ if (!spec) {
84
+ spinner.fail(chalk.red(`Spec not found: ${specRef}`));
85
+ process.exit(EXIT_CODES.VALIDATION_ERROR);
86
+ }
87
+
88
+ // Resolve issue
89
+ const issue = await client.query(api.issues.get, {
90
+ specId: spec._id,
91
+ number: issueNumber,
92
+ });
93
+ if (!issue) {
94
+ spinner.fail(
95
+ chalk.red(`Issue #${issueNumber} not found on ${spec.scopedName}`)
96
+ );
97
+ process.exit(EXIT_CODES.VALIDATION_ERROR);
98
+ }
99
+ resolvedTargetId = issue._id;
100
+ } else if (targetType === 'bounty') {
101
+ resolvedTargetType = 'bounty';
102
+ resolvedTargetId = targetRef;
103
+ } else {
104
+ spinner.fail(
105
+ chalk.red(
106
+ `Invalid target type: "${targetType}". Use "spec", "issue", or "bounty".`
107
+ )
108
+ );
109
+ process.exit(EXIT_CODES.VALIDATION_ERROR);
110
+ }
111
+
112
+ const args: Record<string, unknown> = {
113
+ targetType: resolvedTargetType,
114
+ targetId: resolvedTargetId,
115
+ body: body.trim(),
116
+ };
117
+
118
+ if (opts.reply) {
119
+ args.parentId = opts.reply;
120
+ }
121
+
122
+ await client.mutation(api.comments.create, args);
123
+
124
+ spinner.succeed(chalk.green(`Comment posted on ${targetType} ${targetRef}`));
125
+ } catch (err) {
126
+ spinner.fail(chalk.red(`Failed to post comment: ${(err as Error).message}`));
127
+ process.exit(EXIT_CODES.NETWORK_ERROR);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Creates the `specmarket comment` command.
133
+ *
134
+ * Usage:
135
+ * specmarket comment spec @user/spec "Great spec!"
136
+ * specmarket comment issue @user/spec#3 "I can reproduce this"
137
+ * specmarket comment bounty <bounty-id> "I'm working on this"
138
+ * --reply <comment-id> Reply to a specific comment (threading)
139
+ */
140
+ export function createCommentCommand(): Command {
141
+ return new Command('comment')
142
+ .description('Post a comment on a spec, issue, or bounty (requires login)')
143
+ .argument(
144
+ '<target-type>',
145
+ 'Target type: spec, issue, or bounty'
146
+ )
147
+ .argument(
148
+ '<target-ref>',
149
+ 'Target reference (e.g., @user/spec, @user/spec#3, bounty-id)'
150
+ )
151
+ .argument('<body>', 'Comment body text')
152
+ .option(
153
+ '--reply <comment-id>',
154
+ 'Reply to a specific comment (threading)'
155
+ )
156
+ .action(
157
+ async (
158
+ targetType: string,
159
+ targetRef: string,
160
+ body: string,
161
+ opts: { reply?: string }
162
+ ) => {
163
+ try {
164
+ await handleComment(targetType, targetRef, body, opts);
165
+ } catch (err) {
166
+ const error = err as NodeJS.ErrnoException;
167
+ if (error.code === String(EXIT_CODES.AUTH_ERROR)) {
168
+ console.error(chalk.red(error.message));
169
+ process.exit(EXIT_CODES.AUTH_ERROR);
170
+ }
171
+ console.error(chalk.red(`Error: ${error.message}`));
172
+ process.exit(EXIT_CODES.NETWORK_ERROR);
173
+ }
174
+ }
175
+ );
176
+ }
@@ -0,0 +1,163 @@
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 { mockQuery, mockAction, mockClient, mockSpinner } = vi.hoisted(() => {
10
+ const mockQuery = vi.fn();
11
+ const mockAction = vi.fn();
12
+ const mockClient = { query: mockQuery, action: mockAction };
13
+ const mockSpinner = {
14
+ start: vi.fn().mockReturnThis(),
15
+ stop: vi.fn().mockReturnThis(),
16
+ succeed: vi.fn().mockReturnThis(),
17
+ fail: vi.fn().mockReturnThis(),
18
+ text: '',
19
+ };
20
+ return { mockQuery, mockAction, mockClient, mockSpinner };
21
+ });
22
+
23
+ vi.mock('../lib/convex-client.js', () => ({
24
+ getConvexClient: vi.fn().mockResolvedValue(mockClient),
25
+ }));
26
+
27
+ vi.mock('../lib/auth.js', () => ({
28
+ requireAuth: vi.fn().mockResolvedValue({
29
+ token: 'test-token',
30
+ username: 'testuser',
31
+ expiresAt: Date.now() + 3600_000,
32
+ }),
33
+ }));
34
+
35
+ vi.mock('ora', () => ({
36
+ default: vi.fn().mockReturnValue(mockSpinner),
37
+ }));
38
+
39
+ vi.mock('@specmarket/convex/api', () => ({
40
+ api: {
41
+ specs: {
42
+ get: 'specs.get',
43
+ download: 'specs.download',
44
+ },
45
+ },
46
+ }));
47
+
48
+ // Mock exec for unzip
49
+ vi.mock('../lib/exec.js', () => ({
50
+ execAsync: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
51
+ }));
52
+
53
+ // Mock fetch for download
54
+ const mockFetch = vi.fn();
55
+ vi.stubGlobal('fetch', mockFetch);
56
+
57
+ const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {
58
+ throw new Error('process.exit called');
59
+ }) as any);
60
+
61
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
62
+ vi.spyOn(console, 'error').mockImplementation(() => {});
63
+
64
+ import { handleFork } from './fork.js';
65
+
66
+ // --- Test data ---
67
+
68
+ const MOCK_SPEC = {
69
+ _id: 'spec123',
70
+ scopedName: '@alice/todo-app',
71
+ displayName: 'Todo App',
72
+ slug: 'todo-app',
73
+ currentVersion: '2.0.0',
74
+ };
75
+
76
+ describe('handleFork', () => {
77
+ let targetDir: string;
78
+
79
+ beforeEach(() => {
80
+ vi.clearAllMocks();
81
+ mockExit.mockImplementation((() => {
82
+ throw new Error('process.exit called');
83
+ }) as any);
84
+ targetDir = join(tmpdir(), `fork-test-${randomUUID()}`);
85
+ });
86
+
87
+ afterEach(async () => {
88
+ await rm(targetDir, { recursive: true, force: true }).catch(() => {});
89
+ });
90
+
91
+ it('exits with error when spec not found', async () => {
92
+ mockQuery.mockResolvedValue(null);
93
+
94
+ await expect(
95
+ handleFork('@alice/nonexistent', targetDir)
96
+ ).rejects.toThrow('process.exit called');
97
+
98
+ expect(mockSpinner.fail).toHaveBeenCalledWith(
99
+ expect.stringContaining('Spec not found')
100
+ );
101
+ });
102
+
103
+ it('downloads and extracts a spec fork successfully', async () => {
104
+ mockQuery.mockResolvedValue(MOCK_SPEC);
105
+ mockAction.mockResolvedValue({ url: 'https://storage.example.com/spec.zip' });
106
+
107
+ // Create a minimal zip-like response (the actual extraction is mocked via exec.js)
108
+ mockFetch.mockResolvedValue({
109
+ ok: true,
110
+ arrayBuffer: async () => new ArrayBuffer(100),
111
+ });
112
+
113
+ // We need to create the targetDir and a spec.yaml in it since the fork handler
114
+ // reads and rewrites spec.yaml after extracting. We'll mock readFile indirectly
115
+ // by having exec mock create the directory structure.
116
+ // Actually, the code does: downloadAndExtract → readFile(specYamlPath).
117
+ // Since exec is mocked, we need to pre-create the spec.yaml file.
118
+ const { mkdir, writeFile } = await import('fs/promises');
119
+ await mkdir(targetDir, { recursive: true });
120
+ await writeFile(
121
+ join(targetDir, 'spec.yaml'),
122
+ 'name: todo-app\nversion: "2.0.0"\n'
123
+ );
124
+
125
+ await handleFork('@alice/todo-app', targetDir);
126
+
127
+ expect(mockAction).toHaveBeenCalledWith('specs.download', {
128
+ specId: 'spec123',
129
+ });
130
+ expect(mockSpinner.succeed).toHaveBeenCalledWith(
131
+ expect.stringContaining('Forked')
132
+ );
133
+
134
+ // Verify spec.yaml was updated with fork info
135
+ const updatedYaml = await readFile(join(targetDir, 'spec.yaml'), 'utf-8');
136
+ expect(updatedYaml).toContain('forked_from_id');
137
+ expect(updatedYaml).toContain('spec123');
138
+ });
139
+
140
+ it('uses scopedName query for @-prefixed spec IDs', async () => {
141
+ mockQuery.mockResolvedValue(null);
142
+
143
+ await expect(
144
+ handleFork('@bob/crm', targetDir)
145
+ ).rejects.toThrow('process.exit called');
146
+
147
+ expect(mockQuery).toHaveBeenCalledWith('specs.get', {
148
+ scopedName: '@bob/crm',
149
+ });
150
+ });
151
+
152
+ it('uses specId query for non-scoped spec IDs', async () => {
153
+ mockQuery.mockResolvedValue(null);
154
+
155
+ await expect(
156
+ handleFork('spec123', targetDir)
157
+ ).rejects.toThrow('process.exit called');
158
+
159
+ expect(mockQuery).toHaveBeenCalledWith('specs.get', {
160
+ specId: 'spec123',
161
+ });
162
+ });
163
+ });