groove-dev 0.27.156 → 0.27.159

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/journalist.js +61 -16
  4. package/node_modules/@groove-dev/daemon/src/process.js +130 -2
  5. package/node_modules/@groove-dev/daemon/src/rotator.js +2 -1
  6. package/node_modules/@groove-dev/daemon/src/routes/files.js +28 -6
  7. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +89 -71
  8. package/node_modules/@groove-dev/gui/dist/assets/index-Bij9o_dc.js +1020 -0
  9. package/node_modules/@groove-dev/gui/dist/assets/index-Dzofq3wS.css +1 -0
  10. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  11. package/node_modules/@groove-dev/gui/package.json +1 -2
  12. package/node_modules/@groove-dev/gui/src/app.css +2 -2
  13. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +8 -8
  14. package/node_modules/@groove-dev/gui/src/components/agents/diff-viewer.jsx +2 -2
  15. package/node_modules/@groove-dev/gui/src/components/agents/workspace-mode.jsx +11 -2
  16. package/node_modules/@groove-dev/gui/src/components/dashboard/cache-ring.jsx +2 -2
  17. package/node_modules/@groove-dev/gui/src/components/dashboard/token-chart.jsx +2 -2
  18. package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +1 -1
  19. package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +8 -1
  20. package/node_modules/@groove-dev/gui/src/components/network/activity-chart.jsx +4 -4
  21. package/node_modules/@groove-dev/gui/src/components/network/performance-dashboard.jsx +1 -1
  22. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +18 -6
  23. package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +122 -17
  24. package/node_modules/@groove-dev/gui/src/stores/groove.js +9 -1
  25. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +69 -38
  26. package/node_modules/@groove-dev/gui/src/views/memory.jsx +121 -49
  27. package/package.json +1 -1
  28. package/packages/cli/package.json +1 -1
  29. package/packages/daemon/package.json +1 -1
  30. package/packages/daemon/src/journalist.js +61 -16
  31. package/packages/daemon/src/process.js +130 -2
  32. package/packages/daemon/src/rotator.js +2 -1
  33. package/packages/daemon/src/routes/files.js +28 -6
  34. package/packages/daemon/src/tunnel-manager.js +89 -71
  35. package/packages/gui/dist/assets/index-Bij9o_dc.js +1020 -0
  36. package/packages/gui/dist/assets/index-Dzofq3wS.css +1 -0
  37. package/packages/gui/dist/index.html +2 -2
  38. package/packages/gui/package.json +1 -2
  39. package/packages/gui/src/app.css +2 -2
  40. package/packages/gui/src/components/agents/agent-feed.jsx +8 -8
  41. package/packages/gui/src/components/agents/diff-viewer.jsx +2 -2
  42. package/packages/gui/src/components/agents/workspace-mode.jsx +11 -2
  43. package/packages/gui/src/components/dashboard/cache-ring.jsx +2 -2
  44. package/packages/gui/src/components/dashboard/token-chart.jsx +2 -2
  45. package/packages/gui/src/components/editor/terminal.jsx +1 -1
  46. package/packages/gui/src/components/layout/welcome-splash.jsx +8 -1
  47. package/packages/gui/src/components/network/activity-chart.jsx +4 -4
  48. package/packages/gui/src/components/network/performance-dashboard.jsx +1 -1
  49. package/packages/gui/src/components/settings/quick-connect.jsx +18 -6
  50. package/packages/gui/src/components/settings/ssh-wizard.jsx +122 -17
  51. package/packages/gui/src/stores/groove.js +9 -1
  52. package/packages/gui/src/stores/slices/agents-slice.js +69 -38
  53. package/packages/gui/src/views/memory.jsx +121 -49
  54. package/ssh/error.png +0 -0
  55. package/node_modules/@fontsource-variable/jetbrains-mono/CHANGELOG.md +0 -2
  56. package/node_modules/@fontsource-variable/jetbrains-mono/LICENSE +0 -93
  57. package/node_modules/@fontsource-variable/jetbrains-mono/README.md +0 -48
  58. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-ext-wght-italic.woff2 +0 -0
  59. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-ext-wght-normal.woff2 +0 -0
  60. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-wght-italic.woff2 +0 -0
  61. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-wght-normal.woff2 +0 -0
  62. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-greek-wght-italic.woff2 +0 -0
  63. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-greek-wght-normal.woff2 +0 -0
  64. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-italic.woff2 +0 -0
  65. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-normal.woff2 +0 -0
  66. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-italic.woff2 +0 -0
  67. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  68. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-vietnamese-wght-italic.woff2 +0 -0
  69. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-vietnamese-wght-normal.woff2 +0 -0
  70. package/node_modules/@fontsource-variable/jetbrains-mono/index.css +0 -59
  71. package/node_modules/@fontsource-variable/jetbrains-mono/metadata.json +0 -29
  72. package/node_modules/@fontsource-variable/jetbrains-mono/package.json +0 -47
  73. package/node_modules/@fontsource-variable/jetbrains-mono/scss/metadata.scss +0 -46
  74. package/node_modules/@fontsource-variable/jetbrains-mono/scss/mixins.scss +0 -193
  75. package/node_modules/@fontsource-variable/jetbrains-mono/unicode.json +0 -8
  76. package/node_modules/@fontsource-variable/jetbrains-mono/wght-italic.css +0 -59
  77. package/node_modules/@fontsource-variable/jetbrains-mono/wght.css +0 -59
  78. package/node_modules/@groove-dev/gui/dist/assets/index-COQYX12F.js +0 -1015
  79. package/node_modules/@groove-dev/gui/dist/assets/index-Diw6wDPU.css +0 -1
  80. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  81. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  82. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  83. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  84. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  85. package/packages/gui/dist/assets/index-COQYX12F.js +0 -1015
  86. package/packages/gui/dist/assets/index-Diw6wDPU.css +0 -1
  87. package/packages/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  88. package/packages/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  89. package/packages/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  90. package/packages/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  91. package/packages/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
@@ -37,6 +37,18 @@ function sshCmd(cmd) {
37
37
  return `bash -lc '${nvmProbe}${cmd}'`;
38
38
  }
39
39
 
40
+ function npmGlobalInstall(pkg, user) {
41
+ const base = `npm i -g --prefer-online ${pkg}`;
42
+ if (user === 'root') return base;
43
+ return `${base} || sudo -n ${base}`;
44
+ }
45
+
46
+ function isPermissionError(output) {
47
+ return /EACCES|permission denied|sudo.*password/i.test(output);
48
+ }
49
+
50
+ const PERMISSION_HINT = 'npm global install requires write access. Either install Node via nvm (recommended) or configure passwordless sudo for npm on the remote server.';
51
+
40
52
  export class TunnelManager {
41
53
  constructor(daemon) {
42
54
  this.daemon = daemon;
@@ -213,6 +225,14 @@ export class TunnelManager {
213
225
  const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
214
226
 
215
227
  try {
228
+ const probeCmd = [
229
+ `NV=$(node --version 2>/dev/null || echo "");`,
230
+ `echo "__NODE__${`$\{NV\}`}__NODE_END__";`,
231
+ `S=$(curl -sf http://localhost:${REMOTE_PORT}/api/status 2>/dev/null);`,
232
+ `if [ -n "$S" ]; then echo "__GROOVE_RUNNING__$S__GROOVE_END__";`,
233
+ `else which groove >/dev/null 2>&1 && echo __GROOVE_VER__$(groove --version 2>/dev/null || echo unknown)__GROOVE_STOPPED__ || echo __GROOVE_NOT_INSTALLED__; fi`,
234
+ ].join(' ');
235
+
216
236
  const result = execFileSync('ssh', [
217
237
  ...keyArgs,
218
238
  '-p', String(config.port || 22),
@@ -220,27 +240,32 @@ export class TunnelManager {
220
240
  '-o', 'StrictHostKeyChecking=accept-new',
221
241
  '-o', 'BatchMode=yes',
222
242
  target,
223
- sshCmd(`S=$(curl -sf http://localhost:${REMOTE_PORT}/api/status 2>/dev/null); if [ -n "$S" ]; then echo "__GROOVE_RUNNING__$S__GROOVE_END__"; else which groove >/dev/null 2>&1 && echo __GROOVE_VER__$(groove --version 2>/dev/null || echo unknown)__GROOVE_STOPPED__ || echo __GROOVE_NOT_INSTALLED__; fi`),
243
+ sshCmd(probeCmd),
224
244
  ], {
225
245
  encoding: 'utf8',
226
246
  timeout: 15000,
227
247
  stdio: ['pipe', 'pipe', 'pipe'],
228
248
  });
229
249
 
250
+ const nodeMatch = result.match(/__NODE__(.+?)__NODE_END__/);
251
+ const nodeVersionRaw = nodeMatch ? nodeMatch[1].trim() : '';
252
+ const nodeInstalled = nodeVersionRaw.startsWith('v');
253
+ const nodeVersion = nodeInstalled ? nodeVersionRaw : null;
254
+
230
255
  if (result.includes('__GROOVE_NOT_INSTALLED__')) {
231
- return { reachable: true, daemonRunning: false, grooveInstalled: false };
256
+ return { reachable: true, daemonRunning: false, grooveInstalled: false, nodeInstalled, nodeVersion };
232
257
  }
233
258
  if (result.includes('__GROOVE_STOPPED__')) {
234
259
  const verMatch = result.match(/__GROOVE_VER__(.+?)__GROOVE_STOPPED__/);
235
260
  const remoteVersion = verMatch ? verMatch[1].trim() : null;
236
- return { reachable: true, daemonRunning: false, grooveInstalled: true, remoteVersion };
261
+ return { reachable: true, daemonRunning: false, grooveInstalled: true, remoteVersion, nodeInstalled, nodeVersion };
237
262
  }
238
263
  const runMatch = result.match(/__GROOVE_RUNNING__(.+?)__GROOVE_END__/);
239
264
  let remoteVersion = null;
240
265
  if (runMatch) {
241
266
  try { remoteVersion = JSON.parse(runMatch[1]).version || null; } catch { /* ignore */ }
242
267
  }
243
- return { reachable: true, daemonRunning: true, grooveInstalled: true, remoteVersion };
268
+ return { reachable: true, daemonRunning: true, grooveInstalled: true, remoteVersion, nodeInstalled, nodeVersion };
244
269
  } catch (err) {
245
270
  const stderr = err.stderr?.toString() || '';
246
271
  if (stderr.includes('Permission denied')) {
@@ -259,14 +284,17 @@ export class TunnelManager {
259
284
 
260
285
  if (this.active.has(id)) {
261
286
  const existing = this.active.get(id);
262
- return { localPort: existing.localPort, pid: existing.pid };
287
+ return { localPort: existing.localPort, pid: existing.pid, name: config.name };
263
288
  }
264
289
 
265
290
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'testing' } });
266
291
 
292
+ // For known servers, skip the full test — tunnel first, check version after
267
293
  let testResult;
268
294
  if (opts.skipTest && opts.testResult) {
269
295
  testResult = opts.testResult;
296
+ } else if (config.lastConnected && opts.skipTest !== false) {
297
+ testResult = { reachable: true, daemonRunning: true, grooveInstalled: true, remoteVersion: null };
270
298
  } else {
271
299
  testResult = await this.test(id);
272
300
  }
@@ -274,22 +302,19 @@ export class TunnelManager {
274
302
  throw new Error(testResult.error || 'Host unreachable');
275
303
  }
276
304
 
305
+ // First-time only: install groove if missing, start daemon if not running
277
306
  let preConnectHandled = false;
278
307
  if (!testResult.daemonRunning && !testResult.grooveInstalled) {
279
308
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'installing' } });
280
309
  await this.remoteInstall(id);
281
310
  preConnectHandled = true;
282
311
  } else if (!testResult.daemonRunning && testResult.grooveInstalled) {
283
- const localVer = getLocalVersion();
284
- if (testResult.remoteVersion && testResult.remoteVersion !== localVer) {
285
- this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'upgrading', from: testResult.remoteVersion, to: localVer } });
286
- await this._remoteUpgrade(id, config);
287
- }
288
312
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
289
313
  await this.autoStart(id);
290
314
  preConnectHandled = true;
291
315
  }
292
316
 
317
+ // Establish SSH tunnel
293
318
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
294
319
 
295
320
  const localPort = await this._findAvailablePort();
@@ -345,9 +370,7 @@ export class TunnelManager {
345
370
  failCount: 0,
346
371
  });
347
372
 
348
- // Verify the remote daemon is actually reachable through the tunnel.
349
- // The cached test result (line 270) assumes daemonRunning=true based on
350
- // lastConnected, but the daemon may have stopped since then.
373
+ // Verify daemon is reachable through tunnel, start if needed
351
374
  let remoteAlive = false;
352
375
  try {
353
376
  const probe = await fetch(`http://localhost:${localPort}/api/health`, {
@@ -359,7 +382,6 @@ export class TunnelManager {
359
382
  if (!remoteAlive && config.autoStart) {
360
383
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
361
384
  await this.autoStart(id);
362
- // Give the daemon a moment to accept connections through the tunnel
363
385
  for (let i = 0; i < 5; i++) {
364
386
  await new Promise(r => setTimeout(r, 1000));
365
387
  try {
@@ -373,9 +395,9 @@ export class TunnelManager {
373
395
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'waiting', message: 'Remote daemon not running. Start it manually or enable auto-start.' } });
374
396
  }
375
397
 
376
- const skipUpgrade = remoteAlive && testResult.remoteVersion && testResult.remoteVersion === getLocalVersion();
377
- if (remoteAlive && !preConnectHandled && !skipUpgrade) {
378
- await this._checkAndUpgradeRunning(id, config, localPort);
398
+ // Auto-upgrade: check version through tunnel, upgrade if behind (non-blocking)
399
+ if (remoteAlive && !preConnectHandled) {
400
+ this._checkAndUpgradeRunning(id, config, localPort).catch(() => {});
379
401
  }
380
402
 
381
403
  const remoteVer = testResult?.remoteVersion || null;
@@ -428,98 +450,93 @@ export class TunnelManager {
428
450
  }
429
451
 
430
452
  async _checkAndUpgradeRunning(id, config, localPort) {
431
- const localVer = getLocalVersion();
432
- if (localVer === '0.0.0') return;
433
-
434
453
  try {
454
+ // Get remote daemon version
435
455
  const resp = await fetch(`http://localhost:${localPort}/api/status`, {
436
456
  signal: AbortSignal.timeout(5000),
437
457
  });
438
458
  if (!resp.ok) return;
439
459
  const status = await resp.json();
440
- const oldVersion = status.version;
441
- if (!oldVersion || oldVersion === localVer) return;
442
-
443
- this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'upgrading', from: oldVersion, to: localVer } });
460
+ const remoteVer = status.version;
461
+ if (!remoteVer) return;
444
462
 
463
+ // Check latest version on npm (from the remote server)
445
464
  const target = `${config.user}@${config.host}`;
446
465
  const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
447
466
  const sshBase = [...keyArgs, '-p', String(config.port || 22), '-o', 'ConnectTimeout=10', '-o', 'BatchMode=yes', target];
448
- const pinnedPkg = `groove-dev@${localVer}`;
449
- const installCmd = config.user === 'root' ? `npm i -g --prefer-online ${pinnedPkg}` : `sudo npm i -g --prefer-online ${pinnedPkg}`;
450
467
 
468
+ let npmVer;
451
469
  try {
452
- execFileSync('ssh', [...sshBase, sshCmd(installCmd)], {
453
- encoding: 'utf8',
454
- timeout: 120000,
455
- stdio: ['pipe', 'pipe', 'pipe'],
456
- });
457
- } catch {
458
- const fallbackPkg = 'groove-dev';
459
- const fallbackCmd = config.user === 'root' ? `npm i -g --prefer-online ${fallbackPkg}` : `sudo npm i -g --prefer-online ${fallbackPkg}`;
460
- execFileSync('ssh', [...sshBase, sshCmd(fallbackCmd)], {
461
- encoding: 'utf8',
462
- timeout: 120000,
463
- stdio: ['pipe', 'pipe', 'pipe'],
464
- });
470
+ npmVer = execFileSync('ssh', [...sshBase, sshCmd('npm view groove-dev version 2>/dev/null')], {
471
+ encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
472
+ }).trim();
473
+ } catch { return; }
474
+
475
+ if (!npmVer || npmVer === remoteVer) {
476
+ const localVer = getLocalVersion();
477
+ this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: localVer, remoteVersion: remoteVer, match: remoteVer === localVer } });
478
+ return;
465
479
  }
466
480
 
467
- const verOutput = execFileSync('ssh', [...sshBase, sshCmd('groove --version')], {
468
- encoding: 'utf8',
469
- timeout: 10000,
470
- stdio: ['pipe', 'pipe', 'pipe'],
471
- }).trim();
472
- const installedVer = verOutput.replace(/[^0-9.]/g, '') || verOutput.trim();
481
+ // Remote is behind npm upgrade
482
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'upgrading', from: remoteVer, to: npmVer } });
483
+
484
+ const installCmd = npmGlobalInstall(`groove-dev@${npmVer}`, config.user);
485
+ const cleanupCmd = 'rm -rf $(npm root -g)/.groove-dev-* $(npm root -g)/groove-dev 2>/dev/null || true';
473
486
 
474
- if (installedVer !== localVer) {
475
- this.daemon.broadcast({ type: 'tunnel.version-mismatch', data: { id, localVersion: localVer, remoteVersion: installedVer, message: 'Pinned version not available on npm, installed latest' } });
487
+ try {
488
+ execFileSync('ssh', [...sshBase, sshCmd(installCmd)], {
489
+ encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'],
490
+ });
491
+ } catch (err) {
492
+ const errOutput = err.stdout?.toString() || err.stderr?.toString() || err.message;
493
+ if (errOutput.includes('ENOTEMPTY')) {
494
+ execFileSync('ssh', [...sshBase, sshCmd(cleanupCmd)], { encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] });
495
+ execFileSync('ssh', [...sshBase, sshCmd(installCmd)], { encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'] });
496
+ } else {
497
+ throw err;
498
+ }
476
499
  }
477
500
 
501
+ // Restart remote daemon
478
502
  const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` : '';
479
503
  const setProjectDir = config.projectDir
480
504
  ? `curl -sf -X POST -H 'Content-Type: application/json' --data '{"path":"${config.projectDir}"}' http://localhost:${REMOTE_PORT}/api/project-dir > /dev/null 2>&1 || true; `
481
505
  : '';
482
506
  const restartCmd = `kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 2; ${cdPrefix}GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 4; curl -sf http://localhost:${REMOTE_PORT}/api/status && (${setProjectDir}true) || true`;
483
- const restartResult = execFileSync('ssh', [...sshBase, sshCmd(restartCmd)], {
484
- encoding: 'utf8',
485
- timeout: 60000,
486
- stdio: ['pipe', 'pipe', 'pipe'],
507
+ execFileSync('ssh', [...sshBase, sshCmd(restartCmd)], {
508
+ encoding: 'utf8', timeout: 60000, stdio: ['pipe', 'pipe', 'pipe'],
487
509
  });
488
510
 
511
+ // Verify through tunnel
489
512
  let daemonVer = null;
490
- try { daemonVer = JSON.parse(restartResult.trim()).version || null; } catch { /* parse failed */ }
491
-
492
513
  for (let i = 0; i < 3; i++) {
493
514
  try {
494
515
  const check = await fetch(`http://localhost:${localPort}/api/status`, {
495
516
  signal: AbortSignal.timeout(3000),
496
517
  });
497
518
  if (check.ok) {
498
- const checkData = await check.json();
499
- daemonVer = checkData.version || daemonVer;
519
+ daemonVer = (await check.json()).version || null;
500
520
  break;
501
521
  }
502
522
  } catch { /* retry */ }
503
523
  await new Promise(r => setTimeout(r, 2000));
504
524
  }
505
525
 
526
+ const localVer = getLocalVersion();
506
527
  if (daemonVer) {
507
528
  this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: localVer, remoteVersion: daemonVer, match: daemonVer === localVer } });
508
529
  } else {
509
- this.daemon.broadcast({ type: 'tunnel.upgrade-failed', data: { id, error: 'Daemon did not respond after restart', from: oldVersion, attempted: localVer } });
530
+ this.daemon.broadcast({ type: 'tunnel.upgrade-failed', data: { id, error: 'Daemon did not respond after restart', from: remoteVer, attempted: npmVer } });
510
531
  }
511
532
 
512
- this.daemon.audit.log('tunnel.upgrade', { id, from: oldVersion, to: daemonVer || installedVer });
533
+ this.daemon.audit.log('tunnel.upgrade', { id, from: remoteVer, to: daemonVer || npmVer });
513
534
  } catch (err) {
514
535
  try {
515
536
  const verify = await fetch(`http://localhost:${localPort}/api/status`, { signal: AbortSignal.timeout(5000) });
516
537
  if (verify.ok) {
517
538
  const verifyData = await verify.json();
518
- if (verifyData.version === localVer) {
519
- this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: localVer, remoteVersion: verifyData.version, match: true } });
520
- return;
521
- }
522
- this.daemon.broadcast({ type: 'tunnel.version-mismatch', data: { id, localVersion: localVer, remoteVersion: verifyData.version, message: 'Upgrade timed out but remote is reachable' } });
539
+ this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: getLocalVersion(), remoteVersion: verifyData.version, match: false } });
523
540
  return;
524
541
  }
525
542
  } catch { /* tunnel verification failed */ }
@@ -533,7 +550,7 @@ export class TunnelManager {
533
550
  const sshBase = [...keyArgs, '-p', String(config.port || 22), '-o', 'ConnectTimeout=10', '-o', 'BatchMode=yes', target];
534
551
  const localVer = getLocalVersion();
535
552
  const pkg = localVer !== '0.0.0' ? `groove-dev@${localVer}` : 'groove-dev';
536
- const installCmd = config.user === 'root' ? `npm i -g --prefer-online ${pkg}` : `sudo npm i -g --prefer-online ${pkg}`;
553
+ const installCmd = npmGlobalInstall(pkg, config.user);
537
554
 
538
555
  let usedFallback = false;
539
556
  try {
@@ -554,7 +571,7 @@ export class TunnelManager {
554
571
  }
555
572
  } else {
556
573
  if (localVer !== '0.0.0' && pkg.includes('@')) {
557
- const fallbackCmd = config.user === 'root' ? 'npm i -g --prefer-online groove-dev' : 'sudo npm i -g --prefer-online groove-dev';
574
+ const fallbackCmd = npmGlobalInstall('groove-dev', config.user);
558
575
  try {
559
576
  execFileSync('ssh', [...sshBase, sshCmd(fallbackCmd)], {
560
577
  encoding: 'utf8',
@@ -565,6 +582,7 @@ export class TunnelManager {
565
582
  } catch { /* fall through to original error */ }
566
583
  }
567
584
  if (!usedFallback) {
585
+ if (isPermissionError(errOutput)) throw new Error(PERMISSION_HINT);
568
586
  throw new Error(`Remote upgrade failed: ${errOutput.slice(-400)}`);
569
587
  }
570
588
  }
@@ -670,12 +688,10 @@ export class TunnelManager {
670
688
  throw new Error(`Failed to check remote environment: ${err.message}`);
671
689
  }
672
690
 
673
- // Step 2: Install groove-dev globally (use sudo if not root)
691
+ // Step 2: Install groove-dev globally (try user-space first, sudo fallback)
674
692
  const localVer = getLocalVersion();
675
693
  const pkg = localVer !== '0.0.0' ? `groove-dev@${localVer}` : 'groove-dev';
676
- const installCmd = config.user === 'root'
677
- ? `npm i -g --prefer-online ${pkg}`
678
- : `sudo npm i -g --prefer-online ${pkg}`;
694
+ const installCmd = npmGlobalInstall(pkg, config.user);
679
695
 
680
696
  try {
681
697
  execFileSync('ssh', [
@@ -697,7 +713,7 @@ export class TunnelManager {
697
713
  throw new Error(`npm install failed after cleanup: ${retryOutput.slice(-400)}`);
698
714
  }
699
715
  } else if (localVer !== '0.0.0' && pkg.includes('@')) {
700
- const fallbackCmd = config.user === 'root' ? 'npm i -g --prefer-online groove-dev' : 'sudo npm i -g --prefer-online groove-dev';
716
+ const fallbackCmd = npmGlobalInstall('groove-dev', config.user);
701
717
  try {
702
718
  execFileSync('ssh', [...sshBase, remoteCmd(fallbackCmd)], {
703
719
  encoding: 'utf8',
@@ -706,9 +722,11 @@ export class TunnelManager {
706
722
  });
707
723
  } catch (err2) {
708
724
  const output = err2.stdout?.toString() || err2.stderr?.toString() || err2.message;
725
+ if (isPermissionError(output)) throw new Error(PERMISSION_HINT);
709
726
  throw new Error(`npm install failed: ${output.slice(-400)}`);
710
727
  }
711
728
  } else {
729
+ if (isPermissionError(errOutput)) throw new Error(PERMISSION_HINT);
712
730
  throw new Error(`npm install failed: ${errOutput.slice(-400)}`);
713
731
  }
714
732
  }
@@ -752,7 +770,7 @@ export class TunnelManager {
752
770
  const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
753
771
  const sshBase = [...keyArgs, '-p', String(config.port || 22), '-o', 'ConnectTimeout=10', '-o', 'BatchMode=yes', target];
754
772
  const pinnedPkg = `groove-dev@${localVer}`;
755
- const installCmd = config.user === 'root' ? `npm i -g --prefer-online ${pinnedPkg}` : `sudo npm i -g --prefer-online ${pinnedPkg}`;
773
+ const installCmd = npmGlobalInstall(pinnedPkg, config.user);
756
774
 
757
775
  try {
758
776
  execFileSync('ssh', [...sshBase, sshCmd(installCmd)], {
@@ -771,7 +789,7 @@ export class TunnelManager {
771
789
  throw new Error(`npm install failed after cleanup: ${retryOutput.slice(-400)}`);
772
790
  }
773
791
  } else {
774
- const fallbackCmd = config.user === 'root' ? 'npm i -g --prefer-online groove-dev' : 'sudo npm i -g --prefer-online groove-dev';
792
+ const fallbackCmd = npmGlobalInstall('groove-dev', config.user);
775
793
  execFileSync('ssh', [...sshBase, sshCmd(fallbackCmd)], {
776
794
  encoding: 'utf8',
777
795
  timeout: 120000,