@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.
- package/README.md +173 -18
- 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 +462 -31
- package/lib/cli/doctor.js +160 -0
- package/lib/cli/fleet-service.js +319 -0
- package/lib/cli/init.js +107 -7
- 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 +59 -2
- package/lib/files/store.js +16 -1
- package/lib/fleet/boot.js +62 -0
- package/lib/fleet/builtin.js +594 -0
- package/lib/fleet/definitions.js +410 -0
- package/lib/fleet/index.js +49 -10
- package/lib/fleet/managed.js +211 -0
- package/lib/fleet/provider.js +102 -0
- package/lib/fleet/routes.js +78 -8
- package/lib/fs/routes.js +56 -0
- 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 +214 -15
- package/lib/settings/routes.js +473 -0
- package/lib/ws/bridge.js +10 -1
- package/package.json +8 -2
- 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-BWhSqR3J.css +0 -32
- package/frontend/dist/assets/index-Bx8vdLZY.css +0 -32
- package/frontend/dist/assets/index-CLb2xmyu.js +0 -81
- package/frontend/dist/assets/index-CgxfkmRo.js +0 -81
- package/frontend/dist/assets/index-DE67kR0M.js +0 -81
- package/frontend/dist/assets/index-DQMx2p4x.js +0 -81
- package/frontend/dist/assets/index-DWhfVwKW.css +0 -32
- package/frontend/dist/assets/index-DoPZ_3-h.js +0 -81
- package/frontend/dist/assets/index-o9l7pPAf.css +0 -32
- package/frontend/dist/assets/index-q9PUrGO0.js +0 -81
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/settings/routes.js — Settings API per wizard/settings UI (design §4b(6), B2).
|
|
3
|
+
//
|
|
4
|
+
// Contratto duro:
|
|
5
|
+
// - read-only: GET /api/settings (roles, firstRun, port, platform, service,
|
|
6
|
+
// rendezvous redatto, version).
|
|
7
|
+
// - mutanti (LISTA CHIUSA §4b(6)), tutti dietro requireToken (montati sotto il
|
|
8
|
+
// router /api gia' autenticato) + gate READONLY route-level (pattern
|
|
9
|
+
// lib/fleet/routes.js, 403 esplicito):
|
|
10
|
+
// POST /config scrittura config.json ATOMICA (whitelist chiavi)
|
|
11
|
+
// POST /token/rotate riusa rotateToken(); token MAI in risposta (§4b(3))
|
|
12
|
+
// POST /nodes nodes add (riusa lib/nodes/commands.js)
|
|
13
|
+
// DELETE /nodes/:name nodes remove
|
|
14
|
+
// POST /node-role node on/off (rendezvous config)
|
|
15
|
+
// POST /service/regenerate rigenera unit service (NO restart automatico)
|
|
16
|
+
// - NON gate READONLY (lifecycle di PROCESSO, non mutazione di config — decisione
|
|
17
|
+
// B0 documentata in lib/nodes/commands.js: READONLY blocca le mutazioni di
|
|
18
|
+
// config, non up/down/restart dei tunnel):
|
|
19
|
+
// POST /nodes/:name/test non-mutante (POST per coerenza azione)
|
|
20
|
+
// POST /nodes/:name/up|down|restart
|
|
21
|
+
//
|
|
22
|
+
// Invarianti:
|
|
23
|
+
// - token MAI in risposta: ogni payload passa da deepScrubTokens() (cintura oltre
|
|
24
|
+
// alla redazione a monte via redactStore); rotateToken() ritorna il nuovo token
|
|
25
|
+
// ma il valore viene SCARTATO, mai serializzato.
|
|
26
|
+
// - failure esplicite: {error:'causa precisa'} con status coerente (400 input,
|
|
27
|
+
// 403 readonly, 404 nodo, 409 conflitto, 500 con messaggio). Niente stack trace.
|
|
28
|
+
// - validazione input strict fail-closed: schema chiuso per ogni body, garbage
|
|
29
|
+
// -> 400 con causa, mai guess.
|
|
30
|
+
// - NIENTE reimplementazione della logica B0: i mutanti nodes/node-role
|
|
31
|
+
// incapsulano le funzioni CLI di lib/nodes/commands.js (log catturato per
|
|
32
|
+
// estrarre esito/authorized_keys); config.json scritto col pattern atomico
|
|
33
|
+
// tmp+rename di lib/nodes/store.js.
|
|
34
|
+
//
|
|
35
|
+
// Nota lifecycle: i tunnel avviati da /nodes/:name/up sono spawn DETACHED+unref
|
|
36
|
+
// con pidfile (lib/nodes/tunnel.js): non restano figli dell'handler HTTP; il
|
|
37
|
+
// commento B0 "mai spawn dalla superficie web" e' superato per B2 da questo
|
|
38
|
+
// contratto esplicito (§4b(6): "restart processi tunnel" e' mutazione ammessa).
|
|
39
|
+
const fs = require('node:fs');
|
|
40
|
+
const os = require('node:os');
|
|
41
|
+
const path = require('node:path');
|
|
42
|
+
const crypto = require('node:crypto');
|
|
43
|
+
const express = require('express');
|
|
44
|
+
|
|
45
|
+
const nodesStore = require('../nodes/store.js');
|
|
46
|
+
const nodesCmds = require('../nodes/commands.js');
|
|
47
|
+
const { rotateToken } = require('../auth/token.js');
|
|
48
|
+
const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
|
|
49
|
+
const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
|
|
50
|
+
const { isServiceRunning, readRoles } = require('../cli/commands.js');
|
|
51
|
+
const { configJsonPath } = require('../config.js');
|
|
52
|
+
const VERSION = require('../../package.json').version;
|
|
53
|
+
|
|
54
|
+
// Whitelist chiavi scrivibili di config.json via API (lista chiusa dal task B2).
|
|
55
|
+
const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone']);
|
|
56
|
+
const ROLE_KEYS = new Set(['client', 'node']);
|
|
57
|
+
const ADD_KEYS = new Set(['name', 'ssh', 'remotePort', 'localPort', 'keyPath']);
|
|
58
|
+
const NODE_ROLE_KEYS = new Set(['enabled', 'rendezvousSsh', 'publishedPort', 'keyPath']);
|
|
59
|
+
|
|
60
|
+
// Cintura §4b(3): rimozione ricorsiva di ogni chiave `token` da QUALUNQUE payload
|
|
61
|
+
// di risposta (la vista redatta espone hasToken, mai il valore). Difesa in
|
|
62
|
+
// profondita' oltre a redactStore: anche un bug a monte non fa uscire il segreto.
|
|
63
|
+
function deepScrubTokens(v) {
|
|
64
|
+
if (Array.isArray(v)) return v.map(deepScrubTokens);
|
|
65
|
+
if (v && typeof v === 'object') {
|
|
66
|
+
const out = {};
|
|
67
|
+
for (const [k, val] of Object.entries(v)) {
|
|
68
|
+
if (k === 'token') continue;
|
|
69
|
+
out[k] = deepScrubTokens(val);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
return v;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Cattura il log delle funzioni CLI riusate: serve per estrarre la causa d'errore
|
|
77
|
+
// precisa e la riga authorized_keys (che NON e' un segreto: pubkey con restrict).
|
|
78
|
+
function capture() {
|
|
79
|
+
const lines = [];
|
|
80
|
+
return { lines, log: (s) => lines.push(String(s)) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function lastLine(cap, fallback) {
|
|
84
|
+
return cap.lines.length ? cap.lines[cap.lines.length - 1] : fallback;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function authorizedKeysLine(cap) {
|
|
88
|
+
return cap.lines.find((l) => l.startsWith('restrict,')) || null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Scrittura ATOMICA di config.json: tmp stessa dir (suffisso random: due write
|
|
92
|
+
// concorrenti nello stesso processo non collidono) -> chmod 0600 -> rename.
|
|
93
|
+
// Stesso pattern hardening di lib/nodes/store.js (no-symlink incluso).
|
|
94
|
+
function atomicWriteConfig(p, obj) {
|
|
95
|
+
try {
|
|
96
|
+
if (fs.lstatSync(p).isSymbolicLink()) {
|
|
97
|
+
throw new Error('refuse to write: config.json target e\' un symlink');
|
|
98
|
+
}
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if (e.code !== 'ENOENT') throw e;
|
|
101
|
+
}
|
|
102
|
+
const dir = path.dirname(p);
|
|
103
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
104
|
+
const tmp = path.join(dir, `.${path.basename(p)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
105
|
+
try {
|
|
106
|
+
fs.writeFileSync(tmp, `${JSON.stringify(obj, null, 2)}\n`, { mode: 0o600 });
|
|
107
|
+
fs.chmodSync(tmp, 0o600);
|
|
108
|
+
fs.renameSync(tmp, p);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
try { fs.unlinkSync(tmp); } catch (_) { /* cleanup best-effort */ }
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// config.json corrente come oggetto ({} se assente/illeggibile) + flag esistenza.
|
|
116
|
+
function readConfigFile(p) {
|
|
117
|
+
try {
|
|
118
|
+
const obj = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
119
|
+
return { exists: true, cfg: (obj && typeof obj === 'object' && !Array.isArray(obj)) ? obj : {} };
|
|
120
|
+
} catch (_) { return { exists: false, cfg: {} }; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function settingsRoutes(deps = {}) {
|
|
124
|
+
const cfg = deps.cfg || {};
|
|
125
|
+
const seams = cfg.settingsSeams || {};
|
|
126
|
+
// tokenStore/closeSessions iniettati da server.js per la semantica di invalidazione
|
|
127
|
+
// live (audit F7 / §4b(3)). Assenti nei test unitari puri su routes -> la rotazione
|
|
128
|
+
// scrive solo il file (come prima), senza reload in-memory.
|
|
129
|
+
const tokenStore = deps.tokenStore || null;
|
|
130
|
+
const closeSessions = deps.closeSessions || null;
|
|
131
|
+
const home = cfg.home || os.homedir();
|
|
132
|
+
const configDir = cfg.configDir || path.join(home, '.nexuscrew');
|
|
133
|
+
// configJsonPath() rispetta NEXUSCREW_CONFIG_FILE (stessa risoluzione di
|
|
134
|
+
// loadConfig): settings API e server DEVONO leggere/scrivere lo STESSO file —
|
|
135
|
+
// il fallback home-based divergeva dalla config del server nelle istanze
|
|
136
|
+
// isolate via env (bug trovato in audit: smoke test che scrivono la config reale).
|
|
137
|
+
const configPath = cfg.configPath || configJsonPath();
|
|
138
|
+
const nodesPath = deps.nodesPath || cfg.nodesPath || nodesStore.defaultNodesPath(home);
|
|
139
|
+
const tokenPath = cfg.tokenPath || path.join(configDir, 'token');
|
|
140
|
+
const platform = seams.platform || detectPlatform();
|
|
141
|
+
|
|
142
|
+
const r = express.Router();
|
|
143
|
+
r.use(express.json({ limit: '8kb' }));
|
|
144
|
+
|
|
145
|
+
const send = (res, status, payload) => res.status(status).json(deepScrubTokens(payload));
|
|
146
|
+
|
|
147
|
+
// Gate READONLY route-level (pattern lib/fleet/routes.js): PRIMA di qualunque
|
|
148
|
+
// dispatch verso le funzioni CLI. Vale per i soli mutanti di config/token/service.
|
|
149
|
+
const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
150
|
+
const mutGate = (_req, res, next) => {
|
|
151
|
+
if (readonly()) return send(res, 403, { error: 'READONLY: mutazione settings bloccata' });
|
|
152
|
+
next();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Opzioni comuni passate alle funzioni CLI riusate (path espliciti + seam test).
|
|
156
|
+
const cliOpts = (cap) => ({
|
|
157
|
+
home, configDir, configPath, nodesPath, log: cap.log,
|
|
158
|
+
keygen: seams.keygen, execFileImpl: seams.execFileImpl,
|
|
159
|
+
spawnImpl: seams.spawnImpl, sshBin: seams.sshBin, logFd: seams.logFd,
|
|
160
|
+
httpProbe: seams.httpProbe, sshVersion: seams.sshVersion,
|
|
161
|
+
spawnSyncImpl: seams.spawnSyncImpl,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const validName = (name) => typeof name === 'string' && nodesStore.NODE_NAME_RE.test(name);
|
|
165
|
+
|
|
166
|
+
// --- GET / — vista read-only per wizard/settings UI -----------------------
|
|
167
|
+
r.get('/', (_req, res) => {
|
|
168
|
+
try {
|
|
169
|
+
const { exists, cfg: fileCfg } = readConfigFile(configPath);
|
|
170
|
+
// firstRun: config.json assente O wizardDone non esplicitamente true
|
|
171
|
+
// (il wizard persiste wizardDone:true via POST /config a fine setup).
|
|
172
|
+
const firstRun = !exists || fileCfg.wizardDone !== true;
|
|
173
|
+
const svcPath = seams.serviceInstallPath || svcInstallPath(platform, home);
|
|
174
|
+
const service = {
|
|
175
|
+
installed: fs.existsSync(svcPath),
|
|
176
|
+
active: isServiceRunning({ platform, execImpl: seams.execImpl, uid: seams.uid, home }),
|
|
177
|
+
};
|
|
178
|
+
const out = {
|
|
179
|
+
roles: readRoles(configPath),
|
|
180
|
+
firstRun,
|
|
181
|
+
port: cfg.port,
|
|
182
|
+
platform,
|
|
183
|
+
service,
|
|
184
|
+
version: VERSION,
|
|
185
|
+
};
|
|
186
|
+
// rendezvous via redactStore (view sicura §4b(4)): non contiene token, ma
|
|
187
|
+
// la si prende comunque SOLO dalla vista redatta.
|
|
188
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
189
|
+
if (st) {
|
|
190
|
+
const view = nodesStore.redactStore(st);
|
|
191
|
+
if (view.rendezvous) out.rendezvous = view.rendezvous;
|
|
192
|
+
}
|
|
193
|
+
send(res, 200, out);
|
|
194
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// --- POST /config — scrittura atomica, subset whitelisted ------------------
|
|
198
|
+
r.post('/config', mutGate, (req, res) => {
|
|
199
|
+
try {
|
|
200
|
+
const b = req.body;
|
|
201
|
+
if (!b || typeof b !== 'object' || Array.isArray(b)) {
|
|
202
|
+
return send(res, 400, { error: 'body deve essere un oggetto JSON' });
|
|
203
|
+
}
|
|
204
|
+
for (const k of Object.keys(b)) {
|
|
205
|
+
if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone)` });
|
|
206
|
+
}
|
|
207
|
+
if (Object.keys(b).length === 0) {
|
|
208
|
+
return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone)' });
|
|
209
|
+
}
|
|
210
|
+
if (b.roles !== undefined) {
|
|
211
|
+
if (!b.roles || typeof b.roles !== 'object' || Array.isArray(b.roles)) {
|
|
212
|
+
return send(res, 400, { error: 'roles deve essere un oggetto {client?, node?}' });
|
|
213
|
+
}
|
|
214
|
+
for (const k of Object.keys(b.roles)) {
|
|
215
|
+
if (!ROLE_KEYS.has(k)) return send(res, 400, { error: `roles: chiave non ammessa "${k}" (solo client, node)` });
|
|
216
|
+
if (typeof b.roles[k] !== 'boolean') return send(res, 400, { error: `roles.${k} deve essere boolean` });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (b.port !== undefined && !nodesStore.isPort(b.port)) {
|
|
220
|
+
return send(res, 400, { error: 'port deve essere un intero 1..65535' });
|
|
221
|
+
}
|
|
222
|
+
if (b.wizardDone !== undefined && typeof b.wizardDone !== 'boolean') {
|
|
223
|
+
return send(res, 400, { error: 'wizardDone deve essere boolean' });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// merge sul config esistente (preserva le chiavi non gestite qui)
|
|
227
|
+
const { cfg: current } = readConfigFile(configPath);
|
|
228
|
+
const next = { ...current };
|
|
229
|
+
if (b.roles !== undefined) {
|
|
230
|
+
const prev = (current.roles && typeof current.roles === 'object') ? current.roles : {};
|
|
231
|
+
next.roles = { client: !!prev.client, node: !!prev.node, ...b.roles };
|
|
232
|
+
}
|
|
233
|
+
if (b.port !== undefined) next.port = b.port;
|
|
234
|
+
if (b.wizardDone !== undefined) next.wizardDone = b.wizardDone;
|
|
235
|
+
atomicWriteConfig(configPath, next);
|
|
236
|
+
|
|
237
|
+
const out = {
|
|
238
|
+
saved: true,
|
|
239
|
+
config: { roles: next.roles, port: next.port, wizardDone: next.wizardDone },
|
|
240
|
+
};
|
|
241
|
+
// Il server legge la porta SOLO allo startup: il cambio vale al prossimo
|
|
242
|
+
// restart (contratto dichiarato nella risposta, la UI avvisa).
|
|
243
|
+
if (b.port !== undefined && b.port !== cfg.port) {
|
|
244
|
+
out.note = 'la porta cambia al prossimo restart del service';
|
|
245
|
+
}
|
|
246
|
+
send(res, 200, out);
|
|
247
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// --- POST /token/rotate — atomico + reload live + chiusura WS; token MAI in risposta
|
|
251
|
+
// Contratto §4b(3): "scrittura atomica del token file + chiusura delle sessioni
|
|
252
|
+
// WS/API attive locali + reload credenziali proxy". Audit F7: prima questa route
|
|
253
|
+
// scriveva solo il file, e il server teneva il VECCHIO token in memoria -> restava
|
|
254
|
+
// accettato fino al restart manuale. Ora: (1) scrittura atomica, (2) reload live
|
|
255
|
+
// dell'holder in memoria (requireToken/verify vedono il nuovo), (3) chiusura delle
|
|
256
|
+
// sessioni WS long-lived (autenticate solo all'upgrade). Il nuovo token NON trapela
|
|
257
|
+
// mai: rotateToken() lo ritorna ma il valore viene SCARTATO, e reload() non lo espone.
|
|
258
|
+
r.post('/token/rotate', mutGate, (_req, res) => {
|
|
259
|
+
try {
|
|
260
|
+
rotateToken(tokenPath);
|
|
261
|
+
if (tokenStore && typeof tokenStore.reload === 'function') tokenStore.reload();
|
|
262
|
+
if (typeof closeSessions === 'function') closeSessions();
|
|
263
|
+
send(res, 200, {
|
|
264
|
+
rotated: true,
|
|
265
|
+
note: 'token ruotato: sessioni attive chiuse, vecchio token invalidato (401) — recupera il nuovo con `nexuscrew url`',
|
|
266
|
+
});
|
|
267
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// --- POST /nodes — nodes add (riusa nodesCmds.nodesAdd) --------------------
|
|
271
|
+
r.post('/nodes', mutGate, (req, res) => {
|
|
272
|
+
try {
|
|
273
|
+
const b = req.body;
|
|
274
|
+
if (!b || typeof b !== 'object' || Array.isArray(b)) {
|
|
275
|
+
return send(res, 400, { error: 'body deve essere un oggetto JSON' });
|
|
276
|
+
}
|
|
277
|
+
for (const k of Object.keys(b)) {
|
|
278
|
+
if (!ADD_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (schema: name, ssh, remotePort?, localPort?, keyPath?)` });
|
|
279
|
+
}
|
|
280
|
+
if (!validName(b.name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
|
|
281
|
+
if (!nodesStore.parseSsh(b.ssh)) return send(res, 400, { error: 'ssh non valido (atteso user@host strict)' });
|
|
282
|
+
if (b.remotePort !== undefined && !nodesStore.isPort(b.remotePort)) {
|
|
283
|
+
return send(res, 400, { error: 'remotePort deve essere un intero 1..65535' });
|
|
284
|
+
}
|
|
285
|
+
if (b.localPort !== undefined && !nodesStore.isPort(b.localPort)) {
|
|
286
|
+
return send(res, 400, { error: 'localPort deve essere un intero 1..65535' });
|
|
287
|
+
}
|
|
288
|
+
if (b.keyPath !== undefined && !nodesStore.isAbsPath(b.keyPath)) {
|
|
289
|
+
return send(res, 400, { error: 'keyPath deve essere un path assoluto' });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const cap = capture();
|
|
293
|
+
const out = nodesCmds.nodesAdd({
|
|
294
|
+
...cliOpts(cap),
|
|
295
|
+
name: b.name, ssh: b.ssh,
|
|
296
|
+
remotePort: b.remotePort, localPort: b.localPort, key: b.keyPath,
|
|
297
|
+
});
|
|
298
|
+
if (out.code === 0) {
|
|
299
|
+
return send(res, 200, {
|
|
300
|
+
added: true,
|
|
301
|
+
name: out.name,
|
|
302
|
+
remotePort: out.remotePort,
|
|
303
|
+
localPort: out.localPort,
|
|
304
|
+
// La riga authorized_keys NON e' un segreto: pubkey con restrict/permitopen.
|
|
305
|
+
authorizedKeys: authorizedKeysLine(cap),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
const msg = lastLine(cap, 'nodes add fallito');
|
|
309
|
+
if (out.reason === 'readonly') return send(res, 403, { error: msg });
|
|
310
|
+
if (out.reason === 'add rifiutato') {
|
|
311
|
+
// duplicato/self-reference = conflitto (409); schema rifiutato = 400.
|
|
312
|
+
const status = /duplicato|self-reference/.test(msg) ? 409 : 400;
|
|
313
|
+
return send(res, status, { error: msg });
|
|
314
|
+
}
|
|
315
|
+
// store invalido / keygen fallita / write fallita: errore lato server.
|
|
316
|
+
return send(res, 500, { error: msg });
|
|
317
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// --- DELETE /nodes/:name — nodes remove ------------------------------------
|
|
321
|
+
r.delete('/nodes/:name', mutGate, (req, res) => {
|
|
322
|
+
try {
|
|
323
|
+
const name = String(req.params.name || '');
|
|
324
|
+
if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
|
|
325
|
+
const cap = capture();
|
|
326
|
+
const out = nodesCmds.nodesRemove({ ...cliOpts(cap), name });
|
|
327
|
+
if (out.code === 0) return send(res, 200, { removed: true, name, stopped: !!out.stopped });
|
|
328
|
+
const msg = lastLine(cap, 'nodes remove fallito');
|
|
329
|
+
if (out.reason === 'readonly') return send(res, 403, { error: msg });
|
|
330
|
+
const status = /sconosciuto|nessun nodes\.json/.test(msg) ? 404 : 500;
|
|
331
|
+
send(res, status, { error: msg });
|
|
332
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// --- POST /nodes/:name/test — non-mutante (POST per coerenza azione) -------
|
|
336
|
+
// NON gated READONLY: e' una probe diagnostica, coerente col `nodes test` CLI.
|
|
337
|
+
r.post('/nodes/:name/test', async (req, res) => {
|
|
338
|
+
try {
|
|
339
|
+
const name = String(req.params.name || '');
|
|
340
|
+
if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
|
|
341
|
+
const cap = capture();
|
|
342
|
+
const out = await nodesCmds.nodesTest({ ...cliOpts(cap), name });
|
|
343
|
+
if (out.result === 'unknown-node') return send(res, 404, { error: `nodo sconosciuto "${name}"` });
|
|
344
|
+
// result distingue: ok | tunnel-down | health-ko | token-missing | token-ko
|
|
345
|
+
send(res, 200, { ok: out.result === 'ok', result: out.result, detail: lastLine(cap, '') });
|
|
346
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// --- POST /nodes/:name/up|down|restart — lifecycle tunnel ------------------
|
|
350
|
+
// GATE READONLY (audit F6, contract §4b(6): "restart processi tunnel" e' tra i
|
|
351
|
+
// mutanti della lista chiusa e NEXUSCREW_READONLY blocca TUTTI i mutanti). Prima
|
|
352
|
+
// questi endpoint erano esplicitamente NON gated ("decisione B0") — ma cio'
|
|
353
|
+
// contradiceva il contratto duro §4b(6): up/down/restart mutano processi e vanno
|
|
354
|
+
// bloccati in READONLY. /nodes/:name/test resta NON gated (probe diagnostica).
|
|
355
|
+
function lifecycleHandler(action) {
|
|
356
|
+
return (req, res) => {
|
|
357
|
+
try {
|
|
358
|
+
const name = String(req.params.name || '');
|
|
359
|
+
if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
|
|
360
|
+
const cap = capture();
|
|
361
|
+
const fn = action === 'up' ? nodesCmds.nodesUp
|
|
362
|
+
: action === 'down' ? nodesCmds.nodesDown
|
|
363
|
+
: nodesCmds.nodesRestart;
|
|
364
|
+
const out = fn({ ...cliOpts(cap), name });
|
|
365
|
+
if (out.code !== 0) {
|
|
366
|
+
const msg = lastLine(cap, `nodes ${action} fallito`);
|
|
367
|
+
const status = /sconosciuto/.test(msg) ? 404 : 500;
|
|
368
|
+
return send(res, status, { error: msg });
|
|
369
|
+
}
|
|
370
|
+
if (action === 'up') return send(res, 200, { name, started: out.started, pid: out.pid });
|
|
371
|
+
if (action === 'down') return send(res, 200, { name, stopped: out.stopped });
|
|
372
|
+
return send(res, 200, { name, restarted: true, pid: out.pid });
|
|
373
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
r.post('/nodes/:name/up', mutGate, lifecycleHandler('up'));
|
|
377
|
+
r.post('/nodes/:name/down', mutGate, lifecycleHandler('down'));
|
|
378
|
+
r.post('/nodes/:name/restart', mutGate, lifecycleHandler('restart'));
|
|
379
|
+
|
|
380
|
+
// --- POST /node-role — node on/off (rendezvous config) ---------------------
|
|
381
|
+
r.post('/node-role', mutGate, (req, res) => {
|
|
382
|
+
try {
|
|
383
|
+
const b = req.body;
|
|
384
|
+
if (!b || typeof b !== 'object' || Array.isArray(b)) {
|
|
385
|
+
return send(res, 400, { error: 'body deve essere un oggetto JSON' });
|
|
386
|
+
}
|
|
387
|
+
for (const k of Object.keys(b)) {
|
|
388
|
+
if (!NODE_ROLE_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (schema: enabled, rendezvousSsh?, publishedPort?, keyPath?)` });
|
|
389
|
+
}
|
|
390
|
+
if (typeof b.enabled !== 'boolean') return send(res, 400, { error: 'enabled deve essere boolean' });
|
|
391
|
+
if (b.rendezvousSsh !== undefined && !nodesStore.parseSsh(b.rendezvousSsh)) {
|
|
392
|
+
return send(res, 400, { error: 'rendezvousSsh non valido (atteso user@host strict)' });
|
|
393
|
+
}
|
|
394
|
+
if (b.publishedPort !== undefined && !nodesStore.isPort(b.publishedPort)) {
|
|
395
|
+
return send(res, 400, { error: 'publishedPort deve essere un intero 1..65535' });
|
|
396
|
+
}
|
|
397
|
+
if (b.keyPath !== undefined && !nodesStore.isAbsPath(b.keyPath)) {
|
|
398
|
+
return send(res, 400, { error: 'keyPath deve essere un path assoluto' });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const cap = capture();
|
|
402
|
+
if (b.enabled === false) {
|
|
403
|
+
const out = nodesCmds.nodeOff({ ...cliOpts(cap) });
|
|
404
|
+
if (out.code === 0) return send(res, 200, { enabled: false, roles: out.roles });
|
|
405
|
+
return send(res, 500, { error: lastLine(cap, 'node off fallito') });
|
|
406
|
+
}
|
|
407
|
+
const out = nodesCmds.nodeOn({
|
|
408
|
+
...cliOpts(cap),
|
|
409
|
+
rendezvousSsh: b.rendezvousSsh,
|
|
410
|
+
publishedPort: b.publishedPort,
|
|
411
|
+
key: b.keyPath,
|
|
412
|
+
port: cfg.port, // porta nexus locale da esporre = quella del server attivo
|
|
413
|
+
});
|
|
414
|
+
if (out.code === 0) {
|
|
415
|
+
const resp = { enabled: true, roles: out.roles, tunnel: out.tunnel || null };
|
|
416
|
+
const line = authorizedKeysLine(cap);
|
|
417
|
+
if (line) resp.authorizedKeys = line; // pubkey con permitlisten: non un segreto
|
|
418
|
+
return send(res, 200, resp);
|
|
419
|
+
}
|
|
420
|
+
const msg = lastLine(cap, 'node on fallito');
|
|
421
|
+
if (out.reason === 'readonly') return send(res, 403, { error: msg });
|
|
422
|
+
if (out.reason === 'no rendezvous') return send(res, 400, { error: msg });
|
|
423
|
+
if (out.reason === 'permitlisten') return send(res, 409, { error: msg });
|
|
424
|
+
send(res, 500, { error: msg });
|
|
425
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// --- POST /service/regenerate — rigenera l'unit service --------------------
|
|
429
|
+
// Riusa generateService/installService (lib/cli/service.js: escaping per-platform,
|
|
430
|
+
// no-symlink, tmp+rename atomico). L'execImpl e' un NO-OP che registra i comandi
|
|
431
|
+
// di attivazione SENZA eseguirli: il contratto B2 vieta il restart automatico
|
|
432
|
+
// dall'API (la UI avvisa di riavviare a mano).
|
|
433
|
+
r.post('/service/regenerate', mutGate, (_req, res) => {
|
|
434
|
+
try {
|
|
435
|
+
const { cfg: fileCfg } = readConfigFile(configPath);
|
|
436
|
+
const port = nodesStore.isPort(fileCfg.port) ? fileCfg.port : cfg.port;
|
|
437
|
+
const ctx = {
|
|
438
|
+
repoRoot: repoRoot(),
|
|
439
|
+
nodeBin: nodeBin(),
|
|
440
|
+
port, home,
|
|
441
|
+
uid: seams.uid || uid(),
|
|
442
|
+
installPath: seams.serviceInstallPath,
|
|
443
|
+
};
|
|
444
|
+
const content = generateService(platform, ctx);
|
|
445
|
+
const skipped = [];
|
|
446
|
+
const noExec = (bin, args) => { skipped.push(`${bin} ${(args || []).join(' ')}`); };
|
|
447
|
+
const out = installService(platform, content, ctx, { execImpl: noExec });
|
|
448
|
+
send(res, 200, {
|
|
449
|
+
regenerated: true,
|
|
450
|
+
target: out.target,
|
|
451
|
+
note: 'unit rigenerata; nessun restart automatico — riavvia il service per applicarla',
|
|
452
|
+
skippedActivation: skipped,
|
|
453
|
+
});
|
|
454
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// Error handler del router: body JSON malformato (express.json) -> 400 con causa;
|
|
458
|
+
// qualunque altro errore -> status coerente. MAI stack trace in risposta.
|
|
459
|
+
// eslint-disable-next-line no-unused-vars
|
|
460
|
+
r.use((err, _req, res, _next) => {
|
|
461
|
+
if (err && err.type === 'entity.parse.failed') {
|
|
462
|
+
return send(res, 400, { error: 'body JSON non valido' });
|
|
463
|
+
}
|
|
464
|
+
if (err && err.type === 'entity.too.large') {
|
|
465
|
+
return send(res, 400, { error: 'body troppo grande (limite 8kb)' });
|
|
466
|
+
}
|
|
467
|
+
send(res, (err && err.status) || 500, { error: String((err && err.message) || err) });
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
return r;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
module.exports = { settingsRoutes, deepScrubTokens };
|
package/lib/ws/bridge.js
CHANGED
|
@@ -46,7 +46,12 @@ function bindWs(ws, deps) {
|
|
|
46
46
|
['set-option', '-t', `=${msg.session}:`, 'window-size', 'latest'], () => {});
|
|
47
47
|
} catch (_) {}
|
|
48
48
|
pty = openAttach(msg.session, {
|
|
49
|
-
|
|
49
|
+
// readonlyDefault del server e' un PAVIMENTO, non un default: se il server
|
|
50
|
+
// e' READONLY nessun client puo' declassarlo (msg.readonly:false non deve
|
|
51
|
+
// vincere). Il client puo' solo AGGIUNGERE restrizione (attach read-only
|
|
52
|
+
// su un server read-write). Contratto design §4b(6): READONLY blocca anche
|
|
53
|
+
// le scritture PTY. (fix audit finale: prima il ?? faceva vincere il client.)
|
|
54
|
+
readonly: defaults.readonlyDefault === true || !!msg.readonly,
|
|
50
55
|
takeSize,
|
|
51
56
|
cols: clamp(msg.cols, 20, 300, 80),
|
|
52
57
|
rows: clamp(msg.rows, 5, 120, 24),
|
|
@@ -68,6 +73,10 @@ function bindWs(ws, deps) {
|
|
|
68
73
|
try { msg = JSON.parse(data.toString()); } catch (_) { return; }
|
|
69
74
|
if (msg.type === 'attach') return fail(1002, 'already attached'); // no 2nd attach
|
|
70
75
|
if (msg.type === 'resize') pty.resize(clamp(msg.cols, 20, 300, 80), clamp(msg.rows, 5, 120, 24));
|
|
76
|
+
// focus: il tile che prende il focus diventa size-owner (promote); perdendolo
|
|
77
|
+
// torna ignore-size (demote). Cosi' N deck/tile sulla stessa sessione NON si
|
|
78
|
+
// contendono la geometria: comanda solo chi ha il focus (§5b size policy).
|
|
79
|
+
else if (msg.type === 'focus') { if (msg.on) pty.promote(); else pty.demote(); }
|
|
71
80
|
else if (msg.type === 'input') pty.write(typeof msg.data === 'string' ? msg.data : '');
|
|
72
81
|
else if (msg.type === 'key') pty.write(typeof msg.seq === 'string' ? msg.seq.slice(0, 64) : '');
|
|
73
82
|
else if (msg.type === 'action') runAction(session, msg.name); // nav window/pane server-side
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmmbuto/nexuscrew",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Faithful browser tmux client
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
|
|
5
5
|
"main": "lib/server.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"nexuscrew": "bin/nexuscrew.js"
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/",
|
|
11
11
|
"lib/",
|
|
12
|
+
"skills/",
|
|
12
13
|
"frontend/dist/",
|
|
13
14
|
"frontend/index.html",
|
|
14
15
|
"LICENSE",
|
|
@@ -25,9 +26,14 @@
|
|
|
25
26
|
"@mmmbuto/pty-termux-utils": "^1.1.4",
|
|
26
27
|
"express": "^4.21.0",
|
|
27
28
|
"multer": "^2.2.0",
|
|
29
|
+
"qrcode-terminal": "^0.12.0",
|
|
30
|
+
"web-push": "^3.6.7",
|
|
28
31
|
"ws": "^8.18.0"
|
|
29
32
|
},
|
|
30
33
|
"optionalDependencies": {
|
|
34
|
+
"@lydell/node-pty-darwin-arm64": "^1.2.0-beta.14",
|
|
35
|
+
"@lydell/node-pty-darwin-x64": "^1.2.0-beta.14",
|
|
36
|
+
"@lydell/node-pty-linux-arm64": "^1.2.0-beta.12",
|
|
31
37
|
"@lydell/node-pty-linux-x64": "^1.2.0-beta.12",
|
|
32
38
|
"node-pty": "^1.1.0"
|
|
33
39
|
},
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nexuscrew-agent
|
|
3
|
+
description: Use when an AI agent connected to NexusCrew must notify or ask the human, inspect NexusCrew session/fleet status, read its inbox, deliver a file (report, screenshot, export), send text/input to a tmux session, or recover from tmux send-keys messages that remain unsubmitted or arrive garbled. Prefer the NexusCrew MCP tools nc_notify, nc_ask, nc_status, nc_inbox, and nc_send_file when exposed; use the bundled tmux/file helpers as fallback.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# NexusCrew Agent I/O
|
|
7
|
+
|
|
8
|
+
Use the [NexusCrew](https://github.com/DioNanos/nexuscrew) MCP bridge for communication with the human and read-only runtime status. Use the bundled helpers for direct tmux messaging or as a compatibility fallback.
|
|
9
|
+
|
|
10
|
+
## MCP bridge (preferred)
|
|
11
|
+
|
|
12
|
+
When the client exposes the NexusCrew MCP server, use these tools directly:
|
|
13
|
+
|
|
14
|
+
| Goal | MCP tool |
|
|
15
|
+
|---|---|
|
|
16
|
+
| Notify the human about a result, blocker, or milestone | `nc_notify` |
|
|
17
|
+
| Ask for a decision without blocking the agent | `nc_ask` |
|
|
18
|
+
| Inspect live tmux sessions and fleet cells | `nc_status` |
|
|
19
|
+
| List files received for the current session | `nc_inbox` |
|
|
20
|
+
| Deliver an absolute file path under the user's home | `nc_send_file` |
|
|
21
|
+
|
|
22
|
+
Apply these rules:
|
|
23
|
+
|
|
24
|
+
- Use `nc_notify` for meaningful asynchronous updates, failures requiring attention, and completion. Do not notify for every command or duplicate routine chat commentary.
|
|
25
|
+
- Never include access tokens, credentials, private keys, push subscriptions, or other secrets in a notification, ask, file caption, or tool result.
|
|
26
|
+
- Treat `nc_ask` as non-blocking: it returns an ask ID immediately. Continue safe independent work or wait normally; the human response arrives in the originating tmux session with a `[human reply · ask#<id>]` prefix by default.
|
|
27
|
+
- Use `nc_status` instead of scraping NexusCrew state files. Use `nc_inbox` instead of guessing an inbox path when the tool is available.
|
|
28
|
+
- Pass `nc_send_file` an existing absolute regular-file path below the user's home. Let NexusCrew choose and sanitize the outbox name.
|
|
29
|
+
- Do not treat an MCP notification as a substitute for the final response required by the active client.
|
|
30
|
+
|
|
31
|
+
The MCP server is the stdio command `nexuscrew mcp` and must be registered in the host AI client. If the `nc_*` tools are not exposed, report that the bridge is not configured in that session and use the fallback flows below where applicable.
|
|
32
|
+
|
|
33
|
+
## File exchange (inbox / outbox)
|
|
34
|
+
|
|
35
|
+
Per session, NexusCrew watches `<root>/<session>/{inbox,outbox}` (root = `$NEXUSCREW_FILES_ROOT`, default `~/NexusFiles`):
|
|
36
|
+
|
|
37
|
+
- **outbox** — files you write here surface in the browser UI with a badge. This is how you deliver a deliverable.
|
|
38
|
+
- **inbox** — files the human sends arrive here. The path reaches you in your prompt either way, but through two different human-side flows: the files-panel upload pastes the bare path straight into your PTY, while the composer **attach button** (paperclip, v0.7.7+) puts the path inside the human's typed message — expect paths mixed with instructions, not always a bare path on its own line. Read the files from the inbox; never overwrite, treat as read-only input.
|
|
39
|
+
|
|
40
|
+
When `nc_send_file` is unavailable, deliver with the helper (resolves the current tmux session, timestamps, never overwrites):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
bin/nc-deliver report.pdf chart.png # → ~/NexusFiles/<session>/outbox/
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Don't hand-craft the path from a guessed session name — use `nc-deliver`, or derive the session with `tmux display-message -p '#S'`.
|
|
47
|
+
|
|
48
|
+
## Sending text to a tmux session
|
|
49
|
+
|
|
50
|
+
`tmux send-keys 'msg' Enter` is **not** reliable: a TUI's paste-burst detector swallows the Enter and the message just sits in the composer, while exit code is still 0. Use the helper:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
bin/nc-send <session> "text" # paste + submit
|
|
54
|
+
bin/nc-send <session> --file prompt.txt # from a file
|
|
55
|
+
bin/nc-send <session> --no-submit "text" # leave in composer, no Enter
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
It does: `load-buffer` → `paste-buffer -p` (bracketed paste) → burst-flush (`C-e`) → `Enter`. **Verify it landed** — never trust the exit code:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
tmux capture-pane -t <session> -p | tail -8 # see the text / a running state
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Quick reference
|
|
65
|
+
|
|
66
|
+
| Goal | Do this |
|
|
67
|
+
|---|---|
|
|
68
|
+
| Notify the human | `nc_notify` |
|
|
69
|
+
| Ask the human without blocking | `nc_ask` |
|
|
70
|
+
| Inspect NexusCrew runtime state | `nc_status` |
|
|
71
|
+
| Give the human a file | `nc_send_file` or fallback `nc-deliver <file>...` |
|
|
72
|
+
| Read a file the human sent | `nc_inbox` or fallback to the path in the prompt |
|
|
73
|
+
| Send a prompt/command to a session | `nc-send <session> "text"` |
|
|
74
|
+
| Queue text without running it | `nc-send <session> --no-submit "text"` |
|
|
75
|
+
| Confirm a send worked | `tmux capture-pane -t <session> -p | tail` |
|
|
76
|
+
|
|
77
|
+
## Common mistakes
|
|
78
|
+
|
|
79
|
+
- **Trusting `send-keys ... Enter`** → message stuck in composer. Use `nc-send` (bracketed paste + flush).
|
|
80
|
+
- **Not verifying the send** → exit 0 means nothing; always `capture-pane`.
|
|
81
|
+
- **`tmux` aliased/wrapped by the shell** (e.g. an oh-my-zsh plugin) → the nudge silently fails. Helpers resolve the real binary; in ad-hoc commands use `/usr/bin/tmux` or `command tmux`.
|
|
82
|
+
- **Pasting onto a dirty composer** → text concatenates with whatever was there. Clear it first, or the previous line will merge with yours.
|
|
83
|
+
- **Delivering to a guessed session name** → file lands in an orphan folder with no badge. Use `nc-deliver` (it reads the real session).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# nc-deliver — deliver files to the human via the NexusCrew outbox (shows a
|
|
3
|
+
# badge in the browser UI). Writes into <root>/<session>/outbox/, where root is
|
|
4
|
+
# $NEXUSCREW_FILES_ROOT (default ~/NexusFiles) and session is the current tmux
|
|
5
|
+
# session. Files are timestamp-prefixed; never overwrites.
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
root="${NEXUSCREW_FILES_ROOT:-$HOME/NexusFiles}"
|
|
9
|
+
session="${NEXUSCREW_SESSION:-$(tmux display-message -p '#S' 2>/dev/null || true)}"
|
|
10
|
+
|
|
11
|
+
[ -n "$session" ] || { echo "nc-deliver: no tmux session (run inside tmux, or set NEXUSCREW_SESSION)" >&2; exit 2; }
|
|
12
|
+
[ "$#" -ge 1 ] || { echo "usage: nc-deliver <file> [file...]" >&2; exit 2; }
|
|
13
|
+
|
|
14
|
+
dir="$root/$session/outbox"
|
|
15
|
+
mkdir -p "$dir"
|
|
16
|
+
|
|
17
|
+
for f in "$@"; do
|
|
18
|
+
[ -f "$f" ] || { echo "nc-deliver: not a file: $f" >&2; exit 1; }
|
|
19
|
+
dest="$dir/$(date +%Y%m%d-%H%M)_$(basename -- "$f")"
|
|
20
|
+
i=1; while [ -e "$dest" ]; do dest="$dir/$(date +%Y%m%d-%H%M)_${i}-$(basename -- "$f")"; i=$((i+1)); done
|
|
21
|
+
cp -- "$f" "$dest"
|
|
22
|
+
echo "delivered → $dest"
|
|
23
|
+
done
|