knoxis-helper 1.0.0
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/bin/knoxis-helper.js +191 -0
- package/lib/knoxis-local-agent.js +1137 -0
- package/lib/knoxis-pair-program.js +513 -0
- package/package.json +17 -0
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Knoxis Local Agent - Zero Dependencies Version
|
|
5
|
+
*
|
|
6
|
+
* Runs on the user's local machine to handle terminal operations
|
|
7
|
+
* when the backend is deployed in Azure.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node knoxis-local-agent.js
|
|
11
|
+
* curl -L https://github.com/USER/REPO/releases/latest/download/knoxis-helper.js | node - pair
|
|
12
|
+
*
|
|
13
|
+
* The web frontend connects to this agent at http://localhost:3456
|
|
14
|
+
* to request terminal operations on the user's local machine.
|
|
15
|
+
*
|
|
16
|
+
* ZERO EXTERNAL DEPENDENCIES - uses only Node.js built-in modules
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const http = require('http');
|
|
20
|
+
const https = require('https');
|
|
21
|
+
const { exec, spawn, spawnSync } = require('child_process');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const url = require('url');
|
|
26
|
+
|
|
27
|
+
const DEFAULT_PORT = parseInt(process.env.KNOXIS_AGENT_PORT || '3456', 10);
|
|
28
|
+
const CERT_DIR = process.env.KNOXIS_CERT_DIR || path.join(os.homedir(), '.knoxis', 'certs');
|
|
29
|
+
const CERT_FILE = process.env.KNOXIS_CERT_FILE || path.join(CERT_DIR, 'localhost.pem');
|
|
30
|
+
const KEY_FILE = process.env.KNOXIS_CERT_KEY || path.join(CERT_DIR, 'localhost-key.pem');
|
|
31
|
+
|
|
32
|
+
// Trusted origins for CORS (deployed frontends)
|
|
33
|
+
const TRUSTED_ORIGINS = [
|
|
34
|
+
'https://qig.ai',
|
|
35
|
+
'https://www.qig.ai',
|
|
36
|
+
'https://app.qig.ai',
|
|
37
|
+
'http://localhost:3000',
|
|
38
|
+
'http://localhost:5173',
|
|
39
|
+
'http://127.0.0.1:3000',
|
|
40
|
+
'http://127.0.0.1:5173'
|
|
41
|
+
];
|
|
42
|
+
const ALLOWED_ORIGINS = (process.env.KNOXIS_ALLOWED_ORIGINS || '')
|
|
43
|
+
.split(',')
|
|
44
|
+
.map(origin => origin.trim())
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
// Merge custom origins with trusted ones
|
|
47
|
+
const ALL_ALLOWED_ORIGINS = [...new Set([...TRUSTED_ORIGINS, ...ALLOWED_ORIGINS])];
|
|
48
|
+
|
|
49
|
+
const serverMeta = { secure: false, port: DEFAULT_PORT };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Generate self-signed certificate for HTTPS using OpenSSL (pre-installed on macOS/Linux)
|
|
53
|
+
*/
|
|
54
|
+
function generateSelfSignedCert() {
|
|
55
|
+
console.log('š Generating self-signed certificate...');
|
|
56
|
+
|
|
57
|
+
if (!fs.existsSync(CERT_DIR)) {
|
|
58
|
+
fs.mkdirSync(CERT_DIR, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const keyPath = path.join(CERT_DIR, 'localhost-key.pem');
|
|
62
|
+
const certPath = path.join(CERT_DIR, 'localhost.pem');
|
|
63
|
+
|
|
64
|
+
// Generate key + cert in one command
|
|
65
|
+
const result = spawnSync('openssl', [
|
|
66
|
+
'req', '-x509', '-newkey', 'rsa:2048',
|
|
67
|
+
'-keyout', keyPath,
|
|
68
|
+
'-out', certPath,
|
|
69
|
+
'-days', '365',
|
|
70
|
+
'-nodes',
|
|
71
|
+
'-subj', '/CN=localhost',
|
|
72
|
+
'-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1'
|
|
73
|
+
], { stdio: 'pipe' });
|
|
74
|
+
|
|
75
|
+
if (result.status === 0) {
|
|
76
|
+
console.log('ā
Certificate generated');
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.warn('ā ļø OpenSSL failed - running in HTTP mode');
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get CORS headers for the given request origin
|
|
86
|
+
*/
|
|
87
|
+
function getCorsHeaders(requestOrigin) {
|
|
88
|
+
const headers = {
|
|
89
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS, PATCH',
|
|
90
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, Accept, Origin',
|
|
91
|
+
'Access-Control-Max-Age': '86400'
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Check if origin is allowed
|
|
95
|
+
if (requestOrigin) {
|
|
96
|
+
const isAllowed = ALL_ALLOWED_ORIGINS.some(allowed => {
|
|
97
|
+
if (allowed === '*') return true;
|
|
98
|
+
return requestOrigin === allowed || requestOrigin.endsWith(allowed.replace('https://', '.'));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (isAllowed) {
|
|
102
|
+
// Use specific origin (required when credentials are used)
|
|
103
|
+
headers['Access-Control-Allow-Origin'] = requestOrigin;
|
|
104
|
+
headers['Access-Control-Allow-Credentials'] = 'true';
|
|
105
|
+
} else {
|
|
106
|
+
// For unknown origins, allow without credentials
|
|
107
|
+
headers['Access-Control-Allow-Origin'] = '*';
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
headers['Access-Control-Allow-Origin'] = '*';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return headers;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function escapeForDoubleQuotedShellArg(value) {
|
|
117
|
+
return String(value || '')
|
|
118
|
+
.replace(/\\/g, '\\\\')
|
|
119
|
+
.replace(/"/g, '\\"')
|
|
120
|
+
.replace(/\$/g, '\\$')
|
|
121
|
+
.replace(/`/g, '\\`')
|
|
122
|
+
.replace(/!/g, '\\!');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const DEFAULT_HEADLESS_TIMEOUT_MS = parseInt(process.env.KNOXIS_HEADLESS_TIMEOUT_MS || '1200000', 10); // 20 min
|
|
126
|
+
const DEFAULT_HEADLESS_MAX_OUTPUT_CHARS = parseInt(process.env.KNOXIS_HEADLESS_MAX_OUTPUT_CHARS || '50000', 10);
|
|
127
|
+
|
|
128
|
+
function buildShellCommand(command) {
|
|
129
|
+
if (os.platform() === 'win32') {
|
|
130
|
+
return { cmd: 'cmd.exe', args: ['/c', command] };
|
|
131
|
+
}
|
|
132
|
+
return { cmd: 'bash', args: ['-lc', command] };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function commandExists(cmd) {
|
|
136
|
+
const detector = os.platform() === 'win32' ? 'where' : 'which';
|
|
137
|
+
const result = spawnSync(detector, [cmd], { stdio: 'ignore' });
|
|
138
|
+
return result.status === 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function safeBasename(value) {
|
|
142
|
+
return String(value || '').replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 80) || 'session';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function ensureDir(dirPath) {
|
|
146
|
+
if (!fs.existsSync(dirPath)) {
|
|
147
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function runHeadlessProcess({ workspace, command, prompt, sessionLabel }) {
|
|
152
|
+
return new Promise((resolve) => {
|
|
153
|
+
const startedAt = Date.now();
|
|
154
|
+
const workspaceDir = workspace || process.cwd();
|
|
155
|
+
|
|
156
|
+
const logDir = path.join(workspaceDir, '.knoxis', 'headless');
|
|
157
|
+
ensureDir(logDir);
|
|
158
|
+
const logFile = path.join(logDir, `${Date.now()}-${safeBasename(sessionLabel)}.log`);
|
|
159
|
+
const logStream = fs.createWriteStream(logFile, { encoding: 'utf8' });
|
|
160
|
+
|
|
161
|
+
const trimmedCommand = String(command || '').trim();
|
|
162
|
+
const hasPrompt = typeof prompt === 'string' && prompt.trim().length > 0;
|
|
163
|
+
const lowerCommand = trimmedCommand.toLowerCase();
|
|
164
|
+
const looksLikePairProgram = lowerCommand.includes('knoxis-pair-program');
|
|
165
|
+
|
|
166
|
+
let proc;
|
|
167
|
+
let stdout = '';
|
|
168
|
+
let stderr = '';
|
|
169
|
+
let truncated = false;
|
|
170
|
+
let timedOut = false;
|
|
171
|
+
|
|
172
|
+
const capture = (chunk, isStdErr) => {
|
|
173
|
+
const text = chunk.toString();
|
|
174
|
+
logStream.write(text);
|
|
175
|
+
if (isStdErr) {
|
|
176
|
+
stderr += text;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (stdout.length < DEFAULT_HEADLESS_MAX_OUTPUT_CHARS) {
|
|
180
|
+
stdout += text;
|
|
181
|
+
} else {
|
|
182
|
+
truncated = true;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const finish = (exitCode) => {
|
|
187
|
+
try { logStream.end(); } catch (e) {}
|
|
188
|
+
resolve({
|
|
189
|
+
success: exitCode === 0 && !timedOut,
|
|
190
|
+
exitCode,
|
|
191
|
+
timedOut,
|
|
192
|
+
truncated,
|
|
193
|
+
durationMs: Date.now() - startedAt,
|
|
194
|
+
workspace: workspaceDir,
|
|
195
|
+
logFile,
|
|
196
|
+
stdout: stdout.trim(),
|
|
197
|
+
stderr: stderr.trim(),
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const timeout = setTimeout(() => {
|
|
202
|
+
timedOut = true;
|
|
203
|
+
try { proc.kill('SIGKILL'); } catch (e) {}
|
|
204
|
+
}, DEFAULT_HEADLESS_TIMEOUT_MS);
|
|
205
|
+
|
|
206
|
+
const spawnShell = (shellCommand) => {
|
|
207
|
+
const spec = buildShellCommand(shellCommand);
|
|
208
|
+
proc = spawn(spec.cmd, spec.args, { cwd: workspaceDir, env: process.env });
|
|
209
|
+
proc.stdout.on('data', chunk => capture(chunk, false));
|
|
210
|
+
proc.stderr.on('data', chunk => capture(chunk, true));
|
|
211
|
+
proc.on('close', code => {
|
|
212
|
+
clearTimeout(timeout);
|
|
213
|
+
finish(code == null ? 1 : code);
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
if (!looksLikePairProgram && hasPrompt && (!trimmedCommand || lowerCommand.startsWith('claude'))) {
|
|
218
|
+
if (!commandExists('claude')) {
|
|
219
|
+
clearTimeout(timeout);
|
|
220
|
+
finish(127);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
proc = spawn('claude', ['--dangerously-skip-permissions'], { cwd: workspaceDir, env: process.env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
225
|
+
proc.stdout.on('data', chunk => capture(chunk, false));
|
|
226
|
+
proc.stderr.on('data', chunk => capture(chunk, true));
|
|
227
|
+
proc.on('close', code => {
|
|
228
|
+
clearTimeout(timeout);
|
|
229
|
+
finish(code == null ? 1 : code);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
proc.stdin.write(prompt);
|
|
233
|
+
proc.stdin.end();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (trimmedCommand) {
|
|
238
|
+
spawnShell(trimmedCommand);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Fallback: no command, no prompt
|
|
243
|
+
clearTimeout(timeout);
|
|
244
|
+
finish(1);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ===== WORKSPACE MANAGEMENT (self-contained) =====
|
|
249
|
+
const KNOXIS_DIR = path.join(os.homedir(), '.knoxis');
|
|
250
|
+
const WORKSPACES_FILE = path.join(KNOXIS_DIR, 'workspaces.json');
|
|
251
|
+
const RECENT_FILE = path.join(KNOXIS_DIR, 'recent.json');
|
|
252
|
+
|
|
253
|
+
function ensureKnoxisDir() {
|
|
254
|
+
if (!fs.existsSync(KNOXIS_DIR)) {
|
|
255
|
+
fs.mkdirSync(KNOXIS_DIR, { recursive: true });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function loadWorkspaces() {
|
|
260
|
+
ensureKnoxisDir();
|
|
261
|
+
try {
|
|
262
|
+
if (fs.existsSync(WORKSPACES_FILE)) {
|
|
263
|
+
return JSON.parse(fs.readFileSync(WORKSPACES_FILE, 'utf8'));
|
|
264
|
+
}
|
|
265
|
+
} catch (e) {
|
|
266
|
+
console.warn('ā ļø Failed to load workspaces:', e.message);
|
|
267
|
+
}
|
|
268
|
+
return {};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function saveWorkspaces(workspaces) {
|
|
272
|
+
ensureKnoxisDir();
|
|
273
|
+
fs.writeFileSync(WORKSPACES_FILE, JSON.stringify(workspaces, null, 2));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function resolveWorkspacePath(nameOrPath) {
|
|
277
|
+
// Direct path
|
|
278
|
+
if (fs.existsSync(nameOrPath)) {
|
|
279
|
+
return path.resolve(nameOrPath);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Try workspace registry
|
|
283
|
+
const workspaces = loadWorkspaces();
|
|
284
|
+
if (workspaces[nameOrPath]) {
|
|
285
|
+
return workspaces[nameOrPath];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Fuzzy match
|
|
289
|
+
const lower = nameOrPath.toLowerCase();
|
|
290
|
+
for (const [name, wsPath] of Object.entries(workspaces)) {
|
|
291
|
+
if (name.toLowerCase().includes(lower)) {
|
|
292
|
+
return wsPath;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function discoverProjects() {
|
|
300
|
+
const discovered = {};
|
|
301
|
+
const searchDirs = [
|
|
302
|
+
path.join(os.homedir(), 'Projects'),
|
|
303
|
+
path.join(os.homedir(), 'IdeaProjects'),
|
|
304
|
+
path.join(os.homedir(), 'Developer'),
|
|
305
|
+
path.join(os.homedir(), 'Code'),
|
|
306
|
+
path.join(os.homedir(), 'dev'),
|
|
307
|
+
path.join(os.homedir(), 'src'),
|
|
308
|
+
path.join(os.homedir(), 'work'),
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
for (const searchDir of searchDirs) {
|
|
312
|
+
if (fs.existsSync(searchDir)) {
|
|
313
|
+
try {
|
|
314
|
+
const entries = fs.readdirSync(searchDir, { withFileTypes: true });
|
|
315
|
+
for (const entry of entries) {
|
|
316
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
317
|
+
const projectPath = path.join(searchDir, entry.name);
|
|
318
|
+
// Check if it looks like a project
|
|
319
|
+
const hasGit = fs.existsSync(path.join(projectPath, '.git'));
|
|
320
|
+
const hasPackage = fs.existsSync(path.join(projectPath, 'package.json'));
|
|
321
|
+
const hasPom = fs.existsSync(path.join(projectPath, 'pom.xml'));
|
|
322
|
+
const hasSrc = fs.existsSync(path.join(projectPath, 'src'));
|
|
323
|
+
|
|
324
|
+
if (hasGit || hasPackage || hasPom || hasSrc) {
|
|
325
|
+
discovered[entry.name] = projectPath;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} catch (e) {
|
|
330
|
+
// Skip inaccessible directories
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return discovered;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ===== FILE FINDER (self-contained) =====
|
|
339
|
+
function findFilesRecursive(dir, patterns, results = [], maxDepth = 5, currentDepth = 0) {
|
|
340
|
+
if (currentDepth > maxDepth || results.length >= 20) return results;
|
|
341
|
+
|
|
342
|
+
const skipDirs = ['node_modules', '.git', 'target', 'build', 'dist', '__pycache__', 'venv', '.idea'];
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
346
|
+
|
|
347
|
+
for (const entry of entries) {
|
|
348
|
+
if (results.length >= 20) break;
|
|
349
|
+
|
|
350
|
+
const fullPath = path.join(dir, entry.name);
|
|
351
|
+
|
|
352
|
+
if (entry.isDirectory()) {
|
|
353
|
+
if (!skipDirs.includes(entry.name) && !entry.name.startsWith('.')) {
|
|
354
|
+
findFilesRecursive(fullPath, patterns, results, maxDepth, currentDepth + 1);
|
|
355
|
+
}
|
|
356
|
+
} else if (entry.isFile()) {
|
|
357
|
+
const nameLower = entry.name.toLowerCase();
|
|
358
|
+
const matches = patterns.some(p => nameLower.includes(p.toLowerCase()));
|
|
359
|
+
if (matches) {
|
|
360
|
+
results.push(fullPath);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
} catch (e) {
|
|
365
|
+
// Skip inaccessible directories
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return results;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function extractSearchPatterns(query) {
|
|
372
|
+
const patterns = [];
|
|
373
|
+
const lower = query.toLowerCase();
|
|
374
|
+
|
|
375
|
+
// Map common terms to file patterns
|
|
376
|
+
const termMappings = {
|
|
377
|
+
'controller': ['Controller', 'controller'],
|
|
378
|
+
'service': ['Service', 'service'],
|
|
379
|
+
'model': ['Model', 'Entity', 'model', 'entity'],
|
|
380
|
+
'repo': ['Repository', 'Repo', 'repository'],
|
|
381
|
+
'config': ['Config', 'config', '.yml', '.yaml', '.properties'],
|
|
382
|
+
'test': ['Test', 'Spec', 'test', 'spec'],
|
|
383
|
+
'component': ['Component', 'component', '.tsx', '.jsx'],
|
|
384
|
+
'hook': ['use', 'Hook'],
|
|
385
|
+
'api': ['Api', 'Endpoint', 'Route', 'api'],
|
|
386
|
+
'voice': ['Voice', 'voice', 'Audio', 'audio'],
|
|
387
|
+
'intent': ['Intent', 'intent'],
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
for (const [term, mappings] of Object.entries(termMappings)) {
|
|
391
|
+
if (lower.includes(term)) {
|
|
392
|
+
patterns.push(...mappings);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Add individual words from query
|
|
397
|
+
const words = query.split(/\s+/).filter(w => w.length >= 3);
|
|
398
|
+
patterns.push(...words);
|
|
399
|
+
|
|
400
|
+
return [...new Set(patterns)];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Helper to parse JSON body
|
|
404
|
+
function parseBody(req) {
|
|
405
|
+
return new Promise((resolve, reject) => {
|
|
406
|
+
let body = '';
|
|
407
|
+
req.on('data', chunk => { body += chunk.toString(); });
|
|
408
|
+
req.on('end', () => {
|
|
409
|
+
try {
|
|
410
|
+
resolve(body ? JSON.parse(body) : {});
|
|
411
|
+
} catch (err) {
|
|
412
|
+
reject(err);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
req.on('error', reject);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Helper to send JSON response
|
|
420
|
+
function sendJSON(res, statusCode, data, requestOrigin) {
|
|
421
|
+
const corsHeaders = getCorsHeaders(requestOrigin);
|
|
422
|
+
res.writeHead(statusCode, {
|
|
423
|
+
'Content-Type': 'application/json',
|
|
424
|
+
...corsHeaders
|
|
425
|
+
});
|
|
426
|
+
res.end(JSON.stringify(data));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Request handler
|
|
430
|
+
async function handleRequest(req, res) {
|
|
431
|
+
const parsedUrl = url.parse(req.url, true);
|
|
432
|
+
const pathname = parsedUrl.pathname;
|
|
433
|
+
const method = req.method;
|
|
434
|
+
const requestOrigin = req.headers.origin || req.headers.referer?.replace(/\/$/, '') || '';
|
|
435
|
+
|
|
436
|
+
// Handle CORS preflight
|
|
437
|
+
if (method === 'OPTIONS') {
|
|
438
|
+
const corsHeaders = getCorsHeaders(requestOrigin);
|
|
439
|
+
res.writeHead(204, corsHeaders);
|
|
440
|
+
return res.end();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Health check
|
|
444
|
+
if (pathname === '/health' && method === 'GET') {
|
|
445
|
+
return sendJSON(res, 200, {
|
|
446
|
+
status: 'healthy',
|
|
447
|
+
platform: os.platform(),
|
|
448
|
+
agent: 'knoxis-local-agent',
|
|
449
|
+
version: '2.1.0-https',
|
|
450
|
+
secure: serverMeta.secure,
|
|
451
|
+
port: serverMeta.port,
|
|
452
|
+
dependencies: 'none',
|
|
453
|
+
allowedOrigins: ALL_ALLOWED_ORIGINS
|
|
454
|
+
}, requestOrigin);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Execute terminal command
|
|
458
|
+
if (pathname === '/terminal/execute' && method === 'POST') {
|
|
459
|
+
try {
|
|
460
|
+
const body = await parseBody(req);
|
|
461
|
+
const { workspaceDirectory, workingDirectory, command, prompt, headless, sessionId } = body;
|
|
462
|
+
const workspace = workspaceDirectory || workingDirectory;
|
|
463
|
+
|
|
464
|
+
console.log('š Workspace:', workspace);
|
|
465
|
+
console.log('š Command:', command);
|
|
466
|
+
if (prompt) console.log('š¬ Prompt length:', prompt.length);
|
|
467
|
+
if (headless) console.log('š«„ Headless:', true);
|
|
468
|
+
|
|
469
|
+
const platform = os.platform();
|
|
470
|
+
|
|
471
|
+
if (headless) {
|
|
472
|
+
const result = await runHeadlessProcess({
|
|
473
|
+
workspace,
|
|
474
|
+
command,
|
|
475
|
+
prompt,
|
|
476
|
+
sessionLabel: sessionId || 'pair'
|
|
477
|
+
});
|
|
478
|
+
return sendJSON(res, result.success ? 200 : 500, result, requestOrigin);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (platform === 'darwin') {
|
|
482
|
+
await openMacTerminal(workspace, command);
|
|
483
|
+
return sendJSON(res, 200, { success: true, message: 'Terminal opened on macOS', platform: 'darwin' }, requestOrigin);
|
|
484
|
+
}
|
|
485
|
+
if (platform === 'win32') {
|
|
486
|
+
await openWindowsTerminal(workspace, command);
|
|
487
|
+
return sendJSON(res, 200, { success: true, message: 'Terminal opened on Windows', platform: 'win32' }, requestOrigin);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
await openLinuxTerminal(workspace, command);
|
|
491
|
+
return sendJSON(res, 200, { success: true, message: 'Terminal opened on Linux', platform: 'linux' }, requestOrigin);
|
|
492
|
+
|
|
493
|
+
} catch (error) {
|
|
494
|
+
console.error('ā Failed to open terminal:', error);
|
|
495
|
+
return sendJSON(res, 500, { success: false, error: error.message }, requestOrigin);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Directory check
|
|
500
|
+
if (pathname === '/directory/check' && method === 'GET') {
|
|
501
|
+
const dirPath = parsedUrl.query.path;
|
|
502
|
+
|
|
503
|
+
if (!dirPath) {
|
|
504
|
+
return sendJSON(res, 400, { success: false, error: 'Path parameter required' }, requestOrigin);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
const exists = fs.existsSync(dirPath);
|
|
509
|
+
const stats = exists ? fs.statSync(dirPath) : null;
|
|
510
|
+
|
|
511
|
+
return sendJSON(res, 200, {
|
|
512
|
+
success: true,
|
|
513
|
+
exists,
|
|
514
|
+
isDirectory: stats ? stats.isDirectory() : false,
|
|
515
|
+
path: dirPath
|
|
516
|
+
}, requestOrigin);
|
|
517
|
+
} catch (error) {
|
|
518
|
+
return sendJSON(res, 200, { success: false, exists: false, error: error.message }, requestOrigin);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ===== WORKSPACE MANAGEMENT ENDPOINTS =====
|
|
523
|
+
|
|
524
|
+
// List all workspaces
|
|
525
|
+
if (pathname === '/workspace/list' && method === 'GET') {
|
|
526
|
+
const workspaces = loadWorkspaces();
|
|
527
|
+
const list = Object.entries(workspaces).map(([name, wsPath]) => ({
|
|
528
|
+
name,
|
|
529
|
+
path: wsPath,
|
|
530
|
+
exists: fs.existsSync(wsPath)
|
|
531
|
+
}));
|
|
532
|
+
return sendJSON(res, 200, { success: true, workspaces: list }, requestOrigin);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Get workspace by name (with fuzzy matching)
|
|
536
|
+
if (pathname === '/workspace/get' && method === 'GET') {
|
|
537
|
+
const name = parsedUrl.query.name;
|
|
538
|
+
if (!name) {
|
|
539
|
+
return sendJSON(res, 400, { success: false, error: 'Name parameter required' }, requestOrigin);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const wsPath = resolveWorkspacePath(name);
|
|
543
|
+
if (wsPath) {
|
|
544
|
+
return sendJSON(res, 200, { success: true, name, path: wsPath }, requestOrigin);
|
|
545
|
+
}
|
|
546
|
+
return sendJSON(res, 404, { success: false, error: `Workspace not found: ${name}` }, requestOrigin);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Save workspace
|
|
550
|
+
if (pathname === '/workspace/save' && method === 'POST') {
|
|
551
|
+
try {
|
|
552
|
+
const body = await parseBody(req);
|
|
553
|
+
const { name, path: wsPath } = body;
|
|
554
|
+
|
|
555
|
+
if (!name) {
|
|
556
|
+
return sendJSON(res, 400, { success: false, error: 'Name required' }, requestOrigin);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const pathToSave = wsPath || process.cwd();
|
|
560
|
+
const resolved = path.resolve(pathToSave);
|
|
561
|
+
|
|
562
|
+
if (!fs.existsSync(resolved)) {
|
|
563
|
+
return sendJSON(res, 400, { success: false, error: `Path does not exist: ${resolved}` }, requestOrigin);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const workspaces = loadWorkspaces();
|
|
567
|
+
workspaces[name] = resolved;
|
|
568
|
+
saveWorkspaces(workspaces);
|
|
569
|
+
|
|
570
|
+
return sendJSON(res, 200, { success: true, name, path: resolved }, requestOrigin);
|
|
571
|
+
} catch (error) {
|
|
572
|
+
return sendJSON(res, 500, { success: false, error: error.message }, requestOrigin);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Remove workspace
|
|
577
|
+
if (pathname === '/workspace/remove' && method === 'DELETE') {
|
|
578
|
+
const name = parsedUrl.query.name;
|
|
579
|
+
if (!name) {
|
|
580
|
+
return sendJSON(res, 400, { success: false, error: 'Name parameter required' }, requestOrigin);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const workspaces = loadWorkspaces();
|
|
584
|
+
if (workspaces[name]) {
|
|
585
|
+
delete workspaces[name];
|
|
586
|
+
saveWorkspaces(workspaces);
|
|
587
|
+
return sendJSON(res, 200, { success: true, message: `Removed workspace: ${name}` }, requestOrigin);
|
|
588
|
+
}
|
|
589
|
+
return sendJSON(res, 404, { success: false, error: `Workspace not found: ${name}` }, requestOrigin);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Discover projects automatically
|
|
593
|
+
if (pathname === '/workspace/discover' && method === 'POST') {
|
|
594
|
+
const discovered = discoverProjects();
|
|
595
|
+
const workspaces = loadWorkspaces();
|
|
596
|
+
let added = 0;
|
|
597
|
+
|
|
598
|
+
for (const [name, wsPath] of Object.entries(discovered)) {
|
|
599
|
+
if (!workspaces[name]) {
|
|
600
|
+
workspaces[name] = wsPath;
|
|
601
|
+
added++;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
saveWorkspaces(workspaces);
|
|
606
|
+
|
|
607
|
+
return sendJSON(res, 200, {
|
|
608
|
+
success: true,
|
|
609
|
+
discovered: Object.keys(discovered).length,
|
|
610
|
+
added,
|
|
611
|
+
workspaces: Object.entries(workspaces).map(([name, wsPath]) => ({ name, path: wsPath }))
|
|
612
|
+
}, requestOrigin);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ===== FILE FINDER ENDPOINTS =====
|
|
616
|
+
|
|
617
|
+
// Find files in workspace
|
|
618
|
+
if (pathname === '/files/find' && method === 'GET') {
|
|
619
|
+
const query = parsedUrl.query.query || parsedUrl.query.q;
|
|
620
|
+
const workspace = parsedUrl.query.workspace || parsedUrl.query.w;
|
|
621
|
+
const maxResults = parseInt(parsedUrl.query.max || '10');
|
|
622
|
+
|
|
623
|
+
if (!query) {
|
|
624
|
+
return sendJSON(res, 400, { success: false, error: 'Query parameter required' }, requestOrigin);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
let searchDir = process.cwd();
|
|
628
|
+
if (workspace) {
|
|
629
|
+
const wsPath = resolveWorkspacePath(workspace);
|
|
630
|
+
if (wsPath) {
|
|
631
|
+
searchDir = wsPath;
|
|
632
|
+
} else {
|
|
633
|
+
return sendJSON(res, 404, { success: false, error: `Workspace not found: ${workspace}` }, requestOrigin);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const patterns = extractSearchPatterns(query);
|
|
638
|
+
const results = findFilesRecursive(searchDir, patterns, [], 6, 0);
|
|
639
|
+
|
|
640
|
+
const files = results.slice(0, maxResults).map(fullPath => ({
|
|
641
|
+
path: fullPath.replace(searchDir + '/', ''),
|
|
642
|
+
fullPath,
|
|
643
|
+
name: path.basename(fullPath)
|
|
644
|
+
}));
|
|
645
|
+
|
|
646
|
+
return sendJSON(res, 200, {
|
|
647
|
+
success: true,
|
|
648
|
+
query,
|
|
649
|
+
workspace: workspace || null,
|
|
650
|
+
searchDir,
|
|
651
|
+
patterns,
|
|
652
|
+
files
|
|
653
|
+
}, requestOrigin);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ===== PAIR PROGRAMMING ENDPOINTS =====
|
|
657
|
+
|
|
658
|
+
// Start pair programming session (opens terminal)
|
|
659
|
+
if (pathname === '/pair/start' && method === 'POST') {
|
|
660
|
+
try {
|
|
661
|
+
const body = await parseBody(req);
|
|
662
|
+
const { workspace, task, file, provider, headless, sessionId } = body;
|
|
663
|
+
|
|
664
|
+
if (!task) {
|
|
665
|
+
return sendJSON(res, 400, { success: false, error: 'Task description required' }, requestOrigin);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
let workspaceDir = process.cwd();
|
|
669
|
+
if (workspace) {
|
|
670
|
+
const wsPath = resolveWorkspacePath(workspace);
|
|
671
|
+
if (wsPath) {
|
|
672
|
+
workspaceDir = wsPath;
|
|
673
|
+
} else {
|
|
674
|
+
return sendJSON(res, 404, { success: false, error: `Workspace not found: ${workspace}` }, requestOrigin);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Build the prompt for Claude
|
|
679
|
+
let prompt = task;
|
|
680
|
+
if (file) {
|
|
681
|
+
prompt = `Working on file: ${file}\n\nTask: ${task}`;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Build command - use claude with auto-approve
|
|
685
|
+
const command = `claude --dangerously-skip-permissions "${escapeForDoubleQuotedShellArg(prompt)}"`;
|
|
686
|
+
|
|
687
|
+
if (headless) {
|
|
688
|
+
const result = await runHeadlessProcess({
|
|
689
|
+
workspace: workspaceDir,
|
|
690
|
+
command: provider && String(provider).toLowerCase() === 'codex' ? 'codex' : 'claude',
|
|
691
|
+
prompt,
|
|
692
|
+
sessionLabel: sessionId || 'pair'
|
|
693
|
+
});
|
|
694
|
+
return sendJSON(res, result.success ? 200 : 500, result, requestOrigin);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const platform = os.platform();
|
|
698
|
+
if (platform === 'darwin') {
|
|
699
|
+
await openMacTerminal(workspaceDir, command);
|
|
700
|
+
} else if (platform === 'win32') {
|
|
701
|
+
await openWindowsTerminal(workspaceDir, command);
|
|
702
|
+
} else {
|
|
703
|
+
await openLinuxTerminal(workspaceDir, command);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
return sendJSON(res, 200, {
|
|
707
|
+
success: true,
|
|
708
|
+
message: 'Pair programming session started',
|
|
709
|
+
workspace: workspaceDir,
|
|
710
|
+
task,
|
|
711
|
+
file: file || null
|
|
712
|
+
}, requestOrigin);
|
|
713
|
+
} catch (error) {
|
|
714
|
+
return sendJSON(res, 500, { success: false, error: error.message }, requestOrigin);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// 404
|
|
719
|
+
sendJSON(res, 404, { error: 'Not found' }, requestOrigin);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Open terminal on macOS
|
|
724
|
+
*/
|
|
725
|
+
function openMacTerminal(workspaceDir, command) {
|
|
726
|
+
return new Promise((resolve, reject) => {
|
|
727
|
+
if (!fs.existsSync(workspaceDir)) {
|
|
728
|
+
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const escapedDir = workspaceDir.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
732
|
+
let finalCommand = command;
|
|
733
|
+
|
|
734
|
+
if (command.includes('claude') && !command.includes('--dangerously-skip-permissions')) {
|
|
735
|
+
finalCommand = command.replace(/^claude\s+/, 'claude --dangerously-skip-permissions ');
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const escapedCommand = finalCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
739
|
+
|
|
740
|
+
const appleScript = `
|
|
741
|
+
tell application "Terminal"
|
|
742
|
+
activate
|
|
743
|
+
set newTab to do script "cd \\"${escapedDir}\\""
|
|
744
|
+
delay 0.5
|
|
745
|
+
do script "${escapedCommand}" in newTab
|
|
746
|
+
end tell
|
|
747
|
+
`.trim();
|
|
748
|
+
|
|
749
|
+
const tempScript = `/tmp/knoxis-terminal-${Date.now()}.scpt`;
|
|
750
|
+
fs.writeFileSync(tempScript, appleScript);
|
|
751
|
+
|
|
752
|
+
exec(`osascript "${tempScript}"`, (error) => {
|
|
753
|
+
try { fs.unlinkSync(tempScript); } catch (e) {}
|
|
754
|
+
|
|
755
|
+
if (error) {
|
|
756
|
+
console.error('ā AppleScript error:', error);
|
|
757
|
+
reject(error);
|
|
758
|
+
} else {
|
|
759
|
+
console.log('ā
Terminal opened on macOS');
|
|
760
|
+
resolve();
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Open terminal on Windows
|
|
768
|
+
*/
|
|
769
|
+
function openWindowsTerminal(workspaceDir, command) {
|
|
770
|
+
return new Promise((resolve, reject) => {
|
|
771
|
+
if (!fs.existsSync(workspaceDir)) {
|
|
772
|
+
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const fullCommand = `start cmd /K "cd /d "${workspaceDir}" && ${command}"`;
|
|
776
|
+
|
|
777
|
+
exec(fullCommand, (error) => {
|
|
778
|
+
if (error) {
|
|
779
|
+
console.error('ā Windows terminal error:', error);
|
|
780
|
+
reject(error);
|
|
781
|
+
} else {
|
|
782
|
+
console.log('ā
Terminal opened on Windows');
|
|
783
|
+
resolve();
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Open terminal on Linux
|
|
791
|
+
*/
|
|
792
|
+
function openLinuxTerminal(workspaceDir, command) {
|
|
793
|
+
return new Promise((resolve, reject) => {
|
|
794
|
+
if (!fs.existsSync(workspaceDir)) {
|
|
795
|
+
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const fullCommand = `gnome-terminal --working-directory="${workspaceDir}" -- bash -c "${command}; exec bash"`;
|
|
799
|
+
|
|
800
|
+
exec(fullCommand, (error) => {
|
|
801
|
+
if (error) {
|
|
802
|
+
const xtermCommand = `xterm -e "cd '${workspaceDir}' && ${command}; bash"`;
|
|
803
|
+
exec(xtermCommand, (error2) => {
|
|
804
|
+
if (error2) {
|
|
805
|
+
console.error('ā Linux terminal error:', error2);
|
|
806
|
+
reject(error2);
|
|
807
|
+
} else {
|
|
808
|
+
console.log('ā
Terminal opened on Linux (xterm)');
|
|
809
|
+
resolve();
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
} else {
|
|
813
|
+
console.log('ā
Terminal opened on Linux (gnome-terminal)');
|
|
814
|
+
resolve();
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function createServer() {
|
|
821
|
+
// Try to load existing certs
|
|
822
|
+
try {
|
|
823
|
+
if (fs.existsSync(KEY_FILE) && fs.existsSync(CERT_FILE)) {
|
|
824
|
+
const key = fs.readFileSync(KEY_FILE);
|
|
825
|
+
const cert = fs.readFileSync(CERT_FILE);
|
|
826
|
+
serverMeta.secure = true;
|
|
827
|
+
return https.createServer({ key, cert }, handleRequest);
|
|
828
|
+
}
|
|
829
|
+
} catch (err) {
|
|
830
|
+
console.warn('ā ļø Failed to load TLS certificates:', err.message);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// No certs exist - try to generate them
|
|
834
|
+
if (!fs.existsSync(CERT_FILE) || !fs.existsSync(KEY_FILE)) {
|
|
835
|
+
const generated = generateSelfSignedCert();
|
|
836
|
+
if (generated && fs.existsSync(KEY_FILE) && fs.existsSync(CERT_FILE)) {
|
|
837
|
+
try {
|
|
838
|
+
const key = fs.readFileSync(KEY_FILE);
|
|
839
|
+
const cert = fs.readFileSync(CERT_FILE);
|
|
840
|
+
serverMeta.secure = true;
|
|
841
|
+
return https.createServer({ key, cert }, handleRequest);
|
|
842
|
+
} catch (err) {
|
|
843
|
+
console.warn('ā ļø Failed to load generated certificates:', err.message);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Fallback to HTTP (will cause mixed content issues from HTTPS frontends)
|
|
849
|
+
serverMeta.secure = false;
|
|
850
|
+
console.warn('');
|
|
851
|
+
console.warn('ā ļø RUNNING IN HTTP MODE - This will cause 405/CORS errors from HTTPS frontends!');
|
|
852
|
+
console.warn('');
|
|
853
|
+
return http.createServer(handleRequest);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function ensureLaunchAgentIfNeeded() {
|
|
857
|
+
if (process.platform !== 'darwin') return;
|
|
858
|
+
if (process.env.KNOXIS_SKIP_LAUNCH_AGENT === '1') return;
|
|
859
|
+
|
|
860
|
+
try {
|
|
861
|
+
const agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
862
|
+
if (!fs.existsSync(agentsDir)) {
|
|
863
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const plistPath = path.join(agentsDir, 'com.knoxis.helper.plist');
|
|
867
|
+
const programArgs = [process.execPath, __filename];
|
|
868
|
+
|
|
869
|
+
const logDir = path.join(os.homedir(), 'Library', 'Logs');
|
|
870
|
+
if (!fs.existsSync(logDir)) {
|
|
871
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
872
|
+
}
|
|
873
|
+
const stdoutPath = path.join(logDir, 'KnoxisHelper.log');
|
|
874
|
+
|
|
875
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
876
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
877
|
+
<plist version="1.0">
|
|
878
|
+
<dict>
|
|
879
|
+
<key>Label</key>
|
|
880
|
+
<string>com.knoxis.helper</string>
|
|
881
|
+
<key>ProgramArguments</key>
|
|
882
|
+
<array>
|
|
883
|
+
${programArgs.map(arg => `<string>${arg}</string>`).join('\n ')}
|
|
884
|
+
</array>
|
|
885
|
+
<key>RunAtLoad</key>
|
|
886
|
+
<true/>
|
|
887
|
+
<key>KeepAlive</key>
|
|
888
|
+
<true/>
|
|
889
|
+
<key>StandardOutPath</key>
|
|
890
|
+
<string>${stdoutPath}</string>
|
|
891
|
+
<key>StandardErrorPath</key>
|
|
892
|
+
<string>${stdoutPath}</string>
|
|
893
|
+
</dict>
|
|
894
|
+
</plist>`;
|
|
895
|
+
|
|
896
|
+
let needsWrite = true;
|
|
897
|
+
if (fs.existsSync(plistPath)) {
|
|
898
|
+
const current = fs.readFileSync(plistPath, 'utf8');
|
|
899
|
+
if (current === plist) needsWrite = false;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (needsWrite) {
|
|
903
|
+
fs.writeFileSync(plistPath, plist, 'utf8');
|
|
904
|
+
spawnSync('launchctl', ['unload', plistPath]);
|
|
905
|
+
const load = spawnSync('launchctl', ['load', '-w', plistPath]);
|
|
906
|
+
if (load.status === 0) {
|
|
907
|
+
console.log('š ļø Installed launch agent to auto-start Knoxis helper');
|
|
908
|
+
} else {
|
|
909
|
+
console.warn('ā ļø Unable to automatically load launch agent. Run:');
|
|
910
|
+
console.warn(` launchctl load -w ${plistPath}`);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
} catch (err) {
|
|
915
|
+
console.warn('ā ļø Unable to configure auto-start for Knoxis helper:', err.message);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// ===== WEBSOCKET RELAY CLIENT =====
|
|
920
|
+
// Connects to the deployed backend's WebSocket to receive pair programming commands
|
|
921
|
+
// without needing the frontend to relay. Backend pushes commands directly.
|
|
922
|
+
|
|
923
|
+
const BACKEND_WS_URL = process.env.KNOXIS_BACKEND_WS_URL || null;
|
|
924
|
+
const RELAY_USER_ID = process.env.KNOXIS_USER_ID || null;
|
|
925
|
+
const RELAY_RECONNECT_INTERVAL_MS = parseInt(process.env.KNOXIS_RECONNECT_MS || '10000', 10);
|
|
926
|
+
|
|
927
|
+
let relaySocket = null;
|
|
928
|
+
let relayReconnectTimer = null;
|
|
929
|
+
|
|
930
|
+
function connectRelayWebSocket() {
|
|
931
|
+
if (!BACKEND_WS_URL || !RELAY_USER_ID) {
|
|
932
|
+
return; // Not configured - skip relay
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const wsUrl = `${BACKEND_WS_URL}/ws/knoxis-terminal?userId=${encodeURIComponent(RELAY_USER_ID)}`;
|
|
936
|
+
console.log(`š Connecting relay to: ${wsUrl}`);
|
|
937
|
+
|
|
938
|
+
// Use Node.js built-in WebSocket (Node 22+) or ws module
|
|
939
|
+
let WebSocketImpl;
|
|
940
|
+
try {
|
|
941
|
+
WebSocketImpl = globalThis.WebSocket || require('ws');
|
|
942
|
+
} catch (e) {
|
|
943
|
+
// For older Node versions without built-in WebSocket, try dynamic import or skip
|
|
944
|
+
try {
|
|
945
|
+
WebSocketImpl = require('ws');
|
|
946
|
+
} catch (e2) {
|
|
947
|
+
console.warn('ā ļø WebSocket relay requires Node 22+ or the "ws" package. Relay disabled.');
|
|
948
|
+
console.warn(' Install with: npm install ws');
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
try {
|
|
954
|
+
relaySocket = new WebSocketImpl(wsUrl);
|
|
955
|
+
} catch (e) {
|
|
956
|
+
console.warn('ā ļø Failed to create WebSocket connection:', e.message);
|
|
957
|
+
scheduleRelayReconnect();
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
relaySocket.onopen = () => {
|
|
962
|
+
console.log('ā
Relay WebSocket connected to backend');
|
|
963
|
+
if (relayReconnectTimer) {
|
|
964
|
+
clearTimeout(relayReconnectTimer);
|
|
965
|
+
relayReconnectTimer = null;
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
relaySocket.onmessage = async (event) => {
|
|
970
|
+
try {
|
|
971
|
+
const data = typeof event.data === 'string' ? event.data : event.data.toString();
|
|
972
|
+
const msg = JSON.parse(data);
|
|
973
|
+
|
|
974
|
+
if (msg.type === 'execute_command') {
|
|
975
|
+
console.log(`š„ Relay command received: ${msg.requestId}`);
|
|
976
|
+
console.log(` š Dir: ${msg.workingDir}`);
|
|
977
|
+
console.log(` š Cmd: ${(msg.command || '').substring(0, 100)}...`);
|
|
978
|
+
|
|
979
|
+
const workspace = msg.workingDir || process.cwd();
|
|
980
|
+
const command = msg.command || '';
|
|
981
|
+
|
|
982
|
+
let result;
|
|
983
|
+
try {
|
|
984
|
+
if (msg.headless) {
|
|
985
|
+
result = await runHeadlessProcess({
|
|
986
|
+
workspace,
|
|
987
|
+
command,
|
|
988
|
+
prompt: msg.prompt,
|
|
989
|
+
sessionLabel: msg.requestId || 'relay'
|
|
990
|
+
});
|
|
991
|
+
} else {
|
|
992
|
+
const platform = os.platform();
|
|
993
|
+
if (platform === 'darwin') {
|
|
994
|
+
await openMacTerminal(workspace, command);
|
|
995
|
+
} else if (platform === 'win32') {
|
|
996
|
+
await openWindowsTerminal(workspace, command);
|
|
997
|
+
} else {
|
|
998
|
+
await openLinuxTerminal(workspace, command);
|
|
999
|
+
}
|
|
1000
|
+
result = { success: true, message: 'Terminal opened via relay' };
|
|
1001
|
+
}
|
|
1002
|
+
} catch (err) {
|
|
1003
|
+
result = { success: false, error: err.message };
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Send result back to backend
|
|
1007
|
+
const response = JSON.stringify({
|
|
1008
|
+
type: 'command_result',
|
|
1009
|
+
requestId: msg.requestId,
|
|
1010
|
+
...result,
|
|
1011
|
+
timestamp: Date.now()
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
if (relaySocket && relaySocket.readyState === 1) { // OPEN
|
|
1015
|
+
relaySocket.send(response);
|
|
1016
|
+
console.log(`š¤ Relay result sent for: ${msg.requestId} (success: ${result.success})`);
|
|
1017
|
+
}
|
|
1018
|
+
} else if (msg.type === 'connected') {
|
|
1019
|
+
console.log(`š¤ Backend acknowledged: ${msg.message}`);
|
|
1020
|
+
}
|
|
1021
|
+
} catch (e) {
|
|
1022
|
+
console.error('ā Relay message handling error:', e.message);
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
relaySocket.onclose = (event) => {
|
|
1027
|
+
console.log(`š Relay WebSocket closed (code: ${event.code || 'unknown'})`);
|
|
1028
|
+
relaySocket = null;
|
|
1029
|
+
scheduleRelayReconnect();
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
relaySocket.onerror = (err) => {
|
|
1033
|
+
console.error('ā Relay WebSocket error:', err.message || 'connection failed');
|
|
1034
|
+
// onclose will fire after this, triggering reconnect
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function scheduleRelayReconnect() {
|
|
1039
|
+
if (relayReconnectTimer) return;
|
|
1040
|
+
if (!BACKEND_WS_URL || !RELAY_USER_ID) return;
|
|
1041
|
+
|
|
1042
|
+
console.log(`ā³ Relay reconnecting in ${RELAY_RECONNECT_INTERVAL_MS / 1000}s...`);
|
|
1043
|
+
relayReconnectTimer = setTimeout(() => {
|
|
1044
|
+
relayReconnectTimer = null;
|
|
1045
|
+
connectRelayWebSocket();
|
|
1046
|
+
}, RELAY_RECONNECT_INTERVAL_MS);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Send heartbeat to keep relay alive
|
|
1050
|
+
function startRelayHeartbeat() {
|
|
1051
|
+
if (!BACKEND_WS_URL || !RELAY_USER_ID) return;
|
|
1052
|
+
|
|
1053
|
+
setInterval(() => {
|
|
1054
|
+
if (relaySocket && relaySocket.readyState === 1) {
|
|
1055
|
+
try {
|
|
1056
|
+
relaySocket.send(JSON.stringify({ type: 'heartbeat', timestamp: Date.now() }));
|
|
1057
|
+
} catch (e) {
|
|
1058
|
+
// Will reconnect on close
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}, 30000); // Every 30 seconds
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Graceful shutdown
|
|
1065
|
+
function shutdown() {
|
|
1066
|
+
console.log('\nš Shutting down Knoxis Local Agent...');
|
|
1067
|
+
if (relaySocket) {
|
|
1068
|
+
try { relaySocket.close(); } catch (e) {}
|
|
1069
|
+
}
|
|
1070
|
+
process.exit(0);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
process.on('SIGINT', shutdown);
|
|
1074
|
+
process.on('SIGTERM', shutdown);
|
|
1075
|
+
|
|
1076
|
+
// Start the server
|
|
1077
|
+
ensureLaunchAgentIfNeeded();
|
|
1078
|
+
const server = createServer();
|
|
1079
|
+
|
|
1080
|
+
// Start WebSocket relay if configured
|
|
1081
|
+
connectRelayWebSocket();
|
|
1082
|
+
startRelayHeartbeat();
|
|
1083
|
+
|
|
1084
|
+
server.listen(serverMeta.port, () => {
|
|
1085
|
+
const scheme = serverMeta.secure ? 'https' : 'http';
|
|
1086
|
+
console.log('');
|
|
1087
|
+
console.log('āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā');
|
|
1088
|
+
console.log('ā š KNOXIS LOCAL AGENT v2.1.0 (HTTPS + CORS Fix) ā');
|
|
1089
|
+
console.log('āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā');
|
|
1090
|
+
console.log('');
|
|
1091
|
+
console.log(`š Mode: ${serverMeta.secure ? 'HTTPS (Secure)' : 'HTTP (Insecure - see warning below)'}`);
|
|
1092
|
+
console.log(`š Listening: ${scheme}://localhost:${serverMeta.port}`);
|
|
1093
|
+
console.log(`š¦ Dependencies: NONE (pure Node.js)`);
|
|
1094
|
+
console.log('');
|
|
1095
|
+
console.log('š Allowed Origins (CORS):');
|
|
1096
|
+
ALL_ALLOWED_ORIGINS.forEach(origin => console.log(` ā ${origin}`));
|
|
1097
|
+
console.log('');
|
|
1098
|
+
console.log('š Workspace Management:');
|
|
1099
|
+
console.log(' GET /workspace/list - List saved workspaces');
|
|
1100
|
+
console.log(' GET /workspace/get?name=X - Get workspace path (fuzzy match)');
|
|
1101
|
+
console.log(' POST /workspace/save - Save workspace {name, path}');
|
|
1102
|
+
console.log(' POST /workspace/discover - Auto-discover projects');
|
|
1103
|
+
console.log('');
|
|
1104
|
+
console.log('š File Finder:');
|
|
1105
|
+
console.log(' GET /files/find?q=X&w=Y - Find files by query in workspace');
|
|
1106
|
+
console.log('');
|
|
1107
|
+
console.log('š¤ Pair Programming:');
|
|
1108
|
+
console.log(' POST /pair/start - Start session {workspace, task, file}');
|
|
1109
|
+
console.log('');
|
|
1110
|
+
if (BACKEND_WS_URL && RELAY_USER_ID) {
|
|
1111
|
+
console.log('š WebSocket Relay:');
|
|
1112
|
+
console.log(` Backend: ${BACKEND_WS_URL}`);
|
|
1113
|
+
console.log(` User ID: ${RELAY_USER_ID}`);
|
|
1114
|
+
console.log(' Status: Connecting...');
|
|
1115
|
+
console.log('');
|
|
1116
|
+
} else {
|
|
1117
|
+
console.log('š” WebSocket Relay: Not configured');
|
|
1118
|
+
console.log(' Set KNOXIS_BACKEND_WS_URL and KNOXIS_USER_ID to enable');
|
|
1119
|
+
console.log(` Example: KNOXIS_BACKEND_WS_URL=wss://your-backend.azurecontainerapps.io KNOXIS_USER_ID=your-uuid node ${path.basename(__filename)}`);
|
|
1120
|
+
console.log('');
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if (!serverMeta.secure) {
|
|
1124
|
+
console.warn('āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā');
|
|
1125
|
+
console.warn('ā ā ļø WARNING: Running in HTTP mode (no TLS certificates) ā');
|
|
1126
|
+
console.warn('ā Browsers will block requests from HTTPS sites (like qig.ai) ā');
|
|
1127
|
+
console.warn('āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā');
|
|
1128
|
+
console.warn('');
|
|
1129
|
+
console.warn('To fix this, either:');
|
|
1130
|
+
console.warn(` 1. Install OpenSSL and restart (auto-generates certs)`);
|
|
1131
|
+
console.warn(` 2. Manually place certs in: ${CERT_DIR}`);
|
|
1132
|
+
console.warn('');
|
|
1133
|
+
} else {
|
|
1134
|
+
console.log('ā
HTTPS enabled - ready for secure connections from deployed frontends');
|
|
1135
|
+
console.log('');
|
|
1136
|
+
}
|
|
1137
|
+
});
|