@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
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const { EventEmitter } = require('events');
|
|
2
|
+
const LogWatcher = require('./log-watcher');
|
|
3
|
+
|
|
4
|
+
function normalizeSnapshot(text = '') {
|
|
5
|
+
return String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function commonPrefixLength(a = '', b = '') {
|
|
9
|
+
const max = Math.min(a.length, b.length);
|
|
10
|
+
let index = 0;
|
|
11
|
+
while (index < max && a[index] === b[index]) {
|
|
12
|
+
index += 1;
|
|
13
|
+
}
|
|
14
|
+
return index;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class RemotePaneWatcher extends EventEmitter {
|
|
18
|
+
constructor(tmux, options = {}) {
|
|
19
|
+
super();
|
|
20
|
+
this.tmux = tmux;
|
|
21
|
+
this.parserAdapter = new LogWatcher();
|
|
22
|
+
this.watchers = new Map();
|
|
23
|
+
this.pollMs = options.pollMs || 400;
|
|
24
|
+
this.idleMs = options.idleMs || 4500;
|
|
25
|
+
this.timeoutMs = options.timeoutMs || 120000;
|
|
26
|
+
|
|
27
|
+
this.parserAdapter.on('data', (event) => this.emit('data', event));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
watch({ conversationId, engine, host, windowName, promptText = '', initialSnapshot = '' }) {
|
|
31
|
+
const key = `${host?.name || host || 'local'}:${windowName}:${conversationId}`;
|
|
32
|
+
if (this.watchers.has(key)) return key;
|
|
33
|
+
|
|
34
|
+
const parserState = this.parserAdapter.createParserState(engine, conversationId);
|
|
35
|
+
const state = {
|
|
36
|
+
key,
|
|
37
|
+
conversationId,
|
|
38
|
+
engine,
|
|
39
|
+
host,
|
|
40
|
+
windowName,
|
|
41
|
+
parserState,
|
|
42
|
+
promptText: String(promptText || ''),
|
|
43
|
+
lastSnapshot: normalizeSnapshot(initialSnapshot),
|
|
44
|
+
startedAt: Date.now(),
|
|
45
|
+
lastChangeAt: Date.now(),
|
|
46
|
+
lastEmitAt: 0,
|
|
47
|
+
stopped: false,
|
|
48
|
+
sawAnyOutput: false
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const intervalId = setInterval(() => this._poll(state), this.pollMs);
|
|
52
|
+
state.intervalId = intervalId;
|
|
53
|
+
this.watchers.set(key, state);
|
|
54
|
+
return key;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
unwatch(key) {
|
|
58
|
+
const state = this.watchers.get(key);
|
|
59
|
+
if (!state) return;
|
|
60
|
+
clearInterval(state.intervalId);
|
|
61
|
+
state.stopped = true;
|
|
62
|
+
this.watchers.delete(key);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
unwatchByConversation(conversationId) {
|
|
66
|
+
for (const [key, state] of this.watchers.entries()) {
|
|
67
|
+
if (state.conversationId === conversationId) {
|
|
68
|
+
this.unwatch(key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
unwatchAll() {
|
|
74
|
+
for (const key of this.watchers.keys()) {
|
|
75
|
+
this.unwatch(key);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_poll(state) {
|
|
80
|
+
if (state.stopped) return;
|
|
81
|
+
|
|
82
|
+
if (Date.now() - state.startedAt > this.timeoutMs) {
|
|
83
|
+
this._finish(state, true);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const snapshot = normalizeSnapshot(
|
|
88
|
+
this.tmux.capturePane(state.windowName, state.host, 250)
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (!snapshot) {
|
|
92
|
+
if (Date.now() - state.lastChangeAt > this.idleMs) {
|
|
93
|
+
this._finish(state, true);
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (snapshot === state.lastSnapshot) {
|
|
99
|
+
if (state.sawAnyOutput && Date.now() - state.lastChangeAt > this.idleMs) {
|
|
100
|
+
this._finish(state, true);
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const prefixLength = commonPrefixLength(state.lastSnapshot, snapshot);
|
|
106
|
+
let delta = snapshot.slice(prefixLength);
|
|
107
|
+
state.lastSnapshot = snapshot;
|
|
108
|
+
state.lastChangeAt = Date.now();
|
|
109
|
+
|
|
110
|
+
if (!delta.trim()) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
delta = this._stripPromptEcho(delta, state);
|
|
115
|
+
if (!delta.trim()) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
state.sawAnyOutput = true;
|
|
120
|
+
this.parserAdapter.processTextChunk(delta, state.parserState);
|
|
121
|
+
state.lastEmitAt = Date.now();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
_stripPromptEcho(delta, state) {
|
|
125
|
+
if (!state.promptText) return delta;
|
|
126
|
+
|
|
127
|
+
const prompt = state.promptText.trim();
|
|
128
|
+
if (!prompt) return delta;
|
|
129
|
+
|
|
130
|
+
if (delta.includes(prompt)) {
|
|
131
|
+
return delta.replace(prompt, '');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const compact = delta.trim();
|
|
135
|
+
if (prompt.startsWith(compact) && compact.length > 10) {
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return delta;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_finish(state, emitDone = false) {
|
|
143
|
+
this.parserAdapter.flushState(state.parserState);
|
|
144
|
+
if (emitDone) {
|
|
145
|
+
this.emit('data', {
|
|
146
|
+
type: 'done',
|
|
147
|
+
conversationId: state.conversationId,
|
|
148
|
+
engine: state.engine
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
this.unwatch(state.key);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = RemotePaneWatcher;
|
|
@@ -81,8 +81,8 @@ class SessionStore {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/** Get conversations grouped by date */
|
|
84
|
-
listGroupedByDate({ workspace, limit = 100 } = {}) {
|
|
85
|
-
const convs = this.listConversations({ workspace, limit });
|
|
84
|
+
listGroupedByDate({ workspace, host, limit = 100 } = {}) {
|
|
85
|
+
const convs = this.listConversations({ workspace, host, limit });
|
|
86
86
|
const grouped = {};
|
|
87
87
|
for (const c of convs) {
|
|
88
88
|
const date = new Date(c.updated_at * 1000).toISOString().split('T')[0];
|
|
@@ -155,7 +155,7 @@ class SessionStore {
|
|
|
155
155
|
const active = stmt.all();
|
|
156
156
|
for (const conv of active) {
|
|
157
157
|
if (conv.tmux_window) {
|
|
158
|
-
const exists = tmuxManager.hasWindow(conv.tmux_window, conv.host
|
|
158
|
+
const exists = tmuxManager.hasWindow(conv.tmux_window, conv.host || 'local');
|
|
159
159
|
if (!exists) {
|
|
160
160
|
this.updateConversation(conv.id, { status: 'dead' });
|
|
161
161
|
}
|
|
@@ -5,35 +5,67 @@
|
|
|
5
5
|
* Every AI session = one tmux window inside the master session.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
const { execSync
|
|
8
|
+
const { execSync } = require('child_process');
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const fs = require('fs');
|
|
11
|
+
const { readHosts, getHostByName } = require('../config/hosts');
|
|
12
|
+
const { PATHS } = require('../utils/paths');
|
|
11
13
|
|
|
12
14
|
const TMUX_SESSION = 'nexuscrew';
|
|
13
15
|
|
|
16
|
+
function shellEscape(value) {
|
|
17
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
18
|
+
}
|
|
19
|
+
|
|
14
20
|
class TmuxManager {
|
|
15
21
|
constructor(config = {}) {
|
|
16
22
|
this.sessionName = config.tmuxSession || TMUX_SESSION;
|
|
17
|
-
this.logDir = config.logDir ||
|
|
18
|
-
this.hosts = config.hosts || [{ name: 'local', type: 'local' }];
|
|
23
|
+
this.logDir = config.logDir || PATHS.LOGS_DIR;
|
|
19
24
|
|
|
20
|
-
// Ensure log dir
|
|
21
25
|
if (!fs.existsSync(this.logDir)) {
|
|
22
26
|
fs.mkdirSync(this.logDir, { recursive: true });
|
|
23
27
|
}
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
getAllHosts() {
|
|
31
|
+
return readHosts();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
getHost(name) {
|
|
35
|
+
return this.resolveHost(name);
|
|
36
|
+
}
|
|
27
37
|
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
resolveHost(hostRef) {
|
|
39
|
+
if (!hostRef || hostRef === 'local') {
|
|
40
|
+
return getHostByName('local');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof hostRef === 'string') {
|
|
44
|
+
return getHostByName(hostRef);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (typeof hostRef === 'object') {
|
|
48
|
+
if (hostRef.type === 'local' || hostRef.name === 'local') {
|
|
49
|
+
return getHostByName('local');
|
|
50
|
+
}
|
|
51
|
+
if (hostRef.name) {
|
|
52
|
+
return { ...getHostByName(hostRef.name), ...hostRef };
|
|
53
|
+
}
|
|
54
|
+
return hostRef;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
ensureSession(hostRef = null) {
|
|
61
|
+
const host = this.resolveHost(hostRef);
|
|
30
62
|
const cmd = this._cmd('tmux new-session -d -s ${SESSION} -x 200 -y 50 2>/dev/null || true', host);
|
|
31
63
|
this._exec(cmd);
|
|
32
64
|
}
|
|
33
65
|
|
|
34
|
-
|
|
35
|
-
hasSession(host = null) {
|
|
66
|
+
hasSession(hostRef = null) {
|
|
36
67
|
try {
|
|
68
|
+
const host = this.resolveHost(hostRef);
|
|
37
69
|
this._exec(this._cmd('tmux has-session -t ${SESSION} 2>/dev/null', host));
|
|
38
70
|
return true;
|
|
39
71
|
} catch {
|
|
@@ -41,98 +73,68 @@ class TmuxManager {
|
|
|
41
73
|
}
|
|
42
74
|
}
|
|
43
75
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Create a new tmux window running a CLI command
|
|
48
|
-
* @param {Object} opts
|
|
49
|
-
* @param {string} opts.windowName - Unique window name (e.g. "claude-abc123")
|
|
50
|
-
* @param {string} opts.command - Full command to run
|
|
51
|
-
* @param {string} opts.cwd - Working directory
|
|
52
|
-
* @param {string} opts.host - Host name or null for local
|
|
53
|
-
* @returns {{ windowName, logPath }}
|
|
54
|
-
*/
|
|
55
|
-
createWindow({ windowName, command, cwd, host = null }) {
|
|
76
|
+
createWindow({ windowName, command, cwd, host: hostRef = null }) {
|
|
77
|
+
const host = this.resolveHost(hostRef);
|
|
56
78
|
this.ensureSession(host);
|
|
57
79
|
|
|
58
|
-
const logPath =
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
// For remote hosts, logs go to a local path
|
|
62
|
-
const fullCommand = cwd
|
|
63
|
-
? `cd ${cwd} && ${command} 2>&1 | tee ${logPath}`
|
|
64
|
-
: `${command} 2>&1 | tee ${logPath}`;
|
|
80
|
+
const logPath = this.getLogPath(windowName);
|
|
81
|
+
const targetCwd = cwd || process.env.HOME || '.';
|
|
82
|
+
const fullCommand = `cd ${shellEscape(targetCwd)} && ${command} 2>&1 | tee ${shellEscape(logPath)}`;
|
|
65
83
|
|
|
66
84
|
const cmd = this._cmd(
|
|
67
|
-
`tmux new-window -t \${SESSION} -n ${windowName} -c
|
|
85
|
+
`tmux new-window -t \${SESSION} -n ${shellEscape(windowName)} -c ${shellEscape(targetCwd)} ${shellEscape(fullCommand)}`,
|
|
68
86
|
host
|
|
69
87
|
);
|
|
70
88
|
|
|
71
|
-
this._exec(cmd);
|
|
72
|
-
|
|
89
|
+
this._exec(cmd, 20000);
|
|
73
90
|
return { windowName, logPath };
|
|
74
91
|
}
|
|
75
92
|
|
|
76
|
-
/**
|
|
77
|
-
* Send text to a tmux window (like typing)
|
|
78
|
-
* @param {string} windowName
|
|
79
|
-
* @param {string} text - Text to send
|
|
80
|
-
* @param {Object} opts
|
|
81
|
-
* @param {boolean} opts.enter - Press Enter after text
|
|
82
|
-
* @param {string} opts.host - Host name or null
|
|
83
|
-
*/
|
|
84
93
|
sendKeys(windowName, text, opts = {}) {
|
|
85
|
-
const
|
|
86
|
-
const escapedText = text.replace(/'/g, "'\\''");
|
|
94
|
+
const host = this.resolveHost(opts.host);
|
|
87
95
|
const cmd = this._cmd(
|
|
88
|
-
`tmux send-keys -t \${SESSION}:${windowName}
|
|
89
|
-
|
|
96
|
+
`tmux send-keys -t \${SESSION}:${shellEscape(windowName)} -l ${shellEscape(text)}`,
|
|
97
|
+
host
|
|
90
98
|
);
|
|
91
99
|
this._exec(cmd);
|
|
100
|
+
if (opts.enter) {
|
|
101
|
+
this.sendSpecialKey(windowName, 'Enter', host);
|
|
102
|
+
}
|
|
92
103
|
}
|
|
93
104
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
*/
|
|
97
|
-
sendSpecialKey(windowName, key, host = null) {
|
|
105
|
+
sendSpecialKey(windowName, key, hostRef = null) {
|
|
106
|
+
const host = this.resolveHost(hostRef);
|
|
98
107
|
const cmd = this._cmd(
|
|
99
|
-
`tmux send-keys -t \${SESSION}:${windowName} ${key}`,
|
|
108
|
+
`tmux send-keys -t \${SESSION}:${shellEscape(windowName)} ${shellEscape(key)}`,
|
|
100
109
|
host
|
|
101
110
|
);
|
|
102
111
|
this._exec(cmd);
|
|
103
112
|
}
|
|
104
113
|
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
interrupt(windowName, hostRef = null) {
|
|
115
|
+
const host = this.resolveHost(hostRef);
|
|
107
116
|
this.sendSpecialKey(windowName, 'Escape', host);
|
|
108
|
-
|
|
109
|
-
this.sendSpecialKey(windowName, 'C-c', host);
|
|
110
|
-
}, 100);
|
|
117
|
+
this.sendSpecialKey(windowName, 'C-c', host);
|
|
111
118
|
}
|
|
112
119
|
|
|
113
|
-
|
|
114
|
-
* Capture current pane content
|
|
115
|
-
* @returns {string} visible text in the pane
|
|
116
|
-
*/
|
|
117
|
-
capturePane(windowName, host = null) {
|
|
120
|
+
capturePane(windowName, hostRef = null, lines = 200) {
|
|
118
121
|
try {
|
|
122
|
+
const host = this.resolveHost(hostRef);
|
|
119
123
|
const cmd = this._cmd(
|
|
120
|
-
`tmux capture-pane -t \${SESSION}:${windowName} -p -S
|
|
124
|
+
`tmux capture-pane -t \${SESSION}:${shellEscape(windowName)} -p -S -${Math.max(50, lines)}`,
|
|
121
125
|
host
|
|
122
126
|
);
|
|
123
|
-
return this._exec(cmd);
|
|
127
|
+
return this._exec(cmd, 15000);
|
|
124
128
|
} catch {
|
|
125
129
|
return '';
|
|
126
130
|
}
|
|
127
131
|
}
|
|
128
132
|
|
|
129
|
-
|
|
130
|
-
* Check if a window exists
|
|
131
|
-
*/
|
|
132
|
-
hasWindow(windowName, host = null) {
|
|
133
|
+
hasWindow(windowName, hostRef = null) {
|
|
133
134
|
try {
|
|
135
|
+
const host = this.resolveHost(hostRef);
|
|
134
136
|
const cmd = this._cmd(
|
|
135
|
-
`tmux list-windows -t \${SESSION} -F '#{
|
|
137
|
+
`tmux list-windows -t \${SESSION} -F '#{window_name}' 2>/dev/null | grep -Fx -- ${shellEscape(windowName)}`,
|
|
136
138
|
host
|
|
137
139
|
);
|
|
138
140
|
this._exec(cmd);
|
|
@@ -142,38 +144,35 @@ class TmuxManager {
|
|
|
142
144
|
}
|
|
143
145
|
}
|
|
144
146
|
|
|
145
|
-
|
|
146
|
-
* Kill a window
|
|
147
|
-
*/
|
|
148
|
-
killWindow(windowName, host = null) {
|
|
147
|
+
killWindow(windowName, hostRef = null) {
|
|
149
148
|
try {
|
|
149
|
+
const host = this.resolveHost(hostRef);
|
|
150
150
|
const cmd = this._cmd(
|
|
151
|
-
`tmux kill-window -t \${SESSION}:${windowName} 2>/dev/null`,
|
|
151
|
+
`tmux kill-window -t \${SESSION}:${shellEscape(windowName)} 2>/dev/null`,
|
|
152
152
|
host
|
|
153
153
|
);
|
|
154
154
|
this._exec(cmd);
|
|
155
155
|
} catch {}
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
|
|
159
|
-
* List all windows in the master session
|
|
160
|
-
* @returns {Array<{index, name, active, size}>}
|
|
161
|
-
*/
|
|
162
|
-
listWindows(host = null) {
|
|
158
|
+
listWindows(hostRef = null) {
|
|
163
159
|
try {
|
|
160
|
+
const host = this.resolveHost(hostRef);
|
|
164
161
|
const cmd = this._cmd(
|
|
165
|
-
`tmux list-windows -t \${SESSION} -F '#{
|
|
162
|
+
`tmux list-windows -t \${SESSION} -F '#{window_index}\t#{window_name}\t#{window_width}x#{window_height}\t#{?window_active,1,0}' 2>/dev/null`,
|
|
166
163
|
host
|
|
167
164
|
);
|
|
168
|
-
const output = this._exec(cmd);
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
165
|
+
const output = this._exec(cmd, 15000).trim();
|
|
166
|
+
if (!output) return [];
|
|
167
|
+
|
|
168
|
+
return output.split('\n').filter(Boolean).map((line) => {
|
|
169
|
+
const [index, name, size, active] = line.split('\t');
|
|
172
170
|
return {
|
|
173
|
-
index:
|
|
174
|
-
name
|
|
171
|
+
index: Number(index),
|
|
172
|
+
name,
|
|
175
173
|
size,
|
|
176
|
-
active: active === '1'
|
|
174
|
+
active: active === '1',
|
|
175
|
+
host: host?.name || 'local'
|
|
177
176
|
};
|
|
178
177
|
});
|
|
179
178
|
} catch {
|
|
@@ -181,13 +180,11 @@ class TmuxManager {
|
|
|
181
180
|
}
|
|
182
181
|
}
|
|
183
182
|
|
|
184
|
-
|
|
185
|
-
* Get process PID running in a window
|
|
186
|
-
*/
|
|
187
|
-
getWindowPid(windowName, host = null) {
|
|
183
|
+
getWindowPid(windowName, hostRef = null) {
|
|
188
184
|
try {
|
|
185
|
+
const host = this.resolveHost(hostRef);
|
|
189
186
|
const cmd = this._cmd(
|
|
190
|
-
`tmux list-panes -t \${SESSION}:${windowName} -F '#{
|
|
187
|
+
`tmux list-panes -t \${SESSION}:${shellEscape(windowName)} -F '#{pane_pid}' 2>/dev/null`,
|
|
191
188
|
host
|
|
192
189
|
);
|
|
193
190
|
return this._exec(cmd).trim();
|
|
@@ -196,40 +193,51 @@ class TmuxManager {
|
|
|
196
193
|
}
|
|
197
194
|
}
|
|
198
195
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
/** Get host config by name */
|
|
202
|
-
getHost(name) {
|
|
203
|
-
if (!name || name === 'local') return null;
|
|
204
|
-
return this.hosts.find(h => h.name === name) || null;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** Get all configured hosts */
|
|
208
|
-
getAllHosts() {
|
|
209
|
-
return this.hosts;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** Test SSH connectivity */
|
|
213
|
-
testHost(host) {
|
|
196
|
+
testHost(hostRef) {
|
|
197
|
+
const host = this.resolveHost(hostRef);
|
|
214
198
|
if (!host || host.type === 'local') return true;
|
|
215
199
|
try {
|
|
216
|
-
|
|
217
|
-
this._exec(sshCmd, 5000);
|
|
200
|
+
this._exec(this._sshCmd(host, 'echo ok'), 5000);
|
|
218
201
|
return true;
|
|
219
202
|
} catch {
|
|
220
203
|
return false;
|
|
221
204
|
}
|
|
222
205
|
}
|
|
223
206
|
|
|
224
|
-
|
|
207
|
+
ensureRemoteDirectory(remotePath, hostRef) {
|
|
208
|
+
const host = this.resolveHost(hostRef);
|
|
209
|
+
if (!host || host.type === 'local') {
|
|
210
|
+
fs.mkdirSync(remotePath, { recursive: true });
|
|
211
|
+
return remotePath;
|
|
212
|
+
}
|
|
213
|
+
this._exec(this._sshCmd(host, `mkdir -p ${shellEscape(remotePath)}`), 10000);
|
|
214
|
+
return remotePath;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
copyToHost(localPath, remotePath, hostRef) {
|
|
218
|
+
const host = this.resolveHost(hostRef);
|
|
219
|
+
if (!host || host.type === 'local') {
|
|
220
|
+
const targetDir = path.dirname(remotePath);
|
|
221
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
222
|
+
fs.copyFileSync(localPath, remotePath);
|
|
223
|
+
return remotePath;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this.ensureRemoteDirectory(path.posix.dirname(remotePath), host);
|
|
227
|
+
const parts = ['scp', '-q', '-o', 'ConnectTimeout=5', '-o', 'BatchMode=yes'];
|
|
228
|
+
if (host.port && host.port !== '22') parts.push('-P', String(host.port));
|
|
229
|
+
if (host.key) parts.push('-i', host.key);
|
|
230
|
+
parts.push(localPath, `${host.user}@${host.host}:${remotePath}`);
|
|
231
|
+
this._exec(parts.map(shellEscape).join(' '), 30000);
|
|
232
|
+
return remotePath;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
getLogPath(windowName) {
|
|
236
|
+
return path.join(this.logDir, `${windowName}.log`);
|
|
237
|
+
}
|
|
225
238
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
* @param {string} tmpl - Command template with ${SESSION}
|
|
229
|
-
* @param {Object|null} host - Host config
|
|
230
|
-
* @returns {string} final command
|
|
231
|
-
*/
|
|
232
|
-
_cmd(tmpl, host) {
|
|
239
|
+
_cmd(tmpl, hostRef) {
|
|
240
|
+
const host = this.resolveHost(hostRef);
|
|
233
241
|
const cmd = tmpl.replace(/\$\{SESSION\}/g, this.sessionName);
|
|
234
242
|
if (!host || host.type === 'local') return cmd;
|
|
235
243
|
return this._sshCmd(host, cmd);
|
|
@@ -237,11 +245,14 @@ class TmuxManager {
|
|
|
237
245
|
|
|
238
246
|
_sshCmd(host, cmd) {
|
|
239
247
|
const parts = ['ssh', '-o', 'ConnectTimeout=5', '-o', 'BatchMode=yes'];
|
|
240
|
-
if (host.port && host.port !== '22') parts.push('-p', host.port);
|
|
248
|
+
if (host.port && host.port !== '22') parts.push('-p', String(host.port));
|
|
241
249
|
if (host.key) parts.push('-i', host.key);
|
|
242
250
|
parts.push(`${host.user}@${host.host}`);
|
|
243
|
-
parts.push(cmd);
|
|
244
|
-
return parts.
|
|
251
|
+
parts.push(shellEscape(cmd));
|
|
252
|
+
return parts.map((part, index) => {
|
|
253
|
+
if (index === parts.length - 1) return part;
|
|
254
|
+
return /\s/.test(part) ? shellEscape(part) : part;
|
|
255
|
+
}).join(' ');
|
|
245
256
|
}
|
|
246
257
|
|
|
247
258
|
_exec(cmd, timeout = 10000) {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const { ensureDirectories, PATHS } = require('../utils/paths');
|
|
5
|
+
const { initializeConfig, isInitialized } = require('../config/manager');
|
|
6
|
+
const { ensureHostsFile } = require('../config/hosts');
|
|
7
|
+
const { isTermux } = require('../utils/termux');
|
|
8
|
+
|
|
9
|
+
function has(command) {
|
|
10
|
+
try {
|
|
11
|
+
execSync(`which ${command}`, { stdio: 'ignore' });
|
|
12
|
+
return true;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
ensureDirectories();
|
|
19
|
+
ensureHostsFile();
|
|
20
|
+
|
|
21
|
+
if (!isInitialized()) {
|
|
22
|
+
initializeConfig({ workspaces: [`${process.env.HOME || ''}/Dev`] });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
console.log('');
|
|
26
|
+
console.log('NexusCrew postinstall');
|
|
27
|
+
console.log(` config: ${PATHS.CONFIG_FILE}`);
|
|
28
|
+
console.log(` hosts: ${PATHS.HOSTS_FILE}`);
|
|
29
|
+
console.log(` tmux: ${has('tmux') ? 'OK' : 'missing'}`);
|
|
30
|
+
console.log(` ssh: ${has('ssh') ? 'OK' : 'missing'}`);
|
|
31
|
+
console.log(` scp: ${has('scp') ? 'OK' : 'missing'}`);
|
|
32
|
+
console.log(` whisper-cli: ${has('whisper-cli') ? 'OK' : 'missing'}`);
|
|
33
|
+
if (isTermux()) {
|
|
34
|
+
console.log(' platform: Termux');
|
|
35
|
+
console.log(` termux-services: ${has('sv') ? 'OK' : 'missing'}`);
|
|
36
|
+
}
|
|
37
|
+
console.log(' next: run `nexuscrew setup` to configure workspace, SSH hosts, local STT, and optional Termux boot/runit');
|
|
38
|
+
console.log('');
|
package/lib/utils/paths.js
CHANGED
|
@@ -37,9 +37,24 @@ const PATHS = {
|
|
|
37
37
|
// Engines config directory
|
|
38
38
|
ENGINES_DIR: path.join(HOME, '.nexuscrew', 'engines'),
|
|
39
39
|
|
|
40
|
+
// Attachments directory
|
|
41
|
+
ATTACHMENTS_DIR: path.join(HOME, '.nexuscrew', 'attachments'),
|
|
42
|
+
|
|
43
|
+
// Local helper binaries
|
|
44
|
+
BIN_DIR: path.join(HOME, '.nexuscrew', 'bin'),
|
|
45
|
+
|
|
46
|
+
// Hosts registry
|
|
47
|
+
HOSTS_FILE: path.join(HOME, '.nexuscrew', 'hosts.json'),
|
|
48
|
+
|
|
40
49
|
// Termux boot script
|
|
41
50
|
BOOT_SCRIPT: path.join(HOME, '.termux', 'boot', 'nexuscrew-start.sh'),
|
|
42
51
|
|
|
52
|
+
// Termux runit service directory
|
|
53
|
+
TERMUX_SERVICE_DIR: path.join(process.env.PREFIX || '/data/data/com.termux/files/usr', 'var', 'service', 'nexuscrew'),
|
|
54
|
+
|
|
55
|
+
// Termux runit logs
|
|
56
|
+
TERMUX_SERVICE_LOG_DIR: path.join(process.env.PREFIX || '/data/data/com.termux/files/usr', 'var', 'log', 'sv', 'nexuscrew'),
|
|
57
|
+
|
|
43
58
|
// Claude CLI history (read-only)
|
|
44
59
|
CLAUDE_HISTORY: path.join(HOME, '.claude', 'history.jsonl'),
|
|
45
60
|
CLAUDE_PROJECTS: path.join(HOME, '.claude', 'projects'),
|
|
@@ -56,7 +71,9 @@ function ensureDirectories() {
|
|
|
56
71
|
PATHS.CONFIG_DIR,
|
|
57
72
|
PATHS.DATA_DIR,
|
|
58
73
|
PATHS.LOGS_DIR,
|
|
59
|
-
PATHS.ENGINES_DIR
|
|
74
|
+
PATHS.ENGINES_DIR,
|
|
75
|
+
PATHS.ATTACHMENTS_DIR,
|
|
76
|
+
PATHS.BIN_DIR
|
|
60
77
|
];
|
|
61
78
|
|
|
62
79
|
for (const dir of dirs) {
|
package/lib/utils/termux.js
CHANGED
|
@@ -24,6 +24,14 @@ function getPrefix() {
|
|
|
24
24
|
return process.env.PREFIX || '/data/data/com.termux/files/usr';
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function getServiceDir(serviceName = 'nexuscrew') {
|
|
28
|
+
return path.join(getPrefix(), 'var', 'service', serviceName);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getStartServicesScript() {
|
|
32
|
+
return path.join(getPrefix(), 'etc', 'profile.d', 'start-services.sh');
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
/**
|
|
28
36
|
* Check if a Termux package is installed
|
|
29
37
|
*/
|
|
@@ -68,6 +76,10 @@ function isTermuxBootAvailable() {
|
|
|
68
76
|
return fs.existsSync(bootDir);
|
|
69
77
|
}
|
|
70
78
|
|
|
79
|
+
function isTermuxServicesAvailable() {
|
|
80
|
+
return fs.existsSync(getStartServicesScript()) && isPackageInstalled('sv');
|
|
81
|
+
}
|
|
82
|
+
|
|
71
83
|
/**
|
|
72
84
|
* Acquire Termux wake lock
|
|
73
85
|
*/
|
|
@@ -130,16 +142,41 @@ function checkRequiredPackages() {
|
|
|
130
142
|
return status;
|
|
131
143
|
}
|
|
132
144
|
|
|
145
|
+
function openUrl(url) {
|
|
146
|
+
try {
|
|
147
|
+
if (isTermux() && isPackageInstalled('termux-open-url')) {
|
|
148
|
+
spawn('termux-open-url', [url], { detached: true, stdio: 'ignore' }).unref();
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (process.platform === 'darwin') {
|
|
153
|
+
spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (isPackageInstalled('xdg-open')) {
|
|
158
|
+
spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
} catch {}
|
|
162
|
+
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
133
166
|
module.exports = {
|
|
134
167
|
isTermux,
|
|
135
168
|
getPrefix,
|
|
169
|
+
getServiceDir,
|
|
170
|
+
getStartServicesScript,
|
|
136
171
|
isPackageInstalled,
|
|
137
172
|
installPackage,
|
|
138
173
|
isTermuxApiWorking,
|
|
139
174
|
isTermuxBootAvailable,
|
|
175
|
+
isTermuxServicesAvailable,
|
|
140
176
|
acquireWakeLock,
|
|
141
177
|
releaseWakeLock,
|
|
142
178
|
sendNotification,
|
|
143
179
|
getRequiredPackages,
|
|
144
|
-
checkRequiredPackages
|
|
180
|
+
checkRequiredPackages,
|
|
181
|
+
openUrl
|
|
145
182
|
};
|