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.
- package/CENTRAL_COMMAND_REBUILD.md +689 -0
- package/EMBEDDING_DIAGNOSTIC.md +197 -0
- package/TRAINING_DATA_v4.md +3 -0
- package/moe-training/client/parsers/codex.js +3 -3
- package/moe-training/client/parsers/gemini.js +2 -2
- package/moe-training/client/step-classifier.js +2 -2
- package/moe-training/test/client/step-classifier.test.js +63 -7
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/cli/src/commands/team.js +43 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +75 -15
- package/node_modules/@groove-dev/daemon/src/filewatcher.js +45 -0
- package/node_modules/@groove-dev/daemon/src/index.js +36 -10
- package/node_modules/@groove-dev/daemon/src/teams.js +100 -6
- package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +75 -43
- package/node_modules/@groove-dev/gui/dist/assets/{index-CHu5w3i3.js → index-BKCiOUDb.js} +593 -593
- package/node_modules/@groove-dev/gui/dist/assets/index-D4Q72afD.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/agents/workspace-mode.jsx +0 -22
- package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +43 -45
- package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +3 -1
- package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +2 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +57 -8
- package/node_modules/@groove-dev/gui/src/views/agents.jsx +31 -3
- package/node_modules/@groove-dev/gui/src/views/editor.jsx +1 -20
- package/node_modules/@groove-dev/gui/src/views/teams.jsx +106 -3
- package/node_modules/moe-training/client/parsers/codex.js +3 -3
- package/node_modules/moe-training/client/parsers/gemini.js +2 -2
- package/node_modules/moe-training/client/step-classifier.js +2 -2
- package/node_modules/moe-training/test/client/step-classifier.test.js +63 -7
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/team.js +43 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +75 -15
- package/packages/daemon/src/filewatcher.js +45 -0
- package/packages/daemon/src/index.js +36 -10
- package/packages/daemon/src/teams.js +100 -6
- package/packages/daemon/src/tunnel-manager.js +75 -43
- package/packages/gui/dist/assets/{index-CHu5w3i3.js → index-BKCiOUDb.js} +593 -593
- package/packages/gui/dist/assets/index-D4Q72afD.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/agents/workspace-mode.jsx +0 -22
- package/packages/gui/src/components/layout/status-bar.jsx +43 -45
- package/packages/gui/src/components/preview/preview-workspace.jsx +3 -1
- package/packages/gui/src/components/settings/quick-connect.jsx +2 -1
- package/packages/gui/src/stores/groove.js +57 -8
- package/packages/gui/src/views/agents.jsx +31 -3
- package/packages/gui/src/views/editor.jsx +1 -20
- package/packages/gui/src/views/teams.jsx +106 -3
- package/TRAINING_DATA_v2.md +0 -9
- package/node_modules/@groove-dev/gui/dist/assets/index-DAlSbVyK.css +0 -1
- 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
|
-
|
|
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.
|
|
827
|
-
this.
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
`
|
|
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,
|
|
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,
|
|
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,
|
|
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;
|
|
442
|
-
const restartResult = execFileSync('ssh', [...sshBase,
|
|
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,
|
|
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
|
-
|
|
494
|
-
|
|
496
|
+
const errOutput = err.stdout?.toString() || err.stderr?.toString() || err.message;
|
|
497
|
+
if (errOutput.includes('ENOTEMPTY')) {
|
|
495
498
|
try {
|
|
496
|
-
execFileSync('ssh', [...sshBase,
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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,
|
|
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}
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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(`
|
|
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,
|
|
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,
|
|
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;
|
|
708
|
-
const restartResult = execFileSync('ssh', [...sshBase,
|
|
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'],
|