@mmmbuto/nexuscrew 0.8.7 → 0.8.9

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-D2TrRtWQ.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-DrEuy6A6.css">
14
+ <script type="module" crossorigin src="/assets/index-DTmT7yhV.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-9Drd8g0k.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.8.7"}
1
+ {"version":"0.8.9"}
package/lib/config.js CHANGED
@@ -30,6 +30,9 @@ function baseDefaults() {
30
30
  fleetEnabled: true,
31
31
  fleetBin: path.join(os.homedir(), '.local', 'bin', 'fleet'),
32
32
  providerSecretsPath: path.join(os.homedir(), '.nexuscrew', 'providers.env'),
33
+ // Installazioni npm globali controllano periodicamente il dist-tag latest.
34
+ // Il manager aggiorna solo verso una semver superiore: mai downgrade.
35
+ autoUpdate: true,
33
36
  sessionPresets: {},
34
37
  };
35
38
  }
@@ -63,6 +66,9 @@ function envOverrides() {
63
66
  if (process.env.NEXUSCREW_FLEET) e.fleetEnabled = process.env.NEXUSCREW_FLEET !== '0';
64
67
  if (process.env.NEXUSCREW_FLEET_BIN) e.fleetBin = process.env.NEXUSCREW_FLEET_BIN;
65
68
  if (process.env.NEXUSCREW_PROVIDER_SECRETS) e.providerSecretsPath = process.env.NEXUSCREW_PROVIDER_SECRETS;
69
+ if (process.env.NEXUSCREW_AUTO_UPDATE !== undefined) {
70
+ e.autoUpdate = !['', '0', 'false', 'no', 'off'].includes(String(process.env.NEXUSCREW_AUTO_UPDATE).toLowerCase());
71
+ }
66
72
  return e;
67
73
  }
68
74
 
@@ -26,7 +26,7 @@ const path = require('node:path');
26
26
  const { execFile } = require('node:child_process');
27
27
  const {
28
28
  parseDefinitions, validateCommandTrust, resolveCwd,
29
- loadDefinitions, atomicWrite, CAPS, validTmuxName,
29
+ loadDefinitions, atomicWrite, CAPS, MAX_CELLS, validTmuxName,
30
30
  } = require('./definitions.js');
31
31
  const {
32
32
  publicCatalog, describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels,
@@ -56,7 +56,7 @@ function minimalEnv() {
56
56
  return env;
57
57
  }
58
58
 
59
- function httpError(status, msg) { const e = new Error(msg); e.status = status; return e; }
59
+ function httpError(status, msg, data = null) { const e = new Error(msg); e.status = status; if (data) e.data = data; return e; }
60
60
 
61
61
  // Marcatore di redazione (design §9h): stderr/stdout dei comandi tmux falliti
62
62
  // NON devono mai ecoare i segreti delle definizioni.
@@ -541,6 +541,59 @@ async function createBuiltinFleet(cfg = {}) {
541
541
  const sessions = await refreshSessions();
542
542
  return { ok: true, active: sessions.has(findCell(defs, id).tmuxSession) };
543
543
  }
544
+
545
+ // Ripristino selettivo atomico da backup PWA. Il body contiene SOLO campi
546
+ // cella allowlisted (mai engine.env/provider secrets). Tutte le celle vengono
547
+ // validate insieme da atomicWrite: un errore lascia il file precedente intatto.
548
+ async function restoreCells(cells) {
549
+ if (readonly()) throw httpError(403, 'READONLY: restore-cells bloccato');
550
+ if (!Array.isArray(cells) || cells.length < 1 || cells.length > MAX_CELLS) {
551
+ throw httpError(400, `cells deve contenere 1..${MAX_CELLS} definizioni`);
552
+ }
553
+ const allowed = new Set(['id', 'cwd', 'engine', 'boot', 'model', 'models', 'permissionPolicies', 'prompt']);
554
+ const seen = new Set();
555
+ for (const cell of cells) {
556
+ if (!cell || typeof cell !== 'object' || Array.isArray(cell)) throw httpError(400, 'definizione cell non valida');
557
+ for (const key of Object.keys(cell)) if (!allowed.has(key)) throw httpError(400, `campo backup non ammesso: ${key}`);
558
+ if (typeof cell.id !== 'string' || seen.has(cell.id)) throw httpError(400, `id cell duplicato o mancante: ${cell.id || '?'}`);
559
+ seen.add(cell.id);
560
+ }
561
+ const defs = reloadDefs();
562
+ const availableEngines = new Set(defs.engines.map((engine) => engine.id));
563
+ const referencedEngines = new Set();
564
+ for (const cell of cells) {
565
+ referencedEngines.add(cell.engine);
566
+ for (const id of Object.keys(cell.models || {})) referencedEngines.add(id);
567
+ for (const id of Object.keys(cell.permissionPolicies || {})) referencedEngines.add(id);
568
+ }
569
+ const missingEngines = [...referencedEngines].filter((id) => !availableEngines.has(id)).sort();
570
+ if (missingEngines.length) {
571
+ throw httpError(400, `engine mancanti sul target: ${missingEngines.join(', ')}`, {
572
+ code: 'missing-engines', missingEngines,
573
+ hint: 'definisci prima gli engine mancanti oppure mappa le celle su engine disponibili',
574
+ });
575
+ }
576
+ const replaced = cells.filter((cell) => !!findCell(defs, cell.id)).map((cell) => cell.id);
577
+ const created = cells.filter((cell) => !findCell(defs, cell.id)).map((cell) => cell.id);
578
+ await mutate(defs, (draft) => {
579
+ for (const cell of cells) {
580
+ const index = draft.cells.findIndex((current) => current.id === cell.id);
581
+ const next = { ...cell };
582
+ if (index >= 0) {
583
+ // tmuxSession e' identita' runtime immutabile: il backup non la porta.
584
+ next.tmuxSession = draft.cells[index].tmuxSession;
585
+ draft.cells[index] = next;
586
+ } else draft.cells.push(next);
587
+ }
588
+ });
589
+ const sessions = await refreshSessions();
590
+ const saved = reloadDefs();
591
+ return {
592
+ ok: true, count: cells.length, created, replaced,
593
+ needsRestart: cells.map((cell) => findCell(saved, cell.id))
594
+ .filter((cell) => cell && sessions.has(cell.tmuxSession)).map((cell) => cell.id),
595
+ };
596
+ }
544
597
  async function removeCell(id, opts = {}) {
545
598
  if (readonly()) throw httpError(403, 'READONLY: remove-cell bloccato');
546
599
  let defs = reloadDefs();
@@ -704,7 +757,7 @@ async function createBuiltinFleet(cfg = {}) {
704
757
  }
705
758
 
706
759
  function capabilities() {
707
- return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'schema', 'definitions'];
760
+ return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions'];
708
761
  }
709
762
 
710
763
  return {
@@ -712,7 +765,7 @@ async function createBuiltinFleet(cfg = {}) {
712
765
  provider: 'builtin',
713
766
  status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
714
767
  defineEngine, editEngine, removeEngine,
715
- defineCell, editCell, removeCell, importCell,
768
+ defineCell, editCell, removeCell, importCell, restoreCells,
716
769
  schema, definitions, capabilities,
717
770
  };
718
771
  }
@@ -25,7 +25,9 @@ function requireCap(fleet, cap) {
25
25
  // async): ogni handler attende la resolve; unavailable → 404 sui comandi.
26
26
  function fleetRoutes(fleetP, cfg = {}) {
27
27
  const r = express.Router();
28
- r.use(express.json({ limit: '4kb' }));
28
+ const smallJson = express.json({ limit: '4kb' });
29
+ const restoreJson = express.json({ limit: '256kb' });
30
+ r.use((req, res, next) => (req.path === '/restore-cells' ? restoreJson : smallJson)(req, res, next));
29
31
 
30
32
  const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
31
33
 
@@ -38,7 +40,7 @@ function fleetRoutes(fleetP, cfg = {}) {
38
40
  if (!fleet.available) return res.status(404).json({ error: 'fleet non disponibile' });
39
41
  res.json(await fn(fleet, req.body || {}));
40
42
  } catch (e) {
41
- res.status(e.status || 500).json({ error: String(e.message || e) });
43
+ res.status(e.status || 500).json({ error: String(e.message || e), ...(e.data || {}) });
42
44
  }
43
45
  };
44
46
 
@@ -110,10 +112,19 @@ function fleetRoutes(fleetP, cfg = {}) {
110
112
  r.post('/define-cell', guard((f, b) => { requireCap(f, 'define'); return f.defineCell(b.def); }, { mutate: true }));
111
113
  r.post('/edit-cell', guard((f, b) => { requireCap(f, 'edit'); return f.editCell(b.id, b.patch); }, { mutate: true }));
112
114
  r.post('/remove-cell', guard((f, b) => { requireCap(f, 'remove'); return f.removeCell(b.id, { stop: b.stop === true }); }, { mutate: true }));
115
+ r.post('/restore-cells', guard((f, b) => { requireCap(f, 'restore'); return f.restoreCells(b.cells); }, { mutate: true }));
113
116
  // Riconciliazione sessione tmux esistente (cella Fleet legacy orfana) in cella
114
117
  // gestita fleet.json. Capability 'define' (solo builtin): external legacy -> 501.
115
118
  r.post('/import-cell', guard(async (f, b) => { requireCap(f, 'import'); return f.importCell(b || {}); }, { mutate: true }));
116
119
 
120
+ r.use((err, _req, res, _next) => {
121
+ if (err && (err.type === 'entity.too.large' || err.status === 413)) {
122
+ return res.status(413).json({ error: 'body troppo grande', code: 'body-too-large' });
123
+ }
124
+ if (err instanceof SyntaxError) return res.status(400).json({ error: 'JSON non valido', code: 'invalid-json' });
125
+ return res.status(err.status || 400).json({ error: String(err.message || err) });
126
+ });
127
+
117
128
  return r;
118
129
  }
119
130
 
@@ -30,6 +30,7 @@ function clearHealthCache() { cache.clear(); }
30
30
  // onesto). outbound down/401 -> 'down'/'degraded'.
31
31
  function tunnelFromHealth(h) {
32
32
  if (!h) return { status: 'unknown', managed: false };
33
+ if (h.status === 'passive') return { status: 'passive', managed: false };
33
34
  if (h.transport === 'up' && h.auth === 'ok') return { status: 'up', managed: h.managed !== false };
34
35
  if (h.transport === 'up' && h.auth === 'failed') return { status: 'degraded', managed: h.managed !== false };
35
36
  if (h.transport === 'up') return { status: 'degraded', managed: h.managed !== false };
@@ -39,7 +40,7 @@ function tunnelFromHealth(h) {
39
40
 
40
41
  async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = false }) {
41
42
  if (!node || typeof node !== 'object') return null;
42
- const cacheKey = `${home || ''}\0${node.direction || 'outbound'}\0${node.name}\0${node.localPort}\0${node.nodeId || ''}`;
43
+ const cacheKey = `${home || ''}\0${node.direction || 'outbound'}\0${node.name}\0${node.localPort}\0${node.nodeId || ''}\0${node.rolesKnown === true}\0${node.roles?.node === true}`;
43
44
  const cached = !force && cache.get(cacheKey);
44
45
  if (cached && (now - cached.at) < TTL_MS) return cached.health;
45
46
 
@@ -54,7 +55,17 @@ async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = fal
54
55
  const probed = await probeHealth({
55
56
  port: node.localPort, token: node.token, expectedInstanceId: node.nodeId || null, fetchImpl, now,
56
57
  });
57
- health = { ...probed, managed: false };
58
+ // The receiving side does not own an inbound client's lifecycle. A
59
+ // client-only (or legacy unknown-role) peer being offline is expected,
60
+ // not a broken server. Live auth/payload failures remain real failures.
61
+ if (probed.transport === 'down' && (node.rolesKnown !== true || node.roles?.node !== true)) {
62
+ health = {
63
+ ...probed, status: 'passive', expected: true, managed: false,
64
+ detail: node.rolesKnown === true ? 'client peer offline (expected)' : 'inbound peer offline',
65
+ };
66
+ } else {
67
+ health = { ...probed, managed: false };
68
+ }
58
69
  }
59
70
  } else {
60
71
  const ts = nodesTunnel.readTunnelState(home, node.name);
@@ -89,7 +89,7 @@ function parsePairingUrl(value) {
89
89
  }
90
90
 
91
91
  function createInvite({
92
- invitesPath, instanceId, port, label = 'NexusCrew', now = Date.now(), randomBytes = crypto.randomBytes,
92
+ invitesPath, instanceId, port, linkPort = port, label = 'NexusCrew', now = Date.now(), randomBytes = crypto.randomBytes,
93
93
  ssh, sshPort, name,
94
94
  } = {}) {
95
95
  const invite = randomBytes(32).toString('base64url');
@@ -116,7 +116,8 @@ function createInvite({
116
116
  if (sshVal && store.isPort(sshPort)) payload.sshPort = sshPort;
117
117
  }
118
118
  const pair = encodePairing(payload);
119
- return { pairingUrl: `http://127.0.0.1:${port}/#pair=${pair}`, expiresAt, version: payload.v };
119
+ if (!store.isPort(linkPort)) throw new Error('porta locale del link non valida');
120
+ return { pairingUrl: `http://127.0.0.1:${linkPort}/#pair=${pair}`, expiresAt, version: payload.v };
120
121
  }
121
122
 
122
123
  function consumeInvite({ invitesPath, invite, now = Date.now() }) {
@@ -103,7 +103,7 @@ const LABEL_MAX = 64;
103
103
  // tollerato oltre a quelli noti (schema chiuso: garbage -> null, non guess).
104
104
  const NODE_KEYS = new Set([
105
105
  'name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'identityFile',
106
- 'roles', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
106
+ 'roles', 'rolesKnown', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
107
107
  'direction', 'reversePort', 'label',
108
108
  ]);
109
109
  function parseNode(n, schemaVersion = SCHEMA_VERSION) {
@@ -123,6 +123,10 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
123
123
  if (schemaVersion === LEGACY_SCHEMA_VERSION && !identityFile) return null;
124
124
  const roles = parseRoles(n.roles);
125
125
  if (!roles) return null;
126
+ if (n.rolesKnown !== undefined && typeof n.rolesKnown !== 'boolean') return null;
127
+ // Before 0.8.9 `roles` was filled with a local default and did not describe
128
+ // the remote peer. Only the explicit marker makes role-based health safe.
129
+ const rolesKnown = n.rolesKnown === true;
126
130
  // label (display) opzionale ma, se presente, strict: stringa 1..LABEL_MAX, no
127
131
  // control char (newline/tab inclusi: una label su piu' righe non ha senso in
128
132
  // UI), no solo-spazi. Garbage -> null (schema chiuso), mai guess/truncate.
@@ -140,6 +144,7 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
140
144
  remotePort: n.remotePort,
141
145
  localPort: n.localPort,
142
146
  roles,
147
+ rolesKnown,
143
148
  direction,
144
149
  transport: n.transport || (direction === 'inbound' ? 'inbound' : (schemaVersion === LEGACY_SCHEMA_VERSION ? 'ssh' : 'auto')),
145
150
  autostart: n.autostart === undefined ? schemaVersion !== LEGACY_SCHEMA_VERSION : n.autostart,
@@ -379,6 +384,8 @@ function redactNode(n) {
379
384
  remotePort: n.remotePort,
380
385
  localPort: n.localPort,
381
386
  direction: n.direction || 'outbound',
387
+ roles: { ...n.roles },
388
+ rolesKnown: n.rolesKnown === true,
382
389
  transport: n.transport || 'ssh',
383
390
  autostart: !!n.autostart,
384
391
  visibility: n.visibility || 'network',
@@ -486,7 +493,7 @@ function suggestNodeName(input, existing = []) {
486
493
 
487
494
  module.exports = {
488
495
  // parse/validate
489
- parseStore, parseNode, parseRendezvous, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
496
+ parseStore, parseNode, parseRendezvous, parseRoles, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
490
497
  // I/O
491
498
  defaultNodesPath, loadStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
492
499
  // mutazioni
@@ -55,7 +55,7 @@ function knownResource(resource) {
55
55
  || resource === '/files/download'
56
56
  || resource === '/files/upload'
57
57
  || resource === '/ws'
58
- || /^\/fleet\/(status|schema|definitions|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell)$/.test(resource);
58
+ || /^\/fleet\/(status|schema|definitions|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells)$/.test(resource);
59
59
  }
60
60
 
61
61
  function allowedResource(resource, method = 'GET') {
@@ -68,7 +68,7 @@ function allowedResource(resource, method = 'GET') {
68
68
  if (resource === '/files/upload') return method === 'POST';
69
69
  if (resource === '/ws') return method === 'GET';
70
70
  if (/^\/fleet\/(status|schema|definitions)$/.test(resource)) return method === 'GET';
71
- if (/^\/fleet\/(up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell)$/.test(resource)) return method === 'POST';
71
+ if (/^\/fleet\/(up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells)$/.test(resource)) return method === 'POST';
72
72
  return false;
73
73
  }
74
74
 
@@ -155,6 +155,14 @@ async function probeHealth({ port, token, expectedInstanceId = null, fetchImpl =
155
155
  } else if (expectedInstanceId && body.instanceId !== expectedInstanceId) {
156
156
  out.reachability = 'failed'; out.status = 'degraded'; out.detail = 'peer instanceId inatteso — tunnel/porta punta al nodo sbagliato';
157
157
  } else {
158
+ if (body.roles !== undefined) {
159
+ const roles = store.parseRoles(body.roles);
160
+ if (!roles) {
161
+ out.reachability = 'failed'; out.status = 'degraded'; out.detail = 'health roles non validi';
162
+ return out;
163
+ }
164
+ out.roles = roles; out.rolesKnown = true;
165
+ }
158
166
  out.reachability = 'ok'; out.status = 'healthy'; out.detail = 'ok';
159
167
  }
160
168
  } else if (r.status === 401) {
@@ -256,7 +264,7 @@ async function collectLocalTopology({ nodesPath, cachePath, fetchImpl = fetch, n
256
264
  return { instanceId: live.instanceId, nodes };
257
265
  }
258
266
 
259
- function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly, version = null }) {
267
+ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly, version = null, roles = null }) {
260
268
  const r = express.Router();
261
269
  r.use((req, res, next) => {
262
270
  const peer = peerFromToken(nodesPath, bearerFrom(req));
@@ -269,7 +277,9 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
269
277
  // reachability (payload). Nessun segreto in risposta.
270
278
  r.get('/health', (_req, res) => {
271
279
  const st = store.loadStore(nodesPath);
272
- res.json({ ok: true, instanceId: (st && st.nodeId) || null, version, readonly: !!readonly() });
280
+ const advertisedRoles = typeof roles === 'function' ? roles() : null;
281
+ res.json({ ok: true, instanceId: (st && st.nodeId) || null, version, readonly: !!readonly(),
282
+ ...(advertisedRoles ? { roles: advertisedRoles } : {}) });
273
283
  });
274
284
  r.get('/topology', async (req, res) => {
275
285
  const ttl = Math.max(0, Math.min(MAX_HOPS, Number(req.query.ttl) || MAX_HOPS));
package/lib/server.js CHANGED
@@ -36,6 +36,7 @@ const { createPushService } = require('./notify/push.js');
36
36
  const { createAsksStore } = require('./notify/asks.js');
37
37
  const { createNotifier } = require('./notify/notifier.js');
38
38
  const { notifyRoutes } = require('./notify/routes.js');
39
+ const { createNpmUpdater } = require('./update/manager.js');
39
40
 
40
41
  function sessionExists(tmuxBin, name) {
41
42
  if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name)) return false;
@@ -97,6 +98,13 @@ function createServer(opts = {}) {
97
98
  });
98
99
  const asksStore = createAsksStore({ dir: notifyDir });
99
100
  const notifier = createNotifier({ hub: eventsHub, push: pushSvc });
101
+ const updater = opts.updateManager || createNpmUpdater({
102
+ currentVersion: VERSION,
103
+ home: cfg.home || os.homedir(),
104
+ enabled: cfg.autoUpdate !== false,
105
+ readonly: bridgeReadonly(),
106
+ ...(cfg.updateSeams || {}),
107
+ });
100
108
  const attachedWs = new Map(); // ws -> session (per il push dei frame files)
101
109
  // selectProvider sceglie UNA volta (startup) il provider external|builtin|disabled
102
110
  // (design §4b/§9b/§9g) e ritorna {mode,reason,fleet}; routes consumano il .fleet,
@@ -251,7 +259,7 @@ function createServer(opts = {}) {
251
259
  // Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
252
260
  // wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
253
261
  // READONLY route-level e la redazione token vivono dentro settingsRoutes.
254
- api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions }));
262
+ api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions, updater }));
255
263
  api.get('/topology', async (_req, res) => {
256
264
  try { res.json(await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath })); }
257
265
  catch (e) { res.status(500).json({ error: String(e.message || e) }); }
@@ -283,6 +291,7 @@ function createServer(opts = {}) {
283
291
  app.use('/api', api);
284
292
  app.use('/federation', federation.peerRouter({
285
293
  nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
294
+ roles: () => require('./cli/commands.js').readRoles(cfg.configPath || configJsonPath()),
286
295
  }));
287
296
 
288
297
  // Reverse-proxy single-origin /node/<name>/… (design §4b(2)). Auth locale PRIMA
@@ -314,7 +323,8 @@ function createServer(opts = {}) {
314
323
  // Close the watcher when the HTTP server closes. Registered HERE (inside
315
324
  // createServer) — not in start() — so every createServer consumer is covered,
316
325
  // not only the start() path. watcher.close() is idempotent.
317
- server.on('close', () => { watcher.close(); previews.close(); eventsHub.closeAll(); });
326
+ server.on('listening', () => updater.start());
327
+ server.on('close', () => { watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); });
318
328
  // noServer: gestiamo l'upgrade a mano per instradare /ws (locale) e /node/*
319
329
  // (proxy). Il WS locale resta identico; il proxy WS applica gli STESSI check
320
330
  // dell'HTTP (auth locale -> name strict -> inject token) prima del piping.
@@ -394,7 +404,7 @@ function createServer(opts = {}) {
394
404
  }
395
405
  });
396
406
 
397
- return { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP };
407
+ return { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater };
398
408
  }
399
409
 
400
410
  function start(opts = {}) {
@@ -53,9 +53,10 @@ const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js')
53
53
  const { isServiceRunning, readRoles, bootState } = require('../cli/commands.js');
54
54
  const { configJsonPath } = require('../config.js');
55
55
  const VERSION = require('../../package.json').version;
56
+ const { scrubError } = require('../update/core.js');
56
57
 
57
58
  // Whitelist chiavi scrivibili di config.json via API (lista chiusa dal task B2).
58
- const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone']);
59
+ const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate']);
59
60
  const ROLE_KEYS = new Set(['client', 'node']);
60
61
  const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
61
62
  const NODE_ROLE_KEYS = new Set(['enabled', 'rendezvousSsh', 'publishedPort', 'keyPath']);
@@ -141,6 +142,7 @@ function settingsRoutes(deps = {}) {
141
142
  // scrive solo il file (come prima), senza reload in-memory.
142
143
  const tokenStore = deps.tokenStore || null;
143
144
  const closeSessions = deps.closeSessions || null;
145
+ const updater = deps.updater || null;
144
146
  const home = cfg.home || os.homedir();
145
147
  const configDir = cfg.configDir || path.join(home, '.nexuscrew');
146
148
  // configJsonPath() rispetta NEXUSCREW_CONFIG_FILE (stessa risoluzione di
@@ -191,6 +193,7 @@ function settingsRoutes(deps = {}) {
191
193
  active: isServiceRunning({ platform, execImpl: seams.execImpl, uid: seams.uid, home }),
192
194
  boot: bootState({ platform, execImpl: seams.execImpl, uid: seams.uid, home }).enabled,
193
195
  };
196
+ const updateStatus = updater && typeof updater.status === 'function' ? updater.status() : null;
194
197
  const out = {
195
198
  roles: readRoles(configPath),
196
199
  firstRun,
@@ -198,10 +201,12 @@ function settingsRoutes(deps = {}) {
198
201
  platform,
199
202
  service,
200
203
  version: VERSION,
204
+ autoUpdate: updateStatus ? updateStatus.enabled : fileCfg.autoUpdate !== false,
201
205
  // Nome dispositivo proposto per i form di pairing (etichetta umana, non
202
206
  // lo slug). La UI lo precompila e lascia editing libero.
203
207
  deviceName: defaultDeviceName(),
204
208
  };
209
+ if (updateStatus) out.update = updateStatus;
205
210
  // rendezvous via redactStore (view sicura §4b(4)): non contiene token, ma
206
211
  // la si prende comunque SOLO dalla vista redatta.
207
212
  const st = nodesStore.loadStore(nodesPath);
@@ -221,10 +226,10 @@ function settingsRoutes(deps = {}) {
221
226
  return send(res, 400, { error: 'body deve essere un oggetto JSON' });
222
227
  }
223
228
  for (const k of Object.keys(b)) {
224
- if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone)` });
229
+ if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone, autoUpdate)` });
225
230
  }
226
231
  if (Object.keys(b).length === 0) {
227
- return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone)' });
232
+ return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone, autoUpdate)' });
228
233
  }
229
234
  if (b.roles !== undefined) {
230
235
  if (!b.roles || typeof b.roles !== 'object' || Array.isArray(b.roles)) {
@@ -241,6 +246,9 @@ function settingsRoutes(deps = {}) {
241
246
  if (b.wizardDone !== undefined && typeof b.wizardDone !== 'boolean') {
242
247
  return send(res, 400, { error: 'wizardDone deve essere boolean' });
243
248
  }
249
+ if (b.autoUpdate !== undefined && typeof b.autoUpdate !== 'boolean') {
250
+ return send(res, 400, { error: 'autoUpdate deve essere boolean' });
251
+ }
244
252
 
245
253
  // merge sul config esistente (preserva le chiavi non gestite qui)
246
254
  const { cfg: current } = readConfigFile(configPath);
@@ -251,11 +259,15 @@ function settingsRoutes(deps = {}) {
251
259
  }
252
260
  if (b.port !== undefined) next.port = b.port;
253
261
  if (b.wizardDone !== undefined) next.wizardDone = b.wizardDone;
262
+ if (b.autoUpdate !== undefined) next.autoUpdate = b.autoUpdate;
254
263
  atomicWriteConfig(configPath, next);
264
+ if (b.autoUpdate !== undefined && updater && typeof updater.setEnabled === 'function') {
265
+ updater.setEnabled(b.autoUpdate);
266
+ }
255
267
 
256
268
  const out = {
257
269
  saved: true,
258
- config: { roles: next.roles, port: next.port, wizardDone: next.wizardDone },
270
+ config: { roles: next.roles, port: next.port, wizardDone: next.wizardDone, autoUpdate: next.autoUpdate !== false },
259
271
  };
260
272
  // Il server legge la porta SOLO allo startup: il cambio vale al prossimo
261
273
  // restart (contratto dichiarato nella risposta, la UI avvisa).
@@ -266,6 +278,20 @@ function settingsRoutes(deps = {}) {
266
278
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
267
279
  });
268
280
 
281
+ // Auto-update npm: check e apply sono autenticati, READONLY-gated e non
282
+ // accettano mai package/versione dal browser. Il manager usa esclusivamente
283
+ // @mmmbuto/nexuscrew@latest, confronta semver e installa la versione esatta.
284
+ r.post('/update/check', mutGate, async (_req, res) => {
285
+ if (!updater) return send(res, 501, { error: 'auto-update non disponibile' });
286
+ try { send(res, 200, await updater.check()); }
287
+ catch (e) { send(res, e.status || 500, { error: scrubError(e), ...(e.code ? { code: e.code } : {}) }); }
288
+ });
289
+ r.post('/update/apply', mutGate, async (_req, res) => {
290
+ if (!updater) return send(res, 501, { error: 'auto-update non disponibile' });
291
+ try { send(res, 202, await updater.apply()); }
292
+ catch (e) { send(res, e.status || 500, { error: scrubError(e), ...(e.code ? { code: e.code } : {}) }); }
293
+ });
294
+
269
295
  // --- POST /token/rotate — atomico + reload live + chiusura WS; token MAI in risposta
270
296
  // Contratto §4b(3): "scrittura atomica del token file + chiusura delle sessioni
271
297
  // WS/API attive locali + reload credenziali proxy". Audit F7: prima questa route
@@ -297,25 +323,38 @@ function settingsRoutes(deps = {}) {
297
323
  try {
298
324
  const b = req.body || {};
299
325
  const label = nodesStore.sanitizeLabel(b.label, defaultDeviceName());
326
+ const st = nodesStore.loadOrInitStore(nodesPath);
327
+ const rendezvous = st.rendezvous || null;
300
328
  const extra = {};
301
329
  if (typeof b.ssh === 'string' && b.ssh.trim()) {
302
330
  const ssh = nodesStore.parseSshTarget(b.ssh.trim());
303
331
  if (!ssh) return send(res, 400, { error: 'ssh non valido (atteso user@host o alias)' });
304
332
  extra.ssh = ssh.value;
333
+ } else if (rendezvous && rendezvous.ssh) {
334
+ // Zero-field creator path: the rendezvous SSH target is reachable by
335
+ // definition. Its publishedPort is the REMOTE NEXUSCREW HTTP port,
336
+ // never the OpenSSH transport port.
337
+ extra.ssh = rendezvous.ssh;
305
338
  }
306
339
  if (b.sshPort !== undefined && b.sshPort !== null && b.sshPort !== '') {
307
340
  if (!nodesStore.isPort(Number(b.sshPort))) return send(res, 400, { error: 'sshPort non valida (1..65535)' });
308
341
  extra.sshPort = Number(b.sshPort);
309
342
  }
310
343
  if (extra.sshPort && !extra.ssh) return send(res, 400, { error: 'sshPort richiede un target SSH' });
344
+ let remotePort = cfg.port;
345
+ if (b.remotePort !== undefined && b.remotePort !== null && b.remotePort !== '') {
346
+ if (!nodesStore.isPort(Number(b.remotePort))) return send(res, 400, { error: 'remotePort non valida (1..65535)' });
347
+ remotePort = Number(b.remotePort);
348
+ } else if (rendezvous && extra.ssh === rendezvous.ssh) {
349
+ remotePort = rendezvous.publishedPort;
350
+ }
311
351
  if (typeof b.name === 'string' && b.name.trim()) {
312
352
  if (!nodesStore.NODE_NAME_RE.test(b.name.trim())) return send(res, 400, { error: 'name non valido (a-z 0-9 -, max 32)' });
313
353
  extra.name = b.name.trim();
314
354
  }
315
355
  if (extra.ssh && !extra.name) extra.name = nodesStore.toSlug(label);
316
- const st = nodesStore.loadOrInitStore(nodesPath);
317
356
  send(res, 200, peering.createInvite({
318
- invitesPath, instanceId: st.nodeId, port: cfg.port, label, ...extra,
357
+ invitesPath, instanceId: st.nodeId, port: remotePort, linkPort: cfg.port, label, ...extra,
319
358
  }));
320
359
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
321
360
  });
@@ -467,6 +506,7 @@ function settingsRoutes(deps = {}) {
467
506
  label: localLabel,
468
507
  port: cfg.port,
469
508
  acceptToken,
509
+ roles: readRoles(configPath),
470
510
  }),
471
511
  });
472
512
  } catch (e) {
@@ -488,8 +528,9 @@ function settingsRoutes(deps = {}) {
488
528
  hint: 'rigenera un nuovo link e riprova',
489
529
  });
490
530
  }
531
+ const joinedRoles = joined.roles === undefined ? null : nodesStore.parseRoles(joined.roles);
491
532
  if (!nodesStore.validToken(joined.credential) || !nodesStore.isPort(joined.reversePort)
492
- || !nodesStore.NODE_ID_RE.test(joined.instanceId)) {
533
+ || !nodesStore.NODE_ID_RE.test(joined.instanceId) || (joined.roles !== undefined && !joinedRoles)) {
493
534
  return failRolledBack(502, 'join', 'join-invalid-response', 'risposta di join non valida dal peer', {
494
535
  hint: 'versioni NexusCrew incompatibili? aggiorna entrambi i nodi',
495
536
  });
@@ -506,6 +547,7 @@ function settingsRoutes(deps = {}) {
506
547
  st = nodesStore.loadOrInitStore(nodesPath);
507
548
  st = nodesStore.updateNode(st, b.name, {
508
549
  token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
550
+ ...(joinedRoles ? { roles: joinedRoles, rolesKnown: true } : {}),
509
551
  });
510
552
  nodesStore.atomicWriteStore(nodesPath, st);
511
553
  nodesTunnel.stopTunnel({ home, name: b.name });
@@ -818,6 +860,7 @@ function settingsRoutes(deps = {}) {
818
860
  function publicPeeringRoutes(deps = {}) {
819
861
  const cfg = deps.cfg || {};
820
862
  const home = cfg.home || os.homedir();
863
+ const configPath = cfg.configPath || configJsonPath();
821
864
  const nodesPath = deps.nodesPath || cfg.nodesPath || nodesStore.defaultNodesPath(home);
822
865
  const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
823
866
  const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
@@ -831,8 +874,10 @@ function publicPeeringRoutes(deps = {}) {
831
874
  recent.push(now); attempts.set(key, recent);
832
875
  if (recent.length > 10) return res.status(429).json({ error: 'troppi tentativi di pairing' });
833
876
  const b = req.body || {};
877
+ const peerRoles = b.roles === undefined ? null : nodesStore.parseRoles(b.roles);
834
878
  if (!nodesStore.validToken(b.invite) || !nodesStore.NODE_ID_RE.test(b.instanceId)
835
- || !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)) {
879
+ || !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)
880
+ || (b.roles !== undefined && !peerRoles)) {
836
881
  return res.status(400).json({ error: 'pairing request non valida' });
837
882
  }
838
883
  if (b.label !== undefined && !nodesStore.validLabel(b.label)) {
@@ -848,8 +893,9 @@ function publicPeeringRoutes(deps = {}) {
848
893
  const credential = peering.createPending({ pendingPath, data: {
849
894
  name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
850
895
  label: nodesStore.sanitizeLabel(b.label, name),
896
+ ...(peerRoles ? { roles: peerRoles, rolesKnown: true } : { rolesKnown: false }),
851
897
  } });
852
- res.json({ paired: true, instanceId: st.nodeId, reversePort, credential });
898
+ res.json({ paired: true, instanceId: st.nodeId, reversePort, credential, roles: readRoles(configPath) });
853
899
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
854
900
  });
855
901
  r.post('/confirm', (req, res) => {
@@ -869,6 +915,8 @@ function publicPeeringRoutes(deps = {}) {
869
915
  direction: 'inbound', transport: 'inbound', autostart: true,
870
916
  visibility: 'network', nodeId: pending.instanceId,
871
917
  token: pending.acceptToken, acceptToken: b.credential,
918
+ ...(pending.roles ? { roles: pending.roles } : {}),
919
+ rolesKnown: pending.rolesKnown === true,
872
920
  ...(pending.label ? { label: pending.label } : {}),
873
921
  });
874
922
  nodesStore.atomicWriteStore(nodesPath, st);