@mmmbuto/nexuscrew 0.9.51 → 0.9.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/lib/server.js CHANGED
@@ -7,7 +7,6 @@ const { execFileSync } = require('node:child_process');
7
7
  const express = require('express');
8
8
  const { WebSocketServer } = require('ws');
9
9
  const { defaults, loadConfig, assertLoopback, configJsonPath } = require('./config.js');
10
- const { writeConfigAtomic } = require('./cli/init.js');
11
10
  const { listSessions, listPanes, attachedClients, setSessionVisibility } = require('./tmux/list.js');
12
11
  const { statiSupervisore: statiSupervisoreDi, senzaSupervisore } = require('./tmux/supervisor-pane.js');
13
12
  const { runAction, pasteToSession, submitToSession } = require('./tmux/actions.js');
@@ -68,6 +67,9 @@ const { parseVisitedChain } = require('./audio/origin.js');
68
67
  const { settingsRoutes, publicPeeringRoutes } = require('./settings/routes.js');
69
68
  const decksStore = require('./decks/store.js');
70
69
  const { decksRoutes } = require('./decks/routes.js');
70
+ const prefsStore = require('./prefs/store.js');
71
+ const messages = require('./cli/messages.js');
72
+ const { prefsRoutes } = require('./prefs/routes.js');
71
73
  const { createEventsHub } = require('./notify/events.js');
72
74
  const { createPushService } = require('./notify/push.js');
73
75
  const { createPushRelay, createPushDedup } = require('./notify/push-relay.js');
@@ -407,6 +409,7 @@ function createServer(opts = {}) {
407
409
  const healthFetch = cfg.fetchImpl || fetch;
408
410
  const topologyCachePath = cfg.topologyCachePath || topologyCache.defaultPath(cfg.home || os.homedir());
409
411
  const decksPath = cfg.decksPath || decksStore.defaultDecksPath(cfg.home || os.homedir());
412
+ const prefsPath = cfg.prefsPath || prefsStore.defaultPrefsPath(cfg.home || os.homedir());
410
413
  const proxyReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
411
414
  const audioHome = cfg.home || os.homedir();
412
415
  // Stato audio ancorato alla directory del token (come notifyDir): server,
@@ -1146,6 +1149,8 @@ function createServer(opts = {}) {
1146
1149
  // tetto dello snapshot non si conclude nulla, perche' un'assenza potrebbe
1147
1150
  // essere un taglio della lista e non una chiusura.
1148
1151
  const SNAPSHOT_FULL = 100;
1152
+ // Retry programmati della riconciliazione: uno per owner, senza duplicati.
1153
+ const reconcileRetryArmed = new Set();
1149
1154
  async function reconcileImportedAsks() {
1150
1155
  const store = nodesStore.loadStore(nodesPath);
1151
1156
  if (!store) return;
@@ -1156,18 +1161,38 @@ function createServer(opts = {}) {
1156
1161
  if (!byOwner.has(a.ownerId)) byOwner.set(a.ownerId, []);
1157
1162
  byOwner.get(a.ownerId).push(a);
1158
1163
  }
1164
+ // Porta unica; SOLO 'ok' è autorevole: uno skip, un pending, una finestra
1165
+ // chiusa, un errore o un timeout non chiudono MAI un alias aperto. Su
1166
+ // skipped/pending UN tentativo programmato alla scadenza della finestra,
1167
+ // per owner, senza duplicati: senza, una GET caduta in finestra lascerebbe
1168
+ // la card aperta fino alla prossima lettura dell'operatore.
1159
1169
  for (const [ownerId, aliases] of byOwner) {
1160
- const peer = (store.nodes || []).find((n) => n && n.nodeId === ownerId && n.token && n.localPort);
1161
- if (!peer) continue;
1162
- let snap = null;
1170
+ let door = null;
1163
1171
  try {
1164
- const res = await fetch(`http://127.0.0.1:${peer.localPort}/federation/route/_/event-feed/snapshot`, {
1165
- headers: { authorization: `Bearer ${peer.token}` }, signal: AbortSignal.timeout(3000),
1166
- });
1167
- if (!res.ok) continue;
1168
- snap = await res.json();
1169
- } catch (_) { continue; }
1170
- const live = new Set((snap && Array.isArray(snap.asks) ? snap.asks : []).map((x) => String(x.id)));
1172
+ door = eventFeedClient && typeof eventFeedClient.ownerSnapshotAsks === 'function'
1173
+ ? await eventFeedClient.ownerSnapshotAsks(ownerId) : null;
1174
+ } catch (_) { door = null; }
1175
+ if (!door || door.status !== 'ok') {
1176
+ if (door && (door.status === 'skipped' || door.status === 'pending') && !reconcileRetryArmed.has(ownerId)) {
1177
+ reconcileRetryArmed.add(ownerId);
1178
+ const delay = Math.max(250, (door.retryAt || (Date.now() + 1000)) - Date.now());
1179
+ const t = setTimeout(() => {
1180
+ reconcileRetryArmed.delete(ownerId);
1181
+ void reconcileImportedAsks();
1182
+ }, delay);
1183
+ if (typeof t.unref === 'function') t.unref();
1184
+ }
1185
+ continue;
1186
+ }
1187
+ // 'ok' dalla porta implica asks Array: lo richiede il validatore unico.
1188
+ // Se NON lo è, il contratto è rotto — è un bug, non una chiusura: si
1189
+ // logga e non si tocca NESSUN alias. Prima il .map() lanciava e la
1190
+ // route sopprimeva l'eccezione in silenzio.
1191
+ if (!Array.isArray(door.asks)) {
1192
+ (opts.log || console.log)(`reconcile-imported-asks: snapshot 'ok' senza asks array per l'owner ${String(ownerId).slice(0, 8)}…: contratto rotto, nessuna chiusura`);
1193
+ continue;
1194
+ }
1195
+ const live = new Set(door.asks.map((x) => String(x.id)));
1171
1196
  if (live.size >= SNAPSHOT_FULL) continue;
1172
1197
  for (const alias of aliases) {
1173
1198
  if (live.has(String(alias.ownerAskId))) continue;
@@ -1268,6 +1293,9 @@ function createServer(opts = {}) {
1268
1293
  fleetP,
1269
1294
  cellForSession: (session) => cellIdFromTmuxSession(session),
1270
1295
  eventsEnabled,
1296
+ // Il produttore dello snapshot logga la lettura fallita degli asks: senza
1297
+ // questo wiring la riga finiva nel noop di default delle routes.
1298
+ log: opts.log || console.log,
1271
1299
  }));
1272
1300
  // Client-side subscriber: for every outbound peer the
1273
1301
  // operator flagged eventsReceive, pull the owner's feed (snapshot first),
@@ -1291,6 +1319,13 @@ function createServer(opts = {}) {
1291
1319
  const b = req.body || {};
1292
1320
  if (b.action === 'dismiss') {
1293
1321
  const out = await askRelay.relayDismiss({ ownerId: b.ownerId, askId: b.askId });
1322
+ // Dismiss CONFERMATO dall'owner (2xx): la view del feed lo dimentica
1323
+ // subito — drop + tombstone — che lo stream sia vivo o no. Senza
1324
+ // questo, una view stale o gated (resync-exhausted) ripresenta la
1325
+ // card a ogni feed-state: la card che l'operatore vede tornare dopo lo scarto.
1326
+ if (out.ok && typeof eventFeedClient.dismissConfirmed === 'function') {
1327
+ eventFeedClient.dismissConfirmed(b.ownerId, b.askId);
1328
+ }
1294
1329
  return res.status(out.ok ? 200 : out.code || 500).json(out.ok ? { dismissed: true } : { error: out.error || 'unknown' });
1295
1330
  }
1296
1331
  if (b.action === 'verify') {
@@ -1327,6 +1362,11 @@ function createServer(opts = {}) {
1327
1362
  loadStore: () => nodesStore.loadStore(nodesPath),
1328
1363
  token: () => tokenHolder.value,
1329
1364
  eventsHub: importHub,
1365
+ // Gradini del cooldown post-resync-exhausted: default nel client,
1366
+ // overridabile da config per test e installazioni particolari.
1367
+ resyncCooldownStepsMs: cfg.eventFeedClientResyncCooldownStepsMs,
1368
+ // Finestra minima fra snapshot (invariante anti-storm): stessa via.
1369
+ minSnapshotIntervalMs: cfg.eventFeedClientMinSnapshotIntervalMs,
1330
1370
  log: opts.log || console.log,
1331
1371
  pollMs: cfg.eventFeedClientPollMs,
1332
1372
  });
@@ -1511,6 +1551,7 @@ function createServer(opts = {}) {
1511
1551
  storePath: vlNodesPath, broker: vlNodeBroker, ownerId: vlOwnerId, readonly: proxyReadonly,
1512
1552
  }));
1513
1553
  api.use('/decks', decksRoutes({ cfg, decksPath }));
1554
+ api.use('/prefs', prefsRoutes({ cfg, prefsPath }));
1514
1555
  api.use('/fs', fsRoutes({ home: os.homedir() })); // folder-picker del dialog new session
1515
1556
  // /nodes (read-only, per la settings UI): stesso formato di `nodes list --json`
1516
1557
  // (token SEMPRE redatti via redactStore) + health federato per-nodo. Il campo
@@ -1851,24 +1892,41 @@ function start(opts = {}) {
1851
1892
  });
1852
1893
  panelServerV6.listen(cfg.panelPort, '::1');
1853
1894
  };
1895
+ // Il ripiego del pannello si scrive nel log UNA volta per coppia richiesta/scelta: un nodo che si riavvia spesso
1896
+ // (aggiornamenti, servizio) non deve riempire il log con la stessa riga a ogni avvio e nascondere il resto.
1897
+ const panelNotePath = () => path.join(opts.configDir || path.join(cfg.home || os.homedir(), '.nexuscrew'), 'panel-port.note');
1898
+ const notePanelFallback = (requested, selected) => {
1899
+ const line = `${requested}>${selected}`;
1900
+ try { if (fs.readFileSync(panelNotePath(), 'utf8').trim() === line) return; } catch (_) { /* nessuna nota */ }
1901
+ log(`panel port ${requested} busy; selected ${selected}`);
1902
+ try { fs.mkdirSync(path.dirname(panelNotePath()), { recursive: true }); fs.writeFileSync(panelNotePath(), `${line}\n`); } catch (_) { /* best effort */ }
1903
+ };
1854
1904
  const startPanelServer = () => {
1905
+ // Il pannello non puo' condividere la porta del control plane: se la scelta cade li' (default 41821 con
1906
+ // port=41821, o un ripiego), si parte dalla successiva.
1907
+ const mainPort = cfg.port;
1855
1908
  const requestedPanelPort = cfg.panelPort;
1909
+ const skipMain = (p) => (p === mainPort ? (p >= 65535 ? 41821 : p + 1) : p);
1856
1910
  const tryPanelFallback = (candidate, remaining) => {
1857
1911
  panelServer.once('error', (error) => {
1858
1912
  if (error && error.code === 'EADDRINUSE' && remaining > 1) {
1859
- tryPanelFallback(candidate >= 65535 ? 41821 : candidate + 1, remaining - 1);
1913
+ tryPanelFallback(skipMain(candidate >= 65535 ? 41821 : candidate + 1), remaining - 1);
1860
1914
  return;
1861
1915
  }
1862
1916
  log(`panel: preferred port ${requestedPanelPort} busy and no fallback available (${(error && error.code) || error}); panel disabled this run.`);
1863
1917
  });
1864
1918
  panelServer.listen(candidate, cfg.bind, () => {
1865
1919
  cfg.panelPort = panelServer.address().port;
1866
- log(`panel port ${requestedPanelPort} busy; selected ${cfg.panelPort}`);
1920
+ notePanelFallback(requestedPanelPort, cfg.panelPort);
1867
1921
  startPanelV6();
1868
1922
  });
1869
1923
  };
1924
+ if (requestedPanelPort !== 0 && requestedPanelPort === mainPort) {
1925
+ tryPanelFallback(skipMain(requestedPanelPort >= 65535 ? 41821 : requestedPanelPort + 1), 200);
1926
+ return;
1927
+ }
1870
1928
  panelServer.once('error', (error) => {
1871
- if (error && error.code === 'EADDRINUSE') { tryPanelFallback(requestedPanelPort >= 65535 ? 41821 : requestedPanelPort + 1, 200); return; }
1929
+ if (error && error.code === 'EADDRINUSE') { tryPanelFallback(skipMain(requestedPanelPort >= 65535 ? 41821 : requestedPanelPort + 1), 200); return; }
1872
1930
  log(`panel: failed to start (${(error && error.code) || error}); panel disabled this run.`);
1873
1931
  });
1874
1932
  panelServer.listen(requestedPanelPort, cfg.bind, () => {
@@ -1884,43 +1942,63 @@ function start(opts = {}) {
1884
1942
  log('localhost-only — reach it via a user-controlled SSH or VPN channel.');
1885
1943
  startPanelServer();
1886
1944
  };
1887
- const persistFallback = () => {
1888
- const selected = server.address().port;
1889
- const configPath = opts.configPath || configJsonPath();
1890
- let current = {};
1945
+ // il ripiego automatico della porta principale NON si fa piu'. Un nodo che si sposta di porta cambia
1946
+ // origin: la PWA gia' installata resta sull'origin vecchia e quella nuova parte senza token, pin ne' ordine.
1947
+ // Se la porta e' occupata: (1) se e' il PROPRIO processo orfano (pidfile vivo) lo si aspetta, con un limite,
1948
+ // e si riprende la STESSA porta; (2) altrimenti errore chiaro. Una porta diversa la sceglie l'utente (--port).
1949
+ const ownPortWaitMs = opts.ownPortWaitMs === undefined ? 15000 : opts.ownPortWaitMs;
1950
+ const ownPortRetryMs = opts.ownPortRetryMs === undefined ? 500 : opts.ownPortRetryMs;
1951
+ const isOwnRuntime = typeof opts.isOwnRuntimeImpl === 'function' ? opts.isOwnRuntimeImpl : () => {
1891
1952
  try {
1892
- const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1893
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) current = parsed;
1894
- } catch (_) {}
1895
- writeConfigAtomic(configPath, { ...current, port: selected });
1896
- log(`preferred port ${requestedPort} busy; selected ${selected}`);
1897
- onListening();
1953
+ const pidf = require('./cli/pidfile.js');
1954
+ const meta = pidf.readPidfile(pidf.defaultPidfilePath(cfg.home || os.homedir()));
1955
+ return !!meta && meta.pid !== process.pid && pidf.isAlive(meta) && (meta.port === undefined || Number(meta.port) === Number(requestedPort));
1956
+ } catch (_) { return false; }
1957
+ };
1958
+ const busyError = (cause) => {
1959
+ const e = new Error(messages.portMessage('busy-other', { port: requestedPort }, { lang: opts.lang }));
1960
+ e.code = 'EADDRINUSE'; e.cause = cause; return e;
1961
+ };
1962
+ const listenAfterOwn = (cause) => {
1963
+ const deadline = Date.now() + ownPortWaitMs;
1964
+ const attempt = () => {
1965
+ if (!isOwnRuntime()) { listenError(busyError(cause)); return; }
1966
+ if (Date.now() >= deadline) { listenError(busyError(cause)); return; }
1967
+ setTimeout(() => {
1968
+ server.once('error', (error) => {
1969
+ if (error && error.code === 'EADDRINUSE') attempt(); else listenError(error);
1970
+ });
1971
+ server.listen(requestedPort, cfg.bind, onListening);
1972
+ }, ownPortRetryMs);
1973
+ };
1974
+ attempt();
1898
1975
  };
1899
- const tryFallback = (candidate, remaining) => {
1976
+ const legacyFallback = (candidate, remaining) => {
1977
+ // Solo su richiesta esplicita (autoPort: true): gira su un'altra porta, senza scrivere config.json.
1900
1978
  server.once('error', (error) => {
1901
- if (error && error.code === 'EADDRINUSE' && remaining > 1) {
1902
- tryFallback(candidate >= 65535 ? 41820 : candidate + 1, remaining - 1);
1903
- return;
1904
- }
1905
- throw error;
1979
+ if (error && error.code === 'EADDRINUSE' && remaining > 1) { legacyFallback(candidate >= 65535 ? 41820 : candidate + 1, remaining - 1); return; }
1980
+ listenError(error);
1906
1981
  });
1907
- server.listen(candidate, cfg.bind, persistFallback);
1982
+ server.listen(candidate, cfg.bind, () => { log(`preferred port ${requestedPort} busy; running on ${server.address().port} for this run only`); onListening(); });
1908
1983
  };
1909
1984
  server.once('error', (error) => {
1910
- if (error && error.code === 'EADDRINUSE' && requestedPort !== 0 && opts.autoPort !== false) {
1985
+ if (error && error.code === 'EADDRINUSE' && requestedPort !== 0) {
1911
1986
  if (pairedPeers) {
1912
- const refused = new Error(`preferred port ${requestedPort} is busy; paired peers exist, refusing automatic port change`);
1987
+ const refused = new Error(messages.portMessage('busy-paired', { port: requestedPort }, { lang: opts.lang }));
1913
1988
  refused.code = 'EADDRINUSE_PAIRED';
1914
1989
  refused.cause = error;
1915
1990
  listenError(refused);
1916
1991
  return;
1917
1992
  }
1918
- tryFallback(requestedPort >= 65535 ? 41820 : requestedPort + 1, 200);
1993
+ if (opts.autoPort === true) { legacyFallback(requestedPort >= 65535 ? 41820 : requestedPort + 1, 200); return; }
1994
+ listenAfterOwn(error);
1919
1995
  return;
1920
1996
  }
1921
1997
  listenError(error);
1922
1998
  });
1923
1999
  server.listen(requestedPort, cfg.bind, onListening);
2000
+ server.panelServer = panelServer;
2001
+ server.cfg = cfg;
1924
2002
  return server;
1925
2003
  }
1926
2004
 
@@ -177,7 +177,52 @@ function writeState(file, value) {
177
177
  }
178
178
  }
179
179
 
180
+ // Freni contro il ciclo install -> restart -> reinstall. Stato persistito nel file di stato:
181
+ // installs: { <versione>: [epochMs, ...] } lastInstallAt: epochMs
182
+ const MAX_INSTALLS_PER_VERSION = 2;
183
+ const INSTALL_WINDOW_MS = 24 * 60 * 60 * 1000;
184
+ const MIN_BETWEEN_INSTALLS_MS = 60 * 60 * 1000;
185
+
186
+ function recentInstalls(state, version, now) {
187
+ const list = state && state.installs && Array.isArray(state.installs[version]) ? state.installs[version] : [];
188
+ return list.filter((t) => Number.isFinite(t) && now - t < INSTALL_WINDOW_MS && t <= now + 60_000);
189
+ }
190
+
191
+ // {ok:true} oppure {ok:false, reason}. Solo per gli aggiornamenti automatici: l'apply manuale non e' frenato.
192
+ function installGate(state, version, now = Date.now()) {
193
+ const recent = recentInstalls(state, version, now);
194
+ if (recent.length >= MAX_INSTALLS_PER_VERSION) {
195
+ return { ok: false, reason: `backoff: ${recent.length} installazioni di ${version} nelle ultime 24 h, nessun'altra automatica` };
196
+ }
197
+ const last = Number(state && state.lastInstallAt);
198
+ if (Number.isFinite(last) && last <= now + 60_000 && now - last < MIN_BETWEEN_INSTALLS_MS) {
199
+ return { ok: false, reason: `backoff: ultimo aggiornamento ${Math.round((now - last) / 60000)} min fa, ne servono almeno 60 fra due` };
200
+ }
201
+ return { ok: true };
202
+ }
203
+
204
+ // Registra un tentativo di install (versione + istante) potando cio' che e' fuori finestra.
205
+ function recordInstall(state, version, now = Date.now()) {
206
+ const installs = {};
207
+ for (const [v, list] of Object.entries((state && state.installs) || {})) {
208
+ const kept = recentInstalls(state, v, now);
209
+ if (kept.length) installs[v] = kept;
210
+ }
211
+ installs[version] = [...(installs[version] || []), now];
212
+ return { ...state, installs, lastInstallAt: now };
213
+ }
214
+
215
+ // Ritardo del PRIMO check dopo l'avvio: un demone che riparte spesso non deve richiamare il registry ogni volta.
216
+ // Se l'ultimo check e' recente (e non nel futuro) si aspetta il resto dell'intervallo.
217
+ function initialCheckDelayMs({ lastCheckedAt, now = Date.now(), intervalMs, initialDelayMs }) {
218
+ const at = Date.parse(lastCheckedAt || '');
219
+ if (!Number.isFinite(at) || at > now) return initialDelayMs;
220
+ const age = now - at;
221
+ return age < intervalMs ? Math.max(initialDelayMs, intervalMs - age) : initialDelayMs;
222
+ }
223
+
180
224
  module.exports = {
225
+ MAX_INSTALLS_PER_VERSION, INSTALL_WINDOW_MS, MIN_BETWEEN_INSTALLS_MS, installGate, recordInstall, initialCheckDelayMs,
181
226
  PACKAGE_NAME, VERSION_RE, parseVersion, compareVersions, registryVersion,
182
227
  scrubError, pidAlive, readLock, acquireUpdateLock, adoptUpdateLock, releaseUpdateLock,
183
228
  readState, writeState, stableRuntimeDir,
@@ -7,7 +7,9 @@ const { execFile, spawn } = require('node:child_process');
7
7
  const {
8
8
  PACKAGE_NAME, compareVersions, parseVersion, registryVersion, scrubError, pidAlive, readLock,
9
9
  acquireUpdateLock, releaseUpdateLock, readState, writeState, stableRuntimeDir,
10
+ installGate, recordInstall, initialCheckDelayMs,
10
11
  } = require('./core.js');
12
+ const npmEnv = require('./npm-env.js');
11
13
 
12
14
  const DEFAULT_INITIAL_DELAY_MS = 60 * 1000;
13
15
  const DEFAULT_INTERVAL_MS = 6 * 60 * 60 * 1000;
@@ -24,11 +26,13 @@ function isNewer(candidate, current) {
24
26
  try { return compareVersions(candidate, current) > 0; } catch (_) { return false; }
25
27
  }
26
28
 
27
- function lookupLatestNpm({ execFileImpl = execFile, timeoutMs = 20_000, home = os.homedir(), cwd } = {}) {
29
+ function lookupLatestNpm({ execFileImpl = execFile, timeoutMs = 20_000, home = os.homedir(), cwd, npmInvocationImpl } = {}) {
30
+ // Stesso npm dell'installazione: node del demone + npm-cli accanto (mai `npm` nudo dal PATH).
31
+ const invocation = (npmInvocationImpl || npmEnv.resolveNpmInvocation)({ execPath: process.execPath });
28
32
  return new Promise((resolve, reject) => {
29
- execFileImpl('npm', ['view', `${PACKAGE_NAME}@latest`, 'version', '--json'], {
33
+ execFileImpl(invocation.bin, [...invocation.argvPrefix, 'view', `${PACKAGE_NAME}@latest`, 'version', '--json'], {
30
34
  encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024,
31
- cwd: cwd || stableRuntimeDir(home),
35
+ cwd: cwd || stableRuntimeDir(home), ...(invocation.env ? { env: invocation.env } : {}),
32
36
  }, (error, stdout) => {
33
37
  if (error) return reject(error);
34
38
  try { resolve(registryVersion(stdout)); } catch (e) { reject(e); }
@@ -75,6 +79,16 @@ function createNpmUpdater(opts = {}) {
75
79
  state = { ...state, phase: 'idle', available: false, latest: currentVersion, lastError: '' };
76
80
  writeState(statusPath, state);
77
81
  }
82
+ // un update dato per riuscito ma il processo in esecuzione NON e' alla versione target (ha ricaricato codice
83
+ // vecchio, o e' un altro albero): la versione si blocca, altrimenti ogni riavvio reinstalla lo stesso target.
84
+ if (state.phase === 'installed' && state.targetVersion && state.targetVersion !== currentVersion
85
+ && isNewer(state.targetVersion, currentVersion)) {
86
+ state = {
87
+ ...state, phase: 'error', available: true, blockedVersion: state.targetVersion,
88
+ lastError: `dopo l'aggiornamento il processo esegue la versione ${currentVersion}, attesa ${state.targetVersion}: versione bloccata`,
89
+ };
90
+ writeState(statusPath, state);
91
+ }
78
92
  const activeLock = () => { const lock = readLock(lockPath); return !!(lock && pidAlive(lock.pid)); };
79
93
  if ((state.phase === 'installing' || state.phase === 'restarting') && !pidAlive(state.updaterPid) && !activeLock()) {
80
94
  state = { ...state, phase: 'error', lastError: 'aggiornamento precedente interrotto' };
@@ -111,7 +125,7 @@ function createNpmUpdater(opts = {}) {
111
125
  };
112
126
  };
113
127
 
114
- const launch = (version) => {
128
+ const launch = (version, { manual = false } = {}) => {
115
129
  if (!supported) throw new Error('auto-update disponibile solo nell’installazione npm globale');
116
130
  if (readonly) throw new Error('READONLY: auto-update bloccato');
117
131
  if (!isNewer(version, currentVersion)) return status();
@@ -122,6 +136,13 @@ function createNpmUpdater(opts = {}) {
122
136
  if (state.blockedVersion === version) {
123
137
  const error = new Error(`versione ${version} bloccata dopo un rollback fallito/necessario`); error.status = 409; error.code = 'update-blocked'; throw error;
124
138
  }
139
+ if (!manual) {
140
+ const gate = installGate(state, version);
141
+ if (!gate.ok) {
142
+ persist({ phase: 'available', available: true, lastError: gate.reason });
143
+ const error = new Error(gate.reason); error.status = 409; error.code = 'update-backoff'; throw error;
144
+ }
145
+ }
125
146
  const reservation = acquireUpdateLock(lockPath);
126
147
  if (!reservation.ok) {
127
148
  const error = new Error('aggiornamento già in corso'); error.status = 409; error.code = 'update-busy'; throw error;
@@ -141,7 +162,7 @@ function createNpmUpdater(opts = {}) {
141
162
  const fd = fs.openSync(logPath, 'a', 0o600);
142
163
  let child;
143
164
  try {
144
- persist({ phase: 'installing', targetVersion: version, latest: version, available: true, lastError: '' });
165
+ persist({ ...recordInstall(state, version), phase: 'installing', targetVersion: version, latest: version, available: true, lastError: '' });
145
166
  const runnerArgs = [runnerPath, '--version', version, '--status', statusPath, '--home', home,
146
167
  '--lock', lockPath, '--lock-token', reservation.token];
147
168
  const bin = useSystemdRun ? 'systemd-run' : process.execPath;
@@ -212,7 +233,9 @@ function createNpmUpdater(opts = {}) {
212
233
  lastCheckedAt: new Date().toISOString(),
213
234
  lastError: blocked ? (state.lastError || `versione ${latest} bloccata dopo rollback`) : '',
214
235
  });
215
- if (available && !blocked && autoApply && enabled) return launch(latest);
236
+ if (available && !blocked && autoApply && enabled) {
237
+ try { return launch(latest); } catch (error) { if (error && error.code === 'update-backoff') return status(); throw error; }
238
+ }
216
239
  return status();
217
240
  } catch (e) {
218
241
  return persist({ phase: 'error', lastCheckedAt: new Date().toISOString(), lastError: scrubError(e) });
@@ -231,7 +254,7 @@ function createNpmUpdater(opts = {}) {
231
254
  const latest = state.latest && isNewer(state.latest, currentVersion)
232
255
  ? state.latest : (await check()).latest;
233
256
  if (!latest || !isNewer(latest, currentVersion)) return status();
234
- return launch(latest);
257
+ return launch(latest, { manual: true });
235
258
  };
236
259
 
237
260
  const schedule = (delay) => {
@@ -244,7 +267,7 @@ function createNpmUpdater(opts = {}) {
244
267
  if (typeof timer.unref === 'function') timer.unref();
245
268
  };
246
269
 
247
- const start = () => schedule(initialDelayMs);
270
+ const start = () => schedule(initialCheckDelayMs({ lastCheckedAt: readState(statusPath).lastCheckedAt, intervalMs, initialDelayMs }));
248
271
  const close = () => { if (timer) clearTimeout(timer); timer = null; };
249
272
  const setEnabled = (value) => {
250
273
  enabled = value === true && !readonly;
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+ // npm dell'aggiornamento: lo STESSO node che esegue il demone, con la prova che installerebbe dove il codice vive.
3
+ //
4
+ // Il difetto che questo modulo chiude: `npm` chiamato nudo dal PATH del processo. Con piu' node sulla macchina
5
+ // (proot vs Termux, Homebrew vs sistema, nvm) quello che risponde a `npm` puo' appartenere a un altro node/prefix:
6
+ // `npm install --global` esce 0 ma scrive in un ALTRO albero, e il processo riavviato ricarica codice vecchio.
7
+ const fs = require('node:fs');
8
+ const os = require('node:os');
9
+ const path = require('node:path');
10
+ const { execFileSync } = require('node:child_process');
11
+ const { PACKAGE_NAME } = require('./core.js');
12
+
13
+ // Redazione per il log: mai home, mai segreti. Le stringhe lunghe alfanumeriche (token, chiavi) diventano ***.
14
+ function redact(text, home = os.homedir()) {
15
+ let out = String(text == null ? '' : text);
16
+ if (home && home.length > 1) out = out.split(home).join('~');
17
+ return out.replace(/[A-Za-z0-9_-]{32,}/g, '***').slice(0, 600);
18
+ }
19
+
20
+ function describeEnv({ execPath = process.execPath, envPath = process.env.PATH || '', home = os.homedir(), nodeVersion = process.version } = {}) {
21
+ const entries = String(envPath).split(path.delimiter).filter(Boolean).slice(0, 25).map((e) => redact(e, home).slice(0, 120));
22
+ return `node=${redact(execPath, home)} v=${nodeVersion} PATH=${entries.join(':')}`;
23
+ }
24
+
25
+ // npm-cli.js accanto al node: bin/node -> ../lib/node_modules/npm (Linux/macOS/Termux/Homebrew/nvm),
26
+ // oppure node_modules/npm accanto al binario (Windows e alcuni tarball).
27
+ function findNpmCli(execPath, { exists = fs.existsSync, realpath = fs.realpathSync } = {}) {
28
+ const dirs = [path.dirname(execPath)];
29
+ try { const real = path.dirname(realpath(execPath)); if (!dirs.includes(real)) dirs.push(real); } catch (_) { /* binario non risolvibile */ }
30
+ for (const dir of dirs) {
31
+ for (const rel of [['..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'], ['node_modules', 'npm', 'bin', 'npm-cli.js']]) {
32
+ const candidate = path.join(dir, ...rel);
33
+ if (exists(candidate)) return candidate;
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ // Mini-verifica di un range `engines.node` (clausole con `||`, congiunzioni con spazio; >=, >, <=, <, =, ^, ~).
40
+ // Un range che non si sa leggere non blocca: e' un controllo di sicurezza, non un veto.
41
+ function parseV(v) { const m = /^v?(\d+)(?:\.(\d+|x))?(?:\.(\d+|x))?/.exec(String(v).trim()); return m ? [Number(m[1]), m[2] === undefined || m[2] === 'x' ? null : Number(m[2]), m[3] === undefined || m[3] === 'x' ? null : Number(m[3])] : null; }
42
+ function cmp(a, b) { for (let i = 0; i < 3; i += 1) { const x = a[i] || 0; const y = (b[i] === null || b[i] === undefined) ? 0 : b[i]; if (x !== y) return x > y ? 1 : -1; } return 0; }
43
+ function comparatorOk(version, token) {
44
+ const m = /^(>=|<=|>|<|=|\^|~)?\s*(v?[\d.x]+)$/.exec(token);
45
+ if (!m) return null;
46
+ const op = m[1] || '='; const t = parseV(m[2]); if (!t) return null;
47
+ const c = cmp(version, t);
48
+ if (op === '>=') return c >= 0; if (op === '>') return c > 0; if (op === '<=') return c <= 0; if (op === '<') return c < 0;
49
+ if (op === '=') return t.every((n, i) => n === null || n === version[i]);
50
+ if (op === '^') { if (c < 0) return false; if (t[0] > 0) return version[0] === t[0]; if ((t[1] || 0) > 0) return version[0] === 0 && version[1] === t[1]; return version[0] === 0 && version[1] === 0 && version[2] === (t[2] || 0); }
51
+ if (op === '~') return c >= 0 && version[0] === t[0] && (t[1] === null || version[1] === t[1]);
52
+ return null;
53
+ }
54
+ function nodeRangeOk(nodeVersion, range) {
55
+ const v = parseV(nodeVersion); if (!v || !range) return true;
56
+ let unknown = false;
57
+ for (const clause of String(range).split('||')) {
58
+ const tokens = clause.trim().replace(/(>=|<=|>|<|=|\^|~)\s+/g, '$1').split(/\s+/).filter(Boolean);
59
+ if (!tokens.length) continue;
60
+ const results = tokens.map((tk) => comparatorOk(v, tk));
61
+ if (results.some((r) => r === null)) { unknown = true; continue; }
62
+ if (results.every(Boolean)) return true;
63
+ }
64
+ return unknown; // se nessuna clausola e' soddisfatta ma qualcuna non era leggibile, non si blocca
65
+ }
66
+
67
+ // Come lanciare npm: { bin, argvPrefix, env, kind, npmCli }.
68
+ function resolveNpmInvocation({ execPath = process.execPath, exists, realpath, env = process.env } = {}) {
69
+ const npmCli = findNpmCli(execPath, { exists, realpath });
70
+ const withNodeFirst = { ...env, PATH: `${path.dirname(execPath)}${path.delimiter}${env.PATH || ''}` };
71
+ if (npmCli) return { kind: 'node-npm-cli', bin: execPath, argvPrefix: [npmCli], env: withNodeFirst, npmCli };
72
+ // npm non trovato accanto al node: si ripiega su `npm` del PATH, ma SOLO se la preflight prova dove scrive.
73
+ return { kind: 'path-npm', bin: 'npm', argvPrefix: [], env: withNodeFirst, npmCli: null };
74
+ }
75
+
76
+ function realpathSafe(p, realpath = fs.realpathSync) { try { return realpath(p); } catch (_) { return path.resolve(p); } }
77
+
78
+ // Prova, PRIMA di toccare qualcosa, che l'install globale andrebbe dove il processo in esecuzione vive.
79
+ function npmPreflight({
80
+ invocation, packageRoot = path.resolve(__dirname, '..', '..'), home = os.homedir(), nodeVersion = process.version,
81
+ execImpl = execFileSync, realpath = fs.realpathSync, readFile = fs.readFileSync,
82
+ } = {}) {
83
+ if (invocation.npmCli) {
84
+ let engines = '';
85
+ try { engines = JSON.parse(readFile(path.join(path.dirname(path.dirname(invocation.npmCli)), 'package.json'), 'utf8')).engines?.node || ''; } catch (_) { /* nessun engines leggibile */ }
86
+ if (engines && !nodeRangeOk(nodeVersion, engines)) {
87
+ throw new Error(`npm richiede node ${engines} ma il demone esegue node ${nodeVersion}: aggiornamento annullato prima di installare`);
88
+ }
89
+ }
90
+ let globalRoot = '';
91
+ try {
92
+ globalRoot = String(execImpl(invocation.bin, [...invocation.argvPrefix, 'root', '-g'], { encoding: 'utf8', timeout: 20_000, env: invocation.env, stdio: ['ignore', 'pipe', 'pipe'] }) || '').trim();
93
+ } catch (e) { throw new Error(`npm root -g non riuscito (${redact(e && e.message, home).slice(0, 160)}): aggiornamento annullato prima di installare`); }
94
+ if (!globalRoot) throw new Error('npm root -g vuoto: aggiornamento annullato prima di installare');
95
+ const target = realpathSafe(path.join(globalRoot, PACKAGE_NAME), realpath);
96
+ const running = realpathSafe(packageRoot, realpath);
97
+ if (target !== running) {
98
+ throw new Error(`npm -g installerebbe in ${redact(target, home)} ma il processo esegue ${redact(running, home)} (installazione portatile, locale o node diverso): aggiornamento annullato prima di installare`);
99
+ }
100
+ return { globalRoot, packageRoot: running };
101
+ }
102
+
103
+ // Versione del pacchetto nel percorso ESEGUITO (realpath), con il percorso per il messaggio d'errore.
104
+ function readInstalledFrom(root, { realpath = fs.realpathSync, readFile = fs.readFileSync } = {}) {
105
+ const real = realpathSafe(root, realpath);
106
+ return { path: real, version: JSON.parse(readFile(path.join(real, 'package.json'), 'utf8')).version };
107
+ }
108
+
109
+ module.exports = { redact, describeEnv, findNpmCli, nodeRangeOk, resolveNpmInvocation, npmPreflight, readInstalledFrom };