@nijaru/tk 0.0.4 → 0.1.1

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/src/cli.test.ts DELETED
@@ -1,976 +0,0 @@
1
- import { test, expect, beforeEach, afterEach, describe } from "bun:test";
2
- import { mkdtempSync, rmSync, existsSync } from "fs";
3
- import { join } from "path";
4
- import { tmpdir } from "os";
5
-
6
- const PROJECT_ROOT = import.meta.dir.replace("/src", "");
7
- const CLI_PATH = join(PROJECT_ROOT, "src/cli.ts");
8
-
9
- async function run(
10
- args: string[],
11
- cwd: string,
12
- ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
13
- const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
14
- cwd,
15
- stdout: "pipe",
16
- stderr: "pipe",
17
- env: { ...process.env, NO_COLOR: "1" },
18
- });
19
-
20
- const stdout = await new Response(proc.stdout).text();
21
- const stderr = await new Response(proc.stderr).text();
22
- const exitCode = await proc.exited;
23
-
24
- return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
25
- }
26
-
27
- describe("tk CLI", () => {
28
- let testDir: string;
29
-
30
- beforeEach(() => {
31
- testDir = mkdtempSync(join(tmpdir(), "tk-test-"));
32
- Bun.spawnSync(["git", "init"], { cwd: testDir });
33
- });
34
-
35
- afterEach(() => {
36
- rmSync(testDir, { recursive: true, force: true });
37
- });
38
-
39
- test("--help shows usage", async () => {
40
- const { stdout, exitCode } = await run(["--help"], testDir);
41
- expect(exitCode).toBe(0);
42
- expect(stdout).toContain("tk v");
43
- expect(stdout).toContain("COMMANDS:");
44
- expect(stdout).toContain("add");
45
- expect(stdout).toContain("Run 'tk <command> --help'");
46
- });
47
-
48
- test("command --help shows command-specific help", async () => {
49
- const { stdout: addHelp, exitCode: addCode } = await run(["add", "--help"], testDir);
50
- expect(addCode).toBe(0);
51
- expect(addHelp).toContain("tk add");
52
- expect(addHelp).toContain("--priority");
53
-
54
- const { stdout: lsHelp, exitCode: lsCode } = await run(["ls", "--help"], testDir);
55
- expect(lsCode).toBe(0);
56
- expect(lsHelp).toContain("tk ls");
57
- expect(lsHelp).toContain("--status");
58
- });
59
-
60
- test("help <command> shows command-specific help", async () => {
61
- const { stdout, exitCode } = await run(["help", "add"], testDir);
62
- expect(exitCode).toBe(0);
63
- expect(stdout).toContain("tk add");
64
- expect(stdout).toContain("--priority");
65
- });
66
-
67
- test("--version shows version", async () => {
68
- const { stdout, exitCode } = await run(["--version"], testDir);
69
- expect(exitCode).toBe(0);
70
- expect(stdout).toMatch(/^tk v\d+\.\d+\.\d+$/);
71
- });
72
-
73
- test("unknown command errors", async () => {
74
- const { stderr, exitCode } = await run(["unknown"], testDir);
75
- expect(exitCode).toBe(1);
76
- expect(stderr).toContain("Unknown command: unknown");
77
- });
78
-
79
- describe("add", () => {
80
- test("creates task and returns ID", async () => {
81
- const { stdout, exitCode } = await run(["add", "Test task"], testDir);
82
- expect(exitCode).toBe(0);
83
- expect(stdout).toMatch(/^tk-[a-z0-9]{4}$/);
84
- });
85
-
86
- test("creates task with priority", async () => {
87
- const { stdout } = await run(["add", "Urgent task", "-p", "1"], testDir);
88
- const id = stdout.trim();
89
-
90
- const { stdout: showOut } = await run(["show", id, "--json"], testDir);
91
- const task = JSON.parse(showOut);
92
- expect(task.priority).toBe(1);
93
- });
94
-
95
- test("creates task with description", async () => {
96
- const { stdout } = await run(["add", "Task", "-d", "Description here"], testDir);
97
- const id = stdout.trim();
98
-
99
- const { stdout: showOut } = await run(["show", id, "--json"], testDir);
100
- const task = JSON.parse(showOut);
101
- expect(task.description).toBe("Description here");
102
- });
103
-
104
- test("accepts p0-p4 priority format", async () => {
105
- const { stdout } = await run(["add", "Task", "-p", "p1"], testDir);
106
- const id = stdout.trim();
107
-
108
- const { stdout: showOut } = await run(["show", id, "--json"], testDir);
109
- const task = JSON.parse(showOut);
110
- expect(task.priority).toBe(1);
111
- });
112
-
113
- test("accepts named priority format", async () => {
114
- const { stdout } = await run(["add", "Task", "-p", "urgent"], testDir);
115
- const id = stdout.trim();
116
-
117
- const { stdout: showOut } = await run(["show", id, "--json"], testDir);
118
- const task = JSON.parse(showOut);
119
- expect(task.priority).toBe(1);
120
- });
121
-
122
- test("creates task with labels", async () => {
123
- const { stdout } = await run(["add", "Task", "-l", "bug,urgent"], testDir);
124
- const id = stdout.trim();
125
-
126
- const { stdout: showOut } = await run(["show", id, "--json"], testDir);
127
- const task = JSON.parse(showOut);
128
- expect(task.labels).toContain("bug");
129
- expect(task.labels).toContain("urgent");
130
- });
131
-
132
- test("creates task with due date", async () => {
133
- const { stdout } = await run(["add", "Task", "--due", "2026-01-15"], testDir);
134
- const id = stdout.trim();
135
-
136
- const { stdout: showOut } = await run(["show", id, "--json"], testDir);
137
- const task = JSON.parse(showOut);
138
- expect(task.due_date).toBe("2026-01-15");
139
- });
140
-
141
- test("creates task with relative due date", async () => {
142
- const { stdout } = await run(["add", "Task", "--due", "+7d"], testDir);
143
- const id = stdout.trim();
144
-
145
- const { stdout: showOut } = await run(["show", id, "--json"], testDir);
146
- const task = JSON.parse(showOut);
147
- expect(task.due_date).not.toBeNull();
148
- });
149
-
150
- test("creates task with custom project", async () => {
151
- const { stdout } = await run(["add", "Task", "-P", "api"], testDir);
152
- expect(stdout).toMatch(/^api-[a-z0-9]{4}$/);
153
- });
154
-
155
- test("errors without title", async () => {
156
- const { stderr, exitCode } = await run(["add"], testDir);
157
- expect(exitCode).toBe(1);
158
- expect(stderr).toContain("Title required");
159
- });
160
-
161
- test("errors with whitespace-only title", async () => {
162
- const { stderr, exitCode } = await run(["add", " "], testDir);
163
- expect(exitCode).toBe(1);
164
- expect(stderr).toContain("Title required");
165
- });
166
-
167
- test("errors with non-existent parent", async () => {
168
- const { stderr, exitCode } = await run(["add", "Task", "--parent", "tk-999"], testDir);
169
- expect(exitCode).toBe(1);
170
- expect(stderr).toContain("Parent task not found");
171
- });
172
-
173
- test("resolves parent by ref only", async () => {
174
- const { stdout: parentId } = await run(["add", "Parent task"], testDir);
175
- const ref = parentId.trim().split("-")[1] ?? "";
176
-
177
- const { stdout: childId, exitCode } = await run(
178
- ["add", "Child task", "--parent", ref],
179
- testDir,
180
- );
181
- expect(exitCode).toBe(0);
182
-
183
- const { stdout } = await run(["show", childId.trim()], testDir);
184
- expect(stdout).toContain("Parent:");
185
- expect(stdout).toContain(parentId.trim());
186
- });
187
-
188
- test("errors with invalid project name", async () => {
189
- const { stderr, exitCode } = await run(["add", "Task", "-P", "Invalid-Name!"], testDir);
190
- expect(exitCode).toBe(1);
191
- expect(stderr).toContain("Invalid project name");
192
- });
193
- });
194
-
195
- describe("ls", () => {
196
- test("lists tasks", async () => {
197
- await run(["add", "Task 1"], testDir);
198
- await run(["add", "Task 2"], testDir);
199
-
200
- const { stdout } = await run(["ls"], testDir);
201
- expect(stdout).toContain("Task 1");
202
- expect(stdout).toContain("Task 2");
203
- });
204
-
205
- test("filters by status", async () => {
206
- await run(["add", "Open task"], testDir);
207
- const { stdout: id2 } = await run(["add", "Done task"], testDir);
208
- await run(["done", id2.trim()], testDir);
209
-
210
- const { stdout } = await run(["ls", "-s", "open"], testDir);
211
- expect(stdout).toContain("Open task");
212
- expect(stdout).not.toContain("Done task");
213
- });
214
-
215
- test("filters by project", async () => {
216
- await run(["add", "API task", "-P", "api"], testDir);
217
- await run(["add", "Web task", "-P", "web"], testDir);
218
-
219
- const { stdout } = await run(["ls", "-P", "api"], testDir);
220
- expect(stdout).toContain("API task");
221
- expect(stdout).not.toContain("Web task");
222
- });
223
-
224
- test("filters by label", async () => {
225
- await run(["add", "Bug task", "-l", "bug"], testDir);
226
- await run(["add", "Feature task", "-l", "feature"], testDir);
227
-
228
- const { stdout } = await run(["ls", "-l", "bug"], testDir);
229
- expect(stdout).toContain("Bug task");
230
- expect(stdout).not.toContain("Feature task");
231
- });
232
-
233
- test("--json outputs JSON array", async () => {
234
- await run(["add", "Task"], testDir);
235
-
236
- const { stdout } = await run(["ls", "--json"], testDir);
237
- const tasks = JSON.parse(stdout);
238
- expect(Array.isArray(tasks)).toBe(true);
239
- expect(tasks.length).toBe(1);
240
- });
241
-
242
- test("empty list shows message", async () => {
243
- const { stdout } = await run(["ls"], testDir);
244
- expect(stdout).toContain("No tasks found");
245
- });
246
-
247
- test("sorts by status (active > open > done)", async () => {
248
- const { stdout: idDone } = await run(["add", "Done task"], testDir);
249
- await run(["done", idDone.trim()], testDir);
250
- const { stdout: idActive } = await run(["add", "Active task"], testDir);
251
- await run(["start", idActive.trim()], testDir);
252
- await run(["add", "Open task"], testDir);
253
-
254
- const { stdout } = await run(["ls"], testDir);
255
- const lines = stdout
256
- .trim()
257
- .split("\n")
258
- .filter((l) => l.includes("task"));
259
- expect(lines[0]).toContain("Active task");
260
- expect(lines[1]).toContain("Open task");
261
- expect(lines[2]).toContain("Done task");
262
- });
263
-
264
- test("sorts by priority (p1-4, then p0/none)", async () => {
265
- await run(["add", "Medium", "-p", "3"], testDir);
266
- await run(["add", "Urgent", "-p", "1"], testDir);
267
- await run(["add", "None", "-p", "0"], testDir);
268
- await run(["add", "Low", "-p", "4"], testDir);
269
-
270
- const { stdout } = await run(["ls"], testDir);
271
- const lines = stdout
272
- .trim()
273
- .split("\n")
274
- .filter((l) => /Medium|Urgent|None|Low/.test(l));
275
- expect(lines[0]).toContain("Urgent");
276
- expect(lines[1]).toContain("Medium");
277
- expect(lines[2]).toContain("Low");
278
- expect(lines[3]).toContain("None");
279
- });
280
-
281
- test("hoists overdue tasks to the top of their status group", async () => {
282
- await run(["add", "Urgent Future", "-p", "1", "--due", "2099-01-01"], testDir);
283
- await run(["add", "Low Overdue", "-p", "4", "--due", "2020-01-01"], testDir);
284
-
285
- const { stdout } = await run(["ls"], testDir);
286
- const lines = stdout
287
- .trim()
288
- .split("\n")
289
- .filter((l) => /Future|Overdue/.test(l));
290
- expect(lines[0]).toContain("Low Overdue");
291
- expect(lines[1]).toContain("Urgent Future");
292
- });
293
-
294
- test("sorts by due date when priority is equal", async () => {
295
- await run(["add", "Later", "-p", "3", "--due", "2099-01-02"], testDir);
296
- await run(["add", "Earlier", "-p", "3", "--due", "2099-01-01"], testDir);
297
-
298
- const { stdout } = await run(["ls"], testDir);
299
- const lines = stdout
300
- .trim()
301
- .split("\n")
302
- .filter((l) => /Earlier|Later/.test(l));
303
- expect(lines[0]).toContain("Earlier");
304
- expect(lines[1]).toContain("Later");
305
- });
306
-
307
- test("sorts done tasks by completion time (newest first)", async () => {
308
- const { stdout: id1 } = await run(["add", "Done First"], testDir);
309
- const { stdout: id2 } = await run(["add", "Done Second"], testDir);
310
- await run(["done", id1.trim()], testDir);
311
- // Ensure time difference
312
- await new Promise((r) => setTimeout(r, 10));
313
- await run(["done", id2.trim()], testDir);
314
-
315
- const { stdout } = await run(["ls", "-s", "done"], testDir);
316
- const lines = stdout
317
- .trim()
318
- .split("\n")
319
- .filter((l) => l.includes("Done"));
320
- expect(lines[0]).toContain("Done Second");
321
- expect(lines[1]).toContain("Done First");
322
- });
323
- });
324
-
325
- describe("ready", () => {
326
- test("shows active and unblocked open tasks", async () => {
327
- const { stdout: id1 } = await run(["add", "Active task"], testDir);
328
- await run(["start", id1.trim()], testDir);
329
- await run(["add", "Open task"], testDir);
330
- const { stdout: id2 } = await run(["add", "Blocked task"], testDir);
331
- await run(["block", id2.trim(), id1.trim()], testDir);
332
-
333
- const { stdout } = await run(["ready"], testDir);
334
- expect(stdout).toContain("Active task");
335
- expect(stdout).toContain("Open task");
336
- expect(stdout).not.toContain("Blocked task");
337
- });
338
-
339
- test("shows blocked task after blocker is done", async () => {
340
- const { stdout: id1 } = await run(["add", "Blocker"], testDir);
341
- const { stdout: id2 } = await run(["add", "Blocked"], testDir);
342
- await run(["block", id2.trim(), id1.trim()], testDir);
343
- await run(["done", id1.trim()], testDir);
344
-
345
- const { stdout } = await run(["ready"], testDir);
346
- expect(stdout).toContain("Blocked");
347
- });
348
- });
349
-
350
- describe("status transitions", () => {
351
- test("start: open -> active", async () => {
352
- const { stdout: id } = await run(["add", "Task"], testDir);
353
- await run(["start", id.trim()], testDir);
354
-
355
- const { stdout } = await run(["show", id.trim(), "--json"], testDir);
356
- const task = JSON.parse(stdout);
357
- expect(task.status).toBe("active");
358
- });
359
-
360
- test("done: any -> done", async () => {
361
- const { stdout: id } = await run(["add", "Task"], testDir);
362
- await run(["done", id.trim()], testDir);
363
-
364
- const { stdout } = await run(["show", id.trim(), "--json"], testDir);
365
- const task = JSON.parse(stdout);
366
- expect(task.status).toBe("done");
367
- expect(task.completed_at).not.toBeNull();
368
- });
369
-
370
- test("reopen: done -> open", async () => {
371
- const { stdout: id } = await run(["add", "Task"], testDir);
372
- await run(["done", id.trim()], testDir);
373
- await run(["reopen", id.trim()], testDir);
374
-
375
- const { stdout } = await run(["show", id.trim(), "--json"], testDir);
376
- const task = JSON.parse(stdout);
377
- expect(task.status).toBe("open");
378
- expect(task.completed_at).toBeNull();
379
- });
380
-
381
- test("start errors if already active", async () => {
382
- const { stdout: id } = await run(["add", "Task"], testDir);
383
- await run(["start", id.trim()], testDir);
384
-
385
- const { stderr, exitCode } = await run(["start", id.trim()], testDir);
386
- expect(exitCode).toBe(1);
387
- expect(stderr).toContain("already active");
388
- expect(stderr).toContain("tk done"); // suggests next action
389
- });
390
-
391
- test("start errors if already done", async () => {
392
- const { stdout: id } = await run(["add", "Task"], testDir);
393
- await run(["done", id.trim()], testDir);
394
-
395
- const { stderr, exitCode } = await run(["start", id.trim()], testDir);
396
- expect(exitCode).toBe(1);
397
- expect(stderr).toContain("already done");
398
- expect(stderr).toContain("tk reopen"); // suggests next action
399
- });
400
- });
401
-
402
- describe("edit", () => {
403
- test("updates title", async () => {
404
- const { stdout: id } = await run(["add", "Original"], testDir);
405
- await run(["edit", id.trim(), "-t", "Updated"], testDir);
406
-
407
- const { stdout } = await run(["show", id.trim(), "--json"], testDir);
408
- const task = JSON.parse(stdout);
409
- expect(task.title).toBe("Updated");
410
- });
411
-
412
- test("updates priority", async () => {
413
- const { stdout: id } = await run(["add", "Task", "-p", "3"], testDir);
414
- await run(["edit", id.trim(), "-p", "1"], testDir);
415
-
416
- const { stdout } = await run(["show", id.trim(), "--json"], testDir);
417
- const task = JSON.parse(stdout);
418
- expect(task.priority).toBe(1);
419
- });
420
-
421
- test("adds label with +", async () => {
422
- const { stdout: id } = await run(["add", "Task", "-l", "bug"], testDir);
423
- await run(["edit", id.trim(), "-l", "+urgent"], testDir);
424
-
425
- const { stdout } = await run(["show", id.trim(), "--json"], testDir);
426
- const task = JSON.parse(stdout);
427
- expect(task.labels).toContain("bug");
428
- expect(task.labels).toContain("urgent");
429
- });
430
-
431
- test("removes label with -", async () => {
432
- const { stdout: id } = await run(["add", "Task", "-l", "bug,urgent"], testDir);
433
- // Use --labels= format to avoid -bug being parsed as flag
434
- await run(["edit", id.trim(), "--labels=-bug"], testDir);
435
-
436
- const { stdout } = await run(["show", id.trim(), "--json"], testDir);
437
- const task = JSON.parse(stdout);
438
- expect(task.labels).not.toContain("bug");
439
- expect(task.labels).toContain("urgent");
440
- });
441
-
442
- test("prevents duplicate labels with +", async () => {
443
- const { stdout: id } = await run(["add", "Task", "-l", "bug"], testDir);
444
- await run(["edit", id.trim(), "-l", "+bug"], testDir);
445
-
446
- const { stdout } = await run(["show", id.trim(), "--json"], testDir);
447
- const task = JSON.parse(stdout);
448
- expect(task.labels).toEqual(["bug"]);
449
- });
450
-
451
- test("errors on self-referential parent", async () => {
452
- const { stdout: id } = await run(["add", "Task"], testDir);
453
- const { stderr, exitCode } = await run(["edit", id.trim(), "--parent", id.trim()], testDir);
454
- expect(exitCode).toBe(1);
455
- expect(stderr).toContain("cannot be its own parent");
456
- });
457
-
458
- test("errors on parent cycle", async () => {
459
- const { stdout: id1 } = await run(["add", "Parent"], testDir);
460
- const { stdout: id2 } = await run(["add", "Child", "--parent", id1.trim()], testDir);
461
- const { stderr, exitCode } = await run(["edit", id1.trim(), "--parent", id2.trim()], testDir);
462
- expect(exitCode).toBe(1);
463
- expect(stderr).toContain("circular parent");
464
- });
465
- });
466
-
467
- describe("log", () => {
468
- test("adds log entry", async () => {
469
- const { stdout: id } = await run(["add", "Task"], testDir);
470
- await run(["log", id.trim(), "First entry"], testDir);
471
- await run(["log", id.trim(), "Second entry"], testDir);
472
-
473
- const { stdout } = await run(["show", id.trim(), "--json"], testDir);
474
- const task = JSON.parse(stdout);
475
- expect(task.logs.length).toBe(2);
476
- expect(task.logs[0].msg).toBe("First entry");
477
- expect(task.logs[1].msg).toBe("Second entry");
478
- });
479
- });
480
-
481
- describe("block/unblock", () => {
482
- test("block prevents task from being ready", async () => {
483
- const { stdout: id1 } = await run(["add", "Blocker"], testDir);
484
- const { stdout: id2 } = await run(["add", "Blocked"], testDir);
485
- await run(["block", id2.trim(), id1.trim()], testDir);
486
-
487
- const { stdout } = await run(["show", id2.trim(), "--json"], testDir);
488
- const task = JSON.parse(stdout);
489
- expect(task.blocked_by).toContain(id1.trim());
490
- expect(task.blocked_by_incomplete).toBe(true);
491
- });
492
-
493
- test("unblock removes dependency", async () => {
494
- const { stdout: id1 } = await run(["add", "Blocker"], testDir);
495
- const { stdout: id2 } = await run(["add", "Blocked"], testDir);
496
- await run(["block", id2.trim(), id1.trim()], testDir);
497
- await run(["unblock", id2.trim(), id1.trim()], testDir);
498
-
499
- const { stdout } = await run(["show", id2.trim(), "--json"], testDir);
500
- const task = JSON.parse(stdout);
501
- expect(task.blocked_by).not.toContain(id1.trim());
502
- });
503
-
504
- test("cannot block self", async () => {
505
- const { stdout: id } = await run(["add", "Task"], testDir);
506
- const { stderr, exitCode } = await run(["block", id.trim(), id.trim()], testDir);
507
- expect(exitCode).toBe(1);
508
- expect(stderr).toContain("cannot block itself");
509
- });
510
-
511
- test("validates blocker exists", async () => {
512
- const { stdout: id } = await run(["add", "Task"], testDir);
513
- const { stderr, exitCode } = await run(["block", id.trim(), "tk-999"], testDir);
514
- expect(exitCode).toBe(1);
515
- expect(stderr).toContain("not found");
516
- });
517
-
518
- test("detects circular dependencies", async () => {
519
- const { stdout: id1 } = await run(["add", "Task A"], testDir);
520
- const { stdout: id2 } = await run(["add", "Task B"], testDir);
521
- const { stdout: id3 } = await run(["add", "Task C"], testDir);
522
-
523
- await run(["block", id1.trim(), id2.trim()], testDir);
524
- await run(["block", id2.trim(), id3.trim()], testDir);
525
-
526
- const { stderr, exitCode } = await run(["block", id3.trim(), id1.trim()], testDir);
527
- expect(exitCode).toBe(1);
528
- expect(stderr).toContain("circular");
529
- });
530
- });
531
-
532
- describe("rm", () => {
533
- test("deletes task", async () => {
534
- const { stdout: id } = await run(["add", "Task"], testDir);
535
- await run(["rm", id.trim()], testDir);
536
-
537
- const { stderr, exitCode } = await run(["show", id.trim()], testDir);
538
- expect(exitCode).toBe(1);
539
- expect(stderr).toContain("not found");
540
- });
541
-
542
- test("removes associated blocks", async () => {
543
- const { stdout: id1 } = await run(["add", "Blocker"], testDir);
544
- const { stdout: id2 } = await run(["add", "Blocked"], testDir);
545
- await run(["block", id2.trim(), id1.trim()], testDir);
546
- await run(["rm", id1.trim()], testDir);
547
-
548
- const { stdout } = await run(["show", id2.trim(), "--json"], testDir);
549
- const task = JSON.parse(stdout);
550
- expect(task.blocked_by).not.toContain(id1.trim());
551
- });
552
-
553
- test("clears parent references on child tasks", async () => {
554
- const { stdout: parentId } = await run(["add", "Parent"], testDir);
555
- const { stdout: childId } = await run(["add", "Child", "--parent", parentId.trim()], testDir);
556
-
557
- // Verify child has parent
558
- const { stdout: before } = await run(["show", childId.trim(), "--json"], testDir);
559
- expect(JSON.parse(before).parent).toBe(parentId.trim());
560
-
561
- // Delete parent
562
- await run(["rm", parentId.trim()], testDir);
563
-
564
- // Child's parent should be cleared
565
- const { stdout: after } = await run(["show", childId.trim(), "--json"], testDir);
566
- expect(JSON.parse(after).parent).toBeNull();
567
- });
568
- });
569
-
570
- describe("clean", () => {
571
- test("removes completed tasks", async () => {
572
- const { stdout: id } = await run(["add", "Task"], testDir);
573
- await run(["done", id.trim()], testDir);
574
- await run(["clean", "--force"], testDir);
575
-
576
- const { exitCode } = await run(["show", id.trim()], testDir);
577
- expect(exitCode).toBe(1);
578
- });
579
-
580
- test("keeps open tasks", async () => {
581
- const { stdout: id } = await run(["add", "Open task"], testDir);
582
- await run(["clean", "--force"], testDir);
583
-
584
- const { exitCode } = await run(["show", id.trim()], testDir);
585
- expect(exitCode).toBe(0);
586
- });
587
-
588
- test("--older-than respects age threshold", async () => {
589
- const { stdout: id } = await run(["add", "Task"], testDir);
590
- await run(["done", id.trim()], testDir);
591
-
592
- // Task was just completed, so --older-than 1 should keep it
593
- await run(["clean", "--older-than", "1"], testDir);
594
- const { exitCode: stillExists } = await run(["show", id.trim()], testDir);
595
- expect(stillExists).toBe(0);
596
-
597
- // --older-than 0 should remove it (0 days = remove all)
598
- await run(["clean", "--older-than", "0"], testDir);
599
- const { exitCode: gone } = await run(["show", id.trim()], testDir);
600
- expect(gone).toBe(1);
601
- });
602
- });
603
-
604
- describe("init", () => {
605
- test("creates .tasks directory", async () => {
606
- const newDir = mkdtempSync(join(tmpdir(), "tk-init-"));
607
- Bun.spawnSync(["git", "init"], { cwd: newDir });
608
-
609
- const { stdout, exitCode } = await run(["init"], newDir);
610
- expect(exitCode).toBe(0);
611
- expect(stdout).toContain("Initialized");
612
- expect(existsSync(join(newDir, ".tasks"))).toBe(true);
613
-
614
- rmSync(newDir, { recursive: true, force: true });
615
- });
616
-
617
- test("creates .tasks with custom project", async () => {
618
- const newDir = mkdtempSync(join(tmpdir(), "tk-init-"));
619
- Bun.spawnSync(["git", "init"], { cwd: newDir });
620
-
621
- await run(["init", "-P", "api"], newDir);
622
- const { stdout } = await run(["add", "Task"], newDir);
623
- expect(stdout).toMatch(/^api-[a-z0-9]{4}$/);
624
-
625
- rmSync(newDir, { recursive: true, force: true });
626
- });
627
-
628
- test("reports already initialized", async () => {
629
- await run(["init"], testDir);
630
- const { stdout } = await run(["init"], testDir);
631
- expect(stdout).toContain("Already initialized");
632
- });
633
- });
634
-
635
- describe("config", () => {
636
- test("shows config", async () => {
637
- await run(["init"], testDir);
638
- const { stdout } = await run(["config"], testDir);
639
- expect(stdout).toContain("Version:");
640
- expect(stdout).toContain("Project:");
641
- });
642
-
643
- test("shows default project derived from directory", async () => {
644
- await run(["init"], testDir);
645
- const { stdout } = await run(["config", "project"], testDir);
646
- // Project is derived from directory name (tktest... from temp dir)
647
- expect(stdout).toMatch(/^tktest[a-z0-9]+$/);
648
- });
649
-
650
- test("respects explicit project name", async () => {
651
- await run(["init", "--project", "myapp"], testDir);
652
- const { stdout } = await run(["config", "project"], testDir);
653
- expect(stdout).toBe("myapp");
654
- });
655
-
656
- test("sets default project", async () => {
657
- await run(["init"], testDir);
658
- await run(["config", "project", "api"], testDir);
659
-
660
- const { stdout } = await run(["add", "Task"], testDir);
661
- expect(stdout).toMatch(/^api-[a-z0-9]{4}$/);
662
- });
663
-
664
- test("manages aliases", async () => {
665
- await run(["init"], testDir);
666
- await run(["config", "alias", "api", "packages/api"], testDir);
667
-
668
- const { stdout } = await run(["config", "alias"], testDir);
669
- expect(stdout).toContain("api");
670
- expect(stdout).toContain("packages/api");
671
- });
672
-
673
- test("renames project and updates references", async () => {
674
- await run(["init", "-P", "old"], testDir);
675
-
676
- // Create tasks with references
677
- const { stdout: id1 } = await run(["add", "Task 1"], testDir);
678
- const { stdout: id2 } = await run(["add", "Task 2"], testDir);
679
- await run(["block", id2.trim(), id1.trim()], testDir);
680
-
681
- // Rename project
682
- const { stdout, exitCode } = await run(
683
- ["config", "project", "new", "--rename", "old"],
684
- testDir,
685
- );
686
- expect(exitCode).toBe(0);
687
- expect(stdout).toContain("Renamed 2 tasks");
688
- expect(stdout).toContain("old-*");
689
- expect(stdout).toContain("new-*");
690
-
691
- // Verify new IDs work
692
- const newId1 = id1.trim().replace("old-", "new-");
693
- const { exitCode: showCode } = await run(["show", newId1], testDir);
694
- expect(showCode).toBe(0);
695
-
696
- // Verify references updated
697
- const { stdout: showOut } = await run(
698
- ["show", id2.trim().replace("old-", "new-"), "--json"],
699
- testDir,
700
- );
701
- const task = JSON.parse(showOut);
702
- expect(task.blocked_by[0]).toBe(newId1);
703
- });
704
- });
705
-
706
- describe("error handling", () => {
707
- describe("validation errors", () => {
708
- test("invalid status shows valid options", async () => {
709
- const { stderr, exitCode } = await run(["ls", "-s", "invalid"], testDir);
710
- expect(exitCode).toBe(1);
711
- expect(stderr).toContain("Invalid status");
712
- expect(stderr).toContain("open");
713
- expect(stderr).toContain("active");
714
- expect(stderr).toContain("done");
715
- });
716
-
717
- test("invalid priority shows valid formats", async () => {
718
- const { stderr, exitCode } = await run(["add", "Task", "-p", "p9"], testDir);
719
- expect(exitCode).toBe(1);
720
- expect(stderr).toContain("Invalid priority");
721
- });
722
- });
723
-
724
- describe("missing arguments", () => {
725
- test("show without ID", async () => {
726
- const { stderr, exitCode } = await run(["show"], testDir);
727
- expect(exitCode).toBe(1);
728
- expect(stderr).toContain("ID required");
729
- });
730
-
731
- test("log without message", async () => {
732
- const { stdout: id } = await run(["add", "Task"], testDir);
733
- const { stderr, exitCode } = await run(["log", id.trim()], testDir);
734
- expect(exitCode).toBe(1);
735
- expect(stderr).toContain("Message required");
736
- });
737
- });
738
-
739
- describe("invalid ID format", () => {
740
- test("rejects invalid ID format", async () => {
741
- const { stderr, exitCode } = await run(["show", "invalid"], testDir);
742
- expect(exitCode).toBe(1);
743
- expect(stderr).toContain("not found");
744
- });
745
- });
746
-
747
- describe("not found errors", () => {
748
- test("show non-existent task", async () => {
749
- const { stderr, exitCode } = await run(["show", "tk-999"], testDir);
750
- expect(exitCode).toBe(1);
751
- expect(stderr).toContain("Task not found");
752
- });
753
- });
754
- });
755
-
756
- describe("flag position flexibility", () => {
757
- test("--json works after command", async () => {
758
- await run(["add", "Task"], testDir);
759
- const { stdout, exitCode } = await run(["ls", "--json"], testDir);
760
- expect(exitCode).toBe(0);
761
- const tasks = JSON.parse(stdout);
762
- expect(Array.isArray(tasks)).toBe(true);
763
- });
764
-
765
- test("--json works before command", async () => {
766
- await run(["add", "Task"], testDir);
767
- const { stdout, exitCode } = await run(["--json", "ls"], testDir);
768
- expect(exitCode).toBe(0);
769
- const tasks = JSON.parse(stdout);
770
- expect(Array.isArray(tasks)).toBe(true);
771
- });
772
-
773
- test("log requires quoted message", async () => {
774
- const { stdout: id } = await run(["add", "Task"], testDir);
775
- const taskId = id.trim();
776
-
777
- // Unquoted multiple words should error
778
- const { stderr, exitCode } = await run(["log", taskId, "word1", "word2"], testDir);
779
- expect(exitCode).toBe(1);
780
- expect(stderr).toContain("must be quoted");
781
-
782
- // Quoted message works (shell passes as single arg)
783
- await run(["log", taskId, "Quoted message works"], testDir);
784
- const { stdout } = await run(["show", "--json", taskId], testDir);
785
- const task = JSON.parse(stdout);
786
- expect(task.logs[0].msg).toBe("Quoted message works");
787
- });
788
- });
789
-
790
- describe("ID resolution", () => {
791
- test("can use just ref if unambiguous", async () => {
792
- const { stdout: id } = await run(["add", "Task"], testDir);
793
- const ref = id.trim().split("-")[1] ?? "";
794
-
795
- const { exitCode } = await run(["show", ref], testDir);
796
- expect(exitCode).toBe(0);
797
- });
798
-
799
- test("full ID always works", async () => {
800
- const { stdout: id } = await run(["add", "Task"], testDir);
801
-
802
- const { exitCode } = await run(["show", id.trim()], testDir);
803
- expect(exitCode).toBe(0);
804
- });
805
-
806
- test("ambiguous ID shows matching tasks", async () => {
807
- // Create tasks with same ref prefix in different projects
808
- const { stdout: id1 } = await run(["add", "Task 1", "-P", "api"], testDir);
809
- const { stdout: id2 } = await run(["add", "Task 2", "-P", "web"], testDir);
810
- const ref1 = id1.trim().split("-")[1] ?? "";
811
- const ref2 = id2.trim().split("-")[1] ?? "";
812
-
813
- // Use first char which might match both
814
- const prefix = ref1[0] ?? "";
815
-
816
- // Only test if both refs start with same char (otherwise not ambiguous)
817
- if (ref2.startsWith(prefix)) {
818
- const { stderr, exitCode } = await run(["show", prefix], testDir);
819
- expect(exitCode).toBe(1);
820
- expect(stderr).toContain("Ambiguous");
821
- expect(stderr).toContain("matches");
822
- }
823
- });
824
-
825
- test("not found shows clear error", async () => {
826
- const { stderr, exitCode } = await run(["show", "zzzz"], testDir);
827
- expect(exitCode).toBe(1);
828
- expect(stderr).toContain("not found");
829
- });
830
- });
831
-
832
- describe("tk check", () => {
833
- test("reports all OK when no issues", async () => {
834
- await run(["add", "Task 1"], testDir);
835
- await run(["add", "Task 2"], testDir);
836
-
837
- const { stdout, exitCode } = await run(["check"], testDir);
838
- expect(exitCode).toBe(0);
839
- expect(stdout).toContain("2 tasks OK");
840
- });
841
-
842
- test("auto-fixes orphaned blocker reference", async () => {
843
- // Create two tasks and block one by the other
844
- const { stdout: id1 } = await run(["add", "Blocker"], testDir);
845
- const { stdout: id2 } = await run(["add", "Blocked"], testDir);
846
- const blockerId = id1.trim();
847
- const blockedId = id2.trim();
848
-
849
- await run(["block", blockedId, blockerId], testDir);
850
-
851
- // Manually delete blocker file to simulate merge
852
- const fs = await import("fs");
853
- const path = await import("path");
854
- const blockerFile = path.join(testDir, ".tasks", `${blockerId}.json`);
855
- fs.unlinkSync(blockerFile);
856
-
857
- // Check should fix it
858
- const { stdout } = await run(["check"], testDir);
859
- expect(stdout).toContain("cleaned");
860
- expect(stdout).toContain("orphaned");
861
-
862
- // Verify the blocked_by is now empty
863
- const { stdout: showOut } = await run(["show", "--json", blockedId], testDir);
864
- const task = JSON.parse(showOut);
865
- expect(task.blocked_by).toEqual([]);
866
- });
867
-
868
- test("auto-fixes orphaned parent reference", async () => {
869
- // Create parent and child
870
- const { stdout: parentId } = await run(["add", "Parent"], testDir);
871
- const parent = parentId.trim();
872
- const { stdout: childId } = await run(["add", "Child", "--parent", parent], testDir);
873
- const child = childId.trim();
874
-
875
- // Manually delete parent file
876
- const fs = await import("fs");
877
- const path = await import("path");
878
- const parentFile = path.join(testDir, ".tasks", `${parent}.json`);
879
- fs.unlinkSync(parentFile);
880
-
881
- // Check should fix it
882
- const { stdout } = await run(["check"], testDir);
883
- expect(stdout).toContain("cleaned");
884
- expect(stdout).toContain("orphaned parent");
885
-
886
- // Verify parent is now null
887
- const { stdout: showOut } = await run(["show", "--json", child], testDir);
888
- const task = JSON.parse(showOut);
889
- expect(task.parent).toBeNull();
890
- });
891
-
892
- test("auto-fix happens on show command", async () => {
893
- // Create two tasks and block one
894
- const { stdout: id1 } = await run(["add", "Blocker"], testDir);
895
- const { stdout: id2 } = await run(["add", "Blocked"], testDir);
896
- const blockerId = id1.trim();
897
- const blockedId = id2.trim();
898
-
899
- await run(["block", blockedId, blockerId], testDir);
900
-
901
- // Manually delete blocker file
902
- const fs = await import("fs");
903
- const path = await import("path");
904
- const blockerFile = path.join(testDir, ".tasks", `${blockerId}.json`);
905
- fs.unlinkSync(blockerFile);
906
-
907
- // Show should auto-fix and output cleanup message
908
- const { stderr } = await run(["show", blockedId], testDir);
909
- expect(stderr).toContain("cleaned");
910
- expect(stderr).toContain("orphaned");
911
- });
912
-
913
- test("reports unfixable corrupted JSON", async () => {
914
- await run(["add", "Good task"], testDir);
915
-
916
- // Create a corrupted JSON file
917
- const fs = await import("fs");
918
- const path = await import("path");
919
- const corruptFile = path.join(testDir, ".tasks", "test-bad1.json");
920
- fs.writeFileSync(corruptFile, "{ invalid json");
921
-
922
- const { stdout } = await run(["check"], testDir);
923
- expect(stdout).toContain("Unfixable");
924
- expect(stdout).toContain("test-bad1.json");
925
- });
926
-
927
- test("reports unfixable invalid task structure", async () => {
928
- await run(["add", "Good task"], testDir);
929
-
930
- // Create valid JSON but invalid task structure
931
- const fs = await import("fs");
932
- const path = await import("path");
933
- const badFile = path.join(testDir, ".tasks", "test-bad2.json");
934
- fs.writeFileSync(badFile, '{"foo": "bar"}');
935
-
936
- const { stdout } = await run(["check"], testDir);
937
- expect(stdout).toContain("Unfixable");
938
- expect(stdout).toContain("test-bad2.json");
939
- expect(stdout).toContain("Invalid task structure");
940
- });
941
-
942
- test("auto-fixes ID mismatch (filename vs content)", async () => {
943
- // Create a task
944
- const { stdout: id } = await run(["add", "Test task", "-P", "api"], testDir);
945
- const taskId = id.trim();
946
-
947
- // Manually rename the file to create a mismatch
948
- const fs = await import("fs");
949
- const path = await import("path");
950
- const oldPath = path.join(testDir, ".tasks", `${taskId}.json`);
951
- const newPath = path.join(testDir, ".tasks", "web-x1y2.json");
952
- fs.renameSync(oldPath, newPath);
953
-
954
- // Check should fix it
955
- const { stdout } = await run(["check"], testDir);
956
- expect(stdout).toContain("cleaned");
957
- expect(stdout).toContain("ID mismatch");
958
-
959
- // Verify the content was updated to match filename
960
- const { stdout: showOut } = await run(["show", "--json", "web-x1y2"], testDir);
961
- const task = JSON.parse(showOut);
962
- expect(task.project).toBe("web");
963
- expect(task.ref).toBe("x1y2");
964
- });
965
-
966
- test("check --json returns structured output", async () => {
967
- await run(["add", "Task"], testDir);
968
-
969
- const { stdout } = await run(["check", "--json"], testDir);
970
- const result = JSON.parse(stdout);
971
- expect(result).toHaveProperty("totalTasks");
972
- expect(result).toHaveProperty("cleaned");
973
- expect(result).toHaveProperty("unfixable");
974
- });
975
- });
976
- });