@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,436 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import Table from 'cli-table3';
|
|
4
|
+
import { getConvexClient } from '../lib/convex-client.js';
|
|
5
|
+
import { requireAuth } from '../lib/auth.js';
|
|
6
|
+
import { EXIT_CODES } from '@specmarket/shared';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Loads the Convex API module. Exits on failure.
|
|
10
|
+
*/
|
|
11
|
+
async function loadApi(): Promise<any> {
|
|
12
|
+
try {
|
|
13
|
+
return (await import('@specmarket/convex/api')).api;
|
|
14
|
+
} catch {
|
|
15
|
+
console.error(
|
|
16
|
+
chalk.red('Error: Could not load Convex API bindings. Is CONVEX_URL configured?')
|
|
17
|
+
);
|
|
18
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolves a spec identifier (scoped name or ID) to a spec document.
|
|
24
|
+
* Exits with VALIDATION_ERROR if not found.
|
|
25
|
+
*/
|
|
26
|
+
async function resolveSpec(client: any, api: any, specRef: string): Promise<any> {
|
|
27
|
+
const isScopedName = specRef.startsWith('@') || specRef.includes('/');
|
|
28
|
+
const spec = await client.query(
|
|
29
|
+
api.specs.get,
|
|
30
|
+
isScopedName ? { scopedName: specRef } : { specId: specRef }
|
|
31
|
+
);
|
|
32
|
+
if (!spec) {
|
|
33
|
+
console.error(chalk.red(`Spec not found: ${specRef}`));
|
|
34
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
35
|
+
}
|
|
36
|
+
return spec;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Formats a relative time string from a timestamp (e.g., "3 days ago").
|
|
41
|
+
*/
|
|
42
|
+
function relativeTime(timestamp: number): string {
|
|
43
|
+
const diff = Date.now() - timestamp;
|
|
44
|
+
const seconds = Math.floor(diff / 1000);
|
|
45
|
+
const minutes = Math.floor(seconds / 60);
|
|
46
|
+
const hours = Math.floor(minutes / 60);
|
|
47
|
+
const days = Math.floor(hours / 24);
|
|
48
|
+
|
|
49
|
+
if (days > 0) return `${days}d ago`;
|
|
50
|
+
if (hours > 0) return `${hours}h ago`;
|
|
51
|
+
if (minutes > 0) return `${minutes}m ago`;
|
|
52
|
+
return 'just now';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Lists open issues for a spec in table format.
|
|
57
|
+
* Public endpoint — no auth required.
|
|
58
|
+
*
|
|
59
|
+
* Flags: --status open|closed|all, --label <label>
|
|
60
|
+
*/
|
|
61
|
+
export async function handleIssuesList(
|
|
62
|
+
specRef: string,
|
|
63
|
+
opts: { status?: string; label?: string }
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
const api = await loadApi();
|
|
66
|
+
const client = await getConvexClient();
|
|
67
|
+
const spinner = (await import('ora')).default('Loading issues...').start();
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const spec = await resolveSpec(client, api, specRef);
|
|
71
|
+
|
|
72
|
+
const statusFilter =
|
|
73
|
+
opts.status === 'all' ? undefined : (opts.status as 'open' | 'closed') ?? 'open';
|
|
74
|
+
|
|
75
|
+
const result = await client.query(api.issues.list, {
|
|
76
|
+
specId: spec._id,
|
|
77
|
+
status: statusFilter,
|
|
78
|
+
labels: opts.label ? [opts.label] : undefined,
|
|
79
|
+
paginationOpts: { numItems: 50, cursor: null },
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
spinner.stop();
|
|
83
|
+
|
|
84
|
+
const issues = result.page;
|
|
85
|
+
|
|
86
|
+
if (issues.length === 0) {
|
|
87
|
+
const statusLabel = statusFilter ?? 'any';
|
|
88
|
+
console.log(chalk.gray(`No ${statusLabel} issues for ${spec.scopedName}`));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(
|
|
93
|
+
chalk.bold(`\n${issues.length} issue(s) for ${spec.scopedName}:\n`)
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const table = new Table({
|
|
97
|
+
head: [
|
|
98
|
+
chalk.cyan('#'),
|
|
99
|
+
chalk.cyan('Title'),
|
|
100
|
+
chalk.cyan('Author'),
|
|
101
|
+
chalk.cyan('Age'),
|
|
102
|
+
chalk.cyan('Labels'),
|
|
103
|
+
],
|
|
104
|
+
style: { compact: true },
|
|
105
|
+
colWidths: [6, 40, 16, 10, 20],
|
|
106
|
+
wordWrap: true,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
for (const issue of issues) {
|
|
110
|
+
const statusIcon = issue.status === 'open' ? chalk.green('●') : chalk.gray('○');
|
|
111
|
+
table.push([
|
|
112
|
+
`${statusIcon} ${issue.number}`,
|
|
113
|
+
issue.title.slice(0, 60),
|
|
114
|
+
issue.author ? `@${issue.author.username}` : chalk.gray('unknown'),
|
|
115
|
+
relativeTime(issue.createdAt),
|
|
116
|
+
issue.labels.length > 0 ? issue.labels.join(', ') : chalk.gray('—'),
|
|
117
|
+
]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log(table.toString());
|
|
121
|
+
console.log(
|
|
122
|
+
chalk.gray(
|
|
123
|
+
`\nView: ${chalk.cyan(`specmarket issues ${specRef} <number>`)}`
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
spinner.fail(chalk.red(`Failed to load issues: ${(err as Error).message}`));
|
|
128
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Creates a new issue interactively (prompts for title and body).
|
|
134
|
+
* Requires authentication.
|
|
135
|
+
*/
|
|
136
|
+
export async function handleIssuesCreate(specRef: string): Promise<void> {
|
|
137
|
+
const creds = await requireAuth();
|
|
138
|
+
const api = await loadApi();
|
|
139
|
+
const client = await getConvexClient(creds.token);
|
|
140
|
+
|
|
141
|
+
const spec = await resolveSpec(client, api, specRef);
|
|
142
|
+
|
|
143
|
+
const { default: inquirer } = await import('inquirer');
|
|
144
|
+
|
|
145
|
+
const answers = await inquirer.prompt<{ title: string; body: string }>([
|
|
146
|
+
{
|
|
147
|
+
type: 'input',
|
|
148
|
+
name: 'title',
|
|
149
|
+
message: 'Issue title:',
|
|
150
|
+
validate: (v: string) => v.trim().length > 0 || 'Title cannot be empty',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
type: 'editor',
|
|
154
|
+
name: 'body',
|
|
155
|
+
message: 'Issue body (markdown):',
|
|
156
|
+
validate: (v: string) => v.trim().length > 0 || 'Body cannot be empty',
|
|
157
|
+
},
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
const spinner = (await import('ora')).default('Creating issue...').start();
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const result = await client.mutation(api.issues.create, {
|
|
164
|
+
specId: spec._id,
|
|
165
|
+
title: answers.title.trim(),
|
|
166
|
+
body: answers.body.trim(),
|
|
167
|
+
labels: [],
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
spinner.succeed(
|
|
171
|
+
chalk.green(`Issue #${result.number} created on ${spec.scopedName}`)
|
|
172
|
+
);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
spinner.fail(chalk.red(`Failed to create issue: ${(err as Error).message}`));
|
|
175
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Displays a single issue with detail and recent comments.
|
|
181
|
+
* Public endpoint — no auth required.
|
|
182
|
+
*/
|
|
183
|
+
export async function handleIssuesView(
|
|
184
|
+
specRef: string,
|
|
185
|
+
issueNumber: number
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
const api = await loadApi();
|
|
188
|
+
const client = await getConvexClient();
|
|
189
|
+
const spinner = (await import('ora')).default(`Loading issue #${issueNumber}...`).start();
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const spec = await resolveSpec(client, api, specRef);
|
|
193
|
+
|
|
194
|
+
const issue = await client.query(api.issues.get, {
|
|
195
|
+
specId: spec._id,
|
|
196
|
+
number: issueNumber,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (!issue) {
|
|
200
|
+
spinner.fail(chalk.red(`Issue #${issueNumber} not found on ${spec.scopedName}`));
|
|
201
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Fetch recent comments
|
|
205
|
+
const commentsResult = await client.query(api.comments.list, {
|
|
206
|
+
targetType: 'issue',
|
|
207
|
+
targetId: issue._id,
|
|
208
|
+
paginationOpts: { numItems: 10, cursor: null },
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
spinner.stop();
|
|
212
|
+
|
|
213
|
+
const statusBadge =
|
|
214
|
+
issue.status === 'open'
|
|
215
|
+
? chalk.green.bold(' OPEN ')
|
|
216
|
+
: chalk.gray.bold(' CLOSED ');
|
|
217
|
+
|
|
218
|
+
console.log('');
|
|
219
|
+
console.log(
|
|
220
|
+
`${statusBadge} ${chalk.bold(`#${issue.number}: ${issue.title}`)}`
|
|
221
|
+
);
|
|
222
|
+
console.log(chalk.gray('─'.repeat(60)));
|
|
223
|
+
console.log(
|
|
224
|
+
`${chalk.bold('Author:')} ${issue.author ? `@${issue.author.username}` : 'unknown'} ${chalk.bold('Created:')} ${new Date(issue.createdAt).toLocaleDateString()}`
|
|
225
|
+
);
|
|
226
|
+
if (issue.labels.length > 0) {
|
|
227
|
+
console.log(`${chalk.bold('Labels:')} ${issue.labels.join(', ')}`);
|
|
228
|
+
}
|
|
229
|
+
if (issue.closedAt) {
|
|
230
|
+
console.log(
|
|
231
|
+
`${chalk.bold('Closed:')} ${new Date(issue.closedAt).toLocaleDateString()}`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
console.log('');
|
|
235
|
+
console.log(issue.body);
|
|
236
|
+
console.log('');
|
|
237
|
+
|
|
238
|
+
// Comments
|
|
239
|
+
if (commentsResult.page.length > 0) {
|
|
240
|
+
console.log(
|
|
241
|
+
chalk.bold(`Comments (${issue.commentCount}):`)
|
|
242
|
+
);
|
|
243
|
+
console.log(chalk.gray('─'.repeat(40)));
|
|
244
|
+
|
|
245
|
+
for (const comment of commentsResult.page) {
|
|
246
|
+
const author = comment.author
|
|
247
|
+
? `@${comment.author.username}`
|
|
248
|
+
: 'unknown';
|
|
249
|
+
const edited = comment.editedAt ? chalk.gray(' (edited)') : '';
|
|
250
|
+
console.log(
|
|
251
|
+
` ${chalk.bold(author)} — ${relativeTime(comment.createdAt)}${edited}`
|
|
252
|
+
);
|
|
253
|
+
console.log(` ${comment.body}`);
|
|
254
|
+
|
|
255
|
+
if (comment.replies && comment.replies.length > 0) {
|
|
256
|
+
for (const reply of comment.replies) {
|
|
257
|
+
const replyAuthor = reply.author
|
|
258
|
+
? `@${reply.author.username}`
|
|
259
|
+
: 'unknown';
|
|
260
|
+
const replyEdited = reply.editedAt ? chalk.gray(' (edited)') : '';
|
|
261
|
+
console.log(
|
|
262
|
+
` ${chalk.bold(replyAuthor)} — ${relativeTime(reply.createdAt)}${replyEdited}`
|
|
263
|
+
);
|
|
264
|
+
console.log(` ${reply.body}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
console.log('');
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
console.log(chalk.gray('No comments yet.'));
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
spinner.fail(
|
|
274
|
+
chalk.red(`Failed to load issue: ${(err as Error).message}`)
|
|
275
|
+
);
|
|
276
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Closes an issue. Requires authentication.
|
|
282
|
+
* Authorized: issue author, spec author, or spec maintainer.
|
|
283
|
+
*/
|
|
284
|
+
export async function handleIssuesClose(
|
|
285
|
+
specRef: string,
|
|
286
|
+
issueNumber: number
|
|
287
|
+
): Promise<void> {
|
|
288
|
+
const creds = await requireAuth();
|
|
289
|
+
const api = await loadApi();
|
|
290
|
+
const client = await getConvexClient(creds.token);
|
|
291
|
+
const spinner = (await import('ora')).default(`Closing issue #${issueNumber}...`).start();
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const spec = await resolveSpec(client, api, specRef);
|
|
295
|
+
|
|
296
|
+
const issue = await client.query(api.issues.get, {
|
|
297
|
+
specId: spec._id,
|
|
298
|
+
number: issueNumber,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
if (!issue) {
|
|
302
|
+
spinner.fail(chalk.red(`Issue #${issueNumber} not found on ${spec.scopedName}`));
|
|
303
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
await client.mutation(api.issues.close, {
|
|
307
|
+
issueId: issue._id,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
spinner.succeed(
|
|
311
|
+
chalk.green(`Issue #${issueNumber} closed on ${spec.scopedName}`)
|
|
312
|
+
);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
spinner.fail(chalk.red(`Failed to close issue: ${(err as Error).message}`));
|
|
315
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Reopens a closed issue. Requires authentication.
|
|
321
|
+
* Authorized: issue author, spec author, or spec maintainer.
|
|
322
|
+
*/
|
|
323
|
+
export async function handleIssuesReopen(
|
|
324
|
+
specRef: string,
|
|
325
|
+
issueNumber: number
|
|
326
|
+
): Promise<void> {
|
|
327
|
+
const creds = await requireAuth();
|
|
328
|
+
const api = await loadApi();
|
|
329
|
+
const client = await getConvexClient(creds.token);
|
|
330
|
+
const spinner = (await import('ora')).default(`Reopening issue #${issueNumber}...`).start();
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const spec = await resolveSpec(client, api, specRef);
|
|
334
|
+
|
|
335
|
+
const issue = await client.query(api.issues.get, {
|
|
336
|
+
specId: spec._id,
|
|
337
|
+
number: issueNumber,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
if (!issue) {
|
|
341
|
+
spinner.fail(chalk.red(`Issue #${issueNumber} not found on ${spec.scopedName}`));
|
|
342
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
await client.mutation(api.issues.reopen, {
|
|
346
|
+
issueId: issue._id,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
spinner.succeed(
|
|
350
|
+
chalk.green(`Issue #${issueNumber} reopened on ${spec.scopedName}`)
|
|
351
|
+
);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
spinner.fail(
|
|
354
|
+
chalk.red(`Failed to reopen issue: ${(err as Error).message}`)
|
|
355
|
+
);
|
|
356
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Creates the `specmarket issues` command with subcommands.
|
|
362
|
+
*
|
|
363
|
+
* Usage:
|
|
364
|
+
* specmarket issues @user/spec — list open issues
|
|
365
|
+
* specmarket issues @user/spec create — create issue
|
|
366
|
+
* specmarket issues @user/spec <number> — view issue
|
|
367
|
+
* specmarket issues @user/spec <number> close — close issue
|
|
368
|
+
* specmarket issues @user/spec <number> reopen — reopen issue
|
|
369
|
+
*/
|
|
370
|
+
export function createIssuesCommand(): Command {
|
|
371
|
+
return new Command('issues')
|
|
372
|
+
.description('Manage issues on a spec')
|
|
373
|
+
.argument('<spec-id>', 'Spec scoped name (@user/name) or document ID')
|
|
374
|
+
.argument('[action-or-number]', 'Issue number or "create"')
|
|
375
|
+
.argument('[action]', '"close" or "reopen" (with issue number)')
|
|
376
|
+
.option(
|
|
377
|
+
'-s, --status <status>',
|
|
378
|
+
'Filter by status: open, closed, all (default: open)'
|
|
379
|
+
)
|
|
380
|
+
.option('--label <label>', 'Filter by label')
|
|
381
|
+
.action(
|
|
382
|
+
async (
|
|
383
|
+
specId: string,
|
|
384
|
+
actionOrNumber: string | undefined,
|
|
385
|
+
action: string | undefined,
|
|
386
|
+
opts: { status?: string; label?: string }
|
|
387
|
+
) => {
|
|
388
|
+
try {
|
|
389
|
+
if (!actionOrNumber) {
|
|
390
|
+
// specmarket issues @user/spec → list
|
|
391
|
+
await handleIssuesList(specId, opts);
|
|
392
|
+
} else if (actionOrNumber === 'create') {
|
|
393
|
+
// specmarket issues @user/spec create → create
|
|
394
|
+
await handleIssuesCreate(specId);
|
|
395
|
+
} else {
|
|
396
|
+
const issueNumber = parseInt(actionOrNumber, 10);
|
|
397
|
+
if (isNaN(issueNumber) || issueNumber < 1) {
|
|
398
|
+
console.error(
|
|
399
|
+
chalk.red(
|
|
400
|
+
`Invalid issue number or action: "${actionOrNumber}". ` +
|
|
401
|
+
'Use a number or "create".'
|
|
402
|
+
)
|
|
403
|
+
);
|
|
404
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (!action) {
|
|
408
|
+
// specmarket issues @user/spec 3 → view
|
|
409
|
+
await handleIssuesView(specId, issueNumber);
|
|
410
|
+
} else if (action === 'close') {
|
|
411
|
+
// specmarket issues @user/spec 3 close → close
|
|
412
|
+
await handleIssuesClose(specId, issueNumber);
|
|
413
|
+
} else if (action === 'reopen') {
|
|
414
|
+
// specmarket issues @user/spec 3 reopen → reopen
|
|
415
|
+
await handleIssuesReopen(specId, issueNumber);
|
|
416
|
+
} else {
|
|
417
|
+
console.error(
|
|
418
|
+
chalk.red(
|
|
419
|
+
`Unknown action: "${action}". Use "close" or "reopen".`
|
|
420
|
+
)
|
|
421
|
+
);
|
|
422
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
} catch (err) {
|
|
426
|
+
const error = err as NodeJS.ErrnoException;
|
|
427
|
+
if (error.code === String(EXIT_CODES.AUTH_ERROR)) {
|
|
428
|
+
console.error(chalk.red(error.message));
|
|
429
|
+
process.exit(EXIT_CODES.AUTH_ERROR);
|
|
430
|
+
}
|
|
431
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
432
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
);
|
|
436
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// --- Hoisted mocks ---
|
|
4
|
+
|
|
5
|
+
const { mockQuery, mockClient, mockSpinner, mockLoadCreds, mockSaveCreds } =
|
|
6
|
+
vi.hoisted(() => {
|
|
7
|
+
const mockQuery = vi.fn();
|
|
8
|
+
const mockClient = { query: mockQuery, mutation: vi.fn() };
|
|
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
|
+
const mockLoadCreds = vi.fn();
|
|
16
|
+
const mockSaveCreds = vi.fn();
|
|
17
|
+
return { mockQuery, mockClient, mockSpinner, mockLoadCreds, mockSaveCreds };
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
vi.mock('../lib/convex-client.js', () => ({
|
|
21
|
+
getConvexClient: vi.fn().mockResolvedValue(mockClient),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock('../lib/auth.js', () => ({
|
|
25
|
+
loadCredentials: mockLoadCreds,
|
|
26
|
+
saveCredentials: mockSaveCreds,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock('ora', () => ({
|
|
30
|
+
default: vi.fn().mockReturnValue(mockSpinner),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock('@specmarket/convex/api', () => ({
|
|
34
|
+
api: {
|
|
35
|
+
users: { getMe: 'users.getMe' },
|
|
36
|
+
auth: { createDeviceCode: 'auth.createDeviceCode', checkDeviceCode: 'auth.checkDeviceCode' },
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
41
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
42
|
+
|
|
43
|
+
import { handleLogin } from './login.js';
|
|
44
|
+
|
|
45
|
+
describe('handleLogin', () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.clearAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('shows already-logged-in message when credentials exist', async () => {
|
|
51
|
+
mockLoadCreds.mockResolvedValue({
|
|
52
|
+
token: 'existing-token',
|
|
53
|
+
username: 'alice',
|
|
54
|
+
expiresAt: Date.now() + 3600_000,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await handleLogin({});
|
|
58
|
+
|
|
59
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
60
|
+
expect.stringContaining('Already logged in')
|
|
61
|
+
);
|
|
62
|
+
expect(mockSaveCreds).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('validates and saves token with --token flag', async () => {
|
|
66
|
+
mockLoadCreds.mockResolvedValue(null);
|
|
67
|
+
mockQuery.mockResolvedValue({ _id: 'user1', username: 'bob' });
|
|
68
|
+
mockSaveCreds.mockResolvedValue(undefined);
|
|
69
|
+
|
|
70
|
+
await handleLogin({ token: 'my-jwt-token' });
|
|
71
|
+
|
|
72
|
+
expect(mockSaveCreds).toHaveBeenCalledWith(
|
|
73
|
+
expect.objectContaining({
|
|
74
|
+
token: 'my-jwt-token',
|
|
75
|
+
username: 'bob',
|
|
76
|
+
userId: 'user1',
|
|
77
|
+
})
|
|
78
|
+
);
|
|
79
|
+
expect(mockSpinner.succeed).toHaveBeenCalledWith(
|
|
80
|
+
expect.stringContaining('Logged in')
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('saves token even if getMe query fails', async () => {
|
|
85
|
+
mockLoadCreds.mockResolvedValue(null);
|
|
86
|
+
mockQuery.mockRejectedValue(new Error('Convex unreachable'));
|
|
87
|
+
mockSaveCreds.mockResolvedValue(undefined);
|
|
88
|
+
|
|
89
|
+
await handleLogin({ token: 'fallback-token' });
|
|
90
|
+
|
|
91
|
+
expect(mockSaveCreds).toHaveBeenCalledWith(
|
|
92
|
+
expect.objectContaining({
|
|
93
|
+
token: 'fallback-token',
|
|
94
|
+
username: undefined,
|
|
95
|
+
})
|
|
96
|
+
);
|
|
97
|
+
expect(mockSpinner.succeed).toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
});
|
package/src/commands/login.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import ora from 'ora';
|
|
4
|
-
import { EXIT_CODES } from '@specmarket/shared';
|
|
4
|
+
import { EXIT_CODES, TOKEN_EXPIRY_MS, DEFAULT_WEB_URL } from '@specmarket/shared';
|
|
5
5
|
import { saveCredentials, loadCredentials } from '../lib/auth.js';
|
|
6
6
|
import { getConvexClient } from '../lib/convex-client.js';
|
|
7
|
-
import { TOKEN_EXPIRY_MS } from '@specmarket/shared';
|
|
8
7
|
import type { Credentials } from '@specmarket/shared';
|
|
9
8
|
import createDebug from 'debug';
|
|
10
9
|
|
|
@@ -101,10 +100,7 @@ async function handleTokenLogin(token: string): Promise<void> {
|
|
|
101
100
|
* 6. On expiry/timeout: show error
|
|
102
101
|
*/
|
|
103
102
|
async function handleDeviceCodeLogin(): Promise<void> {
|
|
104
|
-
const
|
|
105
|
-
const baseUrl = config.convexUrl ?? process.env['CONVEX_URL'] ?? 'https://your-deployment.convex.cloud';
|
|
106
|
-
const webUrl = baseUrl.replace('convex.cloud', 'specmarket.dev');
|
|
107
|
-
|
|
103
|
+
const webUrl = DEFAULT_WEB_URL;
|
|
108
104
|
const client = await getConvexClient();
|
|
109
105
|
|
|
110
106
|
let api: any;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// --- Hoisted mocks ---
|
|
4
|
+
|
|
5
|
+
const { mockLoadCreds, mockClearCreds } = vi.hoisted(() => {
|
|
6
|
+
const mockLoadCreds = vi.fn();
|
|
7
|
+
const mockClearCreds = vi.fn();
|
|
8
|
+
return { mockLoadCreds, mockClearCreds };
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
vi.mock('../lib/auth.js', () => ({
|
|
12
|
+
loadCredentials: mockLoadCreds,
|
|
13
|
+
clearCredentials: mockClearCreds,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
17
|
+
|
|
18
|
+
import { handleLogout } from './logout.js';
|
|
19
|
+
|
|
20
|
+
describe('handleLogout', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('clears credentials and shows success message when logged in', async () => {
|
|
26
|
+
mockLoadCreds.mockResolvedValue({
|
|
27
|
+
token: 'test-token',
|
|
28
|
+
username: 'alice',
|
|
29
|
+
expiresAt: Date.now() + 3600_000,
|
|
30
|
+
});
|
|
31
|
+
mockClearCreds.mockResolvedValue(undefined);
|
|
32
|
+
|
|
33
|
+
await handleLogout();
|
|
34
|
+
|
|
35
|
+
expect(mockClearCreds).toHaveBeenCalled();
|
|
36
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
37
|
+
expect.stringContaining('Logged out')
|
|
38
|
+
);
|
|
39
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
40
|
+
expect.stringContaining('alice')
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('shows not-logged-in message when no credentials exist', async () => {
|
|
45
|
+
mockLoadCreds.mockResolvedValue(null);
|
|
46
|
+
|
|
47
|
+
await handleLogout();
|
|
48
|
+
|
|
49
|
+
expect(mockClearCreds).not.toHaveBeenCalled();
|
|
50
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
51
|
+
expect.stringContaining('not currently logged in')
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
});
|