@mmmbuto/nexuscrew 0.8.2 → 0.8.4

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,7 +11,7 @@
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-Dey9rdY-.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-C1AFIaRR.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-COsoJxXQ.css">
16
16
  </head>
17
17
  <body>
@@ -1 +1 @@
1
- {"version":"0.8.2"}
1
+ {"version":"0.8.4"}
@@ -9,6 +9,7 @@ const path = require('node:path');
9
9
  const net = require('node:net');
10
10
  const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
11
11
  const { installPath: serviceInstallPath } = require('./service.js');
12
+ const { fleetInstallPath } = require('./fleet-service.js');
12
13
  const pidf = require('./pidfile.js');
13
14
  const { runInit } = require('./init.js');
14
15
  const { rotateToken } = require('../auth/token.js');
@@ -27,8 +28,10 @@ const VALUE_FLAGS = new Set([
27
28
  const HELP = `NexusCrew — PWA for local and remote AI workers.
28
29
 
29
30
  Usage:
30
- nexuscrew start in background; first run opens the PWA wizard
31
+ nexuscrew start in background; show status and quick guide
31
32
  nexuscrew show start when needed and open the authenticated PWA
33
+ nexuscrew show token print the clickable authenticated URL
34
+ nexuscrew boot enable startup at boot (use: boot off|status)
32
35
  nexuscrew doctor run local diagnostics
33
36
  nexuscrew help show this help
34
37
  nexuscrew version show the installed version
@@ -65,7 +68,7 @@ function parseFlags(argv, valueFlags) {
65
68
  function serve(opts = {}) {
66
69
  const serverStart = opts.serverStart || require('../server.js').start;
67
70
  if (opts.pidfile) {
68
- const pidPath = pidf.defaultPidfilePath();
71
+ const pidPath = pidf.defaultPidfilePath(opts.home);
69
72
  // already-running check
70
73
  const meta = pidf.readPidfile(pidPath);
71
74
  if (meta && pidf.isAlive(meta)) {
@@ -115,8 +118,9 @@ function start(opts = {}) {
115
118
  return { platform, started: true };
116
119
  }
117
120
  if (platform === 'termux') {
121
+ const home = opts.home || require('node:os').homedir();
118
122
  // already-running?
119
- const pidPath = pidf.defaultPidfilePath();
123
+ const pidPath = pidf.defaultPidfilePath(opts.home);
120
124
  const meta = pidf.readPidfile(pidPath);
121
125
  if (meta && pidf.isAlive(meta)) {
122
126
  log(`already running (pid ${meta.pid}) — pidfile ${pidPath}`);
@@ -124,12 +128,24 @@ function start(opts = {}) {
124
128
  }
125
129
  pidf.cleanStale(pidPath);
126
130
  const repoBin = path.join(repoRoot(), 'bin', 'nexuscrew.js');
127
- const logPath = path.join(require('node:os').homedir(), '.nexuscrew', 'nexuscrew.log');
131
+ const logPath = path.join(home, '.nexuscrew', 'nexuscrew.log');
128
132
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
129
133
  const logFd = fs.openSync(logPath, 'a');
130
- const child = spawnImpl(nodeBin(), [repoBin, 'serve', '--pidfile'], {
131
- detached: true, stdio: ['ignore', logFd, logFd],
132
- });
134
+ const resolved = urlmod.resolvePaths(opts);
135
+ const childEnv = {
136
+ ...process.env,
137
+ HOME: home,
138
+ NEXUSCREW_CONFIG_FILE: resolved.configPath,
139
+ NEXUSCREW_TOKEN_FILE: resolved.tokenPath,
140
+ };
141
+ // config.json is authoritative after automatic port fallback.
142
+ delete childEnv.NEXUSCREW_PORT;
143
+ let child;
144
+ try {
145
+ child = spawnImpl(nodeBin(), [repoBin, 'serve', '--pidfile'], {
146
+ detached: true, stdio: ['ignore', logFd, logFd], env: childEnv,
147
+ });
148
+ } finally { try { fs.closeSync(logFd); } catch (_) {} }
133
149
  if (child && typeof child.unref === 'function') child.unref();
134
150
  log(`start: nohup ${nodeBin()} ${repoBin} serve --pidfile (>> ${logPath})`);
135
151
  return { platform, started: true, pid: child && child.pid };
@@ -162,7 +178,7 @@ function stop(opts = {}) {
162
178
  }
163
179
  if (platform === 'termux') {
164
180
  // kill via pidfile verificato (no broad match) + wake-lock-release
165
- const pidPath = pidf.defaultPidfilePath();
181
+ const pidPath = pidf.defaultPidfilePath(opts.home);
166
182
  const r = pidf.killPidfile(pidPath);
167
183
  try { execImpl('termux-wake-lock-release', [], { stdio: 'ignore' }); } catch (_) {}
168
184
  log(`stop: ${r.killed ? `killed pid ${r.pid}` : r.reason}`);
@@ -184,12 +200,93 @@ function isServiceRunning(opts = {}) {
184
200
  catch (_) { return false; }
185
201
  }
186
202
  if (platform === 'termux') {
187
- const meta = pidf.readPidfile(pidf.defaultPidfilePath());
203
+ const meta = pidf.readPidfile(pidf.defaultPidfilePath(opts.home));
188
204
  return !!(meta && pidf.isAlive(meta));
189
205
  }
190
206
  return false;
191
207
  }
192
208
 
209
+ function bootState(opts = {}) {
210
+ const platform = opts.platform || detectPlatform();
211
+ const execImpl = opts.execImpl || execFileSync;
212
+ const home = opts.home || require('node:os').homedir();
213
+ if (platform === 'linux') {
214
+ try { return { platform, enabled: execImpl('systemctl', ['--user', 'is-enabled', 'nexuscrew'], { encoding: 'utf8' }).trim() === 'enabled' }; }
215
+ catch (_) { return { platform, enabled: false }; }
216
+ }
217
+ try { return { platform, enabled: fs.lstatSync(serviceInstallPath(platform, home)).isFile() }; }
218
+ catch (_) { return { platform, enabled: false }; }
219
+ }
220
+
221
+ function unlinkRegular(target) {
222
+ try {
223
+ const st = fs.lstatSync(target);
224
+ if (st.isSymbolicLink() || !st.isFile()) throw new Error(`refusing unsafe boot target: ${target}`);
225
+ fs.unlinkSync(target); return true;
226
+ } catch (e) { if (e.code === 'ENOENT') return false; throw e; }
227
+ }
228
+
229
+ async function bootOn(opts = {}) {
230
+ const platform = opts.platform || detectPlatform();
231
+ const quiet = opts.lifecycleLog || (() => {});
232
+ const { configPath, tokenPath } = urlmod.resolvePaths(opts);
233
+ if (!fs.existsSync(configPath) || !urlmod.readToken(tokenPath)) {
234
+ const selected = await findAvailablePort(opts.port || urlmod.loadPort(opts), opts);
235
+ (opts.runInitImpl || runInit)({ ...opts, platform, port: selected, installBoot: false, printUrl: false, log: quiet });
236
+ }
237
+ try { pidf.killPidfile(pidf.defaultPidfilePath(opts.home)); } catch (_) {}
238
+ (opts.runInitImpl || runInit)({ ...opts, platform, installBoot: true, printUrl: false, log: quiet });
239
+ const result = await smartUp({ ...opts, platform });
240
+ return { ...result, boot: bootState({ ...opts, platform }).enabled };
241
+ }
242
+
243
+ async function bootOff(opts = {}) {
244
+ const platform = opts.platform || detectPlatform();
245
+ const execImpl = opts.execImpl || execFileSync;
246
+ const home = opts.home || require('node:os').homedir();
247
+ if (platform === 'linux') {
248
+ try { execImpl('systemctl', ['--user', 'disable', 'nexuscrew'], { stdio: 'ignore' }); } catch (_) {}
249
+ try { execImpl('systemctl', ['--user', 'disable', 'nexuscrew-fleet.service'], { stdio: 'ignore' }); } catch (_) {}
250
+ } else if (platform === 'mac') {
251
+ const domain = `gui/${opts.uid || uid()}`;
252
+ try { execImpl('launchctl', ['bootout', `${domain}/com.mmmbuto.nexuscrew`], { stdio: 'ignore' }); } catch (_) {}
253
+ try { execImpl('launchctl', ['bootout', `${domain}/com.mmmbuto.nexuscrew-fleet`], { stdio: 'ignore' }); } catch (_) {}
254
+ unlinkRegular(serviceInstallPath(platform, home));
255
+ try { unlinkRegular(fleetInstallPath(platform, home)); } catch (_) {}
256
+ const { tokenPath } = urlmod.resolvePaths(opts); const port = urlmod.loadPort(opts); const token = urlmod.readToken(tokenPath);
257
+ if (!(await probeNexusCrew(port, token, opts))) {
258
+ startPortable({ ...opts, platform });
259
+ await waitForNexusCrew(port, token, opts);
260
+ }
261
+ } else if (platform === 'termux') {
262
+ unlinkRegular(serviceInstallPath(platform, home));
263
+ try { unlinkRegular(fleetInstallPath(platform, home)); } catch (_) {}
264
+ }
265
+ return { platform, enabled: false, running: isServiceRunning({ ...opts, platform }) || !!pidf.readPidfile(pidf.defaultPidfilePath(home)) };
266
+ }
267
+
268
+ function quickSummary(result, opts = {}) {
269
+ const log = opts.log || console.log;
270
+ const platform = result.platform || opts.platform || detectPlatform();
271
+ const home = opts.home || require('node:os').homedir();
272
+ const { configDir } = urlmod.resolvePaths(opts);
273
+ const st = nodesStore.loadStore(opts.nodesPath || path.join(configDir, 'nodes.json'));
274
+ let cached = [];
275
+ try { cached = (require('../nodes/topology-cache.js').loadCache(opts.topologyCachePath || require('../nodes/topology-cache.js').defaultPath(home)) || {}).nodes || []; } catch (_) {}
276
+ const direct = (st && st.nodes) || [];
277
+ const online = direct.filter((n) => nodesTunnel.readTunnelState(home, n.name).status === 'up').length;
278
+ const known = direct.length + cached.filter((n) => !direct.some((d) => d.nodeId && d.nodeId === n.instanceId)).length;
279
+ log(`NexusCrew ${require('../../package.json').version}`);
280
+ log(`server ${result.running ? '● running' : '○ stopped'} · 127.0.0.1:${result.port}`);
281
+ log(`boot ${bootState({ ...opts, platform }).enabled ? 'on' : 'off'} · ${platform}`);
282
+ log(`nodes ${known} known · ${online}/${direct.length} direct tunnels up`);
283
+ log('');
284
+ log('open nexuscrew show');
285
+ log('link nexuscrew show token');
286
+ log('boot nexuscrew boot');
287
+ log('check nexuscrew doctor');
288
+ }
289
+
193
290
  // Ruoli client/node dal config.json (default entrambi off — B0 li popola dal wizard UI).
194
291
  function readRoles(configPath) {
195
292
  try {
@@ -224,7 +321,7 @@ function status(opts = {}) {
224
321
  // boot-script installed vs server running (pidfile vivo)
225
322
  const bootScript = path.join(home, '.termux', 'boot', 'nexuscrew.sh');
226
323
  out.bootScriptInstalled = fs.existsSync(bootScript);
227
- const meta = pidf.readPidfile(pidf.defaultPidfilePath());
324
+ const meta = pidf.readPidfile(pidf.defaultPidfilePath(home));
228
325
  out.running = !!(meta && pidf.isAlive(meta));
229
326
  out.service = out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script';
230
327
  }
@@ -352,9 +449,22 @@ function startPortable(opts = {}) {
352
449
  const logPath = path.join(home, '.nexuscrew', 'nexuscrew.log');
353
450
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
354
451
  const logFd = fs.openSync(logPath, 'a');
355
- const child = spawnImpl(nodeBin(), [path.join(repoRoot(), 'bin', 'nexuscrew.js'), 'serve', '--pidfile'], {
356
- detached: true, stdio: ['ignore', logFd, logFd],
357
- });
452
+ const resolved = urlmod.resolvePaths(opts);
453
+ const childEnv = {
454
+ ...process.env,
455
+ HOME: home,
456
+ NEXUSCREW_CONFIG_FILE: resolved.configPath,
457
+ NEXUSCREW_TOKEN_FILE: resolved.tokenPath,
458
+ ...(opts.filesRoot ? { NEXUSCREW_FILES_ROOT: opts.filesRoot } : {}),
459
+ };
460
+ // config.json is authoritative after automatic port fallback.
461
+ delete childEnv.NEXUSCREW_PORT;
462
+ let child;
463
+ try {
464
+ child = spawnImpl(nodeBin(), [path.join(repoRoot(), 'bin', 'nexuscrew.js'), 'serve', '--pidfile'], {
465
+ detached: true, stdio: ['ignore', logFd, logFd], env: childEnv,
466
+ });
467
+ } finally { try { fs.closeSync(logFd); } catch (_) {} }
358
468
  if (child && typeof child.unref === 'function') child.unref();
359
469
  return { started: true, pid: child && child.pid, portable: true };
360
470
  }
@@ -383,6 +493,7 @@ async function smartUp(opts = {}) {
383
493
  const runInitImpl = opts.runInitImpl || runInit;
384
494
  const startImpl = opts.startImpl || start;
385
495
  const restartImpl = opts.restartImpl || restart;
496
+ const portableStart = opts.startPortableImpl || startPortable;
386
497
  const probe = opts.probeImpl || probeNexusCrew;
387
498
  const { configPath, tokenPath } = urlmod.resolvePaths(opts);
388
499
  let initialized = fs.existsSync(configPath) && !!urlmod.readToken(tokenPath);
@@ -391,7 +502,7 @@ async function smartUp(opts = {}) {
391
502
  if (!initialized) {
392
503
  const requested = opts.port || urlmod.loadPort(opts);
393
504
  const selected = await findAvailablePort(requested, opts);
394
- runInitImpl({ ...opts, port: selected, log: quiet, platform, printUrl: false });
505
+ runInitImpl({ ...opts, port: selected, log: quiet, platform, installBoot: false, printUrl: false });
395
506
  port = selected;
396
507
  initialized = true;
397
508
  }
@@ -399,8 +510,9 @@ async function smartUp(opts = {}) {
399
510
  // 0.8.0 services embedded NEXUSCREW_PORT in their environment, overriding
400
511
  // config.json forever. Regenerate once so config.json becomes authoritative.
401
512
  const home = opts.home || require('node:os').homedir();
402
- if (servicePinsLegacyPort(platform, home, opts.installPath)) {
403
- runInitImpl({ ...opts, log: quiet, platform, printUrl: false });
513
+ const persistent = bootState({ ...opts, platform }).enabled;
514
+ if (persistent && servicePinsLegacyPort(platform, home, opts.installPath)) {
515
+ runInitImpl({ ...opts, log: quiet, platform, installBoot: true, printUrl: false });
404
516
  }
405
517
 
406
518
  if (!port) port = urlmod.loadPort(opts);
@@ -413,29 +525,29 @@ async function smartUp(opts = {}) {
413
525
  if (!running) {
414
526
  if (!(await (opts.portAvailableImpl || portAvailable)(port, '127.0.0.1'))) {
415
527
  port = await findAvailablePort(port + 1, opts);
416
- runInitImpl({ ...opts, port, log: quiet, platform, printUrl: false });
528
+ runInitImpl({ ...opts, port, log: quiet, platform, installBoot: persistent, printUrl: false });
417
529
  token = urlmod.readToken(tokenPath);
418
530
  } else {
419
531
  try {
420
- const started = managedRunning
421
- ? restartImpl({ ...opts, platform, log: quiet })
422
- : startImpl({ ...opts, platform, log: quiet });
532
+ const started = persistent
533
+ ? (managedRunning ? restartImpl({ ...opts, platform, log: quiet }) : startImpl({ ...opts, platform, log: quiet }))
534
+ : portableStart({ ...opts, platform });
423
535
  if (started && started.started === false) {
424
- startPortable({ ...opts, platform }); portableAttempted = true;
536
+ portableStart({ ...opts, platform }); portableAttempted = true;
425
537
  }
426
538
  } catch (_) {
427
- startPortable({ ...opts, platform }); portableAttempted = true;
539
+ portableStart({ ...opts, platform }); portableAttempted = true;
428
540
  }
429
541
  }
430
542
  running = await waitForNexusCrew(port, token, opts);
431
543
  if (!running && !portableAttempted) {
432
- try { startPortable({ ...opts, platform }); portableAttempted = true; } catch (_) {}
544
+ try { portableStart({ ...opts, platform }); portableAttempted = true; } catch (_) {}
433
545
  running = await waitForNexusCrew(port, token, opts);
434
546
  }
435
547
  if (!running) throw new Error(`server did not become ready on 127.0.0.1:${port}`);
436
548
  }
437
549
 
438
- const shouldOpen = !!opts.forceOpen || !wizardComplete(configPath);
550
+ const shouldOpen = !opts.noOpen && (!!opts.forceOpen || !wizardComplete(configPath));
439
551
  if (shouldOpen) openPwa(urlmod.buildUrl(port, token, { withToken: true }), { ...opts, platform });
440
552
  return { platform, initialized, running, opened: shouldOpen, port, url: urlmod.buildUrl(port, null) };
441
553
  }
@@ -475,7 +587,7 @@ function tokenRotate(opts = {}) {
475
587
  } else {
476
588
  // il service manager non lo vede, ma un `serve` manuale (pidfile) puo' essere
477
589
  // vivo col VECCHIO token cachato allo startup: avvisa esplicitamente.
478
- const meta = pidf.readPidfile(pidf.defaultPidfilePath());
590
+ const meta = pidf.readPidfile(pidf.defaultPidfilePath(opts.home));
479
591
  if (meta && pidf.isAlive(meta)) {
480
592
  log(`token rotate: ATTENZIONE — server manuale attivo (pid ${meta.pid}): il vecchio token resta valido finche' non lo riavvii`);
481
593
  } else {
@@ -599,16 +711,32 @@ function dispatch(argv, opts = {}) {
599
711
  log(HELP);
600
712
  return { code: 0 };
601
713
  }
602
- // Public surface: normal start, show, doctor, help, version.
714
+ // Public surface: normal start, show, boot, doctor, help, version.
603
715
  if (!cmd) {
604
716
  if (Object.keys(flags).length) {
605
717
  log(`unknown option: --${Object.keys(flags)[0]}\n\n${HELP}`);
606
718
  return { code: 1 };
607
719
  }
608
- return smartUp({ ...opts, log: opts.log || (() => {}) }).then(() => ({ code: 0 }));
720
+ return smartUp({ ...opts, log: opts.lifecycleLog || (() => {}) }).then((r) => { quickSummary(r, { ...opts, log }); return { code: 0 }; });
609
721
  }
610
722
  if (cmd === 'show') {
611
- return smartUp({ ...opts, forceOpen: true, log: opts.log || (() => {}) }).then(() => ({ code: 0 }));
723
+ if (rest[1] === 'token') {
724
+ return smartUp({ ...opts, noOpen: true, log: opts.lifecycleLog || (() => {}) }).then((r) => {
725
+ const { tokenPath } = urlmod.resolvePaths(opts); const token = urlmod.readToken(tokenPath);
726
+ log(urlmod.buildUrl(r.port, token, { withToken: true })); return { code: 0 };
727
+ });
728
+ }
729
+ if (rest[1]) { log('usage: nexuscrew show [token]'); return { code: 1 }; }
730
+ return smartUp({ ...opts, forceOpen: true, log: opts.lifecycleLog || (() => {}) }).then(() => ({ code: 0 }));
731
+ }
732
+ if (cmd === 'boot') {
733
+ const sub = rest[1] || 'on';
734
+ if (sub === 'status') {
735
+ const b = bootState(opts); log(`boot: ${b.enabled ? 'on' : 'off'} · ${b.platform}`); return { code: 0 };
736
+ }
737
+ if (sub === 'off') return bootOff(opts).then((r) => { log(`boot: off · ${r.platform}`); return { code: 0 }; });
738
+ if (sub === 'on') return bootOn(opts).then((r) => { quickSummary(r, { ...opts, log }); return { code: r.boot ? 0 : 1 }; });
739
+ log('usage: nexuscrew boot [on|off|status]'); return { code: 1 };
612
740
  }
613
741
  if (cmd === 'version' || cmd === '--version' || cmd === '-v') {
614
742
  log(require('../../package.json').version);
@@ -700,5 +828,6 @@ module.exports = {
700
828
  portAvailable, findAvailablePort, probeNexusCrew, waitForNexusCrew, openPwa, startPortable, wizardComplete,
701
829
  servicePinsLegacyPort,
702
830
  isServiceRunning, readRoles,
831
+ bootState, bootOn, bootOff, quickSummary,
703
832
  runFleetBoot, dispatchFleetBoot,
704
833
  };
package/lib/cli/doctor.js CHANGED
@@ -51,7 +51,7 @@ function checkService(platform, home, execImpl, uidVal, installPathOverride) {
51
51
  active = true;
52
52
  } else if (platform === 'termux') {
53
53
  const pidf = require('./pidfile.js');
54
- const meta = pidf.readPidfile(pidf.defaultPidfilePath());
54
+ const meta = pidf.readPidfile(pidf.defaultPidfilePath(home));
55
55
  active = !!(meta && pidf.isAlive(meta));
56
56
  }
57
57
  } catch (_) { active = false; }
package/lib/cli/init.js CHANGED
@@ -75,6 +75,7 @@ function runInit(opts = {}) {
75
75
  const tokenPath = opts.tokenPath || path.join(configDir, 'token');
76
76
  const filesRoot = opts.filesRoot || path.join(home, 'NexusFiles');
77
77
  const dryRun = !!opts.dryRun;
78
+ const installBoot = opts.installBoot !== false;
78
79
  const log = opts.log || (() => {});
79
80
  const tmuxOk = opts.tmuxOk !== undefined ? opts.tmuxOk : haveTmux(opts.tmuxBin || 'tmux');
80
81
 
@@ -169,7 +170,9 @@ function runInit(opts = {}) {
169
170
  };
170
171
  const content = generateService(platform, svcCtx);
171
172
 
172
- if (dryRun) {
173
+ if (!installBoot) {
174
+ actions.push(`boot opt-in: service ${platform} non installato`);
175
+ } else if (dryRun) {
173
176
  actions.push(`DRY-RUN service (${platform}) generato, NON installato`);
174
177
  } else if (!tmuxOk) {
175
178
  // tmux mancante: abort before service install (config/token gia' creati) [M8]
@@ -196,7 +199,9 @@ function runInit(opts = {}) {
196
199
  // principale: ogni errore del companion -> WARN action + return normale.
197
200
  try {
198
201
  const readonly = process.env.NEXUSCREW_READONLY === '1' || opts.readonly;
199
- if (readonly) {
202
+ if (!installBoot) {
203
+ actions.push('fleet companion: boot opt-in, non installato');
204
+ } else if (readonly) {
200
205
  actions.push('fleet companion: READONLY, non installato');
201
206
  } else {
202
207
  // provider mode: companion SOLO se builtin (§9b). selectProviderModeSync e'
@@ -251,7 +256,7 @@ function runInit(opts = {}) {
251
256
  }
252
257
 
253
258
  // Termux:boot best-effort detection (R4)
254
- if (platform === 'termux') {
259
+ if (platform === 'termux' && installBoot) {
255
260
  const bootDir = path.join(home, '.termux', 'boot');
256
261
  const bootOk = fs.existsSync(bootDir);
257
262
  actions.push(bootOk
@@ -7,8 +7,8 @@ const os = require('node:os');
7
7
  const path = require('node:path');
8
8
  const { execFileSync } = require('node:child_process');
9
9
 
10
- function defaultPidfilePath() {
11
- return process.env.NEXUSCREW_PIDFILE || path.join(os.homedir(), '.nexuscrew', 'nexuscrew.pid');
10
+ function defaultPidfilePath(home = os.homedir()) {
11
+ return process.env.NEXUSCREW_PIDFILE || path.join(home, '.nexuscrew', 'nexuscrew.pid');
12
12
  }
13
13
 
14
14
  function readPidfile(p) {
@@ -93,7 +93,7 @@ function normalizeManagedSpec(value) {
93
93
  const model = value.model === undefined ? (profile.model || '') : value.model;
94
94
  if (typeof model !== 'string' || model.length > 128 || /[\x00-\x1f\x7f]/.test(model)) return null;
95
95
  if (profile.requiresModel && !model) return null;
96
- const permissionPolicy = value.permissionPolicy === undefined ? 'standard' : value.permissionPolicy;
96
+ const permissionPolicy = value.permissionPolicy === undefined ? (profile.client === 'claude' ? 'unsafe' : 'standard') : value.permissionPolicy;
97
97
  if (permissionPolicy !== 'standard' && permissionPolicy !== 'unsafe') return null;
98
98
  if (value.client === 'pi' && permissionPolicy !== 'standard') return null;
99
99
  const out = { client: profile.client, provider: profile.provider, model, permissionPolicy };
@@ -118,7 +118,7 @@ function defaultDefinitions() {
118
118
  schemaVersion: 1,
119
119
  engines: CATALOG.filter((p) => p.default).map((p) => ({
120
120
  id: p.id, label: p.label, rc: !!p.rc,
121
- managed: { client: p.client, provider: p.provider, model: p.model || '', permissionPolicy: 'standard' },
121
+ managed: { client: p.client, provider: p.provider, model: p.model || '', permissionPolicy: p.client === 'claude' ? 'unsafe' : 'standard' },
122
122
  })),
123
123
  cells: [],
124
124
  };
@@ -370,6 +370,7 @@ function publicCatalog() {
370
370
  credentialProfile: p.credentialProfile || '', label: p.label, protocol: p.protocol,
371
371
  auth: p.auth, endpoint: p.endpoint || '', model: p.model || '', models: [...(p.models || [])],
372
372
  protocols: [...(p.protocols || [p.protocol])], supportsUnsafe: p.client !== 'pi', requiresModel: !!p.requiresModel || !!p.custom,
373
+ permissionPolicyDefault: p.client === 'claude' ? 'unsafe' : 'standard',
373
374
  rc: !!p.rc, custom: !!p.custom, default: !!p.default,
374
375
  }));
375
376
  }
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+ // lib/nodes/health.js — modello di salute federato a 3 dimensioni per i nodi.
3
+ //
4
+ // Sostituisce il vecchio "inbound sempre up" (lib/server.js) con una valutazione
5
+ // onesta che NON mente sul verde:
6
+ //
7
+ // transport — la porta forward locale (127.0.0.1:localPort) risponde.
8
+ // Per outbound: pidfile del tunnel + probe TCP via /federation/health.
9
+ // auth — la federation accetta la credenziale del nodo (200 vs 401).
10
+ // reachability — l'API remota risponde con payload (200 vs 5xx/network error).
11
+ //
12
+ // Inbound: il nodo ricevente NON possiede il lifecycle del peer, ma il reverse
13
+ // tunnel espone comunque il peer su localPort. Quindi la salute e' probeable;
14
+ // resta managed:false e la UI NON offre il power.
15
+ //
16
+ // Il probe federation e' costoso (fetch): cache per-processo con TTL, keyed by
17
+ // node name, cosi' il poll frequente della UI non re-probe la stessa porta.
18
+
19
+ const nodesTunnel = require('./tunnel.js');
20
+ const { probeHealth } = require('../proxy/federation.js');
21
+
22
+ const TTL_MS = 5000;
23
+ const cache = new Map(); // name -> { health, at }
24
+
25
+ function clearHealthCache() { cache.clear(); }
26
+
27
+ // Compatibilita' tunnel per il frontend attuale (che legge tunnel.status): deriva
28
+ // uno {status, managed} retro-compatibile dal model health, senza reintrodurre il
29
+ // verde hardcoded. inbound -> 'unknown' (la UI lo trattava come up prima: ora e'
30
+ // onesto). outbound down/401 -> 'down'/'degraded'.
31
+ function tunnelFromHealth(h) {
32
+ if (!h) return { status: 'unknown', managed: false };
33
+ if (h.transport === 'up' && h.auth === 'ok') return { status: 'up', managed: h.managed !== false };
34
+ if (h.transport === 'up' && h.auth === 'failed') return { status: 'degraded', managed: h.managed !== false };
35
+ if (h.transport === 'up') return { status: 'degraded', managed: h.managed !== false };
36
+ if (h.transport === 'down') return { status: 'down', managed: h.managed !== false };
37
+ return { status: 'unknown', managed: false }; // inbound / unknown
38
+ }
39
+
40
+ async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = false }) {
41
+ 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 cached = !force && cache.get(cacheKey);
44
+ if (cached && (now - cached.at) < TTL_MS) return cached.health;
45
+
46
+ let health;
47
+ if (node.direction === 'inbound') {
48
+ if (!node.token) {
49
+ health = {
50
+ transport: 'unknown', auth: 'unknown', reachability: 'unknown', status: 'degraded',
51
+ detail: 'peer inbound senza credenziale federation — re-pair', managed: false, at: now,
52
+ };
53
+ } else {
54
+ const probed = await probeHealth({
55
+ port: node.localPort, token: node.token, expectedInstanceId: node.nodeId || null, fetchImpl, now,
56
+ });
57
+ health = { ...probed, managed: false };
58
+ }
59
+ } else {
60
+ const ts = nodesTunnel.readTunnelState(home, node.name);
61
+ if (ts.status !== 'up') {
62
+ health = {
63
+ transport: 'down', auth: 'unknown', reachability: 'unknown', status: 'down',
64
+ detail: ts.reason ? `tunnel down (${ts.reason})` : 'tunnel down',
65
+ managed: ts.managed !== false, at: now,
66
+ };
67
+ } else if (!node.token) {
68
+ health = {
69
+ transport: 'up', auth: 'unknown', reachability: 'unknown', status: 'degraded',
70
+ detail: 'tunnel up, credenziali federation assenti (token mancante) — re-pair',
71
+ managed: true, at: now,
72
+ };
73
+ } else {
74
+ const probed = await probeHealth({
75
+ port: node.localPort, token: node.token, expectedInstanceId: node.nodeId || null, fetchImpl, now,
76
+ });
77
+ health = { ...probed, managed: true };
78
+ }
79
+ }
80
+ cache.set(cacheKey, { health, at: now });
81
+ return health;
82
+ }
83
+
84
+ async function nodesHealth({ nodes, home, fetchImpl, now = Date.now() }) {
85
+ if (!Array.isArray(nodes) || nodes.length === 0) return [];
86
+ return Promise.all(nodes.map((node) => nodeHealth({ node, home, fetchImpl, now })));
87
+ }
88
+
89
+ module.exports = { nodeHealth, nodesHealth, tunnelFromHealth, clearHealthCache, TTL_MS };
@@ -93,12 +93,18 @@ function parseRoles(r) {
93
93
  return { client, node };
94
94
  }
95
95
 
96
+ // label: etichetta umana opzionale per il display (1-64 char visibili, no control).
97
+ // NON e' lo slug di routing: puo' contenere maiuscole, spazi, punteggiatura. Il name
98
+ // resta l'identita' tecnica (segmento path/route); la label e' solo come l'utente la
99
+ // vede. Backward-compatible: record esistenti senza label -> fallback a `name`.
100
+ const LABEL_MAX = 64;
101
+
96
102
  // parseNode(n) -> nodo normalizzato | null (fail-closed). Nessun campo extra
97
103
  // tollerato oltre a quelli noti (schema chiuso: garbage -> null, non guess).
98
104
  const NODE_KEYS = new Set([
99
105
  'name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'identityFile',
100
106
  'roles', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
101
- 'direction', 'reversePort',
107
+ 'direction', 'reversePort', 'label',
102
108
  ]);
103
109
  function parseNode(n, schemaVersion = SCHEMA_VERSION) {
104
110
  if (!n || typeof n !== 'object' || Array.isArray(n)) return null;
@@ -117,6 +123,16 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
117
123
  if (schemaVersion === LEGACY_SCHEMA_VERSION && !identityFile) return null;
118
124
  const roles = parseRoles(n.roles);
119
125
  if (!roles) return null;
126
+ // label (display) opzionale ma, se presente, strict: stringa 1..LABEL_MAX, no
127
+ // control char (newline/tab inclusi: una label su piu' righe non ha senso in
128
+ // UI), no solo-spazi. Garbage -> null (schema chiuso), mai guess/truncate.
129
+ let label = null;
130
+ if (n.label !== undefined) {
131
+ if (typeof n.label !== 'string') return null;
132
+ label = n.label.trim();
133
+ if (!label || label.length > LABEL_MAX) return null;
134
+ if (/[\x00-\x1f\x7f]/.test(label)) return null;
135
+ }
120
136
 
121
137
  const out = {
122
138
  name: n.name,
@@ -163,6 +179,7 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
163
179
  if (typeof n.nodeId !== 'string' || !NODE_ID_RE.test(n.nodeId)) return null;
164
180
  out.nodeId = n.nodeId;
165
181
  }
182
+ if (label) out.label = label;
166
183
  return out;
167
184
  }
168
185
 
@@ -357,6 +374,7 @@ function clearRendezvous(store) {
357
374
  function redactNode(n) {
358
375
  const out = {
359
376
  name: n.name,
377
+ ...(n.label ? { label: n.label } : {}),
360
378
  ...(n.ssh ? { ssh: n.ssh } : {}),
361
379
  remotePort: n.remotePort,
362
380
  localPort: n.localPort,
@@ -409,6 +427,63 @@ function migrateLegacyNodes(configPath, nodesPath) {
409
427
  return { migrated: true, count: written.nodes.length, reason: 'migrato da config.json' };
410
428
  }
411
429
 
430
+ // --- Label / slug helpers (display vs routing identity) ---------------------
431
+ // nodeLabel: etichetta umana di un nodo (display). Mai vuota: fallback a `name`.
432
+ function nodeLabel(n) {
433
+ if (n && typeof n.label === 'string' && n.label.trim()) return n.label.trim();
434
+ return (n && n.name) || '';
435
+ }
436
+
437
+ // validLabel: true se `v` e' una label display accettabile (stringa 1..LABEL_MAX,
438
+ // no control char, no solo-spazi). Riutilizzi la stessa logica strict di parseNode
439
+ // per validare input esterno (form UI, payload pairing) senza ricostruire un nodo.
440
+ function validLabel(v) {
441
+ if (typeof v !== 'string') return false;
442
+ const t = v.trim();
443
+ if (!t || t.length > LABEL_MAX) return false;
444
+ return !/[\x00-\x1f\x7f]/.test(t);
445
+ }
446
+
447
+ // sanitizeLabel: normalizza un input libero in una label display valida. Trim,
448
+ // collapse spazi, tronca a LABEL_MAX. Mai throw, mai ritorna vuoto (fallback
449
+ // `fallback`). Usata dove si vuole proporre una label senza fallire.
450
+ function sanitizeLabel(v, fallback = 'NexusCrew') {
451
+ const t = String(v == null ? '' : v).replace(/[\x00-\x1f\x7f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX);
452
+ return t || fallback;
453
+ }
454
+
455
+ // toSlug: deriva uno slug routing-safe (^[a-z0-9-]{1,32}$) da un input libero.
456
+ // Normalizza (NFKD + strip segni diacritici), lowercase, sostituisce run non
457
+ // alfanumerici con '-', trim dei bordi. Mai solleva: input povero -> 'node'.
458
+ // Usata dal form "Nuovo nodo" per suggerire lo slug dalla label umana senza
459
+ // obbligare l'utente a scrivere a mano caratteri tecnici.
460
+ function toSlug(input) {
461
+ const s = String(input == null ? '' : input)
462
+ .normalize('NFKD')
463
+ .replace(/[̀-ͯ]/g, '') // segni diacritici staccati (combining) -> ASCII
464
+ .toLowerCase()
465
+ .replace(/[^a-z0-9]+/g, '-')
466
+ .replace(/^-+|-+$/g, '')
467
+ .slice(0, 32)
468
+ .replace(/-+$/g, ''); // trim finale dopo il slice
469
+ return s || 'node';
470
+ }
471
+
472
+ // suggestNodeName: slug univoco dato un input libero e l'elenco dei name gia'
473
+ // usati. Disambigua con suffisso -2/-3/... Rende la creazione di un nodo non
474
+ // fallibile per collisione di slug (l'utente scrive "VPS3" e ottiene "vps3",
475
+ // oppure "vps3-2" se esiste gia').
476
+ function suggestNodeName(input, existing = []) {
477
+ const used = new Set(Array.isArray(existing) ? existing : []);
478
+ const base = toSlug(input);
479
+ if (!used.has(base)) return base;
480
+ for (let i = 2; i < 100; i += 1) {
481
+ const candidate = `${base.slice(0, Math.max(1, 32 - String(i).length - 1))}-${i}`;
482
+ if (!used.has(candidate)) return candidate;
483
+ }
484
+ return base; // fallback estremo
485
+ }
486
+
412
487
  module.exports = {
413
488
  // parse/validate
414
489
  parseStore, parseNode, parseRendezvous, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
@@ -420,6 +495,8 @@ module.exports = {
420
495
  redactNode, redactStore,
421
496
  // migrazione
422
497
  migrateLegacyNodes,
498
+ // label / slug
499
+ nodeLabel, validLabel, sanitizeLabel, toSlug, suggestNodeName, LABEL_MAX,
423
500
  // costanti
424
501
  SCHEMA_VERSION, LEGACY_SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
425
502
  };