@mmmbuto/nexuscrew 0.9.2 → 0.9.4

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.
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-uRh_hSop.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-0vuhL1YP.css">
14
+ <script type="module" crossorigin src="/assets/index-DFI1J-u5.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-CbMytNmg.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.9.2"}
1
+ {"version":"0.9.4"}
@@ -23,7 +23,7 @@ const peering = require('../nodes/peering.js');
23
23
  // Service/portable runtime ownership + start/stop lifecycle (Phase 3 extraction).
24
24
  const runtimeLifecycle = require('./runtime-lifecycle.js');
25
25
  const {
26
- start, stop, isServiceRunning, bootState, stopManagedTunnels,
26
+ start, stop, isServiceRunning, bootState, stopManagedTunnels, fermaTunnelPrimaDiRiavviare,
27
27
  managedRuntimeState, portableRuntimeState, resolveRuntimeOwner,
28
28
  refusePairedPortRelocation, stopPortableRuntime, unlinkRegular,
29
29
  } = runtimeLifecycle;
@@ -350,12 +350,18 @@ function restart(opts = {}) {
350
350
  if (!portable.killed) return { platform, owner: before.owner, restarted: false, reason: portable.reason };
351
351
  }
352
352
  try {
353
+ // I tunnel si fermano PRIMA, dal punto unico condiviso con ogni altro
354
+ // percorso di riavvio. Qui la policy è fail-closed: se il passo non
355
+ // riesce, il riavvio gestito non parte — un tunnel vivo sopra un
356
+ // servizio riavviato è il guasto misurato sul campo (2026-08-07).
357
+ const tunnels = fermaTunnelPrimaDiRiavviare(opts);
358
+ if (tunnels.error) {
359
+ return { platform, owner: before.owner, runtimeOwner: 'managed', restarted: false, reason: `stop tunnel: ${tunnels.error}` };
360
+ }
353
361
  if (platform === 'linux') {
354
- (opts.stopTunnelsImpl || stopManagedTunnels)(opts);
355
362
  execImpl('systemctl', ['--user', 'restart', 'nexuscrew'], { stdio: 'ignore' });
356
363
  log('restart: systemctl --user restart nexuscrew');
357
364
  } else {
358
- (opts.stopTunnelsImpl || stopManagedTunnels)(opts);
359
365
  const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
360
366
  execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
361
367
  log(`restart: launchctl kickstart -k ${label}`);
@@ -1505,6 +1511,8 @@ module.exports = {
1505
1511
  isServiceRunning, readRoles,
1506
1512
  bootState, bootOn, bootOff, quickSummary,
1507
1513
  stopManagedTunnels,
1514
+ fermaTunnelPrimaDiRiavviare,
1515
+ stopPortableRuntime,
1508
1516
  managedRuntimeState, portableRuntimeState, resolveRuntimeOwner,
1509
1517
  runFleetBoot, dispatchFleetBoot,
1510
1518
  };
package/lib/cli/init.js CHANGED
@@ -17,7 +17,7 @@ const {
17
17
  generateFleetService, installFleetService, migrationGate,
18
18
  selectProviderModeSync, fleetFileMode,
19
19
  } = require('./fleet-service.js');
20
- const { atomicWrite: writeFleet } = require('../fleet/definitions.js');
20
+ const { aggiornaDefinizioni } = require('../fleet/definitions.js');
21
21
  const { defaultDefinitions } = require('../fleet/managed.js');
22
22
  const { commandExists, resolveCommand } = require('./path.js');
23
23
 
@@ -83,8 +83,23 @@ function ensureFleetDefaults(opts = {}) {
83
83
  if (e.code !== 'ENOENT') throw e;
84
84
  }
85
85
 
86
- writeFleet(fleetDefsPath, defaultDefinitions());
87
- return { path: fleetDefsPath, created: true, enabled: true };
86
+ // Fra il controllo qui sopra e la scrittura c'e' una finestra: se un altro
87
+ // processo crea le definizioni nel frattempo, scrivere i default sopra le sue
88
+ // significa cancellare una configurazione intera — non perdere una modifica.
89
+ // La creazione avviene quindi sotto lock, con l'esistenza RIVERIFICATA
90
+ // dentro: `seMancante` viene chiamata solo se il file davvero non c'e'.
91
+ // `propaga` perche' `created` dica il vero: senza, una rinuncia del lock
92
+ // restituiva lo stato riletto — truthy — e l'init dichiarava `created: true`
93
+ // per un file che non aveva creato.
94
+ let creato = null;
95
+ try {
96
+ creato = aggiornaDefinizioni(fleetDefsPath, () => null, {
97
+ seMancante: () => defaultDefinitions(),
98
+ propaga: true,
99
+ log: opts.log,
100
+ });
101
+ } catch (_) { creato = null; }
102
+ return { path: fleetDefsPath, created: Boolean(creato), enabled: true };
88
103
  }
89
104
 
90
105
  // Migration rule (B2): se non c'è config.json, parse la porta dal service file esistente.
@@ -112,12 +112,26 @@ function cmdMatches(savedCmd, liveCmd) {
112
112
  return liveCmd.includes(savedCmd) || savedCmd.includes(liveCmd);
113
113
  }
114
114
 
115
- // true se il pid appartiene a questo UID E il cmd matcha (o non verificabile).
115
+ // true se il pid appartiene a questo UID, la NASCITA è ancora quella attestata
116
+ // dal pidfile e il cmd matcha (o non è verificabile).
116
117
  // EPERM is deliberately false: NexusCrew must neither adopt nor signal a
117
118
  // process owned by another Android/Linux user.
119
+ // La nascita (processStart, già scritta da writePidfile) decide PRIMA del cmd:
120
+ // cmdMatches è per inclusioni, quindi un comando più lungo del salvato matcha
121
+ // — due supervisor dello stesso tunnel, due serve, il restart di ieri. Se il
122
+ // numero è nato in un istante diverso da quello attestato, il proprietario è
123
+ // morto e il numero è di un altro: il pidfile è stale anche se il cmd «quadra».
124
+ // Non calcolabile (meta senza processStart: pidfile vecchio o /proc nascosto;
125
+ // lettura che ora non riesce: macOS senza ps) → nel dubbio vale il cmd, come
126
+ // sempre: mai dichiarare morto un vivo. Stessa identità e stessa asimmetria
127
+ // del lock delle definizioni (pid + nascita; il falso-morto non esiste).
118
128
  function isAlive(meta, impl = {}) {
119
129
  if (!meta || !Number.isFinite(meta.pid)) return false;
120
130
  if (pidOwnership(meta.pid, impl.killImpl || process.kill) !== 'owned') return false;
131
+ if (meta.processStart) {
132
+ const liveStart = (impl.readProcessStartImpl || readProcessStart)(meta.pid);
133
+ if (liveStart && liveStart !== meta.processStart) return false; // riassegnato
134
+ }
121
135
  if (meta.cmd) {
122
136
  const live = (impl.readCmdlineImpl || readCmdline)(meta.pid);
123
137
  if (live) return cmdMatches(meta.cmd, live);
@@ -171,6 +185,18 @@ function killPidfile(p, signal = 'SIGTERM', impl = {}) {
171
185
  return { killed: false, reason: 'pid reuse (cmd mismatch)', liveCmd: live };
172
186
  }
173
187
  }
188
+ // PID reuse che il cmd NON vede: due processi con lo stesso comando (o un
189
+ // comando che lo contiene). La nascita li distingue: se il numero è nato in
190
+ // un istante diverso da quello attestato, il segnale andrebbe al processo
191
+ // SBAGLIATO — vivo, nostro, magari legittimo. Non si segnala; il pidfile è
192
+ // stale e si toglie (allowLive: garanzia verificata QUI, come dopo il kill).
193
+ if (meta.processStart) {
194
+ const liveStart = (impl.readProcessStartImpl || readProcessStart)(meta.pid);
195
+ if (liveStart && liveStart !== meta.processStart) {
196
+ removePidfile(p, { allowLive: true });
197
+ return { killed: false, reason: 'pid reuse (start mismatch)' };
198
+ }
199
+ }
174
200
  try {
175
201
  killImpl(meta.pid, signal);
176
202
  // allowLive: il segnale e' partito verso il pid VERIFICATO del file (cmd
@@ -103,6 +103,31 @@ function stopManagedTunnels(opts = {}) {
103
103
  return stopped;
104
104
  }
105
105
 
106
+ // IL PASSO «I TUNNEL SI FERMANO PRIMA», una volta sola. Ogni percorso di
107
+ // riavvio — commands.restart (sincrona, mano), stop() e il ramo portatile
108
+ // dell'aggiornamento automatico — attraversa QUESTO ingresso: è il posto
109
+ // unico dove un domani si aggiunge un passo al riavvio, invece di scriverlo
110
+ // in una copia e dimenticarlo nell'altra (la divergenza trovata tre volte,
111
+ // una per guasto).
112
+ //
113
+ // Non lancia MAI: il passo è intrinsecamente best-effort (un tunnel che non
114
+ // si ferma non deve lasciare il riavvio a metà in modo diverso a seconda del
115
+ // chiamante) — ma non tace: l'esito torna a chi chiama, e la POLICY (abort
116
+ // fail-closed per il riavvio gestito, proseguire per l'aggiornamento il cui
117
+ // compito principale è aggiornare) resta a chi la possiede.
118
+ function fermaTunnelPrimaDiRiavviare(opts = {}) {
119
+ const log = opts.log || (() => {});
120
+ try {
121
+ const stopped = (opts.stopTunnelsImpl || stopManagedTunnels)(opts);
122
+ return { stopped, error: null };
123
+ } catch (e) {
124
+ const reason = String(e && e.message ? e.message : e);
125
+ // Tacerlo lo renderebbe irrintracciabile la prossima volta.
126
+ log(`stop tunnel non riuscito — ${reason}`);
127
+ return { stopped: [], error: reason };
128
+ }
129
+ }
130
+
106
131
  function refusePairedPortRelocation(opts, from, to) {
107
132
  if (Number(from) === Number(to)) return;
108
133
  const { configDir } = urlmod.resolvePaths(opts);
@@ -240,7 +265,11 @@ function stop(opts = {}) {
240
265
 
241
266
  // Detached tunnel supervisors are outside both server owners. A real stop
242
267
  // closes them once; the next start restores only autostart:true links.
243
- (opts.stopTunnelsImpl || stopManagedTunnels)(opts);
268
+ // Attraverso lo STESSO ingresso del riavvio (fermaTunnelPrimaDiRiavviare):
269
+ // prima un fallimento qui sfuggiva come eccezione non gestita al chiamante
270
+ // sincrono; ora è esito del stop, come ogni altro errore qui sopra.
271
+ const tunnels = fermaTunnelPrimaDiRiavviare(opts);
272
+ if (tunnels.error) errors.push(`stop tunnel: ${tunnels.error}`);
244
273
  if (platform === 'termux') {
245
274
  try { execImpl('termux-wake-lock-release', [], { stdio: 'ignore' }); } catch (_) {}
246
275
  }
@@ -286,6 +315,7 @@ function unlinkRegular(target) {
286
315
  module.exports = {
287
316
  start,
288
317
  stopManagedTunnels,
318
+ fermaTunnelPrimaDiRiavviare,
289
319
  refusePairedPortRelocation,
290
320
  managedRuntimeState,
291
321
  portableRuntimeState,
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+ // Telemetria per-cella: contesto LIBERO e tier 5h/7d USATI, letti dal file che
3
+ // la statusline di Claude Code scrive in <root>/<sessione>/telemetry.json
4
+ // (snippet documentato in docs/STATUSLINE_TELEMETRY.md, NON applicato: la
5
+ // statusline e' dell'operatore). Il verso dei due dati e' OPPOSTO e i nomi del file lo
6
+ // portano scritto dentro: `contextFreePct` e' quanto RESTA, `tier*UsedPct` e'
7
+ // quanto e' STATO CONSUMATO. Confondere i versi produce una riga che dice il
8
+ // contrario del vero e fa prendere la decisione opposta a quella giusta.
9
+ //
10
+ // Tre regole non opinabili, tutte implementate qui:
11
+ // 1. TIMESTAMP OBBLIGATORIO: oltre MASSIMA_ETA_MS il dato e' morto e viene
12
+ // restituito null — un numero stantio che sembra fresco e' peggio di un
13
+ // numero assente.
14
+ // 2. ASSENZA LEGITTIMA: le celle non-Claude (codex-vl, agy, grok, shell) non
15
+ // hanno quella statusline e non avranno mai questo file. File assente =
16
+ // null, e la riga della lista resta com'era: niente campo, niente
17
+ // trattino, niente «n/d».
18
+ // 3. LETTURA TOLLERANTE: file illeggibile, JSON rotto, campi mancanti o
19
+ // fuori contratto — si degrada a null senza mai far fallire la lista.
20
+ //
21
+ // CONTRATTO del file: valori INTERI 0..100 (gia' percentuali — la
22
+ // normalizzazione frazione→percentuale e' compito di chi scrive, vedi lo
23
+ // snippet). Il lettore accetta SOLO interi: una frazione scritta per errore
24
+ // (0.5 che voleva essere 50%) viene rifiutata, non arrotondata a 1% — un
25
+ // numero sbagliato mostrato con sicurezza e' il difetto che conta, meglio
26
+ // nessun numero.
27
+
28
+ const fs = require('node:fs');
29
+ const path = require('node:path');
30
+
31
+ // La statusline aggiorna a ogni evento del modello: in una cella viva il file
32
+ // e' sempre piu' fresco di cosi'. Cinque minuti coprono una pausa pranzo senza
33
+ // mostrare come attuale il dato di ieri.
34
+ const MASSIMA_ETA_MS = 5 * 60 * 1000;
35
+ // La soglia guarda in ENTRAMBI i versi. Un ts nel futuro farebbe `ora - ts`
36
+ // negativo: la differenza non supera MAI la massima eta' e il dato resterebbe
37
+ // «fresco» per sempre — un orologio avanti, o uno ts scritto male, e la riga
38
+ // mostra un numero morto che non scadra' mai. Due minuti di skew sono il
39
+ // margine che un orologio legittimamente sforato puo' avere; oltre, il ts e'
40
+ // rotto e il dato non esiste.
41
+ const FUTURO_TOLLERATO_MS = 2 * 60 * 1000;
42
+
43
+ const NOME_FILE = 'telemetry.json';
44
+
45
+ // Accetta SOLO interi 0..100 gia' numeri. Tutto il resto e' fuori contratto
46
+ // e non viene mostrato — in particolare null e i booleani: `Number(null)` e'
47
+ // 0 e `Number(true)` e' 1, e un campo assente letto come «0% usato» e'
48
+ // esattamente il numero sbagliato-mostrato-con-sicurezza che questo modulo
49
+ // esiste per evitare.
50
+ function percentualeIntera(valore) {
51
+ if (typeof valore !== 'number' || !Number.isInteger(valore)) return null;
52
+ if (valore < 0 || valore > 100) return null;
53
+ return valore;
54
+ }
55
+
56
+ /**
57
+ * Legge la telemetria di una sessione. Ritorna `{ ts, contextFreePct?,
58
+ * tier5hUsedPct?, tier7dUsedPct? }` con solo i campi validi, oppure null per
59
+ * assenza legittima, dato stantio o qualsiasi rotture. Non lancia MAI.
60
+ */
61
+ function leggiTelemetria(root, sessione, ora = Date.now()) {
62
+ try {
63
+ let raw;
64
+ try {
65
+ raw = fs.readFileSync(path.join(root, String(sessione), NOME_FILE), 'utf8');
66
+ } catch (_) {
67
+ return null; // assente (o non leggibile): cella non-Claude o primo avvio
68
+ }
69
+ const dato = JSON.parse(raw);
70
+ if (!dato || typeof dato !== 'object' || Array.isArray(dato)) return null;
71
+ const ts = Number(dato.ts);
72
+ // Senza timestamp non c'e' freschezza da verificare: il dato non esiste.
73
+ if (!Number.isFinite(ts)) return null;
74
+ if (ora - ts > MASSIMA_ETA_MS) return null; // stantio = assente
75
+ if (ts - ora > FUTURO_TOLLERATO_MS) return null; // ts rotto: «fresco per sempre» non e' fresco
76
+ const campi = {};
77
+ const libero = percentualeIntera(dato.contextFreePct);
78
+ const t5 = percentualeIntera(dato.tier5hUsedPct);
79
+ const t7 = percentualeIntera(dato.tier7dUsedPct);
80
+ if (libero !== null) campi.contextFreePct = libero;
81
+ if (t5 !== null) campi.tier5hUsedPct = t5;
82
+ if (t7 !== null) campi.tier7dUsedPct = t7;
83
+ return Object.keys(campi).length ? { ts, ...campi } : null;
84
+ } catch (_) {
85
+ return null; // JSON rotto o altro: la lista non fallisce per questo
86
+ }
87
+ }
88
+
89
+ module.exports = { leggiTelemetria, MASSIMA_ETA_MS, FUTURO_TOLLERATO_MS, NOME_FILE };