arena-bridge 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to this project are documented here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/) and the project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 0.6.0
8
+
9
+ ### Added
10
+ - Named browser profiles for the agent: `chrome_launch {"profile":"work"}` keeps its logins between
11
+ runs, `chrome_profiles` lists them, and `arena-bridge browser list|open|close` manages them from
12
+ the command line so the user can log in once.
13
+ - A visible agent window can be sized and placed (`width`, `height`, `x`, `y`) so it stays out of
14
+ the way on the user's screen.
15
+
16
+ ## 0.5.2
17
+
18
+ ### Fixed
19
+ - `arena-bridge update` now replaces the very installation it runs from (the npm prefix is
20
+ detected), survives a tarball install that left a conflicting link, and retries when the
21
+ registry metadata is ahead of the tarball.
22
+
7
23
  ## 0.5.1
8
24
 
9
25
  ### Fixed
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
  <img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square" alt="license">
13
13
  <img src="https://img.shields.io/badge/node-%3E%3D18-green.svg?style=flat-square" alt="node">
14
14
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen.svg?style=flat-square" alt="dependencies">
15
- <img src="https://img.shields.io/badge/tests-92%20passed-brightgreen.svg?style=flat-square" alt="tests">
15
+ <img src="https://img.shields.io/badge/tests-96%20passed-brightgreen.svg?style=flat-square" alt="tests">
16
16
  </p>
17
17
 
18
18
  <p align="center">
@@ -79,7 +79,7 @@ arena-bridge setup --root ~/projects
79
79
  | Файлы | `list_dir` `read_file` `write_file` `edit_file` `delete_path` `search_files` `file_get` `file_put` | работа с файлами в разрешённых папках |
80
80
  | Шелл | `bash` `sysinfo` | команды и сведения о машине |
81
81
  | Процессы | `start_process` `read_process_output` `write_process` `kill_process` | фоновые дев-серверы |
82
- | Chrome | `chrome_launch` `chrome_attach` `chrome_open` `chrome_navigate` `chrome_snapshot` `chrome_click` `chrome_type` `chrome_press` `chrome_screenshot` `chrome_evaluate` `chrome_tabs` `chrome_stop` | браузер через CDP |
82
+ | Chrome | `chrome_launch` `chrome_profiles` `chrome_attach` `chrome_open` `chrome_navigate` `chrome_snapshot` `chrome_click` `chrome_type` `chrome_press` `chrome_screenshot` `chrome_evaluate` `chrome_tabs` `chrome_stop` | браузер через CDP |
83
83
  | Десктоп | `mac_see` `mac_click` `mac_type` `mac_key` `mac_scroll` `mac_move` `mac_drag` `mac_apps` `mac_windows` `mac_app_launch` `mac_app_focus` `mac_app_quit` `mac_permissions` | macOS через Peekaboo |
84
84
 
85
85
  Полный каталог с JSON-схемами: `GET /tools` или `arena-bridge tools`.
@@ -171,6 +171,41 @@ arena-bridge trust --off
171
171
 
172
172
  ---
173
173
 
174
+ ## Отдельные браузерные «кабинеты» агента
175
+
176
+ Агенту можно выдать несколько независимых профилей браузера — например «рабочий», «клиентский»,
177
+ «тестовый». В каждом свои входы, и они переживают перезапуск моста.
178
+
179
+ ```bash
180
+ arena-bridge browser open work # открыть Chrome с профилем «work»
181
+ # вы логинитесь в нужные сайты и закрываете окно — входы сохраняются
182
+ arena-bridge browser list # какие профили есть
183
+ arena-bridge browser close work # закрыть окно этого профиля
184
+ ```
185
+
186
+ Агент открывает тот же профиль через `chrome_launch {"profile":"work"}` — уже с вашими входами.
187
+ Каталог профилей агент тоже видит: `chrome_profiles`.
188
+
189
+ Ваш собственный Chrome (на порту 9222, с вашими сессиями) доступен отдельно — через
190
+ `chrome_attach`; любые действия в нём требуют подтверждения.
191
+
192
+ ---
193
+
194
+ ## Как это не мешает вам
195
+
196
+ | Что беспокоит | Как решается |
197
+ | --- | --- |
198
+ | окно агента занимает экран | по умолчанию агент работает в фоне; видимое окно можно сделать маленьким и в углу: `chrome_launch {"headless":false,"width":900,"height":700,"x":-920,"y":40}` |
199
+ | агент перехватывает фокус и курсор | действия в нативных приложениях идут «в фон» — в нужное окно, без переключения на вас; курсор двигается только по вашей явной просьбе |
200
+ | окно агента на вашем рабочем столе | macOS умеет несколько рабочих столов: окно агента живёт на отдельном столе, вы — на своём |
201
+ | нужен отдельный «второй монитор» | в macOS виртуальный монитор штатно не создаётся; сторонние утилиты (BetterDisplay, DisplayLink) умеют — тогда окно агента всегда там, где вы не работаете |
202
+ | нужен полностью независимый курсор и стол | только отдельная виртуальная машина; на 16 ГБ и Intel 2019 года она заметно тормозит и съедает 30–60 ГБ — как правило, не нужно |
203
+
204
+ Рекомендация для ноутбука с 16 ГБ и Intel 2019: отдельные профили браузера + фоновый режим +
205
+ при необходимости второй рабочий стол macOS. Полноценная виртуальная машина того не стоит.
206
+
207
+ ---
208
+
174
209
  ## Безопасность
175
210
 
176
211
  | Токен | Кто знает | Что позволяет |
@@ -215,6 +250,7 @@ arena-bridge audit -n 50 # что происходило
215
250
  | `version` | установленная и последняя версии |
216
251
  | `update` | обновить пакет с npm (`--check` — только проверить) |
217
252
  | `service` | macOS-фоновый сервис: `install` / `uninstall` / `status` |
253
+ | `browser` | профили браузера агента: `list` / `open <name>` / `close <name>` |
218
254
  | `rotate` | сменить агентский или девайсный токен |
219
255
 
220
256
  ---
@@ -246,8 +282,8 @@ curl -sS -X POST "https://<ваш-url>/call" \
246
282
 
247
283
  ```bash
248
284
  npm install # зависимостей нет — только dev-тесты
249
- npm test # 50 тестов Node: direct, approval, trust, MCP, hub, туннель, Chrome, десктоп
250
- npm run test:python # 42 теста Python: то же самое
285
+ npm test # 52 теста Node: direct, approval, trust, MCP, hub, туннель, Chrome, десктоп
286
+ npm run test:python # 44 теста Python: то же самое
251
287
  ```
252
288
 
253
289
  Тесты не требуют ни Chrome, ни Peekaboo: используются фейковые CDP- и Peekaboo-серверы
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arena-bridge",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Give a cloud AI agent (Arena.ai Agent Mode, Claude, Cursor, ...) controlled access to your own machine over HTTP/MCP: files, shell, Chrome and the macOS desktop, with an approval gate, trust windows and an audit log.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/browser.mjs CHANGED
@@ -33,12 +33,72 @@ const KEYS = {
33
33
 
34
34
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
35
35
 
36
- function findChrome() {
36
+ export function findChrome() {
37
37
  if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) return process.env.CHROME_PATH;
38
38
  for (const c of CHROME_CANDIDATES) if (fs.existsSync(c)) return c;
39
39
  return null;
40
40
  }
41
41
 
42
+ /** Where the named agent profiles live (follows ARENA_BRIDGE_HOME when set). */
43
+ export function profileDir() {
44
+ const base = process.env.ARENA_BRIDGE_HOME || path.join(os.homedir(), '.arena-bridge');
45
+ return path.join(base, 'chrome-profiles');
46
+ }
47
+
48
+ export const sanitizeProfile = (name) =>
49
+ String(name)
50
+ .trim()
51
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
52
+ .replace(/^-+|-+$/g, '')
53
+ .slice(0, 60) || 'default';
54
+
55
+ /** A named profile keeps its logins between agent runs; a path is used as given. */
56
+ export function resolveProfile(raw, port = 9223) {
57
+ if (!raw) return path.join(os.homedir(), '.arena-bridge', `chrome-profile-${port}`);
58
+ const value = String(raw);
59
+ if (value.startsWith('/') || value.startsWith('~')) {
60
+ return path.resolve(value.replace(/^~/, os.homedir()));
61
+ }
62
+ return path.join(profileDir(), sanitizeProfile(value));
63
+ }
64
+
65
+ export function profileName(raw) {
66
+ if (!raw) return null;
67
+ const value = String(raw);
68
+ if (value.startsWith('/') || value.startsWith('~')) return null;
69
+ return sanitizeProfile(value);
70
+ }
71
+
72
+ export function listProfiles() {
73
+ const out = [];
74
+ let names = [];
75
+ try {
76
+ names = fs.readdirSync(profileDir());
77
+ } catch {
78
+ return out;
79
+ }
80
+ for (const name of names) {
81
+ const dir = path.join(profileDir(), name);
82
+ let stat = null;
83
+ try {
84
+ stat = fs.statSync(dir);
85
+ } catch {
86
+ continue;
87
+ }
88
+ if (!stat.isDirectory()) continue;
89
+ let size = 0;
90
+ try {
91
+ size = fs
92
+ .readdirSync(dir)
93
+ .reduce((acc, f) => acc + (fs.statSync(path.join(dir, f)).size || 0), 0);
94
+ } catch {
95
+ /* ignore */
96
+ }
97
+ out.push({ name, path: dir, mtime: stat.mtimeMs, files: fs.readdirSync(dir).length, approx_bytes: size });
98
+ }
99
+ return out.sort((a, b) => b.mtime - a.mtime);
100
+ }
101
+
42
102
  const S = (props, required) => ({
43
103
  type: 'object',
44
104
  properties: props || {},
@@ -140,9 +200,7 @@ export class BrowserManager {
140
200
  }
141
201
  const headless = args.headless !== false;
142
202
  const port = Number(args.port || 9223);
143
- const profile = args.profile
144
- ? path.resolve(String(args.profile).replace(/^~/, os.homedir()))
145
- : path.join(os.homedir(), '.arena-bridge', `chrome-profile-${port}`);
203
+ const profile = resolveProfile(args.profile, port);
146
204
 
147
205
  fs.mkdirSync(profile, { recursive: true });
148
206
 
@@ -157,6 +215,14 @@ export class BrowserManager {
157
215
  '--remote-allow-origins=*',
158
216
  ];
159
217
  if (headless) chromeArgs.push(process.env.ARENA_HEADLESS_FLAG || '--headless=new');
218
+ if (!headless) {
219
+ const width = Number(args.width || 0);
220
+ const height = Number(args.height || 0);
221
+ if (width && height) chromeArgs.push(`--window-size=${width},${height}`);
222
+ if (Number.isFinite(Number(args.x)) && Number.isFinite(Number(args.y))) {
223
+ chromeArgs.push(`--window-position=${Number(args.x)},${Number(args.y)}`);
224
+ }
225
+ }
160
226
  if (args.url) chromeArgs.push(String(args.url));
161
227
 
162
228
  const proc = spawn(bin, chromeArgs, { detached: true, stdio: 'ignore' });
@@ -193,6 +259,7 @@ export class BrowserManager {
193
259
  headless,
194
260
  port,
195
261
  profile,
262
+ profile_name: profileName(args.profile),
196
263
  ws_url: wsUrl,
197
264
  tabs: [...session.tabs.values()].map((t) => ({ tab_id: t.targetId, title: t.title, url: t.url })),
198
265
  note: 'This browser belongs to the agent — no local approval needed for actions on it.',
@@ -307,13 +374,41 @@ export class BrowserManager {
307
374
  headless: { type: 'boolean', description: 'true (default) = no window, false = visible window' },
308
375
  port: { type: 'number', description: 'CDP port (default 9223)' },
309
376
  url: { type: 'string', description: 'URL to open on start' },
310
- profile: { type: 'string', description: 'profile directory (default ~/.arena-bridge/chrome-profile-<port>)' },
377
+ profile: {
378
+ type: 'string',
379
+ description:
380
+ 'named profile (keeps its logins between runs) or a directory path. ' +
381
+ 'Default: a throwaway profile for this port.',
382
+ },
383
+ width: { type: 'number', description: 'window width in pixels (visible window only)' },
384
+ height: { type: 'number', description: 'window height in pixels (visible window only)' },
385
+ x: { type: 'number', description: 'window left edge, e.g. -900 puts it on the left of a wide screen' },
386
+ y: { type: 'number', description: 'window top edge' },
311
387
  },
312
388
  [],
313
389
  ),
314
390
  required: [],
315
391
  handler: (args, ctx) => ctx.browser.launch(args, ctx),
316
392
  },
393
+ {
394
+ name: 'chrome_profiles',
395
+ description:
396
+ 'List the agent\'s own named browser profiles (each keeps its own logins between runs). ' +
397
+ 'Launch one with chrome_launch {"profile":"<name>"}; the user logs into it once with ' +
398
+ '"arena-bridge browser open <name>" on their machine.',
399
+ approval: false,
400
+ schema: S({}, []),
401
+ required: [],
402
+ handler: async () => {
403
+ const profiles = listProfiles();
404
+ return {
405
+ ok: true,
406
+ count: profiles.length,
407
+ profiles,
408
+ hint: 'Ask the user to log in: arena-bridge browser open <name> — then use chrome_launch {"profile":"<name>"}',
409
+ };
410
+ },
411
+ },
317
412
  {
318
413
  name: 'chrome_attach',
319
414
  description:
package/src/cli.mjs CHANGED
@@ -11,8 +11,10 @@ import { buildPrompt } from './prompt.mjs';
11
11
  import { listApprovals, decide } from './approvals.mjs';
12
12
  import { tailAudit } from './audit.mjs';
13
13
  import { startTunnel, isPortOpen } from './tunnel.mjs';
14
+ import { spawn, execSync } from 'node:child_process';
14
15
  import { parseDuration, trustState, saveTrust, clearTrust } from './trust.mjs';
15
16
  import { findPeekaboo } from './mac.mjs';
17
+ import { findChrome, listProfiles, resolveProfile, sanitizeProfile } from './browser.mjs';
16
18
  import { packageInfo, latestVersion, compareVersions, selfUpdate } from './selfupdate.mjs';
17
19
  import { serviceStatus, serviceInstall, serviceUninstall } from './service.mjs';
18
20
 
@@ -78,6 +80,8 @@ Commands:
78
80
  update Update this package from npm (--check to only look)
79
81
  service Run the bridge as a macOS background service
80
82
  install | uninstall | status
83
+ browser The agent's own browser profiles
84
+ list | open <name> | close <name>
81
85
 
82
86
  Common options:
83
87
  --mode direct|hub mode for "init"
@@ -437,6 +441,86 @@ function cmdService(args) {
437
441
  if (!st.installed) console.log(' install it with: arena-bridge service install');
438
442
  }
439
443
 
444
+ function cmdBrowser(args) {
445
+ const what = args._[1] || 'list';
446
+ if (what === 'list') {
447
+ const profiles = listProfiles();
448
+ if (!profiles.length) {
449
+ console.log('no agent browser profiles yet — create one: arena-bridge browser open <name>');
450
+ return;
451
+ }
452
+ for (const p of profiles) {
453
+ console.log(`${p.name.padEnd(20)} ${String(p.files).padStart(3)} files ${new Date(p.mtime).toLocaleString()}`);
454
+ console.log(` ${p.path}`);
455
+ }
456
+ console.log('\nThe agent opens a profile with: chrome_launch {"profile":"<name>"}');
457
+ return;
458
+ }
459
+ if (what === 'open') {
460
+ const name = args._[2];
461
+ if (!name) {
462
+ console.error('usage: arena-bridge browser open <name>');
463
+ process.exit(1);
464
+ }
465
+ const bin = findChrome();
466
+ if (!bin) {
467
+ console.error('✗ Chrome not found in /Applications');
468
+ process.exit(1);
469
+ }
470
+ const profile = resolveProfile(name);
471
+ fs.mkdirSync(profile, { recursive: true });
472
+ const chromeArgs = [
473
+ `--user-data-dir=${profile}`,
474
+ '--no-first-run',
475
+ '--no-default-browser-check',
476
+ '--remote-debugging-port=0',
477
+ ];
478
+ const width = Number(args.flags.width || 0);
479
+ const height = Number(args.flags.height || 0);
480
+ if (width && height) chromeArgs.push(`--window-size=${width},${height}`);
481
+ if (args.flags.x != null && args.flags.y != null) {
482
+ chromeArgs.push(`--window-position=${Number(args.flags.x)},${Number(args.flags.y)}`);
483
+ }
484
+ const proc = spawn(bin, chromeArgs, { detached: true, stdio: 'ignore' });
485
+ proc.unref();
486
+ console.log(`✓ Chrome opened with the profile "${sanitizeProfile(name)}"`);
487
+ console.log(` profile: ${profile}`);
488
+ console.log(' log in to the sites the agent needs and close this window — the logins stay.');
489
+ console.log(` the agent opens the same profile with: chrome_launch {"profile":"${sanitizeProfile(name)}"}`);
490
+ return;
491
+ }
492
+ if (what === 'close') {
493
+ const name = args._[2];
494
+ if (!name) {
495
+ console.error('usage: arena-bridge browser close <name>');
496
+ process.exit(1);
497
+ }
498
+ const profile = resolveProfile(name);
499
+ let out = '';
500
+ try {
501
+ out = execSync('ps -Ao pid,command', { encoding: 'utf8' });
502
+ } catch {
503
+ /* ignore */
504
+ }
505
+ let killed = 0;
506
+ for (const line of out.split('\n')) {
507
+ if (!line.includes(`--user-data-dir=${profile}`)) continue;
508
+ const pid = Number(line.trim().split(/\s+/)[0]);
509
+ if (!pid) continue;
510
+ try {
511
+ process.kill(pid);
512
+ killed++;
513
+ } catch {
514
+ /* ignore */
515
+ }
516
+ }
517
+ console.log(`✓ closed ${killed} Chrome window(s) for "${sanitizeProfile(name)}"`);
518
+ return;
519
+ }
520
+ console.error(`unknown browser action: ${what}\nuse: list | open <name> | close <name>`);
521
+ process.exitCode = 1;
522
+ }
523
+
440
524
  function cmdRotate(args) {
441
525
  const cfg = loadConfig();
442
526
  const what = args.flags.device ? 'device' : 'agent';
@@ -521,6 +605,8 @@ export async function main(argv = process.argv.slice(2)) {
521
605
  return cmdUpdate(args);
522
606
  case 'service':
523
607
  return cmdService(args);
608
+ case 'browser':
609
+ return cmdBrowser(args);
524
610
  case undefined:
525
611
  case 'help':
526
612
  case '--help':
package/src/direct.mjs CHANGED
@@ -29,7 +29,7 @@ export function createDirectServer(cfg, engine) {
29
29
  ok: true,
30
30
  mode: 'direct',
31
31
  device: cfg.device,
32
- version: '0.5.1',
32
+ version: '0.6.0',
33
33
  uptime_sec: Math.round(process.uptime()),
34
34
  });
35
35
  }
package/src/hub.mjs CHANGED
@@ -90,7 +90,7 @@ export function createHubServer(cfg) {
90
90
  return sendJson(res, 200, {
91
91
  ok: true,
92
92
  mode: 'hub',
93
- version: '0.5.1',
93
+ version: '0.6.0',
94
94
  devices: [...devices.keys()],
95
95
  });
96
96
  }
package/src/mcp.mjs CHANGED
@@ -48,7 +48,7 @@ export async function handleMcp(req, res, ctx) {
48
48
  mcpResponse(id, {
49
49
  protocolVersion: PROTOCOL_VERSION,
50
50
  capabilities: { tools: { listChanged: false } },
51
- serverInfo: { name: 'arena-bridge', version: '0.5.1' },
51
+ serverInfo: { name: 'arena-bridge', version: '0.6.0' },
52
52
  instructions:
53
53
  'Tools operate the user\'s own machine. Dangerous operations may return ' +
54
54
  'PROTECTED and require a local human approval.',
package/src/prompt.mjs CHANGED
@@ -69,6 +69,10 @@ ${tools.join('\n')}
69
69
  3. \`chrome_attach\` connects to the user's own Chrome (default port 9222, with their logged-in sessions).
70
70
  Actions on an attached browser require a local approval — ask for it instead of retrying.
71
71
  4. Screenshots: pass \`{"path": "shot.png"}\` to save the PNG inside the authorized roots, then read it with \`file_get\`.
72
+ 5. Named profiles keep their logins between runs: \`chrome_launch {"profile":"work"}\` opens
73
+ \`~/.arena-bridge/chrome-profiles/work\`. \`chrome_profiles\` lists them. The user logs in once with
74
+ \`arena-bridge browser open work\` on their machine — ask for that instead of trying to log in yourself.
75
+ 6. A visible window can be kept small and out of the way: \`chrome_launch {"headless":false,"width":900,"height":700,"x":-920,"y":40}\`.
72
76
 
73
77
  ## Desktop control (macOS, through Peekaboo)
74
78
  1. \`mac_see\` is the entry point: it takes a screenshot of the screen (or of one app window with
@@ -78,6 +82,8 @@ ${tools.join('\n')}
78
82
  4. If a capture or a click fails, run \`mac_permissions\` — macOS needs Screen Recording and
79
83
  Accessibility permissions for the terminal that runs the bridge.
80
84
  5. Screenshots are saved as PNG inside the authorized roots; read them back with \`file_get\`.
85
+ 6. Stay out of the user's way: do not pass \`foreground\`, do not focus windows and do not move the
86
+ pointer unless the user asks for it. Background actions are delivered to the target window only.
81
87
 
82
88
  ## Approvals
83
89
  ${trustLine}
@@ -6,6 +6,21 @@ import { fileURLToPath } from 'node:url';
6
6
  const require = createRequire(import.meta.url);
7
7
  const PKG = 'arena-bridge';
8
8
 
9
+ /** The npm prefix the current installation lives in, so an update replaces this very copy. */
10
+ export function installPrefix() {
11
+ try {
12
+ const here = path.dirname(fileURLToPath(import.meta.url));
13
+ const parts = here.split(path.sep);
14
+ const idx = parts.lastIndexOf('node_modules');
15
+ if (idx < 1) return null;
16
+ let prefix = parts.slice(0, idx).join(path.sep);
17
+ if (prefix.endsWith(`${path.sep}lib`)) prefix = prefix.slice(0, -4);
18
+ return prefix || null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
9
24
  export function packageInfo() {
10
25
  try {
11
26
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -65,7 +80,24 @@ export async function selfUpdate({ dryRun = false } = {}) {
65
80
  if (dryRun) {
66
81
  return { ok: true, upToDate: false, installed: info.version, latest, command: `npm i -g ${PKG}@latest` };
67
82
  }
68
- const res = await run('npm', ['i', '-g', `${PKG}@latest`]);
83
+ const prefix = installPrefix();
84
+ const install = (extra = []) =>
85
+ run('npm', ['i', '-g', ...(prefix ? ['--prefix', prefix] : []), ...extra, `${PKG}@${latest}`]);
86
+
87
+ let res = await run('npm', ['i', '-g', ...(prefix ? ['--prefix', prefix] : []), `${PKG}@latest`]);
88
+ if (!res.ok && /notarget|ETARGET|No matching version/i.test(res.stderr || '')) {
89
+ // the registry metadata can be ahead of the tarball for a moment after a publish
90
+ await new Promise((r) => setTimeout(r, 15000));
91
+ res = await install();
92
+ }
93
+ if (!res.ok && /EEXIST|already exists/i.test(res.stderr || '')) {
94
+ // a previous install from a tarball leaves a conflicting link in the global bin
95
+ res = await run('npm', ['i', '-g', ...(prefix ? ['--prefix', prefix] : []), '--force', `${PKG}@${latest}`]);
96
+ if (!res.ok) {
97
+ await run('npm', ['rm', '-g', ...(prefix ? ['--prefix', prefix] : []), PKG]);
98
+ res = await install();
99
+ }
100
+ }
69
101
  if (!res.ok) {
70
102
  return {
71
103
  ok: false,