@mmmbuto/nexuscrew 0.7.6 → 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 (59) hide show
  1. package/README.md +173 -18
  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 +462 -31
  10. package/lib/cli/doctor.js +160 -0
  11. package/lib/cli/fleet-service.js +319 -0
  12. package/lib/cli/init.js +107 -7
  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 +59 -2
  20. package/lib/files/store.js +16 -1
  21. package/lib/fleet/boot.js +62 -0
  22. package/lib/fleet/builtin.js +594 -0
  23. package/lib/fleet/definitions.js +410 -0
  24. package/lib/fleet/index.js +49 -10
  25. package/lib/fleet/managed.js +211 -0
  26. package/lib/fleet/provider.js +102 -0
  27. package/lib/fleet/routes.js +78 -8
  28. package/lib/fs/routes.js +56 -0
  29. package/lib/mcp/server.js +362 -0
  30. package/lib/nodes/commands.js +396 -0
  31. package/lib/nodes/store.js +358 -0
  32. package/lib/nodes/tunnel-supervisor.js +102 -0
  33. package/lib/nodes/tunnel.js +300 -0
  34. package/lib/notify/asks.js +150 -0
  35. package/lib/notify/events.js +59 -0
  36. package/lib/notify/notifier.js +37 -0
  37. package/lib/notify/persist.js +73 -0
  38. package/lib/notify/push.js +289 -0
  39. package/lib/notify/routes.js +260 -0
  40. package/lib/proxy/node-proxy.js +292 -0
  41. package/lib/pty/attach.js +34 -9
  42. package/lib/pty/provider.js +21 -6
  43. package/lib/server.js +214 -15
  44. package/lib/settings/routes.js +473 -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-BWhSqR3J.css +0 -32
  51. package/frontend/dist/assets/index-Bx8vdLZY.css +0 -32
  52. package/frontend/dist/assets/index-CLb2xmyu.js +0 -81
  53. package/frontend/dist/assets/index-CgxfkmRo.js +0 -81
  54. package/frontend/dist/assets/index-DE67kR0M.js +0 -81
  55. package/frontend/dist/assets/index-DQMx2p4x.js +0 -81
  56. package/frontend/dist/assets/index-DWhfVwKW.css +0 -32
  57. package/frontend/dist/assets/index-DoPZ_3-h.js +0 -81
  58. package/frontend/dist/assets/index-o9l7pPAf.css +0 -32
  59. package/frontend/dist/assets/index-q9PUrGO0.js +0 -81
@@ -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
+ };
@@ -1,10 +1,15 @@
1
1
  'use strict';
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const express = require('express');
2
6
  const { Router } = require('express');
3
7
  const multer = require('multer');
4
8
  const store = require('./store.js');
5
9
 
6
10
  // Router file-exchange. Nessuno stato: tutto deriva da cfg + filesystem.
7
- function filesRoutes({ cfg, sessionExists, paste }) {
11
+ // notifier (opzionale, MCP bridge): emette la notify di consegna file outbox.
12
+ function filesRoutes({ cfg, sessionExists, paste, notifier }) {
8
13
  const router = Router();
9
14
  const upload = multer({
10
15
  storage: multer.memoryStorage(),
@@ -23,11 +28,63 @@ function filesRoutes({ cfg, sessionExists, paste }) {
23
28
  return res.status(404).json({ error: 'sessione tmux inesistente' });
24
29
  }
25
30
  const saved = store.saveUpload(cfg.filesRoot, session, req.file.buffer, req.file.originalname);
26
- const pasted = await paste(session, saved.path);
31
+ // paste=false (tasto allegati del composer): il client appende il path al
32
+ // testo del composer — niente scrittura PTY. Default = incolla (FilesPanel).
33
+ const wantPaste = String((req.body && req.body.paste) || '') !== 'false';
34
+ const pasted = wantPaste ? await paste(session, saved.path) : false;
27
35
  res.json({ ...saved, pasted });
28
36
  });
29
37
  });
30
38
 
39
+ // Consegna deliverable dal MCP bridge (nc_send_file): copia un file locale
40
+ // nell'outbox della sessione mittente. Path sorgente fail-closed: stringa
41
+ // assoluta, realpath ESISTENTE sotto HOME (i symlink si risolvono PRIMA del
42
+ // check: un link che esce da HOME viene rifiutato), file regolare.
43
+ // F3 (audit): gated READONLY — la copia e' una scrittura su disco; il gate
44
+ // sta PRIMA di ogni altro check (nessun probe di sessione/path in READONLY).
45
+ const bridgeReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
46
+ router.post('/outbox', express.json({ limit: '8kb' }), (req, res) => {
47
+ try {
48
+ if (bridgeReadonly()) {
49
+ return res.status(403).json({ error: 'READONLY: consegna file bloccata' });
50
+ }
51
+ const b = req.body || {};
52
+ const session = String(b.session || '');
53
+ if (!store.isValidSession(session) || !sessionExists(session)) {
54
+ return res.status(404).json({ error: 'sessione tmux inesistente' });
55
+ }
56
+ if (typeof b.path !== 'string' || !path.isAbsolute(b.path)) {
57
+ return res.status(400).json({ error: 'path deve essere assoluto' });
58
+ }
59
+ if (b.caption !== undefined && (typeof b.caption !== 'string' || b.caption.length > 500)) {
60
+ return res.status(400).json({ error: 'caption deve essere una stringa (max 500)' });
61
+ }
62
+ const home = cfg.home || os.homedir();
63
+ let src;
64
+ try { src = fs.realpathSync(b.path); } catch (_) {
65
+ return res.status(404).json({ error: 'file sorgente inesistente' });
66
+ }
67
+ const homeReal = fs.realpathSync(home);
68
+ if (src !== homeReal && !src.startsWith(homeReal + path.sep)) {
69
+ return res.status(400).json({ error: 'path fuori da HOME' });
70
+ }
71
+ if (!fs.statSync(src).isFile()) {
72
+ return res.status(400).json({ error: 'path non e\' un file regolare' });
73
+ }
74
+ const saved = store.saveDeliverable(cfg.filesRoot, session, src);
75
+ if (!saved) return res.status(400).json({ error: 'sessione invalida' });
76
+ // Notify best-effort (il badge outbox lo genera comunque il watcher).
77
+ if (notifier) {
78
+ notifier.emit({
79
+ title: `file da ${session}`,
80
+ body: b.caption ? `${saved.name} — ${b.caption}` : saved.name,
81
+ session,
82
+ }).catch(() => {});
83
+ }
84
+ res.json({ name: saved.name, box: 'outbox', size: saved.size });
85
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
86
+ });
87
+
31
88
  router.get('/', (req, res) => {
32
89
  const session = String(req.query.session || '');
33
90
  if (!store.isValidSession(session)) return res.status(400).json({ error: 'sessione invalida' });
@@ -77,6 +77,21 @@ function saveUpload(root, session, buffer, origName, now = new Date()) {
77
77
  return { name, path: full, size: buffer.length };
78
78
  }
79
79
 
80
+ // Consegna MCP bridge: copia un file gia' su disco nell'outbox della sessione
81
+ // (badge/watcher scattano da soli). Naming identico a saveUpload (stamp +
82
+ // sanitize + dedup) e COPYFILE_EXCL: mai overwrite. La validazione del path
83
+ // sorgente (assoluto, sotto HOME, realpath) sta nella route, non qui.
84
+ function saveDeliverable(root, session, srcPath, now = new Date()) {
85
+ const dir = ensureBox(root, session, 'outbox');
86
+ if (!dir) return null;
87
+ const base = `${stamp(now)}_${sanitizeName(path.basename(srcPath))}`;
88
+ let name = base;
89
+ for (let i = 1; fs.existsSync(path.join(dir, name)); i += 1) name = `${i}-${base}`;
90
+ const full = path.join(dir, name);
91
+ fs.copyFileSync(srcPath, full, fs.constants.COPYFILE_EXCL);
92
+ return { name, path: full, size: fs.statSync(full).size };
93
+ }
94
+
80
95
  function listBox(root, session, box) {
81
96
  const dir = boxDir(root, session, box);
82
97
  if (!dir) return null;
@@ -123,5 +138,5 @@ function removeFile(root, session, box, name) {
123
138
 
124
139
  module.exports = {
125
140
  isValidSession, sanitizeName, stamp, boxDir, ensureBox,
126
- saveUpload, listBox, resolveExisting, removeFile, BOXES,
141
+ saveUpload, saveDeliverable, listBox, resolveExisting, removeFile, BOXES,
127
142
  };
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+ // B4.3 — Service companion boot. bootCells(fleet, {log}) avvia tutte le celle
3
+ // boot:true di un fleet provider (builtin) chiamandone up(). Una cella gia' in
4
+ // esecuzione (up -> httpError 409 / "duplicate session") = SKIP non fatale;
5
+ // ogni altro errore viene raccolto in failed[] e NON ferma le altre celle.
6
+ // Nessun side-effect all'import. Design §4c / §9b.
7
+ //
8
+ // Agnostico: usa solo il contratto fleet {available, status(), up()} — non legge
9
+ // internals delle definizioni. La lista delle celle boot:true arriva da status()
10
+ // (campo `boot` derivato dalle definitions), cosi' un fleet esterno compatibile
11
+ // potra' riusare la stessa funzione.
12
+
13
+ const SKIP_STATUS = 409; // up() del builtin lancia httpError(409) su sessione duplicata
14
+
15
+ async function bootCells(fleet, { log = () => {} } = {}) {
16
+ const started = [];
17
+ const skipped = [];
18
+ const failed = [];
19
+
20
+ if (!fleet || fleet.available === false) {
21
+ // provider non disponibile: niente da avviare. Il caller (runFleetBoot)
22
+ // filtra gia' mode==='builtin' (= fleet.available); qui e' solo defense-in-depth.
23
+ return { started, skipped, failed };
24
+ }
25
+
26
+ let cells = [];
27
+ try {
28
+ const st = await fleet.status();
29
+ cells = (st && Array.isArray(st.cells)) ? st.cells : [];
30
+ } catch (e) {
31
+ // status e' una lettura: se fallisce non possiamo sapere cosa avviare.
32
+ // Riportiamo come fallimento globale senza processare alcuna cella.
33
+ failed.push({ cell: '*', reason: `status() failed: ${e && e.message ? e.message : e}` });
34
+ return { started, skipped, failed };
35
+ }
36
+
37
+ for (const c of cells) {
38
+ if (!c || !c.boot) continue; // solo boot:true
39
+ const id = c.cell;
40
+ try {
41
+ await fleet.up(id);
42
+ started.push(id);
43
+ log(`fleet-boot: started ${id}`);
44
+ } catch (e) {
45
+ const status = e && typeof e.status === 'number' ? e.status : null;
46
+ const reason = (e && e.message) ? e.message : String(e);
47
+ // gia' in esecuzione: skip non fatale (contratto builtin: 409/duplicate).
48
+ // Il regex copre anche fleet esterni che lanciano un Error generico.
49
+ if (status === SKIP_STATUS || /duplicate session|già in esecuzione/i.test(reason)) {
50
+ skipped.push(id);
51
+ log(`fleet-boot: skipped ${id} (already running)`);
52
+ } else {
53
+ failed.push({ cell: id, reason });
54
+ log(`fleet-boot: failed ${id} — ${reason}`);
55
+ }
56
+ }
57
+ }
58
+
59
+ return { started, skipped, failed };
60
+ }
61
+
62
+ module.exports = { bootCells };