@mmmbuto/nexuscrew 0.8.58 → 0.9.0

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +137 -2
  2. package/README.md +1 -0
  3. package/frontend/dist/assets/index-0vuhL1YP.css +32 -0
  4. package/frontend/dist/assets/index-zjL6kZ7J.js +93 -0
  5. package/frontend/dist/index.html +2 -2
  6. package/frontend/dist/version.json +1 -1
  7. package/lib/audio/adapters.js +50 -6
  8. package/lib/cli/commands.js +40 -2
  9. package/lib/cli/doctor.js +95 -12
  10. package/lib/cli/init.js +25 -3
  11. package/lib/cli/path.js +43 -10
  12. package/lib/cli/pidfile.js +23 -2
  13. package/lib/config.js +15 -0
  14. package/lib/fleet/builtin.js +161 -19
  15. package/lib/fleet/catalogs/opencode-go.json +50 -4
  16. package/lib/fleet/cell-exec.js +87 -9
  17. package/lib/fleet/cell-lease-server.js +719 -0
  18. package/lib/fleet/cell-lease.js +112 -0
  19. package/lib/fleet/definitions.js +101 -7
  20. package/lib/fleet/launch-broker.js +115 -3
  21. package/lib/fleet/lease-client.js +191 -0
  22. package/lib/fleet/lease-routes.js +92 -0
  23. package/lib/fleet/lease-verifier.js +230 -0
  24. package/lib/fleet/managed.js +366 -48
  25. package/lib/fleet/prompt-delivery.js +50 -2
  26. package/lib/fleet/provider.js +1 -1
  27. package/lib/fleet/runtime.js +53 -6
  28. package/lib/live-host/bridge.js +369 -0
  29. package/lib/live-host/routes.js +184 -0
  30. package/lib/live-host/store.js +96 -0
  31. package/lib/mcp/tools.js +51 -0
  32. package/lib/nodes/commands.js +9 -2
  33. package/lib/nodes/store.js +14 -0
  34. package/lib/nodes/tunnel.js +4 -1
  35. package/lib/proxy/federation.js +106 -9
  36. package/lib/proxy/node-proxy.js +33 -0
  37. package/lib/proxy/panel-auth.js +307 -0
  38. package/lib/proxy/panel-proxy.js +305 -0
  39. package/lib/server.js +127 -4
  40. package/package.json +1 -1
  41. package/skills/alibaba-token-media/SKILL.md +19 -0
  42. package/skills/crew/SKILL.md +15 -0
  43. package/skills/fill-forms/SKILL.md +23 -0
  44. package/skills/mail-assistant/SKILL.md +15 -0
  45. package/skills/memory/SKILL.md +15 -0
  46. package/skills/nexuscrew-agent/SKILL.md +18 -0
  47. package/skills/vl-msa/SKILL.md +15 -0
  48. package/frontend/dist/assets/index-BEGNtmx2.js +0 -93
  49. package/frontend/dist/assets/index-CYi_lhCg.css +0 -32
@@ -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-BEGNtmx2.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-CYi_lhCg.css">
14
+ <script type="module" crossorigin src="/assets/index-zjL6kZ7J.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-0vuhL1YP.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.8.58"}
1
+ {"version":"0.9.0"}
@@ -31,13 +31,25 @@ const UTTERANCE_TIMEOUT_MS = 30 * 1000;
31
31
  // errore, la sintesi non e' partita davvero.
32
32
  const START_GRACE_MS = 250;
33
33
 
34
- function isExecutable(fsImpl, file) {
34
+ // Stessa forma corretta in lib/cli/path.js: il discriminante e' CHI ha
35
+ // fallito, non "c'e' stata un'eccezione". ENOENT ("il path non c'e' qui")
36
+ // e' legittimo, si continua a cercare; ogni altro errore (EACCES — sul file
37
+ // O sulla directory che lo contiene, ELOOP, ENOTDIR) significa "non sono
38
+ // riuscito a verificarlo", non "non c'e'".
39
+ function probeBin(fsImpl, file) {
35
40
  try {
36
41
  const st = fsImpl.statSync(file);
37
- if (!st.isFile()) return false;
42
+ if (!st.isFile()) return { status: 'absent' };
38
43
  fsImpl.accessSync(file, fsDefault.constants.X_OK);
39
- return true;
40
- } catch (_) { return false; }
44
+ return { status: 'found', path: file };
45
+ } catch (e) {
46
+ if (e.code === 'ENOENT') return { status: 'absent' };
47
+ return { status: 'blocked', path: file, code: e.code || e.constructor.name };
48
+ }
49
+ }
50
+
51
+ function isExecutable(fsImpl, file) {
52
+ return probeBin(fsImpl, file).status === 'found';
41
53
  }
42
54
 
43
55
  // Ricerca bounded nel PATH: nessuna shell, nessun glob, nessun fallback su cwd.
@@ -52,6 +64,24 @@ function lookupBin(name, { env = process.env, fsImpl = fsDefault } = {}) {
52
64
  return null;
53
65
  }
54
66
 
67
+ // Come lookupBin, ma riporta anche le entry del PATH dove la verifica e'
68
+ // fallita per un motivo diverso da "non c'e' qui" — usata da detectAdapter
69
+ // per non scegliere in silenzio un adapter con proprieta' diverse (es.
70
+ // testo in argv invece che stdin, vedi 'spd-say') quando il preferito era
71
+ // presente ma irraggiungibile, non assente.
72
+ function resolveBin(name, { env = process.env, fsImpl = fsDefault } = {}) {
73
+ if (!name || name.includes('/')) return { path: null, blocked: [] };
74
+ const raw = String(env.PATH || '');
75
+ if (!raw) return { path: null, blocked: [] };
76
+ const blocked = [];
77
+ for (const dir of raw.split(path.delimiter).filter(Boolean).slice(0, 64)) {
78
+ const r = probeBin(fsImpl, path.join(dir, name));
79
+ if (r.status === 'found') return { path: r.path, blocked };
80
+ if (r.status === 'blocked') blocked.push(r);
81
+ }
82
+ return { path: null, blocked };
83
+ }
84
+
55
85
  // Descrittori di piattaforma. `stdin:true` = il testo non tocca argv.
56
86
  // L'ordine dentro ogni piattaforma e' una preferenza dichiarata, non casuale:
57
87
  // prima chi accetta stdin.
@@ -100,10 +130,24 @@ function detectAdapter({ platform = process.platform, env = process.env, fsImpl
100
130
  // secondo caso e' quello reale su Android, quindi va riconosciuto.
101
131
  const termux = typeof env.PREFIX === 'string' && env.PREFIX.includes('com.termux');
102
132
  const key = termux ? 'android' : platform;
133
+ // Un candidato precedente "bloccato" (presente ma irraggiungibile: permessi,
134
+ // symlink rotto) non e' un'assenza: se il descrittore scelto e' un fallback
135
+ // con proprieta' diverse (es. spd-say: testo in argv invece che stdin), chi
136
+ // legge il risultato deve poterlo sapere invece di credere a un'assenza
137
+ // genuina. Il caso "nessun descrittore trovato affatto" resta `null`
138
+ // com'era: cambiare quella shape romperebbe createAdapter(null) altrove
139
+ // per un guadagno che oggi nessun chiamante consumerebbe (nessuno dei due
140
+ // call site ha un canale di log in questo punto).
141
+ const precededByBlocked = [];
103
142
  for (const d of descriptors) {
104
143
  if (!d.platforms.includes(key)) continue;
105
- const bin = lookupBin(d.bin, { env, fsImpl });
106
- if (bin) return { ...d, bin, platform: key, installed: true };
144
+ const r = resolveBin(d.bin, { env, fsImpl });
145
+ if (r.path) {
146
+ return precededByBlocked.length
147
+ ? { ...d, bin: r.path, platform: key, installed: true, precededByBlocked }
148
+ : { ...d, bin: r.path, platform: key, installed: true };
149
+ }
150
+ if (r.blocked.length) precededByBlocked.push({ id: d.id, blocked: r.blocked });
107
151
  }
108
152
  return null;
109
153
  }
@@ -607,12 +607,38 @@ function startPortable(opts = {}) {
607
607
  function openPwa(fullUrl, opts = {}) {
608
608
  if (opts.openImpl) return opts.openImpl(fullUrl);
609
609
  const platform = opts.platform || detectPlatform();
610
- const commandExists = opts.commandExists || require('./path.js').commandExists;
610
+ const env = opts.env || process.env;
611
+ const log = opts.log || console.log;
611
612
  const candidates = platform === 'termux'
612
613
  ? ['termux-open-url']
613
614
  : platform === 'mac' ? ['open'] : ['xdg-open', 'gio'];
614
- const bin = candidates.find((candidate) => commandExists(candidate, opts.env || process.env));
615
+
616
+ let bin = null;
617
+ const blockedBefore = [];
618
+ if (opts.commandExists) {
619
+ // Seam booleano esplicito (test): nessuna informazione su "bloccato" vs
620
+ // "assente" disponibile — comportamento storico, nessun warning possibile.
621
+ bin = candidates.find((candidate) => opts.commandExists(candidate, env)) || null;
622
+ } else {
623
+ const resolveCmd = opts.resolveCommand || require('./path.js').resolveCommand;
624
+ for (const candidate of candidates) {
625
+ const r = resolveCmd(candidate, env);
626
+ if (r.found) { bin = candidate; break; }
627
+ if (r.blocked && r.blocked.length) blockedBefore.push({ candidate, blocked: r.blocked });
628
+ }
629
+ }
615
630
  if (!bin) throw new Error('no URL opener found; install termux-tools or xdg-utils');
631
+ if (blockedBefore.length) {
632
+ // Un candidato preferito non era verificabile (permessi, symlink rotto),
633
+ // non genuinamente assente: `find` sceglierebbe il successivo IN
634
+ // SILENZIO. Continuiamo best-effort (non blocchiamo l'apertura del PWA
635
+ // per questo), ma lo diciamo — altrimenti si esegue un binario diverso
636
+ // da quello inteso senza che nessuno lo sappia.
637
+ const detail = blockedBefore
638
+ .map((b) => `${b.candidate}: ${b.blocked.map((x) => `${x.path} (${x.code})`).join('; ')}`)
639
+ .join(' | ');
640
+ log(`WARN: apertura PWA con "${bin}" — un candidato preferito non era verificabile, non assente (${detail})`);
641
+ }
616
642
  const args = bin === 'gio' ? ['open', fullUrl] : [fullUrl];
617
643
  const child = (opts.spawnImpl || spawn)(bin, args, { detached: true, stdio: 'ignore' });
618
644
  if (child && typeof child.on === 'function') child.on('error', () => {});
@@ -1129,6 +1155,18 @@ async function dispatchNodes(rest, flags, opts = {}) {
1129
1155
  if (!cells.length) { log('nodes cells: elenco vuoto — usa `none` se intendi nessuna cella'); return { code: 1 }; }
1130
1156
  return { code: nodesCmds.nodesEdit({ ...opts, log, ref, patch: { cellVisibility: 'selected', cells } }).code };
1131
1157
  }
1158
+ // Accesso ai pannelli (D8): dietro un pannello c'e' un browser con sessioni
1159
+ // gia' autenticate, e quell'accesso non si revoca cambiando una chiave. Il
1160
+ // resto del modello tratta un peer pairato come l'operatore stesso; qui no:
1161
+ // default negato, si concede un nodo per volta.
1162
+ if (sub === 'panel') {
1163
+ const arg = rest[3];
1164
+ if (arg !== 'on' && arg !== 'off') {
1165
+ log('nodes panel: uso `nodes panel <nodo> on|off`');
1166
+ return { code: 1 };
1167
+ }
1168
+ return { code: nodesCmds.nodesEdit({ ...opts, log, ref, patch: { panelAccess: arg === 'on' } }).code };
1169
+ }
1132
1170
  if (sub === 'remove') {
1133
1171
  if (flags.yes !== true) { log('nodes remove: conferma richiesta con --yes'); return { code: 1 }; }
1134
1172
  return { code: nodesCmds.nodesRemove({ ...opts, log, ref }).code };
package/lib/cli/doctor.js CHANGED
@@ -13,7 +13,7 @@ const { detectPlatform, uid } = require('./platform.js');
13
13
  const { installPath } = require('./service.js');
14
14
  const { fleetInstallPath } = require('./fleet-service.js');
15
15
  const { resolvePaths } = require('./url.js');
16
- const { commandExists } = require('./path.js');
16
+ const { commandExists, resolveCommand } = require('./path.js');
17
17
  const { loadDefinitions } = require('../fleet/definitions.js');
18
18
  const { loadConfig } = require('../config.js');
19
19
  const {
@@ -29,10 +29,20 @@ function checkNode() {
29
29
  return { name: 'node >= 18', ok: maj >= 18, detail: `v${process.versions.node}` };
30
30
  }
31
31
 
32
- function checkTmux(existsImpl, tmuxBin) {
33
- return existsImpl(tmuxBin || 'tmux')
34
- ? { name: 'tmux presente', ok: true }
35
- : { name: 'tmux presente', ok: false, detail: 'non trovato su PATH (installa tmux)' };
32
+ function checkTmux(existsImpl, tmuxBin, resolveImpl) {
33
+ if (existsImpl(tmuxBin || 'tmux')) return { name: 'tmux presente', ok: true };
34
+ // existsImpl e' un booleano (spesso un seam di test): non dice se e' una
35
+ // vera assenza o una verifica impossibile. Quando disponibile, resolveImpl
36
+ // arricchisce SOLO il messaggio — mai la decisione ok/fail, che resta
37
+ // quella di existsImpl.
38
+ if (resolveImpl) {
39
+ const r = resolveImpl(tmuxBin || 'tmux');
40
+ if (r && r.blocked && r.blocked.length) {
41
+ const detail = r.blocked.map((b) => `${b.path} (${b.code})`).join('; ');
42
+ return { name: 'tmux presente', ok: false, detail: `non verificabile su PATH, non "non installato" (${detail})` };
43
+ }
44
+ }
45
+ return { name: 'tmux presente', ok: false, detail: 'non trovato su PATH (installa tmux)' };
36
46
  }
37
47
 
38
48
  function checkPty(ptyLoad) {
@@ -48,6 +58,7 @@ function checkService(platform, home, execImpl, uidVal, installPathOverride) {
48
58
  const target = installPathOverride || installPath(platform, home);
49
59
  const installed = fs.existsSync(target);
50
60
  let active = false;
61
+ let activeUnverifiable = null; // null = stato verificato (attivo o no); string = non ho potuto verificare
51
62
  try {
52
63
  if (platform === 'linux') {
53
64
  const s = execImpl('systemctl', ['--user', 'is-active', 'nexuscrew'], { encoding: 'utf8' });
@@ -60,12 +71,27 @@ function checkService(platform, home, execImpl, uidVal, installPathOverride) {
60
71
  const meta = pidf.readPidfile(pidf.defaultPidfilePath(home));
61
72
  active = !!(meta && pidf.isAlive(meta));
62
73
  }
63
- } catch (_) { active = false; }
74
+ } catch (e) {
75
+ active = false;
76
+ // Linux: systemctl assente (ENOENT) o bus/dbus down = non ho potuto VERIFICARE
77
+ // se e' attivo; systemctl che gira e risponde 'inactive' (altri throw) =
78
+ // legittimo "non attivo", VERIFICATO. Verdetto invariato (active resta false,
79
+ // ok/warn non cambiano); il messaggio distingue, come checkTmuxSurvival nello
80
+ // stesso file. Il discriminante e' CHI ha fallito, non che e' ci sia stata
81
+ // un'eccezione. mac/termux non toccati: il collasso nominato e' linux/systemctl.
82
+ if (platform === 'linux' && systemctlUnverifiable(e)) activeUnverifiable = e.code || e.message || e.constructor.name;
83
+ }
64
84
  return {
65
85
  name: 'service installato/attivo',
66
86
  ok: installed,
67
87
  warn: installed && !active, // installato ma non attivo = warning, non fail
68
- detail: installed ? (active ? 'attivo' : 'installato ma non attivo') : `non installato (${target})`,
88
+ detail: installed
89
+ ? (active
90
+ ? 'attivo'
91
+ : (activeUnverifiable
92
+ ? `installato, stato attivita' non verificabile (systemctl/dbus: ${activeUnverifiable}), non "non attivo"`
93
+ : 'installato ma non attivo'))
94
+ : `non installato (${target})`,
69
95
  };
70
96
  }
71
97
 
@@ -164,8 +190,15 @@ function checkBoot(platform, home, execImpl) {
164
190
  const s = execImpl('systemctl', ['--user', 'is-enabled', 'nexuscrew'], { encoding: 'utf8' });
165
191
  const enabled = String(s).trim() === 'enabled';
166
192
  return { name: 'boot (systemd enabled)', ok: true, warn: !enabled, detail: enabled ? 'enabled' : 'non enabled (non parte al boot)' };
167
- } catch (_) {
168
- return { name: 'boot (systemd enabled)', ok: true, warn: true, detail: 'non enabled' };
193
+ } catch (e) {
194
+ // systemctl assente (ENOENT) o bus/dbus down = non ho potuto verificare se
195
+ // e' enabled; systemctl che gira e risponde 'disabled'/'masked' (altri
196
+ // throw) = legittimo "non enabled", VERIFICATO. Verdetto invariato
197
+ // (ok:true, warn:true); il messaggio distingue, come checkTmuxSurvival.
198
+ if (systemctlUnverifiable(e)) {
199
+ return { name: 'boot (systemd enabled)', ok: true, warn: true, detail: `non verificabile (systemctl/dbus non raggiungibile: ${e.code || e.message || e.constructor.name}): l'unita' potrebbe essere enabled, non ho potuto guardare` };
200
+ }
201
+ return { name: 'boot (systemd enabled)', ok: true, warn: true, detail: 'non enabled (non parte al boot)' };
169
202
  }
170
203
  }
171
204
  // mac: RunAtLoad nel plist installato
@@ -192,6 +225,19 @@ function checkUserLinger(platform, execImpl, uidVal) {
192
225
  }
193
226
  }
194
227
 
228
+ // systemctl assente (ENOENT: il binario non e' sul PATH) o bus/dbus non
229
+ // raggiungibile = non ho potuto VERIFICARE lo stato, distinto da systemctl che
230
+ // ha GIRATO e ha risposto 'inactive'/'disabled' (legittimo "non attivo"/"non
231
+ // enabled"). Stesso principio di checkTmuxSurvival (sotto): il discriminante e'
232
+ // CHI ha fallito, non che ci sia stata un'eccezione. Function declaration: hoisted,
233
+ // quindi disponibile a checkService/checkBoot che la precedono nel sorgente.
234
+ function systemctlUnverifiable(e) {
235
+ if (!e) return false;
236
+ if (e.code === 'ENOENT') return true; // systemctl non installato su PATH
237
+ const msg = String((e && e.message) || e);
238
+ return /Failed to connect to bus|Unable to connect to bus|D-Bus|dbus|Could not connect|Connection refused|Failed to get (D-?bus|the bus)/i.test(msg);
239
+ }
240
+
195
241
  function checkTmuxSurvival(platform, execImpl) {
196
242
  if (platform !== 'linux') {
197
243
  return { name: 'tmux survival on service restart', ok: true, detail: `${platform}: systemd cgroup non applicabile` };
@@ -289,12 +335,36 @@ function checkTermuxExec(runtimeEnv, opts = {}) {
289
335
  const libDir = path.join(termux.prefix, 'lib');
290
336
  let present = '';
291
337
  let candidates = [];
338
+ // Stessa forma gia' corretta altrove: ENOENT ("PREFIX/lib non c'e'") e'
339
+ // legittimo, ma EACCES/ELOOP/ENOTDIR ("non sono riuscito a leggere la
340
+ // directory") non e' un'assenza — e' un fallimento della verifica. Il
341
+ // verdetto resta ok:false in entrambi i casi (in nessuno dei due possiamo
342
+ // CONFERMARE che la libreria trusted esista): solo il messaggio distingue.
343
+ let libDirBlocked = null;
292
344
  try {
293
345
  candidates = fs.readdirSync(libDir).filter((name) => TERMUX_EXEC_BASENAME_RE.test(name)).sort();
294
- } catch (_) { /* absent/unreadable */ }
346
+ } catch (e) {
347
+ if (e.code !== 'ENOENT') libDirBlocked = `${e.code || e.constructor.name}: ${e.message}`;
348
+ }
349
+ // Riconsegna: il fix sopra copriva readdirSync sulla DIRECTORY, ma il
350
+ // difetto era tornato un passo piu' avanti, nella stessa funzione —
351
+ // statSync su ogni CANDIDATO ricollassava EACCES/ELOOP in "prossimo",
352
+ // indistinguibile da "questo nome non e' un file valido". Misurato: un
353
+ // candidato che e' un symlink circolare (o rotto in modo diverso da
354
+ // ENOENT) fa fallire statSync con ELOOP — la libreria potrebbe essere
355
+ // davvero li' dietro, solo irraggiungibile in questo modo specifico. Se
356
+ // TUTTI i candidati finiscono bloccati, il loop esce con present='' e,
357
+ // senza questo tracciamento, il ramo finale direbbe "non trovata" —
358
+ // esattamente il messaggio fuorviante gia' chiuso una volta, tornato un
359
+ // passo piu' avanti nella stessa funzione.
360
+ const candidateBlocked = [];
295
361
  for (const name of candidates) {
296
362
  const candidate = path.join(libDir, name);
297
- try { if (fs.statSync(candidate).isFile()) { present = candidate; break; } } catch (_) { /* next */ }
363
+ try {
364
+ if (fs.statSync(candidate).isFile()) { present = candidate; break; }
365
+ } catch (e) {
366
+ if (e.code !== 'ENOENT') candidateBlocked.push(`${name} (${e.code || e.constructor.name})`);
367
+ }
298
368
  }
299
369
  if (trusted) {
300
370
  return { name: 'termux-exec preload', ok: true, detail: `preload trusted: ${path.basename(trusted)}` };
@@ -305,6 +375,18 @@ function checkTermuxExec(runtimeEnv, opts = {}) {
305
375
  detail: `libreria presente (${path.basename(present)}) ma LD_PRELOAD non valido nell'env del doctor: avvia il servizio da una shell Termux di login o via termux-exec preload`,
306
376
  };
307
377
  }
378
+ if (libDirBlocked) {
379
+ return {
380
+ name: 'termux-exec preload', ok: false,
381
+ detail: `non ho potuto verificare PREFIX/lib (${libDirBlocked}), non "assente": sulla build Google Play celle e shell non possono eseguire comandi se manca davvero`,
382
+ };
383
+ }
384
+ if (candidateBlocked.length) {
385
+ return {
386
+ name: 'termux-exec preload', ok: false,
387
+ detail: `non ho potuto verificare ${candidateBlocked.length === 1 ? 'un candidato' : 'alcuni candidati'} sotto PREFIX/lib (${candidateBlocked.join('; ')}), non "assente": sulla build Google Play celle e shell non possono eseguire comandi se manca davvero`,
388
+ };
389
+ }
308
390
  return {
309
391
  name: 'termux-exec preload', ok: false,
310
392
  detail: 'libtermux-exec non trovata sotto PREFIX/lib: sulla build Google Play celle e shell non possono eseguire comandi',
@@ -469,6 +551,7 @@ function doctor(opts = {}) {
469
551
  const log = opts.log || console.log;
470
552
  const ptyLoad = opts.ptyLoad || (() => require('../pty/provider.js').loadPty());
471
553
  const existsImpl = opts.commandExists || commandExists;
554
+ const resolveImpl = opts.resolveCommand || resolveCommand;
472
555
  const { tokenPath } = resolvePaths(opts);
473
556
  const fleetEnabled = opts.fleetEnabled !== false
474
557
  && opts.builtinEnabled !== false
@@ -476,7 +559,7 @@ function doctor(opts = {}) {
476
559
 
477
560
  const checks = [
478
561
  checkNode(),
479
- checkTmux(existsImpl, opts.tmuxBin),
562
+ checkTmux(existsImpl, opts.tmuxBin, resolveImpl),
480
563
  checkPty(ptyLoad),
481
564
  checkService(platform, home, execImpl, uidVal, opts.installPath),
482
565
  checkServiceWorkingDirectory(platform, home, opts.installPath),
package/lib/cli/init.js CHANGED
@@ -19,12 +19,28 @@ const {
19
19
  } = require('./fleet-service.js');
20
20
  const { atomicWrite: writeFleet } = require('../fleet/definitions.js');
21
21
  const { defaultDefinitions } = require('../fleet/managed.js');
22
- const { commandExists } = require('./path.js');
22
+ const { commandExists, resolveCommand } = require('./path.js');
23
23
 
24
24
  function haveTmux(tmuxBin, env = process.env) {
25
25
  return commandExists(tmuxBin, env);
26
26
  }
27
27
 
28
+ // Messaggio per l'utente quando tmux risulta assente. SOLO quando siamo NOI
29
+ // a fare la verifica (nessun tmuxOk imposto dal chiamante) proviamo ad
30
+ // arricchirlo: se resolveCommand trova entry del PATH dove non e' stato
31
+ // possibile guardare (permessi, symlink rotto, directory non
32
+ // attraversabile), il messaggio deve dire "non verificabile", non "non
33
+ // trovato" — altrimenti manda l'operatore a installare un pacchetto che
34
+ // magari e' gia' li'.
35
+ function tmuxUnavailableMessage(tmuxBin, env) {
36
+ const r = resolveCommand(tmuxBin || 'tmux', env);
37
+ if (r.blocked && r.blocked.length) {
38
+ const detail = r.blocked.map((b) => `${b.path} (${b.code})`).join('; ');
39
+ return `tmux non verificabile su PATH, non "non trovato" (${detail})`;
40
+ }
41
+ return 'tmux non trovato su PATH (installa tmux)';
42
+ }
43
+
28
44
  function nodeMajor() {
29
45
  return parseInt(String(process.versions.node).split('.')[0], 10);
30
46
  }
@@ -101,7 +117,7 @@ function runInit(opts = {}) {
101
117
  const dryRun = !!opts.dryRun;
102
118
  const installBoot = opts.installBoot !== false;
103
119
  const log = opts.log || (() => {});
104
- const tmuxOk = opts.tmuxOk !== undefined ? opts.tmuxOk : haveTmux(opts.tmuxBin || 'tmux');
120
+ const tmuxOk = opts.tmuxOk !== undefined ? opts.tmuxOk : haveTmux(opts.tmuxBin || 'tmux', opts.env || process.env);
105
121
 
106
122
  // prereq Node (abort before any write) [M8]
107
123
  if (nodeMajor() < 18) {
@@ -211,7 +227,13 @@ function runInit(opts = {}) {
211
227
  actions.push(`DRY-RUN service (${platform}) generato, NON installato`);
212
228
  } else if (!tmuxOk) {
213
229
  // tmux mancante: abort before service install (config/token gia' creati) [M8]
214
- actions.push(`WARN: tmux non trovato su PATH -> service NON installato (installa tmux, ri-runna init)`);
230
+ // Se il chiamante ha imposto tmuxOk direttamente (test/seam), non
231
+ // abbiamo l'informazione per distinguere assenza da verifica bloccata:
232
+ // il messaggio resta quello storico.
233
+ const reason = opts.tmuxOk === undefined
234
+ ? tmuxUnavailableMessage(opts.tmuxBin || 'tmux', opts.env || process.env)
235
+ : 'tmux non trovato su PATH (installa tmux)';
236
+ actions.push(`WARN: ${reason} -> service NON installato (ri-runna init)`);
215
237
  } else {
216
238
  try {
217
239
  const r = installService(platform, content, svcCtx, { execImpl: opts.execImpl });
package/lib/cli/path.js CHANGED
@@ -4,21 +4,54 @@
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
 
7
- function executable(p) {
7
+ // Il discriminante e' CHI ha fallito, non "c'e' stata un'eccezione" (stessa
8
+ // forma gia' corretta in tests/helpers/pi-real-consumer.js, qui nel codice di
9
+ // prodotto): ENOENT su statSync/accessSync significa "il path non c'e' qui",
10
+ // legittimo, si continua a cercare altrove sul PATH. Ogni altro errore
11
+ // (EACCES — permessi sul file O sulla directory che lo contiene, ELOOP —
12
+ // symlink circolare, ENOTDIR, EIO) significa "non sono riuscito a
13
+ // verificarlo", non "non c'e'".
14
+ function probe(p) {
8
15
  try {
9
16
  const st = fs.statSync(p);
10
- if (!st.isFile()) return false;
17
+ if (!st.isFile()) return { status: 'absent' };
11
18
  fs.accessSync(p, fs.constants.X_OK);
12
- return true;
13
- } catch (_) { return false; }
19
+ return { status: 'found', path: p };
20
+ } catch (e) {
21
+ if (e.code === 'ENOENT') return { status: 'absent' };
22
+ return { status: 'blocked', path: p, code: e.code || e.constructor.name, message: e.message };
23
+ }
24
+ }
25
+
26
+ function executable(p) {
27
+ return probe(p).status === 'found';
28
+ }
29
+
30
+ // Risolve `bin` sul PATH riportando anche le entry dove la verifica e'
31
+ // fallita per un motivo diverso da "non c'e' qui" — cosi' un chiamante che
32
+ // deve spiegare un esito negativo puo' distinguere un'assenza genuina da un
33
+ // controllo impossibile (permessi, symlink rotto, directory non
34
+ // attraversabile), invece di trattarli come lo stesso "non trovato".
35
+ function resolveCommand(bin, env = process.env) {
36
+ if (typeof bin !== 'string' || !bin || bin.includes('\0')) return { found: false, path: null, blocked: [] };
37
+ if (path.isAbsolute(bin) || bin.includes('/') || bin.includes('\\')) {
38
+ const r = probe(bin);
39
+ return r.status === 'found'
40
+ ? { found: true, path: r.path, blocked: [] }
41
+ : { found: false, path: null, blocked: r.status === 'blocked' ? [r] : [] };
42
+ }
43
+ const dirs = String((env && env.PATH) || '').split(path.delimiter).filter(Boolean);
44
+ const blocked = [];
45
+ for (const dir of dirs) {
46
+ const r = probe(path.join(dir, bin));
47
+ if (r.status === 'found') return { found: true, path: r.path, blocked };
48
+ if (r.status === 'blocked') blocked.push(r);
49
+ }
50
+ return { found: false, path: null, blocked };
14
51
  }
15
52
 
16
53
  function commandExists(bin, env = process.env) {
17
- if (typeof bin !== 'string' || !bin || bin.includes('\0')) return false;
18
- if (path.isAbsolute(bin) || bin.includes('/') || bin.includes('\\')) return executable(bin);
19
- return String((env && env.PATH) || '').split(path.delimiter)
20
- .filter(Boolean)
21
- .some((dir) => executable(path.join(dir, bin)));
54
+ return resolveCommand(bin, env).found;
22
55
  }
23
56
 
24
- module.exports = { commandExists };
57
+ module.exports = { commandExists, resolveCommand };
@@ -55,8 +55,26 @@ function writePidfile(p, pid, cmd, extra = {}) {
55
55
  fs.writeFileSync(p, meta + '\n', { flag: 'wx', mode: 0o600 });
56
56
  }
57
57
 
58
- function removePidfile(p) {
58
+ // La rimozione NON e' un unlink nudo. Il pidfile e' la prova che un processo
59
+ // e' vivo: togliere quello di un VIVO che non siamo noi significa cancellarla
60
+ // — chi governa quel processo (stop, doctor, supervisor) lo crederebbe morto,
61
+ // o peggio adotterebbe uno slot libero che e' occupato. La rimozione e'
62
+ // legittima in tre casi, verificati QUI:
63
+ // 1. il pidfile e' il NOSTRO (meta.pid === process.pid): self-cleanup;
64
+ // 2. e' STALE: il pid e' morto o non e' piu' attribuibile al cmd registrato;
65
+ // 3. non e' leggibile come pidfile: garbage, non il pidfile di nessuno.
66
+ // `allowLive: true` e' la garanzia del CHIAMANTE, non un bypass: killPidfile la
67
+ // usa DOPO un kill verificato del pid esatto del file; il supervisor dei tunnel
68
+ // dopo il match pid+runId del proprio spawn. Quelle vie verificano il soggetto
69
+ // per conto loro prima di dichiararlo. Ritorna false (e non tocca il file) se
70
+ // il pidfile appartiene a un vivo che non siamo noi e non c'e' garanzia.
71
+ function removePidfile(p, { allowLive = false, impl = {} } = {}) {
72
+ const meta = readPidfile(p);
73
+ if (!allowLive && meta && meta.pid !== process.pid && isAlive(meta, impl)) {
74
+ return false; // pidfile di un processo vivo che non siamo noi: resta
75
+ }
59
76
  try { fs.unlinkSync(p); } catch (_) {}
77
+ return true;
60
78
  }
61
79
 
62
80
  // A PID can exist without belonging to this UID. Android commonly reuses PIDs
@@ -155,7 +173,10 @@ function killPidfile(p, signal = 'SIGTERM', impl = {}) {
155
173
  }
156
174
  try {
157
175
  killImpl(meta.pid, signal);
158
- removePidfile(p);
176
+ // allowLive: il segnale e' partito verso il pid VERIFICATO del file (cmd
177
+ // matchato sopra): la rimozione e' giusta anche se il processo non e' ancora
178
+ // sparito da /proc quando unlink gira.
179
+ removePidfile(p, { allowLive: true });
159
180
  return { killed: true, pid: meta.pid };
160
181
  } catch (e) {
161
182
  return { killed: false, reason: e.message };
package/lib/config.js CHANGED
@@ -49,6 +49,16 @@ function baseDefaults() {
49
49
  // Il manager aggiorna solo verso una semver superiore: mai downgrade.
50
50
  autoUpdate: true,
51
51
  sessionPresets: {},
52
+ // Ponte Live (fetta 3, rev5 MC0): isolabile — a false il ponte non si
53
+ // connette mai e ogni avvio di Live resta sul comportamento standard.
54
+ liveBridgeEnabled: true,
55
+ // Socket di controllo dell'app-server (misurato 2026-08-15:
56
+ // $CODEX_HOME/app-server-control/app-server-control.sock, WebSocket sopra
57
+ // unix socket). Non cablare mai una porta al posto di questo path.
58
+ liveBridgeSocketPath: path.join(os.homedir(), '.codex', 'app-server-control', 'app-server-control.sock'),
59
+ // MC1.5: limite dichiarato per OGNI fase del ponte (GET designazione e
60
+ // sessione sul socket). Oltre questo la Live parte senza puntamento.
61
+ liveBridgeTimeoutMs: 1500,
52
62
  };
53
63
  }
54
64
 
@@ -95,6 +105,11 @@ function envOverrides() {
95
105
  if (process.env.NEXUSCREW_AUTO_UPDATE !== undefined) {
96
106
  e.autoUpdate = !['', '0', 'false', 'no', 'off'].includes(String(process.env.NEXUSCREW_AUTO_UPDATE).toLowerCase());
97
107
  }
108
+ if (process.env.NEXUSCREW_LIVE_BRIDGE !== undefined) {
109
+ e.liveBridgeEnabled = !['', '0', 'false', 'no', 'off'].includes(String(process.env.NEXUSCREW_LIVE_BRIDGE).toLowerCase());
110
+ }
111
+ if (process.env.NEXUSCREW_LIVE_BRIDGE_SOCKET) e.liveBridgeSocketPath = process.env.NEXUSCREW_LIVE_BRIDGE_SOCKET;
112
+ if (process.env.NEXUSCREW_LIVE_BRIDGE_TIMEOUT_MS) e.liveBridgeTimeoutMs = Number(process.env.NEXUSCREW_LIVE_BRIDGE_TIMEOUT_MS);
98
113
  return e;
99
114
  }
100
115