@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.
@@ -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.
@@ -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 };