@mmmbuto/nexuscrew 0.8.19 → 0.8.21
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/README.md +43 -4
- package/frontend/dist/assets/index-DQbVJLji.css +32 -0
- package/frontend/dist/assets/index-DaFQbMHq.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/init.js +9 -1
- package/lib/decks/routes.js +2 -2
- package/lib/decks/store.js +34 -5
- package/lib/fleet/builtin.js +18 -4
- package/lib/fleet/catalogs/openrouter-kimi-k3.json +44 -0
- package/lib/fleet/managed.js +109 -5
- package/lib/fleet/openrouter-auth-helper.js +21 -0
- package/lib/nodes/commands.js +35 -2
- package/lib/nodes/peering.js +45 -3
- package/lib/nodes/store.js +51 -10
- package/lib/nodes/tunnel-supervisor.js +47 -4
- package/lib/nodes/tunnel.js +46 -1
- package/lib/proxy/federation.js +123 -18
- package/lib/server.js +11 -0
- package/lib/settings/pairing-coordinator.js +3 -3
- package/lib/settings/public-peering-routes.js +39 -11
- package/lib/settings/routes.js +54 -36
- package/lib/tmux/list.js +11 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-DOHx885g.js +0 -91
- package/frontend/dist/assets/index-kxpt4ezv.css +0 -32
package/lib/settings/routes.js
CHANGED
|
@@ -47,7 +47,7 @@ const nodesStore = require('../nodes/store.js');
|
|
|
47
47
|
const nodesCmds = require('../nodes/commands.js');
|
|
48
48
|
const nodesTunnel = require('../nodes/tunnel.js');
|
|
49
49
|
const peering = require('../nodes/peering.js');
|
|
50
|
-
const { waitForHealthyPeer } = require('../proxy/federation.js');
|
|
50
|
+
const { waitForHealthyPeer, notifyHubShare } = require('../proxy/federation.js');
|
|
51
51
|
const { rotateToken } = require('../auth/token.js');
|
|
52
52
|
const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
|
|
53
53
|
const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
|
|
@@ -215,7 +215,7 @@ function settingsRoutes(deps = {}) {
|
|
|
215
215
|
};
|
|
216
216
|
if (updateStatus) out.update = updateStatus;
|
|
217
217
|
send(res, 200, out);
|
|
218
|
-
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
218
|
+
} catch (e) { send(res, e.status || 500, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
|
|
219
219
|
});
|
|
220
220
|
|
|
221
221
|
// --- POST /config — scrittura atomica, subset whitelisted ------------------
|
|
@@ -333,7 +333,7 @@ function settingsRoutes(deps = {}) {
|
|
|
333
333
|
try {
|
|
334
334
|
const b = req.body || {};
|
|
335
335
|
const label = nodesStore.sanitizeLabel(b.label, defaultDeviceName());
|
|
336
|
-
const st = nodesStore.
|
|
336
|
+
const st = nodesStore.loadStoreStrict(nodesPath);
|
|
337
337
|
const extra = {};
|
|
338
338
|
if (typeof b.ssh === 'string' && b.ssh.trim()) {
|
|
339
339
|
const ssh = nodesStore.parseSshTarget(b.ssh.trim());
|
|
@@ -359,7 +359,7 @@ function settingsRoutes(deps = {}) {
|
|
|
359
359
|
send(res, 200, peering.createInvite({
|
|
360
360
|
invitesPath, instanceId: st.nodeId, port: remotePort, linkPort: runtimePort(), label, ...extra,
|
|
361
361
|
}));
|
|
362
|
-
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
362
|
+
} catch (e) { send(res, e.status || 500, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
|
|
363
363
|
});
|
|
364
364
|
|
|
365
365
|
// --- POST /nodes/pair — hydra join in stadi espliciti ---------------------
|
|
@@ -413,7 +413,7 @@ function settingsRoutes(deps = {}) {
|
|
|
413
413
|
// impossibile (validata sopra), ma non facciamo mai fallire l'add per lei.
|
|
414
414
|
if (b.label !== undefined) {
|
|
415
415
|
try {
|
|
416
|
-
let st = nodesStore.
|
|
416
|
+
let st = nodesStore.loadStoreStrict(nodesPath);
|
|
417
417
|
st = nodesStore.updateNode(st, out.name, { label: nodesStore.sanitizeLabel(b.label, out.name) });
|
|
418
418
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
419
419
|
} catch (_) { /* best-effort: il nodo e' gia' creato */ }
|
|
@@ -435,7 +435,11 @@ function settingsRoutes(deps = {}) {
|
|
|
435
435
|
const status = /duplicato|self-reference/.test(msg) ? 409 : 400;
|
|
436
436
|
return send(res, status, { error: msg });
|
|
437
437
|
}
|
|
438
|
-
//
|
|
438
|
+
// Store mancante/invalido e' indisponibilita operativa, mai un invito a
|
|
439
|
+
// ricrearlo implicitamente. Gli altri errori restano 500.
|
|
440
|
+
if (out.reason === 'store invalido') {
|
|
441
|
+
return send(res, out.status || 503, { error: msg, ...(out.errorCode ? { code: out.errorCode } : {}) });
|
|
442
|
+
}
|
|
439
443
|
return send(res, 500, { error: msg });
|
|
440
444
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
441
445
|
});
|
|
@@ -522,32 +526,20 @@ function settingsRoutes(deps = {}) {
|
|
|
522
526
|
if (body.shared && !nodesStore.isPort(node.reversePort)) {
|
|
523
527
|
return send(res, 409, { error: 'canale share non negoziato: ripeti il pairing' });
|
|
524
528
|
}
|
|
525
|
-
|
|
526
|
-
|
|
529
|
+
const wasShared = node.shared === true;
|
|
530
|
+
let persistedDesired = wasShared;
|
|
527
531
|
const fetchImpl = seams.fetchImpl || fetch;
|
|
528
|
-
const notifyHub =
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
try {
|
|
532
|
-
const response = await fetchImpl(`http://127.0.0.1:${node.localPort}/federation/share`, {
|
|
533
|
-
method: 'POST', signal: ctrl.signal,
|
|
534
|
-
headers: { authorization: `Bearer ${node.token}`, 'content-type': 'application/json' },
|
|
535
|
-
body: JSON.stringify({ shared }),
|
|
536
|
-
});
|
|
537
|
-
if (!response.ok) throw new Error(`hub HTTP ${response.status}`);
|
|
538
|
-
} finally { clearTimeout(timer); }
|
|
539
|
-
};
|
|
540
|
-
const applyLocal = async (shared) => {
|
|
541
|
-
st = nodesStore.loadOrInitStore(nodesPath);
|
|
542
|
-
st = nodesStore.updateNode(st, name, { shared });
|
|
543
|
-
nodesStore.atomicWriteStore(nodesPath, st);
|
|
544
|
-
node = nodesStore.getNode(st, name);
|
|
545
|
-
nodesTunnel.stopTunnel({ home, name });
|
|
532
|
+
const notifyHub = (shared) => notifyHubShare({ node, shared, fetchImpl, timeoutMs: 5000 });
|
|
533
|
+
const ensureLocal = async ({ restart = false } = {}) => {
|
|
534
|
+
if (restart) nodesTunnel.stopTunnel({ home, name });
|
|
546
535
|
const started = nodesTunnel.startForward({
|
|
547
536
|
home, node, localAppPort: runtimePort(),
|
|
548
537
|
spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
|
|
549
538
|
sshBin: seams.sshBin, logFd: seams.logFd,
|
|
550
539
|
});
|
|
540
|
+
// startForward is spec-aware: an exact live argv is idempotent, while a
|
|
541
|
+
// stale pre-upgrade supervisor whose -L/-R no longer matches nodes.json
|
|
542
|
+
// is replaced. This is the recovery path for store=false + stale -R.
|
|
551
543
|
if (!started.started && started.reason !== 'already running') {
|
|
552
544
|
throw new Error(started.reason || 'avvio SSH fallito');
|
|
553
545
|
}
|
|
@@ -558,29 +550,55 @@ function settingsRoutes(deps = {}) {
|
|
|
558
550
|
});
|
|
559
551
|
if (!ready || ready.status !== 'healthy') {
|
|
560
552
|
const diagnosis = (seams.readTunnelDiagnostic || nodesTunnel.readTunnelDiagnostic)(home, name, node.remotePort);
|
|
561
|
-
|
|
553
|
+
const failure = new Error((diagnosis && diagnosis.detail) || (ready && ready.detail) || 'hub non raggiungibile dopo il riavvio SSH');
|
|
554
|
+
if (diagnosis && typeof diagnosis.hint === 'string') failure.hint = diagnosis.hint;
|
|
555
|
+
throw failure;
|
|
562
556
|
}
|
|
557
|
+
return started;
|
|
558
|
+
};
|
|
559
|
+
const applyLocal = async (shared) => {
|
|
560
|
+
st = nodesStore.loadStoreStrict(nodesPath);
|
|
561
|
+
st = nodesStore.updateNode(st, name, { shared });
|
|
562
|
+
nodesStore.atomicWriteStore(nodesPath, st);
|
|
563
|
+
persistedDesired = shared;
|
|
564
|
+
node = nodesStore.getNode(st, name);
|
|
565
|
+
await ensureLocal({ restart: true });
|
|
563
566
|
};
|
|
564
567
|
|
|
565
568
|
try {
|
|
569
|
+
if (node.shared === body.shared) {
|
|
570
|
+
// Persisted intent is not proof of the detached process mode. A stale
|
|
571
|
+
// supervisor can survive an npm upgrade and keep retrying -R even when
|
|
572
|
+
// the store/UI says private. Re-enter the spec-aware start path before
|
|
573
|
+
// reconciling the hub, without rewriting nodes.json.
|
|
574
|
+
await ensureLocal();
|
|
575
|
+
await notifyHub(body.shared);
|
|
576
|
+
return send(res, 200, { name, shared: body.shared, unchanged: true, reconciled: true });
|
|
577
|
+
}
|
|
566
578
|
if (body.shared) {
|
|
567
579
|
await applyLocal(true);
|
|
568
580
|
await notifyHub(true);
|
|
569
581
|
} else {
|
|
570
|
-
//
|
|
571
|
-
|
|
582
|
+
// Persist OFF first. If the process dies before the hub ACK, the next
|
|
583
|
+
// boot starts only -L and reconciles the stored false state.
|
|
572
584
|
await applyLocal(false);
|
|
585
|
+
await notifyHub(false);
|
|
573
586
|
}
|
|
574
587
|
return send(res, 200, { name, shared: body.shared });
|
|
575
588
|
} catch (e) {
|
|
576
589
|
// Share-on is transactional: a failed hub acknowledgement returns to the
|
|
577
590
|
// safe private -L-only state. Never include remote response bodies/tokens.
|
|
578
|
-
if (body.shared) {
|
|
591
|
+
if (body.shared && !wasShared) {
|
|
579
592
|
try { await applyLocal(false); } catch (_) { /* best-effort safe rollback */ }
|
|
580
593
|
}
|
|
594
|
+
const offPersisted = body.shared === false && persistedDesired === false;
|
|
595
|
+
const redact = (value) => String(value || '').replace(/Bearer\s+\S+/gi, 'Bearer ***');
|
|
581
596
|
return send(res, 502, {
|
|
582
|
-
error: body.shared ? 'Share non attivato'
|
|
583
|
-
|
|
597
|
+
error: body.shared ? 'Share non attivato'
|
|
598
|
+
: offPersisted ? 'Share disattivato localmente; hub non riconciliato' : 'Share non disattivato',
|
|
599
|
+
...(offPersisted ? { shared: false, reconcilePending: true } : {}),
|
|
600
|
+
detail: redact(e && e.message || e),
|
|
601
|
+
...(e && typeof e.hint === 'string' ? { hint: redact(e.hint) } : {}),
|
|
584
602
|
});
|
|
585
603
|
}
|
|
586
604
|
});
|
|
@@ -593,11 +611,11 @@ function settingsRoutes(deps = {}) {
|
|
|
593
611
|
if (!validName(name) || !['network', 'relay-only', 'selected'].includes(visibility)) {
|
|
594
612
|
return send(res, 400, { error: 'visibility non valida' });
|
|
595
613
|
}
|
|
596
|
-
let st = nodesStore.
|
|
614
|
+
let st = nodesStore.loadStoreStrict(nodesPath);
|
|
597
615
|
st = nodesStore.updateNode(st, name, { visibility, selected: visibility === 'selected' ? selected : [] });
|
|
598
616
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
599
617
|
send(res, 200, { saved: true, name, visibility });
|
|
600
|
-
} catch (e) { send(res, 400, { error: String(e.message || e) }); }
|
|
618
|
+
} catch (e) { send(res, e.status || 400, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
|
|
601
619
|
});
|
|
602
620
|
// Rinomina la label umana di un nodo SENZA toccare il name (route/URL stabili).
|
|
603
621
|
r.patch('/nodes/:name/label', mutGate, (req, res) => {
|
|
@@ -606,12 +624,12 @@ function settingsRoutes(deps = {}) {
|
|
|
606
624
|
const label = req.body && req.body.label;
|
|
607
625
|
if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
|
|
608
626
|
if (!nodesStore.validLabel(label)) return send(res, 400, { error: 'label non valida (max 64 char, niente a capo)' });
|
|
609
|
-
let st = nodesStore.
|
|
627
|
+
let st = nodesStore.loadStoreStrict(nodesPath);
|
|
610
628
|
if (!nodesStore.getNode(st, name)) return send(res, 404, { error: `nodo sconosciuto "${name}"` });
|
|
611
629
|
st = nodesStore.updateNode(st, name, { label: nodesStore.sanitizeLabel(label, name) });
|
|
612
630
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
613
631
|
send(res, 200, { saved: true, name, label: nodesStore.nodeLabel(nodesStore.getNode(st, name)) });
|
|
614
|
-
} catch (e) { send(res, 400, { error: String(e.message || e) }); }
|
|
632
|
+
} catch (e) { send(res, e.status || 400, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
|
|
615
633
|
});
|
|
616
634
|
|
|
617
635
|
// Legacy compatibility endpoint: the old node-role/rendezvous flow opened a
|
package/lib/tmux/list.js
CHANGED
|
@@ -52,6 +52,15 @@ function parseSessions(raw) {
|
|
|
52
52
|
.filter((session) => session.windows > 0);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// tmux uses different expected "no server" errors across platforms. Linux
|
|
56
|
+
// commonly prints "no server running" while macOS reports the missing socket
|
|
57
|
+
// path. Both mean an empty inventory, not a broken NexusCrew node.
|
|
58
|
+
function isNoTmuxServerError(raw) {
|
|
59
|
+
const message = String(raw || '');
|
|
60
|
+
return /no server running/i.test(message)
|
|
61
|
+
|| /error connecting to .*(?:No such file or directory|Connection refused)/i.test(message);
|
|
62
|
+
}
|
|
63
|
+
|
|
55
64
|
function setSessionVisibility(tmuxBin = 'tmux', name, technical = false) {
|
|
56
65
|
return new Promise((resolve, reject) => {
|
|
57
66
|
if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name) || name.startsWith('-')) {
|
|
@@ -73,7 +82,7 @@ function listSessions(tmuxBin = 'tmux') {
|
|
|
73
82
|
return new Promise((resolve, reject) => {
|
|
74
83
|
execFile(tmuxBin, ['list-sessions', '-F', FMT], (err, stdout, stderr) => {
|
|
75
84
|
if (err) {
|
|
76
|
-
if (
|
|
85
|
+
if (isNoTmuxServerError(stderr || err.message)) return resolve([]);
|
|
77
86
|
return reject(new Error(`tmux list-sessions failed: ${stderr || err.message}`));
|
|
78
87
|
}
|
|
79
88
|
resolve(parseSessions(stdout));
|
|
@@ -95,5 +104,5 @@ function attachedClients(tmuxBin = 'tmux', session) {
|
|
|
95
104
|
}
|
|
96
105
|
|
|
97
106
|
module.exports = {
|
|
98
|
-
parsePaneTitle, parseSessions, listSessions, attachedClients, setSessionVisibility, FMT,
|
|
107
|
+
parsePaneTitle, parseSessions, isNoTmuxServerError, listSessions, attachedClients, setSessionVisibility, FMT,
|
|
99
108
|
};
|