@mmmbuto/nexuscrew 0.4.2 → 0.7.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 +37 -5
- package/bin/nexuscrew.js +5 -1
- package/frontend/dist/assets/index-BWhSqR3J.css +32 -0
- package/frontend/dist/assets/index-DQMx2p4x.js +81 -0
- package/frontend/dist/index.html +3 -3
- package/frontend/index.html +1 -1
- package/lib/auth/middleware.js +17 -0
- package/lib/auth/token.js +43 -5
- package/lib/cli/commands.js +198 -0
- package/lib/cli/init.js +149 -0
- package/lib/cli/pidfile.js +100 -0
- package/lib/cli/platform.js +35 -0
- package/lib/cli/service.js +229 -0
- package/lib/config.js +59 -9
- package/lib/files/routes.js +60 -0
- package/lib/files/store.js +127 -0
- package/lib/files/watcher.js +69 -0
- package/lib/fleet/exec.js +32 -0
- package/lib/fleet/index.js +99 -0
- package/lib/fleet/routes.js +34 -0
- package/lib/server.js +90 -9
- package/lib/tmux/actions.js +65 -3
- package/lib/tmux/lifecycle.js +86 -0
- package/lib/tmux/list.js +4 -2
- package/lib/tmux/preview.js +73 -0
- package/lib/voice/transcribe.js +41 -0
- package/lib/ws/bridge.js +2 -1
- package/package.json +12 -8
- package/frontend/dist/assets/index-BZIjqGMo.css +0 -32
- package/frontend/dist/assets/index-Bl1a-2Lf.js +0 -81
package/frontend/dist/index.html
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
|
6
6
|
<meta name="theme-color" content="#0d1117" />
|
|
7
7
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
8
8
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="NexusCrew" />
|
|
12
12
|
<link rel="manifest" href="/manifest.json" />
|
|
13
13
|
<title>NexusCrew</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-DQMx2p4x.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BWhSqR3J.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="root"></div>
|
package/frontend/index.html
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
|
6
6
|
<meta name="theme-color" content="#0d1117" />
|
|
7
7
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
8
8
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { verify } = require('./token.js');
|
|
3
|
+
|
|
4
|
+
function bearerFrom(req) {
|
|
5
|
+
const m = String(req.headers.authorization || '').match(/^Bearer\s+(.+)$/i);
|
|
6
|
+
return m ? m[1].trim() : '';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// 401 uniforme: nessun dettaglio su cosa è andato storto.
|
|
10
|
+
function requireToken(token) {
|
|
11
|
+
return (req, res, next) => {
|
|
12
|
+
if (verify(token, bearerFrom(req))) return next();
|
|
13
|
+
res.status(401).json({ error: 'unauthorized' });
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { requireToken, bearerFrom };
|
package/lib/auth/token.js
CHANGED
|
@@ -3,14 +3,52 @@ const fs = require('node:fs');
|
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const crypto = require('node:crypto');
|
|
5
5
|
|
|
6
|
+
// Exclusive create + anti-symlink. [M4]
|
|
7
|
+
// - file esistente non-vuoto (lstat, NO follow symlink) -> preserva (mai overwrite)
|
|
8
|
+
// - symlink path -> reject
|
|
9
|
+
// - file vuoto -> unlink + ricrea exclusive (wx)
|
|
10
|
+
// - race con altro processo (EEXIST) -> leggi il token dell'altro
|
|
11
|
+
// Legge un token esistente in modo safe (lstat no-follow, reject symlink, regular file).
|
|
12
|
+
// Usato sia dal path iniziale che dal ramo EEXIST (race symlink-safe). [M2]
|
|
13
|
+
function readTokenSafe(tokenPath) {
|
|
14
|
+
const st = fs.lstatSync(tokenPath); // lstat: no follow symlink
|
|
15
|
+
if (st.isSymbolicLink()) {
|
|
16
|
+
throw new Error(`refusing symlink token path: ${tokenPath}`);
|
|
17
|
+
}
|
|
18
|
+
if (st.isFile()) {
|
|
19
|
+
const t = fs.readFileSync(tokenPath, 'utf8').trim();
|
|
20
|
+
return t || null;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
6
25
|
function loadOrCreateToken(tokenPath) {
|
|
7
26
|
try {
|
|
8
|
-
const
|
|
9
|
-
if (
|
|
10
|
-
|
|
27
|
+
const st = fs.lstatSync(tokenPath); // lstat: no follow symlink
|
|
28
|
+
if (st.isSymbolicLink()) {
|
|
29
|
+
throw new Error(`refusing symlink token path: ${tokenPath}`);
|
|
30
|
+
}
|
|
31
|
+
if (st.isFile()) {
|
|
32
|
+
const t = fs.readFileSync(tokenPath, 'utf8').trim();
|
|
33
|
+
if (t) return t; // preserva esistente valido
|
|
34
|
+
fs.unlinkSync(tokenPath); // file vuoto: ricrea exclusive
|
|
35
|
+
}
|
|
36
|
+
} catch (e) {
|
|
37
|
+
if (e.code !== 'ENOENT') throw e; // symlink/permessi/altro -> propaga
|
|
38
|
+
// ENOENT: non esiste, crea sotto
|
|
39
|
+
}
|
|
11
40
|
fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
|
|
12
41
|
const tok = crypto.randomBytes(24).toString('base64url');
|
|
13
|
-
|
|
42
|
+
try {
|
|
43
|
+
fs.writeFileSync(tokenPath, tok + '\n', { flag: 'wx', mode: 0o600 }); // exclusive create
|
|
44
|
+
} catch (e) {
|
|
45
|
+
if (e.code === 'EEXIST') {
|
|
46
|
+
// un altro processo ha creato nel frattempo: re-validate no-follow (race symlink-safe, M2)
|
|
47
|
+
const t = readTokenSafe(tokenPath);
|
|
48
|
+
if (t) return t;
|
|
49
|
+
}
|
|
50
|
+
throw e;
|
|
51
|
+
}
|
|
14
52
|
fs.chmodSync(tokenPath, 0o600);
|
|
15
53
|
return tok;
|
|
16
54
|
}
|
|
@@ -23,4 +61,4 @@ function verify(expected, given) {
|
|
|
23
61
|
return crypto.timingSafeEqual(a, b);
|
|
24
62
|
}
|
|
25
63
|
|
|
26
|
-
module.exports = { loadOrCreateToken, verify };
|
|
64
|
+
module.exports = { loadOrCreateToken, readTokenSafe, verify };
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// CLI dispatcher: init / serve / start / stop / status. [M6][R1]
|
|
3
|
+
// serve = foreground HTTP (+ --pidfile lifecycle su Termux/manuale).
|
|
4
|
+
// start/stop/status = per-platform: linux (systemctl --user), mac (launchctl),
|
|
5
|
+
// termux (nohup serve --pidfile + pidfile verificato; status boot-script vs running).
|
|
6
|
+
const { execFileSync, spawn } = require('node:child_process');
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
|
|
10
|
+
const pidf = require('./pidfile.js');
|
|
11
|
+
const { runInit } = require('./init.js');
|
|
12
|
+
|
|
13
|
+
const HELP = `NexusCrew (portable) — browser tmux client.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
nexuscrew init [--dry-run] [--port N] setup: detect + config + token + service + URL
|
|
17
|
+
nexuscrew serve [--pidfile] HTTP server foreground (dev / ExecStart)
|
|
18
|
+
nexuscrew start avvia il servizio (systemctl / launchctl / nohup+pidfile)
|
|
19
|
+
nexuscrew stop stop del servizio (service manager / pidfile verificato)
|
|
20
|
+
nexuscrew status stato: platform + service + porta + URL
|
|
21
|
+
|
|
22
|
+
Piattaforme: linux (systemd --user), mac (launchd), termux (nohup + pidfile).
|
|
23
|
+
Bind loopback 127.0.0.1 — raggiungibile via tunnel SSH/VPN.`;
|
|
24
|
+
|
|
25
|
+
function parseFlags(argv) {
|
|
26
|
+
const flags = {};
|
|
27
|
+
const rest = [];
|
|
28
|
+
for (const a of argv) {
|
|
29
|
+
if (a.startsWith('--')) {
|
|
30
|
+
const [k, v] = a.slice(2).split('=');
|
|
31
|
+
flags[k] = v !== undefined ? v : true;
|
|
32
|
+
} else rest.push(a);
|
|
33
|
+
}
|
|
34
|
+
return { flags, rest };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function serve(opts = {}) {
|
|
38
|
+
const serverStart = opts.serverStart || require('../server.js').start;
|
|
39
|
+
if (opts.pidfile) {
|
|
40
|
+
const pidPath = pidf.defaultPidfilePath();
|
|
41
|
+
// already-running check
|
|
42
|
+
const meta = pidf.readPidfile(pidPath);
|
|
43
|
+
if (meta && pidf.isAlive(meta)) {
|
|
44
|
+
throw new Error(`already running (pid ${meta.pid}) — pidfile ${pidPath}`);
|
|
45
|
+
}
|
|
46
|
+
pidf.cleanStale(pidPath);
|
|
47
|
+
const cmd = [process.execPath].concat(process.argv.slice(1)).join(' ');
|
|
48
|
+
pidf.writePidfile(pidPath, process.pid, cmd);
|
|
49
|
+
const cleanup = () => pidf.removePidfile(pidPath);
|
|
50
|
+
process.on('SIGINT', () => { cleanup(); process.exit(0); });
|
|
51
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
|
52
|
+
process.on('exit', cleanup);
|
|
53
|
+
}
|
|
54
|
+
serverStart(opts);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// start per-platform. execImpl/spawnImpl per test.
|
|
58
|
+
function start(opts = {}) {
|
|
59
|
+
const platform = opts.platform || detectPlatform();
|
|
60
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
61
|
+
const spawnImpl = opts.spawnImpl || spawn;
|
|
62
|
+
const log = opts.log || console.log;
|
|
63
|
+
|
|
64
|
+
if (platform === 'linux') {
|
|
65
|
+
execImpl('systemctl', ['--user', 'start', 'nexuscrew'], { stdio: 'ignore' });
|
|
66
|
+
log('start: systemctl --user start nexuscrew');
|
|
67
|
+
return { platform, started: true };
|
|
68
|
+
}
|
|
69
|
+
if (platform === 'mac') {
|
|
70
|
+
const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
71
|
+
execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
|
|
72
|
+
log(`start: launchctl kickstart ${label}`);
|
|
73
|
+
return { platform, started: true };
|
|
74
|
+
}
|
|
75
|
+
if (platform === 'termux') {
|
|
76
|
+
// already-running?
|
|
77
|
+
const pidPath = pidf.defaultPidfilePath();
|
|
78
|
+
const meta = pidf.readPidfile(pidPath);
|
|
79
|
+
if (meta && pidf.isAlive(meta)) {
|
|
80
|
+
log(`already running (pid ${meta.pid}) — pidfile ${pidPath}`);
|
|
81
|
+
return { platform, started: false, reason: 'already running' };
|
|
82
|
+
}
|
|
83
|
+
pidf.cleanStale(pidPath);
|
|
84
|
+
const repoBin = path.join(repoRoot(), 'bin', 'nexuscrew.js');
|
|
85
|
+
const logPath = path.join(require('node:os').homedir(), '.nexuscrew', 'nexuscrew.log');
|
|
86
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
87
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
88
|
+
const child = spawnImpl(nodeBin(), [repoBin, 'serve', '--pidfile'], {
|
|
89
|
+
detached: true, stdio: ['ignore', logFd, logFd],
|
|
90
|
+
});
|
|
91
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
92
|
+
log(`start: nohup ${nodeBin()} ${repoBin} serve --pidfile (>> ${logPath})`);
|
|
93
|
+
return { platform, started: true, pid: child && child.pid };
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`start: platform ${platform} non supportata`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stop(opts = {}) {
|
|
99
|
+
const platform = opts.platform || detectPlatform();
|
|
100
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
101
|
+
const log = opts.log || console.log;
|
|
102
|
+
|
|
103
|
+
if (platform === 'linux') {
|
|
104
|
+
execImpl('systemctl', ['--user', 'stop', 'nexuscrew'], { stdio: 'ignore' });
|
|
105
|
+
log('stop: systemctl --user stop nexuscrew');
|
|
106
|
+
return { platform, stopped: true };
|
|
107
|
+
}
|
|
108
|
+
if (platform === 'mac') {
|
|
109
|
+
const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
110
|
+
execImpl('launchctl', ['kill', 'SIGTERM', label], { stdio: 'ignore' });
|
|
111
|
+
log(`stop: launchctl kill SIGTERM ${label}`);
|
|
112
|
+
return { platform, stopped: true };
|
|
113
|
+
}
|
|
114
|
+
if (platform === 'termux') {
|
|
115
|
+
// kill via pidfile verificato (no broad match) + wake-lock-release
|
|
116
|
+
const pidPath = pidf.defaultPidfilePath();
|
|
117
|
+
const r = pidf.killPidfile(pidPath);
|
|
118
|
+
try { execImpl('termux-wake-lock-release', [], { stdio: 'ignore' }); } catch (_) {}
|
|
119
|
+
log(`stop: ${r.killed ? `killed pid ${r.pid}` : r.reason}`);
|
|
120
|
+
return { platform, stopped: r.killed, reason: r.reason };
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`stop: platform ${platform} non supportata`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function status(opts = {}) {
|
|
126
|
+
const platform = opts.platform || detectPlatform();
|
|
127
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
128
|
+
const log = opts.log || console.log;
|
|
129
|
+
const home = opts.home || require('node:os').homedir();
|
|
130
|
+
|
|
131
|
+
const out = { platform, service: null, running: null, port: null, url: null };
|
|
132
|
+
|
|
133
|
+
if (platform === 'linux') {
|
|
134
|
+
try {
|
|
135
|
+
const s = execImpl('systemctl', ['--user', 'is-active', 'nexuscrew'], { encoding: 'utf8' }).trim();
|
|
136
|
+
out.service = s; out.running = s === 'active';
|
|
137
|
+
} catch (_) { out.service = 'inactive'; out.running = false; }
|
|
138
|
+
} else if (platform === 'mac') {
|
|
139
|
+
out.service = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
140
|
+
try {
|
|
141
|
+
execImpl('launchctl', ['print', out.service], { stdio: 'ignore' });
|
|
142
|
+
out.running = true;
|
|
143
|
+
} catch (_) { out.running = false; }
|
|
144
|
+
} else if (platform === 'termux') {
|
|
145
|
+
// boot-script installed vs server running (pidfile vivo)
|
|
146
|
+
const bootScript = path.join(home, '.termux', 'boot', 'nexuscrew.sh');
|
|
147
|
+
out.bootScriptInstalled = fs.existsSync(bootScript);
|
|
148
|
+
const meta = pidf.readPidfile(pidf.defaultPidfilePath());
|
|
149
|
+
out.running = !!(meta && pidf.isAlive(meta));
|
|
150
|
+
out.service = out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// porta da config.json
|
|
154
|
+
try {
|
|
155
|
+
const { loadConfig } = require('../config.js');
|
|
156
|
+
const cfg = loadConfig();
|
|
157
|
+
out.port = cfg.port;
|
|
158
|
+
out.url = `http://127.0.0.1:${cfg.port}/`;
|
|
159
|
+
} catch (_) {}
|
|
160
|
+
|
|
161
|
+
log(JSON.stringify(out, null, 2));
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function dispatch(argv, opts = {}) {
|
|
166
|
+
const { flags, rest } = parseFlags(argv);
|
|
167
|
+
const cmd = rest[0];
|
|
168
|
+
const log = opts.log || console.log;
|
|
169
|
+
|
|
170
|
+
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
171
|
+
log(HELP);
|
|
172
|
+
return { code: 0 };
|
|
173
|
+
}
|
|
174
|
+
if (cmd === 'init') {
|
|
175
|
+
runInit({ ...opts, dryRun: flags['dry-run'], port: flags.port ? Number(flags.port) : undefined, log });
|
|
176
|
+
return { code: 0 };
|
|
177
|
+
}
|
|
178
|
+
if (cmd === 'serve') {
|
|
179
|
+
serve({ pidfile: flags.pidfile, serverStart: opts.serverStart });
|
|
180
|
+
return { code: 0, keepAlive: true }; // server.listen tiene il processo vivo; non exit
|
|
181
|
+
}
|
|
182
|
+
if (cmd === 'start') {
|
|
183
|
+
start({ execImpl: opts.execImpl, spawnImpl: opts.spawnImpl, log, platform: opts.platform, uid: opts.uid });
|
|
184
|
+
return { code: 0 };
|
|
185
|
+
}
|
|
186
|
+
if (cmd === 'stop') {
|
|
187
|
+
stop({ execImpl: opts.execImpl, log, platform: opts.platform, uid: opts.uid });
|
|
188
|
+
return { code: 0 };
|
|
189
|
+
}
|
|
190
|
+
if (cmd === 'status') {
|
|
191
|
+
status({ execImpl: opts.execImpl, log, platform: opts.platform, uid: opts.uid, home: opts.home });
|
|
192
|
+
return { code: 0 };
|
|
193
|
+
}
|
|
194
|
+
log(`unknown command: ${cmd}\n\n${HELP}`);
|
|
195
|
+
return { code: 1 };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { dispatch, serve, start, stop, status, parseFlags, HELP };
|
package/lib/cli/init.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// nexuscrew init: orchestrazione setup portatile. [B2][M8][R4]
|
|
3
|
+
// detectPlatform -> prereq (Node>=18 abort, tmux abort-before-service) ->
|
|
4
|
+
// migration rule (porta da service esistente) -> config.json (preserva) ->
|
|
5
|
+
// token (preserva) -> NexusFiles -> generateService -> installService (skip dry-run) ->
|
|
6
|
+
// print URL #token. Termux:boot detection best-effort.
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const os = require('node:os');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const { execFileSync } = require('node:child_process');
|
|
11
|
+
const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
|
|
12
|
+
const { loadOrCreateToken } = require('../auth/token.js');
|
|
13
|
+
const { generateService, installService, fileMode, installPath: svcInstallPath } = require('./service.js');
|
|
14
|
+
|
|
15
|
+
function haveTmux(tmuxBin) {
|
|
16
|
+
try { execFileSync('command', ['-v', tmuxBin], { stdio: 'ignore', shell: true }); return true; }
|
|
17
|
+
catch (_) { return false; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function nodeMajor() {
|
|
21
|
+
return parseInt(String(process.versions.node).split('.')[0], 10);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Migration rule (B2): se non c'è config.json, parse la porta dal service file esistente.
|
|
25
|
+
function readExistingPort(platform, home, installPathOverride) {
|
|
26
|
+
const p = installPathOverride || svcInstallPath(platform, home);
|
|
27
|
+
let raw;
|
|
28
|
+
try { raw = fs.readFileSync(p, 'utf8'); } catch (_) { return null; }
|
|
29
|
+
if (platform === 'linux') {
|
|
30
|
+
const m = raw.match(/Environment=NEXUSCREW_PORT=(\d+)/);
|
|
31
|
+
return m ? Number(m[1]) : null;
|
|
32
|
+
}
|
|
33
|
+
if (platform === 'mac') {
|
|
34
|
+
const m = raw.match(/<key>NEXUSCREW_PORT<\/key>\s*<string>(\d+)/);
|
|
35
|
+
return m ? Number(m[1]) : null;
|
|
36
|
+
}
|
|
37
|
+
if (platform === 'termux') {
|
|
38
|
+
const m = raw.match(/export NEXUSCREW_PORT=(\d+)/);
|
|
39
|
+
return m ? Number(m[1]) : null;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runInit(opts = {}) {
|
|
45
|
+
const platform = opts.platform || detectPlatform();
|
|
46
|
+
const home = opts.home || os.homedir();
|
|
47
|
+
const configDir = opts.configDir || path.join(home, '.nexuscrew');
|
|
48
|
+
const configPath = opts.configPath || path.join(configDir, 'config.json');
|
|
49
|
+
const tokenPath = opts.tokenPath || path.join(configDir, 'token');
|
|
50
|
+
const filesRoot = opts.filesRoot || path.join(home, 'NexusFiles');
|
|
51
|
+
const dryRun = !!opts.dryRun;
|
|
52
|
+
const log = opts.log || (() => {});
|
|
53
|
+
const tmuxOk = opts.tmuxOk !== undefined ? opts.tmuxOk : haveTmux(opts.tmuxBin || 'tmux');
|
|
54
|
+
|
|
55
|
+
// prereq Node (abort before any write) [M8]
|
|
56
|
+
if (nodeMajor() < 18) {
|
|
57
|
+
throw new Error(`Node >= 18 richiesto (attuale ${process.versions.node}). Aggiorna Node prima di init.`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const actions = [];
|
|
61
|
+
|
|
62
|
+
// porta: opts.port > migration rule > config esistente > default 41820
|
|
63
|
+
let port = opts.port;
|
|
64
|
+
if (!port) {
|
|
65
|
+
const migrated = readExistingPort(platform, home, opts.installPath);
|
|
66
|
+
if (migrated) { port = migrated; actions.push(`migration: porta ${port} letta dal service esistente`); }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// config.json (scrivi se non esiste; preserva se esiste) [B2]
|
|
70
|
+
if (!dryRun) {
|
|
71
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
72
|
+
if (!fs.existsSync(configPath)) {
|
|
73
|
+
if (!port) port = 41820;
|
|
74
|
+
fs.writeFileSync(configPath, JSON.stringify({ port }, null, 2) + '\n', { mode: 0o600 });
|
|
75
|
+
actions.push(`created config ${configPath} (port ${port})`);
|
|
76
|
+
} else {
|
|
77
|
+
try {
|
|
78
|
+
const c = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
79
|
+
if (c && c.port) port = c.port;
|
|
80
|
+
} catch (_) {}
|
|
81
|
+
actions.push(`preserved config ${configPath} (port ${port})`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!port) port = 41820;
|
|
85
|
+
|
|
86
|
+
// token (preserva esistente) [M4]
|
|
87
|
+
let token;
|
|
88
|
+
if (!dryRun) {
|
|
89
|
+
token = loadOrCreateToken(tokenPath);
|
|
90
|
+
actions.push(`token ok (${tokenPath})`);
|
|
91
|
+
} else {
|
|
92
|
+
token = null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// NexusFiles dir
|
|
96
|
+
if (!dryRun) {
|
|
97
|
+
fs.mkdirSync(filesRoot, { recursive: true });
|
|
98
|
+
actions.push(`files root ${filesRoot}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// service generation
|
|
102
|
+
const svcCtx = {
|
|
103
|
+
repoRoot: repoRoot(),
|
|
104
|
+
nodeBin: nodeBin(),
|
|
105
|
+
port, home, uid: uid(),
|
|
106
|
+
installPath: opts.installPath,
|
|
107
|
+
};
|
|
108
|
+
const content = generateService(platform, svcCtx);
|
|
109
|
+
|
|
110
|
+
if (dryRun) {
|
|
111
|
+
actions.push(`DRY-RUN service (${platform}) generato, NON installato`);
|
|
112
|
+
} else if (!tmuxOk) {
|
|
113
|
+
// tmux mancante: abort before service install (config/token gia' creati) [M8]
|
|
114
|
+
actions.push(`WARN: tmux non trovato su PATH -> service NON installato (installa tmux, ri-runna init)`);
|
|
115
|
+
} else {
|
|
116
|
+
try {
|
|
117
|
+
const r = installService(platform, content, svcCtx, { execImpl: opts.execImpl });
|
|
118
|
+
if (r.failures && r.failures.length) {
|
|
119
|
+
// file installato MA activation (systemctl/launchctl) fallita [M1]
|
|
120
|
+
actions.push(`WARN: service file installato ${r.target} MA activation fallita: ${r.failures.map((f) => f.cmd).join('; ')} (file preservato, diagnosi)`);
|
|
121
|
+
} else {
|
|
122
|
+
actions.push(`service installed ${r.target} (mode 0${fileMode(platform).toString(8)})`);
|
|
123
|
+
}
|
|
124
|
+
} catch (e) {
|
|
125
|
+
// failure: preserve file + diagnosi (no rollback) [M8]
|
|
126
|
+
actions.push(`WARN: service install fallito: ${e.message} (file generati preservati)`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Termux:boot best-effort detection (R4)
|
|
131
|
+
if (platform === 'termux') {
|
|
132
|
+
const bootDir = path.join(home, '.termux', 'boot');
|
|
133
|
+
const bootOk = fs.existsSync(bootDir);
|
|
134
|
+
actions.push(bootOk
|
|
135
|
+
? `Termux:boot dir presente — verifica app Termux:Boot installata/abilitata per l'avvio al reboot`
|
|
136
|
+
: `Termux:boot dir MANCANTE — installa/apri l'app Termux:Boot una volta per l'avvio automatico`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
140
|
+
const urlWithToken = token ? `${url}#token=${token}` : url;
|
|
141
|
+
actions.push(`platform: ${platform}`);
|
|
142
|
+
actions.push(`URL: ${urlWithToken}`);
|
|
143
|
+
|
|
144
|
+
for (const a of actions) log(a);
|
|
145
|
+
|
|
146
|
+
return { platform, port, token, url: urlWithToken, actions, tmuxOk, dryRun };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { runInit, readExistingPort, haveTmux, nodeMajor };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pidfile con verified kill: metadata {pid, cmd, startTs}; kill verifica cmd+pid
|
|
3
|
+
// prima di signalare (no PID reuse, no broad match by name). [R1]
|
|
4
|
+
// Primario su Termux (serve --pidfile); opzionale --manual su linux/mac.
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const { execFileSync } = require('node:child_process');
|
|
9
|
+
|
|
10
|
+
function defaultPidfilePath() {
|
|
11
|
+
return process.env.NEXUSCREW_PIDFILE || path.join(os.homedir(), '.nexuscrew', 'nexuscrew.pid');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readPidfile(p) {
|
|
15
|
+
try {
|
|
16
|
+
const obj = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
17
|
+
return (obj && typeof obj === 'object' && Number.isFinite(obj.pid)) ? obj : null;
|
|
18
|
+
} catch (_) { return null; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Exclusive create (wx): fallisce se il pidfile esiste già (no overwrite silenzioso).
|
|
22
|
+
function writePidfile(p, pid, cmd) {
|
|
23
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
24
|
+
const meta = JSON.stringify({ pid, cmd: cmd || '', startTs: Date.now() });
|
|
25
|
+
fs.writeFileSync(p, meta + '\n', { flag: 'wx', mode: 0o600 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function removePidfile(p) {
|
|
29
|
+
try { fs.unlinkSync(p); } catch (_) {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function pidExists(pid) {
|
|
33
|
+
try { process.kill(pid, 0); return true; }
|
|
34
|
+
catch (e) { return e.code === 'EPERM'; } // EPERM = esiste ma non nostro
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readCmdline(pid) {
|
|
38
|
+
// Linux/Termux: /proc/<pid>/cmdline; fallback ps (mac)
|
|
39
|
+
try {
|
|
40
|
+
return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
|
|
41
|
+
} catch (_) {
|
|
42
|
+
try { return execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' }).trim(); }
|
|
43
|
+
catch (_) { return ''; }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function cmdMatches(savedCmd, liveCmd) {
|
|
48
|
+
if (!savedCmd || !liveCmd) return true; // conservativo: non posso verificare, assumo match (no broad-kill)
|
|
49
|
+
return liveCmd.includes(savedCmd) || savedCmd.includes(liveCmd);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// true se il pid esiste E il cmd matcha (o non verificabile).
|
|
53
|
+
function isAlive(meta) {
|
|
54
|
+
if (!meta || !Number.isFinite(meta.pid)) return false;
|
|
55
|
+
if (!pidExists(meta.pid)) return false;
|
|
56
|
+
if (meta.cmd) {
|
|
57
|
+
const live = readCmdline(meta.pid);
|
|
58
|
+
if (live) return cmdMatches(meta.cmd, live);
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Rimuove pidfile stale (pid morto o non verificabile). Ritorna true se rimosso.
|
|
64
|
+
function cleanStale(p) {
|
|
65
|
+
const meta = readPidfile(p);
|
|
66
|
+
if (!meta) return false;
|
|
67
|
+
if (!isAlive(meta)) { removePidfile(p); return true; }
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Kill verificato: legge pidfile, verifica pid+cmd, signal. MAI broad match by name.
|
|
72
|
+
// Ritorna { killed, pid?, reason? }.
|
|
73
|
+
function killPidfile(p, signal = 'SIGTERM') {
|
|
74
|
+
const meta = readPidfile(p);
|
|
75
|
+
if (!meta) return { killed: false, reason: 'no pidfile' };
|
|
76
|
+
if (!pidExists(meta.pid)) {
|
|
77
|
+
removePidfile(p);
|
|
78
|
+
return { killed: false, reason: 'stale (pid dead)' };
|
|
79
|
+
}
|
|
80
|
+
if (meta.cmd) {
|
|
81
|
+
const live = readCmdline(meta.pid);
|
|
82
|
+
if (live && !cmdMatches(meta.cmd, live)) {
|
|
83
|
+
// PID reuse: processo diverso. Non killare. Rimuovi pidfile stale.
|
|
84
|
+
removePidfile(p);
|
|
85
|
+
return { killed: false, reason: 'pid reuse (cmd mismatch)', liveCmd: live };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
process.kill(meta.pid, signal);
|
|
90
|
+
removePidfile(p);
|
|
91
|
+
return { killed: true, pid: meta.pid };
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return { killed: false, reason: e.message };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
defaultPidfilePath, readPidfile, writePidfile, removePidfile,
|
|
99
|
+
pidExists, readCmdline, isAlive, cleanStale, killPidfile,
|
|
100
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Platform detection per NexusCrew portatile.
|
|
3
|
+
// Regole (in ordine): TERMUX_VERSION -> termux; PREFIX com.termux -> termux;
|
|
4
|
+
// process.platform === 'android' -> termux; 'darwin' -> mac; 'linux' -> linux.
|
|
5
|
+
// Allineato a lib/pty/provider.js (che gia tratta android come Termux).
|
|
6
|
+
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
|
|
9
|
+
function detectPlatform(ctx = {}) {
|
|
10
|
+
const env = ctx.env || process.env;
|
|
11
|
+
const platform = ctx.platform || process.platform;
|
|
12
|
+
if (env.TERMUX_VERSION) return 'termux';
|
|
13
|
+
if ((env.PREFIX || '').includes('com.termux')) return 'termux';
|
|
14
|
+
if (platform === 'android') return 'termux';
|
|
15
|
+
if (platform === 'darwin') return 'mac';
|
|
16
|
+
if (platform === 'linux') return 'linux';
|
|
17
|
+
return 'linux'; // fallback sicuro (loopback + tmux disponibili)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function nodeBin() {
|
|
21
|
+
// Mai hardcoded nvm: il node che sta girando.
|
|
22
|
+
return process.execPath;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function repoRoot() {
|
|
26
|
+
// lib/cli/platform.js -> ../.. = repo root.
|
|
27
|
+
return path.resolve(__dirname, '..', '..');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function uid() {
|
|
31
|
+
// Per launchd gui/<uid>. Fallback 501 (mac default) se getuid non disponibile.
|
|
32
|
+
try { return process.getuid(); } catch (_) { return 501; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { detectPlatform, nodeBin, repoRoot, uid };
|