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.
Files changed (39) hide show
  1. package/node_modules/@groove-dev/cli/bin/groove.js +32 -0
  2. package/node_modules/@groove-dev/cli/src/commands/audit.js +60 -0
  3. package/node_modules/@groove-dev/cli/src/commands/connect.js +279 -0
  4. package/node_modules/@groove-dev/cli/src/commands/disconnect.js +91 -0
  5. package/node_modules/@groove-dev/cli/src/commands/federation.js +84 -0
  6. package/node_modules/@groove-dev/cli/src/commands/start.js +7 -2
  7. package/node_modules/@groove-dev/cli/src/commands/status.js +4 -1
  8. package/node_modules/@groove-dev/daemon/src/api.js +106 -2
  9. package/node_modules/@groove-dev/daemon/src/audit.js +65 -0
  10. package/node_modules/@groove-dev/daemon/src/federation.js +352 -0
  11. package/node_modules/@groove-dev/daemon/src/firstrun.js +27 -2
  12. package/node_modules/@groove-dev/daemon/src/index.js +59 -6
  13. package/node_modules/@groove-dev/gui/dist/assets/{index-BqZnnVJF.js → index-B49YqEXS.js} +17 -17
  14. package/node_modules/@groove-dev/gui/dist/assets/{index-CPzm9ZE9.css → index-Gfb8Zxy9.css} +1 -1
  15. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  16. package/node_modules/@groove-dev/gui/src/App.jsx +24 -1
  17. package/node_modules/@groove-dev/gui/src/components/SpawnPanel.jsx +1 -1
  18. package/node_modules/@groove-dev/gui/src/stores/groove.js +19 -2
  19. package/node_modules/@groove-dev/gui/src/theme.css +2 -2
  20. package/package.json +1 -1
  21. package/packages/cli/bin/groove.js +32 -0
  22. package/packages/cli/src/commands/audit.js +60 -0
  23. package/packages/cli/src/commands/connect.js +279 -0
  24. package/packages/cli/src/commands/disconnect.js +91 -0
  25. package/packages/cli/src/commands/federation.js +84 -0
  26. package/packages/cli/src/commands/start.js +7 -2
  27. package/packages/cli/src/commands/status.js +4 -1
  28. package/packages/daemon/src/api.js +106 -2
  29. package/packages/daemon/src/audit.js +65 -0
  30. package/packages/daemon/src/federation.js +352 -0
  31. package/packages/daemon/src/firstrun.js +27 -2
  32. package/packages/daemon/src/index.js +59 -6
  33. package/packages/gui/dist/assets/{index-BqZnnVJF.js → index-B49YqEXS.js} +17 -17
  34. package/packages/gui/dist/assets/{index-CPzm9ZE9.css → index-Gfb8Zxy9.css} +1 -1
  35. package/packages/gui/dist/index.html +2 -2
  36. package/packages/gui/src/App.jsx +24 -1
  37. package/packages/gui/src/components/SpawnPanel.jsx +1 -1
  38. package/packages/gui/src/stores/groove.js +19 -2
  39. 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({ port: parseInt(options.port, 10) });
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
- console.log(chalk.green('Ready.') + ` Open http://localhost:${options.port} for the GUI.`);
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://localhost:${s.port}`)}`);
22
+ console.log(` GUI: ${chalk.cyan(`http://${guiHost}:${s.port}`)}`);
20
23
  console.log('');
21
24
  } catch {
22
25
  console.log('');