@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.
@@ -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 };
@@ -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
- if (sub === 'test') return { code: (await nodesCmds.nodesTest({ ...opts, log, ref })).code };
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
- const result = restart({ ...opts, log });
1141
- return { code: result.restarted ? 0 : 1 };
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.