llm-switcher 1.2.11 → 1.3.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.
@@ -452,3 +452,168 @@ test('the Windows shim raises the toast only while this tool is routed', (t) =>
452
452
  assert.match(off.out, /ran/);
453
453
  assert.equal(off.asked, '', 'a tool that is off raises no toast');
454
454
  });
455
+
456
+ test('shim status follows the Windows lookup: an .exe earlier on PATH wins over the shim', () => {
457
+ const dirA = fs.mkdtempSync(path.join(STATE, 'real-'));
458
+ const shimDir = fs.mkdtempSync(path.join(STATE, 'shim-'));
459
+ fs.writeFileSync(path.join(dirA, 'agy.exe'), '');
460
+ fs.writeFileSync(path.join(shimDir, 'agy.cmd'), '');
461
+ const opts = { pathEnv: [dirA, shimDir].join(';'), shimDir, platform: 'win32', pathExt: '.COM;.EXE;.BAT;.CMD' };
462
+ const st = shimStatus(['agy'], opts);
463
+ assert.equal(st.shims[0].effective, path.join(dirA, 'agy.exe'));
464
+ assert.equal(st.shims[0].active, false, 'the real agy.exe runs first, so the shim is not active');
465
+ const fixed = shimStatus(['agy'], { ...opts, pathEnv: [shimDir, dirA].join(';') });
466
+ assert.equal(fixed.shims[0].active, true);
467
+ });
468
+
469
+ // ---- agy: the shim proves the gateway before it hands over CLOUD_CODE_URL -----------------------
470
+ // agy sends its Google token in clear text to CLOUD_CODE_URL. A process that took the gateway port
471
+ // must therefore never receive it: the shim asks for the identity proof first.
472
+ const { identityProof } = await import(pathToFileURL(path.join(ROOT, 'state.mjs')).href);
473
+ const AGY_TOKEN = 'a'.repeat(64);
474
+ // Outside STATE: the other tests empty STATE file by file.
475
+ const AGY_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'agyconfig-'));
476
+ const AGY_CONFIG = path.join(AGY_CONFIG_DIR, 'config.json');
477
+ test.after(() => fs.rmSync(AGY_CONFIG_DIR, { recursive: true, force: true }));
478
+ fs.writeFileSync(path.join(path.dirname(AGY_CONFIG), 'admin.token'), AGY_TOKEN);
479
+ const VERIFY = path.join(ROOT, 'verify-gateway.mjs');
480
+ // Async on purpose: the /health listener lives in this process, and a spawnSync would block it.
481
+ const { spawn: spawnAsync } = await import('node:child_process');
482
+ function runAsync(cmd, args, opts) {
483
+ return new Promise((resolve) => {
484
+ const child = spawnAsync(cmd, args, opts);
485
+ let out = '', err = '';
486
+ child.stdout?.on('data', d => { out += d; });
487
+ child.stderr?.on('data', d => { err += d; });
488
+ const timer = setTimeout(() => child.kill(), 30000);
489
+ child.on('close', (status) => { clearTimeout(timer); resolve({ status, stdout: out, stderr: err }); });
490
+ });
491
+ }
492
+ const NOTICE = /did not prove it is this switcher/;
493
+
494
+ // A /health listener: 'real' signs like this gateway, 'forged' sends a wrong proof, 'silent' never answers.
495
+ async function agyListener(kind) {
496
+ const http = await import('node:http');
497
+ const net = await import('node:net');
498
+ const server = kind === 'silent'
499
+ ? net.createServer((sock) => sock.on('error', () => {}))
500
+ : http.createServer((req, res) => {
501
+ const port = server.address().port;
502
+ const nonce = new URL(req.url, 'http://x').searchParams.get('challenge');
503
+ const proof = kind === 'real' ? identityProof(nonce, { role: 'gateway', port, pid: 4242 }, AGY_TOKEN) : 'forged';
504
+ res.setHeader('content-type', 'application/json');
505
+ res.end(JSON.stringify({ status: 'ok', proxy: 'llm-switcher', port, pid: 4242, proof }));
506
+ });
507
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
508
+ return server;
509
+ }
510
+
511
+ async function runAgyWindowsShim(port) {
512
+ const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyshim-'));
513
+ const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyfake-'));
514
+ fs.writeFileSync(path.join(fakeDir, 'agy.cmd'), '@echo off\r\necho CCU=[%CLOUD_CODE_URL%]\r\n');
515
+ for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
516
+ fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
517
+ fs.writeFileSync(path.join(STATE, 'env-agy.cmd'), `SET "CLOUD_CODE_URL=http://127.0.0.1:${port}"\r\n`);
518
+ const shim = path.join(shimDir, 'agy.cmd');
519
+ fs.writeFileSync(shim, renderShim('agy', 'win32'), 'utf8');
520
+ const started = Date.now();
521
+ const child = await runAsync('cmd.exe', ['/d', '/c', shim], {
522
+ env: { ...process.env, LLM_SWITCHER_CONFIG: AGY_CONFIG, PATH: [fakeDir, shimDir, path.join(process.env.SystemRoot, 'System32'), path.dirname(process.execPath)].join(path.delimiter) }
523
+ });
524
+ fs.rmSync(shimDir, { recursive: true, force: true });
525
+ fs.rmSync(fakeDir, { recursive: true, force: true });
526
+ return { out: child.stdout || '', err: child.stderr || '', ms: Date.now() - started };
527
+ }
528
+
529
+ for (const [kind, routed] of [['forged', false], ['silent', false], ['real', true]]) {
530
+ test(`the Windows agy shim with a ${kind} listener on the gateway port ${routed ? 'keeps' : 'drops'} CLOUD_CODE_URL`, async (t) => {
531
+ if (process.platform !== 'win32') return t.skip('cmd.exe only');
532
+ const server = await agyListener(kind);
533
+ t.after(() => server.close());
534
+ const port = server.address().port;
535
+ const { out, err, ms } = await runAgyWindowsShim(port);
536
+ if (routed) {
537
+ assert.match(out, new RegExp(`CCU=\\[http://127\\.0\\.0\\.1:${port}\\]`));
538
+ assert.doesNotMatch(err, NOTICE);
539
+ } else {
540
+ assert.match(out, /CCU=\[\]/, 'the token must not go to a port that did not prove itself');
541
+ assert.match(err, NOTICE);
542
+ }
543
+ assert.ok(ms < 8000, `the shim took ${ms} ms`);
544
+ });
545
+ }
546
+
547
+ for (const [kind, routed] of [['forged', false], ['silent', false], ['real', true]]) {
548
+ test(`the POSIX agy shim with a ${kind} listener on the gateway port ${routed ? 'keeps' : 'drops'} CLOUD_CODE_URL`, async (t) => {
549
+ if (process.platform === 'win32') return t.skip('posix only');
550
+ const server = await agyListener(kind);
551
+ t.after(() => server.close());
552
+ const port = server.address().port;
553
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyposix-'));
554
+ fs.writeFileSync(path.join(dir, 'agy'), '#!/usr/bin/env bash\necho "CCU=[${CLOUD_CODE_URL:-}]"\n');
555
+ fs.chmodSync(path.join(dir, 'agy'), 0o755);
556
+ for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
557
+ fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
558
+ fs.writeFileSync(path.join(STATE, 'env-agy.sh'), `export CLOUD_CODE_URL='http://127.0.0.1:${port}'\n`);
559
+ const started = Date.now();
560
+ const out = await runAsync(path.join(RENDER_DIR, 'agy'), [], {
561
+ env: { ...process.env, LLM_SWITCHER_CONFIG: AGY_CONFIG, PATH: [RENDER_DIR, dir, '/usr/bin:/bin', NODE_DIR].join(path.delimiter) }
562
+ });
563
+ fs.rmSync(dir, { recursive: true, force: true });
564
+ if (routed) {
565
+ assert.match(out.stdout, new RegExp(`CCU=\\[http://127\\.0\\.0\\.1:${port}\\]`));
566
+ assert.doesNotMatch(out.stderr, NOTICE);
567
+ } else {
568
+ assert.match(out.stdout, /CCU=\[\]/);
569
+ assert.match(out.stderr, NOTICE);
570
+ }
571
+ assert.ok(Date.now() - started < 8000, `the shim took ${Date.now() - started} ms`);
572
+ });
573
+ }
574
+
575
+ test('verify-gateway accepts only 127.0.0.1 with a real port, whatever listens', async (t) => {
576
+ const server = await agyListener('real');
577
+ t.after(() => server.close());
578
+ const port = server.address().port;
579
+ const run = async (u) => (await runAsync(process.execPath, [VERIFY, u], { env: { ...process.env, LLM_SWITCHER_CONFIG: AGY_CONFIG } })).status;
580
+ assert.equal(await run(`http://127.0.0.1:${port}`), 0, 'the real gateway passes');
581
+ assert.notEqual(await run('http://127.0.0.1/'), 0, 'no port');
582
+ assert.notEqual(await run(`http://localhost:${port}`), 0, 'localhost can resolve to ::1, which the probe never checked');
583
+ assert.notEqual(await run(`http://other.example:${port}`), 0);
584
+ assert.notEqual(await run('not a url'), 0);
585
+ });
586
+
587
+ // While agy is off, a gateway address left in the shell from an earlier run is the switcher's own
588
+ // value and goes; an address the person set for another host is theirs and stays.
589
+ for (const [value, kept] of [['http://127.0.0.1:3456', false], ['https://cloudcode.corp.example', true]]) {
590
+ test(`the Windows agy shim, with agy off, ${kept ? 'keeps' : 'scrubs'} CLOUD_CODE_URL=${value}`, async (t) => {
591
+ if (process.platform !== 'win32') return t.skip('cmd.exe only');
592
+ const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyoff-'));
593
+ const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyofffake-'));
594
+ t.after(() => { fs.rmSync(shimDir, { recursive: true, force: true }); fs.rmSync(fakeDir, { recursive: true, force: true }); });
595
+ fs.writeFileSync(path.join(fakeDir, 'agy.cmd'), '@echo off\r\necho CCU=[%CLOUD_CODE_URL%]\r\n');
596
+ for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
597
+ const shim = path.join(shimDir, 'agy.cmd');
598
+ fs.writeFileSync(shim, renderShim('agy', 'win32'), 'utf8');
599
+ const out = await runAsync('cmd.exe', ['/d', '/c', shim], {
600
+ env: { ...process.env, CLOUD_CODE_URL: value, LLM_SWITCHER_CONFIG: AGY_CONFIG, PATH: [fakeDir, shimDir, path.join(process.env.SystemRoot, 'System32'), path.dirname(process.execPath)].join(path.delimiter) }
601
+ });
602
+ assert.ok(out.stdout.includes(`CCU=[${kept ? value : ''}]`), out.stdout);
603
+ assert.doesNotMatch(out.stderr, NOTICE, 'no gateway check runs while agy is off');
604
+ });
605
+
606
+ test(`the POSIX agy shim, with agy off, ${kept ? 'keeps' : 'scrubs'} CLOUD_CODE_URL=${value}`, async (t) => {
607
+ if (process.platform === 'win32') return t.skip('posix only');
608
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyoffposix-'));
609
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
610
+ fs.writeFileSync(path.join(dir, 'agy'), '#!/usr/bin/env bash\necho "CCU=[${CLOUD_CODE_URL:-}]"\n');
611
+ fs.chmodSync(path.join(dir, 'agy'), 0o755);
612
+ for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
613
+ const out = await runAsync(path.join(RENDER_DIR, 'agy'), [], {
614
+ env: { ...process.env, CLOUD_CODE_URL: value, LLM_SWITCHER_CONFIG: AGY_CONFIG, PATH: [RENDER_DIR, dir, '/usr/bin:/bin', NODE_DIR].join(path.delimiter) }
615
+ });
616
+ assert.ok(out.stdout.includes(`CCU=[${kept ? value : ''}]`), out.stdout);
617
+ assert.doesNotMatch(out.stderr, NOTICE);
618
+ });
619
+ }
@@ -30,7 +30,7 @@ const makeCfg = () => ({
30
30
  });
31
31
 
32
32
  test('legacy config without activeProfiles maps every target to activeProfile', () => {
33
- assert.deepEqual(getActiveMap(makeCfg()), { claude: 'router', codex: 'router' });
33
+ assert.deepEqual(getActiveMap(makeCfg()), { claude: 'router', codex: 'router', agy: null });
34
34
  });
35
35
 
36
36
  test('setTargetProfile validates the target, existence (no prototype keys) and the tool', () => {
@@ -40,16 +40,16 @@ test('setTargetProfile validates the target, existence (no prototype keys) and t
40
40
  assert.match(setTargetProfile(cfg, 'anthropic', 'codexOnly'), /cannot serve/);
41
41
  assert.equal(setTargetProfile(cfg, 'responses', 'codexOnly'), null);
42
42
  // `anthropic` and `responses` are spellings of the two tools, and the map holds two keys.
43
- assert.deepEqual(cfg.activeProfiles, { claude: 'router', codex: 'codexOnly' });
43
+ assert.deepEqual(cfg.activeProfiles, { claude: 'router', codex: 'codexOnly', agy: null });
44
44
  });
45
45
 
46
46
  test('activateProfile only assigns compatible tools; deactivateProfile leaves the other alone', () => {
47
47
  const cfg = makeCfg();
48
48
  setTargetProfile(cfg, 'claude', null);
49
49
  activateProfile(cfg, 'codexOnly');
50
- assert.deepEqual(cfg.activeProfiles, { claude: null, codex: 'codexOnly' });
50
+ assert.deepEqual(cfg.activeProfiles, { claude: null, codex: 'codexOnly', agy: null });
51
51
  deactivateProfile(cfg, 'codexOnly');
52
- assert.deepEqual(cfg.activeProfiles, { claude: null, codex: null });
52
+ assert.deepEqual(cfg.activeProfiles, { claude: null, codex: null, agy: null });
53
53
  });
54
54
 
55
55
  test('switching a migrated config does not make it need migration again', () => {
@@ -1315,15 +1315,15 @@ test('R1: rollBack proceeds to restore oldPort gateway even if saveConfig throws
1315
1315
  assert.equal(caught, false);
1316
1316
  });
1317
1317
 
1318
- test('R5: getActiveMap returns exactly { claude, codex } and a present pointer wins over the legacy one', () => {
1318
+ test('R5: getActiveMap returns exactly { claude, codex, agy } and a present pointer wins over the legacy one', () => {
1319
1319
  const cfg = {
1320
1320
  activeProfiles: { claude: 'c-prof', codex: 'cdx-prof' },
1321
1321
  profiles: { 'c-prof': { tool: 'claude' }, 'cdx-prof': { tool: 'codex' } }
1322
1322
  };
1323
- assert.deepEqual(getActiveMap(cfg), { claude: 'c-prof', codex: 'cdx-prof' });
1323
+ assert.deepEqual(getActiveMap(cfg), { claude: 'c-prof', codex: 'cdx-prof', agy: null });
1324
1324
  // An explicit "off" is a present pointer, not a missing one: it must not fall back to
1325
1325
  // activeProfile and switch the tool back on under another key.
1326
- assert.deepEqual(getActiveMap({ activeProfile: 'legacy', activeProfiles: { claude: null } }), { claude: null, codex: 'legacy' });
1326
+ assert.deepEqual(getActiveMap({ activeProfile: 'legacy', activeProfiles: { claude: null } }), { claude: null, codex: 'legacy', agy: null });
1327
1327
  });
1328
1328
 
1329
1329
  test('R7e: saves work again after the file is fixed', (t) => {
@@ -1564,7 +1564,7 @@ test('A5: config.example.json loads and passes validation without requiring migr
1564
1564
  }
1565
1565
 
1566
1566
  assert.equal('activeProfile' in example, false, 'the retired top-level pointer is gone');
1567
- assert.deepEqual(Object.keys(example.activeProfiles).sort(), ['claude', 'codex'], 'exactly the two tools');
1567
+ assert.deepEqual(Object.keys(example.activeProfiles).sort(), ['agy', 'claude', 'codex'], 'exactly the three tools');
1568
1568
  assert.deepEqual(example.blindfold, { port: 3457 }, 'the interceptor port is top-level, and it is only a port');
1569
1569
  for (const [key, p] of Object.entries(example.profiles)) {
1570
1570
  for (const retired of ['inFormat', 'blindfold', 'blindfoldPort', 'blindfoldHost', 'blindfoldPrefix']) {
@@ -0,0 +1,197 @@
1
+ // A real gateway, run from a copy of this checkout that tracks a local bare repository. A release
2
+ // pushed there must reach the running gateway through POST /api/update, and through --autoupdate at
3
+ // start. Nothing here reaches the network or the real home directory.
4
+ import { test } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import fs from 'node:fs';
7
+ import http from 'node:http';
8
+ import net from 'node:net';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { spawn, execFileSync } from 'node:child_process';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { launchBrowser, skipReason } from './cdp.mjs';
14
+
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
17
+ const ID = ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', '-c', 'core.autocrlf=false'];
18
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
19
+
20
+ const freePort = () => new Promise(r => {
21
+ const s = net.createServer().listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => r(port)); });
22
+ });
23
+
24
+ function copySource(dest) {
25
+ const files = git(ROOT, 'ls-files', '--cached', '--others', '--exclude-standard').split(/\r?\n/).filter(Boolean);
26
+ for (const f of files) {
27
+ const from = path.join(ROOT, f);
28
+ if (!fs.existsSync(from)) continue;
29
+ fs.mkdirSync(path.dirname(path.join(dest, f)), { recursive: true });
30
+ fs.copyFileSync(from, path.join(dest, f));
31
+ }
32
+ }
33
+
34
+ function bumpVersion(dir, version) {
35
+ const p = path.join(dir, 'package.json');
36
+ fs.writeFileSync(p, JSON.stringify({ ...JSON.parse(fs.readFileSync(p, 'utf8')), version }, null, 2) + '\n');
37
+ }
38
+
39
+ // The npm registry answer only drives the dashboard notice; a git checkout updates from git.
40
+ async function registry(t, version) {
41
+ const srv = http.createServer((req, res) => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ version })); });
42
+ await new Promise(r => srv.listen(0, '127.0.0.1', r));
43
+ t.after(() => srv.close());
44
+ return `http://127.0.0.1:${srv.address().port}/`;
45
+ }
46
+
47
+ async function fixture(t, { registryUrl = 'http://127.0.0.1:9/' } = {}) {
48
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-update-e2e-'));
49
+ const upstream = path.join(base, 'upstream.git');
50
+ const install = path.join(base, 'install');
51
+ const publisher = path.join(base, 'publisher');
52
+ git(base, 'init', '-q', '--bare', '-b', 'main', upstream);
53
+ fs.mkdirSync(install);
54
+ copySource(install);
55
+ git(install, 'init', '-q', '-b', 'main');
56
+ git(install, 'add', '-A');
57
+ git(install, ...ID, 'commit', '-q', '-m', 'installed');
58
+ git(install, 'remote', 'add', 'origin', upstream);
59
+ git(install, 'push', '-q', '-u', 'origin', 'main');
60
+ fs.mkdirSync(publisher);
61
+ git(publisher, 'init', '-q', '-b', 'main');
62
+ git(publisher, 'remote', 'add', 'origin', upstream);
63
+ git(publisher, 'fetch', '-q', 'origin');
64
+ git(publisher, 'checkout', '-q', '-b', 'main', '--track', 'origin/main');
65
+
66
+ const data = path.join(base, 'data');
67
+ const home = path.join(data, 'home');
68
+ fs.mkdirSync(home, { recursive: true });
69
+ const port = await freePort();
70
+ const cfgPath = path.join(data, 'config.json');
71
+ fs.writeFileSync(cfgPath, JSON.stringify({
72
+ port, blindfold: { port: await freePort() }, activeProfiles: { anthropic: null, responses: null, 'openai-chat': null, vertex: null },
73
+ profiles: { plain: { name: 'Plain', mode: 'convert', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'o' } } }
74
+ }, null, 2));
75
+ const env = {
76
+ ...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: path.join(home, '.codex'), CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
77
+ LLM_SWITCHER_CONFIG: cfgPath, LLM_SWITCHER_STATE_DIR: data, LLM_SWITCHER_BLINDFOLD_CERTS: path.join(data, 'certs'),
78
+ LLM_SWITCHER_PORT: '', PORT: '', LLM_SWITCHER_REGISTRY_URL: registryUrl
79
+ };
80
+
81
+ let proc = null;
82
+ let log = '';
83
+ const pid = async () => {
84
+ try { return (await (await fetch(`http://127.0.0.1:${port}/health?challenge=t`)).json()).pid ?? null; } catch { return null; }
85
+ };
86
+ const fx = {
87
+ port,
88
+ log: () => log,
89
+ pid,
90
+ release(version) {
91
+ bumpVersion(publisher, version);
92
+ git(publisher, 'add', '-A');
93
+ git(publisher, ...ID, 'commit', '-q', '-m', `v${version}`);
94
+ git(publisher, 'push', '-q', 'origin', 'main');
95
+ },
96
+ async start(...extra) {
97
+ proc = spawn(process.execPath, [path.join(install, 'proxy.mjs'), '--port', String(port), ...extra], { env, stdio: ['ignore', 'pipe', 'pipe'] });
98
+ proc.stdout.on('data', d => { log += d; });
99
+ proc.stderr.on('data', d => { log += d; });
100
+ return proc;
101
+ },
102
+ async waitFor(check, what) {
103
+ for (let i = 0; i < 120; i++) {
104
+ const v = await check();
105
+ if (v) return v;
106
+ await sleep(250);
107
+ }
108
+ throw new Error(`timed out waiting for ${what}\n${log}`);
109
+ },
110
+ cli: (...args) => new Promise((resolve) => {
111
+ const c = spawn(process.execPath, [path.join(install, 'switch.mjs'), ...args, '--port', String(port)], { env, stdio: ['ignore', 'pipe', 'pipe'] });
112
+ let out = '';
113
+ c.stdout.on('data', d => { out += d; });
114
+ c.stderr.on('data', d => { out += d; });
115
+ c.on('close', code => resolve({ code, out }));
116
+ }),
117
+ dashboardUrl: `http://127.0.0.1:${port}/ui`,
118
+ api: (p, opts = {}) => fetch(`http://127.0.0.1:${port}${p}`, {
119
+ ...opts,
120
+ headers: { 'x-llm-switcher-token': fs.readFileSync(path.join(data, 'admin.token'), 'utf8').trim(), 'Content-Type': 'application/json' }
121
+ })
122
+ };
123
+ t.after(async () => {
124
+ const live = await pid();
125
+ if (live) try { process.kill(live); } catch {}
126
+ proc?.kill();
127
+ await sleep(300);
128
+ fs.rmSync(base, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
129
+ });
130
+ return fx;
131
+ }
132
+
133
+ async function readSse(res) {
134
+ const events = [];
135
+ for (const block of (await res.text()).split('\n\n')) {
136
+ const event = /^event: (.*)$/m.exec(block)?.[1];
137
+ if (event) events.push({ event, data: JSON.parse(/^data: (.*)$/m.exec(block)[1]) });
138
+ }
139
+ return events;
140
+ }
141
+
142
+ test('POST /api/update pulls the release and a new gateway takes over the port', { timeout: 90000 }, async (t) => {
143
+ const fx = await fixture(t);
144
+ const proc = await fx.start();
145
+ const oldPid = await fx.waitFor(fx.pid, 'the gateway');
146
+ assert.equal((await (await fx.api('/api/version')).json()).current, JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version);
147
+
148
+ fx.release('9.9.9');
149
+ const events = await readSse(await fx.api('/api/update', { method: 'POST', body: '{}' }));
150
+ const result = events.find(e => e.event === 'result')?.data;
151
+ assert.equal(result?.updated, true, JSON.stringify(events));
152
+ assert.equal(result.to, '9.9.9');
153
+
154
+ await fx.waitFor(async () => { const p = await fx.pid(); return p && p !== oldPid; }, 'the new gateway');
155
+ assert.equal((await (await fx.api('/api/version')).json()).current, '9.9.9');
156
+ // The first process stays as the parent: a service manager still sees the pid it started.
157
+ assert.equal(proc.exitCode, null);
158
+
159
+ const again = (await readSse(await fx.api('/api/update', { method: 'POST', body: '{}' }))).find(e => e.event === 'result')?.data;
160
+ assert.equal(again?.updated, false);
161
+ assert.match(again.reason, /latest/i);
162
+ });
163
+
164
+ test('--autoupdate starts the newest release', { timeout: 90000 }, async (t) => {
165
+ const fx = await fixture(t);
166
+ fx.release('9.9.10');
167
+ await fx.start('--autoupdate');
168
+ await fx.waitFor(fx.pid, 'the gateway');
169
+ assert.equal((await (await fx.api('/api/version')).json()).current, '9.9.10');
170
+ assert.match(fx.log(), /Pulled 1 commit/);
171
+ });
172
+
173
+ test('switch update asks the running gateway and waits for the new one', { timeout: 90000 }, async (t) => {
174
+ const fx = await fixture(t);
175
+ await fx.start();
176
+ await fx.waitFor(fx.pid, 'the gateway');
177
+ fx.release('9.9.11');
178
+ const r = await fx.cli('update');
179
+ assert.equal(r.code, 0, r.out);
180
+ assert.match(r.out, /runs v9\.9\.11/);
181
+ assert.equal((await (await fx.api('/api/version')).json()).current, '9.9.11');
182
+ });
183
+
184
+ test('the dashboard Update now button installs the release and reloads on the new gateway', { timeout: 90000, skip: skipReason() }, async (t) => {
185
+ const fx = await fixture(t, { registryUrl: await registry(t, '9.9.12') });
186
+ await fx.start();
187
+ await fx.waitFor(fx.pid, 'the gateway');
188
+ fx.release('9.9.12');
189
+ const browser = await launchBrowser();
190
+ t.after(() => browser.close());
191
+ const page = await browser.newPage();
192
+ await page.goto(fx.dashboardUrl);
193
+ await page.waitFor(`!document.getElementById('update-notice').hidden`, 'the update notice');
194
+ await page.click('#update-now');
195
+ await page.waitFor(`document.getElementById('app-version')?.textContent === 'v9.9.12'`, 'the reloaded dashboard on the new version', 30000);
196
+ assert.equal(await page.evaluate(`document.getElementById('update-notice').hidden`), true);
197
+ });
@@ -0,0 +1,145 @@
1
+ // The update itself: a git checkout pulls fast-forward only, an npm install asks the registry.
2
+ // Every remote here is a local bare repository or a local HTTP server.
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import fs from 'node:fs';
6
+ import http from 'node:http';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { execFileSync } from 'node:child_process';
10
+ import { applyUpdate } from '../update.mjs';
11
+
12
+ const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
13
+ const ID = ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false'];
14
+
15
+ function writeVersion(dir, version) {
16
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'llm-switcher', version }) + '\n');
17
+ }
18
+
19
+ function commit(dir, msg) {
20
+ git(dir, 'add', '-A');
21
+ git(dir, ...ID, 'commit', '-q', '-m', msg);
22
+ }
23
+
24
+ // upstream.git <- publisher (pushes releases), and install (the checkout being updated).
25
+ function fixture(t) {
26
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-update-'));
27
+ t.after(() => fs.rmSync(base, { recursive: true, force: true }));
28
+ const upstream = path.join(base, 'upstream.git');
29
+ const publisher = path.join(base, 'publisher');
30
+ const install = path.join(base, 'install');
31
+ git(base, 'init', '-q', '--bare', '-b', 'main', upstream);
32
+ for (const dir of [publisher, install]) {
33
+ fs.mkdirSync(dir);
34
+ git(dir, 'init', '-q', '-b', 'main');
35
+ git(dir, 'remote', 'add', 'origin', upstream);
36
+ }
37
+ writeVersion(publisher, '1.0.0');
38
+ commit(publisher, 'v1.0.0');
39
+ git(publisher, 'push', '-q', '-u', 'origin', 'main');
40
+ git(install, 'fetch', '-q', 'origin');
41
+ git(install, 'checkout', '-q', '-b', 'main', '--track', 'origin/main');
42
+ const release = (version) => {
43
+ writeVersion(publisher, version);
44
+ commit(publisher, `v${version}`);
45
+ git(publisher, 'push', '-q', 'origin', 'main');
46
+ };
47
+ return { install, release, head: () => git(install, 'rev-parse', 'HEAD') };
48
+ }
49
+
50
+ test('a checkout behind its upstream pulls and reports the new version', async (t) => {
51
+ const fx = fixture(t);
52
+ fx.release('1.0.1');
53
+ const r = await applyUpdate({ root: fx.install });
54
+ assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
55
+ assert.equal(JSON.parse(fs.readFileSync(path.join(fx.install, 'package.json'), 'utf8')).version, '1.0.1');
56
+ });
57
+
58
+ test('a checkout level with its upstream does nothing', async (t) => {
59
+ const fx = fixture(t);
60
+ const r = await applyUpdate({ root: fx.install });
61
+ assert.equal(r.updated, false);
62
+ assert.match(r.reason, /latest/i);
63
+ });
64
+
65
+ test('a checkout with local edits is left alone, even when upstream moved', async (t) => {
66
+ const fx = fixture(t);
67
+ fx.release('1.0.1');
68
+ const before = fx.head();
69
+ fs.appendFileSync(path.join(fx.install, 'package.json'), ' ');
70
+ const r = await applyUpdate({ root: fx.install });
71
+ assert.equal(r.updated, false);
72
+ assert.match(r.reason, /local changes/i);
73
+ assert.equal(fx.head(), before);
74
+ });
75
+
76
+ test('a checkout that is only ahead of upstream does nothing', async (t) => {
77
+ const fx = fixture(t);
78
+ fs.writeFileSync(path.join(fx.install, 'local.txt'), 'x');
79
+ commit(fx.install, 'local work');
80
+ const r = await applyUpdate({ root: fx.install });
81
+ assert.equal(r.updated, false);
82
+ });
83
+
84
+ test('a diverged checkout fails and is never merged', async (t) => {
85
+ const fx = fixture(t);
86
+ fs.writeFileSync(path.join(fx.install, 'local.txt'), 'x');
87
+ commit(fx.install, 'local work');
88
+ fx.release('1.0.1');
89
+ const before = fx.head();
90
+ await assert.rejects(applyUpdate({ root: fx.install }), /fast-forward|diverged/i);
91
+ assert.equal(fx.head(), before);
92
+ });
93
+
94
+ async function registry(t, version) {
95
+ const srv = http.createServer((req, res) => {
96
+ res.writeHead(200, { 'Content-Type': 'application/json' });
97
+ res.end(JSON.stringify({ version }));
98
+ });
99
+ await new Promise(r => srv.listen(0, '127.0.0.1', r));
100
+ t.after(() => srv.close());
101
+ return `http://127.0.0.1:${srv.address().port}/`;
102
+ }
103
+
104
+ function npmInstall(t, version) {
105
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-npm-'));
106
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
107
+ writeVersion(root, version);
108
+ return root;
109
+ }
110
+
111
+ test('an npm install on the latest version runs no install', async (t) => {
112
+ const root = npmInstall(t, '1.0.0');
113
+ const calls = [];
114
+ const r = await applyUpdate({ root, registryUrl: await registry(t, '1.0.0'), run: async (...a) => { calls.push(a); return ''; } });
115
+ assert.equal(r.updated, false);
116
+ assert.deepEqual(calls, []);
117
+ });
118
+
119
+ test('an npm install behind the registry installs that exact version globally', async (t) => {
120
+ const root = npmInstall(t, '1.0.0');
121
+ const calls = [];
122
+ const run = async (cmd, args) => { calls.push([cmd, ...args]); writeVersion(root, '1.0.1'); return ''; };
123
+ const r = await applyUpdate({ root, registryUrl: await registry(t, '1.0.1'), run });
124
+ assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
125
+ assert.equal(calls.length, 1);
126
+ assert.deepEqual(calls[0].slice(-3), ['install', '-g', 'llm-switcher@1.0.1']);
127
+ });
128
+
129
+ // launchd and systemd --user start the gateway with a short PATH that has no Homebrew or nvm npm.
130
+ test('npm runs with the directory of the running node first on PATH', async (t) => {
131
+ const root = npmInstall(t, '1.0.0');
132
+ let env = null;
133
+ const run = async (cmd, args, opts) => { env = opts.env; writeVersion(root, '1.0.1'); return ''; };
134
+ await applyUpdate({ root, registryUrl: await registry(t, '1.0.1'), run });
135
+ const keys = Object.keys(env).filter(k => k.toUpperCase() === 'PATH');
136
+ assert.equal(keys.length, 1, `one PATH key, got ${keys.join(', ')}`);
137
+ assert.equal(env[keys[0]].split(path.delimiter)[0], path.dirname(process.execPath));
138
+ });
139
+
140
+ test('an unreachable registry is not an update', async (t) => {
141
+ const root = npmInstall(t, '1.0.0');
142
+ const r = await applyUpdate({ root, registryUrl: 'http://127.0.0.1:9/' });
143
+ assert.equal(r.updated, false);
144
+ assert.match(r.reason, /registry/i);
145
+ });