@mmmbuto/nexuscrew 0.9.52 → 0.9.54
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 +19 -0
- package/frontend/dist/assets/{index-CcZskBmy.css → index-Da4oBb4f.css} +1 -1
- package/frontend/dist/assets/index-DdTN41V3.js +169 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +73 -16
- package/lib/cli/init.js +17 -7
- package/lib/cli/messages.js +55 -0
- package/lib/cli/url.js +6 -2
- package/lib/config.js +32 -1
- package/lib/fleet/identity-provision.js +19 -9
- 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 +66 -5
- package/lib/server.js +112 -34
- package/lib/settings/routes.js +14 -4
- 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/frontend/dist/index.html
CHANGED
|
@@ -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-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-DdTN41V3.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Da4oBb4f.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="root"></div>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.9.
|
|
1
|
+
{"version":"0.9.54"}
|
package/lib/cli/commands.js
CHANGED
|
@@ -22,12 +22,21 @@ const nodesCmds = require('../nodes/commands.js');
|
|
|
22
22
|
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
|
+
const { withStartLock } = require('../update/start-lock.js');
|
|
26
|
+
const { portMessage } = require('./messages.js');
|
|
25
27
|
const {
|
|
26
|
-
start, stop, isServiceRunning, bootState, stopManagedTunnels, fermaTunnelPrimaDiRiavviare,
|
|
28
|
+
start: startUnlocked, stop, isServiceRunning, bootState, stopManagedTunnels, fermaTunnelPrimaDiRiavviare,
|
|
27
29
|
managedRuntimeState, portableRuntimeState, resolveRuntimeOwner,
|
|
28
30
|
refusePairedPortRelocation, stopPortableRuntime, unlinkRegular,
|
|
29
31
|
} = runtimeLifecycle;
|
|
30
32
|
|
|
33
|
+
// Lock di avvio condiviso (boot/show/start/restart/runner): serializza chi EMETTE un avvio, mai chi attende la salute.
|
|
34
|
+
// Rientrante nello stesso processo; scadenza e proprietario verificato in lib/update/start-lock.js.
|
|
35
|
+
function locked(label, opts, fn) {
|
|
36
|
+
return withStartLock(fn, { home: opts.home, label, waitMs: opts.startLockWaitMs, ttlMs: opts.startLockTtlMs });
|
|
37
|
+
}
|
|
38
|
+
function start(opts = {}) { return locked('start', opts, () => startUnlocked(opts)); }
|
|
39
|
+
|
|
31
40
|
const HELP = `NexusCrew — PWA for local and remote AI workers.
|
|
32
41
|
|
|
33
42
|
Usage:
|
|
@@ -158,7 +167,9 @@ function serve(opts = {}) {
|
|
|
158
167
|
}
|
|
159
168
|
pidf.cleanStale(pidPath);
|
|
160
169
|
const cmd = [process.execPath].concat(process.argv.slice(1)).join(' ');
|
|
161
|
-
|
|
170
|
+
// La porta entra nel pidfile: chi trova la porta occupata riconosce il PROPRIO processo (e sa su quale porta era).
|
|
171
|
+
let servedPort; try { servedPort = Number(opts.port || urlmod.loadPort(opts)); } catch (_) { servedPort = undefined; }
|
|
172
|
+
pidf.writePidfile(pidPath, process.pid, cmd, Number.isInteger(servedPort) ? { port: servedPort } : {});
|
|
162
173
|
const cleanup = () => pidf.removePidfile(pidPath);
|
|
163
174
|
process.on('SIGINT', () => { cleanup(); process.exit(0); });
|
|
164
175
|
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
|
@@ -310,7 +321,8 @@ function status(opts = {}) {
|
|
|
310
321
|
}
|
|
311
322
|
|
|
312
323
|
// restart per-platform (shared da token rotate / update). execImpl per test.
|
|
313
|
-
function restart(opts = {}) {
|
|
324
|
+
function restart(opts = {}) { return locked('restart', opts, () => restartUnlocked(opts)); }
|
|
325
|
+
function restartUnlocked(opts = {}) {
|
|
314
326
|
const platform = opts.platform || detectPlatform();
|
|
315
327
|
const execImpl = opts.execImpl || execFileSync;
|
|
316
328
|
const log = opts.log || console.log;
|
|
@@ -575,13 +587,29 @@ async function probeNexusCrew(port, token, opts = {}) {
|
|
|
575
587
|
// spiegare un riavvio ha bisogno di distinguerle, altrimenti dichiara fallito
|
|
576
588
|
// un riavvio riuscito e manda a cercare un processo morto che e' vivo.
|
|
577
589
|
// Rilievo della revisione: il caso token-assente era gia' coperto, questo no.
|
|
590
|
+
// Chiede al nodo un id dispositivo per il link che sta per aprire/stampare. Best-effort: senza risposta il link
|
|
591
|
+
// resta quello di sempre (il client ne chiedera' uno da se').
|
|
592
|
+
async function issueDeviceId(port, token, opts = {}) {
|
|
593
|
+
if (typeof opts.deviceIdImpl === 'function') { try { return await opts.deviceIdImpl(port, token); } catch (_) { return null; } }
|
|
594
|
+
const fetchImpl = opts.fetchImpl || globalThis.fetch;
|
|
595
|
+
if (!token || typeof fetchImpl !== 'function') return null;
|
|
596
|
+
try {
|
|
597
|
+
const r = await fetchImpl(`http://127.0.0.1:${port}/api/prefs/devices`, {
|
|
598
|
+
method: 'POST', headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout?.(1500),
|
|
599
|
+
});
|
|
600
|
+
if (r.status !== 201) return null;
|
|
601
|
+
const j = await r.json();
|
|
602
|
+
return typeof j.deviceId === 'string' ? j.deviceId : null;
|
|
603
|
+
} catch (_) { return null; }
|
|
604
|
+
}
|
|
605
|
+
|
|
578
606
|
async function probeNexusCrewStatus(port, token, opts = {}) {
|
|
579
607
|
const fetchImpl = opts.fetchImpl || globalThis.fetch;
|
|
580
608
|
if (typeof fetchImpl !== 'function') return null;
|
|
581
609
|
try {
|
|
582
610
|
const r = await fetchImpl(`http://127.0.0.1:${port}/api/config`, {
|
|
583
611
|
headers: { authorization: `Bearer ${token || ''}` },
|
|
584
|
-
signal: AbortSignal.timeout?.(700),
|
|
612
|
+
signal: AbortSignal.timeout?.(opts.probeTimeoutMs || 700),
|
|
585
613
|
});
|
|
586
614
|
return r.status;
|
|
587
615
|
} catch (_) { return null; } // nessuna risposta: e' un altro guasto
|
|
@@ -598,7 +626,8 @@ async function waitForNexusCrew(port, token, opts = {}) {
|
|
|
598
626
|
return false;
|
|
599
627
|
}
|
|
600
628
|
|
|
601
|
-
function startPortable(opts = {}) {
|
|
629
|
+
function startPortable(opts = {}) { return locked('start', opts, () => startPortableUnlocked(opts)); }
|
|
630
|
+
function startPortableUnlocked(opts = {}) {
|
|
602
631
|
const spawnImpl = opts.spawnImpl || spawn;
|
|
603
632
|
const home = opts.home || require('node:os').homedir();
|
|
604
633
|
const existing = portableRuntimeState({ home });
|
|
@@ -670,6 +699,23 @@ function openPwa(fullUrl, opts = {}) {
|
|
|
670
699
|
|
|
671
700
|
// Normal entry point. It is deliberately quiet and returns after starting the
|
|
672
701
|
// background service. Only first-run opens the wizard; `show` always opens it.
|
|
702
|
+
// Porta occupata all'avvio: decide senza mai muovere la porta. 200 (anche lento) = il nodo c'e'; 401/403 = un
|
|
703
|
+
// NexusCrew che non accetta questo token; nessuna risposta = o e' il nostro processo che sta partendo (si attende)
|
|
704
|
+
// o e' un altro processo. Ogni esito negativo e' un errore chiaro; una porta diversa la sceglie l'utente (--port).
|
|
705
|
+
async function settleBusyPort(port, token, runtime, opts = {}) {
|
|
706
|
+
const status = await (opts.probeStatusImpl || probeNexusCrewStatus)(port, token, { ...opts, probeTimeoutMs: opts.patientProbeMs || 3000 });
|
|
707
|
+
if (status === 200) return true;
|
|
708
|
+
if (status === 401 || status === 403) {
|
|
709
|
+
throw new Error(portMessage('busy-token', { port }, { lang: opts.lang }));
|
|
710
|
+
}
|
|
711
|
+
if (runtime && runtime.owner !== 'stopped') {
|
|
712
|
+
const waited = await waitForNexusCrew(port, token, { ...opts, waitAttempts: opts.busyWaitAttempts || 100, waitDelayMs: opts.busyWaitDelayMs || 100 });
|
|
713
|
+
if (waited) return true;
|
|
714
|
+
throw new Error(portMessage('busy-own-slow', { port }, { lang: opts.lang }));
|
|
715
|
+
}
|
|
716
|
+
throw new Error(portMessage('busy-other', { port }, { lang: opts.lang }));
|
|
717
|
+
}
|
|
718
|
+
|
|
673
719
|
async function smartUp(opts = {}) {
|
|
674
720
|
const quiet = opts.lifecycleLog || (() => {});
|
|
675
721
|
const platform = opts.platform || detectPlatform();
|
|
@@ -756,11 +802,10 @@ async function smartUp(opts = {}) {
|
|
|
756
802
|
|
|
757
803
|
if (!running) {
|
|
758
804
|
if (!(await (opts.portAvailableImpl || portAvailable)(port, '127.0.0.1'))) {
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
token = urlmod.readToken(tokenPath);
|
|
805
|
+
// La porta e' occupata e il probe rapido (700 ms) non ha avuto 200: NON e' una prova che il nodo sia spento
|
|
806
|
+
// (telefono lento, nodo sotto carico). Mai spostare la porta ne' riscrivere config.json da qui: un nodo che
|
|
807
|
+
// cambia porta cambia origin e la PWA installata perde token e preferenze.
|
|
808
|
+
running = await settleBusyPort(port, token, runtime, opts);
|
|
764
809
|
} else {
|
|
765
810
|
try {
|
|
766
811
|
runtime = resolveRuntimeOwner({ ...opts, platform });
|
|
@@ -776,7 +821,7 @@ async function smartUp(opts = {}) {
|
|
|
776
821
|
portableStart({ ...opts, platform }); portableAttempted = true;
|
|
777
822
|
}
|
|
778
823
|
}
|
|
779
|
-
running = await waitForNexusCrew(port, token, opts);
|
|
824
|
+
if (!running) running = await waitForNexusCrew(port, token, opts);
|
|
780
825
|
if (!running && !portableAttempted) {
|
|
781
826
|
try { portableStart({ ...opts, platform }); portableAttempted = true; } catch (_) {}
|
|
782
827
|
running = await waitForNexusCrew(port, token, opts);
|
|
@@ -785,7 +830,10 @@ async function smartUp(opts = {}) {
|
|
|
785
830
|
}
|
|
786
831
|
|
|
787
832
|
const shouldOpen = !opts.noOpen && (!!opts.forceOpen || !wizardComplete(configPath));
|
|
788
|
-
if (shouldOpen)
|
|
833
|
+
if (shouldOpen) {
|
|
834
|
+
const deviceId = await issueDeviceId(port, token, opts);
|
|
835
|
+
openPwa(urlmod.buildUrl(port, token, { withToken: true, deviceId }), { ...opts, platform });
|
|
836
|
+
}
|
|
789
837
|
return { platform, initialized, running, opened: shouldOpen, port, url: urlmod.buildUrl(port, null) };
|
|
790
838
|
}
|
|
791
839
|
|
|
@@ -940,8 +988,16 @@ function autoUpdateCommand(args, opts = {}) {
|
|
|
940
988
|
if (!attivo) {
|
|
941
989
|
// Servizio spento: si scrive il file, e si DICE che vale al prossimo avvio.
|
|
942
990
|
// Tacerlo lascerebbe credere che abbia gia' effetto.
|
|
943
|
-
|
|
944
|
-
|
|
991
|
+
// Se la config esiste ma non si legge, NON si riscrive: partire da un
|
|
992
|
+
// oggetto vuoto cancellerebbe le chiavi dell'operatore (wizardDone e le
|
|
993
|
+
// altre) per salvarne una sola. Motivo categorico, mai il messaggio del
|
|
994
|
+
// parser (contiene un estratto del file).
|
|
995
|
+
const letta = require('../config.js').readConfigForWrite(configPath);
|
|
996
|
+
if (!letta.ok) {
|
|
997
|
+
log(`autoupdate: config illeggibile ${letta.path} (${letta.motivo}) — non la riscrivo; ripara o rimuovi il file`);
|
|
998
|
+
return { code: 1 };
|
|
999
|
+
}
|
|
1000
|
+
const cfg = letta.cfg;
|
|
945
1001
|
// Scrittura atomica e rifiuto dei symlink, come fa la route che scrive lo
|
|
946
1002
|
// stesso file: tmp nella stessa directory, 0600, rename. Un `writeFileSync`
|
|
947
1003
|
// diretto puo' lasciare un config.json troncato se il processo muore a
|
|
@@ -1389,9 +1445,10 @@ function dispatch(argv, opts = {}) {
|
|
|
1389
1445
|
}
|
|
1390
1446
|
if (cmd === 'show') {
|
|
1391
1447
|
if (rest[1] === 'token') {
|
|
1392
|
-
return smartUp({ ...opts, noOpen: true, log: opts.lifecycleLog || (() => {}) }).then((r) => {
|
|
1448
|
+
return smartUp({ ...opts, noOpen: true, log: opts.lifecycleLog || (() => {}) }).then(async (r) => {
|
|
1393
1449
|
const { tokenPath } = urlmod.resolvePaths(opts); const token = urlmod.readToken(tokenPath);
|
|
1394
|
-
|
|
1450
|
+
const deviceId = await issueDeviceId(r.port, token, opts);
|
|
1451
|
+
log(urlmod.buildUrl(r.port, token, { withToken: true, deviceId })); return { code: 0 };
|
|
1395
1452
|
});
|
|
1396
1453
|
}
|
|
1397
1454
|
if (rest[1]) { log('usage: nexuscrew show [token]'); return { code: 1 }; }
|
package/lib/cli/init.js
CHANGED
|
@@ -18,6 +18,7 @@ const {
|
|
|
18
18
|
selectProviderModeSync, fleetFileMode,
|
|
19
19
|
} = require('./fleet-service.js');
|
|
20
20
|
const { resolveBootPaths } = require('./stable-alias.js');
|
|
21
|
+
const { readConfigForWrite } = require('../config.js');
|
|
21
22
|
const { aggiornaDefinizioni } = require('../fleet/definitions.js');
|
|
22
23
|
const { defaultDefinitions } = require('../fleet/managed.js');
|
|
23
24
|
const { commandExists, resolveCommand } = require('./path.js');
|
|
@@ -169,17 +170,26 @@ function runInit(opts = {}) {
|
|
|
169
170
|
writeConfigAtomic(configPath, { port });
|
|
170
171
|
actions.push(`created config ${configPath} (port ${port})`);
|
|
171
172
|
} else {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
173
|
+
// Fail-closed come per fleet.json (vedi ensureFleetDefaults): un file
|
|
174
|
+
// PRESENTE ma illeggibile NON si riscrive. Riscriverlo con la sola porta
|
|
175
|
+
// cancellerebbe le chiavi dell'operatore (autoUpdate, wizardDone) e
|
|
176
|
+
// riporterebbe i default: e' il guasto osservato sulle macchine, dove
|
|
177
|
+
// l'opzione autoUpdate tornava a true da sola dopo essere stata spenta.
|
|
178
|
+
// La lettura e' la stessa usata dagli altri scrittori di config
|
|
179
|
+
// (readConfigForWrite): una sola regola, un solo motivo categorico.
|
|
180
|
+
const letta = readConfigForWrite(configPath);
|
|
181
|
+
const current = letta.ok ? letta.cfg : null;
|
|
182
|
+
const illeggibile = letta.ok ? null : letta.motivo;
|
|
183
|
+
if (!current) {
|
|
184
|
+
actions.push(`WARN: config illeggibile ${configPath} (${illeggibile}): non la riscrivo, il file resta com'e' (fail-closed)`);
|
|
185
|
+
if (opts.port) actions.push(`WARN: porta ${opts.port} non applicata alla config illeggibile`);
|
|
186
|
+
} else if (opts.port) {
|
|
177
187
|
current.port = opts.port;
|
|
178
188
|
port = opts.port;
|
|
179
189
|
writeConfigAtomic(configPath, current);
|
|
180
190
|
actions.push(`updated config ${configPath} (port ${port})`);
|
|
181
|
-
} else if (current
|
|
182
|
-
actions.push(`preserved config ${configPath} (port ${port})`);
|
|
191
|
+
} else if (current.port) port = current.port;
|
|
192
|
+
if (current) actions.push(`preserved config ${configPath} (port ${port})`);
|
|
183
193
|
}
|
|
184
194
|
}
|
|
185
195
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Messaggi per chi sta al terminale (o legge il log del servizio) quando la porta e' occupata. Il nodo NON si sposta
|
|
3
|
+
// piu' da solo su un'altra porta: dev'essere chiaro cosa fare. Lingue: it, en, es. Se la lingua non si puo'
|
|
4
|
+
// determinare (servizio senza LANG) escono tutte e tre, una per riga: cosi' si legge comunque.
|
|
5
|
+
const LANGS = ['it', 'en', 'es'];
|
|
6
|
+
|
|
7
|
+
const WHY = {
|
|
8
|
+
en: 'A new port is a new address: the installed app would lose its token and settings.',
|
|
9
|
+
it: 'Una porta nuova è un indirizzo nuovo: l\'app installata perderebbe token e impostazioni.',
|
|
10
|
+
es: 'Un puerto nuevo es una dirección nueva: la app instalada perdería el token y los ajustes.',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const CATALOG = {
|
|
14
|
+
'busy-other': {
|
|
15
|
+
en: ({ port }) => `Port ${port} is in use by another process. The node does not move to another port on its own. ${WHY.en} Free port ${port}, or choose another one explicitly: nexuscrew init --port <N>.`,
|
|
16
|
+
it: ({ port }) => `La porta ${port} è occupata da un altro processo. Il nodo non passa da solo a un'altra porta. ${WHY.it} Libera la porta ${port}, oppure scegline un'altra in modo esplicito: nexuscrew init --port <N>.`,
|
|
17
|
+
es: ({ port }) => `El puerto ${port} está ocupado por otro proceso. El nodo no cambia de puerto por su cuenta. ${WHY.es} Libera el puerto ${port} o elige otro de forma explícita: nexuscrew init --port <N>.`,
|
|
18
|
+
},
|
|
19
|
+
'busy-paired': {
|
|
20
|
+
en: ({ port }) => `Port ${port} is busy and paired peers exist: refusing automatic port change (the peers point at this port). Free port ${port}, or choose another one explicitly and re-pair the peers: nexuscrew init --port <N>.`,
|
|
21
|
+
it: ({ port }) => `La porta ${port} è occupata e ci sono nodi accoppiati: cambio automatico di porta rifiutato (i nodi puntano a questa porta). Libera la porta ${port}, oppure scegline un'altra in modo esplicito e riaccoppia i nodi: nexuscrew init --port <N>.`,
|
|
22
|
+
es: ({ port }) => `El puerto ${port} está ocupado y hay nodos emparejados: cambio automático de puerto rechazado (los nodos apuntan a este puerto). Libera el puerto ${port} o elige otro de forma explícita y vuelve a emparejar los nodos: nexuscrew init --port <N>.`,
|
|
23
|
+
},
|
|
24
|
+
'busy-token': {
|
|
25
|
+
en: ({ port }) => `Port ${port} answers as a NexusCrew node that rejects this token. The port is not changed. Check ~/.nexuscrew/token or the instance running there.`,
|
|
26
|
+
it: ({ port }) => `La porta ${port} risponde come un nodo NexusCrew che rifiuta questo token. La porta non viene cambiata. Controlla ~/.nexuscrew/token o l'istanza che gira lì.`,
|
|
27
|
+
es: ({ port }) => `El puerto ${port} responde como un nodo NexusCrew que rechaza este token. El puerto no se cambia. Revisa ~/.nexuscrew/token o la instancia que corre allí.`,
|
|
28
|
+
},
|
|
29
|
+
'busy-own-slow': {
|
|
30
|
+
en: ({ port }) => `The NexusCrew process on port ${port} is running but does not answer yet. The port is not changed. Retry in a moment (nexuscrew show) or check the log (nexuscrew logs).`,
|
|
31
|
+
it: ({ port }) => `Il processo NexusCrew sulla porta ${port} è avviato ma non risponde ancora. La porta non viene cambiata. Riprova fra poco (nexuscrew show) o controlla il log (nexuscrew logs).`,
|
|
32
|
+
es: ({ port }) => `El proceso NexusCrew en el puerto ${port} está en marcha pero aún no responde. El puerto no se cambia. Reintenta en un momento (nexuscrew show) o revisa el log (nexuscrew logs).`,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// NEXUSCREW_LANG, poi LC_ALL, LC_MESSAGES, LANG: la prima variabile impostata decide; se non e' it/en/es -> 'all'.
|
|
37
|
+
function pickLang(env = process.env) {
|
|
38
|
+
for (const name of ['NEXUSCREW_LANG', 'LC_ALL', 'LC_MESSAGES', 'LANG']) {
|
|
39
|
+
const value = String(env[name] || '').trim();
|
|
40
|
+
if (!value || value === 'C' || value === 'POSIX' || /^C\./.test(value)) continue;
|
|
41
|
+
const base = value.slice(0, 2).toLowerCase();
|
|
42
|
+
return LANGS.includes(base) ? base : 'all';
|
|
43
|
+
}
|
|
44
|
+
return 'all';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function portMessage(key, params, { lang, env } = {}) {
|
|
48
|
+
const entry = CATALOG[key];
|
|
49
|
+
if (!entry) throw new Error(`messaggio sconosciuto: ${key}`);
|
|
50
|
+
const chosen = lang || pickLang(env);
|
|
51
|
+
if (chosen === 'all') return ['en', 'it', 'es'].map((l) => entry[l](params)).join('\n');
|
|
52
|
+
return (entry[chosen] || entry.en)(params);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { LANGS, pickLang, portMessage };
|
package/lib/cli/url.js
CHANGED
|
@@ -46,9 +46,13 @@ function loadPort(opts = {}) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
// URL loopback. withToken=true e token presente -> aggiunge #token=… (mai altrove).
|
|
49
|
-
|
|
49
|
+
// deviceId (opzionale, 32 hex emesso dal nodo): identifica il profilo di preferenze del dispositivo che apre il
|
|
50
|
+
// link. Viaggia solo accanto al token e mai da solo.
|
|
51
|
+
function buildUrl(port, token, { withToken = false, deviceId } = {}) {
|
|
50
52
|
const base = `http://127.0.0.1:${port}/`;
|
|
51
|
-
|
|
53
|
+
if (!(withToken && token)) return base;
|
|
54
|
+
const device = typeof deviceId === 'string' && /^[a-f0-9]{32}$/.test(deviceId) ? `&device=${deviceId}` : '';
|
|
55
|
+
return `${base}#token=${token}${device}`;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
// QR ASCII del testo. Generazione locale (qrcode-terminal), callback sincrona.
|
package/lib/config.js
CHANGED
|
@@ -147,6 +147,37 @@ function readConfigJson() {
|
|
|
147
147
|
} catch (_) { return {}; }
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
// Lettura della config per una SCRITTURA (merge). Distingue tre casi, e i tre
|
|
151
|
+
// non collassano in uno: chi scrive deve poter dire «assente» (legittimo: si
|
|
152
|
+
// parte da zero) da «presente ma illeggibile» (mai partire da zero: si
|
|
153
|
+
// perderebbero le chiavi dell'operatore, e i default tornerebbero — e' il
|
|
154
|
+
// guasto per cui autoUpdate si riaccendeva da solo).
|
|
155
|
+
// Il motivo e' una CATEGORIA fissa — 'parse-error', 'not-object',
|
|
156
|
+
// 'read-error:<codice errno>' — MAI il messaggio del parser: su Node recenti
|
|
157
|
+
// JSON.parse include un estratto dell'input, cioe' contenuto della config, e
|
|
158
|
+
// questi messaggi finiscono in risposte HTTP e log.
|
|
159
|
+
// -> { ok: true, cfg } | { ok: false, motivo, path }
|
|
160
|
+
function readConfigForWrite(filePath, { fsImpl = fs } = {}) {
|
|
161
|
+
const target = filePath || configJsonPath();
|
|
162
|
+
let testo;
|
|
163
|
+
try {
|
|
164
|
+
testo = fsImpl.readFileSync(target, 'utf8');
|
|
165
|
+
} catch (e) {
|
|
166
|
+
if (e && e.code === 'ENOENT') return { ok: true, cfg: {} }; // assente: si parte da zero
|
|
167
|
+
return { ok: false, motivo: `read-error${e && e.code ? `:${e.code}` : ''}`, path: target };
|
|
168
|
+
}
|
|
169
|
+
let parsed;
|
|
170
|
+
try {
|
|
171
|
+
parsed = JSON.parse(testo);
|
|
172
|
+
} catch (_) {
|
|
173
|
+
return { ok: false, motivo: 'parse-error', path: target };
|
|
174
|
+
}
|
|
175
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
176
|
+
return { ok: false, motivo: 'not-object', path: target };
|
|
177
|
+
}
|
|
178
|
+
return { ok: true, cfg: parsed };
|
|
179
|
+
}
|
|
180
|
+
|
|
150
181
|
// Override da env (precedence più alta di config.json).
|
|
151
182
|
function envOverrides() {
|
|
152
183
|
const e = {};
|
|
@@ -259,4 +290,4 @@ function defaults() {
|
|
|
259
290
|
return { ...base, ...env, terminal: resolveTerminal(base, env) };
|
|
260
291
|
}
|
|
261
292
|
|
|
262
|
-
module.exports = { assertLoopback, baseDefaults, readConfigJson, loadConfig, defaults, LOOPBACK, configJsonPath, eventsEnabledFlag, readFederationEventsEnabled, TERMINAL_DEFAULTS, resolveTerminal };
|
|
293
|
+
module.exports = { assertLoopback, baseDefaults, readConfigJson, readConfigForWrite, loadConfig, defaults, LOOPBACK, configJsonPath, eventsEnabledFlag, readFederationEventsEnabled, TERMINAL_DEFAULTS, resolveTerminal };
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
const fs = require('node:fs');
|
|
11
11
|
const path = require('node:path');
|
|
12
12
|
const crypto = require('node:crypto');
|
|
13
|
+
const { readConfigForWrite } = require('../config.js');
|
|
13
14
|
|
|
14
15
|
const CREDENTIAL_FILES = Object.freeze({
|
|
15
16
|
identityDaemonCredential: 'daemon.credential',
|
|
@@ -61,6 +62,23 @@ function provisionIdentityAuthority({
|
|
|
61
62
|
fsImpl = fs,
|
|
62
63
|
now = new Date,
|
|
63
64
|
} = {}) {
|
|
65
|
+
// PRIMA di qualunque scrittura (cartella compresa): se la config esiste ma
|
|
66
|
+
// non si legge, si rifiuta senza aver toccato NULLA. Motivo categorico, mai
|
|
67
|
+
// il messaggio del parser: contiene un estratto del file e finirebbe nel
|
|
68
|
+
// referto del comando.
|
|
69
|
+
const resolvedConfigPath = configPath
|
|
70
|
+
|| (process.env.NEXUSCREW_CONFIG_FILE
|
|
71
|
+
|| path.join(home || require('node:os').homedir(), '.nexuscrew', 'config.json'));
|
|
72
|
+
const lettaConfig = writeConfig
|
|
73
|
+
? readConfigForWrite(resolvedConfigPath, { fsImpl })
|
|
74
|
+
: { ok: true, cfg: {} };
|
|
75
|
+
if (!lettaConfig.ok) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false, code: 'CONFIG_UNREADABLE', dir: dir || defaultAuthorityDir(home),
|
|
78
|
+
message: `config illeggibile ${lettaConfig.path} (${lettaConfig.motivo}): non la riscrivo, niente e' stato scritto`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
64
82
|
const targetDir = dir || defaultAuthorityDir(home);
|
|
65
83
|
fsImpl.mkdirSync(targetDir, { recursive: true, mode: 0o700 });
|
|
66
84
|
|
|
@@ -104,18 +122,10 @@ function provisionIdentityAuthority({
|
|
|
104
122
|
for (const filePath of written) fsImpl.chmodSync(filePath, 0o600);
|
|
105
123
|
for (const filePath of written) assertSafeMode(filePath, 0o600);
|
|
106
124
|
|
|
107
|
-
const resolvedConfigPath = configPath
|
|
108
|
-
|| (process.env.NEXUSCREW_CONFIG_FILE
|
|
109
|
-
|| path.join(home || require('node:os').homedir(), '.nexuscrew', 'config.json'));
|
|
110
|
-
|
|
111
125
|
let configUpdated = false;
|
|
112
126
|
let configInstructions = null;
|
|
113
127
|
if (writeConfig) {
|
|
114
|
-
|
|
115
|
-
try {
|
|
116
|
-
config = JSON.parse(fsImpl.readFileSync(resolvedConfigPath, 'utf8'));
|
|
117
|
-
if (!config || typeof config !== 'object' || Array.isArray(config)) config = {};
|
|
118
|
-
} catch (_) { config = {}; }
|
|
128
|
+
const config = lettaConfig.cfg;
|
|
119
129
|
config.fleet = { ...(config.fleet || {}), identity: { ...(config.fleet?.identity || {}), mode: 'authority' } };
|
|
120
130
|
fsImpl.mkdirSync(path.dirname(resolvedConfigPath), { recursive: true });
|
|
121
131
|
fsImpl.writeFileSync(resolvedConfigPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
@@ -45,6 +45,7 @@ function createAskAnswerService({ asks, paste, receipts, onClosure, labelPrefix
|
|
|
45
45
|
// for a paste that never happened.
|
|
46
46
|
if (request && receipts) receipts.finalize(request.peerId, askId, request.requestId, 'failed', `refused:${claim.reason}`);
|
|
47
47
|
if (claim.reason === 'unknown') return { ok: false, code: 404, error: 'ask inesistente', reason: 'unknown' };
|
|
48
|
+
if (claim.reason === 'store-unreadable') return { ok: false, code: 503, error: 'store delle ask illeggibile: risposta non accettata', reason: 'store-unreadable' };
|
|
48
49
|
if (claim.reason === 'dismissed') return { ok: false, code: 409, error: 'ask gia\' scartato (dismissed)', reason: 'dismissed' };
|
|
49
50
|
if (claim.reason === 'answering') return { ok: false, code: 409, error: 'risposta gia\' in corso da un\'altra richiesta', reason: 'answering' };
|
|
50
51
|
return { ok: false, code: 409, error: 'ask gia\' risposto', reason: 'answered' };
|
package/lib/notify/asks.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// claim -> (paste) -> commit/release (da revisione: una sola answer puo' vincere).
|
|
7
7
|
const path = require('node:path');
|
|
8
8
|
const crypto = require('node:crypto');
|
|
9
|
-
const {
|
|
9
|
+
const { readJsonStrict, atomicWriteJson } = require('./persist.js');
|
|
10
10
|
|
|
11
11
|
const ASKS_FILE = 'asks.json';
|
|
12
12
|
// da revisione: MAX_OPEN e' un cap DURO sugli ask aperti — al cap il nuovo ask viene
|
|
@@ -28,17 +28,56 @@ function createAsksStore(opts = {}) {
|
|
|
28
28
|
const now = opts.now || (() => Date.now());
|
|
29
29
|
|
|
30
30
|
// Carica lazy al primo accesso (niente I/O in createServer per i path reali).
|
|
31
|
+
// Tre esiti:
|
|
32
|
+
// - leggibile (assente => [], oppure oggetto con `asks` array => filtrato per
|
|
33
|
+
// id stringa): si CACHEA come oggi, perche' e' uno stato legittimo e stabile.
|
|
34
|
+
// - illeggibile (JSON malformato, oppure oggetto senza `asks` o con `asks`
|
|
35
|
+
// non array): NON si cachea. Ogni accesso rilegge il file, cosicche' se
|
|
36
|
+
// l'operatore ripara asks.json lo store torna sano senza riavvio. I reader
|
|
37
|
+
// (list/get/openCount/findImported) vedono [] — le superfici LOCALI non
|
|
38
|
+
// cambiano comportamento — mentre le MUTAZIONI rifiutano (v. unreadableGuard)
|
|
39
|
+
// per non sovrascrivere il file malformato con un defaults vuoto, che
|
|
40
|
+
// cancellerebbe le domande aperte.
|
|
31
41
|
let asks = null;
|
|
32
42
|
function load() {
|
|
33
43
|
if (asks) return asks;
|
|
34
|
-
const
|
|
35
|
-
asks =
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
const r = readJsonStrict(filePath);
|
|
45
|
+
if (r.state === 'absent') { asks = []; return asks; }
|
|
46
|
+
if (r.state === 'ok' && Array.isArray(r.value.asks)) {
|
|
47
|
+
asks = r.value.asks.filter((a) => a && typeof a === 'object' && typeof a.id === 'string');
|
|
48
|
+
return asks;
|
|
49
|
+
}
|
|
50
|
+
// illeggibile: NON cacheare (reload al prossimo accesso se il file viene riparato).
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Health leggibile dal produttore dello snapshot: distingue un vuoto
|
|
55
|
+
// LEGITTIMO (store leggibile, nessuna domanda aperta) da un vuoto NON
|
|
56
|
+
// autorevole (store illeggibile). Non muta stato.
|
|
57
|
+
function health() {
|
|
58
|
+
if (asks) return { readable: true };
|
|
59
|
+
const r = readJsonStrict(filePath);
|
|
60
|
+
if (r.state === 'absent') return { readable: true };
|
|
61
|
+
if (r.state === 'ok' && Array.isArray(r.value.asks)) return { readable: true };
|
|
62
|
+
const reason = r.state === 'malformed'
|
|
63
|
+
? r.reason
|
|
64
|
+
: `forma invalida: 'asks' non e' un array (${r.value && r.value.asks !== undefined ? typeof r.value.asks : 'assente'})`;
|
|
65
|
+
return { readable: false, reason };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Null se lo store e' leggibile (e le mutazioni possono procedere), altrimenti
|
|
69
|
+
// la reason. Si usa all'inizio di ogni mutazione: uno store illeggibile non
|
|
70
|
+
// viene MAI scritto, cosi' il file malformato non viene sovrascritto.
|
|
71
|
+
function unreadableGuard() {
|
|
72
|
+
if (asks) return null;
|
|
73
|
+
const h = health();
|
|
74
|
+
return h.readable ? null : (h.reason || 'store-unreadable');
|
|
39
75
|
}
|
|
40
76
|
|
|
41
77
|
function save() {
|
|
78
|
+
// Difensivo: mai sovrascrivere uno store illeggibile. Le mutazioni hanno
|
|
79
|
+
// gia' il guard, ma questo impedisce un overwrite se una via lo saltasse.
|
|
80
|
+
if (unreadableGuard()) return;
|
|
42
81
|
// prune: mai piu' di MAX_KEEP; si scartano prima gli answered piu' vecchi.
|
|
43
82
|
const list = load();
|
|
44
83
|
if (list.length > MAX_KEEP) {
|
|
@@ -90,6 +129,8 @@ function createAsksStore(opts = {}) {
|
|
|
90
129
|
// riga resta nello storico marcata, quindi non ricompare a un reload e non
|
|
91
130
|
// torna risponibile.
|
|
92
131
|
function closeImported({ ownerId, ownerAskId, outcome }) {
|
|
132
|
+
const ur = unreadableGuard();
|
|
133
|
+
if (ur) return { ok: false, reason: 'store-unreadable', changed: false, ask: null };
|
|
93
134
|
const ask = findImported(ownerId, ownerAskId);
|
|
94
135
|
if (!ask) return { ok: true, changed: false, ask: null };
|
|
95
136
|
if (ask.answered || ask.dismissed) return { ok: true, changed: false, ask };
|
|
@@ -101,6 +142,8 @@ function createAsksStore(opts = {}) {
|
|
|
101
142
|
}
|
|
102
143
|
|
|
103
144
|
function create({ question, options, session, ownerId, ownerAskId, originNode, originCell }) {
|
|
145
|
+
const ur = unreadableGuard();
|
|
146
|
+
if (ur) return { ok: false, reason: 'store-unreadable', error: `store illeggibile (${ur}): creazione rifiutata` };
|
|
104
147
|
const v = validate({ question, options });
|
|
105
148
|
if (!v.ok) return { ok: false, reason: 'invalid', error: v.error };
|
|
106
149
|
// DEDUP: la stessa domanda puo' arrivare due volte allo stesso nodo (import
|
|
@@ -174,6 +217,8 @@ function createAsksStore(opts = {}) {
|
|
|
174
217
|
const answering = new Set();
|
|
175
218
|
|
|
176
219
|
function claim(id) {
|
|
220
|
+
const ur = unreadableGuard();
|
|
221
|
+
if (ur) return { ok: false, reason: 'store-unreadable' };
|
|
177
222
|
const ask = get(id);
|
|
178
223
|
if (!ask) return { ok: false, reason: 'unknown' };
|
|
179
224
|
// Un ask dismissato non e' piu' risponibile: senza questa guardia la
|
|
@@ -197,6 +242,8 @@ function createAsksStore(opts = {}) {
|
|
|
197
242
|
|
|
198
243
|
// Commit: paste riuscito -> answered persistito, claim rilasciato.
|
|
199
244
|
function commit(id, text) {
|
|
245
|
+
const ur = unreadableGuard();
|
|
246
|
+
if (ur) return false;
|
|
200
247
|
const ask = get(id);
|
|
201
248
|
answering.delete(id);
|
|
202
249
|
if (!ask || ask.answered) return false;
|
|
@@ -214,6 +261,8 @@ function createAsksStore(opts = {}) {
|
|
|
214
261
|
// volte non e' un errore. Un ask dismissato non e' piu' open (non appare in
|
|
215
262
|
// list({open:true})/openCount) ma resta nello storico (list({open:false})).
|
|
216
263
|
function dismiss(id) {
|
|
264
|
+
const ur = unreadableGuard();
|
|
265
|
+
if (ur) return { ok: false, reason: 'store-unreadable' };
|
|
217
266
|
const ask = get(id);
|
|
218
267
|
if (!ask) return { ok: false, reason: 'unknown' };
|
|
219
268
|
if (answering.has(id)) return { ok: false, reason: 'answering' };
|
|
@@ -230,6 +279,8 @@ function createAsksStore(opts = {}) {
|
|
|
230
279
|
// `allow-new-attempt` leaves it open. Both advance the revision, so the token
|
|
231
280
|
// the operator reconciled with can never be reused on the same generation.
|
|
232
281
|
function markReconciled(id, decision) {
|
|
282
|
+
const ur = unreadableGuard();
|
|
283
|
+
if (ur) return { ok: false, reason: 'store-unreadable' };
|
|
233
284
|
const ask = get(id);
|
|
234
285
|
if (!ask) return { ok: false, reason: 'unknown' };
|
|
235
286
|
answering.delete(id);
|
|
@@ -250,7 +301,7 @@ function createAsksStore(opts = {}) {
|
|
|
250
301
|
return commit(id, text);
|
|
251
302
|
}
|
|
252
303
|
|
|
253
|
-
return { create, get, list, openCount, isImported, findImported, closeImported, claim, release, commit, markAnswered, markReconciled, isAnswering, dismiss, validate, filePath, MAX_OPEN, MAX_OPEN_IMPORTED };
|
|
304
|
+
return { create, get, list, openCount, isImported, findImported, closeImported, claim, release, commit, markAnswered, markReconciled, isAnswering, dismiss, validate, health, filePath, MAX_OPEN, MAX_OPEN_IMPORTED };
|
|
254
305
|
}
|
|
255
306
|
|
|
256
307
|
module.exports = { createAsksStore };
|