@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.
@@ -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-5MtkSiOQ.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-CcZskBmy.css">
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.52"}
1
+ {"version":"0.9.54"}
@@ -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
- pidf.writePidfile(pidPath, process.pid, cmd);
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
- const previousPort = port;
760
- port = await findAvailablePort(port + 1, opts);
761
- refusePairedPortRelocation(opts, previousPort, port);
762
- runInitImpl({ ...opts, port, log: quiet, platform, installBoot: persistent, printUrl: false });
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) openPwa(urlmod.buildUrl(port, token, { withToken: true }), { ...opts, platform });
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
- let cfg = {};
944
- try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (_) { /* file nuovo */ }
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
- log(urlmod.buildUrl(r.port, token, { withToken: true })); return { code: 0 };
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
- let current;
173
- try {
174
- current = JSON.parse(fs.readFileSync(configPath, 'utf8'));
175
- } catch (_) { current = {}; }
176
- if (opts.port) {
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 && current.port) port = current.port;
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
- function buildUrl(port, token, { withToken = false } = {}) {
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
- return withToken && token ? `${base}#token=${token}` : base;
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
- let config = {};
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' };
@@ -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 { readJsonSafe, atomicWriteJson } = require('./persist.js');
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 cur = readJsonSafe(filePath);
35
- asks = Array.isArray(cur.asks)
36
- ? cur.asks.filter((a) => a && typeof a === 'object' && typeof a.id === 'string')
37
- : [];
38
- return asks;
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 };