@mmmbuto/nexuscrew 0.2.1 → 0.2.3
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 +122 -67
- package/bin/nexuscrew.js +194 -470
- package/frontend/dist/assets/{index-BG1N7bSL.js → index-BsldfYnr.js} +1704 -1711
- package/frontend/dist/assets/index-DcQGg3dp.css +1 -0
- package/frontend/dist/index.html +3 -11
- package/lib/server/routes/hosts.js +22 -12
- package/lib/server/routes/launchers.js +12 -0
- package/lib/server/routes/models.js +16 -63
- package/lib/server/routes/runtime.js +10 -0
- package/lib/server/routes/send.js +97 -258
- package/lib/server/routes/sessions.js +13 -42
- package/lib/server/routes/status.js +18 -22
- package/lib/server/routes/tmux.js +88 -0
- package/lib/server/server.js +60 -81
- package/lib/services/engine-discovery.js +192 -445
- package/lib/services/log-watcher.js +22 -40
- package/lib/services/session-store.js +88 -62
- package/lib/services/tmux-manager.js +111 -173
- package/package.json +6 -10
- package/frontend/dist/apple-touch-icon.png +0 -0
- package/frontend/dist/assets/index-CiAtinNP.css +0 -1
- package/frontend/dist/favicon.svg +0 -20
- package/frontend/dist/icon-192.png +0 -0
- package/frontend/dist/icon-512.png +0 -0
- package/frontend/dist/site.webmanifest +0 -29
- package/lib/config/hosts.js +0 -67
- package/lib/config/manager.js +0 -379
- package/lib/config/models.js +0 -408
- package/lib/server/db/adapter.js +0 -274
- package/lib/server/db/drivers/sql-js.js +0 -75
- package/lib/server/db/migrate.js +0 -174
- package/lib/server/db/migrations/001_base_schema.sql +0 -70
- package/lib/server/middleware/auth.js +0 -134
- package/lib/server/middleware/rate-limit.js +0 -63
- package/lib/server/models/User.js +0 -128
- package/lib/server/routes/auth.js +0 -168
- package/lib/server/routes/keys.js +0 -28
- package/lib/server/routes/runtimes.js +0 -34
- package/lib/server/routes/speech.js +0 -46
- package/lib/server/routes/upload.js +0 -135
- package/lib/server/routes/wake-lock.js +0 -95
- package/lib/server/routes/workspaces.js +0 -101
- package/lib/server/services/attachment-manager.js +0 -57
- package/lib/server/services/context-bridge.js +0 -425
- package/lib/server/services/runtime-manager.js +0 -462
- package/lib/server/services/speech-manager.js +0 -76
- package/lib/server/services/summary-generator.js +0 -309
- package/lib/server/services/workspace-manager.js +0 -79
- package/lib/services/remote-pane-watcher.js +0 -155
- package/lib/setup/postinstall.js +0 -38
- package/lib/utils/paths.js +0 -124
- package/lib/utils/termux.js +0 -182
package/bin/nexuscrew.js
CHANGED
|
@@ -2,556 +2,280 @@
|
|
|
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');
|
|
8
5
|
const path = require('path');
|
|
9
6
|
const fs = require('fs');
|
|
10
|
-
const
|
|
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');
|
|
35
|
-
|
|
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
|
-
}
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const { execSync, spawn } = require('child_process');
|
|
140
9
|
|
|
141
|
-
|
|
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
|
-
}
|
|
10
|
+
const EngineDiscovery = require('../lib/services/engine-discovery');
|
|
11
|
+
const packageJson = require('../package.json');
|
|
219
12
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
});
|
|
13
|
+
const CONFIG_DIR = path.join(os.homedir(), '.nexuscrew');
|
|
14
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
15
|
+
const HOSTS_PATH = path.join(CONFIG_DIR, 'hosts.json');
|
|
232
16
|
|
|
233
|
-
|
|
234
|
-
writePidFile(child.pid);
|
|
235
|
-
return child.pid;
|
|
236
|
-
}
|
|
17
|
+
const DEFAULT_PORT = '41820';
|
|
237
18
|
|
|
19
|
+
const program = new Command();
|
|
238
20
|
program
|
|
239
21
|
.name('nexuscrew')
|
|
240
22
|
.description('tmux-based AI cockpit')
|
|
241
|
-
.version(
|
|
23
|
+
.version(packageJson.version);
|
|
242
24
|
|
|
243
25
|
program.command('init')
|
|
244
|
-
.description('
|
|
245
|
-
.option('
|
|
26
|
+
.description('First-time setup')
|
|
27
|
+
.option('--silent', 'Suppress setup output')
|
|
246
28
|
.action((opts) => {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
console.
|
|
252
|
-
|
|
253
|
-
console.log(`tmux: ${commandExists('tmux') ? 'OK' : 'missing'}`);
|
|
254
|
-
console.log(`ssh: ${commandExists('ssh') ? 'OK' : 'missing'}`);
|
|
255
|
-
console.log(`scp: ${commandExists('scp') ? 'OK' : 'missing'}`);
|
|
256
|
-
}
|
|
257
|
-
});
|
|
29
|
+
const log = (...args) => {
|
|
30
|
+
if (!opts.silent) console.log(...args);
|
|
31
|
+
};
|
|
32
|
+
const error = (...args) => {
|
|
33
|
+
if (!opts.silent) console.error(...args);
|
|
34
|
+
};
|
|
258
35
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
}
|
|
36
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
37
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
38
|
+
log('Created ~/.nexuscrew/');
|
|
282
39
|
}
|
|
283
40
|
|
|
284
|
-
if (
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
type: 'ssh',
|
|
294
|
-
host,
|
|
295
|
-
user,
|
|
296
|
-
port: sshPort,
|
|
297
|
-
key: key || null,
|
|
298
|
-
default: false
|
|
299
|
-
});
|
|
300
|
-
writeHosts(hosts);
|
|
41
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
42
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify({
|
|
43
|
+
port: Number(DEFAULT_PORT),
|
|
44
|
+
logDir: path.join(CONFIG_DIR, 'logs'),
|
|
45
|
+
autoDiscovery: true,
|
|
46
|
+
preferredShell: '',
|
|
47
|
+
extraShellSources: []
|
|
48
|
+
}, null, 2));
|
|
49
|
+
log('Created config.json');
|
|
301
50
|
}
|
|
302
51
|
|
|
303
|
-
if (
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
}
|
|
52
|
+
if (!fs.existsSync(HOSTS_PATH)) {
|
|
53
|
+
fs.writeFileSync(HOSTS_PATH, JSON.stringify([
|
|
54
|
+
{ name: 'local', type: 'local', default: true }
|
|
55
|
+
], null, 2));
|
|
56
|
+
log('Created hosts.json');
|
|
318
57
|
}
|
|
319
58
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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')}`);
|
|
59
|
+
const logDir = path.join(CONFIG_DIR, 'logs');
|
|
60
|
+
if (!fs.existsSync(logDir)) {
|
|
61
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
334
62
|
}
|
|
335
|
-
console.log('Run "nexuscrew stt doctor" to finish local speech setup.');
|
|
336
|
-
});
|
|
337
63
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
tmux.ensureSession('local');
|
|
359
|
-
|
|
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;
|
|
64
|
+
try {
|
|
65
|
+
execSync('command -v tmux >/dev/null 2>&1', { stdio: 'pipe' });
|
|
66
|
+
log('tmux: OK');
|
|
67
|
+
} catch {
|
|
68
|
+
error('tmux not found! Install: apt install tmux (or pkg install tmux)');
|
|
372
69
|
}
|
|
373
70
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
console.log(`Log: ${PATHS.SERVER_LOG}`);
|
|
71
|
+
log('\nDone! Run: nexuscrew start');
|
|
72
|
+
});
|
|
377
73
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
74
|
+
program.command('start')
|
|
75
|
+
.description('Start the server')
|
|
76
|
+
.option('-p, --port <port>', 'Port', DEFAULT_PORT)
|
|
77
|
+
.option('-d, --dev', 'Dev mode (no frontend)')
|
|
78
|
+
.action((opts) => {
|
|
79
|
+
const env = { ...process.env, PORT: opts.port };
|
|
80
|
+
if (opts.dev) env.NEXUSCREW_DEV = '1';
|
|
81
|
+
|
|
82
|
+
const args = [path.join(__dirname, '..', 'lib', 'server', 'server.js')];
|
|
83
|
+
const child = spawn('node', args, {
|
|
84
|
+
env,
|
|
85
|
+
stdio: 'inherit',
|
|
86
|
+
detached: false
|
|
87
|
+
});
|
|
88
|
+
child.on('exit', (code) => process.exit(code || 0));
|
|
382
89
|
});
|
|
383
90
|
|
|
384
91
|
program.command('stop')
|
|
385
92
|
.description('Stop the server')
|
|
386
93
|
.action(() => {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
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;
|
|
94
|
+
try {
|
|
95
|
+
const pid = execSync(`lsof -ti :${DEFAULT_PORT} 2>/dev/null`).toString().trim();
|
|
96
|
+
if (pid) {
|
|
97
|
+
execSync(`kill ${pid}`);
|
|
98
|
+
console.log('Server stopped');
|
|
99
|
+
} else {
|
|
100
|
+
console.log('Server not running');
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
console.log('Server not running');
|
|
405
104
|
}
|
|
406
|
-
|
|
407
|
-
console.log('Server not running');
|
|
408
105
|
});
|
|
409
106
|
|
|
410
107
|
program.command('status')
|
|
411
|
-
.description('Show server
|
|
412
|
-
.action(
|
|
413
|
-
|
|
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
|
-
});
|
|
108
|
+
.description('Show server and tmux status')
|
|
109
|
+
.action(() => {
|
|
110
|
+
console.log('=== NexusCrew Status ===\n');
|
|
432
111
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
112
|
+
try {
|
|
113
|
+
const pid = execSync(`lsof -ti :${DEFAULT_PORT} 2>/dev/null`).toString().trim();
|
|
114
|
+
console.log(`Server: RUNNING (pid ${pid})`);
|
|
115
|
+
} catch {
|
|
116
|
+
console.log('Server: STOPPED');
|
|
117
|
+
}
|
|
438
118
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
119
|
+
try {
|
|
120
|
+
const sessions = execSync("tmux list-sessions -F '#{session_name} #{?session_attached,*, }' 2>/dev/null").toString().trim();
|
|
121
|
+
console.log('\nActive tmux sessions:');
|
|
122
|
+
sessions.split('\n').filter(Boolean).forEach((session) => console.log(` ${session}`));
|
|
123
|
+
} catch {
|
|
124
|
+
console.log('\nActive tmux sessions: none');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (fs.existsSync(HOSTS_PATH)) {
|
|
128
|
+
const hosts = JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'));
|
|
129
|
+
console.log('\nHosts:');
|
|
130
|
+
hosts.forEach((host) => {
|
|
131
|
+
if (host.type === 'local') {
|
|
132
|
+
console.log(` ${host.name}: local`);
|
|
133
|
+
} else {
|
|
134
|
+
const status = testSSH(host) ? 'OK' : 'UNREACHABLE';
|
|
135
|
+
console.log(` ${host.name}: ${host.user}@${host.host}${host.port ? ':' + host.port : ''} [${status}]`);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
448
138
|
}
|
|
449
139
|
});
|
|
450
140
|
|
|
451
141
|
program.command('attach')
|
|
452
|
-
.description('Attach to
|
|
453
|
-
.
|
|
142
|
+
.description('Attach to a tmux session')
|
|
143
|
+
.requiredOption('-s, --session <name>', 'Session name')
|
|
454
144
|
.action((opts) => {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
145
|
+
try {
|
|
146
|
+
execSync(`tmux attach -t ${shellQuote(opts.session)}`, { stdio: 'inherit' });
|
|
147
|
+
} catch {
|
|
148
|
+
console.error(`Session not found: ${opts.session}`);
|
|
149
|
+
}
|
|
458
150
|
});
|
|
459
151
|
|
|
460
152
|
program.command('sessions')
|
|
461
|
-
.description('List
|
|
153
|
+
.description('List active tmux sessions')
|
|
462
154
|
.action(() => {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
155
|
+
try {
|
|
156
|
+
const out = execSync(
|
|
157
|
+
"tmux list-sessions -F '#{session_name} [#{session_windows} windows] #{?session_attached,(attached),}' 2>/dev/null"
|
|
158
|
+
).toString().trim();
|
|
159
|
+
console.log(out || 'No tmux sessions found');
|
|
160
|
+
} catch {
|
|
161
|
+
console.log('No tmux sessions found');
|
|
468
162
|
}
|
|
469
|
-
windows.forEach((window) => {
|
|
470
|
-
console.log(`${window.index}: ${window.name} [${window.size}] ${window.active ? '(active)' : ''}`);
|
|
471
|
-
});
|
|
472
163
|
});
|
|
473
164
|
|
|
474
165
|
program.command('config')
|
|
475
|
-
.description('
|
|
476
|
-
.
|
|
477
|
-
.
|
|
478
|
-
.action((
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
console.log(JSON.stringify(getConfigValue(payload), null, 2));
|
|
166
|
+
.description('View/edit configuration')
|
|
167
|
+
.option('-l, --list', 'Show current config')
|
|
168
|
+
.option('-s, --set <key=value>', 'Set a config value')
|
|
169
|
+
.action((opts) => {
|
|
170
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
171
|
+
console.error('Run nexuscrew init first');
|
|
482
172
|
return;
|
|
483
173
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
174
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
175
|
+
|
|
176
|
+
if (opts.set) {
|
|
177
|
+
const [key, ...rest] = opts.set.split('=');
|
|
178
|
+
const value = rest.join('=');
|
|
179
|
+
let parsedValue = value;
|
|
180
|
+
if (value === 'true') parsedValue = true;
|
|
181
|
+
if (value === 'false') parsedValue = false;
|
|
182
|
+
if (!Number.isNaN(Number(value)) && value.trim() !== '') parsedValue = Number(value);
|
|
183
|
+
config[key] = parsedValue;
|
|
184
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
185
|
+
console.log(`Set ${key} = ${value}`);
|
|
186
|
+
} else {
|
|
187
|
+
console.log(JSON.stringify(config, null, 2));
|
|
489
188
|
}
|
|
490
|
-
console.log(JSON.stringify(getConfig(), null, 2));
|
|
491
189
|
});
|
|
492
190
|
|
|
493
|
-
const hostsCmd = program.command('hosts').description('Manage
|
|
191
|
+
const hostsCmd = program.command('hosts').description('Manage remote hosts');
|
|
494
192
|
|
|
495
193
|
hostsCmd.command('list')
|
|
194
|
+
.description('List configured hosts')
|
|
496
195
|
.action(() => {
|
|
497
|
-
|
|
498
|
-
console.log(
|
|
196
|
+
if (!fs.existsSync(HOSTS_PATH)) {
|
|
197
|
+
console.log('No hosts configured. Run nexuscrew init.');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const hosts = JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'));
|
|
201
|
+
hosts.forEach((host, i) => {
|
|
202
|
+
const status = host.type === 'local' ? 'local' : (testSSH(host) ? 'OK' : 'UNREACHABLE');
|
|
203
|
+
console.log(`[${i}] ${host.name} (${host.type}) ${host.host || ''} [${status}]${host.default ? ' *default*' : ''}`);
|
|
499
204
|
});
|
|
500
205
|
});
|
|
501
206
|
|
|
502
207
|
hostsCmd.command('add')
|
|
503
|
-
.
|
|
504
|
-
.requiredOption('-
|
|
505
|
-
.
|
|
208
|
+
.description('Add a remote host')
|
|
209
|
+
.requiredOption('-n, --name <name>', 'Host name')
|
|
210
|
+
.requiredOption('-h, --host <host>', 'Hostname or IP')
|
|
211
|
+
.option('-u, --user <user>', 'SSH user', os.userInfo().username)
|
|
506
212
|
.option('-p, --port <port>', 'SSH port', '22')
|
|
507
213
|
.option('-k, --key <path>', 'SSH key path')
|
|
508
214
|
.action((opts) => {
|
|
509
|
-
const hosts =
|
|
215
|
+
const hosts = fs.existsSync(HOSTS_PATH)
|
|
216
|
+
? JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'))
|
|
217
|
+
: [];
|
|
510
218
|
hosts.push({
|
|
511
219
|
name: opts.name,
|
|
512
220
|
type: 'ssh',
|
|
513
221
|
host: opts.host,
|
|
514
222
|
user: opts.user,
|
|
515
223
|
port: opts.port,
|
|
516
|
-
key: opts.key ||
|
|
517
|
-
default: false
|
|
224
|
+
key: opts.key || undefined
|
|
518
225
|
});
|
|
519
|
-
|
|
226
|
+
fs.writeFileSync(HOSTS_PATH, JSON.stringify(hosts, null, 2));
|
|
520
227
|
console.log(`Added host: ${opts.name}`);
|
|
521
228
|
});
|
|
522
229
|
|
|
523
230
|
hostsCmd.command('remove')
|
|
524
|
-
.
|
|
231
|
+
.description('Remove a host')
|
|
232
|
+
.requiredOption('-n, --name <name>', 'Host name')
|
|
525
233
|
.action((opts) => {
|
|
526
|
-
|
|
234
|
+
let hosts = fs.existsSync(HOSTS_PATH)
|
|
235
|
+
? JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'))
|
|
236
|
+
: [];
|
|
237
|
+
hosts = hosts.filter((host) => host.name !== opts.name);
|
|
238
|
+
fs.writeFileSync(HOSTS_PATH, JSON.stringify(hosts, null, 2));
|
|
527
239
|
console.log(`Removed host: ${opts.name}`);
|
|
528
240
|
});
|
|
529
241
|
|
|
530
|
-
|
|
531
|
-
.
|
|
532
|
-
.action((
|
|
533
|
-
const
|
|
534
|
-
console.log(
|
|
242
|
+
program.command('engines')
|
|
243
|
+
.description('Discover available launchers')
|
|
244
|
+
.action(() => {
|
|
245
|
+
const launchers = new EngineDiscovery(readConfig()).getLaunchers();
|
|
246
|
+
console.log('\nDiscovered Launchers:\n');
|
|
247
|
+
for (const launcher of launchers) {
|
|
248
|
+
console.log(` ${launcher.label}: ${launcher.kind} [${launcher.family}]${launcher.runnable ? '' : ' (detected only)'}`);
|
|
249
|
+
console.log(` command: ${launcher.commandPreview}`);
|
|
250
|
+
console.log(` source: ${launcher.source}`);
|
|
251
|
+
}
|
|
252
|
+
console.log('');
|
|
535
253
|
});
|
|
536
254
|
|
|
537
|
-
|
|
255
|
+
function readConfig() {
|
|
256
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
257
|
+
return { preferredShell: '', extraShellSources: [] };
|
|
258
|
+
}
|
|
259
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
260
|
+
if (config.providersPath && !config.extraShellSources) {
|
|
261
|
+
config.extraShellSources = [config.providersPath].filter(Boolean);
|
|
262
|
+
}
|
|
263
|
+
return config;
|
|
264
|
+
}
|
|
538
265
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
}
|
|
266
|
+
function testSSH(host) {
|
|
267
|
+
try {
|
|
268
|
+
const keyOpt = host.key ? `-i ${shellQuote(host.key)}` : '';
|
|
269
|
+
const portOpt = host.port && host.port !== '22' ? `-p ${host.port}` : '';
|
|
270
|
+
execSync(`ssh -o ConnectTimeout=3 -o BatchMode=yes ${keyOpt} ${portOpt} ${host.user}@${host.host} echo ok`, { stdio: 'pipe' });
|
|
271
|
+
return true;
|
|
272
|
+
} catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
546
276
|
|
|
547
|
-
|
|
548
|
-
.
|
|
549
|
-
|
|
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
|
-
});
|
|
277
|
+
function shellQuote(value) {
|
|
278
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
279
|
+
}
|
|
556
280
|
|
|
557
|
-
program.
|
|
281
|
+
program.parse();
|