@mmmbuto/nexuscrew 0.8.19 → 0.8.21

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.
@@ -47,7 +47,7 @@ const nodesStore = require('../nodes/store.js');
47
47
  const nodesCmds = require('../nodes/commands.js');
48
48
  const nodesTunnel = require('../nodes/tunnel.js');
49
49
  const peering = require('../nodes/peering.js');
50
- const { waitForHealthyPeer } = require('../proxy/federation.js');
50
+ const { waitForHealthyPeer, notifyHubShare } = require('../proxy/federation.js');
51
51
  const { rotateToken } = require('../auth/token.js');
52
52
  const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
53
53
  const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
@@ -215,7 +215,7 @@ function settingsRoutes(deps = {}) {
215
215
  };
216
216
  if (updateStatus) out.update = updateStatus;
217
217
  send(res, 200, out);
218
- } catch (e) { send(res, 500, { error: String(e.message || e) }); }
218
+ } catch (e) { send(res, e.status || 500, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
219
219
  });
220
220
 
221
221
  // --- POST /config — scrittura atomica, subset whitelisted ------------------
@@ -333,7 +333,7 @@ function settingsRoutes(deps = {}) {
333
333
  try {
334
334
  const b = req.body || {};
335
335
  const label = nodesStore.sanitizeLabel(b.label, defaultDeviceName());
336
- const st = nodesStore.loadOrInitStore(nodesPath);
336
+ const st = nodesStore.loadStoreStrict(nodesPath);
337
337
  const extra = {};
338
338
  if (typeof b.ssh === 'string' && b.ssh.trim()) {
339
339
  const ssh = nodesStore.parseSshTarget(b.ssh.trim());
@@ -359,7 +359,7 @@ function settingsRoutes(deps = {}) {
359
359
  send(res, 200, peering.createInvite({
360
360
  invitesPath, instanceId: st.nodeId, port: remotePort, linkPort: runtimePort(), label, ...extra,
361
361
  }));
362
- } catch (e) { send(res, 500, { error: String(e.message || e) }); }
362
+ } catch (e) { send(res, e.status || 500, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
363
363
  });
364
364
 
365
365
  // --- POST /nodes/pair — hydra join in stadi espliciti ---------------------
@@ -413,7 +413,7 @@ function settingsRoutes(deps = {}) {
413
413
  // impossibile (validata sopra), ma non facciamo mai fallire l'add per lei.
414
414
  if (b.label !== undefined) {
415
415
  try {
416
- let st = nodesStore.loadOrInitStore(nodesPath);
416
+ let st = nodesStore.loadStoreStrict(nodesPath);
417
417
  st = nodesStore.updateNode(st, out.name, { label: nodesStore.sanitizeLabel(b.label, out.name) });
418
418
  nodesStore.atomicWriteStore(nodesPath, st);
419
419
  } catch (_) { /* best-effort: il nodo e' gia' creato */ }
@@ -435,7 +435,11 @@ function settingsRoutes(deps = {}) {
435
435
  const status = /duplicato|self-reference/.test(msg) ? 409 : 400;
436
436
  return send(res, status, { error: msg });
437
437
  }
438
- // store invalido / keygen fallita / write fallita: errore lato server.
438
+ // Store mancante/invalido e' indisponibilita operativa, mai un invito a
439
+ // ricrearlo implicitamente. Gli altri errori restano 500.
440
+ if (out.reason === 'store invalido') {
441
+ return send(res, out.status || 503, { error: msg, ...(out.errorCode ? { code: out.errorCode } : {}) });
442
+ }
439
443
  return send(res, 500, { error: msg });
440
444
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
441
445
  });
@@ -522,32 +526,20 @@ function settingsRoutes(deps = {}) {
522
526
  if (body.shared && !nodesStore.isPort(node.reversePort)) {
523
527
  return send(res, 409, { error: 'canale share non negoziato: ripeti il pairing' });
524
528
  }
525
- if (node.shared === body.shared) return send(res, 200, { name, shared: body.shared, unchanged: true });
526
-
529
+ const wasShared = node.shared === true;
530
+ let persistedDesired = wasShared;
527
531
  const fetchImpl = seams.fetchImpl || fetch;
528
- const notifyHub = async (shared) => {
529
- const ctrl = new AbortController();
530
- const timer = setTimeout(() => ctrl.abort(), 5000);
531
- try {
532
- const response = await fetchImpl(`http://127.0.0.1:${node.localPort}/federation/share`, {
533
- method: 'POST', signal: ctrl.signal,
534
- headers: { authorization: `Bearer ${node.token}`, 'content-type': 'application/json' },
535
- body: JSON.stringify({ shared }),
536
- });
537
- if (!response.ok) throw new Error(`hub HTTP ${response.status}`);
538
- } finally { clearTimeout(timer); }
539
- };
540
- const applyLocal = async (shared) => {
541
- st = nodesStore.loadOrInitStore(nodesPath);
542
- st = nodesStore.updateNode(st, name, { shared });
543
- nodesStore.atomicWriteStore(nodesPath, st);
544
- node = nodesStore.getNode(st, name);
545
- nodesTunnel.stopTunnel({ home, name });
532
+ const notifyHub = (shared) => notifyHubShare({ node, shared, fetchImpl, timeoutMs: 5000 });
533
+ const ensureLocal = async ({ restart = false } = {}) => {
534
+ if (restart) nodesTunnel.stopTunnel({ home, name });
546
535
  const started = nodesTunnel.startForward({
547
536
  home, node, localAppPort: runtimePort(),
548
537
  spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
549
538
  sshBin: seams.sshBin, logFd: seams.logFd,
550
539
  });
540
+ // startForward is spec-aware: an exact live argv is idempotent, while a
541
+ // stale pre-upgrade supervisor whose -L/-R no longer matches nodes.json
542
+ // is replaced. This is the recovery path for store=false + stale -R.
551
543
  if (!started.started && started.reason !== 'already running') {
552
544
  throw new Error(started.reason || 'avvio SSH fallito');
553
545
  }
@@ -558,29 +550,55 @@ function settingsRoutes(deps = {}) {
558
550
  });
559
551
  if (!ready || ready.status !== 'healthy') {
560
552
  const diagnosis = (seams.readTunnelDiagnostic || nodesTunnel.readTunnelDiagnostic)(home, name, node.remotePort);
561
- throw new Error((diagnosis && diagnosis.detail) || (ready && ready.detail) || 'hub non raggiungibile dopo il riavvio SSH');
553
+ const failure = new Error((diagnosis && diagnosis.detail) || (ready && ready.detail) || 'hub non raggiungibile dopo il riavvio SSH');
554
+ if (diagnosis && typeof diagnosis.hint === 'string') failure.hint = diagnosis.hint;
555
+ throw failure;
562
556
  }
557
+ return started;
558
+ };
559
+ const applyLocal = async (shared) => {
560
+ st = nodesStore.loadStoreStrict(nodesPath);
561
+ st = nodesStore.updateNode(st, name, { shared });
562
+ nodesStore.atomicWriteStore(nodesPath, st);
563
+ persistedDesired = shared;
564
+ node = nodesStore.getNode(st, name);
565
+ await ensureLocal({ restart: true });
563
566
  };
564
567
 
565
568
  try {
569
+ if (node.shared === body.shared) {
570
+ // Persisted intent is not proof of the detached process mode. A stale
571
+ // supervisor can survive an npm upgrade and keep retrying -R even when
572
+ // the store/UI says private. Re-enter the spec-aware start path before
573
+ // reconciling the hub, without rewriting nodes.json.
574
+ await ensureLocal();
575
+ await notifyHub(body.shared);
576
+ return send(res, 200, { name, shared: body.shared, unchanged: true, reconciled: true });
577
+ }
566
578
  if (body.shared) {
567
579
  await applyLocal(true);
568
580
  await notifyHub(true);
569
581
  } else {
570
- // Revoke at the hub while -R is still alive, then remove -R locally.
571
- await notifyHub(false);
582
+ // Persist OFF first. If the process dies before the hub ACK, the next
583
+ // boot starts only -L and reconciles the stored false state.
572
584
  await applyLocal(false);
585
+ await notifyHub(false);
573
586
  }
574
587
  return send(res, 200, { name, shared: body.shared });
575
588
  } catch (e) {
576
589
  // Share-on is transactional: a failed hub acknowledgement returns to the
577
590
  // safe private -L-only state. Never include remote response bodies/tokens.
578
- if (body.shared) {
591
+ if (body.shared && !wasShared) {
579
592
  try { await applyLocal(false); } catch (_) { /* best-effort safe rollback */ }
580
593
  }
594
+ const offPersisted = body.shared === false && persistedDesired === false;
595
+ const redact = (value) => String(value || '').replace(/Bearer\s+\S+/gi, 'Bearer ***');
581
596
  return send(res, 502, {
582
- error: body.shared ? 'Share non attivato' : 'Share non disattivato',
583
- detail: String(e && e.message || e).replace(/Bearer\s+\S+/gi, 'Bearer ***'),
597
+ error: body.shared ? 'Share non attivato'
598
+ : offPersisted ? 'Share disattivato localmente; hub non riconciliato' : 'Share non disattivato',
599
+ ...(offPersisted ? { shared: false, reconcilePending: true } : {}),
600
+ detail: redact(e && e.message || e),
601
+ ...(e && typeof e.hint === 'string' ? { hint: redact(e.hint) } : {}),
584
602
  });
585
603
  }
586
604
  });
@@ -593,11 +611,11 @@ function settingsRoutes(deps = {}) {
593
611
  if (!validName(name) || !['network', 'relay-only', 'selected'].includes(visibility)) {
594
612
  return send(res, 400, { error: 'visibility non valida' });
595
613
  }
596
- let st = nodesStore.loadOrInitStore(nodesPath);
614
+ let st = nodesStore.loadStoreStrict(nodesPath);
597
615
  st = nodesStore.updateNode(st, name, { visibility, selected: visibility === 'selected' ? selected : [] });
598
616
  nodesStore.atomicWriteStore(nodesPath, st);
599
617
  send(res, 200, { saved: true, name, visibility });
600
- } catch (e) { send(res, 400, { error: String(e.message || e) }); }
618
+ } catch (e) { send(res, e.status || 400, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
601
619
  });
602
620
  // Rinomina la label umana di un nodo SENZA toccare il name (route/URL stabili).
603
621
  r.patch('/nodes/:name/label', mutGate, (req, res) => {
@@ -606,12 +624,12 @@ function settingsRoutes(deps = {}) {
606
624
  const label = req.body && req.body.label;
607
625
  if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
608
626
  if (!nodesStore.validLabel(label)) return send(res, 400, { error: 'label non valida (max 64 char, niente a capo)' });
609
- let st = nodesStore.loadOrInitStore(nodesPath);
627
+ let st = nodesStore.loadStoreStrict(nodesPath);
610
628
  if (!nodesStore.getNode(st, name)) return send(res, 404, { error: `nodo sconosciuto "${name}"` });
611
629
  st = nodesStore.updateNode(st, name, { label: nodesStore.sanitizeLabel(label, name) });
612
630
  nodesStore.atomicWriteStore(nodesPath, st);
613
631
  send(res, 200, { saved: true, name, label: nodesStore.nodeLabel(nodesStore.getNode(st, name)) });
614
- } catch (e) { send(res, 400, { error: String(e.message || e) }); }
632
+ } catch (e) { send(res, e.status || 400, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
615
633
  });
616
634
 
617
635
  // Legacy compatibility endpoint: the old node-role/rendezvous flow opened a
package/lib/tmux/list.js CHANGED
@@ -52,6 +52,15 @@ function parseSessions(raw) {
52
52
  .filter((session) => session.windows > 0);
53
53
  }
54
54
 
55
+ // tmux uses different expected "no server" errors across platforms. Linux
56
+ // commonly prints "no server running" while macOS reports the missing socket
57
+ // path. Both mean an empty inventory, not a broken NexusCrew node.
58
+ function isNoTmuxServerError(raw) {
59
+ const message = String(raw || '');
60
+ return /no server running/i.test(message)
61
+ || /error connecting to .*(?:No such file or directory|Connection refused)/i.test(message);
62
+ }
63
+
55
64
  function setSessionVisibility(tmuxBin = 'tmux', name, technical = false) {
56
65
  return new Promise((resolve, reject) => {
57
66
  if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name) || name.startsWith('-')) {
@@ -73,7 +82,7 @@ function listSessions(tmuxBin = 'tmux') {
73
82
  return new Promise((resolve, reject) => {
74
83
  execFile(tmuxBin, ['list-sessions', '-F', FMT], (err, stdout, stderr) => {
75
84
  if (err) {
76
- if (/no server running/i.test(stderr || '')) return resolve([]);
85
+ if (isNoTmuxServerError(stderr || err.message)) return resolve([]);
77
86
  return reject(new Error(`tmux list-sessions failed: ${stderr || err.message}`));
78
87
  }
79
88
  resolve(parseSessions(stdout));
@@ -95,5 +104,5 @@ function attachedClients(tmuxBin = 'tmux', session) {
95
104
  }
96
105
 
97
106
  module.exports = {
98
- parsePaneTitle, parseSessions, listSessions, attachedClients, setSessionVisibility, FMT,
107
+ parsePaneTitle, parseSessions, isNoTmuxServerError, listSessions, attachedClients, setSessionVisibility, FMT,
99
108
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.19",
3
+ "version": "0.8.21",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {