@mmmbuto/nexuscrew 0.2.0 → 0.2.1
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 +79 -66
- package/bin/nexuscrew.js +467 -264
- package/frontend/dist/apple-touch-icon.png +0 -0
- package/frontend/dist/assets/{index-8pw4eMB-.js → index-BG1N7bSL.js} +268 -273
- package/frontend/dist/assets/index-CiAtinNP.css +1 -0
- package/frontend/dist/favicon.svg +20 -0
- package/frontend/dist/icon-192.png +0 -0
- package/frontend/dist/icon-512.png +0 -0
- package/frontend/dist/index.html +11 -3
- package/frontend/dist/site.webmanifest +29 -0
- package/lib/config/hosts.js +67 -0
- package/lib/config/manager.js +23 -6
- package/lib/server/routes/hosts.js +11 -20
- package/lib/server/routes/keys.js +13 -0
- package/lib/server/routes/send.js +52 -12
- package/lib/server/routes/sessions.js +9 -12
- package/lib/server/routes/speech.js +22 -51
- package/lib/server/routes/status.js +8 -6
- package/lib/server/routes/upload.js +2 -1
- package/lib/server/server.js +21 -20
- package/lib/server/services/attachment-manager.js +57 -0
- package/lib/server/services/speech-manager.js +76 -0
- package/lib/services/log-watcher.js +40 -22
- package/lib/services/remote-pane-watcher.js +155 -0
- package/lib/services/session-store.js +3 -3
- package/lib/services/tmux-manager.js +127 -116
- package/lib/setup/postinstall.js +38 -0
- package/lib/utils/paths.js +18 -1
- package/lib/utils/termux.js +38 -1
- package/package.json +3 -3
- package/frontend/dist/assets/index-BF0tdvNT.css +0 -1
package/bin/nexuscrew.js
CHANGED
|
@@ -2,353 +2,556 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const { Command } = require('commander');
|
|
5
|
+
const readline = require('readline/promises');
|
|
6
|
+
const { stdin, stdout } = require('process');
|
|
7
|
+
const { spawn, execSync } = require('child_process');
|
|
5
8
|
const path = require('path');
|
|
6
9
|
const fs = require('fs');
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
const http = require('http');
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
getConfig,
|
|
14
|
+
initializeConfig,
|
|
15
|
+
getConfigValue,
|
|
16
|
+
setConfigValue,
|
|
17
|
+
isInitialized,
|
|
18
|
+
saveConfig
|
|
19
|
+
} = require('../lib/config/manager');
|
|
20
|
+
const { readHosts, writeHosts, ensureHostsFile } = require('../lib/config/hosts');
|
|
21
|
+
const { PATHS, ensureDirectories } = require('../lib/utils/paths');
|
|
22
|
+
const {
|
|
23
|
+
isTermux,
|
|
24
|
+
installPackage,
|
|
25
|
+
isPackageInstalled,
|
|
26
|
+
isTermuxBootAvailable,
|
|
27
|
+
isTermuxServicesAvailable,
|
|
28
|
+
getServiceDir,
|
|
29
|
+
getStartServicesScript,
|
|
30
|
+
openUrl
|
|
31
|
+
} = require('../lib/utils/termux');
|
|
32
|
+
const TmuxManager = require('../lib/services/tmux-manager');
|
|
33
|
+
const SpeechManager = require('../lib/server/services/speech-manager');
|
|
34
|
+
const EngineDiscovery = require('../lib/services/engine-discovery');
|
|
14
35
|
|
|
15
36
|
const program = new Command();
|
|
37
|
+
|
|
38
|
+
function ensureBaseState() {
|
|
39
|
+
ensureDirectories();
|
|
40
|
+
ensureHostsFile();
|
|
41
|
+
if (!isInitialized()) {
|
|
42
|
+
initializeConfig({ workspaces: [path.join(process.env.HOME || '', 'Dev')] });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function commandExists(command) {
|
|
47
|
+
try {
|
|
48
|
+
execSync(`which ${command}`, { stdio: 'ignore' });
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function prompt(question, fallback = '') {
|
|
56
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
57
|
+
try {
|
|
58
|
+
const answer = await rl.question(`${question}${fallback ? ` [${fallback}]` : ''}: `);
|
|
59
|
+
return answer.trim() || fallback;
|
|
60
|
+
} finally {
|
|
61
|
+
rl.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function promptBoolean(question, defaultValue = false) {
|
|
66
|
+
const answer = await prompt(question, defaultValue ? 'Y' : 'N');
|
|
67
|
+
return answer.toLowerCase() === 'y';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getProjectRoot() {
|
|
71
|
+
return path.join(__dirname, '..');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getServerEntry() {
|
|
75
|
+
return path.join(getProjectRoot(), 'lib', 'server', 'server.js');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getServerUrl(port) {
|
|
79
|
+
return `http://localhost:${port}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function ensureParentDir(filePath) {
|
|
83
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readPidFile() {
|
|
87
|
+
if (!fs.existsSync(PATHS.PID_FILE)) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const pid = Number(fs.readFileSync(PATHS.PID_FILE, 'utf8').trim());
|
|
92
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isPidRunning(pid) {
|
|
96
|
+
if (!pid) return false;
|
|
97
|
+
try {
|
|
98
|
+
process.kill(pid, 0);
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function writePidFile(pid) {
|
|
106
|
+
ensureParentDir(PATHS.PID_FILE);
|
|
107
|
+
fs.writeFileSync(PATHS.PID_FILE, `${pid}\n`, 'utf8');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function removePidFile() {
|
|
111
|
+
if (fs.existsSync(PATHS.PID_FILE)) {
|
|
112
|
+
fs.unlinkSync(PATHS.PID_FILE);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getListeningPid(port) {
|
|
117
|
+
try {
|
|
118
|
+
const pid = execSync(`lsof -ti :${port} 2>/dev/null`, { encoding: 'utf8' }).trim();
|
|
119
|
+
return pid ? Number(pid) : null;
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getExistingServerPid(port) {
|
|
126
|
+
const pidFromFile = readPidFile();
|
|
127
|
+
if (isPidRunning(pidFromFile)) {
|
|
128
|
+
return pidFromFile;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (pidFromFile) {
|
|
132
|
+
removePidFile();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const pidFromPort = getListeningPid(port);
|
|
136
|
+
if (isPidRunning(pidFromPort)) {
|
|
137
|
+
writePidFile(pidFromPort);
|
|
138
|
+
return pidFromPort;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isTermuxServiceConfigured(config) {
|
|
145
|
+
const serviceName = config.termux?.service_name || 'nexuscrew';
|
|
146
|
+
return fs.existsSync(getServiceDir(serviceName));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function getTermuxServiceStatus(config) {
|
|
150
|
+
const serviceName = config.termux?.service_name || 'nexuscrew';
|
|
151
|
+
if (!isTermux() || !isPackageInstalled('sv') || !fs.existsSync(getServiceDir(serviceName))) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
return execSync(`sv status ${serviceName}`, { encoding: 'utf8' }).trim();
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return error.stdout?.toString?.().trim() || 'unknown';
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function configureTermuxBoot(config) {
|
|
163
|
+
ensureParentDir(PATHS.BOOT_SCRIPT);
|
|
164
|
+
const startServicesScript = getStartServicesScript();
|
|
165
|
+
const script = [
|
|
166
|
+
'#!/data/data/com.termux/files/usr/bin/sh',
|
|
167
|
+
'termux-wake-lock >/dev/null 2>&1 || true',
|
|
168
|
+
`[ -f "${startServicesScript}" ] && . "${startServicesScript}"`
|
|
169
|
+
].join('\n') + '\n';
|
|
170
|
+
|
|
171
|
+
fs.writeFileSync(PATHS.BOOT_SCRIPT, script, 'utf8');
|
|
172
|
+
fs.chmodSync(PATHS.BOOT_SCRIPT, 0o755);
|
|
173
|
+
config.termux.boot_start = true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function configureTermuxService(config, port) {
|
|
177
|
+
const serviceName = config.termux?.service_name || 'nexuscrew';
|
|
178
|
+
const serviceDir = getServiceDir(serviceName);
|
|
179
|
+
const runFile = path.join(serviceDir, 'run');
|
|
180
|
+
const script = [
|
|
181
|
+
'#!/data/data/com.termux/files/usr/bin/sh',
|
|
182
|
+
`cd "${getProjectRoot()}" || exit 1`,
|
|
183
|
+
'mkdir -p "$HOME/.nexuscrew/logs"',
|
|
184
|
+
`export PORT="${port}"`,
|
|
185
|
+
'exec node lib/server/server.js >>"$HOME/.nexuscrew/logs/server.log" 2>&1'
|
|
186
|
+
].join('\n') + '\n';
|
|
187
|
+
|
|
188
|
+
fs.mkdirSync(serviceDir, { recursive: true });
|
|
189
|
+
fs.writeFileSync(runFile, script, 'utf8');
|
|
190
|
+
fs.chmodSync(runFile, 0o755);
|
|
191
|
+
execSync(`sv-enable ${serviceName}`, { stdio: 'ignore' });
|
|
192
|
+
config.termux.service_enabled = true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function waitForHealth(port, timeoutMs = 8000) {
|
|
196
|
+
const deadline = Date.now() + timeoutMs;
|
|
197
|
+
|
|
198
|
+
return new Promise((resolve) => {
|
|
199
|
+
const probe = () => {
|
|
200
|
+
const req = http.get(`${getServerUrl(port)}/health`, (res) => {
|
|
201
|
+
res.resume();
|
|
202
|
+
resolve(res.statusCode === 200);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
req.on('error', () => {
|
|
206
|
+
if (Date.now() >= deadline) {
|
|
207
|
+
resolve(false);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
setTimeout(probe, 250);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
req.setTimeout(1000, () => req.destroy());
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
probe();
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function startDetachedServer(port) {
|
|
221
|
+
ensureParentDir(PATHS.SERVER_LOG);
|
|
222
|
+
const logFd = fs.openSync(PATHS.SERVER_LOG, 'a');
|
|
223
|
+
const child = spawn(process.execPath, [getServerEntry()], {
|
|
224
|
+
cwd: getProjectRoot(),
|
|
225
|
+
detached: true,
|
|
226
|
+
env: {
|
|
227
|
+
...process.env,
|
|
228
|
+
PORT: String(port)
|
|
229
|
+
},
|
|
230
|
+
stdio: ['ignore', logFd, logFd]
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
child.unref();
|
|
234
|
+
writePidFile(child.pid);
|
|
235
|
+
return child.pid;
|
|
236
|
+
}
|
|
237
|
+
|
|
16
238
|
program
|
|
17
239
|
.name('nexuscrew')
|
|
18
240
|
.description('tmux-based AI cockpit')
|
|
19
|
-
.version('0.2.
|
|
241
|
+
.version('0.2.1');
|
|
20
242
|
|
|
21
|
-
// --- init ---
|
|
22
243
|
program.command('init')
|
|
23
|
-
.description('
|
|
24
|
-
.option('-s, --silent', 'Silent mode
|
|
244
|
+
.description('Bootstrap config, directories, and host registry')
|
|
245
|
+
.option('-s, --silent', 'Silent mode')
|
|
25
246
|
.action((opts) => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
247
|
+
ensureBaseState();
|
|
248
|
+
if (!opts.silent) {
|
|
249
|
+
console.log(`Config: ${PATHS.CONFIG_FILE}`);
|
|
250
|
+
console.log(`Hosts: ${PATHS.HOSTS_FILE}`);
|
|
251
|
+
console.log(`Logs: ${PATHS.LOGS_DIR}`);
|
|
252
|
+
console.log(`Attachments: ${PATHS.ATTACHMENTS_DIR}`);
|
|
253
|
+
console.log(`tmux: ${commandExists('tmux') ? 'OK' : 'missing'}`);
|
|
254
|
+
console.log(`ssh: ${commandExists('ssh') ? 'OK' : 'missing'}`);
|
|
255
|
+
console.log(`scp: ${commandExists('scp') ? 'OK' : 'missing'}`);
|
|
29
256
|
}
|
|
257
|
+
});
|
|
30
258
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
259
|
+
program.command('setup')
|
|
260
|
+
.description('Interactive setup wizard')
|
|
261
|
+
.action(async () => {
|
|
262
|
+
ensureBaseState();
|
|
263
|
+
|
|
264
|
+
const current = getConfig();
|
|
265
|
+
const defaultWorkspace = current.workspaces?.default || path.join(process.env.HOME || '', 'Dev');
|
|
266
|
+
const port = Number(await prompt('HTTP port', String(current.server?.port || 41820)));
|
|
267
|
+
const tmuxSession = await prompt('tmux session name', current.tmuxSession || 'nexuscrew');
|
|
268
|
+
const workspace = await prompt('Default workspace', defaultWorkspace);
|
|
269
|
+
const addHost = await promptBoolean('Add SSH host now? (y/N)', false);
|
|
270
|
+
|
|
271
|
+
current.server.port = port;
|
|
272
|
+
current.tmuxSession = tmuxSession;
|
|
273
|
+
current.workspaces.default = workspace;
|
|
274
|
+
current.workspaces.paths = Array.from(new Set([workspace, ...(current.workspaces.paths || [])]));
|
|
275
|
+
|
|
276
|
+
if (isTermux()) {
|
|
277
|
+
for (const pkg of ['tmux', 'openssh', 'termux-services']) {
|
|
278
|
+
if (!isPackageInstalled(pkg)) {
|
|
279
|
+
installPackage(pkg);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
41
282
|
}
|
|
42
283
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
284
|
+
if (addHost) {
|
|
285
|
+
const name = await prompt('Host alias', 'server');
|
|
286
|
+
const host = await prompt('SSH host');
|
|
287
|
+
const user = await prompt('SSH user', 'dag');
|
|
288
|
+
const sshPort = await prompt('SSH port', '22');
|
|
289
|
+
const key = await prompt('SSH key path (optional)', '');
|
|
290
|
+
const hosts = readHosts().filter((entry) => entry.name !== name);
|
|
291
|
+
hosts.push({
|
|
292
|
+
name,
|
|
293
|
+
type: 'ssh',
|
|
294
|
+
host,
|
|
295
|
+
user,
|
|
296
|
+
port: sshPort,
|
|
297
|
+
key: key || null,
|
|
298
|
+
default: false
|
|
299
|
+
});
|
|
300
|
+
writeHosts(hosts);
|
|
49
301
|
}
|
|
50
302
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
303
|
+
if (isTermux()) {
|
|
304
|
+
const configureBoot = await promptBoolean('Configure Termux boot? (y/N)', false);
|
|
305
|
+
const configureService = await promptBoolean('Enable native Termux runit service watchdog? (y/N)', false);
|
|
306
|
+
|
|
307
|
+
if (configureBoot) {
|
|
308
|
+
configureTermuxBoot(current);
|
|
309
|
+
}
|
|
56
310
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
311
|
+
if (configureService) {
|
|
312
|
+
if (!isTermuxServicesAvailable()) {
|
|
313
|
+
console.log('termux-services is not ready yet. Restart the shell after installation and rerun setup if needed.');
|
|
314
|
+
} else {
|
|
315
|
+
configureTermuxService(current, port);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
63
318
|
}
|
|
64
319
|
|
|
65
|
-
|
|
320
|
+
saveConfig(current);
|
|
321
|
+
|
|
322
|
+
const speech = new SpeechManager();
|
|
323
|
+
const sttStatus = speech.getStatus();
|
|
324
|
+
console.log(`STT provider: ${sttStatus.provider}`);
|
|
325
|
+
console.log(`Whisper configured: ${sttStatus.whisper.configured ? 'yes' : 'no'}`);
|
|
326
|
+
if (isTermux()) {
|
|
327
|
+
if (current.termux?.boot_start && !isTermuxBootAvailable()) {
|
|
328
|
+
console.log('Boot script created. Install/open the Termux:Boot app once to activate it on boot.');
|
|
329
|
+
}
|
|
330
|
+
console.log(`Termux boot: ${current.termux?.boot_start ? 'enabled' : 'disabled'}`);
|
|
331
|
+
console.log(`Termux service: ${current.termux?.service_enabled ? 'enabled' : 'disabled'}`);
|
|
332
|
+
console.log(`Boot script: ${PATHS.BOOT_SCRIPT}`);
|
|
333
|
+
console.log(`Service dir: ${getServiceDir(current.termux?.service_name || 'nexuscrew')}`);
|
|
334
|
+
}
|
|
335
|
+
console.log('Run "nexuscrew stt doctor" to finish local speech setup.');
|
|
66
336
|
});
|
|
67
337
|
|
|
68
|
-
// --- start ---
|
|
69
338
|
program.command('start')
|
|
70
|
-
.description('Start the server')
|
|
71
|
-
.option('-p, --port <port>', 'Port
|
|
72
|
-
.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
339
|
+
.description('Start the server in background')
|
|
340
|
+
.option('-p, --port <port>', 'Port override')
|
|
341
|
+
.action(async (opts) => {
|
|
342
|
+
ensureBaseState();
|
|
343
|
+
const config = getConfig();
|
|
344
|
+
const tmux = new TmuxManager(config);
|
|
345
|
+
const port = Number(opts.port || config.server?.port || 41820);
|
|
346
|
+
const serviceConfigured = isTermux() && isTermuxServicesAvailable() && isTermuxServiceConfigured(config);
|
|
347
|
+
const existingPid = getExistingServerPid(port);
|
|
348
|
+
|
|
349
|
+
if (existingPid) {
|
|
350
|
+
console.log(`Server already running on ${getServerUrl(port)} (PID ${existingPid})`);
|
|
351
|
+
const shouldOpen = await promptBoolean('Open browser? (Y/n)', true);
|
|
352
|
+
if (shouldOpen && !openUrl(getServerUrl(port))) {
|
|
353
|
+
console.log(`Open this URL manually: ${getServerUrl(port)}`);
|
|
354
|
+
}
|
|
355
|
+
return;
|
|
80
356
|
}
|
|
81
357
|
|
|
82
|
-
|
|
83
|
-
if (opts.dev) env.NEXUSCREW_DEV = '1';
|
|
358
|
+
tmux.ensureSession('local');
|
|
84
359
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
360
|
+
if (serviceConfigured) {
|
|
361
|
+
execSync(`sv up ${config.termux?.service_name || 'nexuscrew'}`, { stdio: 'ignore' });
|
|
362
|
+
} else {
|
|
363
|
+
startDetachedServer(port);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const healthy = await waitForHealth(port);
|
|
367
|
+
if (!healthy) {
|
|
368
|
+
console.error('Server did not become healthy in time. Check the log file for details.');
|
|
369
|
+
console.error(`Log: ${PATHS.SERVER_LOG}`);
|
|
370
|
+
process.exitCode = 1;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const pid = getExistingServerPid(port);
|
|
375
|
+
console.log(`Server started: ${getServerUrl(port)}${pid ? ` (PID ${pid})` : ''}`);
|
|
376
|
+
console.log(`Log: ${PATHS.SERVER_LOG}`);
|
|
377
|
+
|
|
378
|
+
const shouldOpen = await promptBoolean('Open browser? (Y/n)', true);
|
|
379
|
+
if (shouldOpen && !openUrl(getServerUrl(port))) {
|
|
380
|
+
console.log(`Open this URL manually: ${getServerUrl(port)}`);
|
|
381
|
+
}
|
|
92
382
|
});
|
|
93
383
|
|
|
94
|
-
// --- stop ---
|
|
95
384
|
program.command('stop')
|
|
96
385
|
.description('Stop the server')
|
|
97
386
|
.action(() => {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
387
|
+
const config = getConfig();
|
|
388
|
+
const port = config.server?.port || 41820;
|
|
389
|
+
const serviceName = config.termux?.service_name || 'nexuscrew';
|
|
390
|
+
|
|
391
|
+
if (isTermux() && isTermuxServicesAvailable() && isTermuxServiceConfigured(config)) {
|
|
392
|
+
try {
|
|
393
|
+
execSync(`sv down ${serviceName}`, { stdio: 'ignore' });
|
|
394
|
+
} catch {}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const pid = getExistingServerPid(port);
|
|
398
|
+
if (pid) {
|
|
399
|
+
try {
|
|
400
|
+
process.kill(pid, 'SIGTERM');
|
|
401
|
+
} catch {}
|
|
402
|
+
removePidFile();
|
|
403
|
+
console.log('Server stopped');
|
|
404
|
+
return;
|
|
108
405
|
}
|
|
406
|
+
|
|
407
|
+
console.log('Server not running');
|
|
109
408
|
});
|
|
110
409
|
|
|
111
|
-
// --- status ---
|
|
112
410
|
program.command('status')
|
|
113
|
-
.description('Show server and
|
|
114
|
-
.action(() => {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
411
|
+
.description('Show server, tmux, host, and speech status')
|
|
412
|
+
.action(async () => {
|
|
413
|
+
ensureBaseState();
|
|
414
|
+
const config = getConfig();
|
|
415
|
+
const tmux = new TmuxManager(config);
|
|
416
|
+
const speech = new SpeechManager();
|
|
417
|
+
const port = config.server?.port || 41820;
|
|
418
|
+
const pid = getExistingServerPid(port);
|
|
419
|
+
const healthy = pid ? await waitForHealth(port, 1500) : false;
|
|
420
|
+
|
|
421
|
+
console.log('=== NexusCrew Status ===');
|
|
422
|
+
console.log(`Server: ${pid ? `RUNNING (${pid})` : 'STOPPED'}`);
|
|
423
|
+
console.log(`Health: ${healthy ? 'OK' : 'DOWN'}`);
|
|
424
|
+
console.log(`HTTP: ${getServerUrl(port)}`);
|
|
425
|
+
console.log(`PID file: ${fs.existsSync(PATHS.PID_FILE) ? PATHS.PID_FILE : 'missing'}`);
|
|
426
|
+
console.log(`Log file: ${PATHS.SERVER_LOG}`);
|
|
427
|
+
|
|
428
|
+
console.log(`tmux session: ${config.tmuxSession}`);
|
|
429
|
+
tmux.listWindows('local').forEach((window) => {
|
|
430
|
+
console.log(` ${window.index}: ${window.name} ${window.active ? '(active)' : ''}`);
|
|
431
|
+
});
|
|
124
432
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
console.log(
|
|
129
|
-
|
|
130
|
-
} catch {
|
|
131
|
-
console.log(`\ntmux session "${TMUX_SESSION}": NOT FOUND`);
|
|
132
|
-
}
|
|
433
|
+
console.log('Hosts:');
|
|
434
|
+
readHosts().forEach((host) => {
|
|
435
|
+
const status = host.type === 'local' ? 'local' : (tmux.testHost(host) ? 'OK' : 'UNREACHABLE');
|
|
436
|
+
console.log(` ${host.name}: ${status}`);
|
|
437
|
+
});
|
|
133
438
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
console.log(` ${h.name}: ${h.user}@${h.host}${h.port ? ':' + h.port : ''} [${status}]`);
|
|
144
|
-
}
|
|
145
|
-
});
|
|
439
|
+
const speechStatus = speech.getStatus();
|
|
440
|
+
console.log(`STT: ${speechStatus.provider} (${speechStatus.whisper.available ? 'bin OK' : 'bin missing'})`);
|
|
441
|
+
console.log(`TTS: ${speechStatus.tts.provider}`);
|
|
442
|
+
if (isTermux()) {
|
|
443
|
+
console.log(`Termux boot: ${config.termux?.boot_start ? 'enabled' : 'disabled'}`);
|
|
444
|
+
const serviceStatus = getTermuxServiceStatus(config);
|
|
445
|
+
if (serviceStatus) {
|
|
446
|
+
console.log(`Termux service: ${serviceStatus}`);
|
|
447
|
+
}
|
|
146
448
|
}
|
|
147
449
|
});
|
|
148
450
|
|
|
149
|
-
// --- attach ---
|
|
150
451
|
program.command('attach')
|
|
151
|
-
.description('Attach to tmux session')
|
|
152
|
-
.option('-w, --window <name>', '
|
|
452
|
+
.description('Attach to local tmux session')
|
|
453
|
+
.option('-w, --window <name>', 'Window name')
|
|
153
454
|
.action((opts) => {
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
} catch {
|
|
158
|
-
console.error(`Session/window not found: ${target}`);
|
|
159
|
-
}
|
|
455
|
+
const tmuxSession = getConfig().tmuxSession || 'nexuscrew';
|
|
456
|
+
const target = opts.window ? `${tmuxSession}:${opts.window}` : tmuxSession;
|
|
457
|
+
execSync(`tmux attach -t ${target}`, { stdio: 'inherit' });
|
|
160
458
|
});
|
|
161
459
|
|
|
162
|
-
// --- sessions ---
|
|
163
460
|
program.command('sessions')
|
|
164
|
-
.description('List tmux windows')
|
|
461
|
+
.description('List local tmux windows')
|
|
165
462
|
.action(() => {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
).toString().trim();
|
|
170
|
-
console.log(out);
|
|
171
|
-
} catch {
|
|
463
|
+
const tmux = new TmuxManager(getConfig());
|
|
464
|
+
const windows = tmux.listWindows('local');
|
|
465
|
+
if (windows.length === 0) {
|
|
172
466
|
console.log('No tmux session found');
|
|
467
|
+
return;
|
|
173
468
|
}
|
|
469
|
+
windows.forEach((window) => {
|
|
470
|
+
console.log(`${window.index}: ${window.name} [${window.size}] ${window.active ? '(active)' : ''}`);
|
|
471
|
+
});
|
|
174
472
|
});
|
|
175
473
|
|
|
176
|
-
// --- config ---
|
|
177
474
|
program.command('config')
|
|
178
|
-
.description('
|
|
179
|
-
.
|
|
180
|
-
.
|
|
181
|
-
.action((
|
|
182
|
-
|
|
183
|
-
|
|
475
|
+
.description('Read or update configuration')
|
|
476
|
+
.argument('[action]', 'get | set | list', 'list')
|
|
477
|
+
.argument('[payload]')
|
|
478
|
+
.action((action, payload) => {
|
|
479
|
+
ensureBaseState();
|
|
480
|
+
if (action === 'get' && payload) {
|
|
481
|
+
console.log(JSON.stringify(getConfigValue(payload), null, 2));
|
|
184
482
|
return;
|
|
185
483
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
config[key] = isNaN(value) ? value : Number(value);
|
|
192
|
-
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
193
|
-
console.log(`Set ${key} = ${value}`);
|
|
194
|
-
} else {
|
|
195
|
-
console.log(JSON.stringify(config, null, 2));
|
|
484
|
+
if (action === 'set' && payload) {
|
|
485
|
+
const [key, ...rest] = payload.split('=');
|
|
486
|
+
setConfigValue(key, rest.join('='));
|
|
487
|
+
console.log(`Updated ${key}`);
|
|
488
|
+
return;
|
|
196
489
|
}
|
|
490
|
+
console.log(JSON.stringify(getConfig(), null, 2));
|
|
197
491
|
});
|
|
198
492
|
|
|
199
|
-
|
|
200
|
-
const hostsCmd = program.command('hosts').description('Manage remote hosts');
|
|
493
|
+
const hostsCmd = program.command('hosts').description('Manage SSH hosts');
|
|
201
494
|
|
|
202
495
|
hostsCmd.command('list')
|
|
203
|
-
.description('List configured hosts')
|
|
204
496
|
.action(() => {
|
|
205
|
-
|
|
206
|
-
console.log(
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
const hosts = JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'));
|
|
210
|
-
hosts.forEach((h, i) => {
|
|
211
|
-
const status = h.type === 'local' ? 'local' : (testSSH(h) ? 'OK' : 'UNREACHABLE');
|
|
212
|
-
console.log(`[${i}] ${h.name} (${h.type}) ${h.host || ''} [${status}]${h.default ? ' *default*' : ''}`);
|
|
497
|
+
readHosts().forEach((host) => {
|
|
498
|
+
console.log(`${host.name} ${host.type === 'local' ? '(local)' : `${host.user}@${host.host}:${host.port}`}`);
|
|
213
499
|
});
|
|
214
500
|
});
|
|
215
501
|
|
|
216
502
|
hostsCmd.command('add')
|
|
217
|
-
.
|
|
218
|
-
.requiredOption('-
|
|
219
|
-
.requiredOption('-h, --host <host>', 'Hostname or IP')
|
|
503
|
+
.requiredOption('-n, --name <name>')
|
|
504
|
+
.requiredOption('-h, --host <host>')
|
|
220
505
|
.option('-u, --user <user>', 'SSH user', 'dag')
|
|
221
506
|
.option('-p, --port <port>', 'SSH port', '22')
|
|
222
507
|
.option('-k, --key <path>', 'SSH key path')
|
|
223
508
|
.action((opts) => {
|
|
224
|
-
const hosts =
|
|
225
|
-
? JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'))
|
|
226
|
-
: [];
|
|
509
|
+
const hosts = readHosts().filter((host) => host.name !== opts.name);
|
|
227
510
|
hosts.push({
|
|
228
511
|
name: opts.name,
|
|
229
512
|
type: 'ssh',
|
|
230
513
|
host: opts.host,
|
|
231
514
|
user: opts.user,
|
|
232
515
|
port: opts.port,
|
|
233
|
-
key: opts.key ||
|
|
516
|
+
key: opts.key || null,
|
|
517
|
+
default: false
|
|
234
518
|
});
|
|
235
|
-
|
|
519
|
+
writeHosts(hosts);
|
|
236
520
|
console.log(`Added host: ${opts.name}`);
|
|
237
521
|
});
|
|
238
522
|
|
|
239
523
|
hostsCmd.command('remove')
|
|
240
|
-
.
|
|
241
|
-
.requiredOption('-n, --name <name>', 'Host name')
|
|
524
|
+
.requiredOption('-n, --name <name>')
|
|
242
525
|
.action((opts) => {
|
|
243
|
-
|
|
244
|
-
? JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'))
|
|
245
|
-
: [];
|
|
246
|
-
hosts = hosts.filter(h => h.name !== opts.name);
|
|
247
|
-
fs.writeFileSync(HOSTS_PATH, JSON.stringify(hosts, null, 2));
|
|
526
|
+
writeHosts(readHosts().filter((host) => host.name !== opts.name));
|
|
248
527
|
console.log(`Removed host: ${opts.name}`);
|
|
249
528
|
});
|
|
250
529
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
.
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
console.log('\nDiscovered Engines:\n');
|
|
257
|
-
for (const [name, info] of Object.entries(engines)) {
|
|
258
|
-
console.log(` ${name}: ${info.available ? 'OK' : 'NOT FOUND'}`);
|
|
259
|
-
if (info.path) console.log(` path: ${info.path}`);
|
|
260
|
-
if (info.aliases.length) {
|
|
261
|
-
console.log(` aliases: ${info.aliases.join(', ')}`);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
console.log('');
|
|
530
|
+
hostsCmd.command('test')
|
|
531
|
+
.argument('[name]', 'Host name', 'local')
|
|
532
|
+
.action((name) => {
|
|
533
|
+
const tmux = new TmuxManager(getConfig());
|
|
534
|
+
console.log(tmux.testHost(name) ? 'OK' : 'UNREACHABLE');
|
|
265
535
|
});
|
|
266
536
|
|
|
267
|
-
|
|
268
|
-
function testSSH(host) {
|
|
269
|
-
try {
|
|
270
|
-
const keyOpt = host.key ? `-i ${host.key}` : '';
|
|
271
|
-
const portOpt = host.port && host.port !== '22' ? `-p ${host.port}` : '';
|
|
272
|
-
execSync(`ssh -o ConnectTimeout=3 -o BatchMode=yes ${keyOpt} ${portOpt} ${host.user}@${host.host} echo ok`, { stdio: 'pipe' });
|
|
273
|
-
return true;
|
|
274
|
-
} catch {
|
|
275
|
-
return false;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
537
|
+
const sttCmd = program.command('stt').description('Local speech-to-text tools');
|
|
278
538
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
// Direct CLI discovery
|
|
288
|
-
const cliPaths = {
|
|
289
|
-
claude: [
|
|
290
|
-
path.join(process.env.HOME, '.local/bin/claude'),
|
|
291
|
-
path.join(process.env.HOME, '.claude/local/claude'),
|
|
292
|
-
'/usr/local/bin/claude'
|
|
293
|
-
],
|
|
294
|
-
codex: [
|
|
295
|
-
path.join(process.env.HOME, '.local/codex-lts/bin/codex'),
|
|
296
|
-
path.join(process.env.HOME, '.local/codex-lts/node_modules/.bin/codex')
|
|
297
|
-
],
|
|
298
|
-
gemini: [
|
|
299
|
-
path.join(process.env.HOME, '.local/bin/gemini'),
|
|
300
|
-
path.join(process.env.HOME, '.gemini/bin/gemini')
|
|
301
|
-
],
|
|
302
|
-
qwen: [
|
|
303
|
-
path.join(process.env.HOME, '.local/bin/qwen'),
|
|
304
|
-
path.join(process.env.HOME, '.qwen/bin/qwen')
|
|
305
|
-
]
|
|
306
|
-
};
|
|
307
|
-
|
|
308
|
-
for (const [engine, paths] of Object.entries(cliPaths)) {
|
|
309
|
-
for (const p of paths) {
|
|
310
|
-
try {
|
|
311
|
-
if (fs.existsSync(p) && fs.statSync(p).mode & 0o111) {
|
|
312
|
-
engines[engine].available = true;
|
|
313
|
-
engines[engine].path = p;
|
|
314
|
-
break;
|
|
315
|
-
}
|
|
316
|
-
} catch {}
|
|
317
|
-
}
|
|
318
|
-
// Fallback: check PATH
|
|
319
|
-
if (!engines[engine].available) {
|
|
320
|
-
try {
|
|
321
|
-
const which = execSync(`which ${engine} 2>/dev/null`).toString().trim();
|
|
322
|
-
if (which) {
|
|
323
|
-
engines[engine].available = true;
|
|
324
|
-
engines[engine].path = which;
|
|
325
|
-
}
|
|
326
|
-
} catch {}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Parse providers file for aliases (if configured)
|
|
331
|
-
const configPath = path.join(process.env.HOME || '', '.nexuscrew', 'config.json');
|
|
332
|
-
let providersPath = '';
|
|
333
|
-
try {
|
|
334
|
-
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
335
|
-
providersPath = cfg.providersPath || '';
|
|
336
|
-
} catch {}
|
|
337
|
-
if (providersPath && fs.existsSync(providersPath)) {
|
|
338
|
-
const content = fs.readFileSync(providersPath, 'utf8');
|
|
339
|
-
const funcRegex = /^(codex-[a-z0-9-]+|claude-[a-z0-9-]+|gemini-[a-z0-9-]+|qwen-[a-z0-9-]+)\(\)/gm;
|
|
340
|
-
let match;
|
|
341
|
-
while ((match = funcRegex.exec(content)) !== null) {
|
|
342
|
-
const alias = match[1];
|
|
343
|
-
const baseEngine = alias.split('-')[0];
|
|
344
|
-
const engine = baseEngine === 'codex' ? 'codex' : baseEngine === 'claude' ? 'claude' : baseEngine === 'gemini' ? 'gemini' : 'qwen';
|
|
345
|
-
if (engines[engine]) {
|
|
346
|
-
engines[engine].aliases.push(alias);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
539
|
+
sttCmd.command('doctor')
|
|
540
|
+
.description('Inspect local whisper.cpp setup')
|
|
541
|
+
.action(() => {
|
|
542
|
+
const speech = new SpeechManager();
|
|
543
|
+
const status = speech.getStatus();
|
|
544
|
+
console.log(JSON.stringify(status, null, 2));
|
|
545
|
+
});
|
|
350
546
|
|
|
351
|
-
|
|
352
|
-
|
|
547
|
+
program.command('engines')
|
|
548
|
+
.description('List discovered CLI engines')
|
|
549
|
+
.action(() => {
|
|
550
|
+
const discovery = new EngineDiscovery(getConfig().providersPath);
|
|
551
|
+
const engines = discovery.discover();
|
|
552
|
+
Object.entries(engines).forEach(([name, info]) => {
|
|
553
|
+
console.log(`${name}: ${info.available ? 'OK' : 'NOT FOUND'}${info.path ? ` -> ${info.path}` : ''}`);
|
|
554
|
+
});
|
|
555
|
+
});
|
|
353
556
|
|
|
354
|
-
program.
|
|
557
|
+
program.parseAsync();
|