tlc-claude-code 2.0.1 → 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/.claude/agents/builder.md +144 -0
- package/.claude/agents/planner.md +143 -0
- package/.claude/agents/reviewer.md +160 -0
- package/.claude/commands/tlc/build.md +4 -0
- package/.claude/commands/tlc/deploy.md +194 -2
- package/.claude/commands/tlc/e2e-verify.md +214 -0
- package/.claude/commands/tlc/guard.md +191 -0
- package/.claude/commands/tlc/help.md +32 -0
- package/.claude/commands/tlc/init.md +73 -37
- package/.claude/commands/tlc/llm.md +19 -4
- package/.claude/commands/tlc/preflight.md +134 -0
- package/.claude/commands/tlc/review-plan.md +363 -0
- package/.claude/commands/tlc/review.md +172 -57
- package/.claude/commands/tlc/watchci.md +159 -0
- package/.claude/hooks/tlc-block-tools.sh +41 -0
- package/.claude/hooks/tlc-capture-exchange.sh +50 -0
- package/.claude/hooks/tlc-post-build.sh +38 -0
- package/.claude/hooks/tlc-post-push.sh +22 -0
- package/.claude/hooks/tlc-prompt-guard.sh +69 -0
- package/.claude/hooks/tlc-session-init.sh +123 -0
- package/CLAUDE.md +13 -0
- package/bin/install.js +268 -2
- package/bin/postinstall.js +102 -24
- package/bin/setup-autoupdate.js +206 -0
- package/bin/setup-autoupdate.test.js +124 -0
- package/bin/tlc.js +0 -0
- package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/docker-compose.dev.yml +18 -12
- package/package.json +4 -2
- package/scripts/project-docs.js +1 -1
- package/server/index.js +228 -2
- package/server/lib/capture-bridge.js +242 -0
- package/server/lib/capture-bridge.test.js +363 -0
- package/server/lib/capture-guard.js +140 -0
- package/server/lib/capture-guard.test.js +182 -0
- package/server/lib/command-runner.js +159 -0
- package/server/lib/command-runner.test.js +92 -0
- package/server/lib/cost-tracker.test.js +49 -12
- package/server/lib/deploy/runners/dependency-runner.js +106 -0
- package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
- package/server/lib/deploy/runners/secrets-runner.js +174 -0
- package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
- package/server/lib/deploy/security-gates.js +11 -24
- package/server/lib/deploy/security-gates.test.js +9 -2
- package/server/lib/deploy-engine.js +182 -0
- package/server/lib/deploy-engine.test.js +147 -0
- package/server/lib/docker-api.js +137 -0
- package/server/lib/docker-api.test.js +202 -0
- package/server/lib/docker-client.js +297 -0
- package/server/lib/docker-client.test.js +308 -0
- package/server/lib/input-sanitizer.js +86 -0
- package/server/lib/input-sanitizer.test.js +117 -0
- package/server/lib/launchd-agent.js +225 -0
- package/server/lib/launchd-agent.test.js +185 -0
- package/server/lib/memory-api.js +3 -1
- package/server/lib/memory-api.test.js +3 -5
- package/server/lib/memory-bridge-e2e.test.js +160 -0
- package/server/lib/memory-committer.js +18 -4
- package/server/lib/memory-committer.test.js +21 -0
- package/server/lib/memory-hooks-capture.test.js +69 -4
- package/server/lib/memory-hooks-integration.test.js +98 -0
- package/server/lib/memory-hooks.js +42 -4
- package/server/lib/memory-store-adapter.js +105 -0
- package/server/lib/memory-store-adapter.test.js +141 -0
- package/server/lib/memory-wiring-e2e.test.js +93 -0
- package/server/lib/nginx-config.js +114 -0
- package/server/lib/nginx-config.test.js +82 -0
- package/server/lib/ollama-health.js +91 -0
- package/server/lib/ollama-health.test.js +74 -0
- package/server/lib/orchestration/agent-dispatcher.js +114 -0
- package/server/lib/orchestration/agent-dispatcher.test.js +110 -0
- package/server/lib/orchestration/orchestrator.js +130 -0
- package/server/lib/orchestration/orchestrator.test.js +192 -0
- package/server/lib/orchestration/tmux-manager.js +101 -0
- package/server/lib/orchestration/tmux-manager.test.js +109 -0
- package/server/lib/orchestration/worktree-manager.js +132 -0
- package/server/lib/orchestration/worktree-manager.test.js +129 -0
- package/server/lib/port-guard.js +44 -0
- package/server/lib/port-guard.test.js +65 -0
- package/server/lib/project-scanner.js +37 -2
- package/server/lib/project-scanner.test.js +152 -0
- package/server/lib/remember-command.js +2 -0
- package/server/lib/remember-command.test.js +23 -0
- package/server/lib/review/plan-reviewer.js +260 -0
- package/server/lib/review/plan-reviewer.test.js +269 -0
- package/server/lib/review/review-schemas.js +173 -0
- package/server/lib/review/review-schemas.test.js +152 -0
- package/server/lib/security/crypto-utils.test.js +2 -2
- package/server/lib/semantic-recall.js +1 -1
- package/server/lib/semantic-recall.test.js +17 -0
- package/server/lib/ssh-client.js +184 -0
- package/server/lib/ssh-client.test.js +127 -0
- package/server/lib/vps-api.js +184 -0
- package/server/lib/vps-api.test.js +208 -0
- package/server/lib/vps-bootstrap.js +124 -0
- package/server/lib/vps-bootstrap.test.js +79 -0
- package/server/lib/vps-monitor.js +126 -0
- package/server/lib/vps-monitor.test.js +98 -0
- package/server/lib/workspace-api.js +182 -1
- package/server/lib/workspace-api.test.js +474 -0
- package/server/package-lock.json +737 -0
- package/server/package.json +3 -0
- package/server/setup.sh +271 -271
- package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
- package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
- package/dashboard-web/dist/assets/index-W36XHPC5.js.map +0 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createSession,
|
|
4
|
+
addPane,
|
|
5
|
+
capturePane,
|
|
6
|
+
killSession,
|
|
7
|
+
isAvailable,
|
|
8
|
+
buildLayout,
|
|
9
|
+
} from './tmux-manager.js';
|
|
10
|
+
|
|
11
|
+
describe('tmux-manager', () => {
|
|
12
|
+
let exec;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
exec = vi.fn(() => '');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('isAvailable', () => {
|
|
19
|
+
it('returns true when tmux is installed', () => {
|
|
20
|
+
exec.mockReturnValue('/usr/bin/tmux');
|
|
21
|
+
expect(isAvailable({ exec })).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns false when tmux is missing', () => {
|
|
25
|
+
exec.mockImplementation(() => { throw new Error('not found'); });
|
|
26
|
+
expect(isAvailable({ exec })).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('createSession', () => {
|
|
31
|
+
it('generates correct tmux new-session command', () => {
|
|
32
|
+
exec.mockReturnValue('');
|
|
33
|
+
createSession('tlc-build-42', { exec });
|
|
34
|
+
const cmd = exec.mock.calls[0][0];
|
|
35
|
+
expect(cmd).toContain('tmux new-session');
|
|
36
|
+
expect(cmd).toContain('tlc-build-42');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('sanitizes session names', () => {
|
|
40
|
+
exec.mockReturnValue('');
|
|
41
|
+
createSession('bad session/name!', { exec });
|
|
42
|
+
const cmd = exec.mock.calls[0][0];
|
|
43
|
+
expect(cmd).not.toContain('/');
|
|
44
|
+
expect(cmd).not.toContain('!');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('addPane', () => {
|
|
49
|
+
it('splits correctly for 2 panes (vertical)', () => {
|
|
50
|
+
exec.mockReturnValue('');
|
|
51
|
+
addPane('sess', 'agent-1', 'echo hello', { exec, paneCount: 2 });
|
|
52
|
+
const cmd = exec.mock.calls[0][0];
|
|
53
|
+
expect(cmd).toContain('split-window');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('splits correctly for 4 panes (2x2 grid)', () => {
|
|
57
|
+
exec.mockReturnValue('');
|
|
58
|
+
addPane('sess', 'agent-3', 'echo test', { exec, paneCount: 4, paneIndex: 3 });
|
|
59
|
+
const cmd = exec.mock.calls[0][0];
|
|
60
|
+
expect(cmd).toContain('split-window');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('runs command in the new pane', () => {
|
|
64
|
+
exec.mockReturnValue('');
|
|
65
|
+
addPane('sess', 'agent-1', 'codex exec "do stuff"', { exec, paneCount: 2 });
|
|
66
|
+
const cmds = exec.mock.calls.map(c => c[0]);
|
|
67
|
+
expect(cmds.some(c => c.includes('codex exec'))).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('capturePane', () => {
|
|
72
|
+
it('returns pane content via tmux capture-pane', () => {
|
|
73
|
+
exec.mockReturnValue('Line 1\nLine 2\nLine 3');
|
|
74
|
+
const content = capturePane('sess', 0, { exec });
|
|
75
|
+
expect(content).toContain('Line 1');
|
|
76
|
+
expect(content).toContain('Line 3');
|
|
77
|
+
const cmd = exec.mock.calls[0][0];
|
|
78
|
+
expect(cmd).toContain('capture-pane');
|
|
79
|
+
expect(cmd).toContain(':.'); // pane target uses .N syntax
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('killSession', () => {
|
|
84
|
+
it('runs tmux kill-session', () => {
|
|
85
|
+
exec.mockReturnValue('');
|
|
86
|
+
killSession('tlc-build-42', { exec });
|
|
87
|
+
const cmd = exec.mock.calls[0][0];
|
|
88
|
+
expect(cmd).toContain('kill-session');
|
|
89
|
+
expect(cmd).toContain('tlc-build-42');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('buildLayout', () => {
|
|
94
|
+
it('returns vertical for 2 agents', () => {
|
|
95
|
+
const layout = buildLayout(2);
|
|
96
|
+
expect(layout.type).toBe('vertical');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns grid for 4 agents', () => {
|
|
100
|
+
const layout = buildLayout(4);
|
|
101
|
+
expect(layout.type).toBe('grid');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('returns tiled for >4 agents', () => {
|
|
105
|
+
const layout = buildLayout(6);
|
|
106
|
+
expect(layout.type).toBe('tiled');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree Manager
|
|
3
|
+
* Creates and manages git worktrees for parallel agent work.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sanitize a worktree name: spaces become hyphens, all other non-alphanumeric
|
|
8
|
+
* non-hyphen characters are stripped, then multiple hyphens are collapsed.
|
|
9
|
+
* @param {string} name
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
export function sanitizeName(name) {
|
|
13
|
+
return name
|
|
14
|
+
.replace(/ /g, '-')
|
|
15
|
+
.replace(/[^a-zA-Z0-9-]/g, '')
|
|
16
|
+
.replace(/-{2,}/g, '-');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a git worktree for the given name.
|
|
21
|
+
* @param {string} name - Worktree name (will be sanitized)
|
|
22
|
+
* @param {{ exec: Function, maxConcurrent?: number }} options
|
|
23
|
+
* @returns {{ name: string, branch: string, path: string }}
|
|
24
|
+
*/
|
|
25
|
+
export function createWorktree(name, { exec, maxConcurrent } = {}) {
|
|
26
|
+
const safe = sanitizeName(name);
|
|
27
|
+
const limit = maxConcurrent != null ? maxConcurrent : 4;
|
|
28
|
+
|
|
29
|
+
// Only check the concurrent limit when explicitly requested (maxConcurrent passed)
|
|
30
|
+
if (maxConcurrent != null) {
|
|
31
|
+
const listOutput = exec('git worktree list');
|
|
32
|
+
// Exclude the primary checkout (first line) — only count auxiliary worktrees
|
|
33
|
+
const currentCount = listOutput
|
|
34
|
+
.split('\n')
|
|
35
|
+
.filter((line) => line.trim() !== '').length - 1;
|
|
36
|
+
|
|
37
|
+
if (currentCount >= limit) {
|
|
38
|
+
throw new Error(`Max concurrent worktrees (${limit}) reached`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const branch = `worktree-${safe}`;
|
|
43
|
+
const path = `.claude/worktrees/${safe}`;
|
|
44
|
+
|
|
45
|
+
exec(`git worktree add -b ${branch} ${path}`);
|
|
46
|
+
|
|
47
|
+
return { name: safe, branch, path };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* List active worktrees under .claude/worktrees/.
|
|
52
|
+
* @param {{ exec: Function }} options
|
|
53
|
+
* @returns {Array<{ name: string, branch: string, path: string }>}
|
|
54
|
+
*/
|
|
55
|
+
export function listWorktrees({ exec } = {}) {
|
|
56
|
+
const output = exec('git worktree list');
|
|
57
|
+
return output
|
|
58
|
+
.split('\n')
|
|
59
|
+
.filter((line) => line.includes('.claude/worktrees/'))
|
|
60
|
+
.map((line) => {
|
|
61
|
+
// Format: /repo/.claude/worktrees/task-1 abc1234 [worktree-task-1]
|
|
62
|
+
const parts = line.trim().split(/\s+/);
|
|
63
|
+
const worktreePath = parts[0];
|
|
64
|
+
const branchMatch = line.match(/\[([^\]]+)\]/);
|
|
65
|
+
const branch = branchMatch ? branchMatch[1] : '';
|
|
66
|
+
const namePart = worktreePath.split('.claude/worktrees/')[1];
|
|
67
|
+
return { name: namePart, branch, path: worktreePath };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Merge a worktree branch into targetBranch, then remove the worktree.
|
|
73
|
+
* On conflict, returns { merged: false, conflict: true } without removing.
|
|
74
|
+
* @param {string} name - Worktree name (already sanitized)
|
|
75
|
+
* @param {string} targetBranch
|
|
76
|
+
* @param {{ exec: Function }} options
|
|
77
|
+
* @returns {{ merged: boolean, conflict?: boolean }}
|
|
78
|
+
*/
|
|
79
|
+
export function mergeWorktree(name, targetBranch, { exec } = {}) {
|
|
80
|
+
const safe = sanitizeName(name);
|
|
81
|
+
const branch = `worktree-${safe}`;
|
|
82
|
+
const path = `.claude/worktrees/${safe}`;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
exec(`git checkout ${targetBranch}`);
|
|
86
|
+
exec(`git merge ${branch}`);
|
|
87
|
+
} catch (_err) {
|
|
88
|
+
return { merged: false, conflict: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
exec(`git worktree remove ${path}`);
|
|
92
|
+
exec(`git branch -D ${branch}`);
|
|
93
|
+
|
|
94
|
+
return { merged: true };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Forcefully remove a worktree directory and its tracking branch.
|
|
99
|
+
* @param {string} name - Worktree name (already sanitized)
|
|
100
|
+
* @param {{ exec: Function }} options
|
|
101
|
+
*/
|
|
102
|
+
export function removeWorktree(name, { exec } = {}) {
|
|
103
|
+
const safe = sanitizeName(name);
|
|
104
|
+
const branch = `worktree-${safe}`;
|
|
105
|
+
const path = `.claude/worktrees/${safe}`;
|
|
106
|
+
|
|
107
|
+
exec(`git worktree remove --force ${path}`);
|
|
108
|
+
exec(`git branch -D ${branch}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Remove worktrees whose last commit is older than maxAge milliseconds.
|
|
113
|
+
* @param {{ exec: Function, maxAge?: number }} options
|
|
114
|
+
* @returns {string[]} Names of removed worktrees
|
|
115
|
+
*/
|
|
116
|
+
export function cleanupStale({ exec, maxAge = 2 * 3600_000 } = {}) {
|
|
117
|
+
const worktrees = listWorktrees({ exec });
|
|
118
|
+
const removed = [];
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
|
|
121
|
+
for (const wt of worktrees) {
|
|
122
|
+
const lastCommitStr = exec(`git log -1 --format=%cI ${wt.branch}`).trim();
|
|
123
|
+
const lastCommitTime = new Date(lastCommitStr).getTime();
|
|
124
|
+
|
|
125
|
+
if (now - lastCommitTime > maxAge) {
|
|
126
|
+
removeWorktree(wt.name, { exec });
|
|
127
|
+
removed.push(wt.name);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return removed;
|
|
132
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createWorktree,
|
|
4
|
+
listWorktrees,
|
|
5
|
+
mergeWorktree,
|
|
6
|
+
removeWorktree,
|
|
7
|
+
cleanupStale,
|
|
8
|
+
sanitizeName,
|
|
9
|
+
} from './worktree-manager.js';
|
|
10
|
+
|
|
11
|
+
describe('worktree-manager', () => {
|
|
12
|
+
let exec;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
exec = vi.fn(() => '');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('sanitizeName', () => {
|
|
19
|
+
it('passes through alphanumeric and hyphens', () => {
|
|
20
|
+
expect(sanitizeName('phase-42-task-1')).toBe('phase-42-task-1');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('strips special characters', () => {
|
|
24
|
+
expect(sanitizeName('my task/name@v2!')).toBe('my-tasknamev2');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('collapses multiple hyphens', () => {
|
|
28
|
+
expect(sanitizeName('a--b---c')).toBe('a-b-c');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('createWorktree', () => {
|
|
33
|
+
it('creates directory and branch', () => {
|
|
34
|
+
exec.mockReturnValue('');
|
|
35
|
+
const result = createWorktree('phase-42-task-1', { exec });
|
|
36
|
+
expect(result.name).toBe('phase-42-task-1');
|
|
37
|
+
expect(result.branch).toBe('worktree-phase-42-task-1');
|
|
38
|
+
expect(result.path).toContain('.claude/worktrees/phase-42-task-1');
|
|
39
|
+
expect(exec).toHaveBeenCalled();
|
|
40
|
+
const cmd = exec.mock.calls[0][0];
|
|
41
|
+
expect(cmd).toContain('git worktree add');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('rejects when at max concurrent limit', () => {
|
|
45
|
+
// 5 lines = 1 primary + 4 auxiliary worktrees → at limit of 4
|
|
46
|
+
exec.mockReturnValue('/repo abc [main]\nworktree-a\nworktree-b\nworktree-c\nworktree-d\n');
|
|
47
|
+
expect(() => createWorktree('task-5', { exec, maxConcurrent: 4 }))
|
|
48
|
+
.toThrow(/max/i);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('sanitizes names', () => {
|
|
52
|
+
exec.mockReturnValue('');
|
|
53
|
+
const result = createWorktree('bad name/here!', { exec });
|
|
54
|
+
expect(result.name).toBe('bad-namehere');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('listWorktrees', () => {
|
|
59
|
+
it('returns active worktrees with branch and path', () => {
|
|
60
|
+
exec.mockReturnValue(
|
|
61
|
+
'/repo/.claude/worktrees/task-1 abc1234 [worktree-task-1]\n' +
|
|
62
|
+
'/repo/.claude/worktrees/task-2 def5678 [worktree-task-2]\n'
|
|
63
|
+
);
|
|
64
|
+
const list = listWorktrees({ exec });
|
|
65
|
+
expect(list).toHaveLength(2);
|
|
66
|
+
expect(list[0].name).toBe('task-1');
|
|
67
|
+
expect(list[0].branch).toBe('worktree-task-1');
|
|
68
|
+
expect(list[0].path).toContain('task-1');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns empty array when no worktrees', () => {
|
|
72
|
+
exec.mockReturnValue('/repo abc1234 [main]\n');
|
|
73
|
+
const list = listWorktrees({ exec });
|
|
74
|
+
expect(list).toHaveLength(0);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('mergeWorktree', () => {
|
|
79
|
+
it('merges branch and cleans up', () => {
|
|
80
|
+
exec.mockReturnValue('');
|
|
81
|
+
const result = mergeWorktree('task-1', 'main', { exec });
|
|
82
|
+
expect(result.merged).toBe(true);
|
|
83
|
+
const cmds = exec.mock.calls.map(c => c[0]);
|
|
84
|
+
expect(cmds.some(c => c.includes('git merge'))).toBe(true);
|
|
85
|
+
expect(cmds.some(c => c.includes('git worktree remove'))).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('reports conflict without destroying worktree', () => {
|
|
89
|
+
exec.mockImplementation((cmd) => {
|
|
90
|
+
if (cmd.includes('git merge')) throw new Error('CONFLICT');
|
|
91
|
+
return '';
|
|
92
|
+
});
|
|
93
|
+
const result = mergeWorktree('task-1', 'main', { exec });
|
|
94
|
+
expect(result.merged).toBe(false);
|
|
95
|
+
expect(result.conflict).toBe(true);
|
|
96
|
+
// Should NOT have called worktree remove
|
|
97
|
+
const cmds = exec.mock.calls.map(c => c[0]);
|
|
98
|
+
expect(cmds.some(c => c.includes('git worktree remove'))).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('removeWorktree', () => {
|
|
103
|
+
it('removes both directory and branch', () => {
|
|
104
|
+
exec.mockReturnValue('');
|
|
105
|
+
removeWorktree('task-1', { exec });
|
|
106
|
+
const cmds = exec.mock.calls.map(c => c[0]);
|
|
107
|
+
expect(cmds.some(c => c.includes('git worktree remove'))).toBe(true);
|
|
108
|
+
expect(cmds.some(c => c.includes('git branch -D'))).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('cleanupStale', () => {
|
|
113
|
+
it('removes only worktrees past timeout', () => {
|
|
114
|
+
const oldDate = new Date(Date.now() - 3 * 3600_000).toISOString(); // 3h ago
|
|
115
|
+
const freshDate = new Date(Date.now() - 30 * 60_000).toISOString(); // 30min ago
|
|
116
|
+
exec.mockImplementation((cmd) => {
|
|
117
|
+
if (cmd.includes('worktree list')) {
|
|
118
|
+
return '/repo/.claude/worktrees/old-task abc [worktree-old-task]\n/repo/.claude/worktrees/new-task def [worktree-new-task]\n';
|
|
119
|
+
}
|
|
120
|
+
if (cmd.includes('log') && cmd.includes('old-task')) return oldDate;
|
|
121
|
+
if (cmd.includes('log') && cmd.includes('new-task')) return freshDate;
|
|
122
|
+
return '';
|
|
123
|
+
});
|
|
124
|
+
const removed = cleanupStale({ exec, maxAge: 2 * 3600_000 });
|
|
125
|
+
expect(removed).toContain('old-task');
|
|
126
|
+
expect(removed).not.toContain('new-task');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port guard - checks if a port is available before server startup.
|
|
3
|
+
*
|
|
4
|
+
* Detects port conflicts and reports which process holds the port.
|
|
5
|
+
* Designed for use with launchd ThrottleInterval to prevent restart spam.
|
|
6
|
+
*
|
|
7
|
+
* @module port-guard
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const net = require('net');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check if a port is available.
|
|
14
|
+
*
|
|
15
|
+
* @param {number} port - Port number to check
|
|
16
|
+
* @returns {Promise<{available: boolean, port: number, pid?: number, command?: string}>}
|
|
17
|
+
*/
|
|
18
|
+
async function checkPort(port) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const server = net.createServer();
|
|
21
|
+
|
|
22
|
+
server.once('error', (err) => {
|
|
23
|
+
if (err.code === 'EADDRINUSE') {
|
|
24
|
+
resolve({ available: false, port });
|
|
25
|
+
} else {
|
|
26
|
+
// Unexpected error — treat as unavailable
|
|
27
|
+
resolve({ available: false, port });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
server.once('listening', () => {
|
|
32
|
+
// Port is free — close the test server
|
|
33
|
+
const addr = server.address();
|
|
34
|
+
const actualPort = addr ? addr.port : port;
|
|
35
|
+
server.close(() => {
|
|
36
|
+
resolve({ available: true, port: actualPort });
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
server.listen(port);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { checkPort };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port guard tests - Phase 83 Task 2
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
6
|
+
import net from 'net';
|
|
7
|
+
|
|
8
|
+
import { checkPort } from './port-guard.js';
|
|
9
|
+
|
|
10
|
+
describe('port-guard', () => {
|
|
11
|
+
let tempServer;
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
if (tempServer) {
|
|
15
|
+
tempServer.close();
|
|
16
|
+
tempServer = null;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns available:true when port is free', async () => {
|
|
21
|
+
// Use a high ephemeral port unlikely to be in use
|
|
22
|
+
const result = await checkPort(0);
|
|
23
|
+
expect(result.available).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns available:false when port is occupied', async () => {
|
|
27
|
+
// Occupy a port first
|
|
28
|
+
tempServer = net.createServer();
|
|
29
|
+
await new Promise((resolve, reject) => {
|
|
30
|
+
tempServer.listen(0, resolve);
|
|
31
|
+
tempServer.on('error', reject);
|
|
32
|
+
});
|
|
33
|
+
const port = tempServer.address().port;
|
|
34
|
+
|
|
35
|
+
const result = await checkPort(port);
|
|
36
|
+
expect(result.available).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('includes pid info when port is occupied (best effort)', async () => {
|
|
40
|
+
tempServer = net.createServer();
|
|
41
|
+
await new Promise((resolve, reject) => {
|
|
42
|
+
tempServer.listen(0, resolve);
|
|
43
|
+
tempServer.on('error', reject);
|
|
44
|
+
});
|
|
45
|
+
const port = tempServer.address().port;
|
|
46
|
+
|
|
47
|
+
const result = await checkPort(port);
|
|
48
|
+
expect(result.available).toBe(false);
|
|
49
|
+
// pid is best-effort (may not be available on all platforms)
|
|
50
|
+
expect(result).toHaveProperty('port', port);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('handles EADDRINUSE gracefully', async () => {
|
|
54
|
+
tempServer = net.createServer();
|
|
55
|
+
await new Promise((resolve, reject) => {
|
|
56
|
+
tempServer.listen(0, resolve);
|
|
57
|
+
tempServer.on('error', reject);
|
|
58
|
+
});
|
|
59
|
+
const port = tempServer.address().port;
|
|
60
|
+
|
|
61
|
+
// Should not throw
|
|
62
|
+
const result = await checkPort(port);
|
|
63
|
+
expect(result.available).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -109,9 +109,11 @@ function readProjectMetadata(projectDir) {
|
|
|
109
109
|
const hasTlc = fs.existsSync(path.join(projectDir, '.tlc.json'));
|
|
110
110
|
const hasPlanning = fs.existsSync(path.join(projectDir, '.planning'));
|
|
111
111
|
|
|
112
|
-
// Read name and
|
|
112
|
+
// Read name, version, and workspaces from package.json if present
|
|
113
113
|
let name = path.basename(projectDir);
|
|
114
114
|
let version = null;
|
|
115
|
+
let isMonorepo = false;
|
|
116
|
+
let workspaces = [];
|
|
115
117
|
|
|
116
118
|
const pkgPath = path.join(projectDir, 'package.json');
|
|
117
119
|
if (fs.existsSync(pkgPath)) {
|
|
@@ -123,6 +125,34 @@ function readProjectMetadata(projectDir) {
|
|
|
123
125
|
if (pkg.version) {
|
|
124
126
|
version = pkg.version;
|
|
125
127
|
}
|
|
128
|
+
|
|
129
|
+
// Detect monorepo workspaces (npm array or yarn object format)
|
|
130
|
+
let workspacePatterns = null;
|
|
131
|
+
if (Array.isArray(pkg.workspaces)) {
|
|
132
|
+
workspacePatterns = pkg.workspaces;
|
|
133
|
+
} else if (pkg.workspaces && Array.isArray(pkg.workspaces.packages)) {
|
|
134
|
+
workspacePatterns = pkg.workspaces.packages;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (workspacePatterns) {
|
|
138
|
+
isMonorepo = true;
|
|
139
|
+
// Resolve glob patterns to actual directories
|
|
140
|
+
for (const pattern of workspacePatterns) {
|
|
141
|
+
try {
|
|
142
|
+
const globDir = path.join(projectDir, path.dirname(pattern));
|
|
143
|
+
if (fs.existsSync(globDir)) {
|
|
144
|
+
const entries = fs.readdirSync(globDir, { withFileTypes: true });
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
if (entry.isDirectory()) {
|
|
147
|
+
workspaces.push(path.join(path.dirname(pattern), entry.name));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// Ignore glob resolution errors
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
126
156
|
} catch {
|
|
127
157
|
// Ignore malformed package.json
|
|
128
158
|
}
|
|
@@ -147,6 +177,8 @@ function readProjectMetadata(projectDir) {
|
|
|
147
177
|
phaseName: phaseInfo.phaseName,
|
|
148
178
|
totalPhases: phaseInfo.totalPhases,
|
|
149
179
|
completedPhases: phaseInfo.completedPhases,
|
|
180
|
+
isMonorepo,
|
|
181
|
+
workspaces,
|
|
150
182
|
};
|
|
151
183
|
}
|
|
152
184
|
|
|
@@ -235,9 +267,12 @@ class ProjectScanner {
|
|
|
235
267
|
if (typeof onProgress === 'function') {
|
|
236
268
|
onProgress(projectsByPath.size);
|
|
237
269
|
}
|
|
270
|
+
|
|
271
|
+
// Stop recursion: a project's children are not separate projects
|
|
272
|
+
return;
|
|
238
273
|
}
|
|
239
274
|
|
|
240
|
-
// Recurse into subdirectories
|
|
275
|
+
// Recurse into subdirectories (only for non-project directories)
|
|
241
276
|
let entries;
|
|
242
277
|
try {
|
|
243
278
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|