@mmmbuto/nexuscrew 0.7.7 → 0.8.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 (47) hide show
  1. package/README.md +156 -20
  2. package/frontend/dist/assets/index-BBOgTCoO.css +32 -0
  3. package/frontend/dist/assets/index-DQrDBogT.js +83 -0
  4. package/frontend/dist/index.html +2 -2
  5. package/frontend/dist/sw.js +29 -0
  6. package/frontend/dist/version.json +1 -0
  7. package/lib/auth/middleware.js +7 -1
  8. package/lib/auth/token.js +31 -1
  9. package/lib/cli/commands.js +415 -31
  10. package/lib/cli/doctor.js +160 -0
  11. package/lib/cli/fleet-service.js +16 -3
  12. package/lib/cli/init.js +39 -6
  13. package/lib/cli/path.js +24 -0
  14. package/lib/cli/service.js +14 -3
  15. package/lib/cli/url.js +63 -0
  16. package/lib/config.js +5 -0
  17. package/lib/decks/routes.js +81 -0
  18. package/lib/decks/store.js +117 -0
  19. package/lib/files/routes.js +55 -1
  20. package/lib/files/store.js +16 -1
  21. package/lib/fleet/builtin.js +141 -24
  22. package/lib/fleet/definitions.js +26 -0
  23. package/lib/fleet/managed.js +211 -0
  24. package/lib/fleet/routes.js +5 -4
  25. package/lib/mcp/server.js +362 -0
  26. package/lib/nodes/commands.js +396 -0
  27. package/lib/nodes/store.js +358 -0
  28. package/lib/nodes/tunnel-supervisor.js +102 -0
  29. package/lib/nodes/tunnel.js +300 -0
  30. package/lib/notify/asks.js +150 -0
  31. package/lib/notify/events.js +59 -0
  32. package/lib/notify/notifier.js +37 -0
  33. package/lib/notify/persist.js +73 -0
  34. package/lib/notify/push.js +289 -0
  35. package/lib/notify/routes.js +260 -0
  36. package/lib/proxy/node-proxy.js +292 -0
  37. package/lib/pty/attach.js +34 -9
  38. package/lib/pty/provider.js +21 -6
  39. package/lib/server.js +206 -12
  40. package/lib/settings/routes.js +473 -0
  41. package/lib/ws/bridge.js +10 -1
  42. package/package.json +7 -1
  43. package/skills/nexuscrew-agent/SKILL.md +83 -0
  44. package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
  45. package/skills/nexuscrew-agent/bin/nc-send +48 -0
  46. package/frontend/dist/assets/index-DUbtTZMj.css +0 -32
  47. 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,6 @@
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');
12
11
  const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
13
12
  const { loadOrCreateToken } = require('../auth/token.js');
14
13
  const { generateService, installService, fileMode, installPath: svcInstallPath } = require('./service.js');
@@ -17,10 +16,12 @@ const {
17
16
  generateFleetService, installFleetService, migrationGate,
18
17
  selectProviderModeSync, fleetFileMode,
19
18
  } = require('./fleet-service.js');
19
+ const { atomicWrite: writeFleet } = require('../fleet/definitions.js');
20
+ const { defaultDefinitions } = require('../fleet/managed.js');
21
+ const { commandExists } = require('./path.js');
20
22
 
21
- function haveTmux(tmuxBin) {
22
- try { execFileSync('command', ['-v', tmuxBin], { stdio: 'ignore', shell: true }); return true; }
23
- catch (_) { return false; }
23
+ function haveTmux(tmuxBin, env = process.env) {
24
+ return commandExists(tmuxBin, env);
24
25
  }
25
26
 
26
27
  function nodeMajor() {
@@ -87,6 +88,20 @@ function runInit(opts = {}) {
87
88
  actions.push(`preserved config ${configPath} (port ${port})`);
88
89
  }
89
90
  }
91
+
92
+ // Fleet app defaults: soltanto i due client nativi. Provider cloud/Z.AI sono
93
+ // disponibili nel catalogo managed ma vanno aggiunti esplicitamente.
94
+ const fleetDefsPath = opts.fleetDefsPath || path.join(configDir, 'fleet.json');
95
+ if (!dryRun && !fs.existsSync(fleetDefsPath)) {
96
+ try {
97
+ writeFleet(fleetDefsPath, defaultDefinitions());
98
+ actions.push(`created fleet defaults ${fleetDefsPath} (claude.native, codex-vl.native)`);
99
+ } catch (e) {
100
+ actions.push(`WARN: fleet defaults non creati: ${e.message}`);
101
+ }
102
+ } else if (!dryRun) {
103
+ actions.push(`preserved fleet definitions ${fleetDefsPath}`);
104
+ }
90
105
  if (!port) port = 41820;
91
106
 
92
107
  // token (preserva esistente) [M4]
@@ -104,6 +119,22 @@ function runInit(opts = {}) {
104
119
  actions.push(`files root ${filesRoot}`);
105
120
  }
106
121
 
122
+ // nodes.json (B0): nodeId STABILE per installazione (generato qui se manca) +
123
+ // migrazione esplicita guarded dei nodes legacy da config.json. READONLY/dry-run
124
+ // saltano; ogni errore -> WARN, MAI far fallire l'init.
125
+ if (!dryRun && process.env.NEXUSCREW_READONLY !== '1' && !opts.readonly) {
126
+ try {
127
+ const nstore = require('../nodes/store.js');
128
+ const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
129
+ const st = nstore.loadOrInitStore(nodesPath);
130
+ actions.push(`nodes.json ok (${nodesPath}, nodeId ${st.nodeId.slice(0, 8)}…)`);
131
+ const mig = nstore.migrateLegacyNodes(configPath, nodesPath);
132
+ if (mig.migrated) actions.push(`nodes: migrati ${mig.count} nodi legacy da config.json`);
133
+ } catch (e) {
134
+ actions.push(`WARN: nodes.json init/migrazione fallita: ${e.message} (init prosegue)`);
135
+ }
136
+ }
137
+
107
138
  // service generation
108
139
  const svcCtx = {
109
140
  repoRoot: repoRoot(),
@@ -153,7 +184,7 @@ function runInit(opts = {}) {
153
184
  fleetBin: opts.fleetBin || path.join(home, '.local', 'bin', 'fleet'),
154
185
  fleetProvider: opts.fleetProvider || process.env.NEXUSCREW_FLEET_PROVIDER,
155
186
  builtinEnabled: opts.builtinEnabled,
156
- fleetDefsPath: opts.fleetDefsPath || path.join(configDir, 'fleet.json'),
187
+ fleetDefsPath,
157
188
  };
158
189
  const sel = selectProvider(fleetCfg) || {};
159
190
  if (sel.mode !== 'builtin') {
@@ -206,7 +237,9 @@ function runInit(opts = {}) {
206
237
  const url = `http://127.0.0.1:${port}/`;
207
238
  const urlWithToken = token ? `${url}#token=${token}` : url;
208
239
  actions.push(`platform: ${platform}`);
209
- actions.push(`URL: ${urlWithToken}`);
240
+ // printUrl:false -> non stampare l'URL col token (usato da smart-up, che presenta
241
+ // URL base + QR da se': cosi' l'output di smart-up non contiene il token in chiaro).
242
+ if (opts.printUrl !== false) actions.push(`URL: ${urlWithToken}`);
210
243
 
211
244
  for (const a of actions) log(a);
212
245
 
@@ -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 };
@@ -87,6 +87,10 @@ function generateMac(ctx) {
87
87
  const repoXml = escapeXml(ctx.repoRoot);
88
88
  const homeXml = escapeXml(ctx.home);
89
89
  const port = ctx.port;
90
+ const launchPath = [...new Set([
91
+ path.dirname(ctx.nodeBin), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
92
+ ])].join(':');
93
+ const launchPathXml = escapeXml(launchPath);
90
94
  return `<?xml version="1.0" encoding="UTF-8"?>
91
95
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
92
96
  <plist version="1.0">
@@ -105,6 +109,8 @@ function generateMac(ctx) {
105
109
  <dict>
106
110
  <key>NEXUSCREW_PORT</key>
107
111
  <string>${port}</string>
112
+ <key>PATH</key>
113
+ <string>${launchPathXml}</string>
108
114
  </dict>
109
115
  <key>RunAtLoad</key>
110
116
  <true/>
@@ -193,7 +199,11 @@ function installService(platform, content, ctx, { dryRun = false, execImpl = exe
193
199
  const failures = [];
194
200
  for (const [bin, args] of cmds) {
195
201
  try { execImpl(bin, args, { stdio: 'ignore' }); }
196
- catch (e) { failures.push({ cmd: `${bin} ${args.join(' ')}`, error: e.message }); }
202
+ catch (e) {
203
+ // bootout e' idempotente: un job non ancora caricato non e' un errore di installazione.
204
+ if (platform === 'mac' && bin === 'launchctl' && args[0] === 'bootout') continue;
205
+ failures.push({ cmd: `${bin} ${args.join(' ')}`, error: e.message });
206
+ }
197
207
  }
198
208
 
199
209
  return { target, mode, written: true, failures };
@@ -208,11 +218,12 @@ function installCommands(platform, target, ctx) {
208
218
  ];
209
219
  }
210
220
  if (platform === 'mac') {
211
- const label = `gui/${ctx.uid || uid()}/com.mmmbuto.nexuscrew`;
221
+ const domain = `gui/${ctx.uid || uid()}`;
222
+ const label = `${domain}/com.mmmbuto.nexuscrew`;
212
223
  // bootout (ignore se non esiste) poi bootstrap
213
224
  return [
214
225
  ['launchctl', ['bootout', label]],
215
- ['launchctl', ['bootstrap', label, target]],
226
+ ['launchctl', ['bootstrap', domain, target]],
216
227
  ];
217
228
  }
218
229
  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 };
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const os = require('node:os');
5
+ const crypto = require('node:crypto');
6
+
7
+ const SCHEMA_VERSION = 1;
8
+ const MAX_DECKS = 24;
9
+ const MAX_TILES = 9;
10
+ const NAME_RE = /^[a-z0-9-]{1,32}$/;
11
+ const NODE_RE = /^[a-z0-9-]{1,32}$/;
12
+
13
+ function defaultDecksPath(home) {
14
+ return path.join(home || os.homedir(), '.nexuscrew', 'decks.json');
15
+ }
16
+
17
+ function emptyLayout() { return { columns: [] }; }
18
+ function emptyStore() {
19
+ return { schemaVersion: SCHEMA_VERSION, decks: [{ name: 'main', revision: 0, layout: emptyLayout() }] };
20
+ }
21
+
22
+ function validText(s, max) {
23
+ if (typeof s !== 'string' || !s || s.length > max) return false;
24
+ for (let i = 0; i < s.length; i += 1) {
25
+ const c = s.charCodeAt(i);
26
+ if (c <= 0x1f || c === 0x7f) return false;
27
+ }
28
+ return true;
29
+ }
30
+
31
+ function parseLayout(raw) {
32
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !Array.isArray(raw.columns)) return null;
33
+ if (raw.columns.length > MAX_TILES) return null;
34
+ const seen = new Set();
35
+ const columns = [];
36
+ let count = 0;
37
+ for (const c of raw.columns) {
38
+ if (!c || typeof c !== 'object' || Array.isArray(c) || !Array.isArray(c.tiles) || !c.tiles.length) return null;
39
+ const width = Number(c.width);
40
+ if (!Number.isFinite(width) || width < 0.2 || width > 100) return null;
41
+ const tiles = [];
42
+ for (const t of c.tiles) {
43
+ if (!t || typeof t !== 'object' || Array.isArray(t) || !validText(t.session, 128)) return null;
44
+ if (t.node !== undefined && (typeof t.node !== 'string' || !NODE_RE.test(t.node))) return null;
45
+ const height = Number(t.height);
46
+ const fontSize = Number(t.fontSize);
47
+ if (!Number.isFinite(height) || height < 0.2 || height > 100) return null;
48
+ if (!Number.isFinite(fontSize) || fontSize < 9 || fontSize > 24) return null;
49
+ const key = t.node ? `${t.node}:${t.session}` : t.session;
50
+ if (seen.has(key) || ++count > MAX_TILES) return null;
51
+ seen.add(key);
52
+ const tile = { session: t.session, height, fontSize };
53
+ if (t.node) tile.node = t.node;
54
+ tiles.push(tile);
55
+ }
56
+ columns.push({ width, tiles });
57
+ }
58
+ return { columns };
59
+ }
60
+
61
+ function parseStore(raw) {
62
+ try {
63
+ const obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
64
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)
65
+ || obj.schemaVersion !== SCHEMA_VERSION || !Array.isArray(obj.decks)
66
+ || obj.decks.length < 1 || obj.decks.length > MAX_DECKS) return null;
67
+ const names = new Set();
68
+ const decks = [];
69
+ for (const d of obj.decks) {
70
+ if (!d || typeof d !== 'object' || !NAME_RE.test(d.name) || names.has(d.name)) return null;
71
+ if (!Number.isSafeInteger(d.revision) || d.revision < 0) return null;
72
+ const layout = parseLayout(d.layout);
73
+ if (!layout) return null;
74
+ names.add(d.name);
75
+ decks.push({ name: d.name, revision: d.revision, layout });
76
+ }
77
+ if (!names.has('main')) return null;
78
+ decks.sort((a, b) => (a.name === 'main' ? -1 : b.name === 'main' ? 1 : a.name.localeCompare(b.name)));
79
+ return { schemaVersion: SCHEMA_VERSION, decks };
80
+ } catch (_) { return null; }
81
+ }
82
+
83
+ function loadStore(p) {
84
+ try {
85
+ const st = fs.lstatSync(p);
86
+ if (!st.isFile() || st.isSymbolicLink()) return null;
87
+ return parseStore(fs.readFileSync(p, 'utf8'));
88
+ } catch (_) { return null; }
89
+ }
90
+
91
+ function atomicWrite(p, data) {
92
+ try {
93
+ if (fs.lstatSync(p).isSymbolicLink()) throw new Error('refuse symlink decks.json');
94
+ } catch (e) { if (e.code !== 'ENOENT') throw e; }
95
+ const parsed = parseStore(data);
96
+ if (!parsed) throw new Error('decks.json non valido');
97
+ fs.mkdirSync(path.dirname(p), { recursive: true });
98
+ const tmp = path.join(path.dirname(p), `.${path.basename(p)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
99
+ try {
100
+ fs.writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
101
+ fs.chmodSync(tmp, 0o600);
102
+ fs.renameSync(tmp, p);
103
+ } catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} throw e; }
104
+ return parsed;
105
+ }
106
+
107
+ function loadOrCreate(p) {
108
+ const found = loadStore(p);
109
+ if (found) return found;
110
+ try { if (fs.existsSync(p)) throw new Error('decks.json presente ma invalido'); } catch (e) { throw e; }
111
+ return atomicWrite(p, emptyStore());
112
+ }
113
+
114
+ module.exports = {
115
+ SCHEMA_VERSION, MAX_DECKS, MAX_TILES, NAME_RE,
116
+ defaultDecksPath, emptyStore, parseLayout, parseStore, loadStore, loadOrCreate, atomicWrite,
117
+ };