@mmmbuto/nexuscli 0.7.1 → 0.7.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 +2 -1
- package/bin/nexuscli.js +6 -6
- package/lib/server/.env.example +1 -1
- package/lib/server/routes/sessions.js +42 -3
- package/lib/server/server.js +0 -2
- package/lib/server/services/cli-loader.js +75 -13
- package/lib/server/services/codex-output-parser.js +0 -8
- package/lib/server/services/context-bridge.js +4 -6
- package/lib/server/services/gemini-wrapper.js +6 -14
- package/lib/server/services/workspace-manager.js +1 -1
- package/lib/server/tests/performance.test.js +1 -1
- package/lib/server/tests/services.test.js +2 -2
- package/package.json +1 -1
- package/lib/server/db.js.old +0 -225
- package/lib/server/docs/API_WRAPPER_CONTRACT.md +0 -682
- package/lib/server/docs/ARCHITECTURE.md +0 -441
- package/lib/server/docs/DATABASE_SCHEMA.md +0 -783
- package/lib/server/docs/DESIGN_PRINCIPLES.md +0 -598
- package/lib/server/docs/NEXUSCHAT_ANALYSIS.md +0 -488
- package/lib/server/docs/PIPELINE_INTEGRATION.md +0 -636
- package/lib/server/docs/README.md +0 -272
- package/lib/server/docs/UI_DESIGN.md +0 -916
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
NexusCLI is an experimental, ultra-light terminal cockpit designed for
|
|
13
13
|
AI-assisted development workflows on Termux (Android).
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Mobile-First AI Control Plane
|
|
16
16
|
|
|
17
17
|
Web UI wrapper for Claude Code, Codex CLI, and Gemini CLI with voice input support.
|
|
18
18
|
|
|
@@ -96,6 +96,7 @@ nexuscli start
|
|
|
96
96
|
| `nexuscli start` | Start server (HTTP:41800 + HTTPS:41801) |
|
|
97
97
|
| `nexuscli stop` | Stop server |
|
|
98
98
|
| `nexuscli status` | Show status, ports, and engines |
|
|
99
|
+
| `nexuscli model [model-id]` | Set/get default model preference |
|
|
99
100
|
| `nexuscli engines` | Manage AI engines |
|
|
100
101
|
| `nexuscli workspaces` | Manage workspaces |
|
|
101
102
|
| `nexuscli config` | Configuration |
|
package/bin/nexuscli.js
CHANGED
|
@@ -110,18 +110,18 @@ program
|
|
|
110
110
|
.option('-a, --admin', 'Create as admin user')
|
|
111
111
|
.action(usersCommand);
|
|
112
112
|
|
|
113
|
-
// nexuscli setup-termux
|
|
114
|
-
program
|
|
115
|
-
.command('setup-termux')
|
|
116
|
-
.description('Bootstrap Termux: install packages, setup SSH, show connection info')
|
|
117
|
-
.action(setupTermuxCommand);
|
|
118
|
-
|
|
119
113
|
// nexuscli uninstall
|
|
120
114
|
program
|
|
121
115
|
.command('uninstall')
|
|
122
116
|
.description('Prepare for uninstallation (optional data removal)')
|
|
123
117
|
.action(uninstallCommand);
|
|
124
118
|
|
|
119
|
+
// nexuscli setup-termux
|
|
120
|
+
program
|
|
121
|
+
.command('setup-termux')
|
|
122
|
+
.description('Bootstrap Termux for remote development (SSH, packages)')
|
|
123
|
+
.action(setupTermuxCommand);
|
|
124
|
+
|
|
125
125
|
// Parse arguments
|
|
126
126
|
program.parse();
|
|
127
127
|
|
package/lib/server/.env.example
CHANGED
|
@@ -66,6 +66,8 @@ router.get('/:id/messages', async (req, res) => {
|
|
|
66
66
|
|
|
67
67
|
const { messages, pagination } = await cliLoader.loadMessagesFromCLI({
|
|
68
68
|
sessionId,
|
|
69
|
+
threadId: session.session_path, // native thread id (Codex/Gemini)
|
|
70
|
+
sessionPath: session.session_path,
|
|
69
71
|
engine: session.engine || 'claude-code',
|
|
70
72
|
workspacePath: session.workspace_path,
|
|
71
73
|
limit,
|
|
@@ -147,6 +149,8 @@ router.post('/:id/summarize', async (req, res) => {
|
|
|
147
149
|
// Load messages from CLI history
|
|
148
150
|
const { messages } = await cliLoader.loadMessagesFromCLI({
|
|
149
151
|
sessionId,
|
|
152
|
+
threadId: session.session_path,
|
|
153
|
+
sessionPath: session.session_path,
|
|
150
154
|
engine: session.engine || 'claude-code',
|
|
151
155
|
workspacePath: session.workspace_path,
|
|
152
156
|
limit,
|
|
@@ -224,7 +228,7 @@ router.delete('/:id', async (req, res) => {
|
|
|
224
228
|
|
|
225
229
|
// Delete the original .jsonl file (SYNC DELETE)
|
|
226
230
|
let fileDeleted = false;
|
|
227
|
-
const sessionFile = getSessionFilePath(sessionId, session.engine, session.workspace_path);
|
|
231
|
+
const sessionFile = getSessionFilePath(sessionId, session.engine, session.workspace_path, session.session_path);
|
|
228
232
|
if (sessionFile && fs.existsSync(sessionFile)) {
|
|
229
233
|
try {
|
|
230
234
|
fs.unlinkSync(sessionFile);
|
|
@@ -263,7 +267,7 @@ function pathToSlug(workspacePath) {
|
|
|
263
267
|
/**
|
|
264
268
|
* Helper: Get the filesystem path for a session file
|
|
265
269
|
*/
|
|
266
|
-
function getSessionFilePath(sessionId, engine, workspacePath) {
|
|
270
|
+
function getSessionFilePath(sessionId, engine, workspacePath, sessionPath) {
|
|
267
271
|
const normalizedEngine = engine?.toLowerCase().includes('claude') ? 'claude'
|
|
268
272
|
: engine?.toLowerCase().includes('codex') ? 'codex'
|
|
269
273
|
: engine?.toLowerCase().includes('gemini') ? 'gemini'
|
|
@@ -274,7 +278,12 @@ function getSessionFilePath(sessionId, engine, workspacePath) {
|
|
|
274
278
|
const slug = pathToSlug(workspacePath);
|
|
275
279
|
return path.join(SESSION_DIRS.claude, slug, `${sessionId}.jsonl`);
|
|
276
280
|
case 'codex':
|
|
277
|
-
|
|
281
|
+
// Try native threadId first, then legacy sessionId
|
|
282
|
+
const nativeId = sessionPath || sessionId;
|
|
283
|
+
const baseDir = SESSION_DIRS.codex;
|
|
284
|
+
const flatPath = path.join(baseDir, `${nativeId}.jsonl`);
|
|
285
|
+
if (fs.existsSync(flatPath)) return flatPath;
|
|
286
|
+
return findCodexSessionFile(baseDir, nativeId);
|
|
278
287
|
case 'gemini':
|
|
279
288
|
return path.join(SESSION_DIRS.gemini, `${sessionId}.jsonl`);
|
|
280
289
|
default:
|
|
@@ -282,4 +291,34 @@ function getSessionFilePath(sessionId, engine, workspacePath) {
|
|
|
282
291
|
}
|
|
283
292
|
}
|
|
284
293
|
|
|
294
|
+
function findCodexSessionFile(baseDir, threadId) {
|
|
295
|
+
if (!threadId || !fs.existsSync(baseDir)) return null;
|
|
296
|
+
try {
|
|
297
|
+
const years = fs.readdirSync(baseDir);
|
|
298
|
+
for (const year of years) {
|
|
299
|
+
const yearPath = path.join(baseDir, year);
|
|
300
|
+
if (!fs.statSync(yearPath).isDirectory()) continue;
|
|
301
|
+
const months = fs.readdirSync(yearPath);
|
|
302
|
+
for (const month of months) {
|
|
303
|
+
const monthPath = path.join(yearPath, month);
|
|
304
|
+
if (!fs.statSync(monthPath).isDirectory()) continue;
|
|
305
|
+
const days = fs.readdirSync(monthPath);
|
|
306
|
+
for (const day of days) {
|
|
307
|
+
const dayPath = path.join(monthPath, day);
|
|
308
|
+
if (!fs.statSync(dayPath).isDirectory()) continue;
|
|
309
|
+
const files = fs.readdirSync(dayPath);
|
|
310
|
+
for (const file of files) {
|
|
311
|
+
if (file.endsWith('.jsonl') && file.includes(threadId)) {
|
|
312
|
+
return path.join(dayPath, file);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
} catch (err) {
|
|
319
|
+
console.warn(`[Sessions] Failed to search Codex session file: ${err.message}`);
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
285
324
|
module.exports = router;
|
package/lib/server/server.js
CHANGED
|
@@ -28,7 +28,6 @@ const wakeLockRouter = require('./routes/wake-lock');
|
|
|
28
28
|
const uploadRouter = require('./routes/upload');
|
|
29
29
|
const keysRouter = require('./routes/keys');
|
|
30
30
|
const speechRouter = require('./routes/speech');
|
|
31
|
-
const configRouter = require('./routes/config');
|
|
32
31
|
|
|
33
32
|
const app = express();
|
|
34
33
|
const PORT = process.env.PORT || 41800;
|
|
@@ -63,7 +62,6 @@ app.use(express.static(frontendDist));
|
|
|
63
62
|
// Public routes
|
|
64
63
|
app.use('/api/v1/auth', authRouter);
|
|
65
64
|
app.use('/api/v1/models', modelsRouter);
|
|
66
|
-
app.use('/api/v1/config', configRouter);
|
|
67
65
|
app.use('/api/v1/workspace', workspaceRouter);
|
|
68
66
|
app.use('/api/v1', wakeLockRouter); // Wake lock endpoints (public for app visibility handling)
|
|
69
67
|
app.use('/api/v1/workspaces', authMiddleware, workspacesRouter);
|
|
@@ -47,6 +47,8 @@ class CliLoader {
|
|
|
47
47
|
*/
|
|
48
48
|
async loadMessagesFromCLI({
|
|
49
49
|
sessionId,
|
|
50
|
+
threadId, // optional native thread id (e.g., Codex exec thread)
|
|
51
|
+
sessionPath, // alias for compatibility
|
|
50
52
|
engine = 'claude',
|
|
51
53
|
workspacePath,
|
|
52
54
|
limit = DEFAULT_LIMIT,
|
|
@@ -59,6 +61,7 @@ class CliLoader {
|
|
|
59
61
|
|
|
60
62
|
const startedAt = Date.now();
|
|
61
63
|
const normalizedEngine = this._normalizeEngine(engine);
|
|
64
|
+
const nativeId = threadId || sessionPath || sessionId;
|
|
62
65
|
|
|
63
66
|
let result;
|
|
64
67
|
switch (normalizedEngine) {
|
|
@@ -67,7 +70,7 @@ class CliLoader {
|
|
|
67
70
|
break;
|
|
68
71
|
|
|
69
72
|
case 'codex':
|
|
70
|
-
result = await this.loadCodexMessages({ sessionId, limit, before, mode });
|
|
73
|
+
result = await this.loadCodexMessages({ sessionId, nativeId, limit, before, mode });
|
|
71
74
|
break;
|
|
72
75
|
|
|
73
76
|
case 'gemini':
|
|
@@ -174,22 +177,27 @@ class CliLoader {
|
|
|
174
177
|
// CODEX - Load from ~/.codex/sessions/<sessionId>.jsonl
|
|
175
178
|
// ============================================================
|
|
176
179
|
|
|
177
|
-
async loadCodexMessages({ sessionId, limit, before, mode }) {
|
|
178
|
-
const
|
|
180
|
+
async loadCodexMessages({ sessionId, nativeId, limit, before, mode }) {
|
|
181
|
+
const baseDir = path.join(this.codexPath, 'sessions');
|
|
182
|
+
let sessionFile = path.join(baseDir, `${nativeId || sessionId}.jsonl`);
|
|
179
183
|
|
|
180
|
-
//
|
|
184
|
+
// If flat file missing, search nested rollout-* files by threadId
|
|
181
185
|
if (!fs.existsSync(sessionFile)) {
|
|
182
|
-
|
|
183
|
-
|
|
186
|
+
sessionFile = this.findCodexSessionFile(baseDir, nativeId || sessionId);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Codex exec may not persist sessions; handle gracefully
|
|
190
|
+
if (!sessionFile || !fs.existsSync(sessionFile)) {
|
|
191
|
+
console.log(`[CliLoader] Codex session file not found (id=${nativeId || sessionId})`);
|
|
184
192
|
return this._emptyResult();
|
|
185
193
|
}
|
|
186
194
|
|
|
187
195
|
const rawMessages = await this._parseJsonlFile(sessionFile);
|
|
188
196
|
|
|
189
|
-
//
|
|
197
|
+
// Normalize then filter only chat messages
|
|
190
198
|
const messages = rawMessages
|
|
191
|
-
.
|
|
192
|
-
.
|
|
199
|
+
.map(entry => this._normalizeCodexEntry(entry))
|
|
200
|
+
.filter(msg => msg && (msg.role === 'user' || msg.role === 'assistant'));
|
|
193
201
|
|
|
194
202
|
return this._paginateMessages(messages, limit, before, mode);
|
|
195
203
|
}
|
|
@@ -198,13 +206,34 @@ class CliLoader {
|
|
|
198
206
|
* Normalize Codex session entry to message shape
|
|
199
207
|
*/
|
|
200
208
|
_normalizeCodexEntry(entry) {
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
209
|
+
// Skip non-chat bookkeeping events
|
|
210
|
+
const skipTypes = ['session_meta', 'turn_context', 'event_msg', 'token_count'];
|
|
211
|
+
if (skipTypes.includes(entry.type)) return null;
|
|
212
|
+
|
|
213
|
+
const role =
|
|
214
|
+
entry.role ||
|
|
215
|
+
entry.payload?.role ||
|
|
216
|
+
(entry.payload?.type === 'message' ? entry.payload.role : null) ||
|
|
217
|
+
entry.message?.role ||
|
|
218
|
+
'assistant';
|
|
219
|
+
|
|
220
|
+
const created_at = entry.timestamp
|
|
221
|
+
? new Date(entry.timestamp).getTime()
|
|
222
|
+
: (entry.payload?.timestamp ? new Date(entry.payload.timestamp).getTime() : Date.now());
|
|
223
|
+
|
|
224
|
+
// Codex may store content in multiple shapes
|
|
205
225
|
let content = '';
|
|
206
226
|
if (typeof entry.content === 'string') {
|
|
207
227
|
content = entry.content;
|
|
228
|
+
} else if (typeof entry.payload?.content === 'string') {
|
|
229
|
+
content = entry.payload.content;
|
|
230
|
+
} else if (Array.isArray(entry.payload?.content)) {
|
|
231
|
+
content = entry.payload.content
|
|
232
|
+
.map(block => block.text || block.message || block.title || '')
|
|
233
|
+
.filter(Boolean)
|
|
234
|
+
.join('\n');
|
|
235
|
+
} else if (entry.payload?.text) {
|
|
236
|
+
content = entry.payload.text;
|
|
208
237
|
} else if (entry.message) {
|
|
209
238
|
content = typeof entry.message === 'string' ? entry.message : JSON.stringify(entry.message);
|
|
210
239
|
}
|
|
@@ -222,6 +251,39 @@ class CliLoader {
|
|
|
222
251
|
};
|
|
223
252
|
}
|
|
224
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Find Codex rollout file by threadId within YYYY/MM/DD directories
|
|
256
|
+
*/
|
|
257
|
+
findCodexSessionFile(baseDir, threadId) {
|
|
258
|
+
if (!threadId || !fs.existsSync(baseDir)) return null;
|
|
259
|
+
try {
|
|
260
|
+
const years = fs.readdirSync(baseDir);
|
|
261
|
+
for (const year of years) {
|
|
262
|
+
const yearPath = path.join(baseDir, year);
|
|
263
|
+
if (!fs.statSync(yearPath).isDirectory()) continue;
|
|
264
|
+
const months = fs.readdirSync(yearPath);
|
|
265
|
+
for (const month of months) {
|
|
266
|
+
const monthPath = path.join(yearPath, month);
|
|
267
|
+
if (!fs.statSync(monthPath).isDirectory()) continue;
|
|
268
|
+
const days = fs.readdirSync(monthPath);
|
|
269
|
+
for (const day of days) {
|
|
270
|
+
const dayPath = path.join(monthPath, day);
|
|
271
|
+
if (!fs.statSync(dayPath).isDirectory()) continue;
|
|
272
|
+
const files = fs.readdirSync(dayPath);
|
|
273
|
+
for (const file of files) {
|
|
274
|
+
if (file.endsWith('.jsonl') && file.includes(threadId)) {
|
|
275
|
+
return path.join(dayPath, file);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.warn(`[CliLoader] Failed to search Codex session file: ${err.message}`);
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
225
287
|
// ============================================================
|
|
226
288
|
// GEMINI - Load from ~/.gemini/sessions/<sessionId>.jsonl
|
|
227
289
|
// ============================================================
|
|
@@ -263,13 +263,6 @@ class CodexOutputParser {
|
|
|
263
263
|
return this.usage;
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
/**
|
|
267
|
-
* Get thread ID (native Codex session ID)
|
|
268
|
-
*/
|
|
269
|
-
getThreadId() {
|
|
270
|
-
return this.threadId;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
266
|
/**
|
|
274
267
|
* Reset parser state for new request
|
|
275
268
|
*/
|
|
@@ -277,7 +270,6 @@ class CodexOutputParser {
|
|
|
277
270
|
this.buffer = '';
|
|
278
271
|
this.finalResponse = '';
|
|
279
272
|
this.usage = null;
|
|
280
|
-
this.threadId = null;
|
|
281
273
|
this.pendingCommands.clear();
|
|
282
274
|
}
|
|
283
275
|
}
|
|
@@ -139,13 +139,11 @@ class ContextBridge {
|
|
|
139
139
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
140
140
|
const msg = messages[i];
|
|
141
141
|
|
|
142
|
-
// For code-focused engines,
|
|
143
|
-
// BUT always keep user messages for context continuity
|
|
142
|
+
// For code-focused engines, filter out non-code content
|
|
144
143
|
let content = msg.content;
|
|
145
|
-
if (config.codeOnly
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
content = codeContent || (content.length > 500 ? content.substring(0, 500) + '...' : content);
|
|
144
|
+
if (config.codeOnly) {
|
|
145
|
+
content = this.extractCodeContent(content);
|
|
146
|
+
if (!content) continue; // Skip if no code
|
|
149
147
|
}
|
|
150
148
|
|
|
151
149
|
// Truncate long messages
|
|
@@ -67,15 +67,15 @@ class GeminiWrapper {
|
|
|
67
67
|
*
|
|
68
68
|
* @param {Object} params
|
|
69
69
|
* @param {string} params.prompt - User message/prompt
|
|
70
|
-
* @param {string} params.
|
|
70
|
+
* @param {string} params.sessionId - Session UUID (for logging)
|
|
71
71
|
* @param {string} [params.model='gemini-3-pro-preview'] - Model name
|
|
72
72
|
* @param {string} [params.workspacePath] - Workspace directory
|
|
73
73
|
* @param {Function} [params.onStatus] - Callback for status events (SSE streaming)
|
|
74
|
-
* @returns {Promise<{text: string, usage: Object
|
|
74
|
+
* @returns {Promise<{text: string, usage: Object}>}
|
|
75
75
|
*/
|
|
76
76
|
async sendMessage({
|
|
77
77
|
prompt,
|
|
78
|
-
|
|
78
|
+
sessionId,
|
|
79
79
|
model = DEFAULT_MODEL,
|
|
80
80
|
workspacePath,
|
|
81
81
|
onStatus
|
|
@@ -87,23 +87,16 @@ class GeminiWrapper {
|
|
|
87
87
|
const cwd = workspacePath || this.workspaceDir;
|
|
88
88
|
|
|
89
89
|
// Build CLI arguments
|
|
90
|
-
//
|
|
90
|
+
// Note: cwd is set in pty.spawn() options, no need for --include-directories
|
|
91
91
|
const args = [
|
|
92
92
|
'-y', // YOLO mode - auto-approve all actions
|
|
93
93
|
'-m', model, // Model selection
|
|
94
94
|
'-o', 'stream-json', // JSON streaming for structured events
|
|
95
|
+
prompt // Prompt as positional argument
|
|
95
96
|
];
|
|
96
97
|
|
|
97
|
-
// Add resume flag if continuing existing session
|
|
98
|
-
if (threadId) {
|
|
99
|
-
args.push('--resume', threadId);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Add prompt as positional argument
|
|
103
|
-
args.push(prompt);
|
|
104
|
-
|
|
105
98
|
console.log(`[GeminiWrapper] Model: ${model}`);
|
|
106
|
-
console.log(`[GeminiWrapper]
|
|
99
|
+
console.log(`[GeminiWrapper] Session: ${sessionId}`);
|
|
107
100
|
console.log(`[GeminiWrapper] CWD: ${cwd}`);
|
|
108
101
|
console.log(`[GeminiWrapper] Prompt length: ${prompt.length}`);
|
|
109
102
|
|
|
@@ -177,7 +170,6 @@ class GeminiWrapper {
|
|
|
177
170
|
|
|
178
171
|
resolve({
|
|
179
172
|
text: finalResponse,
|
|
180
|
-
sessionId: parser.getSessionId(), // Native Gemini session ID for resume
|
|
181
173
|
usage: {
|
|
182
174
|
prompt_tokens: promptTokens,
|
|
183
175
|
completion_tokens: completionTokens,
|
|
@@ -396,7 +396,7 @@ class WorkspaceManager {
|
|
|
396
396
|
* @returns {string}
|
|
397
397
|
*/
|
|
398
398
|
getSessionPath(workspacePath) {
|
|
399
|
-
// Convert /
|
|
399
|
+
// Convert /home/user/myproject → -home-user-myproject
|
|
400
400
|
const projectDir = workspacePath.replace(/\//g, '-').replace(/^-/, '');
|
|
401
401
|
return path.join(this.claudePath, 'projects', projectDir);
|
|
402
402
|
}
|
|
@@ -45,7 +45,7 @@ describe('Performance Benchmarks', () => {
|
|
|
45
45
|
|
|
46
46
|
test('Workspace validation should be fast', async () => {
|
|
47
47
|
const manager = new WorkspaceManager();
|
|
48
|
-
const testPath = '/
|
|
48
|
+
const testPath = '/home/user/myproject';
|
|
49
49
|
|
|
50
50
|
const start = Date.now();
|
|
51
51
|
const validated = await manager.validateWorkspace(testPath);
|
|
@@ -16,7 +16,7 @@ describe('WorkspaceManager', () => {
|
|
|
16
16
|
|
|
17
17
|
test('should validate workspace path', async () => {
|
|
18
18
|
// Test with allowed path
|
|
19
|
-
const validPath = '/
|
|
19
|
+
const validPath = '/home/user/myproject';
|
|
20
20
|
const result = await manager.validateWorkspace(validPath);
|
|
21
21
|
expect(result).toBe(validPath);
|
|
22
22
|
});
|
|
@@ -147,7 +147,7 @@ describe('SummaryGenerator', () => {
|
|
|
147
147
|
describe('Integration - Service Interactions', () => {
|
|
148
148
|
test('WorkspaceManager should use consistent path resolution', async () => {
|
|
149
149
|
const manager = new WorkspaceManager();
|
|
150
|
-
const testPath = '/
|
|
150
|
+
const testPath = '/home/user/myproject';
|
|
151
151
|
const validated = await manager.validateWorkspace(testPath);
|
|
152
152
|
expect(validated).toBe(testPath);
|
|
153
153
|
});
|
package/package.json
CHANGED
package/lib/server/db.js.old
DELETED
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
const initSqlJs = require('sql.js');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
|
|
5
|
-
// Detect environment (Linux vs Termux)
|
|
6
|
-
const isTermux = process.env.PREFIX?.includes('com.termux');
|
|
7
|
-
|
|
8
|
-
// Database directory
|
|
9
|
-
const dbDir = isTermux
|
|
10
|
-
? path.join(process.env.HOME, '.nexuscli')
|
|
11
|
-
: process.env.NEXUSCLI_DB_DIR || '/var/lib/nexuscli';
|
|
12
|
-
|
|
13
|
-
// Database file path
|
|
14
|
-
const dbPath = path.join(dbDir, 'nexuscli.db');
|
|
15
|
-
|
|
16
|
-
// Ensure directory exists
|
|
17
|
-
if (!fs.existsSync(dbDir)) {
|
|
18
|
-
fs.mkdirSync(dbDir, { recursive: true });
|
|
19
|
-
console.log(`✅ Created database directory: ${dbDir}`);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
let db = null;
|
|
23
|
-
|
|
24
|
-
// Initialize database
|
|
25
|
-
async function initDb() {
|
|
26
|
-
const SQL = await initSqlJs();
|
|
27
|
-
|
|
28
|
-
// Load existing database or create new
|
|
29
|
-
if (fs.existsSync(dbPath)) {
|
|
30
|
-
const buffer = fs.readFileSync(dbPath);
|
|
31
|
-
db = new SQL.Database(buffer);
|
|
32
|
-
console.log(`✅ Database loaded: ${dbPath}`);
|
|
33
|
-
} else {
|
|
34
|
-
db = new SQL.Database();
|
|
35
|
-
console.log(`✅ Database created: ${dbPath}`);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Initialize schema
|
|
39
|
-
initSchema();
|
|
40
|
-
|
|
41
|
-
// Auto-save every 5 seconds
|
|
42
|
-
setInterval(saveDb, 5000);
|
|
43
|
-
|
|
44
|
-
return db;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Save database to file
|
|
48
|
-
function saveDb() {
|
|
49
|
-
if (!db) return;
|
|
50
|
-
const data = db.export();
|
|
51
|
-
const buffer = Buffer.from(data);
|
|
52
|
-
fs.writeFileSync(dbPath, buffer);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Initialize schema
|
|
56
|
-
function initSchema() {
|
|
57
|
-
db.run(`
|
|
58
|
-
-- Conversations table
|
|
59
|
-
CREATE TABLE IF NOT EXISTS conversations (
|
|
60
|
-
id TEXT PRIMARY KEY,
|
|
61
|
-
title TEXT NOT NULL,
|
|
62
|
-
created_at INTEGER NOT NULL,
|
|
63
|
-
updated_at INTEGER NOT NULL,
|
|
64
|
-
metadata TEXT
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
CREATE INDEX IF NOT EXISTS idx_conversations_updated_at
|
|
68
|
-
ON conversations(updated_at DESC);
|
|
69
|
-
|
|
70
|
-
-- Messages table
|
|
71
|
-
CREATE TABLE IF NOT EXISTS messages (
|
|
72
|
-
id TEXT PRIMARY KEY,
|
|
73
|
-
conversation_id TEXT NOT NULL,
|
|
74
|
-
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
|
|
75
|
-
content TEXT NOT NULL,
|
|
76
|
-
created_at INTEGER NOT NULL,
|
|
77
|
-
metadata TEXT,
|
|
78
|
-
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id
|
|
82
|
-
ON messages(conversation_id);
|
|
83
|
-
|
|
84
|
-
CREATE INDEX IF NOT EXISTS idx_messages_created_at
|
|
85
|
-
ON messages(created_at ASC);
|
|
86
|
-
|
|
87
|
-
-- Jobs table
|
|
88
|
-
CREATE TABLE IF NOT EXISTS jobs (
|
|
89
|
-
id TEXT PRIMARY KEY,
|
|
90
|
-
conversation_id TEXT,
|
|
91
|
-
message_id TEXT,
|
|
92
|
-
node_id TEXT NOT NULL,
|
|
93
|
-
tool TEXT NOT NULL,
|
|
94
|
-
command TEXT NOT NULL,
|
|
95
|
-
status TEXT NOT NULL CHECK(status IN ('queued', 'executing', 'completed', 'failed', 'cancelled')),
|
|
96
|
-
exit_code INTEGER,
|
|
97
|
-
stdout TEXT,
|
|
98
|
-
stderr TEXT,
|
|
99
|
-
duration INTEGER,
|
|
100
|
-
created_at INTEGER NOT NULL,
|
|
101
|
-
started_at INTEGER,
|
|
102
|
-
completed_at INTEGER,
|
|
103
|
-
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL,
|
|
104
|
-
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
CREATE INDEX IF NOT EXISTS idx_jobs_conversation_id
|
|
108
|
-
ON jobs(conversation_id);
|
|
109
|
-
|
|
110
|
-
CREATE INDEX IF NOT EXISTS idx_jobs_status
|
|
111
|
-
ON jobs(status);
|
|
112
|
-
|
|
113
|
-
CREATE INDEX IF NOT EXISTS idx_jobs_created_at
|
|
114
|
-
ON jobs(created_at DESC);
|
|
115
|
-
|
|
116
|
-
-- Users table
|
|
117
|
-
CREATE TABLE IF NOT EXISTS users (
|
|
118
|
-
id TEXT PRIMARY KEY,
|
|
119
|
-
username TEXT UNIQUE NOT NULL,
|
|
120
|
-
password_hash TEXT NOT NULL,
|
|
121
|
-
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin', 'user')),
|
|
122
|
-
is_locked INTEGER NOT NULL DEFAULT 0,
|
|
123
|
-
failed_attempts INTEGER NOT NULL DEFAULT 0,
|
|
124
|
-
last_failed_attempt INTEGER,
|
|
125
|
-
locked_until INTEGER,
|
|
126
|
-
created_at INTEGER NOT NULL,
|
|
127
|
-
last_login INTEGER
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
CREATE INDEX IF NOT EXISTS idx_users_username
|
|
131
|
-
ON users(username);
|
|
132
|
-
|
|
133
|
-
-- Login attempts table (for rate limiting)
|
|
134
|
-
CREATE TABLE IF NOT EXISTS login_attempts (
|
|
135
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
136
|
-
ip_address TEXT NOT NULL,
|
|
137
|
-
username TEXT,
|
|
138
|
-
success INTEGER NOT NULL DEFAULT 0,
|
|
139
|
-
timestamp INTEGER NOT NULL
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip
|
|
143
|
-
ON login_attempts(ip_address, timestamp DESC);
|
|
144
|
-
|
|
145
|
-
CREATE INDEX IF NOT EXISTS idx_login_attempts_timestamp
|
|
146
|
-
ON login_attempts(timestamp DESC);
|
|
147
|
-
|
|
148
|
-
-- Nodes table (optional - for multi-node setups)
|
|
149
|
-
CREATE TABLE IF NOT EXISTS nodes (
|
|
150
|
-
id TEXT PRIMARY KEY,
|
|
151
|
-
hostname TEXT NOT NULL,
|
|
152
|
-
ip_address TEXT,
|
|
153
|
-
status TEXT NOT NULL CHECK(status IN ('online', 'offline', 'error')),
|
|
154
|
-
capabilities TEXT,
|
|
155
|
-
last_heartbeat INTEGER,
|
|
156
|
-
created_at INTEGER NOT NULL
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
CREATE INDEX IF NOT EXISTS idx_nodes_status
|
|
160
|
-
ON nodes(status);
|
|
161
|
-
`);
|
|
162
|
-
|
|
163
|
-
console.log('✅ Database schema initialized');
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Prepare statement (sql.js uses db.prepare)
|
|
167
|
-
function prepare(sql) {
|
|
168
|
-
const stmt = db.prepare(sql);
|
|
169
|
-
return {
|
|
170
|
-
run: (...params) => {
|
|
171
|
-
stmt.bind(params);
|
|
172
|
-
stmt.step();
|
|
173
|
-
stmt.reset();
|
|
174
|
-
saveDb(); // Auto-save on write
|
|
175
|
-
},
|
|
176
|
-
get: (...params) => {
|
|
177
|
-
stmt.bind(params);
|
|
178
|
-
const result = stmt.step() ? stmt.getAsObject() : null;
|
|
179
|
-
stmt.reset();
|
|
180
|
-
return result;
|
|
181
|
-
},
|
|
182
|
-
all: (...params) => {
|
|
183
|
-
stmt.bind(params);
|
|
184
|
-
const results = [];
|
|
185
|
-
while (stmt.step()) {
|
|
186
|
-
results.push(stmt.getAsObject());
|
|
187
|
-
}
|
|
188
|
-
stmt.reset();
|
|
189
|
-
return results;
|
|
190
|
-
}
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Graceful shutdown
|
|
195
|
-
process.on('exit', () => {
|
|
196
|
-
if (db) {
|
|
197
|
-
saveDb();
|
|
198
|
-
db.close();
|
|
199
|
-
console.log('✅ Database connection closed');
|
|
200
|
-
}
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
process.on('SIGINT', () => {
|
|
204
|
-
if (db) {
|
|
205
|
-
saveDb();
|
|
206
|
-
db.close();
|
|
207
|
-
console.log('✅ Database connection closed (SIGINT)');
|
|
208
|
-
}
|
|
209
|
-
process.exit(0);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
process.on('SIGTERM', () => {
|
|
213
|
-
if (db) {
|
|
214
|
-
saveDb();
|
|
215
|
-
db.close();
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
// Export db object (initialized async)
|
|
220
|
-
module.exports = {
|
|
221
|
-
initDb,
|
|
222
|
-
getDb: () => db,
|
|
223
|
-
prepare,
|
|
224
|
-
saveDb
|
|
225
|
-
};
|