@mmmbuto/nexuscrew 0.9.52 → 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/CHANGELOG.md +12 -0
- package/frontend/dist/assets/index-DBlFdkZn.js +169 -0
- package/frontend/dist/assets/{index-CcZskBmy.css → index-Da4oBb4f.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +63 -14
- package/lib/cli/messages.js +55 -0
- package/lib/cli/url.js +6 -2
- package/lib/notify/ask-answer-service.js +1 -0
- package/lib/notify/asks.js +58 -7
- package/lib/notify/event-feed-client.js +420 -48
- package/lib/notify/event-feed-routes.js +44 -3
- package/lib/notify/persist.js +69 -12
- package/lib/notify/routes.js +19 -1
- package/lib/prefs/routes.js +52 -0
- package/lib/prefs/store.js +112 -0
- package/lib/proxy/federation.js +49 -5
- package/lib/server.js +112 -34
- package/lib/update/core.js +45 -0
- package/lib/update/manager.js +31 -8
- package/lib/update/npm-env.js +109 -0
- package/lib/update/runner.js +54 -14
- package/lib/update/start-lock.js +106 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-5MtkSiOQ.js +0 -169
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
|
-
|
|
1161
|
-
if (!peer) continue;
|
|
1162
|
-
let snap = null;
|
|
1170
|
+
let door = null;
|
|
1163
1171
|
try {
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
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
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
1985
|
+
if (error && error.code === 'EADDRINUSE' && requestedPort !== 0) {
|
|
1911
1986
|
if (pairedPeers) {
|
|
1912
|
-
const refused = new Error(
|
|
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
|
-
|
|
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
|
|
package/lib/update/core.js
CHANGED
|
@@ -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,
|
package/lib/update/manager.js
CHANGED
|
@@ -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(
|
|
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)
|
|
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 };
|