groove-dev 0.27.112 → 0.27.115

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 (55) hide show
  1. package/CENTRAL_COMMAND_REBUILD.md +689 -0
  2. package/EMBEDDING_DIAGNOSTIC.md +197 -0
  3. package/TRAINING_DATA_v4.md +3 -0
  4. package/moe-training/client/parsers/codex.js +3 -3
  5. package/moe-training/client/parsers/gemini.js +2 -2
  6. package/moe-training/client/step-classifier.js +2 -2
  7. package/moe-training/test/client/step-classifier.test.js +63 -7
  8. package/node_modules/@groove-dev/cli/package.json +1 -1
  9. package/node_modules/@groove-dev/cli/src/commands/team.js +43 -1
  10. package/node_modules/@groove-dev/daemon/package.json +1 -1
  11. package/node_modules/@groove-dev/daemon/src/api.js +75 -15
  12. package/node_modules/@groove-dev/daemon/src/filewatcher.js +45 -0
  13. package/node_modules/@groove-dev/daemon/src/index.js +36 -10
  14. package/node_modules/@groove-dev/daemon/src/teams.js +100 -6
  15. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +75 -43
  16. package/node_modules/@groove-dev/gui/dist/assets/{index-CHu5w3i3.js → index-BKCiOUDb.js} +593 -593
  17. package/node_modules/@groove-dev/gui/dist/assets/index-D4Q72afD.css +1 -0
  18. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  19. package/node_modules/@groove-dev/gui/package.json +1 -1
  20. package/node_modules/@groove-dev/gui/src/components/agents/workspace-mode.jsx +0 -22
  21. package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +43 -45
  22. package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +3 -1
  23. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +2 -1
  24. package/node_modules/@groove-dev/gui/src/stores/groove.js +57 -8
  25. package/node_modules/@groove-dev/gui/src/views/agents.jsx +31 -3
  26. package/node_modules/@groove-dev/gui/src/views/editor.jsx +1 -20
  27. package/node_modules/@groove-dev/gui/src/views/teams.jsx +106 -3
  28. package/node_modules/moe-training/client/parsers/codex.js +3 -3
  29. package/node_modules/moe-training/client/parsers/gemini.js +2 -2
  30. package/node_modules/moe-training/client/step-classifier.js +2 -2
  31. package/node_modules/moe-training/test/client/step-classifier.test.js +63 -7
  32. package/package.json +1 -1
  33. package/packages/cli/package.json +1 -1
  34. package/packages/cli/src/commands/team.js +43 -1
  35. package/packages/daemon/package.json +1 -1
  36. package/packages/daemon/src/api.js +75 -15
  37. package/packages/daemon/src/filewatcher.js +45 -0
  38. package/packages/daemon/src/index.js +36 -10
  39. package/packages/daemon/src/teams.js +100 -6
  40. package/packages/daemon/src/tunnel-manager.js +75 -43
  41. package/packages/gui/dist/assets/{index-CHu5w3i3.js → index-BKCiOUDb.js} +593 -593
  42. package/packages/gui/dist/assets/index-D4Q72afD.css +1 -0
  43. package/packages/gui/dist/index.html +2 -2
  44. package/packages/gui/package.json +1 -1
  45. package/packages/gui/src/components/agents/workspace-mode.jsx +0 -22
  46. package/packages/gui/src/components/layout/status-bar.jsx +43 -45
  47. package/packages/gui/src/components/preview/preview-workspace.jsx +3 -1
  48. package/packages/gui/src/components/settings/quick-connect.jsx +2 -1
  49. package/packages/gui/src/stores/groove.js +57 -8
  50. package/packages/gui/src/views/agents.jsx +31 -3
  51. package/packages/gui/src/views/editor.jsx +1 -20
  52. package/packages/gui/src/views/teams.jsx +106 -3
  53. package/TRAINING_DATA_v2.md +0 -9
  54. package/node_modules/@groove-dev/gui/dist/assets/index-DAlSbVyK.css +0 -1
  55. package/packages/gui/dist/assets/index-DAlSbVyK.css +0 -1
@@ -8,6 +8,7 @@ export class FileWatcher {
8
8
  constructor(daemon) {
9
9
  this.daemon = daemon;
10
10
  this.watchers = new Map(); // relPath → { watcher, timer }
11
+ this.dirWatchers = new Map(); // relPath → { watcher, timer }
11
12
  }
12
13
 
13
14
  watch(relPath) {
@@ -51,9 +52,53 @@ export class FileWatcher {
51
52
  this.watchers.delete(relPath);
52
53
  }
53
54
 
55
+ watchDir(relPath) {
56
+ if (typeof relPath !== 'string') return;
57
+ if (relPath && relPath.includes('..')) return;
58
+ if (this.dirWatchers.has(relPath)) return;
59
+
60
+ const fullPath = relPath ? resolve(this.daemon.projectDir, relPath) : this.daemon.projectDir;
61
+
62
+ try {
63
+ const watcher = watch(fullPath, () => {
64
+ const entry = this.dirWatchers.get(relPath);
65
+ if (!entry) return;
66
+
67
+ if (entry.timer) clearTimeout(entry.timer);
68
+ entry.timer = setTimeout(() => {
69
+ this.daemon.broadcast({
70
+ type: 'file:tree-changed',
71
+ path: relPath,
72
+ timestamp: Date.now(),
73
+ });
74
+ }, 300);
75
+ });
76
+
77
+ watcher.on('error', () => {
78
+ this.unwatchDir(relPath);
79
+ });
80
+
81
+ this.dirWatchers.set(relPath, { watcher, timer: null });
82
+ } catch {
83
+ // Directory doesn't exist or not watchable — ignore
84
+ }
85
+ }
86
+
87
+ unwatchDir(relPath) {
88
+ const entry = this.dirWatchers.get(relPath);
89
+ if (!entry) return;
90
+
91
+ if (entry.timer) clearTimeout(entry.timer);
92
+ try { entry.watcher.close(); } catch { /* already closed */ }
93
+ this.dirWatchers.delete(relPath);
94
+ }
95
+
54
96
  unwatchAll() {
55
97
  for (const [relPath] of this.watchers) {
56
98
  this.unwatch(relPath);
57
99
  }
100
+ for (const [relPath] of this.dirWatchers) {
101
+ this.unwatchDir(relPath);
102
+ }
58
103
  }
59
104
  }
@@ -290,11 +290,11 @@ export class Daemon {
290
290
  });
291
291
 
292
292
  // Debounced file I/O for registry changes (at most once per 2s)
293
- let _registryIoTimer = null;
293
+ this._registryIoTimer = null;
294
294
  const _debouncedRegistryIo = () => {
295
- if (_registryIoTimer) return;
296
- _registryIoTimer = setTimeout(() => {
297
- _registryIoTimer = null;
295
+ if (this._registryIoTimer) return;
296
+ this._registryIoTimer = setTimeout(() => {
297
+ this._registryIoTimer = null;
298
298
  this.introducer.writeRegistryFile(this.projectDir);
299
299
  this.introducer.injectGrooveSection(this.projectDir);
300
300
  }, 2000);
@@ -325,8 +325,9 @@ export class Daemon {
325
325
  data: enrichAgents(this.registry.getAll()),
326
326
  }));
327
327
 
328
- // Track which files this client is watching (for cleanup on disconnect)
328
+ // Track which files/dirs this client is watching (for cleanup on disconnect)
329
329
  const watchedFiles = new Set();
330
+ const watchedDirs = new Set();
330
331
 
331
332
  ws.on('message', (raw) => {
332
333
  try {
@@ -335,7 +336,7 @@ export class Daemon {
335
336
  // Validate message type against whitelist
336
337
  const VALID_WS_TYPES = new Set([
337
338
  'terminal:spawn', 'terminal:resize', 'terminal:input', 'terminal:close', 'terminal:kill', 'terminal:rename',
338
- 'editor:watch', 'editor:unwatch', 'editor:save',
339
+ 'editor:watch', 'editor:unwatch', 'editor:save', 'editor:watchdir', 'editor:unwatchdir',
339
340
  'ping'
340
341
  ]);
341
342
  if (!msg || typeof msg !== 'object' || !VALID_WS_TYPES.has(msg.type)) return;
@@ -351,6 +352,14 @@ export class Daemon {
351
352
  case 'editor:unwatch':
352
353
  if (msg.path) { this.fileWatcher.unwatch(msg.path); watchedFiles.delete(msg.path); }
353
354
  break;
355
+ case 'editor:watchdir':
356
+ if (typeof msg.path === 'string' && !msg.path.includes('..')) {
357
+ this.fileWatcher.watchDir(msg.path); watchedDirs.add(msg.path);
358
+ }
359
+ break;
360
+ case 'editor:unwatchdir':
361
+ if (typeof msg.path === 'string') { this.fileWatcher.unwatchDir(msg.path); watchedDirs.delete(msg.path); }
362
+ break;
354
363
  // Terminal
355
364
  case 'terminal:spawn': {
356
365
  if (msg.cwd !== undefined && (typeof msg.cwd !== 'string' || msg.cwd.includes('..'))) break;
@@ -389,6 +398,9 @@ export class Daemon {
389
398
  for (const path of watchedFiles) {
390
399
  this.fileWatcher.unwatch(path);
391
400
  }
401
+ for (const path of watchedDirs) {
402
+ this.fileWatcher.unwatchDir(path);
403
+ }
392
404
  this.terminalManager.cleanupClient(ws);
393
405
  });
394
406
  });
@@ -779,6 +791,11 @@ export class Daemon {
779
791
  if (this._stateSaveInterval) clearInterval(this._stateSaveInterval);
780
792
  if (this._classifierInterval) clearInterval(this._classifierInterval);
781
793
  if (this._subscriptionPollInterval) clearInterval(this._subscriptionPollInterval);
794
+ if (this._registryIoTimer) clearTimeout(this._registryIoTimer);
795
+ if (this._networkCheckProc) {
796
+ try { this._networkCheckProc.kill(); } catch { /* already exited */ }
797
+ this._networkCheckProc = null;
798
+ }
782
799
 
783
800
  // Clean up file watchers and terminal sessions
784
801
  this.fileWatcher.unwatchAll();
@@ -823,10 +840,19 @@ export class Daemon {
823
840
 
824
841
  // Close server
825
842
  return new Promise((resolvePromise) => {
826
- this.wss.close(() => {
827
- this.server.close(() => {
828
- console.log('GROOVE daemon stopped.');
829
- resolvePromise();
843
+ this.federationWss.close(() => {
844
+ this.wss.close(() => {
845
+ this.server.close(() => {
846
+ // Unref lingering handles (idle fetch/undici TLS pool connections,
847
+ // closed servers) so they don't prevent process exit in tests.
848
+ for (const h of process._getActiveHandles()) {
849
+ if (typeof h.unref === 'function' && h !== process.stdout && h !== process.stderr) {
850
+ h.unref();
851
+ }
852
+ }
853
+ console.log('GROOVE daemon stopped.');
854
+ resolvePromise();
855
+ });
830
856
  });
831
857
  });
832
858
  });
@@ -1,8 +1,8 @@
1
1
  // GROOVE — Teams (Live Agent Groups)
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
- import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, rmSync } from 'fs';
5
- import { resolve } from 'path';
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, rmSync, readdirSync, cpSync } from 'fs';
5
+ import { resolve, basename } from 'path';
6
6
  import { randomUUID } from 'crypto';
7
7
  import { validateTeamName } from './validate.js';
8
8
 
@@ -162,17 +162,39 @@ export class Teams {
162
162
  this.daemon.registry.remove(agent.id);
163
163
  }
164
164
 
165
- // Remove the team's working directory refuse to nuke the project root
166
- // (legacy default teams that were never migrated point there).
165
+ // Archive the team's working directory instead of deleting it
167
166
  if (
168
167
  team.workingDir &&
169
168
  team.workingDir !== this.daemon.projectDir &&
170
169
  existsSync(team.workingDir)
171
170
  ) {
172
171
  try {
173
- rmSync(team.workingDir, { recursive: true, force: true });
172
+ const archiveDir = resolve(this.daemon.grooveDir, 'archived-teams');
173
+ mkdirSync(archiveDir, { recursive: true });
174
+ const slug = basename(team.workingDir);
175
+ const archiveName = `${slug}-${Date.now()}`;
176
+ const archivePath = resolve(archiveDir, archiveName);
177
+
178
+ try {
179
+ renameSync(team.workingDir, archivePath);
180
+ } catch (err) {
181
+ if (err.code === 'EXDEV') {
182
+ cpSync(team.workingDir, archivePath, { recursive: true });
183
+ rmSync(team.workingDir, { recursive: true, force: true });
184
+ } else {
185
+ throw err;
186
+ }
187
+ }
188
+
189
+ const metadata = {
190
+ originalName: team.name,
191
+ originalId: team.id,
192
+ deletedAt: new Date().toISOString(),
193
+ agentCount: agents.length,
194
+ };
195
+ writeFileSync(resolve(archivePath, 'metadata.json'), JSON.stringify(metadata, null, 2));
174
196
  } catch (err) {
175
- console.log(`[Groove:Teams] Failed to remove directory: ${err.message}`);
197
+ console.log(`[Groove:Teams] Failed to archive directory: ${err.message}`);
176
198
  }
177
199
  }
178
200
 
@@ -193,6 +215,78 @@ export class Teams {
193
215
  return true;
194
216
  }
195
217
 
218
+ listArchived() {
219
+ const archiveDir = resolve(this.daemon.grooveDir, 'archived-teams');
220
+ if (!existsSync(archiveDir)) return [];
221
+ const entries = readdirSync(archiveDir, { withFileTypes: true });
222
+ const result = [];
223
+ for (const entry of entries) {
224
+ if (!entry.isDirectory()) continue;
225
+ const metaPath = resolve(archiveDir, entry.name, 'metadata.json');
226
+ try {
227
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
228
+ result.push({ id: entry.name, ...meta });
229
+ } catch {
230
+ result.push({ id: entry.name, originalName: entry.name, deletedAt: null, agentCount: 0 });
231
+ }
232
+ }
233
+ return result;
234
+ }
235
+
236
+ restore(archivedId) {
237
+ const archiveDir = resolve(this.daemon.grooveDir, 'archived-teams');
238
+ const archivePath = resolve(archiveDir, archivedId);
239
+ if (!existsSync(archivePath)) throw new Error('Archived team not found');
240
+
241
+ let meta = {};
242
+ const metaPath = resolve(archivePath, 'metadata.json');
243
+ try { meta = JSON.parse(readFileSync(metaPath, 'utf8')); } catch { /* use defaults */ }
244
+
245
+ const name = meta.originalName || archivedId;
246
+ const dirName = slugify(name);
247
+ let workingDir = resolve(this.daemon.projectDir, dirName);
248
+
249
+ if (existsSync(workingDir)) {
250
+ workingDir = resolve(this.daemon.projectDir, `${dirName}-${Date.now()}`);
251
+ }
252
+
253
+ try {
254
+ renameSync(archivePath, workingDir);
255
+ } catch (err) {
256
+ if (err.code === 'EXDEV') {
257
+ cpSync(archivePath, workingDir, { recursive: true });
258
+ rmSync(archivePath, { recursive: true, force: true });
259
+ } else {
260
+ throw err;
261
+ }
262
+ }
263
+
264
+ // Remove the metadata file from the restored directory
265
+ const restoredMetaPath = resolve(workingDir, 'metadata.json');
266
+ try { rmSync(restoredMetaPath); } catch { /* may not exist */ }
267
+
268
+ const id = randomUUID().slice(0, 8);
269
+ const team = {
270
+ id,
271
+ name,
272
+ isDefault: false,
273
+ workingDir,
274
+ createdAt: new Date().toISOString(),
275
+ };
276
+ this.teams.set(id, team);
277
+ this._save();
278
+ this.daemon.broadcast({ type: 'team:created', team });
279
+ return team;
280
+ }
281
+
282
+ purge(archivedId) {
283
+ const archiveDir = resolve(this.daemon.grooveDir, 'archived-teams');
284
+ const archivePath = resolve(archiveDir, archivedId);
285
+ if (!existsSync(archivePath)) throw new Error('Archived team not found');
286
+ rmSync(archivePath, { recursive: true, force: true });
287
+ return true;
288
+ }
289
+
196
290
  // Migrate old agents (teamName but no teamId) to default team
197
291
  migrateAgents() {
198
292
  const defaultTeam = this.getDefault();
@@ -3,15 +3,13 @@
3
3
 
4
4
  import { execFileSync, spawn } from 'child_process';
5
5
  import { existsSync, writeFileSync, readFileSync, statSync } from 'fs';
6
- import { resolve, dirname, join } from 'path';
7
- import { fileURLToPath } from 'url';
6
+ import { resolve } from 'path';
8
7
  import { createConnection } from 'net';
9
8
  import crypto from 'crypto';
10
9
 
11
- const __dirname = dirname(fileURLToPath(import.meta.url));
12
10
  function getLocalVersion() {
13
11
  try {
14
- const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', '..', 'package.json'), 'utf8'));
12
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
15
13
  return pkg.version || '0.0.0';
16
14
  } catch { return '0.0.0'; }
17
15
  }
@@ -34,6 +32,11 @@ function validateField(value, name) {
34
32
  }
35
33
  }
36
34
 
35
+ function sshCmd(cmd) {
36
+ const nvmProbe = 'export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; ';
37
+ return `bash -lc '${nvmProbe}${cmd}'`;
38
+ }
39
+
37
40
  export class TunnelManager {
38
41
  constructor(daemon) {
39
42
  this.daemon = daemon;
@@ -217,7 +220,7 @@ export class TunnelManager {
217
220
  '-o', 'StrictHostKeyChecking=accept-new',
218
221
  '-o', 'BatchMode=yes',
219
222
  target,
220
- `bash -lc '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'`,
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`),
221
224
  ], {
222
225
  encoding: 'utf8',
223
226
  timeout: 20000,
@@ -412,7 +415,7 @@ export class TunnelManager {
412
415
  const installCmd = config.user === 'root' ? `npm i -g --prefer-online ${pinnedPkg}` : `sudo npm i -g --prefer-online ${pinnedPkg}`;
413
416
 
414
417
  try {
415
- execFileSync('ssh', [...sshBase, `bash -lc '${installCmd}'`], {
418
+ execFileSync('ssh', [...sshBase, sshCmd(installCmd)], {
416
419
  encoding: 'utf8',
417
420
  timeout: 120000,
418
421
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -420,14 +423,14 @@ export class TunnelManager {
420
423
  } catch {
421
424
  const fallbackPkg = 'groove-dev';
422
425
  const fallbackCmd = config.user === 'root' ? `npm i -g --prefer-online ${fallbackPkg}` : `sudo npm i -g --prefer-online ${fallbackPkg}`;
423
- execFileSync('ssh', [...sshBase, `bash -lc '${fallbackCmd}'`], {
426
+ execFileSync('ssh', [...sshBase, sshCmd(fallbackCmd)], {
424
427
  encoding: 'utf8',
425
428
  timeout: 120000,
426
429
  stdio: ['pipe', 'pipe', 'pipe'],
427
430
  });
428
431
  }
429
432
 
430
- const verOutput = execFileSync('ssh', [...sshBase, `bash -lc 'groove --version'`], {
433
+ const verOutput = execFileSync('ssh', [...sshBase, sshCmd('groove --version')], {
431
434
  encoding: 'utf8',
432
435
  timeout: 10000,
433
436
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -438,8 +441,8 @@ export class TunnelManager {
438
441
  this.daemon.broadcast({ type: 'tunnel.version-mismatch', data: { id, localVersion: localVer, remoteVersion: installedVer, message: 'Pinned version not available on npm, installed latest' } });
439
442
  }
440
443
 
441
- const restartCmd = `kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 2; nohup groove start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 4; curl -sf http://localhost:${REMOTE_PORT}/api/status`;
442
- const restartResult = execFileSync('ssh', [...sshBase, `bash -lc '${restartCmd}'`], {
444
+ const restartCmd = `kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 2; 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`;
445
+ const restartResult = execFileSync('ssh', [...sshBase, sshCmd(restartCmd)], {
443
446
  encoding: 'utf8',
444
447
  timeout: 60000,
445
448
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -484,30 +487,40 @@ export class TunnelManager {
484
487
 
485
488
  let usedFallback = false;
486
489
  try {
487
- execFileSync('ssh', [...sshBase, `bash -lc '${installCmd}'`], {
490
+ execFileSync('ssh', [...sshBase, sshCmd(installCmd)], {
488
491
  encoding: 'utf8',
489
492
  timeout: 120000,
490
493
  stdio: ['pipe', 'pipe', 'pipe'],
491
494
  });
492
495
  } catch (err) {
493
- if (localVer !== '0.0.0' && pkg.includes('@')) {
494
- const fallbackCmd = config.user === 'root' ? 'npm i -g --prefer-online groove-dev' : 'sudo npm i -g --prefer-online groove-dev';
496
+ const errOutput = err.stdout?.toString() || err.stderr?.toString() || err.message;
497
+ if (errOutput.includes('ENOTEMPTY')) {
495
498
  try {
496
- execFileSync('ssh', [...sshBase, `bash -lc '${fallbackCmd}'`], {
497
- encoding: 'utf8',
498
- timeout: 120000,
499
- stdio: ['pipe', 'pipe', 'pipe'],
500
- });
501
- usedFallback = true;
502
- } catch { /* fall through to original error */ }
503
- }
504
- if (!usedFallback) {
505
- const output = err.stdout?.toString() || err.stderr?.toString() || err.message;
506
- throw new Error(`Remote upgrade failed: ${output.slice(-400)}`);
499
+ execFileSync('ssh', [...sshBase, sshCmd('rm -rf $(npm root -g)/.groove-dev-* $(npm root -g)/groove-dev 2>/dev/null || true')], { encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] });
500
+ execFileSync('ssh', [...sshBase, sshCmd(installCmd)], { encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'] });
501
+ } catch (retryErr) {
502
+ const retryOutput = retryErr.stdout?.toString() || retryErr.stderr?.toString() || retryErr.message;
503
+ throw new Error(`Remote upgrade failed after cleanup: ${retryOutput.slice(-400)}`);
504
+ }
505
+ } else {
506
+ if (localVer !== '0.0.0' && pkg.includes('@')) {
507
+ const fallbackCmd = config.user === 'root' ? 'npm i -g --prefer-online groove-dev' : 'sudo npm i -g --prefer-online groove-dev';
508
+ try {
509
+ execFileSync('ssh', [...sshBase, sshCmd(fallbackCmd)], {
510
+ encoding: 'utf8',
511
+ timeout: 120000,
512
+ stdio: ['pipe', 'pipe', 'pipe'],
513
+ });
514
+ usedFallback = true;
515
+ } catch { /* fall through to original error */ }
516
+ }
517
+ if (!usedFallback) {
518
+ throw new Error(`Remote upgrade failed: ${errOutput.slice(-400)}`);
519
+ }
507
520
  }
508
521
  }
509
522
 
510
- const verOutput = execFileSync('ssh', [...sshBase, `bash -lc 'groove --version'`], {
523
+ const verOutput = execFileSync('ssh', [...sshBase, sshCmd('groove --version')], {
511
524
  encoding: 'utf8',
512
525
  timeout: 10000,
513
526
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -537,7 +550,7 @@ export class TunnelManager {
537
550
  ? `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; `
538
551
  : '';
539
552
  const remoteCmd =
540
- `${cdPrefix}nohup groove start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; ` +
553
+ `${cdPrefix}GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; ` +
541
554
  `sleep 5; ` +
542
555
  `curl -sf http://localhost:${REMOTE_PORT}/api/health > /dev/null ` +
543
556
  `&& (${setProjectDir}echo __DAEMON_OK__) ` +
@@ -550,7 +563,7 @@ export class TunnelManager {
550
563
  '-o', 'ConnectTimeout=10',
551
564
  '-o', 'BatchMode=yes',
552
565
  target,
553
- `bash -lc '${remoteCmd}'`,
566
+ sshCmd(remoteCmd),
554
567
  ], {
555
568
  encoding: 'utf8',
556
569
  timeout: 45000,
@@ -585,7 +598,7 @@ export class TunnelManager {
585
598
 
586
599
  // Non-interactive SSH doesn't source shell profiles, so npm/node may not be on PATH.
587
600
  // Use a login shell (-l) to get the user's full environment.
588
- const remoteCmd = (cmd) => `bash -lc '${cmd}'`;
601
+ const remoteCmd = (cmd) => sshCmd(cmd);
589
602
 
590
603
  // Step 1: Check if node/npm are available
591
604
  try {
@@ -623,7 +636,16 @@ export class TunnelManager {
623
636
  stdio: ['pipe', 'pipe', 'pipe'],
624
637
  });
625
638
  } catch (err) {
626
- if (localVer !== '0.0.0' && pkg.includes('@')) {
639
+ const errOutput = err.stdout?.toString() || err.stderr?.toString() || err.message;
640
+ if (errOutput.includes('ENOTEMPTY')) {
641
+ try {
642
+ execFileSync('ssh', [...sshBase, remoteCmd('rm -rf $(npm root -g)/.groove-dev-* $(npm root -g)/groove-dev 2>/dev/null || true')], { encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] });
643
+ execFileSync('ssh', [...sshBase, remoteCmd(installCmd)], { encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'] });
644
+ } catch (retryErr) {
645
+ const retryOutput = retryErr.stdout?.toString() || retryErr.stderr?.toString() || retryErr.message;
646
+ throw new Error(`npm install failed after cleanup: ${retryOutput.slice(-400)}`);
647
+ }
648
+ } else if (localVer !== '0.0.0' && pkg.includes('@')) {
627
649
  const fallbackCmd = config.user === 'root' ? 'npm i -g --prefer-online groove-dev' : 'sudo npm i -g --prefer-online groove-dev';
628
650
  try {
629
651
  execFileSync('ssh', [...sshBase, remoteCmd(fallbackCmd)], {
@@ -636,8 +658,7 @@ export class TunnelManager {
636
658
  throw new Error(`npm install failed: ${output.slice(-400)}`);
637
659
  }
638
660
  } else {
639
- const output = err.stdout?.toString() || err.stderr?.toString() || err.message;
640
- throw new Error(`npm install failed: ${output.slice(-400)}`);
661
+ throw new Error(`npm install failed: ${errOutput.slice(-400)}`);
641
662
  }
642
663
  }
643
664
 
@@ -645,7 +666,7 @@ export class TunnelManager {
645
666
  try {
646
667
  const result = execFileSync('ssh', [
647
668
  ...sshBase,
648
- remoteCmd(`nohup groove start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 5; curl -sf http://localhost:${REMOTE_PORT}/api/health > /dev/null && echo __DAEMON_OK__ || (echo __DAEMON_FAIL__; tail -20 /tmp/groove-daemon.log 2>/dev/null)`),
669
+ remoteCmd(`GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 5; curl -sf http://localhost:${REMOTE_PORT}/api/health > /dev/null && echo __DAEMON_OK__ || (echo __DAEMON_FAIL__; tail -20 /tmp/groove-daemon.log 2>/dev/null)`),
649
670
  ], {
650
671
  encoding: 'utf8',
651
672
  timeout: 45000,
@@ -683,29 +704,40 @@ export class TunnelManager {
683
704
  const installCmd = config.user === 'root' ? `npm i -g --prefer-online ${pinnedPkg}` : `sudo npm i -g --prefer-online ${pinnedPkg}`;
684
705
 
685
706
  try {
686
- execFileSync('ssh', [...sshBase, `bash -lc '${installCmd}'`], {
687
- encoding: 'utf8',
688
- timeout: 120000,
689
- stdio: ['pipe', 'pipe', 'pipe'],
690
- });
691
- } catch {
692
- const fallbackCmd = config.user === 'root' ? 'npm i -g --prefer-online groove-dev' : 'sudo npm i -g --prefer-online groove-dev';
693
- execFileSync('ssh', [...sshBase, `bash -lc '${fallbackCmd}'`], {
707
+ execFileSync('ssh', [...sshBase, sshCmd(installCmd)], {
694
708
  encoding: 'utf8',
695
709
  timeout: 120000,
696
710
  stdio: ['pipe', 'pipe', 'pipe'],
697
711
  });
712
+ } catch (err) {
713
+ const errOutput = err.stdout?.toString() || err.stderr?.toString() || err.message;
714
+ if (errOutput.includes('ENOTEMPTY')) {
715
+ try {
716
+ execFileSync('ssh', [...sshBase, sshCmd('rm -rf $(npm root -g)/.groove-dev-* $(npm root -g)/groove-dev 2>/dev/null || true')], { encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] });
717
+ execFileSync('ssh', [...sshBase, sshCmd(installCmd)], { encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'] });
718
+ } catch (retryErr) {
719
+ const retryOutput = retryErr.stdout?.toString() || retryErr.stderr?.toString() || retryErr.message;
720
+ throw new Error(`npm install failed after cleanup: ${retryOutput.slice(-400)}`);
721
+ }
722
+ } else {
723
+ const fallbackCmd = config.user === 'root' ? 'npm i -g --prefer-online groove-dev' : 'sudo npm i -g --prefer-online groove-dev';
724
+ execFileSync('ssh', [...sshBase, sshCmd(fallbackCmd)], {
725
+ encoding: 'utf8',
726
+ timeout: 120000,
727
+ stdio: ['pipe', 'pipe', 'pipe'],
728
+ });
729
+ }
698
730
  }
699
731
 
700
- const verOutput = execFileSync('ssh', [...sshBase, `bash -lc 'groove --version'`], {
732
+ const verOutput = execFileSync('ssh', [...sshBase, sshCmd('groove --version')], {
701
733
  encoding: 'utf8',
702
734
  timeout: 10000,
703
735
  stdio: ['pipe', 'pipe', 'pipe'],
704
736
  }).trim();
705
737
  const installedVer = verOutput.replace(/[^0-9.]/g, '') || verOutput.trim();
706
738
 
707
- const restartCmd = `kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 2; nohup groove start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 4; curl -sf http://localhost:${REMOTE_PORT}/api/status`;
708
- const restartResult = execFileSync('ssh', [...sshBase, `bash -lc '${restartCmd}'`], {
739
+ const restartCmd = `kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 2; 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`;
740
+ const restartResult = execFileSync('ssh', [...sshBase, sshCmd(restartCmd)], {
709
741
  encoding: 'utf8',
710
742
  timeout: 60000,
711
743
  stdio: ['pipe', 'pipe', 'pipe'],