groove-dev 0.11.0 → 0.12.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/node_modules/@groove-dev/cli/bin/groove.js +32 -0
- package/node_modules/@groove-dev/cli/src/commands/audit.js +60 -0
- package/node_modules/@groove-dev/cli/src/commands/connect.js +279 -0
- package/node_modules/@groove-dev/cli/src/commands/disconnect.js +91 -0
- package/node_modules/@groove-dev/cli/src/commands/federation.js +84 -0
- package/node_modules/@groove-dev/cli/src/commands/start.js +7 -2
- package/node_modules/@groove-dev/cli/src/commands/status.js +4 -1
- package/node_modules/@groove-dev/daemon/src/api.js +106 -2
- package/node_modules/@groove-dev/daemon/src/audit.js +65 -0
- package/node_modules/@groove-dev/daemon/src/federation.js +352 -0
- package/node_modules/@groove-dev/daemon/src/firstrun.js +27 -2
- package/node_modules/@groove-dev/daemon/src/index.js +59 -6
- package/node_modules/@groove-dev/gui/dist/assets/{index-BqZnnVJF.js → index-B49YqEXS.js} +17 -17
- package/node_modules/@groove-dev/gui/dist/assets/{index-CPzm9ZE9.css → index-Gfb8Zxy9.css} +1 -1
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/src/App.jsx +24 -1
- package/node_modules/@groove-dev/gui/src/components/SpawnPanel.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +19 -2
- package/node_modules/@groove-dev/gui/src/theme.css +2 -2
- package/package.json +1 -1
- package/packages/cli/bin/groove.js +32 -0
- package/packages/cli/src/commands/audit.js +60 -0
- package/packages/cli/src/commands/connect.js +279 -0
- package/packages/cli/src/commands/disconnect.js +91 -0
- package/packages/cli/src/commands/federation.js +84 -0
- package/packages/cli/src/commands/start.js +7 -2
- package/packages/cli/src/commands/status.js +4 -1
- package/packages/daemon/src/api.js +106 -2
- package/packages/daemon/src/audit.js +65 -0
- package/packages/daemon/src/federation.js +352 -0
- package/packages/daemon/src/firstrun.js +27 -2
- package/packages/daemon/src/index.js +59 -6
- package/packages/gui/dist/assets/{index-BqZnnVJF.js → index-B49YqEXS.js} +17 -17
- package/packages/gui/dist/assets/{index-CPzm9ZE9.css → index-Gfb8Zxy9.css} +1 -1
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/src/App.jsx +24 -1
- package/packages/gui/src/components/SpawnPanel.jsx +1 -1
- package/packages/gui/src/stores/groove.js +19 -2
- package/packages/gui/src/theme.css +2 -2
|
@@ -16,6 +16,10 @@ import { teamSave, teamLoad, teamList, teamDelete, teamExport, teamImport } from
|
|
|
16
16
|
import { approvals, approve, reject } from '../src/commands/approve.js';
|
|
17
17
|
import { providers, setKey } from '../src/commands/providers.js';
|
|
18
18
|
import { configShow, configSet } from '../src/commands/config.js';
|
|
19
|
+
import { connect } from '../src/commands/connect.js';
|
|
20
|
+
import { disconnect } from '../src/commands/disconnect.js';
|
|
21
|
+
import { audit } from '../src/commands/audit.js';
|
|
22
|
+
import { federationPair, federationUnpair, federationList, federationStatus } from '../src/commands/federation.js';
|
|
19
23
|
|
|
20
24
|
program
|
|
21
25
|
.name('groove')
|
|
@@ -26,6 +30,7 @@ program
|
|
|
26
30
|
.command('start')
|
|
27
31
|
.description('Start the GROOVE daemon')
|
|
28
32
|
.option('-p, --port <port>', 'Port to run on', '31415')
|
|
33
|
+
.option('-H, --host <host>', 'Host/IP to bind to (use "tailscale" for auto-detect)', '127.0.0.1')
|
|
29
34
|
.action(start);
|
|
30
35
|
|
|
31
36
|
program
|
|
@@ -90,6 +95,33 @@ program
|
|
|
90
95
|
program.command('providers').description('List available AI providers').action(providers);
|
|
91
96
|
program.command('set-key <provider> <key>').description('Set API key for a provider').action(setKey);
|
|
92
97
|
|
|
98
|
+
// Remote
|
|
99
|
+
program
|
|
100
|
+
.command('connect <target>')
|
|
101
|
+
.description('Connect to a remote GROOVE daemon via SSH tunnel')
|
|
102
|
+
.option('-i, --identity <keyfile>', 'SSH private key file')
|
|
103
|
+
.option('--no-browser', 'Don\'t open browser automatically')
|
|
104
|
+
.action(connect);
|
|
105
|
+
|
|
106
|
+
program
|
|
107
|
+
.command('disconnect')
|
|
108
|
+
.description('Disconnect from remote GROOVE daemon')
|
|
109
|
+
.action(disconnect);
|
|
110
|
+
|
|
111
|
+
// Audit
|
|
112
|
+
program
|
|
113
|
+
.command('audit')
|
|
114
|
+
.description('View audit log of state-changing operations')
|
|
115
|
+
.option('-n, --limit <count>', 'Number of entries to show', '25')
|
|
116
|
+
.action(audit);
|
|
117
|
+
|
|
118
|
+
// Federation
|
|
119
|
+
const federation = program.command('federation').description('Manage daemon-to-daemon federation');
|
|
120
|
+
federation.command('pair <target>').description('Pair with a remote GROOVE daemon (ip or ip:port)').action(federationPair);
|
|
121
|
+
federation.command('unpair <id>').description('Remove a paired peer').action(federationUnpair);
|
|
122
|
+
federation.command('list').description('List paired peers').action(federationList);
|
|
123
|
+
federation.command('status').description('Show federation status').action(federationStatus);
|
|
124
|
+
|
|
93
125
|
// Config
|
|
94
126
|
const config = program.command('config').description('View and modify configuration');
|
|
95
127
|
config.command('show').description('Show current configuration').action(configShow);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// GROOVE CLI — audit command
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { apiCall } from '../client.js';
|
|
6
|
+
|
|
7
|
+
const ACTION_COLORS = {
|
|
8
|
+
'agent.spawn': 'green',
|
|
9
|
+
'agent.kill': 'red',
|
|
10
|
+
'agent.kill_all': 'red',
|
|
11
|
+
'agent.rotate': 'yellow',
|
|
12
|
+
'agent.instruct': 'cyan',
|
|
13
|
+
'team.save': 'blue',
|
|
14
|
+
'team.load': 'blue',
|
|
15
|
+
'team.delete': 'red',
|
|
16
|
+
'team.import': 'blue',
|
|
17
|
+
'team.launch': 'green',
|
|
18
|
+
'config.set': 'yellow',
|
|
19
|
+
'credential.set': 'yellow',
|
|
20
|
+
'credential.delete': 'red',
|
|
21
|
+
'approval.approve': 'green',
|
|
22
|
+
'approval.reject': 'red',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function formatEntry(entry) {
|
|
26
|
+
const time = entry.t ? new Date(entry.t).toLocaleTimeString() : '??:??:??';
|
|
27
|
+
const color = ACTION_COLORS[entry.action] || 'white';
|
|
28
|
+
const action = chalk[color](entry.action.padEnd(20));
|
|
29
|
+
|
|
30
|
+
// Build detail string from remaining fields
|
|
31
|
+
const { t, action: _, ...detail } = entry;
|
|
32
|
+
const detailStr = Object.entries(detail)
|
|
33
|
+
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
|
34
|
+
.join(' ');
|
|
35
|
+
|
|
36
|
+
return ` ${chalk.dim(time)} ${action} ${chalk.dim(detailStr)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function audit(options) {
|
|
40
|
+
try {
|
|
41
|
+
const limit = parseInt(options.limit, 10) || 25;
|
|
42
|
+
const entries = await apiCall('GET', `/api/audit?limit=${limit}`);
|
|
43
|
+
|
|
44
|
+
console.log('');
|
|
45
|
+
if (entries.length === 0) {
|
|
46
|
+
console.log(chalk.dim(' No audit entries yet.'));
|
|
47
|
+
} else {
|
|
48
|
+
console.log(chalk.bold(` Audit Log`) + chalk.dim(` (${entries.length} entries, newest first)`));
|
|
49
|
+
console.log('');
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
console.log(formatEntry(entry));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
console.log('');
|
|
55
|
+
} catch {
|
|
56
|
+
console.log('');
|
|
57
|
+
console.log(chalk.yellow(' Daemon not running.'));
|
|
58
|
+
console.log('');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// GROOVE CLI — connect command (SSH tunnel to remote daemon)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { execFileSync, spawn } from 'child_process';
|
|
5
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
|
|
6
|
+
import { resolve } from 'path';
|
|
7
|
+
import { createConnection } from 'net';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
|
|
10
|
+
const REMOTE_PORT = 31415;
|
|
11
|
+
const DEFAULT_LOCAL_PORT = 31416;
|
|
12
|
+
const MAX_PORT_ATTEMPTS = 10;
|
|
13
|
+
|
|
14
|
+
// Allow user@host OR plain hostname (SSH config aliases like "groove-ai")
|
|
15
|
+
const SSH_TARGET_PATTERN = /^([a-zA-Z0-9._-]+@)?[a-zA-Z0-9._-]+$/;
|
|
16
|
+
|
|
17
|
+
function validateTarget(target) {
|
|
18
|
+
if (!target || typeof target !== 'string') {
|
|
19
|
+
throw new Error('SSH target is required (e.g., user@host or ssh-config-alias)');
|
|
20
|
+
}
|
|
21
|
+
// Block injection characters
|
|
22
|
+
if (/[;|&`$(){}[\]<>!#\n\r\\]/.test(target)) {
|
|
23
|
+
throw new Error('Invalid characters in SSH target');
|
|
24
|
+
}
|
|
25
|
+
if (!SSH_TARGET_PATTERN.test(target)) {
|
|
26
|
+
throw new Error('Invalid SSH target format. Expected: user@hostname or ssh-config-alias');
|
|
27
|
+
}
|
|
28
|
+
if (target.length > 253) {
|
|
29
|
+
throw new Error('SSH target too long');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function grooveDir() {
|
|
34
|
+
const dir = resolve(process.cwd(), '.groove');
|
|
35
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pidFile() {
|
|
40
|
+
return resolve(grooveDir(), 'tunnel.pid');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function tunnelInfoFile() {
|
|
44
|
+
return resolve(grooveDir(), 'tunnel.json');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isPortInUse(port) {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
const conn = createConnection({ host: '127.0.0.1', port });
|
|
50
|
+
conn.setTimeout(3000);
|
|
51
|
+
conn.on('connect', () => { conn.destroy(); resolve(true); });
|
|
52
|
+
conn.on('error', () => resolve(false));
|
|
53
|
+
conn.on('timeout', () => { conn.destroy(); resolve(false); });
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function findAvailablePort() {
|
|
58
|
+
for (let port = DEFAULT_LOCAL_PORT; port < DEFAULT_LOCAL_PORT + MAX_PORT_ATTEMPTS; port++) {
|
|
59
|
+
if (!(await isPortInUse(port))) return port;
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`No available local port found (tried ${DEFAULT_LOCAL_PORT}-${DEFAULT_LOCAL_PORT + MAX_PORT_ATTEMPTS - 1})`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function preflight(target, keyFile) {
|
|
65
|
+
// SSH in and check if daemon is listening on remote
|
|
66
|
+
// Use curl to health endpoint — works on both Linux and macOS
|
|
67
|
+
const args = [
|
|
68
|
+
...(keyFile ? ['-i', keyFile] : []),
|
|
69
|
+
'-o', 'ConnectTimeout=10',
|
|
70
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
71
|
+
'-o', 'BatchMode=yes',
|
|
72
|
+
target,
|
|
73
|
+
`curl -sf http://localhost:${REMOTE_PORT}/api/health >/dev/null 2>&1 || echo __GROOVE_NOT_RUNNING__`,
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const result = execFileSync('ssh', args, {
|
|
78
|
+
encoding: 'utf8',
|
|
79
|
+
timeout: 15000,
|
|
80
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (result.includes('__GROOVE_NOT_RUNNING__')) {
|
|
84
|
+
return { running: false };
|
|
85
|
+
}
|
|
86
|
+
return { running: true };
|
|
87
|
+
} catch (err) {
|
|
88
|
+
const stderr = err.stderr?.toString() || '';
|
|
89
|
+
if (stderr.includes('Permission denied')) {
|
|
90
|
+
throw new Error('SSH authentication failed. Check your key or credentials.');
|
|
91
|
+
}
|
|
92
|
+
if (stderr.includes('Connection refused') || stderr.includes('Connection timed out') || stderr.includes('No route to host')) {
|
|
93
|
+
throw new Error(`Cannot reach ${target}. Check the hostname and that SSH is running.`);
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`SSH preflight failed: ${stderr.trim() || err.message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isSshProcess(pid) {
|
|
100
|
+
// Verify the PID belongs to an SSH process (not a random reused PID)
|
|
101
|
+
try {
|
|
102
|
+
const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
103
|
+
encoding: 'utf8',
|
|
104
|
+
timeout: 3000,
|
|
105
|
+
}).trim();
|
|
106
|
+
return cmd.includes('ssh');
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function existingTunnel() {
|
|
113
|
+
const pf = pidFile();
|
|
114
|
+
if (!existsSync(pf)) return null;
|
|
115
|
+
const pid = parseInt(readFileSync(pf, 'utf8').trim(), 10);
|
|
116
|
+
if (isNaN(pid)) return null;
|
|
117
|
+
// Check if process is still alive AND is an SSH process
|
|
118
|
+
try {
|
|
119
|
+
process.kill(pid, 0);
|
|
120
|
+
if (!isSshProcess(pid)) {
|
|
121
|
+
// PID was reused by a different process — stale tunnel files
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
// Read tunnel info if available
|
|
125
|
+
const infoPath = tunnelInfoFile();
|
|
126
|
+
if (existsSync(infoPath)) {
|
|
127
|
+
const info = JSON.parse(readFileSync(infoPath, 'utf8'));
|
|
128
|
+
return { pid, ...info };
|
|
129
|
+
}
|
|
130
|
+
return { pid };
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function openBrowser(url) {
|
|
137
|
+
const platform = process.platform;
|
|
138
|
+
try {
|
|
139
|
+
if (platform === 'darwin') {
|
|
140
|
+
execFileSync('open', [url], { stdio: 'ignore' });
|
|
141
|
+
} else if (platform === 'win32') {
|
|
142
|
+
execFileSync('cmd', ['/c', 'start', '', url], { stdio: 'ignore' });
|
|
143
|
+
} else {
|
|
144
|
+
execFileSync('xdg-open', [url], { stdio: 'ignore' });
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// Browser open is best-effort
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function connect(target, options) {
|
|
152
|
+
console.log('');
|
|
153
|
+
|
|
154
|
+
// Validate SSH target
|
|
155
|
+
try {
|
|
156
|
+
validateTarget(target);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.log(chalk.red(' Error: ') + err.message);
|
|
159
|
+
console.log('');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Check for existing tunnel
|
|
164
|
+
const existing = existingTunnel();
|
|
165
|
+
if (existing) {
|
|
166
|
+
console.log(chalk.yellow(' Tunnel already active') + ` (PID ${existing.pid})`);
|
|
167
|
+
if (existing.target) {
|
|
168
|
+
console.log(` Target: ${existing.target}`);
|
|
169
|
+
}
|
|
170
|
+
if (existing.localPort) {
|
|
171
|
+
console.log(` GUI: ${chalk.cyan(`http://localhost:${existing.localPort}`)}`);
|
|
172
|
+
}
|
|
173
|
+
console.log('');
|
|
174
|
+
console.log(` Run ${chalk.bold('groove disconnect')} first to close it.`);
|
|
175
|
+
console.log('');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Preflight — check daemon is running on remote
|
|
180
|
+
console.log(chalk.dim(' Checking remote daemon...'));
|
|
181
|
+
try {
|
|
182
|
+
const check = preflight(target, options.identity);
|
|
183
|
+
if (!check.running) {
|
|
184
|
+
console.log(chalk.red(' Daemon not running on remote.'));
|
|
185
|
+
console.log(` SSH into ${chalk.bold(target)} and run ${chalk.bold('groove start')} first.`);
|
|
186
|
+
console.log('');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.log(chalk.red(' ' + err.message));
|
|
191
|
+
console.log('');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log(chalk.dim(' Remote daemon is running.'));
|
|
196
|
+
|
|
197
|
+
// Find available local port
|
|
198
|
+
let localPort;
|
|
199
|
+
try {
|
|
200
|
+
localPort = await findAvailablePort();
|
|
201
|
+
} catch (err) {
|
|
202
|
+
console.log(chalk.red(' ' + err.message));
|
|
203
|
+
console.log('');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (localPort !== DEFAULT_LOCAL_PORT) {
|
|
208
|
+
console.log(chalk.yellow(` Port ${DEFAULT_LOCAL_PORT} in use, using ${localPort} instead.`));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Spawn SSH tunnel
|
|
212
|
+
const sshArgs = [
|
|
213
|
+
'-N', // No remote command
|
|
214
|
+
'-L', `127.0.0.1:${localPort}:localhost:${REMOTE_PORT}`, // Local bind to 127.0.0.1 only
|
|
215
|
+
'-o', 'ServerAliveInterval=30', // Keepalive every 30s
|
|
216
|
+
'-o', 'ServerAliveCountMax=3', // Die after 3 missed keepalives
|
|
217
|
+
'-o', 'ExitOnForwardFailure=yes', // Fail if port forward fails
|
|
218
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
219
|
+
...(options.identity ? ['-i', options.identity] : []),
|
|
220
|
+
target,
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
const tunnel = spawn('ssh', sshArgs, {
|
|
224
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
225
|
+
detached: true,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Capture stderr for early errors
|
|
229
|
+
let stderrBuf = '';
|
|
230
|
+
tunnel.stderr.on('data', (chunk) => { stderrBuf += chunk.toString(); });
|
|
231
|
+
|
|
232
|
+
// Wait a moment to catch immediate failures (bad key, connection refused)
|
|
233
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
234
|
+
|
|
235
|
+
// Check if tunnel is still alive
|
|
236
|
+
if (tunnel.exitCode !== null) {
|
|
237
|
+
console.log(chalk.red(' Tunnel failed to start.'));
|
|
238
|
+
if (stderrBuf.trim()) {
|
|
239
|
+
console.log(chalk.dim(' ' + stderrBuf.trim()));
|
|
240
|
+
}
|
|
241
|
+
console.log('');
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Verify the tunnel is actually forwarding
|
|
246
|
+
const tunnelUp = await isPortInUse(localPort);
|
|
247
|
+
if (!tunnelUp) {
|
|
248
|
+
console.log(chalk.red(' Tunnel started but port forward not active.'));
|
|
249
|
+
try { process.kill(tunnel.pid); } catch { /* ignore */ }
|
|
250
|
+
console.log('');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Detach and save PID
|
|
255
|
+
tunnel.unref();
|
|
256
|
+
writeFileSync(pidFile(), String(tunnel.pid), { mode: 0o600 });
|
|
257
|
+
writeFileSync(tunnelInfoFile(), JSON.stringify({
|
|
258
|
+
target,
|
|
259
|
+
localPort,
|
|
260
|
+
remotePort: REMOTE_PORT,
|
|
261
|
+
startedAt: new Date().toISOString(),
|
|
262
|
+
}), { mode: 0o600 });
|
|
263
|
+
|
|
264
|
+
const url = `http://localhost:${localPort}`;
|
|
265
|
+
|
|
266
|
+
console.log('');
|
|
267
|
+
console.log(chalk.green(' Connected!'));
|
|
268
|
+
console.log('');
|
|
269
|
+
console.log(` Target: ${chalk.bold(target)}`);
|
|
270
|
+
console.log(` Tunnel: localhost:${localPort} → ${target}:${REMOTE_PORT}`);
|
|
271
|
+
console.log(` GUI: ${chalk.cyan(url)}`);
|
|
272
|
+
console.log(` PID: ${tunnel.pid}`);
|
|
273
|
+
console.log('');
|
|
274
|
+
|
|
275
|
+
// Open browser (Commander: --no-browser sets options.browser = false)
|
|
276
|
+
if (options.browser !== false) {
|
|
277
|
+
openBrowser(url);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// GROOVE CLI — disconnect command (kill SSH tunnel)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
|
+
import { existsSync, readFileSync, unlinkSync } from 'fs';
|
|
6
|
+
import { resolve } from 'path';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
|
|
9
|
+
function pidFile() {
|
|
10
|
+
return resolve(process.cwd(), '.groove', 'tunnel.pid');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function tunnelInfoFile() {
|
|
14
|
+
return resolve(process.cwd(), '.groove', 'tunnel.json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function cleanup() {
|
|
18
|
+
try { if (existsSync(pidFile())) unlinkSync(pidFile()); } catch { /* ignore */ }
|
|
19
|
+
try { if (existsSync(tunnelInfoFile())) unlinkSync(tunnelInfoFile()); } catch { /* ignore */ }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function disconnect() {
|
|
23
|
+
console.log('');
|
|
24
|
+
|
|
25
|
+
const pf = pidFile();
|
|
26
|
+
if (!existsSync(pf)) {
|
|
27
|
+
console.log(chalk.yellow(' No active tunnel found.'));
|
|
28
|
+
console.log('');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const pid = parseInt(readFileSync(pf, 'utf8').trim(), 10);
|
|
33
|
+
if (isNaN(pid)) {
|
|
34
|
+
console.log(chalk.yellow(' Invalid tunnel PID file. Cleaning up.'));
|
|
35
|
+
cleanup();
|
|
36
|
+
console.log('');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Read tunnel info for display
|
|
41
|
+
let info = {};
|
|
42
|
+
try {
|
|
43
|
+
const infoPath = tunnelInfoFile();
|
|
44
|
+
if (existsSync(infoPath)) {
|
|
45
|
+
info = JSON.parse(readFileSync(infoPath, 'utf8'));
|
|
46
|
+
}
|
|
47
|
+
} catch { /* ignore */ }
|
|
48
|
+
|
|
49
|
+
// Check if process is alive
|
|
50
|
+
try {
|
|
51
|
+
process.kill(pid, 0);
|
|
52
|
+
} catch {
|
|
53
|
+
console.log(chalk.yellow(' Tunnel process already dead. Cleaning up.'));
|
|
54
|
+
cleanup();
|
|
55
|
+
console.log('');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Verify the PID belongs to an SSH process (not a random reused PID after reboot)
|
|
60
|
+
try {
|
|
61
|
+
const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
62
|
+
encoding: 'utf8', timeout: 3000,
|
|
63
|
+
}).trim();
|
|
64
|
+
if (!cmd.includes('ssh')) {
|
|
65
|
+
console.log(chalk.yellow(' PID no longer belongs to an SSH tunnel. Cleaning up stale files.'));
|
|
66
|
+
cleanup();
|
|
67
|
+
console.log('');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// ps failed — proceed cautiously, don't kill
|
|
72
|
+
console.log(chalk.yellow(' Cannot verify tunnel process. Cleaning up stale files.'));
|
|
73
|
+
cleanup();
|
|
74
|
+
console.log('');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Kill the tunnel
|
|
79
|
+
try {
|
|
80
|
+
process.kill(pid, 'SIGTERM');
|
|
81
|
+
console.log(chalk.green(' Tunnel disconnected.'));
|
|
82
|
+
if (info.target) {
|
|
83
|
+
console.log(` Was connected to: ${chalk.dim(info.target)}`);
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.log(chalk.red(' Failed to kill tunnel: ') + err.message);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
cleanup();
|
|
90
|
+
console.log('');
|
|
91
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// GROOVE CLI — federation commands
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { apiCall } from '../client.js';
|
|
6
|
+
|
|
7
|
+
export async function federationPair(target) {
|
|
8
|
+
// target can be: <ip>:<port>, <ip> (default port), tailscale-ip
|
|
9
|
+
let remoteUrl = target;
|
|
10
|
+
if (!remoteUrl.startsWith('http')) {
|
|
11
|
+
// Add default port if not specified
|
|
12
|
+
const hasPort = remoteUrl.includes(':') && !remoteUrl.startsWith('[');
|
|
13
|
+
remoteUrl = `http://${remoteUrl}${hasPort ? '' : ':31415'}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
console.log('');
|
|
17
|
+
console.log(chalk.dim(` Pairing with ${remoteUrl}...`));
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const result = await apiCall('POST', '/api/federation/initiate', { remoteUrl });
|
|
21
|
+
console.log(chalk.green(' Paired successfully!'));
|
|
22
|
+
console.log('');
|
|
23
|
+
console.log(` Peer ID: ${result.peerId}`);
|
|
24
|
+
console.log(` Peer Name: ${result.peerName}`);
|
|
25
|
+
console.log(` Peer Host: ${result.peerHost}`);
|
|
26
|
+
console.log('');
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.log(chalk.red(' Pairing failed: ') + err.message);
|
|
29
|
+
console.log('');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function federationUnpair(peerId) {
|
|
34
|
+
console.log('');
|
|
35
|
+
try {
|
|
36
|
+
await apiCall('DELETE', `/api/federation/peers/${peerId}`);
|
|
37
|
+
console.log(chalk.green(` Unpaired peer ${peerId}.`));
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.log(chalk.red(' Unpair failed: ') + err.message);
|
|
40
|
+
}
|
|
41
|
+
console.log('');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function federationList() {
|
|
45
|
+
console.log('');
|
|
46
|
+
try {
|
|
47
|
+
const peers = await apiCall('GET', '/api/federation/peers');
|
|
48
|
+
if (peers.length === 0) {
|
|
49
|
+
console.log(chalk.dim(' No paired peers.'));
|
|
50
|
+
console.log(` Run ${chalk.bold('groove federation pair <host>')} to pair with a remote daemon.`);
|
|
51
|
+
} else {
|
|
52
|
+
console.log(chalk.bold(` Paired Peers`) + chalk.dim(` (${peers.length})`));
|
|
53
|
+
console.log('');
|
|
54
|
+
for (const peer of peers) {
|
|
55
|
+
const age = peer.pairedAt ? chalk.dim(` (since ${new Date(peer.pairedAt).toLocaleDateString()})`) : '';
|
|
56
|
+
console.log(` ${chalk.cyan(peer.id)} ${peer.host}:${peer.port}${age}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
console.log(chalk.yellow(' Daemon not running.'));
|
|
61
|
+
}
|
|
62
|
+
console.log('');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function federationStatus() {
|
|
66
|
+
console.log('');
|
|
67
|
+
try {
|
|
68
|
+
const status = await apiCall('GET', '/api/federation');
|
|
69
|
+
console.log(chalk.bold(' Federation'));
|
|
70
|
+
console.log('');
|
|
71
|
+
console.log(` Daemon ID: ${chalk.cyan(status.id)}`);
|
|
72
|
+
console.log(` Keypair: ${status.hasKeypair ? chalk.green('ready') : chalk.red('missing')}`);
|
|
73
|
+
console.log(` Peers: ${status.peerCount}`);
|
|
74
|
+
if (status.peers.length > 0) {
|
|
75
|
+
console.log('');
|
|
76
|
+
for (const peer of status.peers) {
|
|
77
|
+
console.log(` ${chalk.cyan(peer.id)} ${peer.host}:${peer.port}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
console.log(chalk.yellow(' Daemon not running.'));
|
|
82
|
+
}
|
|
83
|
+
console.log('');
|
|
84
|
+
}
|
|
@@ -8,7 +8,10 @@ export async function start(options) {
|
|
|
8
8
|
console.log(chalk.bold('GROOVE') + ' starting daemon...');
|
|
9
9
|
|
|
10
10
|
try {
|
|
11
|
-
const daemon = new Daemon({
|
|
11
|
+
const daemon = new Daemon({
|
|
12
|
+
port: parseInt(options.port, 10),
|
|
13
|
+
host: options.host,
|
|
14
|
+
});
|
|
12
15
|
|
|
13
16
|
const shutdown = async () => {
|
|
14
17
|
console.log('\nShutting down...');
|
|
@@ -23,7 +26,9 @@ export async function start(options) {
|
|
|
23
26
|
process.on('SIGTERM', shutdown);
|
|
24
27
|
|
|
25
28
|
await daemon.start();
|
|
26
|
-
|
|
29
|
+
const isRemote = daemon.host !== '127.0.0.1';
|
|
30
|
+
const guiUrl = `http://${isRemote ? daemon.host : 'localhost'}:${daemon.port}`;
|
|
31
|
+
console.log(chalk.green('Ready.') + ` Open ${guiUrl} for the GUI.`);
|
|
27
32
|
} catch (err) {
|
|
28
33
|
console.error(chalk.red('Failed to start:'), err.message);
|
|
29
34
|
process.exit(1);
|
|
@@ -10,13 +10,16 @@ export async function status() {
|
|
|
10
10
|
console.log('');
|
|
11
11
|
console.log(chalk.bold(' GROOVE Daemon'));
|
|
12
12
|
console.log('');
|
|
13
|
+
const isRemote = s.host && s.host !== '127.0.0.1';
|
|
14
|
+
const guiHost = isRemote ? s.host : 'localhost';
|
|
13
15
|
console.log(` Status: ${chalk.green('running')}`);
|
|
14
16
|
console.log(` PID: ${s.pid}`);
|
|
17
|
+
console.log(` Host: ${s.host || '127.0.0.1'}${isRemote ? chalk.yellow(' (network)') : ''}`);
|
|
15
18
|
console.log(` Port: ${s.port}`);
|
|
16
19
|
console.log(` Uptime: ${formatUptime(s.uptime)}`);
|
|
17
20
|
console.log(` Agents: ${s.agents} total, ${s.running} running`);
|
|
18
21
|
console.log(` Project: ${s.projectDir}`);
|
|
19
|
-
console.log(` GUI: ${chalk.cyan(`http
|
|
22
|
+
console.log(` GUI: ${chalk.cyan(`http://${guiHost}:${s.port}`)}`);
|
|
20
23
|
console.log('');
|
|
21
24
|
} catch {
|
|
22
25
|
console.log('');
|