tlc-claude-code 2.3.0 → 2.4.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/.claude/commands/tlc/autofix.md +31 -0
- package/.claude/commands/tlc/build.md +31 -0
- package/.claude/commands/tlc/coverage.md +31 -0
- package/.claude/commands/tlc/discuss.md +31 -0
- package/.claude/commands/tlc/docs.md +31 -0
- package/.claude/commands/tlc/edge-cases.md +31 -0
- package/.claude/commands/tlc/plan.md +31 -0
- package/.claude/commands/tlc/quick.md +31 -0
- package/.claude/commands/tlc/review.md +31 -0
- package/.claude/hooks/tlc-session-init.sh +14 -3
- package/bin/setup-autoupdate.js +316 -87
- package/bin/setup-autoupdate.test.js +454 -34
- package/package.json +1 -1
- package/scripts/project-docs.js +1 -1
- package/server/lib/cli-dispatcher.js +98 -0
- package/server/lib/cli-dispatcher.test.js +249 -0
- package/server/lib/command-router.js +171 -0
- package/server/lib/command-router.test.js +336 -0
- package/server/lib/prompt-packager.js +98 -0
- package/server/lib/prompt-packager.test.js +185 -0
- package/server/lib/routing-command.js +159 -0
- package/server/lib/routing-command.test.js +290 -0
- package/server/lib/task-router-config.js +146 -0
- package/server/lib/task-router-config.test.js +493 -0
- package/server/setup.sh +271 -271
|
@@ -1,88 +1,430 @@
|
|
|
1
|
-
import { describe, it, expect, vi
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import {
|
|
3
|
-
generateLaunchdPlist,
|
|
4
|
-
generateCronEntry,
|
|
5
3
|
generateUpdateScript,
|
|
4
|
+
generateCronEntry,
|
|
5
|
+
generateLaunchdPlist,
|
|
6
6
|
enable,
|
|
7
7
|
disable,
|
|
8
8
|
writeTimestamp,
|
|
9
9
|
readTimestamp,
|
|
10
10
|
isStale,
|
|
11
|
+
checkForUpdate,
|
|
12
|
+
CLI_PACKAGES,
|
|
13
|
+
TLC_PACKAGE,
|
|
11
14
|
} from './setup-autoupdate.js';
|
|
12
15
|
|
|
13
16
|
describe('setup-autoupdate', () => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
expect(
|
|
20
|
-
|
|
17
|
+
// ───────────────────────────────────────────────────
|
|
18
|
+
// CLI_PACKAGES constant
|
|
19
|
+
// ───────────────────────────────────────────────────
|
|
20
|
+
describe('CLI_PACKAGES', () => {
|
|
21
|
+
it('maps claude to its npm and homebrew package names', () => {
|
|
22
|
+
expect(CLI_PACKAGES.claude).toEqual({
|
|
23
|
+
npm: '@anthropic-ai/claude-code',
|
|
24
|
+
brew: 'claude-code',
|
|
25
|
+
selfUpdate: 'claude update',
|
|
26
|
+
});
|
|
21
27
|
});
|
|
22
|
-
});
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
it('maps codex to its npm and homebrew package names', () => {
|
|
30
|
+
expect(CLI_PACKAGES.codex).toEqual({
|
|
31
|
+
npm: '@openai/codex',
|
|
32
|
+
brew: 'codex',
|
|
33
|
+
selfUpdate: null,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('maps gemini to its npm and homebrew package names', () => {
|
|
38
|
+
expect(CLI_PACKAGES.gemini).toEqual({
|
|
39
|
+
npm: '@google/gemini-cli',
|
|
40
|
+
brew: 'gemini-cli',
|
|
41
|
+
selfUpdate: null,
|
|
42
|
+
});
|
|
29
43
|
});
|
|
30
44
|
});
|
|
31
45
|
|
|
46
|
+
// ───────────────────────────────────────────────────
|
|
47
|
+
// generateUpdateScript — self-contained bash script
|
|
48
|
+
// ───────────────────────────────────────────────────
|
|
32
49
|
describe('generateUpdateScript', () => {
|
|
33
|
-
it('
|
|
50
|
+
it('starts with bash shebang and strict mode', () => {
|
|
51
|
+
const script = generateUpdateScript();
|
|
52
|
+
expect(script).toMatch(/^#!\/usr\/bin\/env bash\n/);
|
|
53
|
+
expect(script).toContain('set -euo pipefail');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('defines a detect_install_method function', () => {
|
|
57
|
+
const script = generateUpdateScript();
|
|
58
|
+
expect(script).toContain('detect_install_method');
|
|
59
|
+
expect(script).toContain('brew list --formula');
|
|
60
|
+
expect(script).toContain('npm list -g');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('defines an update_cli function that dispatches by install method', () => {
|
|
64
|
+
const script = generateUpdateScript();
|
|
65
|
+
expect(script).toContain('update_cli');
|
|
66
|
+
expect(script).toContain('brew upgrade');
|
|
67
|
+
expect(script).toContain('npm update -g');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('includes update blocks for all three CLIs', () => {
|
|
71
|
+
const script = generateUpdateScript();
|
|
72
|
+
expect(script).toContain('claude');
|
|
73
|
+
expect(script).toContain('codex');
|
|
74
|
+
expect(script).toContain('gemini');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('handles claude self-update for standalone installs', () => {
|
|
34
78
|
const script = generateUpdateScript();
|
|
35
79
|
expect(script).toContain('claude update');
|
|
36
80
|
});
|
|
37
81
|
|
|
38
|
-
it('includes
|
|
82
|
+
it('includes homebrew detection for each CLI', () => {
|
|
39
83
|
const script = generateUpdateScript();
|
|
40
|
-
expect(script).toContain('
|
|
84
|
+
expect(script).toContain('claude-code');
|
|
85
|
+
expect(script).toContain('@openai/codex');
|
|
86
|
+
expect(script).toContain('@google/gemini-cli');
|
|
41
87
|
});
|
|
42
88
|
|
|
43
|
-
it('logs to autoupdate.log', () => {
|
|
89
|
+
it('logs to autoupdate.log with timestamps', () => {
|
|
44
90
|
const script = generateUpdateScript();
|
|
45
91
|
expect(script).toContain('autoupdate.log');
|
|
92
|
+
expect(script).toContain('date -u');
|
|
46
93
|
});
|
|
47
94
|
|
|
48
|
-
it('writes timestamp to .last-update', () => {
|
|
95
|
+
it('writes completion timestamp to .last-update', () => {
|
|
49
96
|
const script = generateUpdateScript();
|
|
50
97
|
expect(script).toContain('.last-update');
|
|
51
98
|
});
|
|
99
|
+
|
|
100
|
+
it('continues on individual CLI failure without aborting', () => {
|
|
101
|
+
const script = generateUpdateScript();
|
|
102
|
+
// Each update command should have || error handling, not abort on failure
|
|
103
|
+
const updateLines = script.split('\n').filter(l => l.includes('>> "$LOG_FILE" 2>&1'));
|
|
104
|
+
for (const line of updateLines) {
|
|
105
|
+
expect(line).toContain('||');
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('skips CLIs that are not installed', () => {
|
|
110
|
+
const script = generateUpdateScript();
|
|
111
|
+
// Each CLI block should check command -v before attempting update
|
|
112
|
+
expect(script).toContain('command -v claude');
|
|
113
|
+
expect(script).toContain('command -v codex');
|
|
114
|
+
expect(script).toContain('command -v gemini');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('loads shell profile to find brew and npm in PATH', () => {
|
|
118
|
+
const script = generateUpdateScript();
|
|
119
|
+
// Cron jobs have minimal PATH — script must source profile
|
|
120
|
+
expect(script).toMatch(/source.*profile|\.zshrc|\.bashrc|PATH.*homebrew|\/opt\/homebrew|\/usr\/local/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('sources version managers (nvm, volta, asdf) for PATH', () => {
|
|
124
|
+
const script = generateUpdateScript();
|
|
125
|
+
expect(script).toContain('.nvm/nvm.sh');
|
|
126
|
+
expect(script).toContain('.volta/bin');
|
|
127
|
+
expect(script).toContain('.asdf/shims');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('updates TLC itself via npm', () => {
|
|
131
|
+
const script = generateUpdateScript();
|
|
132
|
+
expect(script).toContain('tlc-claude-code');
|
|
133
|
+
expect(script).toContain('npm update -g');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('checks for TLC update availability and writes notification file', () => {
|
|
137
|
+
const script = generateUpdateScript();
|
|
138
|
+
expect(script).toContain('.update-available');
|
|
139
|
+
expect(script).toContain('npm show tlc-claude-code version');
|
|
140
|
+
});
|
|
52
141
|
});
|
|
53
142
|
|
|
143
|
+
// ───────────────────────────────────────────────────
|
|
144
|
+
// generateCronEntry
|
|
145
|
+
// ───────────────────────────────────────────────────
|
|
146
|
+
describe('generateCronEntry', () => {
|
|
147
|
+
it('returns a valid crontab line running at 4am daily', () => {
|
|
148
|
+
const entry = generateCronEntry('/path/to/script.sh');
|
|
149
|
+
expect(entry).toMatch(/^0 4 \* \* \*/);
|
|
150
|
+
expect(entry).toContain('/path/to/script.sh');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('includes a comment marker for identification', () => {
|
|
154
|
+
const entry = generateCronEntry('/path/to/script.sh');
|
|
155
|
+
expect(entry).toContain('# tlc-autoupdate');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ───────────────────────────────────────────────────
|
|
160
|
+
// enable — launchd on macOS, cron on Linux
|
|
161
|
+
// ───────────────────────────────────────────────────
|
|
54
162
|
describe('enable', () => {
|
|
55
|
-
it('
|
|
56
|
-
const fs = {
|
|
163
|
+
it('uses launchd on macOS', () => {
|
|
164
|
+
const fs = {
|
|
165
|
+
writeFileSync: vi.fn(),
|
|
166
|
+
mkdirSync: vi.fn(),
|
|
167
|
+
chmodSync: vi.fn(),
|
|
168
|
+
};
|
|
57
169
|
const result = enable({ platform: 'darwin', fs });
|
|
58
170
|
expect(result.type).toBe('launchd');
|
|
59
|
-
expect(
|
|
171
|
+
expect(result.plistPath).toContain('com.tlc.autoupdate.plist');
|
|
60
172
|
});
|
|
61
173
|
|
|
62
|
-
it('
|
|
174
|
+
it('writes valid launchd plist with daily interval on macOS', () => {
|
|
175
|
+
const fs = {
|
|
176
|
+
writeFileSync: vi.fn(),
|
|
177
|
+
mkdirSync: vi.fn(),
|
|
178
|
+
chmodSync: vi.fn(),
|
|
179
|
+
};
|
|
180
|
+
enable({ platform: 'darwin', fs });
|
|
181
|
+
|
|
182
|
+
const plistCall = fs.writeFileSync.mock.calls.find(c => c[0].endsWith('.plist'));
|
|
183
|
+
expect(plistCall).toBeDefined();
|
|
184
|
+
expect(plistCall[1]).toContain('<?xml');
|
|
185
|
+
expect(plistCall[1]).toContain('<integer>86400</integer>');
|
|
186
|
+
expect(plistCall[1]).toContain('autoupdate.sh');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('uses cron on Linux', () => {
|
|
63
190
|
const execSync = vi.fn(() => '');
|
|
64
|
-
const fs = {
|
|
191
|
+
const fs = {
|
|
192
|
+
writeFileSync: vi.fn(),
|
|
193
|
+
mkdirSync: vi.fn(),
|
|
194
|
+
chmodSync: vi.fn(),
|
|
195
|
+
};
|
|
65
196
|
const result = enable({ platform: 'linux', fs, execSync });
|
|
66
197
|
expect(result.type).toBe('cron');
|
|
67
198
|
});
|
|
199
|
+
|
|
200
|
+
it('writes the update script and makes it executable', () => {
|
|
201
|
+
const fs = {
|
|
202
|
+
writeFileSync: vi.fn(),
|
|
203
|
+
mkdirSync: vi.fn(),
|
|
204
|
+
chmodSync: vi.fn(),
|
|
205
|
+
};
|
|
206
|
+
enable({ platform: 'darwin', fs });
|
|
207
|
+
|
|
208
|
+
const scriptCall = fs.writeFileSync.mock.calls.find(c => c[0].endsWith('autoupdate.sh'));
|
|
209
|
+
expect(scriptCall).toBeDefined();
|
|
210
|
+
expect(scriptCall[1]).toContain('#!/usr/bin/env bash');
|
|
211
|
+
|
|
212
|
+
expect(fs.chmodSync).toHaveBeenCalledWith(
|
|
213
|
+
expect.stringContaining('autoupdate.sh'),
|
|
214
|
+
0o755
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('creates TLC directories', () => {
|
|
219
|
+
const fs = {
|
|
220
|
+
writeFileSync: vi.fn(),
|
|
221
|
+
mkdirSync: vi.fn(),
|
|
222
|
+
chmodSync: vi.fn(),
|
|
223
|
+
};
|
|
224
|
+
enable({ platform: 'darwin', fs });
|
|
225
|
+
|
|
226
|
+
const dirs = fs.mkdirSync.mock.calls.map(c => c[0]);
|
|
227
|
+
expect(dirs.some(d => d.endsWith('.tlc'))).toBe(true);
|
|
228
|
+
expect(dirs.some(d => d.includes('logs'))).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('appends cron entry to existing crontab on Linux', () => {
|
|
232
|
+
const existingCrontab = '30 2 * * * /usr/bin/backup\n';
|
|
233
|
+
const execSync = vi.fn((cmd) => {
|
|
234
|
+
if (cmd.startsWith('crontab -l')) return existingCrontab;
|
|
235
|
+
return '';
|
|
236
|
+
});
|
|
237
|
+
const fs = {
|
|
238
|
+
writeFileSync: vi.fn(),
|
|
239
|
+
mkdirSync: vi.fn(),
|
|
240
|
+
chmodSync: vi.fn(),
|
|
241
|
+
};
|
|
242
|
+
enable({ platform: 'linux', fs, execSync });
|
|
243
|
+
|
|
244
|
+
// crontab content is piped via { input } option, not in the command string
|
|
245
|
+
const installCall = execSync.mock.calls.find(c =>
|
|
246
|
+
typeof c[0] === 'string' && c[0] === 'crontab -'
|
|
247
|
+
);
|
|
248
|
+
expect(installCall).toBeDefined();
|
|
249
|
+
expect(installCall[1].input).toContain('backup');
|
|
250
|
+
expect(installCall[1].input).toContain('tlc-autoupdate');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('does not duplicate if cron entry already exists', () => {
|
|
254
|
+
const existingCrontab = '0 4 * * * /home/user/.tlc/autoupdate.sh # tlc-autoupdate\n';
|
|
255
|
+
const execSync = vi.fn((cmd) => {
|
|
256
|
+
if (cmd.startsWith('crontab -l')) return existingCrontab;
|
|
257
|
+
return '';
|
|
258
|
+
});
|
|
259
|
+
const fs = {
|
|
260
|
+
writeFileSync: vi.fn(),
|
|
261
|
+
mkdirSync: vi.fn(),
|
|
262
|
+
chmodSync: vi.fn(),
|
|
263
|
+
};
|
|
264
|
+
const result = enable({ platform: 'linux', fs, execSync });
|
|
265
|
+
|
|
266
|
+
expect(result.type).toBe('cron');
|
|
267
|
+
const installCalls = execSync.mock.calls.filter(c =>
|
|
268
|
+
typeof c[0] === 'string' && c[0].includes('crontab') && !c[0].includes('-l')
|
|
269
|
+
);
|
|
270
|
+
expect(installCalls).toHaveLength(0);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('handles empty crontab gracefully on Linux', () => {
|
|
274
|
+
const execSync = vi.fn((cmd) => {
|
|
275
|
+
if (cmd.startsWith('crontab -l')) throw new Error('no crontab for user');
|
|
276
|
+
return '';
|
|
277
|
+
});
|
|
278
|
+
const fs = {
|
|
279
|
+
writeFileSync: vi.fn(),
|
|
280
|
+
mkdirSync: vi.fn(),
|
|
281
|
+
chmodSync: vi.fn(),
|
|
282
|
+
};
|
|
283
|
+
const result = enable({ platform: 'linux', fs, execSync });
|
|
284
|
+
expect(result.type).toBe('cron');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('returns the script path', () => {
|
|
288
|
+
const fs = {
|
|
289
|
+
writeFileSync: vi.fn(),
|
|
290
|
+
mkdirSync: vi.fn(),
|
|
291
|
+
chmodSync: vi.fn(),
|
|
292
|
+
};
|
|
293
|
+
const result = enable({ platform: 'darwin', fs });
|
|
294
|
+
expect(result.scriptPath).toContain('autoupdate.sh');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('replaces legacy unmarked cron entry with marked one on Linux', () => {
|
|
298
|
+
const home = process.env.HOME || '/home/user';
|
|
299
|
+
const legacyCrontab = `30 2 * * * /usr/bin/backup\n0 4 * * * ${home}/.tlc/autoupdate.sh\n`;
|
|
300
|
+
const execSync = vi.fn((cmd) => {
|
|
301
|
+
if (cmd.startsWith('crontab -l')) return legacyCrontab;
|
|
302
|
+
return '';
|
|
303
|
+
});
|
|
304
|
+
const fs = {
|
|
305
|
+
writeFileSync: vi.fn(),
|
|
306
|
+
mkdirSync: vi.fn(),
|
|
307
|
+
chmodSync: vi.fn(),
|
|
308
|
+
};
|
|
309
|
+
enable({ platform: 'linux', fs, execSync });
|
|
310
|
+
|
|
311
|
+
const installCall = execSync.mock.calls.find(c =>
|
|
312
|
+
typeof c[0] === 'string' && c[0] === 'crontab -'
|
|
313
|
+
);
|
|
314
|
+
expect(installCall).toBeDefined();
|
|
315
|
+
const input = installCall[1].input;
|
|
316
|
+
expect(input).toContain('tlc-autoupdate');
|
|
317
|
+
expect(input).toContain('backup');
|
|
318
|
+
const lines = input.split('\n').filter(l => l.includes('autoupdate.sh'));
|
|
319
|
+
expect(lines).toHaveLength(1);
|
|
320
|
+
});
|
|
68
321
|
});
|
|
69
322
|
|
|
323
|
+
// ───────────────────────────────────────────────────
|
|
324
|
+
// disable — launchd on macOS, cron on Linux
|
|
325
|
+
// ───────────────────────────────────────────────────
|
|
70
326
|
describe('disable', () => {
|
|
71
327
|
it('removes launchd plist on macOS', () => {
|
|
72
|
-
const fs = {
|
|
328
|
+
const fs = {
|
|
329
|
+
existsSync: vi.fn(() => true),
|
|
330
|
+
unlinkSync: vi.fn(),
|
|
331
|
+
};
|
|
73
332
|
const result = disable({ platform: 'darwin', fs });
|
|
74
333
|
expect(result.removed).toBe(true);
|
|
75
|
-
expect(fs.unlinkSync).
|
|
334
|
+
expect(fs.unlinkSync).toHaveBeenCalledWith(
|
|
335
|
+
expect.stringContaining('com.tlc.autoupdate.plist')
|
|
336
|
+
);
|
|
76
337
|
});
|
|
77
338
|
|
|
78
|
-
it('
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
339
|
+
it('returns removed: false on macOS when no plist exists', () => {
|
|
340
|
+
const fs = {
|
|
341
|
+
existsSync: vi.fn(() => false),
|
|
342
|
+
};
|
|
343
|
+
const result = disable({ platform: 'darwin', fs });
|
|
344
|
+
expect(result.removed).toBe(false);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('removes the tlc-autoupdate cron entry on Linux', () => {
|
|
348
|
+
const existingCrontab = '30 2 * * * /usr/bin/backup\n0 4 * * * /home/user/.tlc/autoupdate.sh # tlc-autoupdate\n';
|
|
349
|
+
const execSync = vi.fn((cmd) => {
|
|
350
|
+
if (cmd.startsWith('crontab -l')) return existingCrontab;
|
|
351
|
+
return '';
|
|
352
|
+
});
|
|
353
|
+
disable({ platform: 'linux', execSync });
|
|
354
|
+
|
|
355
|
+
const installCall = execSync.mock.calls.find(c =>
|
|
356
|
+
typeof c[0] === 'string' && c[0] === 'crontab -'
|
|
357
|
+
);
|
|
358
|
+
expect(installCall).toBeDefined();
|
|
359
|
+
expect(installCall[1].input).toContain('backup');
|
|
360
|
+
expect(installCall[1].input).not.toContain('tlc-autoupdate');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('returns removed: true when cron entry was found on Linux', () => {
|
|
364
|
+
const existingCrontab = '0 4 * * * /home/user/.tlc/autoupdate.sh # tlc-autoupdate\n';
|
|
365
|
+
const execSync = vi.fn((cmd) => {
|
|
366
|
+
if (cmd.startsWith('crontab -l')) return existingCrontab;
|
|
367
|
+
return '';
|
|
368
|
+
});
|
|
369
|
+
const result = disable({ platform: 'linux', execSync });
|
|
82
370
|
expect(result.removed).toBe(true);
|
|
83
371
|
});
|
|
372
|
+
|
|
373
|
+
it('returns removed: false when no cron entry exists on Linux', () => {
|
|
374
|
+
const execSync = vi.fn((cmd) => {
|
|
375
|
+
if (cmd.startsWith('crontab -l')) return '30 2 * * * /usr/bin/backup\n';
|
|
376
|
+
return '';
|
|
377
|
+
});
|
|
378
|
+
const result = disable({ platform: 'linux', execSync });
|
|
379
|
+
expect(result.removed).toBe(false);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('preserves other cron entries on Linux', () => {
|
|
383
|
+
const existingCrontab = '30 2 * * * /usr/bin/backup\n0 4 * * * /home/user/.tlc/autoupdate.sh # tlc-autoupdate\n15 3 * * 1 /usr/bin/weekly\n';
|
|
384
|
+
const execSync = vi.fn((cmd) => {
|
|
385
|
+
if (cmd.startsWith('crontab -l')) return existingCrontab;
|
|
386
|
+
return '';
|
|
387
|
+
});
|
|
388
|
+
disable({ platform: 'linux', execSync });
|
|
389
|
+
|
|
390
|
+
const installCall = execSync.mock.calls.find(c =>
|
|
391
|
+
typeof c[0] === 'string' && c[0] === 'crontab -'
|
|
392
|
+
);
|
|
393
|
+
expect(installCall[1].input).toContain('backup');
|
|
394
|
+
expect(installCall[1].input).toContain('weekly');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('handles empty crontab gracefully on Linux', () => {
|
|
398
|
+
const execSync = vi.fn((cmd) => {
|
|
399
|
+
if (cmd.startsWith('crontab -l')) throw new Error('no crontab for user');
|
|
400
|
+
return '';
|
|
401
|
+
});
|
|
402
|
+
const result = disable({ platform: 'linux', execSync });
|
|
403
|
+
expect(result.removed).toBe(false);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('removes legacy unmarked cron entries from old versions', () => {
|
|
407
|
+
const home = process.env.HOME || '/home/user';
|
|
408
|
+
const legacyCrontab = `30 2 * * * /usr/bin/backup\n0 4 * * * ${home}/.tlc/autoupdate.sh\n`;
|
|
409
|
+
const execSync = vi.fn((cmd) => {
|
|
410
|
+
if (cmd.startsWith('crontab -l')) return legacyCrontab;
|
|
411
|
+
return '';
|
|
412
|
+
});
|
|
413
|
+
const result = disable({ platform: 'linux', execSync });
|
|
414
|
+
expect(result.removed).toBe(true);
|
|
415
|
+
|
|
416
|
+
const installCall = execSync.mock.calls.find(c =>
|
|
417
|
+
typeof c[0] === 'string' && c[0] === 'crontab -'
|
|
418
|
+
);
|
|
419
|
+
expect(installCall).toBeDefined();
|
|
420
|
+
expect(installCall[1].input).toContain('backup');
|
|
421
|
+
expect(installCall[1].input).not.toContain('autoupdate.sh');
|
|
422
|
+
});
|
|
84
423
|
});
|
|
85
424
|
|
|
425
|
+
// ───────────────────────────────────────────────────
|
|
426
|
+
// writeTimestamp / readTimestamp
|
|
427
|
+
// ───────────────────────────────────────────────────
|
|
86
428
|
describe('writeTimestamp / readTimestamp', () => {
|
|
87
429
|
it('writes ISO timestamp to .last-update', () => {
|
|
88
430
|
const fs = { writeFileSync: vi.fn(), mkdirSync: vi.fn() };
|
|
@@ -106,14 +448,17 @@ describe('setup-autoupdate', () => {
|
|
|
106
448
|
});
|
|
107
449
|
});
|
|
108
450
|
|
|
451
|
+
// ───────────────────────────────────────────────────
|
|
452
|
+
// isStale
|
|
453
|
+
// ───────────────────────────────────────────────────
|
|
109
454
|
describe('isStale', () => {
|
|
110
455
|
it('returns false for fresh timestamp (<24h)', () => {
|
|
111
|
-
const recent = new Date(Date.now() - 3600_000).toISOString();
|
|
456
|
+
const recent = new Date(Date.now() - 3600_000).toISOString();
|
|
112
457
|
expect(isStale(recent)).toBe(false);
|
|
113
458
|
});
|
|
114
459
|
|
|
115
460
|
it('returns true for stale timestamp (>24h)', () => {
|
|
116
|
-
const old = new Date(Date.now() - 90_000_000).toISOString();
|
|
461
|
+
const old = new Date(Date.now() - 90_000_000).toISOString();
|
|
117
462
|
expect(isStale(old)).toBe(true);
|
|
118
463
|
});
|
|
119
464
|
|
|
@@ -121,4 +466,79 @@ describe('setup-autoupdate', () => {
|
|
|
121
466
|
expect(isStale(null)).toBe(true);
|
|
122
467
|
});
|
|
123
468
|
});
|
|
469
|
+
|
|
470
|
+
// ───────────────────────────────────────────────────
|
|
471
|
+
// TLC_PACKAGE constant
|
|
472
|
+
// ───────────────────────────────────────────────────
|
|
473
|
+
describe('TLC_PACKAGE', () => {
|
|
474
|
+
it('has the correct npm package name', () => {
|
|
475
|
+
expect(TLC_PACKAGE).toBe('tlc-claude-code');
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// ───────────────────────────────────────────────────
|
|
480
|
+
// checkForUpdate — version check + notification
|
|
481
|
+
// ───────────────────────────────────────────────────
|
|
482
|
+
describe('checkForUpdate', () => {
|
|
483
|
+
it('returns null when installed version matches latest', () => {
|
|
484
|
+
const execSync = vi.fn(() => '2.3.0');
|
|
485
|
+
const result = checkForUpdate({ execSync, installedVersion: '2.3.0' });
|
|
486
|
+
expect(result).toBeNull();
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('returns update info when a newer version is available', () => {
|
|
490
|
+
const execSync = vi.fn(() => '2.4.0\n');
|
|
491
|
+
const result = checkForUpdate({ execSync, installedVersion: '2.3.0' });
|
|
492
|
+
expect(result).toEqual({
|
|
493
|
+
current: '2.3.0',
|
|
494
|
+
latest: '2.4.0',
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('returns null when npm show fails', () => {
|
|
499
|
+
const execSync = vi.fn(() => { throw new Error('network error'); });
|
|
500
|
+
const result = checkForUpdate({ execSync, installedVersion: '2.3.0' });
|
|
501
|
+
expect(result).toBeNull();
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('returns null when latest is older than installed (pre-release)', () => {
|
|
505
|
+
const execSync = vi.fn(() => '2.2.0');
|
|
506
|
+
const result = checkForUpdate({ execSync, installedVersion: '2.3.0' });
|
|
507
|
+
expect(result).toBeNull();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('writes notification file when update is available', () => {
|
|
511
|
+
const execSync = vi.fn(() => '2.5.0');
|
|
512
|
+
const fs = { writeFileSync: vi.fn(), mkdirSync: vi.fn() };
|
|
513
|
+
checkForUpdate({ execSync, installedVersion: '2.3.0', fs, dir: '/tmp/.tlc' });
|
|
514
|
+
|
|
515
|
+
const writeCall = fs.writeFileSync.mock.calls.find(c => c[0].includes('.update-available'));
|
|
516
|
+
expect(writeCall).toBeDefined();
|
|
517
|
+
expect(writeCall[1]).toContain('2.5.0');
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('removes notification file when up to date', () => {
|
|
521
|
+
const execSync = vi.fn(() => '2.3.0');
|
|
522
|
+
const fs = {
|
|
523
|
+
existsSync: vi.fn(() => true),
|
|
524
|
+
unlinkSync: vi.fn(),
|
|
525
|
+
};
|
|
526
|
+
checkForUpdate({ execSync, installedVersion: '2.3.0', fs, dir: '/tmp/.tlc' });
|
|
527
|
+
|
|
528
|
+
expect(fs.unlinkSync).toHaveBeenCalledWith(
|
|
529
|
+
expect.stringContaining('.update-available')
|
|
530
|
+
);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('formats notification message with versions and command', () => {
|
|
534
|
+
const execSync = vi.fn(() => '3.0.0');
|
|
535
|
+
const fs = { writeFileSync: vi.fn(), mkdirSync: vi.fn() };
|
|
536
|
+
checkForUpdate({ execSync, installedVersion: '2.3.0', fs, dir: '/tmp/.tlc' });
|
|
537
|
+
|
|
538
|
+
const content = fs.writeFileSync.mock.calls.find(c => c[0].includes('.update-available'))[1];
|
|
539
|
+
expect(content).toContain('2.3.0');
|
|
540
|
+
expect(content).toContain('3.0.0');
|
|
541
|
+
expect(content).toContain('npm update -g tlc-claude-code');
|
|
542
|
+
});
|
|
543
|
+
});
|
|
124
544
|
});
|
package/package.json
CHANGED
package/scripts/project-docs.js
CHANGED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Dispatcher
|
|
3
|
+
*
|
|
4
|
+
* Generic dispatcher for shelling out to any CLI-based LLM provider.
|
|
5
|
+
* Spawns the process, pipes the prompt via stdin, captures stdout/stderr,
|
|
6
|
+
* handles timeouts.
|
|
7
|
+
*
|
|
8
|
+
* @module cli-dispatcher
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Dispatch a command to a CLI process.
|
|
13
|
+
* @param {Object} opts - Dispatch options
|
|
14
|
+
* @param {string} opts.command - CLI executable name (e.g., "codex", "gemini")
|
|
15
|
+
* @param {string[]} opts.args - CLI arguments
|
|
16
|
+
* @param {string} opts.prompt - Prompt text to pipe via stdin
|
|
17
|
+
* @param {number} [opts.timeout=120000] - Timeout in ms
|
|
18
|
+
* @param {string} [opts.cwd] - Working directory
|
|
19
|
+
* @param {Function} opts.spawn - Injected child_process.spawn
|
|
20
|
+
* @returns {Promise<{stdout: string, stderr: string, exitCode: number, duration: number}>}
|
|
21
|
+
*/
|
|
22
|
+
function dispatch({ command, args = [], prompt = '', timeout = 120000, cwd, spawn = require('child_process').spawn }) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const start = Date.now();
|
|
25
|
+
|
|
26
|
+
const spawnOpts = {};
|
|
27
|
+
if (cwd) spawnOpts.cwd = cwd;
|
|
28
|
+
|
|
29
|
+
const proc = spawn(command, args, spawnOpts);
|
|
30
|
+
|
|
31
|
+
let stdout = '';
|
|
32
|
+
let stderr = '';
|
|
33
|
+
let settled = false;
|
|
34
|
+
|
|
35
|
+
const finish = (exitCode, stderrOverride) => {
|
|
36
|
+
if (settled) return;
|
|
37
|
+
settled = true;
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
resolve({
|
|
40
|
+
stdout,
|
|
41
|
+
stderr: stderrOverride !== undefined ? stderrOverride : stderr,
|
|
42
|
+
exitCode,
|
|
43
|
+
duration: Date.now() - start,
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
proc.kill();
|
|
49
|
+
finish(-1, 'Process timed out');
|
|
50
|
+
}, timeout);
|
|
51
|
+
|
|
52
|
+
proc.stdout.on('data', (data) => {
|
|
53
|
+
stdout += data.toString();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
proc.stderr.on('data', (data) => {
|
|
57
|
+
stderr += data.toString();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
proc.on('close', (code) => {
|
|
61
|
+
finish(code);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
proc.on('error', (err) => {
|
|
65
|
+
finish(-1, err.message || 'Failed to spawn process');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Suppress broken pipe if child exits before stdin is consumed
|
|
69
|
+
proc.stdin.on('error', () => {});
|
|
70
|
+
|
|
71
|
+
// Write prompt to stdin then close
|
|
72
|
+
if (prompt) {
|
|
73
|
+
proc.stdin.write(prompt);
|
|
74
|
+
}
|
|
75
|
+
proc.stdin.end();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build command and args from a provider config object.
|
|
81
|
+
* @param {Object} provider - Provider config from model_providers
|
|
82
|
+
* @param {string} provider.type - Provider type ("cli", "inline", etc.)
|
|
83
|
+
* @param {string} provider.command - CLI executable name
|
|
84
|
+
* @param {string[]} [provider.flags] - CLI flags
|
|
85
|
+
* @returns {{command: string, args: string[]}|null} Command spec, or null for non-CLI types
|
|
86
|
+
*/
|
|
87
|
+
function buildProviderCommand(provider) {
|
|
88
|
+
if (provider.type !== 'cli') {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
command: provider.command,
|
|
94
|
+
args: provider.flags || [],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { dispatch, buildProviderCommand };
|