@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.
- package/README.md +156 -20
- package/frontend/dist/assets/index-BBOgTCoO.css +32 -0
- package/frontend/dist/assets/index-DQrDBogT.js +83 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +29 -0
- package/frontend/dist/version.json +1 -0
- package/lib/auth/middleware.js +7 -1
- package/lib/auth/token.js +31 -1
- package/lib/cli/commands.js +415 -31
- package/lib/cli/doctor.js +160 -0
- package/lib/cli/fleet-service.js +16 -3
- package/lib/cli/init.js +39 -6
- package/lib/cli/path.js +24 -0
- package/lib/cli/service.js +14 -3
- package/lib/cli/url.js +63 -0
- package/lib/config.js +5 -0
- package/lib/decks/routes.js +81 -0
- package/lib/decks/store.js +117 -0
- package/lib/files/routes.js +55 -1
- package/lib/files/store.js +16 -1
- package/lib/fleet/builtin.js +141 -24
- package/lib/fleet/definitions.js +26 -0
- package/lib/fleet/managed.js +211 -0
- package/lib/fleet/routes.js +5 -4
- package/lib/mcp/server.js +362 -0
- package/lib/nodes/commands.js +396 -0
- package/lib/nodes/store.js +358 -0
- package/lib/nodes/tunnel-supervisor.js +102 -0
- package/lib/nodes/tunnel.js +300 -0
- package/lib/notify/asks.js +150 -0
- package/lib/notify/events.js +59 -0
- package/lib/notify/notifier.js +37 -0
- package/lib/notify/persist.js +73 -0
- package/lib/notify/push.js +289 -0
- package/lib/notify/routes.js +260 -0
- package/lib/proxy/node-proxy.js +292 -0
- package/lib/pty/attach.js +34 -9
- package/lib/pty/provider.js +21 -6
- package/lib/server.js +206 -12
- package/lib/settings/routes.js +473 -0
- package/lib/ws/bridge.js +10 -1
- package/package.json +7 -1
- package/skills/nexuscrew-agent/SKILL.md +83 -0
- package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
- package/skills/nexuscrew-agent/bin/nc-send +48 -0
- package/frontend/dist/assets/index-DUbtTZMj.css +0 -32
- package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
package/lib/server.js
CHANGED
|
@@ -13,7 +13,7 @@ const { createPreviewSampler } = require('./tmux/preview.js');
|
|
|
13
13
|
const { openAttach } = require('./pty/attach.js');
|
|
14
14
|
const { bindWs } = require('./ws/bridge.js');
|
|
15
15
|
const { loadOrCreateToken, verify } = require('./auth/token.js');
|
|
16
|
-
const { requireToken } = require('./auth/middleware.js');
|
|
16
|
+
const { requireToken, bearerFrom } = require('./auth/middleware.js');
|
|
17
17
|
const { filesRoutes } = require('./files/routes.js');
|
|
18
18
|
const { createOutboxWatcher } = require('./files/watcher.js');
|
|
19
19
|
const VERSION = require('../package.json').version;
|
|
@@ -21,6 +21,17 @@ const { transcribe } = require('./voice/transcribe.js');
|
|
|
21
21
|
const { selectProvider } = require('./fleet/provider.js');
|
|
22
22
|
const { fleetRoutes } = require('./fleet/routes.js');
|
|
23
23
|
const { fsRoutes } = require('./fs/routes.js');
|
|
24
|
+
const nodesStore = require('./nodes/store.js');
|
|
25
|
+
const nodesTunnel = require('./nodes/tunnel.js');
|
|
26
|
+
const { createNodeProxy, handleNodeUpgrade } = require('./proxy/node-proxy.js');
|
|
27
|
+
const { settingsRoutes } = require('./settings/routes.js');
|
|
28
|
+
const decksStore = require('./decks/store.js');
|
|
29
|
+
const { decksRoutes } = require('./decks/routes.js');
|
|
30
|
+
const { createEventsHub } = require('./notify/events.js');
|
|
31
|
+
const { createPushService } = require('./notify/push.js');
|
|
32
|
+
const { createAsksStore } = require('./notify/asks.js');
|
|
33
|
+
const { createNotifier } = require('./notify/notifier.js');
|
|
34
|
+
const { notifyRoutes } = require('./notify/routes.js');
|
|
24
35
|
|
|
25
36
|
function sessionExists(tmuxBin, name) {
|
|
26
37
|
if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name)) return false;
|
|
@@ -28,18 +39,90 @@ function sessionExists(tmuxBin, name) {
|
|
|
28
39
|
catch (_) { return false; }
|
|
29
40
|
}
|
|
30
41
|
|
|
42
|
+
function uiBuildVersion(distDir) {
|
|
43
|
+
try {
|
|
44
|
+
const x = JSON.parse(require('node:fs').readFileSync(path.join(distDir, 'version.json'), 'utf8'));
|
|
45
|
+
return typeof x.version === 'string' ? x.version : null;
|
|
46
|
+
} catch (_) { return null; }
|
|
47
|
+
}
|
|
48
|
+
|
|
31
49
|
function createServer(opts = {}) {
|
|
32
50
|
const cfg = loadConfig(opts);
|
|
33
51
|
assertLoopback(cfg.bind);
|
|
34
|
-
|
|
52
|
+
// Token holder LIVE (audit F7 / §4b(3)): requireToken/verify leggono tokenStore.get()
|
|
53
|
+
// ad ogni richiesta, cosi' una rotazione via Settings API invalida il VECCHIO token
|
|
54
|
+
// (401) e attiva il NUOVO (200) SENZA restart. Prima il token era catturato una volta
|
|
55
|
+
// allo startup e restava valido fino al restart manuale.
|
|
56
|
+
const tokenHolder = { value: loadOrCreateToken(cfg.tokenPath) };
|
|
57
|
+
const tokenStore = {
|
|
58
|
+
get: () => tokenHolder.value,
|
|
59
|
+
reload: () => { tokenHolder.value = loadOrCreateToken(cfg.tokenPath); return tokenHolder.value; },
|
|
60
|
+
};
|
|
61
|
+
const proxySockets = new Set();
|
|
62
|
+
// wss viene creato piu' sotto; closeSessions lo raggiunge a request-time (mai durante
|
|
63
|
+
// createServer) per chiudere le sessioni WS attive sulla rotazione token (§4b(3)).
|
|
64
|
+
let wss = null;
|
|
65
|
+
const closeSessions = () => {
|
|
66
|
+
if (wss) {
|
|
67
|
+
for (const ws of wss.clients) { try { ws.close(4001, 'token rotated'); } catch (_) { /* best-effort */ } }
|
|
68
|
+
}
|
|
69
|
+
for (const socket of proxySockets) { try { socket.destroy(); } catch (_) {} }
|
|
70
|
+
proxySockets.clear();
|
|
71
|
+
};
|
|
35
72
|
const watcher = createOutboxWatcher({ root: cfg.filesRoot });
|
|
36
73
|
const previews = createPreviewSampler(cfg.tmuxBin);
|
|
74
|
+
// MCP bridge (notify/ask/push): lo stato vive accanto al token (dirname del
|
|
75
|
+
// tokenPath = ~/.nexuscrew di default) cosi' le istanze isolate via opts/env
|
|
76
|
+
// nei test NON scrivono mai nella home reale. Tutto lazy: vapid.json/asks.json
|
|
77
|
+
// nascono al primo uso, non allo startup.
|
|
78
|
+
const notifyDir = cfg.notifyDir || path.dirname(cfg.tokenPath);
|
|
79
|
+
// READONLY come floor anche dentro il push service (F3): niente generazione
|
|
80
|
+
// VAPID ne' cleanup subscription quando il server e' readonly.
|
|
81
|
+
const bridgeReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
82
|
+
const eventsHub = createEventsHub();
|
|
83
|
+
const pushSvc = createPushService({
|
|
84
|
+
dir: notifyDir, webpushImpl: cfg.webpushImpl,
|
|
85
|
+
readonly: bridgeReadonly, maxSubs: cfg.pushMaxSubs, lookupImpl: cfg.pushLookupImpl,
|
|
86
|
+
});
|
|
87
|
+
const asksStore = createAsksStore({ dir: notifyDir });
|
|
88
|
+
const notifier = createNotifier({ hub: eventsHub, push: pushSvc });
|
|
37
89
|
const attachedWs = new Map(); // ws -> session (per il push dei frame files)
|
|
38
90
|
// selectProvider sceglie UNA volta (startup) il provider external|builtin|disabled
|
|
39
91
|
// (design §4b/§9b/§9g) e ritorna {mode,reason,fleet}; routes consumano il .fleet,
|
|
40
92
|
// quindi fleetP resta una Promise<Fleet> (createServer non diventa async).
|
|
41
93
|
const fleetP = selectProvider(cfg).then((p) => p.fleet);
|
|
42
94
|
|
|
95
|
+
// Multi-node (B1): nodes.json e' la fonte dati (B0). Il proxy risolve <name>
|
|
96
|
+
// -> {localPort, token} leggendo lo store ad ogni richiesta (fresh: rotazione
|
|
97
|
+
// token / add-remove nodi visibili senza restart). token MAI redatto qui: e'
|
|
98
|
+
// il valore che il proxy inietta upstream, non esce mai verso il browser.
|
|
99
|
+
const nodesPath = cfg.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
|
|
100
|
+
const decksPath = cfg.decksPath || decksStore.defaultDecksPath(cfg.home || os.homedir());
|
|
101
|
+
const proxyReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
102
|
+
function resolveNode(name) {
|
|
103
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
104
|
+
if (!st) return null;
|
|
105
|
+
const node = nodesStore.getNode(st, name);
|
|
106
|
+
if (!node) return null;
|
|
107
|
+
return { localPort: node.localPort, token: node.token || null };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// A node-role installation must republish itself after service/reboot. The
|
|
111
|
+
// detached supervisor is idempotent and owns retry/backoff independently.
|
|
112
|
+
if (cfg.roles && cfg.roles.node === true) {
|
|
113
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
114
|
+
if (st && st.rendezvous) {
|
|
115
|
+
const tr = nodesTunnel.startReverse({
|
|
116
|
+
home: cfg.home || os.homedir(), rendezvous: st.rendezvous,
|
|
117
|
+
spawnImpl: cfg.tunnelSpawnImpl, spawnSyncImpl: cfg.tunnelSpawnSyncImpl,
|
|
118
|
+
sshBin: cfg.sshBin, logFd: cfg.tunnelLogFd,
|
|
119
|
+
});
|
|
120
|
+
if (!tr.started && tr.reason !== 'already running') {
|
|
121
|
+
process.stderr.write(`[nexuscrew] reverse tunnel autostart failed: ${tr.reason || 'unknown'}\n`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
43
126
|
const app = express();
|
|
44
127
|
const distDir = path.join(__dirname, '..', 'frontend', 'dist');
|
|
45
128
|
// no-store on everything (HTML+assets+API): this is a local, token-adjacent tool.
|
|
@@ -48,7 +131,7 @@ function createServer(opts = {}) {
|
|
|
48
131
|
// Tutte le /api dietro Bearer: sul loopback il gate vero è il tunnel,
|
|
49
132
|
// ma il token chiude anche altri processi locali della stessa macchina.
|
|
50
133
|
const api = express.Router();
|
|
51
|
-
api.use(requireToken(
|
|
134
|
+
api.use(requireToken(tokenStore));
|
|
52
135
|
api.get('/sessions', async (_req, res) => {
|
|
53
136
|
try {
|
|
54
137
|
const sessions = await listSessions(cfg.tmuxBin);
|
|
@@ -82,7 +165,7 @@ function createServer(opts = {}) {
|
|
|
82
165
|
} catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
|
|
83
166
|
});
|
|
84
167
|
api.get('/config', (_req, res) => res.json({
|
|
85
|
-
readonlyDefault: cfg.readonlyDefault, version: VERSION,
|
|
168
|
+
readonlyDefault: cfg.readonlyDefault, version: VERSION, uiVersion: uiBuildVersion(distDir),
|
|
86
169
|
bind: cfg.bind, port: cfg.port,
|
|
87
170
|
presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
|
|
88
171
|
}));
|
|
@@ -90,9 +173,38 @@ function createServer(opts = {}) {
|
|
|
90
173
|
cfg,
|
|
91
174
|
sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
|
|
92
175
|
paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
|
|
176
|
+
notifier,
|
|
177
|
+
}));
|
|
178
|
+
// MCP bridge (design §2): /notify, /push/*, /asks — dietro lo stesso Bearer
|
|
179
|
+
// del router /api; gate READONLY sui mutanti dentro notifyRoutes.
|
|
180
|
+
api.use(notifyRoutes({
|
|
181
|
+
cfg,
|
|
182
|
+
notifier,
|
|
183
|
+
push: pushSvc,
|
|
184
|
+
asks: asksStore,
|
|
185
|
+
paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
|
|
186
|
+
sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
|
|
93
187
|
}));
|
|
94
188
|
api.use('/fleet', fleetRoutes(fleetP, cfg));
|
|
189
|
+
api.use('/decks', decksRoutes({ cfg, decksPath }));
|
|
95
190
|
api.use('/fs', fsRoutes({ home: os.homedir() })); // folder-picker del dialog new session
|
|
191
|
+
// /nodes (read-only, per la settings UI B2): stesso formato di `nodes list --json`
|
|
192
|
+
// (token SEMPRE redatti via redactStore) + stato tunnel per-nodo.
|
|
193
|
+
api.get('/nodes', (_req, res) => {
|
|
194
|
+
try {
|
|
195
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
196
|
+
if (!st) return res.json({ nodeId: null, nodes: [] });
|
|
197
|
+
const view = nodesStore.redactStore(st);
|
|
198
|
+
const nodes = view.nodes.map((n) => ({ ...n, tunnel: nodesTunnel.readTunnelState(os.homedir(), n.name) }));
|
|
199
|
+
const out = { nodeId: view.nodeId, nodes };
|
|
200
|
+
if (view.rendezvous) out.rendezvous = view.rendezvous;
|
|
201
|
+
res.json(out);
|
|
202
|
+
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
203
|
+
});
|
|
204
|
+
// Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
|
|
205
|
+
// wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
|
|
206
|
+
// READONLY route-level e la redazione token vivono dentro settingsRoutes.
|
|
207
|
+
api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions }));
|
|
96
208
|
api.get('/voice/status', (_req, res) => res.json({ serverSttConfigured: !!cfg.voiceUrl }));
|
|
97
209
|
api.post('/voice/transcribe',
|
|
98
210
|
express.raw({ type: () => true, limit: '25mb' }),
|
|
@@ -102,21 +214,99 @@ function createServer(opts = {}) {
|
|
|
102
214
|
res.json({ text: out.text || '' });
|
|
103
215
|
} catch (e) { res.status(e.status || 502).json({ error: e.message }); }
|
|
104
216
|
});
|
|
217
|
+
// SSE eventi UI (notify/ask, MCP bridge §2a): EventSource non puo' settare
|
|
218
|
+
// header -> il token e' accettato anche in query, SOLO perche' il bind e'
|
|
219
|
+
// loopback-only (stesso pattern dell'upgrade WS del proxy /node). Montata
|
|
220
|
+
// PRIMA del router /api (che e' Bearer-only) e sempre sul token live.
|
|
221
|
+
app.get('/api/events', (req, res) => {
|
|
222
|
+
const given = bearerFrom(req) || String(req.query.token || '');
|
|
223
|
+
if (!verify(tokenHolder.value, given)) {
|
|
224
|
+
return res.status(401).json({ error: 'unauthorized' });
|
|
225
|
+
}
|
|
226
|
+
eventsHub.handle(req, res);
|
|
227
|
+
});
|
|
228
|
+
|
|
105
229
|
app.use('/api', api);
|
|
106
230
|
|
|
231
|
+
// Reverse-proxy single-origin /node/<name>/… (design §4b(2)). Auth locale PRIMA
|
|
232
|
+
// di risolvere <name>: requireToken(token) davanti al router, nessuna route
|
|
233
|
+
// proxy montata prima del middleware auth. Montato PRIMA dello static/catch-all.
|
|
234
|
+
app.use('/node', requireToken(tokenStore), createNodeProxy({ resolveNode, readonly: proxyReadonly }));
|
|
235
|
+
|
|
107
236
|
app.use(express.static(distDir));
|
|
237
|
+
// Deck multi-finestra (§5b): /deck/<name> serve la STESSA SPA (stesso origin,
|
|
238
|
+
// stesso token via fragment). <name> e' una chiave strict client-side, mai usata
|
|
239
|
+
// per costruire path: validazione ^[a-z0-9-]{1,32}$, nome invalido → 404 secco
|
|
240
|
+
// (niente traversal, niente fallback silenzioso alla SPA su nomi sporchi).
|
|
241
|
+
const DECK_NAME_RE = /^[a-z0-9-]{1,32}$/;
|
|
242
|
+
// Cattura TUTTO cio' che sta sotto /deck/ (anche slash/segmenti extra o encoded)
|
|
243
|
+
// e valida il remainder: qualunque cosa non sia un nome deck strict → 404,
|
|
244
|
+
// senza mai cadere nel catch-all SPA con un nome sporco.
|
|
245
|
+
app.get(/^\/deck\/(.*)$/, (req, res) => {
|
|
246
|
+
// parita' col client: deckFromPath accetta UN trailing slash (/deck/main/),
|
|
247
|
+
// il server deve fare lo stesso; piu' di uno resta 404 (regex strict).
|
|
248
|
+
const name = req.params[0].replace(/\/$/, '');
|
|
249
|
+
if (!DECK_NAME_RE.test(name)) {
|
|
250
|
+
return res.status(404).type('text/plain').send('invalid deck name');
|
|
251
|
+
}
|
|
252
|
+
return res.sendFile(path.join(distDir, 'index.html'));
|
|
253
|
+
});
|
|
108
254
|
app.get('*', (_req, res) => res.sendFile(path.join(distDir, 'index.html')));
|
|
109
255
|
|
|
110
256
|
const server = http.createServer(app);
|
|
111
257
|
// Close the watcher when the HTTP server closes. Registered HERE (inside
|
|
112
258
|
// createServer) — not in start() — so every createServer consumer is covered,
|
|
113
259
|
// not only the start() path. watcher.close() is idempotent.
|
|
114
|
-
server.on('close', () => { watcher.close(); previews.close(); });
|
|
115
|
-
|
|
116
|
-
|
|
260
|
+
server.on('close', () => { watcher.close(); previews.close(); eventsHub.closeAll(); });
|
|
261
|
+
// noServer: gestiamo l'upgrade a mano per instradare /ws (locale) e /node/*
|
|
262
|
+
// (proxy). Il WS locale resta identico; il proxy WS applica gli STESSI check
|
|
263
|
+
// dell'HTTP (auth locale -> name strict -> inject token) prima del piping.
|
|
264
|
+
wss = new WebSocketServer({ noServer: true, maxPayload: 1 << 20 });
|
|
265
|
+
// Browser/mobile/tunnel possono lasciare TCP half-open senza un close event.
|
|
266
|
+
// Il ping applicativo fa emergere il guasto; terminate genera un close 1006
|
|
267
|
+
// lato browser, che il client riconnette senza richiedere refresh pagina.
|
|
268
|
+
const heartbeat = setInterval(() => {
|
|
269
|
+
for (const client of wss.clients) {
|
|
270
|
+
if (client.isAlive === false) { try { client.terminate(); } catch (_) {} continue; }
|
|
271
|
+
client.isAlive = false;
|
|
272
|
+
try { client.ping(); } catch (_) { try { client.terminate(); } catch (_e) {} }
|
|
273
|
+
}
|
|
274
|
+
}, opts.wsHeartbeatMs || 30000);
|
|
275
|
+
if (typeof heartbeat.unref === 'function') heartbeat.unref();
|
|
276
|
+
server.on('close', () => clearInterval(heartbeat));
|
|
277
|
+
server.on('upgrade', (req, socket, head) => {
|
|
278
|
+
let pathname;
|
|
279
|
+
try { pathname = new URL(req.url, 'http://127.0.0.1').pathname; }
|
|
280
|
+
catch (_) { try { socket.destroy(); } catch (_e) {} return; }
|
|
281
|
+
if (pathname === '/ws') {
|
|
282
|
+
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (pathname === '/node' || pathname.startsWith('/node/')) {
|
|
286
|
+
handleNodeUpgrade({
|
|
287
|
+
req, socket, head, resolveNode,
|
|
288
|
+
verifyToken: (t) => verify(tokenHolder.value, t),
|
|
289
|
+
readonly: proxyReadonly,
|
|
290
|
+
activeSockets: proxySockets,
|
|
291
|
+
});
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
try { socket.destroy(); } catch (_) {}
|
|
295
|
+
});
|
|
296
|
+
wss.on('connection', (ws, req) => {
|
|
297
|
+
ws.isAlive = true;
|
|
298
|
+
ws.on('pong', () => { ws.isAlive = true; });
|
|
299
|
+
// Preauth via header (B2 attach remoto): quando l'upgrade arriva dal proxy
|
|
300
|
+
// /node/<name> di un hub, il proxy ha gia' iniettato `Authorization: Bearer
|
|
301
|
+
// <token di QUESTO nodo>` (§4b(2)#3) mentre il frame attach porta il token
|
|
302
|
+
// del hub. Un Bearer valido sull'upgrade vale come auth: e' lo STESSO
|
|
303
|
+
// verify dello stesso token locale, solo su un canale diverso. I browser
|
|
304
|
+
// non possono settare header sui WS -> il flusso locale resta identico
|
|
305
|
+
// (token nel frame attach, mai in URL).
|
|
306
|
+
const preauth = req ? verify(tokenHolder.value, bearerFrom(req)) : false;
|
|
117
307
|
bindWs(ws, {
|
|
118
308
|
openAttach,
|
|
119
|
-
verifyToken: (t) => verify(
|
|
309
|
+
verifyToken: (t) => preauth || verify(tokenHolder.value, t),
|
|
120
310
|
isValidSession: (name) => sessionExists(cfg.tmuxBin, name),
|
|
121
311
|
runAction: (sess, action) => runAction(cfg.tmuxBin, sess, action),
|
|
122
312
|
countClients: (sess) => attachedClients(cfg.tmuxBin, sess),
|
|
@@ -134,14 +324,18 @@ function createServer(opts = {}) {
|
|
|
134
324
|
}
|
|
135
325
|
});
|
|
136
326
|
|
|
137
|
-
return { app, server, wss, cfg, token, watcher, fleetP };
|
|
327
|
+
return { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP };
|
|
138
328
|
}
|
|
139
329
|
|
|
140
330
|
function start(opts = {}) {
|
|
141
|
-
const { server, cfg
|
|
331
|
+
const { server, cfg } = createServer(opts);
|
|
332
|
+
const log = opts.log || console.log;
|
|
142
333
|
server.listen(cfg.port, cfg.bind, () => {
|
|
143
|
-
|
|
144
|
-
|
|
334
|
+
// Il token NON si stampa allo startup: finirebbe nei log del servizio
|
|
335
|
+
// (journalctl/logfile), che `nexuscrew logs` espone — contratto A2
|
|
336
|
+
// "token mai nei log". Per l'URL completo: `nexuscrew url` (o --qr).
|
|
337
|
+
log(`nexuscrew on http://${cfg.bind}:${cfg.port} (token: usa \`nexuscrew url\`)`);
|
|
338
|
+
log('localhost-only — reach it via SSH/autossh/VPN tunnel.');
|
|
145
339
|
});
|
|
146
340
|
return server;
|
|
147
341
|
}
|