@mmmbuto/nexuscrew 0.1.0-beta.1 → 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 +67 -120
- package/bin/nexuscrew.js +468 -264
- package/frontend/dist/apple-touch-icon.png +0 -0
- package/frontend/dist/assets/{index-C7bAndew.js → index-BG1N7bSL.js} +1710 -1702
- 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 +379 -0
- package/lib/config/models.js +408 -0
- package/lib/server/db/adapter.js +274 -0
- package/lib/server/db/drivers/sql-js.js +75 -0
- package/lib/server/db/migrate.js +174 -0
- package/lib/server/db/migrations/001_base_schema.sql +70 -0
- package/lib/server/middleware/auth.js +134 -0
- package/lib/server/middleware/rate-limit.js +63 -0
- package/lib/server/models/User.js +128 -0
- package/lib/server/routes/auth.js +168 -0
- package/lib/server/routes/hosts.js +11 -20
- package/lib/server/routes/keys.js +28 -0
- package/lib/server/routes/models.js +59 -4
- package/lib/server/routes/runtimes.js +34 -0
- package/lib/server/routes/send.js +76 -12
- package/lib/server/routes/sessions.js +39 -10
- package/lib/server/routes/speech.js +46 -0
- package/lib/server/routes/status.js +8 -6
- package/lib/server/routes/upload.js +135 -0
- package/lib/server/routes/wake-lock.js +95 -0
- package/lib/server/routes/workspaces.js +101 -0
- package/lib/server/server.js +66 -33
- package/lib/server/services/attachment-manager.js +57 -0
- package/lib/server/services/context-bridge.js +425 -0
- package/lib/server/services/runtime-manager.js +462 -0
- package/lib/server/services/speech-manager.js +76 -0
- package/lib/server/services/summary-generator.js +309 -0
- package/lib/server/services/workspace-manager.js +79 -0
- package/lib/services/engine-discovery.js +198 -13
- package/lib/services/log-watcher.js +40 -22
- package/lib/services/remote-pane-watcher.js +155 -0
- package/lib/services/session-store.js +60 -64
- package/lib/services/tmux-manager.js +127 -116
- package/lib/setup/postinstall.js +38 -0
- package/lib/utils/paths.js +124 -0
- package/lib/utils/termux.js +182 -0
- package/package.json +8 -4
- package/frontend/dist/assets/index-OENqI1_9.css +0 -1
package/bin/nexuscrew.js
CHANGED
|
@@ -2,352 +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.1
|
|
241
|
+
.version('0.2.1');
|
|
20
242
|
|
|
21
|
-
// --- init ---
|
|
22
243
|
program.command('init')
|
|
23
|
-
.description('
|
|
24
|
-
.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
244
|
+
.description('Bootstrap config, directories, and host registry')
|
|
245
|
+
.option('-s, --silent', 'Silent mode')
|
|
246
|
+
.action((opts) => {
|
|
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'}`);
|
|
28
256
|
}
|
|
257
|
+
});
|
|
29
258
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
+
}
|
|
40
282
|
}
|
|
41
283
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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);
|
|
48
301
|
}
|
|
49
302
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
54
|
-
}
|
|
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);
|
|
55
306
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
307
|
+
if (configureBoot) {
|
|
308
|
+
configureTermuxBoot(current);
|
|
309
|
+
}
|
|
310
|
+
|
|
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
|
+
}
|
|
62
318
|
}
|
|
63
319
|
|
|
64
|
-
|
|
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.');
|
|
65
336
|
});
|
|
66
337
|
|
|
67
|
-
// --- start ---
|
|
68
338
|
program.command('start')
|
|
69
|
-
.description('Start the server')
|
|
70
|
-
.option('-p, --port <port>', 'Port
|
|
71
|
-
.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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;
|
|
79
356
|
}
|
|
80
357
|
|
|
81
|
-
|
|
82
|
-
if (opts.dev) env.NEXUSCREW_DEV = '1';
|
|
358
|
+
tmux.ensureSession('local');
|
|
83
359
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
}
|
|
91
382
|
});
|
|
92
383
|
|
|
93
|
-
// --- stop ---
|
|
94
384
|
program.command('stop')
|
|
95
385
|
.description('Stop the server')
|
|
96
386
|
.action(() => {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
} catch {
|
|
106
|
-
console.log('Server not running');
|
|
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 {}
|
|
107
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;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
console.log('Server not running');
|
|
108
408
|
});
|
|
109
409
|
|
|
110
|
-
// --- status ---
|
|
111
410
|
program.command('status')
|
|
112
|
-
.description('Show server and
|
|
113
|
-
.action(() => {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
});
|
|
123
432
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
console.log(
|
|
128
|
-
|
|
129
|
-
} catch {
|
|
130
|
-
console.log(`\ntmux session "${TMUX_SESSION}": NOT FOUND`);
|
|
131
|
-
}
|
|
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
|
+
});
|
|
132
438
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
console.log(` ${h.name}: ${h.user}@${h.host}${h.port ? ':' + h.port : ''} [${status}]`);
|
|
143
|
-
}
|
|
144
|
-
});
|
|
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
|
+
}
|
|
145
448
|
}
|
|
146
449
|
});
|
|
147
450
|
|
|
148
|
-
// --- attach ---
|
|
149
451
|
program.command('attach')
|
|
150
|
-
.description('Attach to tmux session')
|
|
151
|
-
.option('-w, --window <name>', '
|
|
452
|
+
.description('Attach to local tmux session')
|
|
453
|
+
.option('-w, --window <name>', 'Window name')
|
|
152
454
|
.action((opts) => {
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
} catch {
|
|
157
|
-
console.error(`Session/window not found: ${target}`);
|
|
158
|
-
}
|
|
455
|
+
const tmuxSession = getConfig().tmuxSession || 'nexuscrew';
|
|
456
|
+
const target = opts.window ? `${tmuxSession}:${opts.window}` : tmuxSession;
|
|
457
|
+
execSync(`tmux attach -t ${target}`, { stdio: 'inherit' });
|
|
159
458
|
});
|
|
160
459
|
|
|
161
|
-
// --- sessions ---
|
|
162
460
|
program.command('sessions')
|
|
163
|
-
.description('List tmux windows')
|
|
461
|
+
.description('List local tmux windows')
|
|
164
462
|
.action(() => {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
).toString().trim();
|
|
169
|
-
console.log(out);
|
|
170
|
-
} catch {
|
|
463
|
+
const tmux = new TmuxManager(getConfig());
|
|
464
|
+
const windows = tmux.listWindows('local');
|
|
465
|
+
if (windows.length === 0) {
|
|
171
466
|
console.log('No tmux session found');
|
|
467
|
+
return;
|
|
172
468
|
}
|
|
469
|
+
windows.forEach((window) => {
|
|
470
|
+
console.log(`${window.index}: ${window.name} [${window.size}] ${window.active ? '(active)' : ''}`);
|
|
471
|
+
});
|
|
173
472
|
});
|
|
174
473
|
|
|
175
|
-
// --- config ---
|
|
176
474
|
program.command('config')
|
|
177
|
-
.description('
|
|
178
|
-
.
|
|
179
|
-
.
|
|
180
|
-
.action((
|
|
181
|
-
|
|
182
|
-
|
|
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));
|
|
183
482
|
return;
|
|
184
483
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
config[key] = isNaN(value) ? value : Number(value);
|
|
191
|
-
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
192
|
-
console.log(`Set ${key} = ${value}`);
|
|
193
|
-
} else {
|
|
194
|
-
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;
|
|
195
489
|
}
|
|
490
|
+
console.log(JSON.stringify(getConfig(), null, 2));
|
|
196
491
|
});
|
|
197
492
|
|
|
198
|
-
|
|
199
|
-
const hostsCmd = program.command('hosts').description('Manage remote hosts');
|
|
493
|
+
const hostsCmd = program.command('hosts').description('Manage SSH hosts');
|
|
200
494
|
|
|
201
495
|
hostsCmd.command('list')
|
|
202
|
-
.description('List configured hosts')
|
|
203
496
|
.action(() => {
|
|
204
|
-
|
|
205
|
-
console.log(
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
const hosts = JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'));
|
|
209
|
-
hosts.forEach((h, i) => {
|
|
210
|
-
const status = h.type === 'local' ? 'local' : (testSSH(h) ? 'OK' : 'UNREACHABLE');
|
|
211
|
-
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}`}`);
|
|
212
499
|
});
|
|
213
500
|
});
|
|
214
501
|
|
|
215
502
|
hostsCmd.command('add')
|
|
216
|
-
.
|
|
217
|
-
.requiredOption('-
|
|
218
|
-
.requiredOption('-h, --host <host>', 'Hostname or IP')
|
|
503
|
+
.requiredOption('-n, --name <name>')
|
|
504
|
+
.requiredOption('-h, --host <host>')
|
|
219
505
|
.option('-u, --user <user>', 'SSH user', 'dag')
|
|
220
506
|
.option('-p, --port <port>', 'SSH port', '22')
|
|
221
507
|
.option('-k, --key <path>', 'SSH key path')
|
|
222
508
|
.action((opts) => {
|
|
223
|
-
const hosts =
|
|
224
|
-
? JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'))
|
|
225
|
-
: [];
|
|
509
|
+
const hosts = readHosts().filter((host) => host.name !== opts.name);
|
|
226
510
|
hosts.push({
|
|
227
511
|
name: opts.name,
|
|
228
512
|
type: 'ssh',
|
|
229
513
|
host: opts.host,
|
|
230
514
|
user: opts.user,
|
|
231
515
|
port: opts.port,
|
|
232
|
-
key: opts.key ||
|
|
516
|
+
key: opts.key || null,
|
|
517
|
+
default: false
|
|
233
518
|
});
|
|
234
|
-
|
|
519
|
+
writeHosts(hosts);
|
|
235
520
|
console.log(`Added host: ${opts.name}`);
|
|
236
521
|
});
|
|
237
522
|
|
|
238
523
|
hostsCmd.command('remove')
|
|
239
|
-
.
|
|
240
|
-
.requiredOption('-n, --name <name>', 'Host name')
|
|
524
|
+
.requiredOption('-n, --name <name>')
|
|
241
525
|
.action((opts) => {
|
|
242
|
-
|
|
243
|
-
? JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'))
|
|
244
|
-
: [];
|
|
245
|
-
hosts = hosts.filter(h => h.name !== opts.name);
|
|
246
|
-
fs.writeFileSync(HOSTS_PATH, JSON.stringify(hosts, null, 2));
|
|
526
|
+
writeHosts(readHosts().filter((host) => host.name !== opts.name));
|
|
247
527
|
console.log(`Removed host: ${opts.name}`);
|
|
248
528
|
});
|
|
249
529
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
console.log('\nDiscovered Engines:\n');
|
|
256
|
-
for (const [name, info] of Object.entries(engines)) {
|
|
257
|
-
console.log(` ${name}: ${info.available ? 'OK' : 'NOT FOUND'}`);
|
|
258
|
-
if (info.path) console.log(` path: ${info.path}`);
|
|
259
|
-
if (info.aliases.length) {
|
|
260
|
-
console.log(` aliases: ${info.aliases.join(', ')}`);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
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');
|
|
264
535
|
});
|
|
265
536
|
|
|
266
|
-
|
|
267
|
-
function testSSH(host) {
|
|
268
|
-
try {
|
|
269
|
-
const keyOpt = host.key ? `-i ${host.key}` : '';
|
|
270
|
-
const portOpt = host.port && host.port !== '22' ? `-p ${host.port}` : '';
|
|
271
|
-
execSync(`ssh -o ConnectTimeout=3 -o BatchMode=yes ${keyOpt} ${portOpt} ${host.user}@${host.host} echo ok`, { stdio: 'pipe' });
|
|
272
|
-
return true;
|
|
273
|
-
} catch {
|
|
274
|
-
return false;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
537
|
+
const sttCmd = program.command('stt').description('Local speech-to-text tools');
|
|
277
538
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
};
|
|
285
|
-
|
|
286
|
-
// Direct CLI discovery
|
|
287
|
-
const cliPaths = {
|
|
288
|
-
claude: [
|
|
289
|
-
path.join(process.env.HOME, '.local/bin/claude'),
|
|
290
|
-
path.join(process.env.HOME, '.claude/local/claude'),
|
|
291
|
-
'/usr/local/bin/claude'
|
|
292
|
-
],
|
|
293
|
-
codex: [
|
|
294
|
-
path.join(process.env.HOME, '.local/codex-lts/bin/codex'),
|
|
295
|
-
path.join(process.env.HOME, '.local/codex-lts/node_modules/.bin/codex')
|
|
296
|
-
],
|
|
297
|
-
gemini: [
|
|
298
|
-
path.join(process.env.HOME, '.local/bin/gemini'),
|
|
299
|
-
path.join(process.env.HOME, '.gemini/bin/gemini')
|
|
300
|
-
],
|
|
301
|
-
qwen: [
|
|
302
|
-
path.join(process.env.HOME, '.local/bin/qwen'),
|
|
303
|
-
path.join(process.env.HOME, '.qwen/bin/qwen')
|
|
304
|
-
]
|
|
305
|
-
};
|
|
306
|
-
|
|
307
|
-
for (const [engine, paths] of Object.entries(cliPaths)) {
|
|
308
|
-
for (const p of paths) {
|
|
309
|
-
try {
|
|
310
|
-
if (fs.existsSync(p) && fs.statSync(p).mode & 0o111) {
|
|
311
|
-
engines[engine].available = true;
|
|
312
|
-
engines[engine].path = p;
|
|
313
|
-
break;
|
|
314
|
-
}
|
|
315
|
-
} catch {}
|
|
316
|
-
}
|
|
317
|
-
// Fallback: check PATH
|
|
318
|
-
if (!engines[engine].available) {
|
|
319
|
-
try {
|
|
320
|
-
const which = execSync(`which ${engine} 2>/dev/null`).toString().trim();
|
|
321
|
-
if (which) {
|
|
322
|
-
engines[engine].available = true;
|
|
323
|
-
engines[engine].path = which;
|
|
324
|
-
}
|
|
325
|
-
} catch {}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Parse providers file for aliases (if configured)
|
|
330
|
-
const configPath = path.join(process.env.HOME || '', '.nexuscrew', 'config.json');
|
|
331
|
-
let providersPath = '';
|
|
332
|
-
try {
|
|
333
|
-
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
334
|
-
providersPath = cfg.providersPath || '';
|
|
335
|
-
} catch {}
|
|
336
|
-
if (providersPath && fs.existsSync(providersPath)) {
|
|
337
|
-
const content = fs.readFileSync(providersPath, 'utf8');
|
|
338
|
-
const funcRegex = /^(codex-[a-z0-9-]+|claude-[a-z0-9-]+|gemini-[a-z0-9-]+|qwen-[a-z0-9-]+)\(\)/gm;
|
|
339
|
-
let match;
|
|
340
|
-
while ((match = funcRegex.exec(content)) !== null) {
|
|
341
|
-
const alias = match[1];
|
|
342
|
-
const baseEngine = alias.split('-')[0];
|
|
343
|
-
const engine = baseEngine === 'codex' ? 'codex' : baseEngine === 'claude' ? 'claude' : baseEngine === 'gemini' ? 'gemini' : 'qwen';
|
|
344
|
-
if (engines[engine]) {
|
|
345
|
-
engines[engine].aliases.push(alias);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
}
|
|
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
|
+
});
|
|
349
546
|
|
|
350
|
-
|
|
351
|
-
|
|
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
|
+
});
|
|
352
556
|
|
|
353
|
-
program.
|
|
557
|
+
program.parseAsync();
|