boringpm 0.1.2
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 +170 -0
- package/dist/api.js +487 -0
- package/dist/auth-store.js +50 -0
- package/dist/index.js +786 -0
- package/dist/types.js +10 -0
- package/package.json +34 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cancel, confirm, intro, isCancel, note, outro, spinner, text } from "@clack/prompts";
|
|
3
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
4
|
+
import { clearStoredAuthSession, loadStoredAuthToken, saveStoredAuthSession, } from "./auth-store.js";
|
|
5
|
+
import { ApiClient } from "./api.js";
|
|
6
|
+
import { PLAN_SPEC_STATUSES, TASK_STATUSES, TEST_SCENARIO_STATUSES, TEST_SCENARIO_TYPES, } from "./types.js";
|
|
7
|
+
function parseStatus(value) {
|
|
8
|
+
if (!TASK_STATUSES.includes(value)) {
|
|
9
|
+
throw new InvalidArgumentError(`Invalid status \"${value}\". Must be one of: ${TASK_STATUSES.join(", ")}`);
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
function parsePlanSpecStatus(value) {
|
|
14
|
+
if (!PLAN_SPEC_STATUSES.includes(value)) {
|
|
15
|
+
throw new InvalidArgumentError(`Invalid spec status \"${value}\". Must be one of: ${PLAN_SPEC_STATUSES.join(", ")}`);
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function parseTestScenarioStatus(value) {
|
|
20
|
+
if (!TEST_SCENARIO_STATUSES.includes(value)) {
|
|
21
|
+
throw new InvalidArgumentError(`Invalid test scenario status \"${value}\". Must be one of: ${TEST_SCENARIO_STATUSES.join(", ")}`);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function parseTestScenarioType(value) {
|
|
26
|
+
if (!TEST_SCENARIO_TYPES.includes(value)) {
|
|
27
|
+
throw new InvalidArgumentError(`Invalid test scenario type \"${value}\". Must be one of: ${TEST_SCENARIO_TYPES.join(", ")}`);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function parseCsvList(value) {
|
|
32
|
+
return value
|
|
33
|
+
.split(",")
|
|
34
|
+
.map((item) => item.trim())
|
|
35
|
+
.filter(Boolean);
|
|
36
|
+
}
|
|
37
|
+
async function resolveProjectId(client, projectReference) {
|
|
38
|
+
const input = projectReference.trim();
|
|
39
|
+
if (!input) {
|
|
40
|
+
throw new Error("Project reference is required");
|
|
41
|
+
}
|
|
42
|
+
const projects = await client.listProjects();
|
|
43
|
+
const byId = projects.find((project) => project.id === input);
|
|
44
|
+
if (byId) {
|
|
45
|
+
return byId.id;
|
|
46
|
+
}
|
|
47
|
+
const byName = projects.filter((project) => project.name.toLowerCase() === input.toLowerCase());
|
|
48
|
+
if (byName.length === 1) {
|
|
49
|
+
return byName[0].id;
|
|
50
|
+
}
|
|
51
|
+
if (byName.length > 1) {
|
|
52
|
+
throw new Error(`Multiple projects found with name \"${projectReference}\". Use project ID instead. Matching IDs: ${byName
|
|
53
|
+
.map((project) => project.id)
|
|
54
|
+
.join(", ")}`);
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`Project \"${projectReference}\" not found. Run \"boringpm project list\" to see available projects.`);
|
|
57
|
+
}
|
|
58
|
+
async function resolveCurrentUserId(client) {
|
|
59
|
+
const me = await client.me();
|
|
60
|
+
return me.id;
|
|
61
|
+
}
|
|
62
|
+
async function resolveAssigneeReference(client, assigneeReference) {
|
|
63
|
+
if (assigneeReference === undefined) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const value = assigneeReference.trim();
|
|
67
|
+
if (!value) {
|
|
68
|
+
throw new Error("Assignee reference cannot be empty");
|
|
69
|
+
}
|
|
70
|
+
const normalized = value.toLowerCase();
|
|
71
|
+
if (normalized === "none" || normalized === "null" || normalized === "unassigned") {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (normalized === "me") {
|
|
75
|
+
return resolveCurrentUserId(client);
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
async function assertTaskInProject(client, taskId, projectId) {
|
|
80
|
+
const task = await client.getTask(taskId);
|
|
81
|
+
if (task.project !== projectId) {
|
|
82
|
+
throw new Error(`Task ${taskId} does not belong to project ${projectId}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function printTaskComments(comments) {
|
|
86
|
+
if (comments.length === 0) {
|
|
87
|
+
console.log("No comments yet.");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
for (const comment of comments) {
|
|
91
|
+
console.log(`[${comment.created_at}] ${comment.author}: ${comment.content}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function printTestScenarios(scenarios) {
|
|
95
|
+
if (scenarios.length === 0) {
|
|
96
|
+
console.log("No test scenarios yet.");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
for (const scenario of scenarios) {
|
|
100
|
+
console.log(`- ${scenario.id} [${scenario.status}] (${scenario.type}) ${scenario.title}`);
|
|
101
|
+
if (scenario.ran_at || scenario.ran_by) {
|
|
102
|
+
console.log(` ran: ${scenario.ran_at ?? "n/a"} by ${scenario.ran_by ?? "n/a"}`);
|
|
103
|
+
}
|
|
104
|
+
if (scenario.evidence) {
|
|
105
|
+
console.log(` evidence: ${scenario.evidence}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function resolveAuthToken(options) {
|
|
110
|
+
if (options.token?.trim()) {
|
|
111
|
+
return options.token.trim();
|
|
112
|
+
}
|
|
113
|
+
if (process.env.PM_AUTH_TOKEN?.trim()) {
|
|
114
|
+
return process.env.PM_AUTH_TOKEN.trim();
|
|
115
|
+
}
|
|
116
|
+
return loadStoredAuthToken();
|
|
117
|
+
}
|
|
118
|
+
function resolveClient(program) {
|
|
119
|
+
const options = program.opts();
|
|
120
|
+
return new ApiClient({
|
|
121
|
+
baseUrl: options.apiUrl,
|
|
122
|
+
authToken: resolveAuthToken(options),
|
|
123
|
+
actorName: options.actorName || process.env.PM_ACTOR_NAME || undefined,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function printJson(payload) {
|
|
127
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
128
|
+
}
|
|
129
|
+
function unwrapPromptValue(value, cancelledMessage) {
|
|
130
|
+
if (isCancel(value)) {
|
|
131
|
+
cancel(cancelledMessage);
|
|
132
|
+
throw new Error(cancelledMessage);
|
|
133
|
+
}
|
|
134
|
+
return String(value).trim();
|
|
135
|
+
}
|
|
136
|
+
async function confirmDeleteAction(entityLabel, force) {
|
|
137
|
+
if (force) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
141
|
+
throw new Error(`Refusing to delete ${entityLabel} without confirmation in non-interactive mode. Use --force to proceed.`);
|
|
142
|
+
}
|
|
143
|
+
const confirmed = await confirm({
|
|
144
|
+
message: `Delete ${entityLabel}? This cannot be undone.`,
|
|
145
|
+
initialValue: false,
|
|
146
|
+
});
|
|
147
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
148
|
+
const message = "Delete cancelled";
|
|
149
|
+
cancel(message);
|
|
150
|
+
throw new Error(message);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const program = new Command();
|
|
154
|
+
program
|
|
155
|
+
.name("boringpm")
|
|
156
|
+
.description("Project manager CLI")
|
|
157
|
+
.version("0.1.2")
|
|
158
|
+
.option("--api-url <url>", "Project manager API base URL", process.env.PM_API_URL ?? "http://localhost:4000")
|
|
159
|
+
.option("--token <token>", "Auth token (overrides PM_AUTH_TOKEN and stored token)")
|
|
160
|
+
.option("--actor-name <name>", "Display name for notifications (overrides PM_ACTOR_NAME)");
|
|
161
|
+
program
|
|
162
|
+
.command("ping")
|
|
163
|
+
.description("Ping the API server")
|
|
164
|
+
.action(async () => {
|
|
165
|
+
const client = resolveClient(program);
|
|
166
|
+
const payload = await client.health();
|
|
167
|
+
printJson(payload);
|
|
168
|
+
});
|
|
169
|
+
program
|
|
170
|
+
.command("login")
|
|
171
|
+
.description("Interactive login with magic link and verification code")
|
|
172
|
+
.action(async () => {
|
|
173
|
+
const client = resolveClient(program);
|
|
174
|
+
intro("Project Manager Login");
|
|
175
|
+
const emailInput = await text({
|
|
176
|
+
message: "Email",
|
|
177
|
+
placeholder: "you@company.com",
|
|
178
|
+
validate: (value) => {
|
|
179
|
+
const email = value.trim();
|
|
180
|
+
if (!email) {
|
|
181
|
+
return "Email is required";
|
|
182
|
+
}
|
|
183
|
+
if (!email.includes("@")) {
|
|
184
|
+
return "Enter a valid email";
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
const email = unwrapPromptValue(emailInput, "Login cancelled");
|
|
189
|
+
const sendSpinner = spinner();
|
|
190
|
+
sendSpinner.start("Sending magic link/code email...");
|
|
191
|
+
await client.sendMagicLink(email);
|
|
192
|
+
sendSpinner.stop("Magic link/code sent");
|
|
193
|
+
note("Check your email and paste the verification code below.", "Email sent");
|
|
194
|
+
const codeInput = await text({
|
|
195
|
+
message: "Verification code",
|
|
196
|
+
placeholder: "123456",
|
|
197
|
+
validate: (value) => {
|
|
198
|
+
if (!value.trim()) {
|
|
199
|
+
return "Verification code is required";
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
const code = unwrapPromptValue(codeInput, "Login cancelled");
|
|
204
|
+
const verifySpinner = spinner();
|
|
205
|
+
verifySpinner.start("Verifying code...");
|
|
206
|
+
const authPayload = await client.verifyMagicCode(email, code);
|
|
207
|
+
await saveStoredAuthSession({
|
|
208
|
+
token: authPayload.token,
|
|
209
|
+
user: authPayload.user,
|
|
210
|
+
savedAt: new Date().toISOString(),
|
|
211
|
+
});
|
|
212
|
+
verifySpinner.stop("Logged in");
|
|
213
|
+
note(`Signed in as ${authPayload.user.email ?? authPayload.user.id}. Token saved to local auth store.`, "Success");
|
|
214
|
+
outro("Login complete");
|
|
215
|
+
});
|
|
216
|
+
program
|
|
217
|
+
.command("logout")
|
|
218
|
+
.description("Remove stored auth token")
|
|
219
|
+
.action(async () => {
|
|
220
|
+
await clearStoredAuthSession();
|
|
221
|
+
console.log("Logged out. Stored auth token removed.");
|
|
222
|
+
});
|
|
223
|
+
program
|
|
224
|
+
.command("whoami")
|
|
225
|
+
.description("Show currently authenticated user")
|
|
226
|
+
.action(async () => {
|
|
227
|
+
const client = resolveClient(program);
|
|
228
|
+
const payload = await client.me();
|
|
229
|
+
printJson(payload);
|
|
230
|
+
});
|
|
231
|
+
const auth = program.command("auth").description("Authentication commands");
|
|
232
|
+
auth
|
|
233
|
+
.command("send-link")
|
|
234
|
+
.description("Send magic link/code email")
|
|
235
|
+
.argument("<email>", "Email address")
|
|
236
|
+
.action(async (email) => {
|
|
237
|
+
const client = resolveClient(program);
|
|
238
|
+
const payload = await client.sendMagicLink(email);
|
|
239
|
+
printJson(payload);
|
|
240
|
+
});
|
|
241
|
+
auth
|
|
242
|
+
.command("verify")
|
|
243
|
+
.description("Verify email with magic code and return token")
|
|
244
|
+
.argument("<email>", "Email address")
|
|
245
|
+
.argument("<code>", "Verification code")
|
|
246
|
+
.action(async (email, code) => {
|
|
247
|
+
const client = resolveClient(program);
|
|
248
|
+
const payload = await client.verifyMagicCode(email, code);
|
|
249
|
+
printJson(payload);
|
|
250
|
+
});
|
|
251
|
+
auth
|
|
252
|
+
.command("me")
|
|
253
|
+
.description("Show currently authenticated user")
|
|
254
|
+
.action(async () => {
|
|
255
|
+
const client = resolveClient(program);
|
|
256
|
+
const payload = await client.me();
|
|
257
|
+
printJson(payload);
|
|
258
|
+
});
|
|
259
|
+
const team = program.command("team").description("Team operations");
|
|
260
|
+
team
|
|
261
|
+
.command("list")
|
|
262
|
+
.description("List teams you belong to")
|
|
263
|
+
.action(async () => {
|
|
264
|
+
const client = resolveClient(program);
|
|
265
|
+
const teams = await client.listTeams();
|
|
266
|
+
printJson(teams);
|
|
267
|
+
});
|
|
268
|
+
team
|
|
269
|
+
.command("create")
|
|
270
|
+
.description("Create a new team")
|
|
271
|
+
.argument("<name>", "Team name")
|
|
272
|
+
.action(async (name) => {
|
|
273
|
+
const client = resolveClient(program);
|
|
274
|
+
const created = await client.createTeam({ name });
|
|
275
|
+
printJson(created);
|
|
276
|
+
});
|
|
277
|
+
team
|
|
278
|
+
.command("view")
|
|
279
|
+
.description("View a team")
|
|
280
|
+
.argument("<teamId>", "Team ID")
|
|
281
|
+
.action(async (teamId) => {
|
|
282
|
+
const client = resolveClient(program);
|
|
283
|
+
const teamItem = await client.getTeam(teamId);
|
|
284
|
+
printJson(teamItem);
|
|
285
|
+
});
|
|
286
|
+
team
|
|
287
|
+
.command("update")
|
|
288
|
+
.description("Update a team (owner only)")
|
|
289
|
+
.argument("<teamId>", "Team ID")
|
|
290
|
+
.option("--name <name>", "New team name")
|
|
291
|
+
.action(async (teamId, options) => {
|
|
292
|
+
if (!options.name) {
|
|
293
|
+
throw new Error("Provide --name to update");
|
|
294
|
+
}
|
|
295
|
+
const client = resolveClient(program);
|
|
296
|
+
const updated = await client.updateTeam({ teamId, name: options.name });
|
|
297
|
+
printJson(updated);
|
|
298
|
+
});
|
|
299
|
+
team
|
|
300
|
+
.command("delete")
|
|
301
|
+
.description("Delete a team (owner only, blocked if projects linked)")
|
|
302
|
+
.argument("<teamId>", "Team ID")
|
|
303
|
+
.option("--force", "Skip confirmation prompt")
|
|
304
|
+
.action(async (teamId, options) => {
|
|
305
|
+
await confirmDeleteAction(`team ${teamId}`, options.force);
|
|
306
|
+
const client = resolveClient(program);
|
|
307
|
+
const result = await client.deleteTeam(teamId);
|
|
308
|
+
printJson(result);
|
|
309
|
+
});
|
|
310
|
+
team
|
|
311
|
+
.command("add-member")
|
|
312
|
+
.description("Add a member to a team (owner only)")
|
|
313
|
+
.argument("<teamId>", "Team ID")
|
|
314
|
+
.argument("<userId>", "User ID to add")
|
|
315
|
+
.action(async (teamId, userId) => {
|
|
316
|
+
const client = resolveClient(program);
|
|
317
|
+
const updated = await client.addTeamMember(teamId, userId);
|
|
318
|
+
printJson(updated);
|
|
319
|
+
});
|
|
320
|
+
team
|
|
321
|
+
.command("remove-member")
|
|
322
|
+
.description("Remove a member from a team (owner only)")
|
|
323
|
+
.argument("<teamId>", "Team ID")
|
|
324
|
+
.argument("<userId>", "User ID to remove")
|
|
325
|
+
.action(async (teamId, userId) => {
|
|
326
|
+
const client = resolveClient(program);
|
|
327
|
+
const updated = await client.removeTeamMember(teamId, userId);
|
|
328
|
+
printJson(updated);
|
|
329
|
+
});
|
|
330
|
+
team
|
|
331
|
+
.command("projects")
|
|
332
|
+
.description("List projects in a team")
|
|
333
|
+
.argument("<teamId>", "Team ID")
|
|
334
|
+
.action(async (teamId) => {
|
|
335
|
+
const client = resolveClient(program);
|
|
336
|
+
const projects = await client.listTeamProjects(teamId);
|
|
337
|
+
printJson(projects);
|
|
338
|
+
});
|
|
339
|
+
const project = program.command("project").description("Project operations");
|
|
340
|
+
project
|
|
341
|
+
.command("list")
|
|
342
|
+
.description("List visible projects")
|
|
343
|
+
.action(async () => {
|
|
344
|
+
const client = resolveClient(program);
|
|
345
|
+
const projects = await client.listProjects();
|
|
346
|
+
printJson(projects);
|
|
347
|
+
});
|
|
348
|
+
project
|
|
349
|
+
.command("view")
|
|
350
|
+
.description("View a single project")
|
|
351
|
+
.argument("<project>", "Project ID or project name")
|
|
352
|
+
.action(async (projectReference) => {
|
|
353
|
+
const client = resolveClient(program);
|
|
354
|
+
const projectId = await resolveProjectId(client, projectReference);
|
|
355
|
+
const projectItem = await client.getProject(projectId);
|
|
356
|
+
printJson(projectItem);
|
|
357
|
+
});
|
|
358
|
+
project
|
|
359
|
+
.command("create")
|
|
360
|
+
.description("Create a project")
|
|
361
|
+
.argument("<name>", "Project name")
|
|
362
|
+
.option("--participants <users>", "Comma-separated participant user IDs")
|
|
363
|
+
.option("--team <teamId>", "Team ID to link this project to")
|
|
364
|
+
.action(async (name, options) => {
|
|
365
|
+
const client = resolveClient(program);
|
|
366
|
+
const created = await client.createProject({
|
|
367
|
+
name,
|
|
368
|
+
participants: options.participants ? parseCsvList(options.participants) : undefined,
|
|
369
|
+
team: options.team,
|
|
370
|
+
});
|
|
371
|
+
printJson(created);
|
|
372
|
+
});
|
|
373
|
+
project
|
|
374
|
+
.command("add-user")
|
|
375
|
+
.description("Add a participant to a project")
|
|
376
|
+
.argument("<project>", "Project ID or project name")
|
|
377
|
+
.argument("<userId>", "User ID to add")
|
|
378
|
+
.action(async (projectReference, userId) => {
|
|
379
|
+
const client = resolveClient(program);
|
|
380
|
+
const projectId = await resolveProjectId(client, projectReference);
|
|
381
|
+
const updated = await client.addProjectParticipant(projectId, userId);
|
|
382
|
+
printJson(updated);
|
|
383
|
+
});
|
|
384
|
+
project
|
|
385
|
+
.command("remove-user")
|
|
386
|
+
.description("Remove a participant from a project")
|
|
387
|
+
.argument("<project>", "Project ID or project name")
|
|
388
|
+
.argument("<userId>", "User ID to remove")
|
|
389
|
+
.action(async (projectReference, userId) => {
|
|
390
|
+
const client = resolveClient(program);
|
|
391
|
+
const projectId = await resolveProjectId(client, projectReference);
|
|
392
|
+
const updated = await client.removeProjectParticipant(projectId, userId);
|
|
393
|
+
printJson(updated);
|
|
394
|
+
});
|
|
395
|
+
const plan = program.command("plan").description("Plan operations");
|
|
396
|
+
plan
|
|
397
|
+
.command("list")
|
|
398
|
+
.description("List plans in a project")
|
|
399
|
+
.argument("<project>", "Project ID or project name")
|
|
400
|
+
.action(async (projectReference) => {
|
|
401
|
+
const client = resolveClient(program);
|
|
402
|
+
const projectId = await resolveProjectId(client, projectReference);
|
|
403
|
+
const plans = await client.listPlans(projectId);
|
|
404
|
+
printJson(plans);
|
|
405
|
+
});
|
|
406
|
+
plan
|
|
407
|
+
.command("view")
|
|
408
|
+
.description("View a single plan")
|
|
409
|
+
.argument("<planId>", "Plan ID")
|
|
410
|
+
.action(async (planId) => {
|
|
411
|
+
const client = resolveClient(program);
|
|
412
|
+
const planItem = await client.getPlan(planId);
|
|
413
|
+
printJson(planItem);
|
|
414
|
+
});
|
|
415
|
+
plan
|
|
416
|
+
.command("create")
|
|
417
|
+
.description("Create a plan in a project")
|
|
418
|
+
.argument("<project>", "Project ID or project name")
|
|
419
|
+
.argument("<title>", "Plan title")
|
|
420
|
+
.option("--content <content>", "Plan content", "")
|
|
421
|
+
.option("--spec-status <status>", "Spec status", parsePlanSpecStatus, "draft")
|
|
422
|
+
.action(async (projectReference, title, options) => {
|
|
423
|
+
const client = resolveClient(program);
|
|
424
|
+
const projectId = await resolveProjectId(client, projectReference);
|
|
425
|
+
const created = await client.createPlan({
|
|
426
|
+
projectId,
|
|
427
|
+
title,
|
|
428
|
+
content: options.content,
|
|
429
|
+
spec_status: options.specStatus,
|
|
430
|
+
});
|
|
431
|
+
printJson(created);
|
|
432
|
+
});
|
|
433
|
+
plan
|
|
434
|
+
.command("update")
|
|
435
|
+
.description("Update a plan")
|
|
436
|
+
.argument("<planId>", "Plan ID")
|
|
437
|
+
.option("--title <title>", "New title")
|
|
438
|
+
.option("--content <content>", "New content")
|
|
439
|
+
.option("--spec-status <status>", "Spec status", parsePlanSpecStatus)
|
|
440
|
+
.action(async (planId, options) => {
|
|
441
|
+
if (options.title === undefined &&
|
|
442
|
+
options.content === undefined &&
|
|
443
|
+
options.specStatus === undefined) {
|
|
444
|
+
throw new Error("Provide --title, --content, --spec-status, or a combination");
|
|
445
|
+
}
|
|
446
|
+
const client = resolveClient(program);
|
|
447
|
+
const updated = await client.updatePlan({
|
|
448
|
+
planId,
|
|
449
|
+
title: options.title,
|
|
450
|
+
content: options.content,
|
|
451
|
+
spec_status: options.specStatus,
|
|
452
|
+
});
|
|
453
|
+
printJson(updated);
|
|
454
|
+
});
|
|
455
|
+
plan
|
|
456
|
+
.command("approve")
|
|
457
|
+
.description("Mark a spec plan as approved")
|
|
458
|
+
.argument("<planId>", "Plan ID")
|
|
459
|
+
.action(async (planId) => {
|
|
460
|
+
const client = resolveClient(program);
|
|
461
|
+
const updated = await client.updatePlan({
|
|
462
|
+
planId,
|
|
463
|
+
spec_status: "approved",
|
|
464
|
+
});
|
|
465
|
+
printJson(updated);
|
|
466
|
+
});
|
|
467
|
+
plan
|
|
468
|
+
.command("delete")
|
|
469
|
+
.description("Delete a plan")
|
|
470
|
+
.argument("<planId>", "Plan ID")
|
|
471
|
+
.option("-f, --force", "Skip confirmation prompt")
|
|
472
|
+
.action(async (planId, options) => {
|
|
473
|
+
await confirmDeleteAction(`plan ${planId}`, options.force);
|
|
474
|
+
const client = resolveClient(program);
|
|
475
|
+
const deleted = await client.deletePlan(planId);
|
|
476
|
+
printJson(deleted);
|
|
477
|
+
});
|
|
478
|
+
const task = program.command("task").description("Task operations");
|
|
479
|
+
task
|
|
480
|
+
.command("list")
|
|
481
|
+
.description("List tasks in a project")
|
|
482
|
+
.argument("<project>", "Project ID or project name")
|
|
483
|
+
.option("--status <status>", "Filter by status", parseStatus)
|
|
484
|
+
.option("--assignee <userId|me|none>", "Filter by assignee")
|
|
485
|
+
.option("--agent <name|none>", "Filter by agent tag")
|
|
486
|
+
.option("--mine", "Shortcut for --assignee me")
|
|
487
|
+
.action(async (projectReference, options) => {
|
|
488
|
+
if (options.mine && options.assignee !== undefined) {
|
|
489
|
+
throw new Error("Use either --mine or --assignee, not both");
|
|
490
|
+
}
|
|
491
|
+
const client = resolveClient(program);
|
|
492
|
+
const projectId = await resolveProjectId(client, projectReference);
|
|
493
|
+
const assignee = options.mine
|
|
494
|
+
? await resolveCurrentUserId(client)
|
|
495
|
+
: await resolveAssigneeReference(client, options.assignee);
|
|
496
|
+
const agentFilter = options.agent === "none" ? null : options.agent;
|
|
497
|
+
const tasks = await client.listTasks(projectId, {
|
|
498
|
+
status: options.status,
|
|
499
|
+
assignee,
|
|
500
|
+
agent: agentFilter,
|
|
501
|
+
});
|
|
502
|
+
printJson(tasks);
|
|
503
|
+
});
|
|
504
|
+
task
|
|
505
|
+
.command("mine")
|
|
506
|
+
.description("List tasks assigned to you across all visible projects")
|
|
507
|
+
.option("--status <status>", "Filter by status", parseStatus)
|
|
508
|
+
.option("--agent <name>", "Filter by agent tag")
|
|
509
|
+
.action(async (options) => {
|
|
510
|
+
const client = resolveClient(program);
|
|
511
|
+
const myUserId = await resolveCurrentUserId(client);
|
|
512
|
+
const projects = await client.listProjects();
|
|
513
|
+
const agentFilter = options.agent === "none" ? null : options.agent;
|
|
514
|
+
const mineByProject = await Promise.all(projects.map(async (projectItem) => {
|
|
515
|
+
const tasks = await client.listTasks(projectItem.id, {
|
|
516
|
+
status: options.status,
|
|
517
|
+
assignee: myUserId,
|
|
518
|
+
agent: agentFilter,
|
|
519
|
+
});
|
|
520
|
+
return tasks.map((taskItem) => ({
|
|
521
|
+
projectId: projectItem.id,
|
|
522
|
+
projectName: projectItem.name,
|
|
523
|
+
...taskItem,
|
|
524
|
+
}));
|
|
525
|
+
}));
|
|
526
|
+
const tasks = mineByProject
|
|
527
|
+
.flat()
|
|
528
|
+
.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
|
529
|
+
printJson(tasks);
|
|
530
|
+
});
|
|
531
|
+
task
|
|
532
|
+
.command("view")
|
|
533
|
+
.description("View a single task")
|
|
534
|
+
.argument("<taskId>", "Task ID")
|
|
535
|
+
.action(async (taskId) => {
|
|
536
|
+
const client = resolveClient(program);
|
|
537
|
+
const taskItem = await client.getTask(taskId);
|
|
538
|
+
printJson(taskItem);
|
|
539
|
+
});
|
|
540
|
+
task
|
|
541
|
+
.command("create")
|
|
542
|
+
.description("Create a task")
|
|
543
|
+
.argument("<project>", "Project ID or project name")
|
|
544
|
+
.argument("[title]", "Task title")
|
|
545
|
+
.option("--title <title>", "Task title (alternative to positional)")
|
|
546
|
+
.option("--description <description>", "Task description", "")
|
|
547
|
+
.option("--plan <planId>", "Required approved spec plan ID")
|
|
548
|
+
.option("--agent <name>", "Agent tag (e.g. claudinho, anton, linus)")
|
|
549
|
+
.action(async (projectReference, positionalTitle, options) => {
|
|
550
|
+
const title = positionalTitle?.trim() || options.title?.trim() || "";
|
|
551
|
+
if (!title) {
|
|
552
|
+
throw new Error("Task title is required. Provide [title] or --title <title>");
|
|
553
|
+
}
|
|
554
|
+
if (!options.plan?.trim()) {
|
|
555
|
+
throw new Error("Task requires an approved spec link. Provide --plan <planId>");
|
|
556
|
+
}
|
|
557
|
+
const client = resolveClient(program);
|
|
558
|
+
const projectId = await resolveProjectId(client, projectReference);
|
|
559
|
+
const agent = options.agent?.trim();
|
|
560
|
+
const created = await client.createTask({
|
|
561
|
+
projectId,
|
|
562
|
+
title,
|
|
563
|
+
description: options.description,
|
|
564
|
+
status: "pending",
|
|
565
|
+
plan: options.plan.trim(),
|
|
566
|
+
agent: agent ? agent : null,
|
|
567
|
+
});
|
|
568
|
+
printJson(created);
|
|
569
|
+
});
|
|
570
|
+
task
|
|
571
|
+
.command("update")
|
|
572
|
+
.description("Update a task")
|
|
573
|
+
.argument("<taskId>", "Task ID")
|
|
574
|
+
.option("--title <title>", "New title")
|
|
575
|
+
.option("--description <description>", "New description")
|
|
576
|
+
.option("--status <status>", "New status", parseStatus)
|
|
577
|
+
.option("--plan <planId>", "New approved spec plan ID")
|
|
578
|
+
.option("--assignee <userId|me|none>", "Set assignee (self-assignment only enforced by API)")
|
|
579
|
+
.option("--agent <name|none>", "Set agent tag or 'none' to clear")
|
|
580
|
+
.action(async (taskId, options) => {
|
|
581
|
+
if (options.title === undefined &&
|
|
582
|
+
options.description === undefined &&
|
|
583
|
+
options.status === undefined &&
|
|
584
|
+
options.plan === undefined &&
|
|
585
|
+
options.assignee === undefined &&
|
|
586
|
+
options.agent === undefined) {
|
|
587
|
+
throw new Error("Provide at least one option to update");
|
|
588
|
+
}
|
|
589
|
+
const client = resolveClient(program);
|
|
590
|
+
if (options.plan !== undefined && !options.plan.trim()) {
|
|
591
|
+
throw new Error("Plan ID cannot be empty");
|
|
592
|
+
}
|
|
593
|
+
const plan = options.plan !== undefined ? options.plan.trim() : undefined;
|
|
594
|
+
const assignee = await resolveAssigneeReference(client, options.assignee);
|
|
595
|
+
const agent = options.agent === "none" ? null : options.agent;
|
|
596
|
+
const updated = await client.updateTask({
|
|
597
|
+
taskId,
|
|
598
|
+
title: options.title,
|
|
599
|
+
description: options.description,
|
|
600
|
+
status: options.status,
|
|
601
|
+
plan,
|
|
602
|
+
assignee,
|
|
603
|
+
agent,
|
|
604
|
+
});
|
|
605
|
+
printJson(updated);
|
|
606
|
+
});
|
|
607
|
+
task
|
|
608
|
+
.command("claim")
|
|
609
|
+
.description("Claim a task for yourself and move it to in_progress")
|
|
610
|
+
.argument("<taskId>", "Task ID")
|
|
611
|
+
.action(async (taskId) => {
|
|
612
|
+
const client = resolveClient(program);
|
|
613
|
+
const claimed = await client.claimTask(taskId);
|
|
614
|
+
printJson(claimed);
|
|
615
|
+
});
|
|
616
|
+
task
|
|
617
|
+
.command("release")
|
|
618
|
+
.description("Release a task assignment")
|
|
619
|
+
.argument("<taskId>", "Task ID")
|
|
620
|
+
.option("--status <status>", "Status after release", parseStatus)
|
|
621
|
+
.action(async (taskId, options) => {
|
|
622
|
+
const client = resolveClient(program);
|
|
623
|
+
const released = await client.releaseTask({
|
|
624
|
+
taskId,
|
|
625
|
+
status: options.status,
|
|
626
|
+
});
|
|
627
|
+
printJson(released);
|
|
628
|
+
});
|
|
629
|
+
task
|
|
630
|
+
.command("next")
|
|
631
|
+
.description("Claim the next available pending task in a project")
|
|
632
|
+
.argument("<project>", "Project ID or project name")
|
|
633
|
+
.action(async (projectReference) => {
|
|
634
|
+
const client = resolveClient(program);
|
|
635
|
+
const projectId = await resolveProjectId(client, projectReference);
|
|
636
|
+
const claimed = await client.claimNextTask(projectId);
|
|
637
|
+
printJson(claimed);
|
|
638
|
+
});
|
|
639
|
+
task
|
|
640
|
+
.command("done")
|
|
641
|
+
.description("Mark a task as completed")
|
|
642
|
+
.argument("<taskId>", "Task ID")
|
|
643
|
+
.option("--note <note>", "Completion note", "")
|
|
644
|
+
.option("--force", "Force completion with waiver fields")
|
|
645
|
+
.option("--waiver-reason <reason>", "Reason for forced completion")
|
|
646
|
+
.option("--approved-by <userId>", "Approver id for forced completion")
|
|
647
|
+
.action(async (taskId, options) => {
|
|
648
|
+
if (options.force && (!options.waiverReason?.trim() || !options.approvedBy?.trim())) {
|
|
649
|
+
throw new Error("--force requires both --waiver-reason and --approved-by");
|
|
650
|
+
}
|
|
651
|
+
const client = resolveClient(program);
|
|
652
|
+
const completed = await client.completeTask({
|
|
653
|
+
taskId,
|
|
654
|
+
note: options.note,
|
|
655
|
+
force: options.force,
|
|
656
|
+
waiver_reason: options.waiverReason,
|
|
657
|
+
approved_by: options.approvedBy,
|
|
658
|
+
});
|
|
659
|
+
printJson(completed);
|
|
660
|
+
});
|
|
661
|
+
task
|
|
662
|
+
.command("tests")
|
|
663
|
+
.description("List test scenarios for a task")
|
|
664
|
+
.argument("<project>", "Project ID or project name")
|
|
665
|
+
.argument("<taskId>", "Task ID")
|
|
666
|
+
.action(async (projectReference, taskId) => {
|
|
667
|
+
const client = resolveClient(program);
|
|
668
|
+
const projectId = await resolveProjectId(client, projectReference);
|
|
669
|
+
await assertTaskInProject(client, taskId, projectId);
|
|
670
|
+
const scenarios = await client.listTestScenarios(taskId);
|
|
671
|
+
printTestScenarios(scenarios);
|
|
672
|
+
});
|
|
673
|
+
task
|
|
674
|
+
.command("test-add")
|
|
675
|
+
.description("Add a test scenario to a task")
|
|
676
|
+
.argument("<project>", "Project ID or project name")
|
|
677
|
+
.argument("<taskId>", "Task ID")
|
|
678
|
+
.argument("<title>", "Scenario title")
|
|
679
|
+
.requiredOption("--steps <steps>", "Scenario steps")
|
|
680
|
+
.requiredOption("--expected <result>", "Expected result")
|
|
681
|
+
.option("--type <type>", "Scenario type", parseTestScenarioType, "manual")
|
|
682
|
+
.action(async (projectReference, taskId, title, options) => {
|
|
683
|
+
const client = resolveClient(program);
|
|
684
|
+
const projectId = await resolveProjectId(client, projectReference);
|
|
685
|
+
await assertTaskInProject(client, taskId, projectId);
|
|
686
|
+
const scenario = await client.createTestScenario({
|
|
687
|
+
taskId,
|
|
688
|
+
title,
|
|
689
|
+
type: options.type,
|
|
690
|
+
steps: options.steps,
|
|
691
|
+
expected_result: options.expected,
|
|
692
|
+
});
|
|
693
|
+
printJson(scenario);
|
|
694
|
+
});
|
|
695
|
+
task
|
|
696
|
+
.command("test-result")
|
|
697
|
+
.description("Record a test scenario result")
|
|
698
|
+
.argument("<project>", "Project ID or project name")
|
|
699
|
+
.argument("<taskId>", "Task ID")
|
|
700
|
+
.argument("<scenarioId>", "Scenario ID")
|
|
701
|
+
.argument("<status>", "Result status", parseTestScenarioStatus)
|
|
702
|
+
.option("--evidence <text>", "Evidence details (logs, links, notes)")
|
|
703
|
+
.action(async (projectReference, taskId, scenarioId, status, options) => {
|
|
704
|
+
const client = resolveClient(program);
|
|
705
|
+
const projectId = await resolveProjectId(client, projectReference);
|
|
706
|
+
await assertTaskInProject(client, taskId, projectId);
|
|
707
|
+
const updated = await client.updateTestScenarioResult({
|
|
708
|
+
taskId,
|
|
709
|
+
scenarioId,
|
|
710
|
+
status,
|
|
711
|
+
evidence: options.evidence,
|
|
712
|
+
});
|
|
713
|
+
printJson(updated);
|
|
714
|
+
});
|
|
715
|
+
task
|
|
716
|
+
.command("comments")
|
|
717
|
+
.description("List comments for a task")
|
|
718
|
+
.argument("<project>", "Project ID or project name")
|
|
719
|
+
.argument("<taskId>", "Task ID")
|
|
720
|
+
.action(async (projectReference, taskId) => {
|
|
721
|
+
const client = resolveClient(program);
|
|
722
|
+
const projectId = await resolveProjectId(client, projectReference);
|
|
723
|
+
await assertTaskInProject(client, taskId, projectId);
|
|
724
|
+
const comments = await client.listTaskComments(taskId);
|
|
725
|
+
printTaskComments(comments);
|
|
726
|
+
});
|
|
727
|
+
task
|
|
728
|
+
.command("comment")
|
|
729
|
+
.description("Add a comment to a task")
|
|
730
|
+
.argument("<project>", "Project ID or project name")
|
|
731
|
+
.argument("<taskId>", "Task ID")
|
|
732
|
+
.argument("<message>", "Comment message")
|
|
733
|
+
.action(async (projectReference, taskId, message) => {
|
|
734
|
+
const client = resolveClient(program);
|
|
735
|
+
const projectId = await resolveProjectId(client, projectReference);
|
|
736
|
+
await assertTaskInProject(client, taskId, projectId);
|
|
737
|
+
const comment = await client.createTaskComment(taskId, message);
|
|
738
|
+
printJson(comment);
|
|
739
|
+
});
|
|
740
|
+
task
|
|
741
|
+
.command("unclaim")
|
|
742
|
+
.description("Clear assignee from a task")
|
|
743
|
+
.argument("<taskId>", "Task ID")
|
|
744
|
+
.action(async (taskId) => {
|
|
745
|
+
const client = resolveClient(program);
|
|
746
|
+
const updated = await client.updateTask({
|
|
747
|
+
taskId,
|
|
748
|
+
assignee: null,
|
|
749
|
+
});
|
|
750
|
+
printJson(updated);
|
|
751
|
+
});
|
|
752
|
+
task
|
|
753
|
+
.command("delete")
|
|
754
|
+
.description("Delete a task")
|
|
755
|
+
.argument("<taskId>", "Task ID")
|
|
756
|
+
.option("-f, --force", "Skip confirmation prompt")
|
|
757
|
+
.action(async (taskId, options) => {
|
|
758
|
+
await confirmDeleteAction(`task ${taskId}`, options.force);
|
|
759
|
+
const client = resolveClient(program);
|
|
760
|
+
const deleted = await client.deleteTask(taskId);
|
|
761
|
+
printJson(deleted);
|
|
762
|
+
});
|
|
763
|
+
task
|
|
764
|
+
.command("move")
|
|
765
|
+
.description("Move a task to a new status")
|
|
766
|
+
.argument("<taskId>", "Task ID")
|
|
767
|
+
.argument("<status>", "New task status", parseStatus)
|
|
768
|
+
.action(async (taskId, status) => {
|
|
769
|
+
const client = resolveClient(program);
|
|
770
|
+
const updated = await client.updateTask({ taskId, status });
|
|
771
|
+
printJson(updated);
|
|
772
|
+
});
|
|
773
|
+
program
|
|
774
|
+
.command("board")
|
|
775
|
+
.description("Fetch board data for a project")
|
|
776
|
+
.argument("<project>", "Project ID or project name")
|
|
777
|
+
.action(async (projectReference) => {
|
|
778
|
+
const client = resolveClient(program);
|
|
779
|
+
const projectId = await resolveProjectId(client, projectReference);
|
|
780
|
+
const board = await client.board(projectId);
|
|
781
|
+
printJson(board);
|
|
782
|
+
});
|
|
783
|
+
program.parseAsync(process.argv).catch((error) => {
|
|
784
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
785
|
+
process.exit(1);
|
|
786
|
+
});
|