tmux-team 2.0.0-alpha.4 → 2.2.0
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 +223 -17
- package/package.json +1 -1
- package/src/commands/add.ts +9 -5
- package/src/commands/config.ts +104 -9
- package/src/commands/help.ts +3 -1
- package/src/commands/preamble.ts +22 -25
- package/src/commands/remove.ts +5 -3
- package/src/commands/talk.test.ts +199 -14
- package/src/commands/talk.ts +26 -2
- package/src/commands/update.ts +16 -5
- package/src/config.test.ts +183 -9
- package/src/config.ts +29 -16
- package/src/pm/commands.test.ts +389 -55
- package/src/pm/commands.ts +312 -24
- package/src/pm/permissions.test.ts +113 -1
- package/src/pm/permissions.ts +18 -4
- package/src/pm/storage/adapter.ts +2 -0
- package/src/pm/storage/fs.test.ts +129 -1
- package/src/pm/storage/fs.ts +38 -4
- package/src/pm/storage/github.ts +96 -17
- package/src/pm/types.ts +6 -0
- package/src/state.test.ts +20 -10
- package/src/state.ts +28 -1
- package/src/types.ts +5 -1
- package/src/ui.ts +2 -1
package/src/config.ts
CHANGED
|
@@ -19,15 +19,16 @@ const LOCAL_CONFIG_FILENAME = 'tmux-team.json';
|
|
|
19
19
|
const STATE_FILENAME = 'state.json';
|
|
20
20
|
|
|
21
21
|
// Default configuration values
|
|
22
|
-
const DEFAULT_CONFIG:
|
|
22
|
+
const DEFAULT_CONFIG: GlobalConfig = {
|
|
23
23
|
mode: 'polling',
|
|
24
24
|
preambleMode: 'always',
|
|
25
25
|
defaults: {
|
|
26
26
|
timeout: 180,
|
|
27
27
|
pollInterval: 1,
|
|
28
28
|
captureLines: 100,
|
|
29
|
+
preambleEvery: 3, // inject preamble every 3 messages
|
|
30
|
+
hideOrphanTasks: false, // hide tasks without milestone in list
|
|
29
31
|
},
|
|
30
|
-
agents: {},
|
|
31
32
|
};
|
|
32
33
|
|
|
33
34
|
/**
|
|
@@ -130,7 +131,7 @@ export function loadConfig(paths: Paths): ResolvedConfig {
|
|
|
130
131
|
paneRegistry: {},
|
|
131
132
|
};
|
|
132
133
|
|
|
133
|
-
// Merge global config
|
|
134
|
+
// Merge global config (mode, preambleMode, defaults only)
|
|
134
135
|
const globalConfig = loadJsonFile<Partial<GlobalConfig>>(paths.globalConfig);
|
|
135
136
|
if (globalConfig) {
|
|
136
137
|
if (globalConfig.mode) config.mode = globalConfig.mode;
|
|
@@ -138,12 +139,10 @@ export function loadConfig(paths: Paths): ResolvedConfig {
|
|
|
138
139
|
if (globalConfig.defaults) {
|
|
139
140
|
config.defaults = { ...config.defaults, ...globalConfig.defaults };
|
|
140
141
|
}
|
|
141
|
-
if (globalConfig.agents) {
|
|
142
|
-
config.agents = { ...config.agents, ...globalConfig.agents };
|
|
143
|
-
}
|
|
144
142
|
}
|
|
145
143
|
|
|
146
|
-
// Load local config (pane registry + optional settings)
|
|
144
|
+
// Load local config (pane registry + optional settings + agent config)
|
|
145
|
+
// Local config is the SSOT for agent configuration (preamble, deny)
|
|
147
146
|
const localConfigFile = loadJsonFile<LocalConfigFile>(paths.localConfig);
|
|
148
147
|
if (localConfigFile) {
|
|
149
148
|
// Extract local settings if present
|
|
@@ -153,22 +152,36 @@ export function loadConfig(paths: Paths): ResolvedConfig {
|
|
|
153
152
|
if (localSettings) {
|
|
154
153
|
if (localSettings.mode) config.mode = localSettings.mode;
|
|
155
154
|
if (localSettings.preambleMode) config.preambleMode = localSettings.preambleMode;
|
|
155
|
+
if (localSettings.preambleEvery !== undefined) {
|
|
156
|
+
config.defaults.preambleEvery = localSettings.preambleEvery;
|
|
157
|
+
}
|
|
156
158
|
}
|
|
157
159
|
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
+
// Build pane registry and agents config from local entries
|
|
161
|
+
for (const [agentName, entry] of Object.entries(paneEntries)) {
|
|
162
|
+
const paneEntry = entry as LocalConfig[string];
|
|
163
|
+
|
|
164
|
+
// Add to pane registry if has valid pane field
|
|
165
|
+
if (paneEntry.pane) {
|
|
166
|
+
config.paneRegistry[agentName] = paneEntry;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Build agents config from preamble/deny fields
|
|
170
|
+
const hasPreamble = Object.prototype.hasOwnProperty.call(paneEntry, 'preamble');
|
|
171
|
+
const hasDeny = Object.prototype.hasOwnProperty.call(paneEntry, 'deny');
|
|
172
|
+
|
|
173
|
+
if (hasPreamble || hasDeny) {
|
|
174
|
+
config.agents[agentName] = {
|
|
175
|
+
...(hasPreamble && { preamble: paneEntry.preamble }),
|
|
176
|
+
...(hasDeny && { deny: paneEntry.deny }),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
160
180
|
}
|
|
161
181
|
|
|
162
182
|
return config;
|
|
163
183
|
}
|
|
164
184
|
|
|
165
|
-
export function saveLocalConfig(
|
|
166
|
-
paths: Paths,
|
|
167
|
-
paneRegistry: Record<string, { pane: string; remark?: string }>
|
|
168
|
-
): void {
|
|
169
|
-
fs.writeFileSync(paths.localConfig, JSON.stringify(paneRegistry, null, 2) + '\n');
|
|
170
|
-
}
|
|
171
|
-
|
|
172
185
|
export function ensureGlobalDir(paths: Paths): void {
|
|
173
186
|
if (!fs.existsSync(paths.globalDir)) {
|
|
174
187
|
fs.mkdirSync(paths.globalDir, { recursive: true });
|
package/src/pm/commands.test.ts
CHANGED
|
@@ -9,15 +9,7 @@ import os from 'os';
|
|
|
9
9
|
import type { Context } from '../types.js';
|
|
10
10
|
import type { UI } from '../types.js';
|
|
11
11
|
// ExitCodes imported for reference but tested via ctx.exit mock
|
|
12
|
-
import {
|
|
13
|
-
cmdPm,
|
|
14
|
-
cmdPmInit,
|
|
15
|
-
cmdPmMilestone,
|
|
16
|
-
cmdPmTask,
|
|
17
|
-
cmdPmDoc,
|
|
18
|
-
cmdPmLog,
|
|
19
|
-
cmdPmList,
|
|
20
|
-
} from './commands.js';
|
|
12
|
+
import { cmdPm, cmdPmInit, cmdPmMilestone, cmdPmTask, cmdPmLog, cmdPmList } from './commands.js';
|
|
21
13
|
import { findCurrentTeamId, linkTeam, getTeamsDir } from './manager.js';
|
|
22
14
|
|
|
23
15
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -44,7 +36,18 @@ function createMockUI(): UI & { logs: string[]; errors: string[]; jsonData: unkn
|
|
|
44
36
|
|
|
45
37
|
function createMockContext(
|
|
46
38
|
globalDir: string,
|
|
47
|
-
options: {
|
|
39
|
+
options: {
|
|
40
|
+
json?: boolean;
|
|
41
|
+
cwd?: string;
|
|
42
|
+
agents?: Record<string, { deny?: string[] }>;
|
|
43
|
+
defaults?: Partial<{
|
|
44
|
+
timeout: number;
|
|
45
|
+
pollInterval: number;
|
|
46
|
+
captureLines: number;
|
|
47
|
+
preambleEvery: number;
|
|
48
|
+
hideOrphanTasks: boolean;
|
|
49
|
+
}>;
|
|
50
|
+
} = {}
|
|
48
51
|
): Context & { ui: ReturnType<typeof createMockUI>; exitCode: number | null } {
|
|
49
52
|
const ui = createMockUI();
|
|
50
53
|
let exitCode: number | null = null;
|
|
@@ -63,7 +66,14 @@ function createMockContext(
|
|
|
63
66
|
config: {
|
|
64
67
|
mode: 'polling',
|
|
65
68
|
preambleMode: 'always',
|
|
66
|
-
defaults: {
|
|
69
|
+
defaults: {
|
|
70
|
+
timeout: 60,
|
|
71
|
+
pollInterval: 1,
|
|
72
|
+
captureLines: 100,
|
|
73
|
+
preambleEvery: 3,
|
|
74
|
+
hideOrphanTasks: false,
|
|
75
|
+
...options.defaults,
|
|
76
|
+
},
|
|
67
77
|
agents: options.agents ?? {},
|
|
68
78
|
paneRegistry: {},
|
|
69
79
|
},
|
|
@@ -320,9 +330,35 @@ describe('cmdPmMilestone', () => {
|
|
|
320
330
|
|
|
321
331
|
await cmdPmMilestone(ctx, ['add', 'Phase 1']);
|
|
322
332
|
await cmdPmMilestone(ctx, ['add', 'Phase 2']);
|
|
333
|
+
|
|
334
|
+
(ctx.ui.table as ReturnType<typeof vi.fn>).mockClear();
|
|
323
335
|
await cmdPmMilestone(ctx, ['list']);
|
|
324
336
|
|
|
325
|
-
expect(ctx.ui.table).
|
|
337
|
+
expect(ctx.ui.table).toHaveBeenCalledTimes(1);
|
|
338
|
+
expect(ctx.ui.table).toHaveBeenCalledWith(
|
|
339
|
+
['ID', 'NAME', 'STATUS'],
|
|
340
|
+
expect.arrayContaining([
|
|
341
|
+
expect.arrayContaining(['1', 'Phase 1']),
|
|
342
|
+
expect.arrayContaining(['2', 'Phase 2']),
|
|
343
|
+
])
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('lists milestones when called without subcommand', async () => {
|
|
348
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
349
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
350
|
+
|
|
351
|
+
await cmdPmMilestone(ctx, ['add', 'Phase 1']);
|
|
352
|
+
|
|
353
|
+
// Clear mock call history after add, then verify empty args triggers list
|
|
354
|
+
(ctx.ui.table as ReturnType<typeof vi.fn>).mockClear();
|
|
355
|
+
await cmdPmMilestone(ctx, []);
|
|
356
|
+
|
|
357
|
+
expect(ctx.ui.table).toHaveBeenCalledTimes(1);
|
|
358
|
+
expect(ctx.ui.table).toHaveBeenCalledWith(
|
|
359
|
+
['ID', 'NAME', 'STATUS'],
|
|
360
|
+
expect.arrayContaining([expect.arrayContaining(['1', 'Phase 1'])])
|
|
361
|
+
);
|
|
326
362
|
});
|
|
327
363
|
|
|
328
364
|
it('marks milestone as done', async () => {
|
|
@@ -348,6 +384,55 @@ describe('cmdPmMilestone', () => {
|
|
|
348
384
|
expect(ctx.ui.errors[0]).toContain('not found');
|
|
349
385
|
});
|
|
350
386
|
|
|
387
|
+
it('deletes milestone', async () => {
|
|
388
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
389
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
390
|
+
|
|
391
|
+
await cmdPmMilestone(ctx, ['add', 'To Delete']);
|
|
392
|
+
await cmdPmMilestone(ctx, ['delete', '1']);
|
|
393
|
+
|
|
394
|
+
expect(ctx.ui.logs.some((l) => l.includes('deleted'))).toBe(true);
|
|
395
|
+
|
|
396
|
+
// Verify milestone was deleted
|
|
397
|
+
const milestonePath = path.join(globalDir, 'teams', teamId, 'milestones', '1.json');
|
|
398
|
+
expect(fs.existsSync(milestonePath)).toBe(false);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('deletes milestone with rm shorthand', async () => {
|
|
402
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
403
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
404
|
+
|
|
405
|
+
await cmdPmMilestone(ctx, ['add', 'To Remove']);
|
|
406
|
+
await cmdPmMilestone(ctx, ['rm', '1']);
|
|
407
|
+
|
|
408
|
+
expect(ctx.ui.logs.some((l) => l.includes('deleted'))).toBe(true);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('returns JSON on milestone delete with --json flag', async () => {
|
|
412
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
|
|
413
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
414
|
+
|
|
415
|
+
await cmdPmMilestone(ctx, ['add', 'JSON Delete']);
|
|
416
|
+
await cmdPmMilestone(ctx, ['delete', '1']);
|
|
417
|
+
|
|
418
|
+
const output = ctx.ui.jsonData[ctx.ui.jsonData.length - 1] as {
|
|
419
|
+
deleted: boolean;
|
|
420
|
+
id: string;
|
|
421
|
+
name: string;
|
|
422
|
+
};
|
|
423
|
+
expect(output.deleted).toBe(true);
|
|
424
|
+
expect(output.id).toBe('1');
|
|
425
|
+
expect(output.name).toBe('JSON Delete');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('exits with error when deleting non-existent milestone', async () => {
|
|
429
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
430
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
431
|
+
|
|
432
|
+
await expect(cmdPmMilestone(ctx, ['delete', '999'])).rejects.toThrow('Exit');
|
|
433
|
+
expect(ctx.ui.errors[0]).toContain('not found');
|
|
434
|
+
});
|
|
435
|
+
|
|
351
436
|
it('routes "pm m add" to milestone add', async () => {
|
|
352
437
|
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
353
438
|
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
@@ -356,6 +441,153 @@ describe('cmdPmMilestone', () => {
|
|
|
356
441
|
|
|
357
442
|
expect(ctx.ui.logs.some((l) => l.includes('Shorthand Test'))).toBe(true);
|
|
358
443
|
});
|
|
444
|
+
|
|
445
|
+
it('creates milestone with --description flag', async () => {
|
|
446
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
447
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
448
|
+
|
|
449
|
+
await cmdPmMilestone(ctx, ['add', 'Phase 1', '--description', 'Initial development']);
|
|
450
|
+
|
|
451
|
+
// Verify doc file contains description
|
|
452
|
+
const docPath = path.join(globalDir, 'teams', teamId, 'milestones', '1.md');
|
|
453
|
+
const content = fs.readFileSync(docPath, 'utf-8');
|
|
454
|
+
expect(content).toContain('Phase 1');
|
|
455
|
+
expect(content).toContain('Initial development');
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('prints milestone doc by default', async () => {
|
|
459
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
|
|
460
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
461
|
+
|
|
462
|
+
await cmdPmMilestone(ctx, ['add', 'Phase 1', '-d', 'Test description']);
|
|
463
|
+
|
|
464
|
+
(ctx.ui.json as ReturnType<typeof vi.fn>).mockClear();
|
|
465
|
+
await cmdPmMilestone(ctx, ['doc', '1']);
|
|
466
|
+
|
|
467
|
+
expect(ctx.ui.json).toHaveBeenCalledWith(
|
|
468
|
+
expect.objectContaining({
|
|
469
|
+
id: '1',
|
|
470
|
+
doc: expect.stringContaining('Phase 1'),
|
|
471
|
+
})
|
|
472
|
+
);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('returns milestone doc in JSON format', async () => {
|
|
476
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
|
|
477
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
478
|
+
|
|
479
|
+
await cmdPmMilestone(ctx, ['add', 'Phase 1']);
|
|
480
|
+
await cmdPmMilestone(ctx, ['doc', '1']);
|
|
481
|
+
|
|
482
|
+
const jsonOutput = ctx.ui.jsonData.find(
|
|
483
|
+
(d) => typeof d === 'object' && d !== null && 'doc' in d
|
|
484
|
+
) as { id: string; doc: string };
|
|
485
|
+
expect(jsonOutput).toMatchObject({
|
|
486
|
+
id: '1',
|
|
487
|
+
doc: expect.stringContaining('Phase 1'),
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('exits with error for non-existent milestone doc', async () => {
|
|
492
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
493
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
494
|
+
|
|
495
|
+
await expect(cmdPmMilestone(ctx, ['doc', '999'])).rejects.toThrow('Exit');
|
|
496
|
+
expect(ctx.ui.errors[0]).toContain('not found');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('shows docPath with doc ref subcommand', async () => {
|
|
500
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
|
|
501
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
502
|
+
|
|
503
|
+
await cmdPmMilestone(ctx, ['add', 'Phase 1']);
|
|
504
|
+
|
|
505
|
+
(ctx.ui.json as ReturnType<typeof vi.fn>).mockClear();
|
|
506
|
+
await cmdPmMilestone(ctx, ['doc', '1', 'ref']);
|
|
507
|
+
|
|
508
|
+
expect(ctx.ui.json).toHaveBeenCalledTimes(1);
|
|
509
|
+
expect(ctx.ui.json).toHaveBeenCalledWith(
|
|
510
|
+
expect.objectContaining({
|
|
511
|
+
id: '1',
|
|
512
|
+
docPath: expect.stringContaining('1.md'),
|
|
513
|
+
})
|
|
514
|
+
);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('creates milestone with -d shorthand for description', async () => {
|
|
518
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
519
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
520
|
+
|
|
521
|
+
await cmdPmMilestone(ctx, ['add', 'Sprint 1', '-d', 'Two week sprint']);
|
|
522
|
+
|
|
523
|
+
const docPath = path.join(globalDir, 'teams', teamId, 'milestones', '1.md');
|
|
524
|
+
const content = fs.readFileSync(docPath, 'utf-8');
|
|
525
|
+
expect(content).toContain('Sprint 1');
|
|
526
|
+
expect(content).toContain('Two week sprint');
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('prints full doc content including description', async () => {
|
|
530
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
|
|
531
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
532
|
+
|
|
533
|
+
await cmdPmMilestone(ctx, ['add', 'Phase 1', '--description', 'Detailed description here']);
|
|
534
|
+
|
|
535
|
+
(ctx.ui.json as ReturnType<typeof vi.fn>).mockClear();
|
|
536
|
+
await cmdPmMilestone(ctx, ['doc', '1']);
|
|
537
|
+
|
|
538
|
+
expect(ctx.ui.json).toHaveBeenCalledTimes(1);
|
|
539
|
+
expect(ctx.ui.json).toHaveBeenCalledWith(
|
|
540
|
+
expect.objectContaining({
|
|
541
|
+
id: '1',
|
|
542
|
+
doc: expect.stringContaining('Detailed description here'),
|
|
543
|
+
})
|
|
544
|
+
);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('sets milestone documentation with --body flag', async () => {
|
|
548
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
549
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
550
|
+
|
|
551
|
+
await cmdPmMilestone(ctx, ['add', 'Sprint 1']);
|
|
552
|
+
await cmdPmMilestone(ctx, ['doc', '1', '--body', 'New milestone content']);
|
|
553
|
+
expect(ctx.ui.logs.some((l) => l.includes('Saved'))).toBe(true);
|
|
554
|
+
|
|
555
|
+
// Verify the content was saved
|
|
556
|
+
const jsonCtx = createMockContext(globalDir, { cwd: projectDir, json: true });
|
|
557
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
558
|
+
await cmdPmMilestone(jsonCtx, ['doc', '1']);
|
|
559
|
+
|
|
560
|
+
expect(jsonCtx.ui.json).toHaveBeenCalledWith(
|
|
561
|
+
expect.objectContaining({
|
|
562
|
+
doc: 'New milestone content',
|
|
563
|
+
})
|
|
564
|
+
);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('sets milestone documentation with --body-file flag', async () => {
|
|
568
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
569
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
570
|
+
|
|
571
|
+
await cmdPmMilestone(ctx, ['add', 'Sprint 1']);
|
|
572
|
+
|
|
573
|
+
// Create a temp file with content
|
|
574
|
+
const tempFile = path.join(projectDir, 'milestone-doc.md');
|
|
575
|
+
fs.writeFileSync(tempFile, '# Milestone content from file');
|
|
576
|
+
|
|
577
|
+
await cmdPmMilestone(ctx, ['doc', '1', '--body-file', tempFile]);
|
|
578
|
+
expect(ctx.ui.logs.some((l) => l.includes('Saved') && l.includes('from'))).toBe(true);
|
|
579
|
+
|
|
580
|
+
// Verify the content was saved
|
|
581
|
+
const jsonCtx = createMockContext(globalDir, { cwd: projectDir, json: true });
|
|
582
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
583
|
+
await cmdPmMilestone(jsonCtx, ['doc', '1']);
|
|
584
|
+
|
|
585
|
+
expect(jsonCtx.ui.json).toHaveBeenCalledWith(
|
|
586
|
+
expect.objectContaining({
|
|
587
|
+
doc: expect.stringContaining('Milestone content from file'),
|
|
588
|
+
})
|
|
589
|
+
);
|
|
590
|
+
});
|
|
359
591
|
});
|
|
360
592
|
|
|
361
593
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -430,9 +662,35 @@ describe('cmdPmTask', () => {
|
|
|
430
662
|
|
|
431
663
|
await cmdPmTask(ctx, ['add', 'Task 1']);
|
|
432
664
|
await cmdPmTask(ctx, ['add', 'Task 2']);
|
|
665
|
+
|
|
666
|
+
(ctx.ui.table as ReturnType<typeof vi.fn>).mockClear();
|
|
433
667
|
await cmdPmTask(ctx, ['list']);
|
|
434
668
|
|
|
435
|
-
expect(ctx.ui.table).
|
|
669
|
+
expect(ctx.ui.table).toHaveBeenCalledTimes(1);
|
|
670
|
+
expect(ctx.ui.table).toHaveBeenCalledWith(
|
|
671
|
+
['ID', 'TITLE', 'STATUS', 'MILESTONE'],
|
|
672
|
+
expect.arrayContaining([
|
|
673
|
+
expect.arrayContaining(['1', 'Task 1']),
|
|
674
|
+
expect.arrayContaining(['2', 'Task 2']),
|
|
675
|
+
])
|
|
676
|
+
);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it('lists tasks when called without subcommand', async () => {
|
|
680
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
681
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
682
|
+
|
|
683
|
+
await cmdPmTask(ctx, ['add', 'Task 1']);
|
|
684
|
+
|
|
685
|
+
// Clear mock call history after add, then verify empty args triggers list
|
|
686
|
+
(ctx.ui.table as ReturnType<typeof vi.fn>).mockClear();
|
|
687
|
+
await cmdPmTask(ctx, []);
|
|
688
|
+
|
|
689
|
+
expect(ctx.ui.table).toHaveBeenCalledTimes(1);
|
|
690
|
+
expect(ctx.ui.table).toHaveBeenCalledWith(
|
|
691
|
+
['ID', 'TITLE', 'STATUS', 'MILESTONE'],
|
|
692
|
+
expect.arrayContaining([expect.arrayContaining(['1', 'Task 1'])])
|
|
693
|
+
);
|
|
436
694
|
});
|
|
437
695
|
|
|
438
696
|
it('filters task list by status', async () => {
|
|
@@ -464,6 +722,24 @@ describe('cmdPmTask', () => {
|
|
|
464
722
|
expect(lastJson[0].milestone).toBe('1');
|
|
465
723
|
});
|
|
466
724
|
|
|
725
|
+
it('hides tasks without milestone when hideOrphanTasks is enabled', async () => {
|
|
726
|
+
const ctx = createMockContext(globalDir, {
|
|
727
|
+
json: true,
|
|
728
|
+
cwd: projectDir,
|
|
729
|
+
defaults: { hideOrphanTasks: true },
|
|
730
|
+
});
|
|
731
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
732
|
+
|
|
733
|
+
await cmdPmMilestone(ctx, ['add', 'Phase 1']);
|
|
734
|
+
await cmdPmTask(ctx, ['add', 'With milestone', '--milestone', '1']);
|
|
735
|
+
await cmdPmTask(ctx, ['add', 'Without milestone']);
|
|
736
|
+
await cmdPmTask(ctx, ['list']);
|
|
737
|
+
|
|
738
|
+
const tasks = ctx.ui.jsonData[ctx.ui.jsonData.length - 1] as { title: string }[];
|
|
739
|
+
expect(tasks).toHaveLength(1);
|
|
740
|
+
expect(tasks[0].title).toBe('With milestone');
|
|
741
|
+
});
|
|
742
|
+
|
|
467
743
|
it('displays task details', async () => {
|
|
468
744
|
const ctx = createMockContext(globalDir, { json: true, cwd: projectDir });
|
|
469
745
|
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
@@ -530,10 +806,10 @@ describe('cmdPmTask', () => {
|
|
|
530
806
|
});
|
|
531
807
|
|
|
532
808
|
// ─────────────────────────────────────────────────────────────
|
|
533
|
-
//
|
|
809
|
+
// cmdPmTask doc tests
|
|
534
810
|
// ─────────────────────────────────────────────────────────────
|
|
535
811
|
|
|
536
|
-
describe('
|
|
812
|
+
describe('cmdPmTask doc', () => {
|
|
537
813
|
let testDir: string;
|
|
538
814
|
let globalDir: string;
|
|
539
815
|
let projectDir: string;
|
|
@@ -560,23 +836,22 @@ describe('cmdPmDoc', () => {
|
|
|
560
836
|
}
|
|
561
837
|
});
|
|
562
838
|
|
|
563
|
-
it('prints task documentation
|
|
564
|
-
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
839
|
+
it('prints task documentation by default', async () => {
|
|
840
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
|
|
565
841
|
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
566
842
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
const originalLog = console.log;
|
|
570
|
-
console.log = (msg: string) => logs.push(msg);
|
|
571
|
-
|
|
572
|
-
await cmdPmDoc(ctx, ['1', '--print']);
|
|
573
|
-
|
|
574
|
-
console.log = originalLog;
|
|
843
|
+
(ctx.ui.json as ReturnType<typeof vi.fn>).mockClear();
|
|
844
|
+
await cmdPmTask(ctx, ['doc', '1']);
|
|
575
845
|
|
|
576
|
-
expect(
|
|
846
|
+
expect(ctx.ui.json).toHaveBeenCalledWith(
|
|
847
|
+
expect.objectContaining({
|
|
848
|
+
id: '1',
|
|
849
|
+
doc: expect.stringContaining('Test Task'),
|
|
850
|
+
})
|
|
851
|
+
);
|
|
577
852
|
});
|
|
578
853
|
|
|
579
|
-
it('opens documentation in $EDITOR', async () => {
|
|
854
|
+
it('opens documentation in $EDITOR with --edit flag', async () => {
|
|
580
855
|
// This test is tricky because it spawns an editor
|
|
581
856
|
// We'll just verify the command doesn't throw
|
|
582
857
|
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
@@ -587,7 +862,7 @@ describe('cmdPmDoc', () => {
|
|
|
587
862
|
process.env.EDITOR = 'true'; // 'true' command exists and does nothing
|
|
588
863
|
|
|
589
864
|
try {
|
|
590
|
-
await
|
|
865
|
+
await cmdPmTask(ctx, ['doc', '1', '--edit']);
|
|
591
866
|
expect(ctx.ui.logs.some((l) => l.includes('Saved'))).toBe(true);
|
|
592
867
|
} finally {
|
|
593
868
|
if (originalEditor) {
|
|
@@ -602,9 +877,61 @@ describe('cmdPmDoc', () => {
|
|
|
602
877
|
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
603
878
|
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
604
879
|
|
|
605
|
-
await expect(
|
|
880
|
+
await expect(cmdPmTask(ctx, ['doc', '999'])).rejects.toThrow('Exit');
|
|
606
881
|
expect(ctx.ui.errors[0]).toContain('not found');
|
|
607
882
|
});
|
|
883
|
+
|
|
884
|
+
it('sets documentation with --body flag', async () => {
|
|
885
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
886
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
887
|
+
|
|
888
|
+
await cmdPmTask(ctx, ['doc', '1', '--body', 'New content via --body']);
|
|
889
|
+
expect(ctx.ui.logs.some((l) => l.includes('Saved'))).toBe(true);
|
|
890
|
+
|
|
891
|
+
// Verify the content was saved
|
|
892
|
+
const jsonCtx = createMockContext(globalDir, { cwd: projectDir, json: true });
|
|
893
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
894
|
+
await cmdPmTask(jsonCtx, ['doc', '1']);
|
|
895
|
+
|
|
896
|
+
expect(jsonCtx.ui.json).toHaveBeenCalledWith(
|
|
897
|
+
expect.objectContaining({
|
|
898
|
+
doc: 'New content via --body',
|
|
899
|
+
})
|
|
900
|
+
);
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it('sets documentation with --body-file flag', async () => {
|
|
904
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
905
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
906
|
+
|
|
907
|
+
// Create a temp file with content
|
|
908
|
+
const tempFile = path.join(projectDir, 'test-doc.md');
|
|
909
|
+
fs.writeFileSync(tempFile, '# Content from file\n\nThis came from a file.');
|
|
910
|
+
|
|
911
|
+
await cmdPmTask(ctx, ['doc', '1', '--body-file', tempFile]);
|
|
912
|
+
expect(ctx.ui.logs.some((l) => l.includes('Saved') && l.includes('from'))).toBe(true);
|
|
913
|
+
|
|
914
|
+
// Verify the content was saved
|
|
915
|
+
const jsonCtx = createMockContext(globalDir, { cwd: projectDir, json: true });
|
|
916
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
917
|
+
await cmdPmTask(jsonCtx, ['doc', '1']);
|
|
918
|
+
|
|
919
|
+
expect(jsonCtx.ui.json).toHaveBeenCalledWith(
|
|
920
|
+
expect.objectContaining({
|
|
921
|
+
doc: expect.stringContaining('Content from file'),
|
|
922
|
+
})
|
|
923
|
+
);
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it('exits with error for non-existent --body-file', async () => {
|
|
927
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
928
|
+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
929
|
+
|
|
930
|
+
await expect(
|
|
931
|
+
cmdPmTask(ctx, ['doc', '1', '--body-file', '/nonexistent/file.md'])
|
|
932
|
+
).rejects.toThrow('Exit');
|
|
933
|
+
expect(ctx.ui.errors[0]).toContain('File not found');
|
|
934
|
+
});
|
|
608
935
|
});
|
|
609
936
|
|
|
610
937
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -638,23 +965,22 @@ describe('cmdPmLog', () => {
|
|
|
638
965
|
});
|
|
639
966
|
|
|
640
967
|
it('displays audit events', async () => {
|
|
641
|
-
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
968
|
+
const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
|
|
642
969
|
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
643
970
|
|
|
644
971
|
// Create some events
|
|
645
972
|
await cmdPmTask(ctx, ['add', 'Task 1']);
|
|
646
973
|
await cmdPmTask(ctx, ['done', '1']);
|
|
647
974
|
|
|
648
|
-
|
|
649
|
-
const originalLog = console.log;
|
|
650
|
-
console.log = (msg: string) => logs.push(String(msg));
|
|
651
|
-
|
|
975
|
+
(ctx.ui.json as ReturnType<typeof vi.fn>).mockClear();
|
|
652
976
|
await cmdPmLog(ctx, []);
|
|
653
977
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
978
|
+
// cmdPmLog outputs array of events directly
|
|
979
|
+
expect(ctx.ui.json).toHaveBeenCalledWith(
|
|
980
|
+
expect.arrayContaining([
|
|
981
|
+
expect.objectContaining({ event: expect.stringMatching(/team_created|task/) }),
|
|
982
|
+
])
|
|
983
|
+
);
|
|
658
984
|
});
|
|
659
985
|
|
|
660
986
|
it('limits number of events displayed', async () => {
|
|
@@ -746,7 +1072,14 @@ describe('cmdPmList', () => {
|
|
|
746
1072
|
const ctx = createMockContext(globalDir);
|
|
747
1073
|
await cmdPmList(ctx, []);
|
|
748
1074
|
|
|
749
|
-
expect(ctx.ui.table).
|
|
1075
|
+
expect(ctx.ui.table).toHaveBeenCalledTimes(1);
|
|
1076
|
+
expect(ctx.ui.table).toHaveBeenCalledWith(
|
|
1077
|
+
['', 'ID', 'NAME', 'BACKEND', 'CREATED'],
|
|
1078
|
+
expect.arrayContaining([
|
|
1079
|
+
expect.arrayContaining(['Project 1']),
|
|
1080
|
+
expect.arrayContaining(['Project 2']),
|
|
1081
|
+
])
|
|
1082
|
+
);
|
|
750
1083
|
});
|
|
751
1084
|
|
|
752
1085
|
it('shows info message when no teams', async () => {
|
|
@@ -838,15 +1171,14 @@ describe('cmdPm router', () => {
|
|
|
838
1171
|
it('displays help for pm help', async () => {
|
|
839
1172
|
const ctx = createMockContext(globalDir, { cwd: projectDir });
|
|
840
1173
|
|
|
841
|
-
const
|
|
842
|
-
const originalLog = console.log;
|
|
843
|
-
console.log = (msg: string) => logs.push(String(msg));
|
|
844
|
-
|
|
845
|
-
await cmdPm(ctx, ['help']);
|
|
1174
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
846
1175
|
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
1176
|
+
try {
|
|
1177
|
+
await cmdPm(ctx, ['help']);
|
|
1178
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('tmux-team pm'));
|
|
1179
|
+
} finally {
|
|
1180
|
+
logSpy.mockRestore();
|
|
1181
|
+
}
|
|
850
1182
|
});
|
|
851
1183
|
});
|
|
852
1184
|
|
|
@@ -1096,19 +1428,21 @@ describe('Permission integration', () => {
|
|
|
1096
1428
|
|
|
1097
1429
|
const ctx = createMockContext(globalDir, {
|
|
1098
1430
|
cwd: projectDir,
|
|
1431
|
+
json: true,
|
|
1099
1432
|
agents: { codex: { deny: ['pm:doc:update'] } },
|
|
1100
1433
|
});
|
|
1101
1434
|
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
|
|
1102
1435
|
|
|
1103
1436
|
// Read should work
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1437
|
+
(ctx.ui.json as ReturnType<typeof vi.fn>).mockClear();
|
|
1438
|
+
await cmdPmTask(ctx, ['doc', '1']);
|
|
1439
|
+
|
|
1440
|
+
expect(ctx.ui.json).toHaveBeenCalledWith(
|
|
1441
|
+
expect.objectContaining({
|
|
1442
|
+
id: '1',
|
|
1443
|
+
doc: expect.stringContaining('Test task'),
|
|
1444
|
+
})
|
|
1445
|
+
);
|
|
1112
1446
|
});
|
|
1113
1447
|
|
|
1114
1448
|
it('uses TMUX_TEAM_ACTOR when TMT_AGENT_NAME is not set', async () => {
|