@mmmbuto/nexuscrew 0.8.52-rc.9 → 0.8.53
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/CHANGELOG.md +340 -0
- package/frontend/dist/assets/index-BJ-_5bxw.js +93 -0
- package/frontend/dist/assets/{index-_kotgT8Z.css → index-CYi_lhCg.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +11 -0
- package/frontend/dist/version.json +1 -1
- package/lib/audio/dispatch.js +6 -1
- package/lib/cells/scope-guard.js +239 -0
- package/lib/cells/scope.js +144 -0
- package/lib/cli/commands.js +279 -4
- package/lib/fleet/builtin.js +129 -4
- package/lib/fleet/catalogs/alibaba-token-plan-qwen3.8.json +3 -3
- package/lib/fleet/definitions.js +128 -7
- package/lib/fleet/managed.js +229 -20
- package/lib/fleet/model-probe.js +129 -0
- package/lib/fleet/routes.js +27 -1
- package/lib/fleet/runtime.js +3 -3
- package/lib/mcp/server.js +35 -1
- package/lib/mcp/tools.js +67 -4
- package/lib/nodes/commands.js +86 -3
- package/lib/nodes/identity.js +232 -0
- package/lib/nodes/store.js +89 -0
- package/lib/notify/notifier.js +7 -0
- package/lib/notify/routes.js +75 -3
- package/lib/proxy/federation.js +100 -8
- package/lib/server.js +100 -4
- package/lib/settings/pairing-coordinator.js +16 -0
- package/lib/settings/public-peering-routes.js +24 -1
- package/lib/settings/routes.js +1 -1
- package/lib/tmux/actions.js +33 -1
- package/package.json +1 -1
- package/frontend/dist/assets/index-Cefi613r.js +0 -93
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/cells/scope.js — quali celle di QUESTO nodo un peer federato puo' vedere.
|
|
3
|
+
//
|
|
4
|
+
// Gemello di lib/audio/acl.js, e per le stesse ragioni:
|
|
5
|
+
// * la sorgente della decisione e' il node store locale, mai il corpo della
|
|
6
|
+
// richiesta. Un peer non dichiara il proprio scope: lo subisce.
|
|
7
|
+
// * l'identita' arriva dalla catena `visited` che costruisce il server
|
|
8
|
+
// (controlledVisited), non da un campo dichiarato.
|
|
9
|
+
//
|
|
10
|
+
// Due invarianti che una versione semplificata romperebbe in silenzio:
|
|
11
|
+
//
|
|
12
|
+
// 1. In multi-hop lo scope e' l'INTERSEZIONE fra chi consegna e chi origina.
|
|
13
|
+
// `canTransit` autorizza il transito, non l'accesso: un peer B puo'
|
|
14
|
+
// instradare una richiesta di C. Guardare solo B lascerebbe C ereditare i
|
|
15
|
+
// permessi di B. lib/audio/acl.js risolve lo stesso caso controllando
|
|
16
|
+
// entrambi, e qui va replicato invece che semplificato.
|
|
17
|
+
// 2. Una sessione tmux e' concessa solo se lo e' la CELLA a cui appartiene, e
|
|
18
|
+
// una sessione che non appartiene a nessuna cella non e' "libera": e'
|
|
19
|
+
// fuori scope. Altrimenti basterebbe una tmux creata a mano per aggirare
|
|
20
|
+
// il permesso — e `/ws` attacca proprio per nome di sessione.
|
|
21
|
+
//
|
|
22
|
+
// Il permesso e' indicizzato per `nodeId`, non per `name`: il name e' uno slug
|
|
23
|
+
// locale rinominabile, il nodeId e' l'identita' stabile provata dal pairing.
|
|
24
|
+
const nodesStore = require('../nodes/store.js');
|
|
25
|
+
|
|
26
|
+
// Scope di un singolo nodo, normalizzato. `all` non elenca nulla di proposito:
|
|
27
|
+
// una lista che significa "tutte" invecchierebbe a ogni cella nuova.
|
|
28
|
+
function scopeOf(node) {
|
|
29
|
+
if (!node) return { mode: 'none', cells: new Set() };
|
|
30
|
+
const mode = node.cellVisibility || 'all';
|
|
31
|
+
if (mode === 'all') return { mode: 'all', cells: null };
|
|
32
|
+
if (mode === 'none') return { mode: 'none', cells: new Set() };
|
|
33
|
+
return { mode: 'selected', cells: new Set(Array.isArray(node.cells) ? node.cells : []) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Intersezione di due scope. `all` e' l'elemento neutro; `none` assorbe.
|
|
37
|
+
function intersect(a, b) {
|
|
38
|
+
if (a.mode === 'none' || b.mode === 'none') return { mode: 'none', cells: new Set() };
|
|
39
|
+
if (a.mode === 'all') return b;
|
|
40
|
+
if (b.mode === 'all') return a;
|
|
41
|
+
return { mode: 'selected', cells: new Set([...a.cells].filter((c) => b.cells.has(c))) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function findByNodeId(st, nodeId) {
|
|
45
|
+
if (!st || !Array.isArray(st.nodes) || !nodeId) return null;
|
|
46
|
+
return st.nodes.find((n) => n && n.nodeId === nodeId) || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// createCellScope(): deps esplicite, nessun accesso globale.
|
|
50
|
+
// nodesPath percorso del node store
|
|
51
|
+
// loadStoreImpl iniettabile nei test
|
|
52
|
+
// cellForSession mappa tmuxSession -> id cella (null se non e' di una cella)
|
|
53
|
+
function createCellScope({ nodesPath, loadStoreImpl = nodesStore.loadStore, cellForSession = () => null } = {}) {
|
|
54
|
+
// Scope "tutto", usato dal percorso locale: il proprietario della macchina
|
|
55
|
+
// non si limita da solo, e questo modulo non e' il posto dove decidere
|
|
56
|
+
// altrimenti.
|
|
57
|
+
function openScope() {
|
|
58
|
+
return buildApi({ mode: 'all', cells: null });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildApi(resolved, localNodeId = null) {
|
|
62
|
+
const allowsCell = (cell) => {
|
|
63
|
+
if (resolved.mode === 'all') return true;
|
|
64
|
+
if (resolved.mode === 'none') return false;
|
|
65
|
+
return typeof cell === 'string' && resolved.cells.has(cell);
|
|
66
|
+
};
|
|
67
|
+
const allowsSession = (session) => {
|
|
68
|
+
if (resolved.mode === 'all') return true;
|
|
69
|
+
const cell = cellForSession(session);
|
|
70
|
+
// Nessuna cella dietro la sessione => fuori scope. Vedi invariante 2.
|
|
71
|
+
if (!cell) return false;
|
|
72
|
+
return allowsCell(cell);
|
|
73
|
+
};
|
|
74
|
+
// Un tile di deck che punta a un ALTRO nodo non e' una cella di questo
|
|
75
|
+
// hub: lo scope celle governa le celle locali, e la topologia ha gia' le
|
|
76
|
+
// sue regole (canTransit, allowlist federata). Quando pero' non sappiamo
|
|
77
|
+
// chi siamo, un `ownerId` non confrontabile si tratta come LOCALE: e' il
|
|
78
|
+
// verso fail-closed, perche' l'errore costa un tile in meno invece di un
|
|
79
|
+
// nome di cella in piu'.
|
|
80
|
+
const tileIsRemote = (tile) => {
|
|
81
|
+
if (!tile || typeof tile !== 'object') return false;
|
|
82
|
+
if (typeof tile.node === 'string' && tile.node) return true;
|
|
83
|
+
if (typeof tile.ownerId === 'string' && tile.ownerId) {
|
|
84
|
+
return localNodeId ? tile.ownerId !== localNodeId : false;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
mode: resolved.mode,
|
|
90
|
+
cells: resolved.cells ? [...resolved.cells] : null,
|
|
91
|
+
allowsCell,
|
|
92
|
+
allowsSession,
|
|
93
|
+
allowsTile: (tile) => tileIsRemote(tile) || allowsSession(tile && tile.session),
|
|
94
|
+
// Filtri: gli elenchi si restringono con lo STESSO predicato che decide
|
|
95
|
+
// le azioni. Due implementazioni divergenti sarebbero un bug latente.
|
|
96
|
+
filterCells: (list) => (Array.isArray(list) ? list.filter((c) => allowsCell(c && (c.cell ?? c.id))) : []),
|
|
97
|
+
filterSessions: (list) => (Array.isArray(list) ? list.filter((s) => allowsSession(s && (s.name ?? s.tmuxSession))) : []),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// resolve({trust, visited}) -> api dello scope.
|
|
102
|
+
// trust 'local-bridge' (o assente e non federato) : nessuna restrizione
|
|
103
|
+
// trust 'federated' : intersezione consegnante ∩ origine
|
|
104
|
+
function resolve({ trust, visited } = {}) {
|
|
105
|
+
if (trust !== 'federated') return openScope();
|
|
106
|
+
const chain = Array.isArray(visited) ? visited : [];
|
|
107
|
+
// Serve almeno origine + questo nodo. Una catena piu' corta non identifica
|
|
108
|
+
// nessun peer: fail-closed.
|
|
109
|
+
if (chain.length < 2) return buildApi({ mode: 'none', cells: new Set() });
|
|
110
|
+
|
|
111
|
+
let st;
|
|
112
|
+
try { st = loadStoreImpl(nodesPath); } catch (_) { st = null; }
|
|
113
|
+
if (!st) return buildApi({ mode: 'none', cells: new Set() });
|
|
114
|
+
|
|
115
|
+
const deliveringId = chain[chain.length - 2];
|
|
116
|
+
const originId = chain[0];
|
|
117
|
+
// Il CONSEGNANTE deve essere un mio peer: mi ha consegnato la richiesta
|
|
118
|
+
// autenticandosi. Se non lo trovo nello store, fail-closed.
|
|
119
|
+
const delivering = scopeOf(findByNodeId(st, deliveringId));
|
|
120
|
+
// L'ORIGINE no. Un peer transitivo arriva attraverso un hub che io ho
|
|
121
|
+
// autorizzato a instradare, e non ho alcun motivo di conoscerlo: e' il caso
|
|
122
|
+
// normale di una rete a tre nodi (A chiede a C passando per B).
|
|
123
|
+
//
|
|
124
|
+
// Trattare un'origine sconosciuta come `none` sembrava prudente ed era
|
|
125
|
+
// invece una porta chiusa in faccia al traffico legittimo: nella rete reale
|
|
126
|
+
// il terzo nodo smetteva di vedere QUALSIASI cella, senza errore, appena
|
|
127
|
+
// questo codice e' arrivato sui peer. Lo scope celle governa i MIEI peer
|
|
128
|
+
// diretti; chi non e' mio non ha una restrizione mia da subire.
|
|
129
|
+
//
|
|
130
|
+
// Non e' un ampliamento: `all` e' l'elemento neutro dell'intersezione, e la
|
|
131
|
+
// restrizione del consegnante — che e' mio e che ho configurato io —
|
|
132
|
+
// continua a valere per intero. L'invariante resta: un'origine che E' nel
|
|
133
|
+
// mio store porta con se' la propria restrizione anche in multi-hop.
|
|
134
|
+
const originNode = originId === deliveringId ? null : findByNodeId(st, originId);
|
|
135
|
+
const origin = originId === deliveringId
|
|
136
|
+
? delivering
|
|
137
|
+
: (originNode ? scopeOf(originNode) : { mode: 'all', cells: null });
|
|
138
|
+
return buildApi(intersect(delivering, origin), st.nodeId || null);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { resolve };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { createCellScope, scopeOf, intersect };
|
package/lib/cli/commands.js
CHANGED
|
@@ -7,6 +7,7 @@ const { execFileSync, spawn } = require('node:child_process');
|
|
|
7
7
|
const fs = require('node:fs');
|
|
8
8
|
const path = require('node:path');
|
|
9
9
|
const net = require('node:net');
|
|
10
|
+
const crypto = require('node:crypto');
|
|
10
11
|
const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
|
|
11
12
|
const { installPath: serviceInstallPath, ensureLinuxTmuxSurvival } = require('./service.js');
|
|
12
13
|
const { fleetInstallPath } = require('./fleet-service.js');
|
|
@@ -37,7 +38,8 @@ Usage:
|
|
|
37
38
|
nexuscrew boot enable startup at boot (use: boot off|status)
|
|
38
39
|
nexuscrew status show service, port, roles and node status
|
|
39
40
|
nexuscrew stop stop the background service
|
|
40
|
-
nexuscrew restart restart the background service
|
|
41
|
+
nexuscrew restart restart the background service (verifies it came back)
|
|
42
|
+
nexuscrew autoupdate turn automatic updates on or off (use: autoupdate on|off|status)
|
|
41
43
|
nexuscrew doctor run local diagnostics
|
|
42
44
|
nexuscrew nodes list and manage connected peers (use: nodes help)
|
|
43
45
|
nexuscrew help show this help
|
|
@@ -61,6 +63,7 @@ Usage:
|
|
|
61
63
|
[--persist]
|
|
62
64
|
nexuscrew nodes rename <name|nodeId> --label TEXT
|
|
63
65
|
nexuscrew nodes visibility <name|nodeId> network|relay-only|selected
|
|
66
|
+
nexuscrew nodes cells <name|nodeId> all|none|Cella1,Cella2
|
|
64
67
|
[--selected NODE_ID,...]
|
|
65
68
|
nexuscrew nodes share <name|nodeId> on|off [--json]
|
|
66
69
|
nexuscrew nodes invite --ssh TARGET [--ssh-port PORT] [--name SLUG]
|
|
@@ -543,6 +546,25 @@ async function probeNexusCrew(port, token, opts = {}) {
|
|
|
543
546
|
} catch (_) { return false; }
|
|
544
547
|
}
|
|
545
548
|
|
|
549
|
+
// probeNexusCrew restituisce un BOOLEANO e collassa ogni non-200 su `false`:
|
|
550
|
+
// per i suoi molti chiamanti va bene, e cambiarne il contratto ripercuoterebbe
|
|
551
|
+
// ovunque. Ma «non risponde» e «risponde 401» sono due cose opposte — un 401
|
|
552
|
+
// dice che il servizio E' IN PIEDI e che il token locale non vale — e chi deve
|
|
553
|
+
// spiegare un riavvio ha bisogno di distinguerle, altrimenti dichiara fallito
|
|
554
|
+
// un riavvio riuscito e manda a cercare un processo morto che e' vivo.
|
|
555
|
+
// Rilievo dell'audit: il caso token-assente era gia' coperto, questo no.
|
|
556
|
+
async function probeNexusCrewStatus(port, token, opts = {}) {
|
|
557
|
+
const fetchImpl = opts.fetchImpl || globalThis.fetch;
|
|
558
|
+
if (typeof fetchImpl !== 'function') return null;
|
|
559
|
+
try {
|
|
560
|
+
const r = await fetchImpl(`http://127.0.0.1:${port}/api/config`, {
|
|
561
|
+
headers: { authorization: `Bearer ${token || ''}` },
|
|
562
|
+
signal: AbortSignal.timeout?.(700),
|
|
563
|
+
});
|
|
564
|
+
return r.status;
|
|
565
|
+
} catch (_) { return null; } // nessuna risposta: e' un altro guasto
|
|
566
|
+
}
|
|
567
|
+
|
|
546
568
|
async function waitForNexusCrew(port, token, opts = {}) {
|
|
547
569
|
const probe = opts.probeImpl || probeNexusCrew;
|
|
548
570
|
const attempts = opts.waitAttempts === undefined ? 30 : opts.waitAttempts;
|
|
@@ -810,6 +832,113 @@ function logs(opts = {}) {
|
|
|
810
832
|
return { platform, bin, args, follow, keepAlive: true };
|
|
811
833
|
}
|
|
812
834
|
|
|
835
|
+
// autoupdate on|off|status — l'aggiornamento automatico si spegne anche da qui.
|
|
836
|
+
//
|
|
837
|
+
// PERCHE' NEL CLI, visto che la casella esiste gia' in Settings. Perche' il
|
|
838
|
+
// momento in cui serve spegnerlo e' quello in cui la PWA non si apre: il nodo
|
|
839
|
+
// si e' aggiornato, il servizio non e' tornato su, e la riga di comando e'
|
|
840
|
+
// l'unica superficie rimasta. Un controllo che vive solo dove il guasto lo
|
|
841
|
+
// rende irraggiungibile e' mezzo assente.
|
|
842
|
+
//
|
|
843
|
+
// A SERVIZIO ACCESO PASSA DALL'API, e non e' un dettaglio: scrivere il file
|
|
844
|
+
// mentre il processo vive lascerebbe il manager in memoria con il vecchio
|
|
845
|
+
// valore, e continuerebbe ad aggiornare all'ora prevista. Un interruttore che
|
|
846
|
+
// risulta spento e non spegne e' peggio di un interruttore che manca. La route
|
|
847
|
+
// invece persiste E chiama `setEnabled`, quindi vale subito.
|
|
848
|
+
function autoUpdateCommand(args, opts = {}) {
|
|
849
|
+
const log = opts.log || console.log;
|
|
850
|
+
const azione = String(args[0] || 'status').toLowerCase();
|
|
851
|
+
if (!['on', 'off', 'status'].includes(azione)) {
|
|
852
|
+
log('uso: nexuscrew autoupdate on|off|status');
|
|
853
|
+
return { code: 1 };
|
|
854
|
+
}
|
|
855
|
+
const { configPath, tokenPath } = urlmod.resolvePaths(opts);
|
|
856
|
+
const port = urlmod.loadPort(opts);
|
|
857
|
+
const token = urlmod.readToken(tokenPath);
|
|
858
|
+
const attivo = (opts.isServiceRunningImpl || isServiceRunning)({ ...opts });
|
|
859
|
+
const fetchImpl = opts.fetchImpl || fetch;
|
|
860
|
+
|
|
861
|
+
const daFile = () => {
|
|
862
|
+
try {
|
|
863
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
864
|
+
return cfg.autoUpdate !== false;
|
|
865
|
+
} catch (_) { return true; } // default ON, come la configurazione
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
if (azione === 'status') {
|
|
869
|
+
// A servizio acceso vince cio' che il processo sta DAVVERO facendo, non
|
|
870
|
+
// cio' che il file dice: se i due divergono, e' il primo a spegnere o
|
|
871
|
+
// accendere gli aggiornamenti.
|
|
872
|
+
if (!attivo) {
|
|
873
|
+
log(`autoupdate: ${daFile() ? 'on' : 'off'} (da configurazione; servizio non attivo)`);
|
|
874
|
+
return { code: 0 };
|
|
875
|
+
}
|
|
876
|
+
return fetchImpl(`http://127.0.0.1:${port}/api/settings`, {
|
|
877
|
+
headers: { authorization: `Bearer ${token}` },
|
|
878
|
+
}).then(async (r) => {
|
|
879
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
880
|
+
const j = await r.json();
|
|
881
|
+
const acceso = j.autoUpdate !== false;
|
|
882
|
+
log(`autoupdate: ${acceso ? 'on' : 'off'}`);
|
|
883
|
+
if (j.update && j.update.latest) log(` ultima versione vista su npm latest: ${j.update.latest}`);
|
|
884
|
+
return { code: 0 };
|
|
885
|
+
}).catch((e) => {
|
|
886
|
+
log(`autoupdate: ${daFile() ? 'on' : 'off'} (da configurazione; servizio non interrogabile: ${e.message})`);
|
|
887
|
+
return { code: 0 };
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const acceso = azione === 'on';
|
|
892
|
+
if (!attivo) {
|
|
893
|
+
// Servizio spento: si scrive il file, e si DICE che vale al prossimo avvio.
|
|
894
|
+
// Tacerlo lascerebbe credere che abbia gia' effetto.
|
|
895
|
+
let cfg = {};
|
|
896
|
+
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (_) { /* file nuovo */ }
|
|
897
|
+
// Scrittura atomica e rifiuto dei symlink, come fa la route che scrive lo
|
|
898
|
+
// stesso file: tmp nella stessa directory, 0600, rename. Un `writeFileSync`
|
|
899
|
+
// diretto puo' lasciare un config.json troncato se il processo muore a
|
|
900
|
+
// meta' — e un config.json illeggibile e' un nodo che non riparte, cioe'
|
|
901
|
+
// proprio il guasto che questo comando serve a evitare. Rilievo dell'audit.
|
|
902
|
+
try {
|
|
903
|
+
if (fs.lstatSync(configPath).isSymbolicLink()) {
|
|
904
|
+
log('autoupdate: config.json e\' un symlink, non lo scrivo');
|
|
905
|
+
return { code: 1 };
|
|
906
|
+
}
|
|
907
|
+
} catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
908
|
+
const dir = path.dirname(configPath);
|
|
909
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
910
|
+
const tmp = path.join(dir, `.${path.basename(configPath)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
911
|
+
try {
|
|
912
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ ...cfg, autoUpdate: acceso }, null, 2)}\n`, { mode: 0o600 });
|
|
913
|
+
fs.chmodSync(tmp, 0o600);
|
|
914
|
+
fs.renameSync(tmp, configPath);
|
|
915
|
+
} catch (e) {
|
|
916
|
+
try { fs.unlinkSync(tmp); } catch (_) { /* best-effort */ }
|
|
917
|
+
throw e;
|
|
918
|
+
}
|
|
919
|
+
log(`autoupdate: ${azione} (scritto in configurazione; vale al prossimo avvio del servizio)`);
|
|
920
|
+
return { code: 0 };
|
|
921
|
+
}
|
|
922
|
+
return fetchImpl(`http://127.0.0.1:${port}/api/settings/config`, {
|
|
923
|
+
method: 'POST',
|
|
924
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
925
|
+
body: JSON.stringify({ autoUpdate: acceso }),
|
|
926
|
+
}).then(async (r) => {
|
|
927
|
+
if (!r.ok) {
|
|
928
|
+
const j = await r.json().catch(() => ({}));
|
|
929
|
+
log(`autoupdate: non applicato — ${j.error || `HTTP ${r.status}`}`);
|
|
930
|
+
return { code: 1 };
|
|
931
|
+
}
|
|
932
|
+
log(`autoupdate: ${azione} (applicato subito e salvato)`);
|
|
933
|
+
return { code: 0 };
|
|
934
|
+
}).catch((e) => {
|
|
935
|
+
log(`autoupdate: non applicato — ${e.message}`);
|
|
936
|
+
log(' il servizio risulta attivo ma non risponde: non scrivo il file, perche\' resterebbe');
|
|
937
|
+
log(' un valore che il processo vivo non conosce.');
|
|
938
|
+
return { code: 1 };
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
|
|
813
942
|
// update: npm i -g @latest + restart se attivo. Fallimento npm -> messaggio chiaro, code 1.
|
|
814
943
|
function update(opts = {}) {
|
|
815
944
|
const execImpl = opts.execImpl || execFileSync;
|
|
@@ -985,11 +1114,32 @@ async function dispatchNodes(rest, flags, opts = {}) {
|
|
|
985
1114
|
: String(flags.selected).split(',').map((value) => value.trim()).filter(Boolean);
|
|
986
1115
|
return { code: nodesCmds.nodesEdit({ ...opts, log, ref, patch: { visibility, selected } }).code };
|
|
987
1116
|
}
|
|
1117
|
+
// Scope celle (NC-E): quali celle di QUESTO nodo il peer puo' vedere.
|
|
1118
|
+
// Diverso da `visibility`, che governa il transito e non l'accesso.
|
|
1119
|
+
if (sub === 'cells') {
|
|
1120
|
+
const arg = rest[3];
|
|
1121
|
+
if (!arg) {
|
|
1122
|
+
log('nodes cells: uso `nodes cells <nodo> all|none|Cella1,Cella2`');
|
|
1123
|
+
return { code: 1 };
|
|
1124
|
+
}
|
|
1125
|
+
if (arg === 'all' || arg === 'none') {
|
|
1126
|
+
return { code: nodesCmds.nodesEdit({ ...opts, log, ref, patch: { cellVisibility: arg, cells: undefined } }).code };
|
|
1127
|
+
}
|
|
1128
|
+
const cells = String(arg).split(',').map((v) => v.trim()).filter(Boolean);
|
|
1129
|
+
if (!cells.length) { log('nodes cells: elenco vuoto — usa `none` se intendi nessuna cella'); return { code: 1 }; }
|
|
1130
|
+
return { code: nodesCmds.nodesEdit({ ...opts, log, ref, patch: { cellVisibility: 'selected', cells } }).code };
|
|
1131
|
+
}
|
|
988
1132
|
if (sub === 'remove') {
|
|
989
1133
|
if (flags.yes !== true) { log('nodes remove: conferma richiesta con --yes'); return { code: 1 }; }
|
|
990
1134
|
return { code: nodesCmds.nodesRemove({ ...opts, log, ref }).code };
|
|
991
1135
|
}
|
|
992
|
-
|
|
1136
|
+
// Senza riferimento non e' un errore: e' la domanda "quali nodi sono davvero
|
|
1137
|
+
// condivisi?", che prima si poteva fare solo un nodo alla volta.
|
|
1138
|
+
if (sub === 'test') {
|
|
1139
|
+
return ref
|
|
1140
|
+
? { code: (await nodesCmds.nodesTest({ ...opts, log, ref })).code }
|
|
1141
|
+
: { code: (await nodesCmds.nodesTestAll({ ...opts, log })).code };
|
|
1142
|
+
}
|
|
993
1143
|
if (['up', 'down', 'connect', 'disconnect', 'restart', 'reconnect'].includes(sub)) {
|
|
994
1144
|
const fn = sub === 'up' || sub === 'connect' ? nodesCmds.nodesUp
|
|
995
1145
|
: sub === 'restart' || sub === 'reconnect' ? nodesCmds.nodesRestart : nodesCmds.nodesDown;
|
|
@@ -1136,9 +1286,134 @@ function dispatch(argv, opts = {}) {
|
|
|
1136
1286
|
const result = stop({ ...opts, log });
|
|
1137
1287
|
return { code: result.stopped || ['not running', 'stale pidfile'].includes(result.reason) ? 0 : 1 };
|
|
1138
1288
|
}
|
|
1289
|
+
if (cmd === 'autoupdate') {
|
|
1290
|
+
return autoUpdateCommand(rest.slice(1), { ...opts, log });
|
|
1291
|
+
}
|
|
1139
1292
|
if (cmd === 'restart') {
|
|
1140
|
-
|
|
1141
|
-
|
|
1293
|
+
// Seam come negli altri punti del file (`opts.restartImpl || restart`):
|
|
1294
|
+
// serve a poter dichiarare in prova QUALE runtime e' in piedi, perche' il
|
|
1295
|
+
// ritentativo si comporta in modo diverso fra gestito e portatile — e senza
|
|
1296
|
+
// il seam un test finisce per esercitare il ramo che non intendeva.
|
|
1297
|
+
const result = (opts.restartImpl || restart)({ ...opts, log });
|
|
1298
|
+
if (!result.restarted) return { code: 1 };
|
|
1299
|
+
// IL RIAVVIO ORA VERIFICA DI ESSERE TORNATO SU, e prima no. `restart`
|
|
1300
|
+
// dichiarava successo appena `systemctl restart` (o launchctl, o l'avvio
|
|
1301
|
+
// portatile) ritornava: quella e' la conferma che il COMANDO e' partito,
|
|
1302
|
+
// non che il servizio risponda. Su Termux, il 2026-08-07, ha restituito 0
|
|
1303
|
+
// con il servizio morto, e il nodo e' rimasto giu' oltre quattro ore senza
|
|
1304
|
+
// che nessuno lo sapesse — da fuori si vedeva solo un KO generico.
|
|
1305
|
+
//
|
|
1306
|
+
// La verifica esisteva gia' e veniva usata dal percorso di auto-update e
|
|
1307
|
+
// dal bootstrap Fleet («serve un restart verificato»); mancava proprio sul
|
|
1308
|
+
// comando che una persona digita a mano. Con l'auto-update acceso questo
|
|
1309
|
+
// riavvio avviene DA SOLO su ogni nodo, quindi un esito non verificato si
|
|
1310
|
+
// moltiplica per la flotta.
|
|
1311
|
+
//
|
|
1312
|
+
// Il ramo restituisce una PROMESSA e `dispatch` resta sincrona: e' il
|
|
1313
|
+
// disegno che il file ha gia' (dispatchNodes fa lo stesso) e il chiamante
|
|
1314
|
+
// avvolge in `Promise.resolve`. Cambiare la firma di dispatch avrebbe
|
|
1315
|
+
// rotto ottanta test che la chiamano in modo sincrono, per una verifica
|
|
1316
|
+
// che riguarda un comando solo.
|
|
1317
|
+
const { tokenPath } = urlmod.resolvePaths(opts);
|
|
1318
|
+
const port = urlmod.loadPort(opts);
|
|
1319
|
+
const token = urlmod.readToken(tokenPath);
|
|
1320
|
+
// SENZA TOKEN NON SI PUO' DIRE NIENTE SULLA SALUTE, e soprattutto non si
|
|
1321
|
+
// deve dire qualcosa di sbagliato: la sonda fallirebbe per autenticazione e
|
|
1322
|
+
// il messaggio incolperebbe la porta o il processo, mandando a cercare dove
|
|
1323
|
+
// il problema non e'. Rilievo dell'audit. Il riavvio e' comunque partito,
|
|
1324
|
+
// quindi non e' un fallimento: e' una verifica che non si e' potuta fare, e
|
|
1325
|
+
// si dice cosi'.
|
|
1326
|
+
if (!token) {
|
|
1327
|
+
log('restart: comando eseguito, ma la salute NON e\' verificabile senza token locale.');
|
|
1328
|
+
log(' Controlla a mano che il servizio risponda.');
|
|
1329
|
+
return { code: 0 };
|
|
1330
|
+
}
|
|
1331
|
+
const attesa = (sano) => {
|
|
1332
|
+
if (sano) {
|
|
1333
|
+
log(`restart: servizio verificato su 127.0.0.1:${port}`);
|
|
1334
|
+
return { code: 0 };
|
|
1335
|
+
}
|
|
1336
|
+
// NON E' ANCORA UN FALLIMENTO: si prova a capire PERCHE' e, se e' la
|
|
1337
|
+
// causa nota, si rimedia una volta sola.
|
|
1338
|
+
//
|
|
1339
|
+
// LA CAUSA NOTA. Sul percorso portatile — quello di Termux, dove non c'e'
|
|
1340
|
+
// un gestore di servizi che rialzi il processo — `restart` avvia il
|
|
1341
|
+
// nuovo processo SUBITO dopo aver fermato il vecchio, senza aspettare che
|
|
1342
|
+
// muoia ne' che la porta si liberi. Il nuovo non riesce ad ascoltare,
|
|
1343
|
+
// esce, e nessuno se ne accorge: servizio giu', tunnel su (e' un
|
|
1344
|
+
// processo SSH separato), nessun auto-recupero. Misurato il 2026-08-07:
|
|
1345
|
+
// oltre venti minuti in quello stato.
|
|
1346
|
+
//
|
|
1347
|
+
// Il percorso dell'AUTO-UPDATE fa gia' la cosa giusta — aspetta che il
|
|
1348
|
+
// processo sia morto E che la porta sia libera, fino a sei secondi, e
|
|
1349
|
+
// solo allora avvia. Le due strade erano divergenti, e quella che una
|
|
1350
|
+
// persona digita a mano era la meno prudente.
|
|
1351
|
+
//
|
|
1352
|
+
// PRIMA DI DARE LA COLPA A QUALCUNO, si guarda se il servizio risponde
|
|
1353
|
+
// affatto. Un 401 significa che E' IN PIEDI e che il token locale non
|
|
1354
|
+
// vale: il riavvio e' riuscito, e dichiararlo fallito manderebbe a
|
|
1355
|
+
// cercare un processo morto che invece e' vivo.
|
|
1356
|
+
return (opts.probeStatusImpl || probeNexusCrewStatus)(port, token, opts).then((stato) => {
|
|
1357
|
+
if (stato === 401 || stato === 403) {
|
|
1358
|
+
log(`restart: il servizio RISPONDE su 127.0.0.1:${port}, ma il token locale non e' valido.`);
|
|
1359
|
+
log(' Il riavvio e\' riuscito; e\' la credenziale a non funzionare.');
|
|
1360
|
+
log(' Rigenera il token locale, poi riprova a collegarti.');
|
|
1361
|
+
return { code: 1 };
|
|
1362
|
+
}
|
|
1363
|
+
return continua();
|
|
1364
|
+
});
|
|
1365
|
+
};
|
|
1366
|
+
const continua = () => {
|
|
1367
|
+
// SOLO SUL RUNTIME PORTATILE. E' li' che manca un supervisore — ed e'
|
|
1368
|
+
// esattamente la ragione per cui il difetto esiste: su Termux nessuno
|
|
1369
|
+
// rialza il processo. Dove il servizio e' gestito (systemd, launchd) il
|
|
1370
|
+
// supervisore c'e' ed e' suo il compito: avviare noi un processo
|
|
1371
|
+
// portatile accanto significherebbe metterne in piedi uno che il gestore
|
|
1372
|
+
// non conosce, mentre il gestore puo' rialzare la propria unita' — due
|
|
1373
|
+
// processi sulla stessa porta, e il nostro sopravvivrebbe allo stop del
|
|
1374
|
+
// servizio. Li' si riferisce e basta.
|
|
1375
|
+
//
|
|
1376
|
+
// Difetto mio, trovato rileggendo prima dell'audit: la prima stesura
|
|
1377
|
+
// chiamava `startPortable` in ogni caso.
|
|
1378
|
+
if (result.runtimeOwner === 'managed') {
|
|
1379
|
+
log(`restart: il servizio NON risponde su 127.0.0.1:${port} dopo il riavvio.`);
|
|
1380
|
+
log(' Il runtime e\' gestito dal servizio di sistema: non avvio un processo accanto.');
|
|
1381
|
+
log(' Controlla lo stato e i log dell\'unita\' di sistema.');
|
|
1382
|
+
return { code: 1 };
|
|
1383
|
+
}
|
|
1384
|
+
// UN SOLO RITENTATIVO, e solo a porta libera. Se la porta e' ancora
|
|
1385
|
+
// occupata il problema e' un altro (un processo che non muore) e
|
|
1386
|
+
// riprovare lo nasconderebbe; se e' libera e il servizio non c'e', il
|
|
1387
|
+
// nuovo processo e' uscito e riavviarlo e' esattamente il rimedio.
|
|
1388
|
+
// Ripetere all'infinito trasformerebbe un guasto in un ciclo.
|
|
1389
|
+
return (opts.portAvailableImpl || portAvailable)(port, '127.0.0.1').then((libera) => {
|
|
1390
|
+
if (!libera) {
|
|
1391
|
+
log(`restart: il servizio NON risponde su 127.0.0.1:${port} e la porta e' ancora occupata.`);
|
|
1392
|
+
log(' Qualcosa tiene la porta senza servire: non riavvio a vuoto.');
|
|
1393
|
+
log(' Controlla i log del servizio e i processi rimasti.');
|
|
1394
|
+
return { code: 1 };
|
|
1395
|
+
}
|
|
1396
|
+
log(`restart: nessun servizio su ${port} e porta libera — il processo e' uscito. Riprovo una volta.`);
|
|
1397
|
+
(opts.startPortableImpl || startPortable)({ ...opts, spawnImpl: opts.spawnImpl });
|
|
1398
|
+
return (opts.waitForRuntimeImpl || waitForNexusCrew)(port, token, {
|
|
1399
|
+
...opts, waitAttempts: opts.waitAttempts === undefined ? 60 : opts.waitAttempts,
|
|
1400
|
+
waitDelayMs: opts.waitDelayMs === undefined ? 250 : opts.waitDelayMs,
|
|
1401
|
+
}).then((sanoOra) => {
|
|
1402
|
+
if (sanoOra) {
|
|
1403
|
+
log(`restart: servizio verificato su 127.0.0.1:${port} al secondo tentativo`);
|
|
1404
|
+
return { code: 0 };
|
|
1405
|
+
}
|
|
1406
|
+
log(`restart: il servizio NON risponde su 127.0.0.1:${port} nemmeno dopo un secondo avvio.`);
|
|
1407
|
+
log(' Il comando di riavvio e\' partito, il processo non resta su.');
|
|
1408
|
+
log(' Controlla i log del servizio; su Termux puo\' servire riavviare il dispositivo.');
|
|
1409
|
+
return { code: 1 };
|
|
1410
|
+
});
|
|
1411
|
+
});
|
|
1412
|
+
};
|
|
1413
|
+
return (opts.waitForRuntimeImpl || waitForNexusCrew)(port, token, {
|
|
1414
|
+
...opts, waitAttempts: opts.waitAttempts === undefined ? 60 : opts.waitAttempts,
|
|
1415
|
+
waitDelayMs: opts.waitDelayMs === undefined ? 250 : opts.waitDelayMs,
|
|
1416
|
+
}).then(attesa);
|
|
1142
1417
|
}
|
|
1143
1418
|
// Internal runtime commands used by service managers and MCP clients. They
|
|
1144
1419
|
// are intentionally omitted from HELP and are not configuration surfaces.
|