tlc-claude-code 2.3.0 → 2.4.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.
@@ -1,88 +1,430 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
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
- describe('generateLaunchdPlist', () => {
15
- it('returns valid XML plist with daily interval', () => {
16
- const plist = generateLaunchdPlist('/path/to/update-script.sh');
17
- expect(plist).toContain('<?xml');
18
- expect(plist).toContain('com.tlc.autoupdate');
19
- expect(plist).toContain('<integer>86400</integer>');
20
- expect(plist).toContain('/path/to/update-script.sh');
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
- describe('generateCronEntry', () => {
25
- it('returns valid crontab line running at 4am daily', () => {
26
- const entry = generateCronEntry('/path/to/update-script.sh');
27
- expect(entry).toMatch(/^0 4 \* \* \*/);
28
- expect(entry).toContain('/path/to/update-script.sh');
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('includes claude update command', () => {
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 codex update command', () => {
82
+ it('includes homebrew detection for each CLI', () => {
39
83
  const script = generateUpdateScript();
40
- expect(script).toContain('npm update -g @openai/codex');
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('creates launchd plist on macOS', () => {
56
- const fs = { writeFileSync: vi.fn(), mkdirSync: vi.fn(), existsSync: vi.fn(() => false), chmodSync: vi.fn() };
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(fs.writeFileSync).toHaveBeenCalled();
171
+ expect(result.plistPath).toContain('com.tlc.autoupdate.plist');
60
172
  });
61
173
 
62
- it('creates cron entry on Linux', () => {
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 = { writeFileSync: vi.fn(), mkdirSync: vi.fn(), existsSync: vi.fn(() => false), chmodSync: vi.fn() };
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 = { unlinkSync: vi.fn(), existsSync: vi.fn(() => true) };
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).toHaveBeenCalled();
334
+ expect(fs.unlinkSync).toHaveBeenCalledWith(
335
+ expect.stringContaining('com.tlc.autoupdate.plist')
336
+ );
76
337
  });
77
338
 
78
- it('removes cron entry on Linux', () => {
79
- const execSync = vi.fn(() => '0 4 * * * /path/to/script\nother job');
80
- const fs = { existsSync: vi.fn(() => false) };
81
- const result = disable({ platform: 'linux', fs, execSync });
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(); // 1h ago
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(); // 25h ago
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tlc-claude-code",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "TLC - Test Led Coding for Claude Code",
5
5
  "bin": {
6
6
  "tlc-claude-code": "./bin/install.js",
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  /**
3
3
  * Project Documentation Maintenance
4
4
  *
@@ -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 };