@mmmbuto/nexuscrew 0.7.7 → 0.8.2

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 (51) hide show
  1. package/README.md +153 -25
  2. package/bin/nexuscrew.js +10 -4
  3. package/frontend/dist/assets/index-COsoJxXQ.css +32 -0
  4. package/frontend/dist/assets/index-Dey9rdY-.js +90 -0
  5. package/frontend/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +98 -0
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/dist/sw.js +29 -0
  8. package/frontend/dist/version.json +1 -0
  9. package/lib/auth/middleware.js +7 -1
  10. package/lib/auth/token.js +31 -1
  11. package/lib/cli/commands.js +502 -43
  12. package/lib/cli/doctor.js +160 -0
  13. package/lib/cli/fleet-service.js +16 -3
  14. package/lib/cli/init.js +69 -11
  15. package/lib/cli/path.js +24 -0
  16. package/lib/cli/service.js +14 -8
  17. package/lib/cli/url.js +63 -0
  18. package/lib/config.js +5 -0
  19. package/lib/decks/routes.js +81 -0
  20. package/lib/decks/store.js +123 -0
  21. package/lib/files/routes.js +57 -1
  22. package/lib/files/store.js +16 -1
  23. package/lib/fleet/builtin.js +151 -24
  24. package/lib/fleet/definitions.js +26 -0
  25. package/lib/fleet/managed.js +381 -0
  26. package/lib/fleet/routes.js +5 -4
  27. package/lib/mcp/server.js +381 -0
  28. package/lib/nodes/commands.js +411 -0
  29. package/lib/nodes/peering.js +116 -0
  30. package/lib/nodes/store.js +425 -0
  31. package/lib/nodes/tunnel-supervisor.js +102 -0
  32. package/lib/nodes/tunnel.js +315 -0
  33. package/lib/notify/asks.js +150 -0
  34. package/lib/notify/events.js +59 -0
  35. package/lib/notify/notifier.js +37 -0
  36. package/lib/notify/persist.js +73 -0
  37. package/lib/notify/push.js +289 -0
  38. package/lib/notify/routes.js +260 -0
  39. package/lib/proxy/federation.js +217 -0
  40. package/lib/proxy/node-proxy.js +305 -0
  41. package/lib/pty/attach.js +34 -9
  42. package/lib/pty/provider.js +21 -6
  43. package/lib/server.js +291 -14
  44. package/lib/settings/routes.js +685 -0
  45. package/lib/ws/bridge.js +10 -1
  46. package/package.json +8 -2
  47. package/skills/nexuscrew-agent/SKILL.md +83 -0
  48. package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
  49. package/skills/nexuscrew-agent/bin/nc-send +48 -0
  50. package/frontend/dist/assets/index-DUbtTZMj.css +0 -32
  51. package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
@@ -0,0 +1,160 @@
1
+ 'use strict';
2
+ // nexuscrew doctor: auto-diagnosi (design §3, §7). [A2]
3
+ // Struttura ESTENSIBILE: una lista di check-fn, ognuna ritorna
4
+ // { name, ok, warn?, detail? }. B0 aggiungerà i check tunnel/ssh (permitlisten,
5
+ // OpenSSH ≥7.8 sul rendezvous) appendendo alla lista, senza toccare il resto.
6
+ // Exit code: 0 se tutti ok (i warn non falliscono), 1 se almeno un check è FAIL.
7
+ // Nessun segreto nell'output (mai il token, solo il path + i permessi).
8
+ const fs = require('node:fs');
9
+ const os = require('node:os');
10
+ const path = require('node:path');
11
+ const { execFileSync } = require('node:child_process');
12
+ const { detectPlatform, uid } = require('./platform.js');
13
+ const { installPath } = require('./service.js');
14
+ const { resolvePaths } = require('./url.js');
15
+ const { commandExists } = require('./path.js');
16
+
17
+ function nodeMajor() {
18
+ return parseInt(String(process.versions.node).split('.')[0], 10);
19
+ }
20
+
21
+ function checkNode() {
22
+ const maj = nodeMajor();
23
+ return { name: 'node >= 18', ok: maj >= 18, detail: `v${process.versions.node}` };
24
+ }
25
+
26
+ function checkTmux(existsImpl, tmuxBin) {
27
+ return existsImpl(tmuxBin || 'tmux')
28
+ ? { name: 'tmux presente', ok: true }
29
+ : { name: 'tmux presente', ok: false, detail: 'non trovato su PATH (installa tmux)' };
30
+ }
31
+
32
+ function checkPty(ptyLoad) {
33
+ try {
34
+ ptyLoad();
35
+ return { name: 'PTY prebuilt caricabile', ok: true };
36
+ } catch (e) {
37
+ return { name: 'PTY prebuilt caricabile', ok: false, detail: e && e.message ? e.message : 'load fallito' };
38
+ }
39
+ }
40
+
41
+ function checkService(platform, home, execImpl, uidVal, installPathOverride) {
42
+ const target = installPathOverride || installPath(platform, home);
43
+ const installed = fs.existsSync(target);
44
+ let active = false;
45
+ try {
46
+ if (platform === 'linux') {
47
+ const s = execImpl('systemctl', ['--user', 'is-active', 'nexuscrew'], { encoding: 'utf8' });
48
+ active = String(s).trim() === 'active';
49
+ } else if (platform === 'mac') {
50
+ execImpl('launchctl', ['print', `gui/${uidVal}/com.mmmbuto.nexuscrew`], { stdio: 'ignore' });
51
+ active = true;
52
+ } else if (platform === 'termux') {
53
+ const pidf = require('./pidfile.js');
54
+ const meta = pidf.readPidfile(pidf.defaultPidfilePath());
55
+ active = !!(meta && pidf.isAlive(meta));
56
+ }
57
+ } catch (_) { active = false; }
58
+ return {
59
+ name: 'service installato/attivo',
60
+ ok: installed,
61
+ warn: installed && !active, // installato ma non attivo = warning, non fail
62
+ detail: installed ? (active ? 'attivo' : 'installato ma non attivo') : `non installato (${target})`,
63
+ };
64
+ }
65
+
66
+ function checkBoot(platform, home, execImpl) {
67
+ if (platform === 'termux') {
68
+ const p = path.join(home, '.termux', 'boot', 'nexuscrew.sh');
69
+ const ok = fs.existsSync(p);
70
+ return { name: 'boot script', ok, warn: !ok, detail: ok ? p : 'nessun Termux:boot script' };
71
+ }
72
+ if (platform === 'linux') {
73
+ try {
74
+ const s = execImpl('systemctl', ['--user', 'is-enabled', 'nexuscrew'], { encoding: 'utf8' });
75
+ const enabled = String(s).trim() === 'enabled';
76
+ return { name: 'boot (systemd enabled)', ok: true, warn: !enabled, detail: enabled ? 'enabled' : 'non enabled (non parte al boot)' };
77
+ } catch (_) {
78
+ return { name: 'boot (systemd enabled)', ok: true, warn: true, detail: 'non enabled' };
79
+ }
80
+ }
81
+ // mac: RunAtLoad nel plist installato
82
+ const target = installPath('mac', home);
83
+ const ok = fs.existsSync(target);
84
+ return { name: 'boot (launchd RunAtLoad)', ok: true, warn: !ok, detail: ok ? 'plist installato' : 'plist non installato' };
85
+ }
86
+
87
+ // ssh client presente su PATH: prerequisito dei tunnel multi-node (design §4).
88
+ function checkSshClient(existsImpl) {
89
+ return existsImpl('ssh')
90
+ ? { name: 'ssh client presente', ok: true }
91
+ : { name: 'ssh client presente', ok: false, detail: 'ssh non trovato su PATH (necessario per i tunnel multi-node)' };
92
+ }
93
+
94
+ // OpenSSH >=7.8 per permitlisten (vincola i -R lato rendezvous; design §7 (a)).
95
+ // Determinabile e <7.8 -> FAIL (il ruolo node non e' abilitabile). Non
96
+ // determinabile -> WARN (verifica manuale), non blocca la diagnosi generale.
97
+ function checkSshPermitlisten(sshVersionImpl) {
98
+ const tun = require('../nodes/tunnel.js');
99
+ const v = tun.readSshVersion(sshVersionImpl);
100
+ const supp = tun.sshSupportsPermitlisten(v);
101
+ if (supp === null) {
102
+ return { name: 'OpenSSH permitlisten (>=7.8)', ok: true, warn: true, detail: 'versione ssh non determinabile — verifica prima di abilitare il ruolo node' };
103
+ }
104
+ if (supp) return { name: 'OpenSSH permitlisten (>=7.8)', ok: true, detail: v.raw };
105
+ return { name: 'OpenSSH permitlisten (>=7.8)', ok: false, detail: `OpenSSH ${v.major}.${v.minor} < 7.8 — permitlisten non supportato (ruolo node non abilitabile)` };
106
+ }
107
+
108
+ function checkTokenPerms(tokenPath) {
109
+ try {
110
+ const st = fs.lstatSync(tokenPath);
111
+ if (st.isSymbolicLink()) {
112
+ return { name: 'token file permessi', ok: false, detail: 'è un symlink (rifiutato)' };
113
+ }
114
+ const mode = st.mode & 0o777;
115
+ const ok = mode === 0o600;
116
+ return { name: 'token file permessi', ok, detail: `mode 0${mode.toString(8)}${ok ? '' : ' (atteso 0600)'}` };
117
+ } catch (e) {
118
+ if (e.code === 'ENOENT') {
119
+ return { name: 'token file permessi', ok: false, detail: 'token assente (esegui init)' };
120
+ }
121
+ return { name: 'token file permessi', ok: false, detail: e.message };
122
+ }
123
+ }
124
+
125
+ // Esegue tutti i check. Seam iniettabili per test (platform, home, execImpl, ptyLoad).
126
+ function doctor(opts = {}) {
127
+ const platform = opts.platform || detectPlatform();
128
+ const home = opts.home || os.homedir();
129
+ const execImpl = opts.execImpl || execFileSync;
130
+ const uidVal = opts.uid || uid();
131
+ const log = opts.log || console.log;
132
+ const ptyLoad = opts.ptyLoad || (() => require('../pty/provider.js').loadPty());
133
+ const existsImpl = opts.commandExists || commandExists;
134
+ const { tokenPath } = resolvePaths(opts);
135
+
136
+ const checks = [
137
+ checkNode(),
138
+ checkTmux(existsImpl, opts.tmuxBin),
139
+ checkPty(ptyLoad),
140
+ checkService(platform, home, execImpl, uidVal, opts.installPath),
141
+ checkBoot(platform, home, execImpl),
142
+ checkTokenPerms(tokenPath),
143
+ checkSshClient(existsImpl),
144
+ checkSshPermitlisten(opts.sshVersion),
145
+ ];
146
+
147
+ for (const c of checks) {
148
+ const tag = c.ok ? (c.warn ? 'WARN' : 'OK ') : 'FAIL';
149
+ log(`${tag} ${c.name}${c.detail ? ' — ' + c.detail : ''}`);
150
+ }
151
+ const ok = checks.every((c) => c.ok); // i warn non fanno fallire
152
+ log(ok ? 'doctor: tutto ok' : 'doctor: problemi rilevati (vedi FAIL sopra)');
153
+ return { platform, checks, ok, code: ok ? 0 : 1 };
154
+ }
155
+
156
+ module.exports = {
157
+ doctor, nodeMajor,
158
+ checkNode, checkTmux, checkPty, checkService, checkBoot, checkTokenPerms,
159
+ checkSshClient, checkSshPermitlisten,
160
+ };
@@ -74,6 +74,10 @@ function generateFleetMac(opts) {
74
74
  const entryXml = escapeXml(opts.entryPath);
75
75
  const repoXml = escapeXml(repoRoot);
76
76
  const homeXml = escapeXml(home);
77
+ const launchPath = [...new Set([
78
+ path.dirname(opts.nodeBin), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
79
+ ])].join(':');
80
+ const launchPathXml = escapeXml(launchPath);
77
81
  return `<?xml version="1.0" encoding="UTF-8"?>
78
82
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
79
83
  <plist version="1.0">
@@ -88,6 +92,11 @@ function generateFleetMac(opts) {
88
92
  </array>
89
93
  <key>WorkingDirectory</key>
90
94
  <string>${repoXml}</string>
95
+ <key>EnvironmentVariables</key>
96
+ <dict>
97
+ <key>PATH</key>
98
+ <string>${launchPathXml}</string>
99
+ </dict>
91
100
  <key>RunAtLoad</key>
92
101
  <true/>
93
102
  <key>StandardOutPath</key>
@@ -235,10 +244,11 @@ function fleetInstallCommands(platform, target, ctx) {
235
244
  ];
236
245
  }
237
246
  if (platform === 'mac') {
238
- const label = `gui/${ctx.uid || uid()}/com.mmmbuto.nexuscrew-fleet`;
247
+ const domain = `gui/${ctx.uid || uid()}`;
248
+ const label = `${domain}/com.mmmbuto.nexuscrew-fleet`;
239
249
  return [
240
250
  ['launchctl', ['bootout', label]], // idempotente: ignorato se non caricato
241
- ['launchctl', ['bootstrap', label, target]],
251
+ ['launchctl', ['bootstrap', domain, target]],
242
252
  ];
243
253
  }
244
254
  if (platform === 'termux') {
@@ -285,7 +295,10 @@ function installFleetService(platform, content, ctx, { dryRun = false, execImpl
285
295
  const failures = [];
286
296
  for (const [bin, args] of cmds) {
287
297
  try { execImpl(bin, args, { stdio: 'ignore' }); }
288
- catch (e) { failures.push({ cmd: `${bin} ${args.join(' ')}`, error: e.message }); }
298
+ catch (e) {
299
+ if (platform === 'mac' && bin === 'launchctl' && args[0] === 'bootout') continue;
300
+ failures.push({ cmd: `${bin} ${args.join(' ')}`, error: e.message });
301
+ }
289
302
  }
290
303
 
291
304
  return { target, mode, written: true, failures };
package/lib/cli/init.js CHANGED
@@ -8,7 +8,7 @@
8
8
  const fs = require('node:fs');
9
9
  const os = require('node:os');
10
10
  const path = require('node:path');
11
- const { execFileSync } = require('node:child_process');
11
+ const crypto = require('node:crypto');
12
12
  const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
13
13
  const { loadOrCreateToken } = require('../auth/token.js');
14
14
  const { generateService, installService, fileMode, installPath: svcInstallPath } = require('./service.js');
@@ -17,16 +17,36 @@ const {
17
17
  generateFleetService, installFleetService, migrationGate,
18
18
  selectProviderModeSync, fleetFileMode,
19
19
  } = require('./fleet-service.js');
20
+ const { atomicWrite: writeFleet } = require('../fleet/definitions.js');
21
+ const { defaultDefinitions } = require('../fleet/managed.js');
22
+ const { commandExists } = require('./path.js');
20
23
 
21
- function haveTmux(tmuxBin) {
22
- try { execFileSync('command', ['-v', tmuxBin], { stdio: 'ignore', shell: true }); return true; }
23
- catch (_) { return false; }
24
+ function haveTmux(tmuxBin, env = process.env) {
25
+ return commandExists(tmuxBin, env);
24
26
  }
25
27
 
26
28
  function nodeMajor() {
27
29
  return parseInt(String(process.versions.node).split('.')[0], 10);
28
30
  }
29
31
 
32
+ function writeConfigAtomic(configPath, value) {
33
+ try {
34
+ if (fs.lstatSync(configPath).isSymbolicLink()) throw new Error('refusing symlink config target');
35
+ } catch (e) {
36
+ if (e.code !== 'ENOENT') throw e;
37
+ }
38
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
39
+ const tmp = path.join(path.dirname(configPath), `.${path.basename(configPath)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
40
+ try {
41
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
42
+ fs.chmodSync(tmp, 0o600);
43
+ fs.renameSync(tmp, configPath);
44
+ } catch (e) {
45
+ try { fs.unlinkSync(tmp); } catch (_) {}
46
+ throw e;
47
+ }
48
+ }
49
+
30
50
  // Migration rule (B2): se non c'è config.json, parse la porta dal service file esistente.
31
51
  function readExistingPort(platform, home, installPathOverride) {
32
52
  const p = installPathOverride || svcInstallPath(platform, home);
@@ -77,16 +97,36 @@ function runInit(opts = {}) {
77
97
  fs.mkdirSync(configDir, { recursive: true });
78
98
  if (!fs.existsSync(configPath)) {
79
99
  if (!port) port = 41820;
80
- fs.writeFileSync(configPath, JSON.stringify({ port }, null, 2) + '\n', { mode: 0o600 });
100
+ writeConfigAtomic(configPath, { port });
81
101
  actions.push(`created config ${configPath} (port ${port})`);
82
102
  } else {
103
+ let current;
83
104
  try {
84
- const c = JSON.parse(fs.readFileSync(configPath, 'utf8'));
85
- if (c && c.port) port = c.port;
86
- } catch (_) {}
105
+ current = JSON.parse(fs.readFileSync(configPath, 'utf8'));
106
+ } catch (_) { current = {}; }
107
+ if (opts.port) {
108
+ current.port = opts.port;
109
+ port = opts.port;
110
+ writeConfigAtomic(configPath, current);
111
+ actions.push(`updated config ${configPath} (port ${port})`);
112
+ } else if (current && current.port) port = current.port;
87
113
  actions.push(`preserved config ${configPath} (port ${port})`);
88
114
  }
89
115
  }
116
+
117
+ // Fleet app defaults: soltanto i tre client nativi. Provider cloud/Z.AI sono
118
+ // disponibili nel catalogo managed ma vanno aggiunti esplicitamente.
119
+ const fleetDefsPath = opts.fleetDefsPath || path.join(configDir, 'fleet.json');
120
+ if (!dryRun && !fs.existsSync(fleetDefsPath)) {
121
+ try {
122
+ writeFleet(fleetDefsPath, defaultDefinitions());
123
+ actions.push(`created fleet defaults ${fleetDefsPath} (claude.native, codex.native, codex-vl.native)`);
124
+ } catch (e) {
125
+ actions.push(`WARN: fleet defaults non creati: ${e.message}`);
126
+ }
127
+ } else if (!dryRun) {
128
+ actions.push(`preserved fleet definitions ${fleetDefsPath}`);
129
+ }
90
130
  if (!port) port = 41820;
91
131
 
92
132
  // token (preserva esistente) [M4]
@@ -104,6 +144,22 @@ function runInit(opts = {}) {
104
144
  actions.push(`files root ${filesRoot}`);
105
145
  }
106
146
 
147
+ // nodes.json (B0): nodeId STABILE per installazione (generato qui se manca) +
148
+ // migrazione esplicita guarded dei nodes legacy da config.json. READONLY/dry-run
149
+ // saltano; ogni errore -> WARN, MAI far fallire l'init.
150
+ if (!dryRun && process.env.NEXUSCREW_READONLY !== '1' && !opts.readonly) {
151
+ try {
152
+ const nstore = require('../nodes/store.js');
153
+ const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
154
+ const st = nstore.loadOrInitStore(nodesPath);
155
+ actions.push(`nodes.json ok (${nodesPath}, nodeId ${st.nodeId.slice(0, 8)}…)`);
156
+ const mig = nstore.migrateLegacyNodes(configPath, nodesPath);
157
+ if (mig.migrated) actions.push(`nodes: migrati ${mig.count} nodi legacy da config.json`);
158
+ } catch (e) {
159
+ actions.push(`WARN: nodes.json init/migrazione fallita: ${e.message} (init prosegue)`);
160
+ }
161
+ }
162
+
107
163
  // service generation
108
164
  const svcCtx = {
109
165
  repoRoot: repoRoot(),
@@ -153,7 +209,7 @@ function runInit(opts = {}) {
153
209
  fleetBin: opts.fleetBin || path.join(home, '.local', 'bin', 'fleet'),
154
210
  fleetProvider: opts.fleetProvider || process.env.NEXUSCREW_FLEET_PROVIDER,
155
211
  builtinEnabled: opts.builtinEnabled,
156
- fleetDefsPath: opts.fleetDefsPath || path.join(configDir, 'fleet.json'),
212
+ fleetDefsPath,
157
213
  };
158
214
  const sel = selectProvider(fleetCfg) || {};
159
215
  if (sel.mode !== 'builtin') {
@@ -206,11 +262,13 @@ function runInit(opts = {}) {
206
262
  const url = `http://127.0.0.1:${port}/`;
207
263
  const urlWithToken = token ? `${url}#token=${token}` : url;
208
264
  actions.push(`platform: ${platform}`);
209
- actions.push(`URL: ${urlWithToken}`);
265
+ // printUrl:false -> non stampare l'URL col token (usato da smart-up, che presenta
266
+ // URL base + QR da se': cosi' l'output di smart-up non contiene il token in chiaro).
267
+ if (opts.printUrl !== false) actions.push(`URL: ${urlWithToken}`);
210
268
 
211
269
  for (const a of actions) log(a);
212
270
 
213
271
  return { platform, port, token, url: urlWithToken, actions, tmuxOk, dryRun };
214
272
  }
215
273
 
216
- module.exports = { runInit, readExistingPort, haveTmux, nodeMajor };
274
+ module.exports = { runInit, readExistingPort, haveTmux, nodeMajor, writeConfigAtomic };
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+ // Risoluzione eseguibili senza shell: nessun `command -v`, nessuna espansione
3
+ // o concatenazione di input. Supporta path assoluti/espliciti e scan di PATH.
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ function executable(p) {
8
+ try {
9
+ const st = fs.statSync(p);
10
+ if (!st.isFile()) return false;
11
+ fs.accessSync(p, fs.constants.X_OK);
12
+ return true;
13
+ } catch (_) { return false; }
14
+ }
15
+
16
+ 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)));
22
+ }
23
+
24
+ module.exports = { commandExists };
@@ -70,7 +70,6 @@ After=network-online.target
70
70
  [Service]
71
71
  Type=simple
72
72
  WorkingDirectory=${repo}
73
- Environment=NEXUSCREW_PORT=${ctx.port}
74
73
  Environment=PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin
75
74
  ExecStart=${node} ${repoBin} serve
76
75
  Restart=on-failure
@@ -86,7 +85,10 @@ function generateMac(ctx) {
86
85
  const repoBinXml = escapeXml(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
87
86
  const repoXml = escapeXml(ctx.repoRoot);
88
87
  const homeXml = escapeXml(ctx.home);
89
- const port = ctx.port;
88
+ const launchPath = [...new Set([
89
+ path.dirname(ctx.nodeBin), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
90
+ ])].join(':');
91
+ const launchPathXml = escapeXml(launchPath);
90
92
  return `<?xml version="1.0" encoding="UTF-8"?>
91
93
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
92
94
  <plist version="1.0">
@@ -103,8 +105,8 @@ function generateMac(ctx) {
103
105
  <string>${repoXml}</string>
104
106
  <key>EnvironmentVariables</key>
105
107
  <dict>
106
- <key>NEXUSCREW_PORT</key>
107
- <string>${port}</string>
108
+ <key>PATH</key>
109
+ <string>${launchPathXml}</string>
108
110
  </dict>
109
111
  <key>RunAtLoad</key>
110
112
  <true/>
@@ -130,7 +132,6 @@ function generateTermux(ctx) {
130
132
  # NexusCrew boot (Termux) - loopback, localhost del telefono
131
133
  export PATH=/data/data/com.termux/files/usr/bin:$PATH
132
134
  export HOME=/data/data/com.termux/files/home
133
- export NEXUSCREW_PORT=${ctx.port}
134
135
  cd -- ${repoQ}
135
136
  termux-wake-lock 2>/dev/null || true
136
137
  mkdir -p "$HOME/.nexuscrew"
@@ -193,7 +194,11 @@ function installService(platform, content, ctx, { dryRun = false, execImpl = exe
193
194
  const failures = [];
194
195
  for (const [bin, args] of cmds) {
195
196
  try { execImpl(bin, args, { stdio: 'ignore' }); }
196
- catch (e) { failures.push({ cmd: `${bin} ${args.join(' ')}`, error: e.message }); }
197
+ catch (e) {
198
+ // bootout e' idempotente: un job non ancora caricato non e' un errore di installazione.
199
+ if (platform === 'mac' && bin === 'launchctl' && args[0] === 'bootout') continue;
200
+ failures.push({ cmd: `${bin} ${args.join(' ')}`, error: e.message });
201
+ }
197
202
  }
198
203
 
199
204
  return { target, mode, written: true, failures };
@@ -208,11 +213,12 @@ function installCommands(platform, target, ctx) {
208
213
  ];
209
214
  }
210
215
  if (platform === 'mac') {
211
- const label = `gui/${ctx.uid || uid()}/com.mmmbuto.nexuscrew`;
216
+ const domain = `gui/${ctx.uid || uid()}`;
217
+ const label = `${domain}/com.mmmbuto.nexuscrew`;
212
218
  // bootout (ignore se non esiste) poi bootstrap
213
219
  return [
214
220
  ['launchctl', ['bootout', label]],
215
- ['launchctl', ['bootstrap', label, target]],
221
+ ['launchctl', ['bootstrap', domain, target]],
216
222
  ];
217
223
  }
218
224
  if (platform === 'termux') {
package/lib/cli/url.js ADDED
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+ // URL/token helpers per la CLI unificata (design §3). [A2]
3
+ // - resolvePaths: config/token path home-aware (seam per test con HOME temporanea).
4
+ // - readToken: legge il token esistente SENZA crearlo (no-follow symlink), null se assente.
5
+ // - loadPort: porta corrente (opts > env > config.json > default 41820).
6
+ // - buildUrl: URL loopback; withToken aggiunge #token=… (UNICO posto dove il token appare).
7
+ // - renderQr: QR ASCII generato in locale (qrcode-terminal, MIT) — NIENTE fetch a runtime.
8
+ const fs = require('node:fs');
9
+ const os = require('node:os');
10
+ const path = require('node:path');
11
+ const { readTokenSafe } = require('../auth/token.js');
12
+
13
+ const DEFAULT_PORT = 41820;
14
+
15
+ function resolvePaths(opts = {}) {
16
+ const home = opts.home || os.homedir();
17
+ const configDir = opts.configDir || path.join(home, '.nexuscrew');
18
+ return {
19
+ home,
20
+ configDir,
21
+ configPath: opts.configPath || path.join(configDir, 'config.json'),
22
+ tokenPath: opts.tokenPath || path.join(configDir, 'token'),
23
+ };
24
+ }
25
+
26
+ // Legge il token esistente (no create). Symlink -> throw (readTokenSafe); assente -> null.
27
+ function readToken(tokenPath) {
28
+ try {
29
+ return readTokenSafe(tokenPath);
30
+ } catch (e) {
31
+ if (e.code === 'ENOENT') return null; // lstat su path inesistente
32
+ throw e; // symlink / permessi -> propaga
33
+ }
34
+ }
35
+
36
+ // Porta corrente: opts.port > env NEXUSCREW_PORT > config.json > default.
37
+ function loadPort(opts = {}) {
38
+ if (opts.port) return Number(opts.port);
39
+ if (process.env.NEXUSCREW_PORT) return Number(process.env.NEXUSCREW_PORT);
40
+ const { configPath } = resolvePaths(opts);
41
+ try {
42
+ const c = JSON.parse(fs.readFileSync(configPath, 'utf8'));
43
+ if (c && c.port) return Number(c.port);
44
+ } catch (_) {}
45
+ return DEFAULT_PORT;
46
+ }
47
+
48
+ // URL loopback. withToken=true e token presente -> aggiunge #token=… (mai altrove).
49
+ function buildUrl(port, token, { withToken = false } = {}) {
50
+ const base = `http://127.0.0.1:${port}/`;
51
+ return withToken && token ? `${base}#token=${token}` : base;
52
+ }
53
+
54
+ // QR ASCII del testo. Generazione locale (qrcode-terminal), callback sincrona.
55
+ // small=true -> QR compatto (half-block chars) leggibile su terminale mobile/Termux.
56
+ function renderQr(text, opts = {}) {
57
+ const qrcode = opts.qrcode || require('qrcode-terminal');
58
+ let out = '';
59
+ qrcode.generate(String(text), { small: opts.small !== false }, (s) => { out = s; });
60
+ return out;
61
+ }
62
+
63
+ module.exports = { resolvePaths, readToken, loadPort, buildUrl, renderQr, DEFAULT_PORT };
package/lib/config.js CHANGED
@@ -20,6 +20,8 @@ function baseDefaults() {
20
20
  tokenPath: path.join(os.homedir(), '.nexuscrew', 'token'),
21
21
  tmuxBin: 'tmux',
22
22
  readonlyDefault: false,
23
+ // Etichetta neutra usata nel prefisso delle risposte ask incollate in TUI.
24
+ replyLabel: 'human',
23
25
  filesRoot: path.join(os.homedir(), 'NexusFiles'),
24
26
  maxUpload: 100 * 1024 * 1024,
25
27
  voiceUrl: null,
@@ -27,6 +29,7 @@ function baseDefaults() {
27
29
  voiceTokenFile: null,
28
30
  fleetEnabled: true,
29
31
  fleetBin: path.join(os.homedir(), '.local', 'bin', 'fleet'),
32
+ providerSecretsPath: path.join(os.homedir(), '.nexuscrew', 'providers.env'),
30
33
  sessionPresets: {},
31
34
  };
32
35
  }
@@ -51,6 +54,7 @@ function envOverrides() {
51
54
  if (process.env.NEXUSCREW_TOKEN_FILE) e.tokenPath = process.env.NEXUSCREW_TOKEN_FILE;
52
55
  if (process.env.NEXUSCREW_TMUX) e.tmuxBin = process.env.NEXUSCREW_TMUX;
53
56
  if (process.env.NEXUSCREW_READONLY) e.readonlyDefault = process.env.NEXUSCREW_READONLY === '1';
57
+ if (process.env.NEXUSCREW_REPLY_LABEL) e.replyLabel = process.env.NEXUSCREW_REPLY_LABEL;
54
58
  if (process.env.NEXUSCREW_FILES_ROOT) e.filesRoot = process.env.NEXUSCREW_FILES_ROOT;
55
59
  if (process.env.NEXUSCREW_MAX_UPLOAD_MB) e.maxUpload = Number(process.env.NEXUSCREW_MAX_UPLOAD_MB) * 1024 * 1024;
56
60
  if (process.env.NEXUSCREW_VOICE_URL) e.voiceUrl = process.env.NEXUSCREW_VOICE_URL;
@@ -58,6 +62,7 @@ function envOverrides() {
58
62
  if (process.env.NEXUSCREW_VOICE_TOKEN_FILE) e.voiceTokenFile = process.env.NEXUSCREW_VOICE_TOKEN_FILE;
59
63
  if (process.env.NEXUSCREW_FLEET) e.fleetEnabled = process.env.NEXUSCREW_FLEET !== '0';
60
64
  if (process.env.NEXUSCREW_FLEET_BIN) e.fleetBin = process.env.NEXUSCREW_FLEET_BIN;
65
+ if (process.env.NEXUSCREW_PROVIDER_SECRETS) e.providerSecretsPath = process.env.NEXUSCREW_PROVIDER_SECRETS;
61
66
  return e;
62
67
  }
63
68
 
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+ const express = require('express');
3
+ const fs = require('node:fs');
4
+ const store = require('./store.js');
5
+
6
+ function httpError(status, message, extra) {
7
+ const e = new Error(message); e.status = status; e.extra = extra; return e;
8
+ }
9
+
10
+ function decksRoutes({ cfg = {}, decksPath }) {
11
+ const r = express.Router();
12
+ r.use(express.json({ limit: '64kb' }));
13
+ const readonly = () => cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1';
14
+ const mutate = (fn) => (req, res) => {
15
+ if (readonly()) return res.status(403).json({ error: 'READONLY: mutazione deck bloccata' });
16
+ try { return fn(req, res); }
17
+ catch (e) { return res.status(e.status || 500).json({ error: String(e.message || e), ...(e.extra || {}) }); }
18
+ };
19
+ const read = () => {
20
+ const found = store.loadStore(decksPath);
21
+ if (found) return found;
22
+ if (readonly() && !fs.existsSync(decksPath)) return store.emptyStore();
23
+ return store.loadOrCreate(decksPath);
24
+ };
25
+ const expected = (body) => {
26
+ const n = body && body.expectedRevision;
27
+ if (!Number.isSafeInteger(n) || n < 0) throw httpError(400, 'expectedRevision non valida');
28
+ return n;
29
+ };
30
+ const find = (st, name) => st.decks.find((d) => d.name === name) || null;
31
+ const checkRevision = (deck, rev) => {
32
+ if (deck.revision !== rev) throw httpError(409, 'deck modificato da un’altra finestra', { current: deck });
33
+ };
34
+
35
+ r.get('/', (_req, res) => {
36
+ try { res.json(read()); } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
37
+ });
38
+ r.post('/', mutate((req, res) => {
39
+ const name = String(req.body && req.body.name || '');
40
+ if (!store.NAME_RE.test(name)) throw httpError(400, 'nome deck non valido');
41
+ const st = read();
42
+ if (find(st, name)) throw httpError(409, `deck già esistente: ${name}`);
43
+ if (st.decks.length >= store.MAX_DECKS) throw httpError(400, 'limite deck raggiunto');
44
+ const deck = { name, revision: 0, layout: { columns: [] } };
45
+ st.decks.push(deck); store.atomicWrite(decksPath, st);
46
+ res.status(201).json(deck);
47
+ }));
48
+ r.put('/:name', mutate((req, res) => {
49
+ const st = read(); const deck = find(st, String(req.params.name || ''));
50
+ if (!deck) throw httpError(404, 'deck inesistente');
51
+ checkRevision(deck, expected(req.body));
52
+ const layout = store.parseLayout(req.body && req.body.layout);
53
+ if (!layout) throw httpError(400, 'layout deck non valido');
54
+ deck.layout = layout; deck.revision += 1;
55
+ store.atomicWrite(decksPath, st); res.json(deck);
56
+ }));
57
+ r.patch('/:name', mutate((req, res) => {
58
+ const oldName = String(req.params.name || '');
59
+ if (oldName === 'main') throw httpError(400, 'main non rinominabile');
60
+ const newName = String(req.body && req.body.name || '');
61
+ if (!store.NAME_RE.test(newName)) throw httpError(400, 'nome deck non valido');
62
+ const st = read(); const deck = find(st, oldName);
63
+ if (!deck) throw httpError(404, 'deck inesistente');
64
+ checkRevision(deck, expected(req.body));
65
+ if (find(st, newName)) throw httpError(409, `deck già esistente: ${newName}`);
66
+ deck.name = newName; deck.revision += 1;
67
+ store.atomicWrite(decksPath, st); res.json(deck);
68
+ }));
69
+ r.delete('/:name', mutate((req, res) => {
70
+ const name = String(req.params.name || '');
71
+ if (name === 'main') throw httpError(400, 'main non eliminabile');
72
+ const st = read(); const deck = find(st, name);
73
+ if (!deck) throw httpError(404, 'deck inesistente');
74
+ checkRevision(deck, expected(req.body));
75
+ st.decks = st.decks.filter((d) => d.name !== name);
76
+ store.atomicWrite(decksPath, st); res.json({ removed: true, name });
77
+ }));
78
+ return r;
79
+ }
80
+
81
+ module.exports = { decksRoutes };