groove-dev 0.27.44 → 0.27.47

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 (48) hide show
  1. package/default/groovedev-beta-auth-endpoint.md +166 -0
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +619 -0
  5. package/node_modules/@groove-dev/daemon/src/firstrun.js +11 -0
  6. package/node_modules/@groove-dev/daemon/src/index.js +28 -0
  7. package/node_modules/@groove-dev/daemon/src/integrations.js +2 -2
  8. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +1 -1
  9. package/node_modules/@groove-dev/daemon/src/providers/groove-network.js +114 -0
  10. package/node_modules/@groove-dev/daemon/src/providers/index.js +2 -0
  11. package/node_modules/@groove-dev/gui/dist/assets/index-B9oPxmNj.js +8607 -0
  12. package/node_modules/@groove-dev/gui/dist/assets/index-CyVj0fHl.css +1 -0
  13. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  14. package/node_modules/@groove-dev/gui/package.json +1 -1
  15. package/node_modules/@groove-dev/gui/src/app.jsx +3 -0
  16. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +7 -2
  17. package/node_modules/@groove-dev/gui/src/components/network/network-status.jsx +164 -0
  18. package/node_modules/@groove-dev/gui/src/components/network/node-details.jsx +66 -0
  19. package/node_modules/@groove-dev/gui/src/components/network/node-toggle.jsx +172 -0
  20. package/node_modules/@groove-dev/gui/src/stores/groove.js +190 -0
  21. package/node_modules/@groove-dev/gui/src/views/network.jsx +227 -0
  22. package/node_modules/@groove-dev/gui/src/views/settings.jsx +93 -5
  23. package/package.json +1 -1
  24. package/packages/cli/package.json +1 -1
  25. package/packages/daemon/package.json +1 -1
  26. package/packages/daemon/src/api.js +619 -0
  27. package/packages/daemon/src/firstrun.js +11 -0
  28. package/packages/daemon/src/index.js +28 -0
  29. package/packages/daemon/src/integrations.js +2 -2
  30. package/packages/daemon/src/providers/claude-code.js +1 -1
  31. package/packages/daemon/src/providers/groove-network.js +114 -0
  32. package/packages/daemon/src/providers/index.js +2 -0
  33. package/packages/gui/dist/assets/index-B9oPxmNj.js +8607 -0
  34. package/packages/gui/dist/assets/index-CyVj0fHl.css +1 -0
  35. package/packages/gui/dist/index.html +2 -2
  36. package/packages/gui/package.json +1 -1
  37. package/packages/gui/src/app.jsx +3 -0
  38. package/packages/gui/src/components/layout/activity-bar.jsx +7 -2
  39. package/packages/gui/src/components/network/network-status.jsx +164 -0
  40. package/packages/gui/src/components/network/node-details.jsx +66 -0
  41. package/packages/gui/src/components/network/node-toggle.jsx +172 -0
  42. package/packages/gui/src/stores/groove.js +190 -0
  43. package/packages/gui/src/views/network.jsx +227 -0
  44. package/packages/gui/src/views/settings.jsx +93 -5
  45. package/node_modules/@groove-dev/gui/dist/assets/index-B5Uor698.js +0 -8607
  46. package/node_modules/@groove-dev/gui/dist/assets/index-VGmIZurO.css +0 -1
  47. package/packages/gui/dist/assets/index-B5Uor698.js +0 -8607
  48. package/packages/gui/dist/assets/index-VGmIZurO.css +0 -1
@@ -6,6 +6,8 @@ import { resolve, dirname } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, unlinkSync, renameSync, rmSync, createReadStream, copyFileSync, realpathSync } from 'fs';
8
8
  import { spawn, execFile } from 'child_process';
9
+ import { createHash } from 'crypto';
10
+ import { hostname, networkInterfaces, homedir } from 'os';
9
11
  import { lookup as mimeLookup } from './mimetypes.js';
10
12
  import { listProviders, getProvider } from './providers/index.js';
11
13
  import { OllamaProvider } from './providers/ollama.js';
@@ -3720,6 +3722,623 @@ Keep responses concise. Help them think, don't lecture them about the system the
3720
3722
  }
3721
3723
  });
3722
3724
 
3725
+ // --- Groove Network (Beta) ---
3726
+
3727
+ // Offline fallback allowlist — used only when groovedev.ai is unreachable
3728
+ // so beta testers aren't locked out by network failures.
3729
+ const BETA_CODES_FALLBACK = new Set([
3730
+ 'GROOVE-NET-ALPHA-001',
3731
+ 'GROOVE-NET-ALPHA-002',
3732
+ 'GROOVE-NET-ALPHA-003',
3733
+ 'GROOVE-NET-ALPHA-004',
3734
+ 'GROOVE-NET-ALPHA-005',
3735
+ ]);
3736
+
3737
+ const BETA_VALIDATE_URL = 'https://groovedev.ai/api/beta/validate';
3738
+
3739
+ function getMachineId() {
3740
+ const nets = networkInterfaces();
3741
+ const macs = [];
3742
+ for (const name of Object.keys(nets)) {
3743
+ for (const iface of nets[name] || []) {
3744
+ if (iface.mac && iface.mac !== '00:00:00:00:00:00') macs.push(iface.mac);
3745
+ }
3746
+ }
3747
+ macs.sort();
3748
+ return createHash('sha256').update(`${hostname()}|${macs.join(',')}`).digest('hex');
3749
+ }
3750
+
3751
+ async function validateCodeWithServer(code) {
3752
+ const controller = new AbortController();
3753
+ const timeout = setTimeout(() => controller.abort(), 5000);
3754
+ try {
3755
+ const response = await fetch(BETA_VALIDATE_URL, {
3756
+ method: 'POST',
3757
+ headers: { 'Content-Type': 'application/json' },
3758
+ body: JSON.stringify({ code, machineId: getMachineId() }),
3759
+ signal: controller.signal,
3760
+ });
3761
+ if (!response.ok && response.status !== 200) {
3762
+ return { ok: false, reason: 'http', status: response.status };
3763
+ }
3764
+ const body = await response.json();
3765
+ return { ok: true, result: body };
3766
+ } catch (err) {
3767
+ return { ok: false, reason: 'network', error: err.message };
3768
+ } finally {
3769
+ clearTimeout(timeout);
3770
+ }
3771
+ }
3772
+
3773
+ function isNetworkUnlocked() {
3774
+ return !!(daemon.config?.networkBeta?.unlocked);
3775
+ }
3776
+
3777
+ function networkGate(req, res, next) {
3778
+ // Return 404 (not 403) so the feature is invisible until unlocked.
3779
+ if (!isNetworkUnlocked()) return res.status(404).json({ error: 'Not found' });
3780
+ next();
3781
+ }
3782
+
3783
+ async function persistConfig() {
3784
+ const { saveConfig } = await import('./firstrun.js');
3785
+ saveConfig(daemon.grooveDir, daemon.config);
3786
+ }
3787
+
3788
+ app.get('/api/beta/status', (req, res) => {
3789
+ res.json({ unlocked: isNetworkUnlocked() });
3790
+ });
3791
+
3792
+ app.post('/api/beta/activate', async (req, res) => {
3793
+ const { code } = req.body || {};
3794
+ if (typeof code !== 'string' || code.length > 64 || !/^[A-Z0-9-]+$/.test(code)) {
3795
+ return res.status(400).json({ error: 'Invalid code format' });
3796
+ }
3797
+
3798
+ const remote = await validateCodeWithServer(code);
3799
+
3800
+ let valid = false;
3801
+ let message = 'Invalid invite code';
3802
+ let expiresAt = null;
3803
+ let features = [];
3804
+ let source = 'server';
3805
+
3806
+ if (remote.ok && remote.result && typeof remote.result === 'object') {
3807
+ valid = remote.result.valid === true;
3808
+ if (typeof remote.result.message === 'string') message = remote.result.message;
3809
+ if (typeof remote.result.expiresAt === 'string' || remote.result.expiresAt === null) {
3810
+ expiresAt = remote.result.expiresAt || null;
3811
+ }
3812
+ if (Array.isArray(remote.result.features)) features = remote.result.features;
3813
+ } else {
3814
+ // Offline fallback — only trust the hardcoded list when we can't reach the server
3815
+ source = 'fallback';
3816
+ if (BETA_CODES_FALLBACK.has(code)) {
3817
+ valid = true;
3818
+ message = 'Activated (offline)';
3819
+ features = ['network-node', 'network-consumer'];
3820
+ } else {
3821
+ message = 'Invalid invite code';
3822
+ }
3823
+ }
3824
+
3825
+ if (!valid) {
3826
+ daemon.audit.log('beta.activate.denied', { codePrefix: code.slice(0, 10), source });
3827
+ return res.status(200).json({ unlocked: false, message });
3828
+ }
3829
+
3830
+ daemon.config.networkBeta = {
3831
+ ...(daemon.config.networkBeta || {}),
3832
+ unlocked: true,
3833
+ code,
3834
+ expiresAt,
3835
+ features,
3836
+ };
3837
+ await persistConfig();
3838
+ daemon.audit.log('beta.activate', { codePrefix: code.slice(0, 10), source, features });
3839
+ daemon.broadcast({ type: 'config:updated' });
3840
+ res.json({ unlocked: true, message, expiresAt, features });
3841
+ });
3842
+
3843
+ // Re-validate stored code against groovedev.ai. Called at daemon startup
3844
+ // so revoked or expired codes lock the feature automatically. Non-blocking.
3845
+ daemon.revalidateBetaCode = async function revalidateBetaCode() {
3846
+ const cfg = daemon.config?.networkBeta;
3847
+ if (!cfg?.unlocked || !cfg?.code) return;
3848
+ const remote = await validateCodeWithServer(cfg.code);
3849
+ // If we couldn't reach the server, keep the current unlocked state —
3850
+ // network failures must not lock out beta users.
3851
+ if (!remote.ok || !remote.result || typeof remote.result !== 'object') return;
3852
+ if (remote.result.valid === true) {
3853
+ // Refresh features/expiresAt from server in case they changed
3854
+ const next = {
3855
+ ...cfg,
3856
+ expiresAt: typeof remote.result.expiresAt === 'string' ? remote.result.expiresAt : null,
3857
+ features: Array.isArray(remote.result.features) ? remote.result.features : (cfg.features || []),
3858
+ };
3859
+ if (JSON.stringify(next) !== JSON.stringify(cfg)) {
3860
+ daemon.config.networkBeta = next;
3861
+ await persistConfig();
3862
+ daemon.broadcast({ type: 'config:updated' });
3863
+ }
3864
+ return;
3865
+ }
3866
+ // Server says invalid — revoke
3867
+ daemon.config.networkBeta = {
3868
+ ...cfg,
3869
+ unlocked: false,
3870
+ code: null,
3871
+ expiresAt: null,
3872
+ features: [],
3873
+ };
3874
+ await persistConfig();
3875
+ daemon.audit.log('beta.revoked', { reason: remote.result.message || 'server denied' });
3876
+ daemon.broadcast({ type: 'config:updated' });
3877
+ };
3878
+
3879
+ app.post('/api/beta/deactivate', async (req, res) => {
3880
+ // Stop the node if it's running before locking the feature away.
3881
+ try {
3882
+ if (daemon.networkNode?.proc && !daemon.networkNode.proc.killed) {
3883
+ daemon.networkNode.proc.kill('SIGINT');
3884
+ }
3885
+ } catch { /* ignore */ }
3886
+ daemon.networkNode = {
3887
+ active: false, status: 'stopped', pid: null, proc: null,
3888
+ nodeId: null, layers: null, model: null, sessions: 0,
3889
+ hardware: null, startedAt: null, events: [],
3890
+ };
3891
+ daemon.config.networkBeta = {
3892
+ ...(daemon.config.networkBeta || {}),
3893
+ unlocked: false,
3894
+ code: null,
3895
+ };
3896
+ await persistConfig();
3897
+ daemon.audit.log('beta.deactivate', {});
3898
+ daemon.broadcast({ type: 'config:updated' });
3899
+ res.json({ unlocked: false });
3900
+ });
3901
+
3902
+ // Network node lifecycle (gated)
3903
+
3904
+ function snapshotNode() {
3905
+ const n = daemon.networkNode || {};
3906
+ return {
3907
+ active: !!n.active,
3908
+ status: n.status || 'stopped',
3909
+ nodeId: n.nodeId || null,
3910
+ layers: n.layers || null,
3911
+ model: n.model || null,
3912
+ sessions: n.sessions || 0,
3913
+ hardware: n.hardware || null,
3914
+ installed: !!(daemon.config?.networkBeta?.installed),
3915
+ };
3916
+ }
3917
+
3918
+ function eventLevel(event) {
3919
+ if (event === 'error' || event === 'crashed') return 'error';
3920
+ if (event === 'exit' || event === 'stopping' || event === 'disconnected') return 'warning';
3921
+ if (event === 'connected' || event === 'node registered' || event === 'shard loaded') return 'success';
3922
+ if (event === 'serving session' || event === 'session complete' || event === 'session ended') return 'session';
3923
+ return 'info';
3924
+ }
3925
+
3926
+ function pushNodeEvent(event, details) {
3927
+ const d = details || {};
3928
+ const message = typeof d.msg === 'string' ? d.msg
3929
+ : typeof d.message === 'string' ? d.message
3930
+ : typeof d.line === 'string' ? d.line
3931
+ : event;
3932
+ const entry = {
3933
+ timestamp: new Date().toISOString(),
3934
+ event,
3935
+ level: eventLevel(event),
3936
+ message,
3937
+ details: details || null,
3938
+ };
3939
+ daemon.networkNode.events = daemon.networkNode.events || [];
3940
+ daemon.networkNode.events.push(entry);
3941
+ if (daemon.networkNode.events.length > 200) {
3942
+ daemon.networkNode.events = daemon.networkNode.events.slice(-200);
3943
+ }
3944
+ daemon.broadcast({ type: 'network:node:event', data: entry });
3945
+ }
3946
+
3947
+ function normalizeHardware(caps) {
3948
+ if (!caps || typeof caps !== 'object') return null;
3949
+ const formatMb = (mb) => (Number.isFinite(mb) && mb > 0)
3950
+ ? (mb >= 1024 ? `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} GB` : `${mb} MB`)
3951
+ : null;
3952
+ const vram = formatMb(caps.vram_mb);
3953
+ const ram = formatMb(caps.ram_mb);
3954
+ return {
3955
+ device: caps.device || null,
3956
+ gpu: caps.gpu_model || null,
3957
+ memory: vram || ram || null,
3958
+ vram,
3959
+ ram,
3960
+ cpuCores: caps.cpu_cores || null,
3961
+ bandwidthMbps: caps.bandwidth_mbps || null,
3962
+ maxContext: caps.max_context_length || null,
3963
+ };
3964
+ }
3965
+
3966
+ function broadcastNodeStatus() {
3967
+ daemon.broadcast({ type: 'network:node:status', data: snapshotNode() });
3968
+ }
3969
+
3970
+ app.get('/api/network/node/status', networkGate, (req, res) => {
3971
+ res.json(snapshotNode());
3972
+ });
3973
+
3974
+ app.post('/api/network/node/start', networkGate, (req, res) => {
3975
+ if (daemon.networkNode?.active) {
3976
+ return res.status(409).json({ error: 'Node already running' });
3977
+ }
3978
+
3979
+ const cfg = daemon.config.networkBeta || {};
3980
+ const relay = cfg.relayUrl || 'localhost:8770';
3981
+ const device = cfg.devicePreference || 'auto';
3982
+ const maxContext = Number.isFinite(cfg.maxContext) ? cfg.maxContext : 4096;
3983
+
3984
+ // Resolve deploy path (handles ~ and defaults to ~/Desktop/groove-deploy)
3985
+ let deployPath = cfg.deployPath || null;
3986
+ if (!deployPath) {
3987
+ deployPath = resolve(process.env.HOME || '', 'Desktop/groove-deploy');
3988
+ } else if (deployPath.startsWith('~/')) {
3989
+ deployPath = resolve(process.env.HOME || '', deployPath.slice(2));
3990
+ }
3991
+
3992
+ if (!existsSync(deployPath)) {
3993
+ return res.status(400).json({ error: `Deploy path not found: ${deployPath}` });
3994
+ }
3995
+
3996
+ const args = [
3997
+ '-m', 'src.node.server',
3998
+ '--relay', relay,
3999
+ '--device', device,
4000
+ '--max-context', String(maxContext),
4001
+ ];
4002
+
4003
+ let proc;
4004
+ try {
4005
+ proc = spawn('python', args, {
4006
+ cwd: deployPath,
4007
+ env: { ...process.env, PYTHONUNBUFFERED: '1' },
4008
+ stdio: ['ignore', 'pipe', 'pipe'],
4009
+ });
4010
+ } catch (err) {
4011
+ return res.status(500).json({ error: `Failed to spawn node: ${err.message}` });
4012
+ }
4013
+
4014
+ daemon.networkNode = {
4015
+ active: true,
4016
+ status: 'starting',
4017
+ pid: proc.pid,
4018
+ proc,
4019
+ nodeId: null,
4020
+ layers: null,
4021
+ model: null,
4022
+ sessions: 0,
4023
+ hardware: null,
4024
+ startedAt: Date.now(),
4025
+ events: [],
4026
+ };
4027
+
4028
+ pushNodeEvent('starting', { pid: proc.pid, relay, device });
4029
+ broadcastNodeStatus();
4030
+
4031
+ let stderrBuf = '';
4032
+ proc.stderr.on('data', (chunk) => {
4033
+ stderrBuf += chunk.toString();
4034
+ let idx;
4035
+ while ((idx = stderrBuf.indexOf('\n')) !== -1) {
4036
+ const line = stderrBuf.slice(0, idx).trim();
4037
+ stderrBuf = stderrBuf.slice(idx + 1);
4038
+ if (!line) continue;
4039
+ if (line[0] !== '{') {
4040
+ pushNodeEvent('log', { line });
4041
+ continue;
4042
+ }
4043
+ let entry;
4044
+ try { entry = JSON.parse(line); } catch { pushNodeEvent('log', { line }); continue; }
4045
+ const msg = entry.msg || entry.event || '';
4046
+ let changed = false;
4047
+ if (entry.node_id && entry.node_id !== daemon.networkNode.nodeId) {
4048
+ daemon.networkNode.nodeId = entry.node_id; changed = true;
4049
+ }
4050
+ if (msg === 'node registered' || msg === 'connected') {
4051
+ daemon.networkNode.status = 'connected'; changed = true;
4052
+ }
4053
+ if (msg === 'shard loaded' || entry.layer_start !== undefined) {
4054
+ if (entry.layer_start !== undefined && entry.layer_end !== undefined) {
4055
+ daemon.networkNode.layers = [entry.layer_start, entry.layer_end]; changed = true;
4056
+ }
4057
+ if (entry.model_name) { daemon.networkNode.model = entry.model_name; changed = true; }
4058
+ }
4059
+ if (msg === 'serving session') {
4060
+ daemon.networkNode.sessions = (daemon.networkNode.sessions || 0) + 1; changed = true;
4061
+ }
4062
+ if (msg === 'session complete' || msg === 'session ended') {
4063
+ daemon.networkNode.sessions = Math.max(0, (daemon.networkNode.sessions || 0) - 1); changed = true;
4064
+ }
4065
+ if (entry.capabilities || entry.hardware) {
4066
+ daemon.networkNode.hardware = normalizeHardware(entry.capabilities || entry.hardware); changed = true;
4067
+ }
4068
+ pushNodeEvent(msg || 'log', entry);
4069
+ if (changed) broadcastNodeStatus();
4070
+ }
4071
+ });
4072
+
4073
+ proc.stdout.on('data', (chunk) => {
4074
+ const line = chunk.toString().trim();
4075
+ if (line) pushNodeEvent('stdout', { line });
4076
+ });
4077
+
4078
+ proc.on('error', (err) => {
4079
+ daemon.networkNode.status = 'error';
4080
+ pushNodeEvent('error', { message: err.message });
4081
+ broadcastNodeStatus();
4082
+ });
4083
+
4084
+ proc.on('exit', (code, signal) => {
4085
+ daemon.networkNode.active = false;
4086
+ daemon.networkNode.status = 'stopped';
4087
+ daemon.networkNode.pid = null;
4088
+ daemon.networkNode.proc = null;
4089
+ pushNodeEvent('exit', { code, signal });
4090
+ broadcastNodeStatus();
4091
+ });
4092
+
4093
+ daemon.audit.log('network.node.start', { pid: proc.pid, relay, device });
4094
+ res.status(202).json({ started: true, ...snapshotNode() });
4095
+ });
4096
+
4097
+ app.post('/api/network/node/stop', networkGate, (req, res) => {
4098
+ const node = daemon.networkNode;
4099
+ if (!node?.active || !node.proc) {
4100
+ return res.status(409).json({ error: 'Node not running' });
4101
+ }
4102
+ try {
4103
+ node.proc.kill('SIGINT');
4104
+ } catch (err) {
4105
+ return res.status(500).json({ error: `Failed to stop node: ${err.message}` });
4106
+ }
4107
+ daemon.networkNode.status = 'stopping';
4108
+ pushNodeEvent('stopping', { pid: node.pid });
4109
+ broadcastNodeStatus();
4110
+ daemon.audit.log('network.node.stop', { pid: node.pid });
4111
+ res.json({ stopping: true });
4112
+ });
4113
+
4114
+ app.get('/api/network/status', networkGate, (req, res) => {
4115
+ // Mocked relay status until the relay /status HTTP endpoint lands.
4116
+ // Shape matches the spec in groove-comms/GETTING-STARTED.md.
4117
+ const node = daemon.networkNode || {};
4118
+ const selfNode = node.active && node.nodeId ? [{
4119
+ node_id: node.nodeId,
4120
+ device: node.hardware?.device || 'auto',
4121
+ layers: node.layers || [0, 0],
4122
+ status: node.status === 'connected' ? 'active' : node.status,
4123
+ }] : [];
4124
+ const coverage = node.layers ? (node.layers[1] - node.layers[0]) : 0;
4125
+ res.json({
4126
+ nodes: selfNode,
4127
+ models: ['Qwen/Qwen2.5-0.5B'],
4128
+ coverage,
4129
+ totalLayers: 24,
4130
+ activeSessions: node.sessions || 0,
4131
+ });
4132
+ });
4133
+
4134
+ // --- Network package install/uninstall ---
4135
+
4136
+ const NETWORK_REPO_URL = 'https://github.com/grooveai-dev/groove-network.git';
4137
+ const NETWORK_VERSION = 'v0.1.0';
4138
+
4139
+ function networkRoot() {
4140
+ return resolve(homedir(), '.groove', 'network');
4141
+ }
4142
+
4143
+ // Defensive: only permit fs ops on paths that resolve inside ~/.groove/.
4144
+ function isInsideGrooveHome(target) {
4145
+ const home = resolve(homedir(), '.groove') + '/';
4146
+ const full = resolve(target) + '/';
4147
+ return full.startsWith(home);
4148
+ }
4149
+
4150
+ function broadcastInstallProgress(step, message, percent) {
4151
+ daemon.broadcast({
4152
+ type: 'network:install:progress',
4153
+ data: { step, message, percent },
4154
+ });
4155
+ }
4156
+
4157
+ app.get('/api/network/install/status', networkGate, (req, res) => {
4158
+ const installPath = networkRoot();
4159
+ const installed = existsSync(resolve(installPath, 'setup.sh'));
4160
+ res.json({
4161
+ installed,
4162
+ path: installed ? installPath : null,
4163
+ version: installed ? (daemon.config?.networkBeta?.version || null) : null,
4164
+ });
4165
+ });
4166
+
4167
+ app.post('/api/network/install', networkGate, async (req, res) => {
4168
+ if (daemon.networkInstall?.running) {
4169
+ return res.status(409).json({ error: 'Install already in progress' });
4170
+ }
4171
+ if (daemon.config?.networkBeta?.installed) {
4172
+ return res.status(400).json({ error: 'Network package already installed' });
4173
+ }
4174
+
4175
+ const installPath = networkRoot();
4176
+ if (!isInsideGrooveHome(installPath)) {
4177
+ return res.status(500).json({ error: 'Invalid install path' });
4178
+ }
4179
+
4180
+ // Refuse to clone over an existing directory — avoids surprising merges.
4181
+ if (existsSync(installPath)) {
4182
+ return res.status(400).json({ error: 'Install path already exists; uninstall first' });
4183
+ }
4184
+
4185
+ daemon.networkInstall = { running: true, startedAt: Date.now() };
4186
+ res.status(200).json({ status: 'installing' });
4187
+
4188
+ // Run the install asynchronously; progress flows over WebSocket.
4189
+ (async () => {
4190
+ const cleanup = () => {
4191
+ try {
4192
+ if (existsSync(installPath) && isInsideGrooveHome(installPath)) {
4193
+ rmSync(installPath, { recursive: true, force: true });
4194
+ }
4195
+ } catch { /* ignore */ }
4196
+ };
4197
+
4198
+ const fail = (message) => {
4199
+ cleanup();
4200
+ broadcastInstallProgress('error', message, -1);
4201
+ daemon.audit.log('network.install.failed', { message });
4202
+ daemon.networkInstall = { running: false };
4203
+ };
4204
+
4205
+ try {
4206
+ // Build clone URL with optional PAT
4207
+ const pat = daemon.credentials?.getKey?.('github-pat') || null;
4208
+ const cloneUrl = pat
4209
+ ? NETWORK_REPO_URL.replace('https://', `https://${pat}@`)
4210
+ : NETWORK_REPO_URL;
4211
+
4212
+ broadcastInstallProgress('cloning', 'Cloning network package...', 0);
4213
+
4214
+ const cloneArgs = ['clone', '--branch', NETWORK_VERSION, '--depth', '1', cloneUrl, installPath];
4215
+ const clone = spawn('git', cloneArgs, {
4216
+ stdio: ['ignore', 'pipe', 'pipe'],
4217
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
4218
+ });
4219
+
4220
+ let cloneErr = '';
4221
+ clone.stderr.on('data', (chunk) => {
4222
+ const s = chunk.toString();
4223
+ cloneErr += s;
4224
+ // git writes progress to stderr — relay last line as status.
4225
+ const line = s.split('\n').map((l) => l.trim()).filter(Boolean).pop();
4226
+ if (line) broadcastInstallProgress('cloning', line, 5);
4227
+ });
4228
+
4229
+ const cloneCode = await new Promise((resolveClone) => {
4230
+ clone.on('error', (err) => resolveClone({ code: -1, err: err.message }));
4231
+ clone.on('close', (code) => resolveClone({ code }));
4232
+ });
4233
+
4234
+ if (cloneCode.code !== 0) {
4235
+ const hint = cloneErr.trim().split('\n').slice(-1)[0] || 'git clone failed';
4236
+ return fail(`Clone failed: ${hint}`);
4237
+ }
4238
+
4239
+ broadcastInstallProgress('cloned', 'Repository cloned', 10);
4240
+
4241
+ // Run setup.sh --json from the install directory
4242
+ const setup = spawn('bash', ['setup.sh', '--json'], {
4243
+ cwd: installPath,
4244
+ stdio: ['ignore', 'pipe', 'pipe'],
4245
+ env: { ...process.env, PYTHONUNBUFFERED: '1' },
4246
+ });
4247
+
4248
+ daemon.networkInstall.proc = setup;
4249
+
4250
+ let stdoutBuf = '';
4251
+ setup.stdout.on('data', (chunk) => {
4252
+ stdoutBuf += chunk.toString();
4253
+ let idx;
4254
+ while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
4255
+ const line = stdoutBuf.slice(0, idx).trim();
4256
+ stdoutBuf = stdoutBuf.slice(idx + 1);
4257
+ if (!line) continue;
4258
+ if (line[0] !== '{') continue;
4259
+ try {
4260
+ const event = JSON.parse(line);
4261
+ const step = typeof event.step === 'string' ? event.step : 'progress';
4262
+ const message = typeof event.message === 'string' ? event.message : '';
4263
+ const percent = Number.isFinite(event.percent) ? event.percent : null;
4264
+ broadcastInstallProgress(step, message, percent);
4265
+ } catch { /* non-JSON line, ignore */ }
4266
+ }
4267
+ });
4268
+
4269
+ let stderrBuf = '';
4270
+ setup.stderr.on('data', (chunk) => {
4271
+ stderrBuf += chunk.toString();
4272
+ });
4273
+
4274
+ const setupResult = await new Promise((resolveSetup) => {
4275
+ setup.on('error', (err) => resolveSetup({ code: -1, err: err.message }));
4276
+ setup.on('close', (code) => resolveSetup({ code }));
4277
+ });
4278
+
4279
+ if (setupResult.code !== 0) {
4280
+ const hint = stderrBuf.trim().split('\n').slice(-1)[0] || `setup.sh exited ${setupResult.code}`;
4281
+ return fail(`Setup failed: ${hint}`);
4282
+ }
4283
+
4284
+ daemon.config.networkBeta = {
4285
+ ...(daemon.config.networkBeta || {}),
4286
+ installed: true,
4287
+ deployPath: installPath,
4288
+ version: NETWORK_VERSION,
4289
+ };
4290
+ await persistConfig();
4291
+ daemon.broadcast({ type: 'config:updated' });
4292
+ broadcastInstallProgress('done', 'Network package installed', 100);
4293
+ daemon.audit.log('network.install', { path: installPath, version: NETWORK_VERSION });
4294
+ daemon.networkInstall = { running: false };
4295
+ } catch (err) {
4296
+ fail(err?.message || 'Install failed');
4297
+ }
4298
+ })();
4299
+ });
4300
+
4301
+ app.post('/api/network/uninstall', networkGate, async (req, res) => {
4302
+ if (daemon.networkInstall?.running) {
4303
+ return res.status(409).json({ error: 'Install in progress; wait for it to finish' });
4304
+ }
4305
+
4306
+ // Stop the running node first (reuse existing stop logic).
4307
+ try {
4308
+ const node = daemon.networkNode;
4309
+ if (node?.active && node.proc && !node.proc.killed) {
4310
+ try { node.proc.kill('SIGINT'); } catch { /* ignore */ }
4311
+ daemon.networkNode.status = 'stopping';
4312
+ pushNodeEvent('stopping', { pid: node.pid, reason: 'uninstall' });
4313
+ broadcastNodeStatus();
4314
+ }
4315
+ } catch { /* ignore */ }
4316
+
4317
+ const installPath = networkRoot();
4318
+ if (!isInsideGrooveHome(installPath)) {
4319
+ return res.status(500).json({ error: 'Invalid install path' });
4320
+ }
4321
+
4322
+ try {
4323
+ if (existsSync(installPath)) {
4324
+ rmSync(installPath, { recursive: true, force: true });
4325
+ }
4326
+ } catch (err) {
4327
+ return res.status(500).json({ error: `Failed to remove install: ${err.message}` });
4328
+ }
4329
+
4330
+ daemon.config.networkBeta = {
4331
+ ...(daemon.config.networkBeta || {}),
4332
+ installed: false,
4333
+ deployPath: null,
4334
+ version: null,
4335
+ };
4336
+ await persistConfig();
4337
+ daemon.broadcast({ type: 'config:updated' });
4338
+ daemon.audit.log('network.uninstall', { path: installPath });
4339
+ res.json({ status: 'uninstalled' });
4340
+ });
4341
+
3723
4342
  // Serve GUI static files (built GUI) — no-cache headers to prevent stale bundles
3724
4343
  const guiPath = process.env.GROOVE_GUI_PATH || resolve(__dirname, '../../gui/dist');
3725
4344
  app.use(express.static(guiPath, { etag: false, maxAge: 0, lastModified: false }));
@@ -25,6 +25,16 @@ const DEFAULT_CONFIG = {
25
25
  autoRotate: true,
26
26
  tokenCeilingPerAgent: 5_000_000,
27
27
  },
28
+ networkBeta: {
29
+ unlocked: false,
30
+ code: null,
31
+ relayUrl: 'localhost:8770',
32
+ devicePreference: 'auto',
33
+ maxContext: 4096,
34
+ deployPath: null,
35
+ installed: false,
36
+ version: null,
37
+ },
28
38
  recentProjects: [],
29
39
  };
30
40
 
@@ -137,6 +147,7 @@ export function loadConfig(grooveDir) {
137
147
  const merged = { ...DEFAULT_CONFIG, ...saved };
138
148
  // Deep-merge safety subtree so partial user config doesn't drop defaults
139
149
  merged.safety = { ...DEFAULT_CONFIG.safety, ...(saved.safety || {}) };
150
+ merged.networkBeta = { ...DEFAULT_CONFIG.networkBeta, ...(saved.networkBeta || {}) };
140
151
  return merged;
141
152
  } catch {
142
153
  return { ...DEFAULT_CONFIG };