thinkncollab-shell 0.0.18 → 0.0.20
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/package.json +2 -2
- package/src/core/websocket.js +175 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkncollab-shell",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.20",
|
|
4
4
|
"description": "ThinkNCollab Custom Shell with WebSocket collaboration",
|
|
5
5
|
"main": "src/core/shell.js",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"collaboration",
|
|
21
21
|
"cli"
|
|
22
22
|
],
|
|
23
|
-
"author": "
|
|
23
|
+
"author": "Raman Singh <raman@thinkncollab.com>",
|
|
24
24
|
"license": "MIT",
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@inquirer/prompts": "^2.3.1",
|
package/src/core/websocket.js
CHANGED
|
@@ -316,8 +316,183 @@ class WebSocketManager extends EventEmitter {
|
|
|
316
316
|
};
|
|
317
317
|
|
|
318
318
|
this.socket.on('cicd:run-dispatch', handleCicdRun);
|
|
319
|
+
|
|
320
|
+
// ── Task Test Dispatch — async local execution ────────────────────────
|
|
321
|
+
// Server sends this when GitHub webhook fires and assigned user's shell is active,
|
|
322
|
+
// OR when user connects and there are waiting_for_shell TestRuns queued.
|
|
323
|
+
// Test runs fully async using spawn — never blocks the shell.
|
|
324
|
+
const activeTestRunIds = new Set();
|
|
325
|
+
|
|
326
|
+
const handleTestDispatch = async (data) => {
|
|
327
|
+
if (!data || !data.runId || !data.taskId) return;
|
|
328
|
+
if (activeTestRunIds.has(data.runId)) return;
|
|
329
|
+
activeTestRunIds.add(data.runId);
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
const chalk = require('chalk');
|
|
333
|
+
const { spawn } = require('child_process');
|
|
334
|
+
const http = require('http');
|
|
335
|
+
const https = require('https');
|
|
336
|
+
const crypto = require('crypto');
|
|
337
|
+
|
|
338
|
+
const encryptPayload = (dataObj, keySecret) => {
|
|
339
|
+
if (!keySecret) return null;
|
|
340
|
+
const key = crypto.createHash('sha256').update(keySecret).digest();
|
|
341
|
+
const iv = crypto.randomBytes(12);
|
|
342
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
343
|
+
const json = JSON.stringify(dataObj || {});
|
|
344
|
+
const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]);
|
|
345
|
+
const tag = cipher.getAuthTag();
|
|
346
|
+
return { iv: iv.toString('hex'), tag: tag.toString('hex'), cipherText: encrypted.toString('hex') };
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const decryptPayload = (encryptedObj, keySecret) => {
|
|
350
|
+
if (!encryptedObj || !encryptedObj.cipherText || !encryptedObj.iv || !encryptedObj.tag) return null;
|
|
351
|
+
try {
|
|
352
|
+
const key = crypto.createHash('sha256').update(keySecret).digest();
|
|
353
|
+
const iv = Buffer.from(encryptedObj.iv, 'hex');
|
|
354
|
+
const tag = Buffer.from(encryptedObj.tag, 'hex');
|
|
355
|
+
const encryptedText = Buffer.from(encryptedObj.cipherText, 'hex');
|
|
356
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
357
|
+
decipher.setAuthTag(tag);
|
|
358
|
+
const decrypted = Buffer.concat([decipher.update(encryptedText), decipher.final()]);
|
|
359
|
+
return JSON.parse(decrypted.toString('utf8'));
|
|
360
|
+
} catch (_) { return null; }
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const { runId, taskId, runResultToken, encryptedPayload } = data;
|
|
364
|
+
let testConfig = data.testConfig;
|
|
365
|
+
let commit = data.commit;
|
|
366
|
+
let taskTitle = data.taskTitle;
|
|
367
|
+
|
|
368
|
+
// Decrypt payload using AES-256-GCM if encrypted
|
|
369
|
+
if (encryptedPayload && runResultToken) {
|
|
370
|
+
const decrypted = decryptPayload(encryptedPayload, runResultToken);
|
|
371
|
+
if (decrypted) {
|
|
372
|
+
testConfig = decrypted.testConfig || testConfig;
|
|
373
|
+
commit = decrypted.commit || commit;
|
|
374
|
+
taskTitle = decrypted.taskTitle || taskTitle;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
console.log(chalk.cyan(`\n🧪 [TNC TestRunner - AES-256-GCM Secure] Task: "${taskTitle}"`));
|
|
379
|
+
console.log(chalk.gray(` Run ID : ${runId}`));
|
|
380
|
+
console.log(chalk.gray(` Type : ${testConfig?.type}`));
|
|
381
|
+
console.log(chalk.gray(` Commit : ${commit?.sha?.slice(0, 7) || 'N/A'} — ${commit?.message || ''}`));
|
|
382
|
+
|
|
383
|
+
let result = { exitCode: 1, stdout: '', stderr: '', status: null, body: null, url: null };
|
|
384
|
+
|
|
385
|
+
if (testConfig.type === 'exitCode' || testConfig.type === 'stdout' || testConfig.type === 'regex') {
|
|
386
|
+
// ── Async shell command via spawn ─────────────────────────
|
|
387
|
+
const command = testConfig.command || testConfig.script;
|
|
388
|
+
if (!command) {
|
|
389
|
+
result.stderr = 'No command specified in testConfig';
|
|
390
|
+
} else {
|
|
391
|
+
console.log(chalk.yellow(` Running : ${command}`));
|
|
392
|
+
result = await new Promise((resolve) => {
|
|
393
|
+
const proc = spawn(command, [], {
|
|
394
|
+
shell: true,
|
|
395
|
+
cwd: process.cwd(),
|
|
396
|
+
timeout: 60000
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
let stdout = '';
|
|
400
|
+
let stderr = '';
|
|
401
|
+
|
|
402
|
+
proc.stdout.on('data', (chunk) => {
|
|
403
|
+
stdout += chunk.toString();
|
|
404
|
+
process.stdout.write(chalk.gray(' │ ') + chunk.toString());
|
|
405
|
+
});
|
|
406
|
+
proc.stderr.on('data', (chunk) => {
|
|
407
|
+
stderr += chunk.toString();
|
|
408
|
+
process.stderr.write(chalk.red(' │ ') + chunk.toString());
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
proc.on('close', (code) => {
|
|
412
|
+
resolve({ exitCode: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim(), status: null, body: null, url: null });
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
proc.on('error', (err) => {
|
|
416
|
+
resolve({ exitCode: 1, stdout: '', stderr: err.message, status: null, body: null, url: null });
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
console.log(result.exitCode === 0
|
|
420
|
+
? chalk.green(` ✅ Exit 0`)
|
|
421
|
+
: chalk.red(` ❌ Exit ${result.exitCode} — ${result.stderr.slice(0, 100)}`));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
} else if (testConfig.type === 'http') {
|
|
425
|
+
// ── Async HTTP test ───────────────────────────────────────
|
|
426
|
+
const endpoint = testConfig.endpoint || testConfig.url;
|
|
427
|
+
const method = (testConfig.method || 'GET').toUpperCase();
|
|
428
|
+
console.log(chalk.yellow(` Hitting : ${method} ${endpoint}`));
|
|
429
|
+
|
|
430
|
+
result = await new Promise((resolve) => {
|
|
431
|
+
const lib = endpoint?.startsWith('https') ? https : http;
|
|
432
|
+
const url = new URL(endpoint);
|
|
433
|
+
const options = { hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname + url.search, method };
|
|
434
|
+
|
|
435
|
+
const req = lib.request(options, (res) => {
|
|
436
|
+
let body = '';
|
|
437
|
+
res.on('data', chunk => body += chunk.toString());
|
|
438
|
+
res.on('end', () => {
|
|
439
|
+
let parsed = body;
|
|
440
|
+
try { parsed = JSON.parse(body); } catch (_) {}
|
|
441
|
+
console.log(chalk.green(` ✅ HTTP ${res.statusCode}`));
|
|
442
|
+
resolve({ exitCode: 0, stdout: body.slice(0, 500), stderr: '', status: res.statusCode, body: parsed, url: endpoint });
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
req.on('error', (err) => {
|
|
447
|
+
console.log(chalk.red(` ❌ HTTP error: ${err.message}`));
|
|
448
|
+
resolve({ exitCode: 1, stdout: '', stderr: err.message, status: null, body: null, url: endpoint });
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
req.setTimeout(15000, () => {
|
|
452
|
+
req.destroy();
|
|
453
|
+
resolve({ exitCode: 1, stdout: '', stderr: 'Request timeout (15s)', status: null, body: null, url: endpoint });
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
req.end();
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
} else {
|
|
460
|
+
// ── Fallback: run testConfig.command if available ─────────
|
|
461
|
+
const command = testConfig.command || testConfig.script;
|
|
462
|
+
if (command) {
|
|
463
|
+
result = await new Promise((resolve) => {
|
|
464
|
+
const proc = spawn(command, [], { shell: true, cwd: process.cwd(), timeout: 60000 });
|
|
465
|
+
let stdout = '', stderr = '';
|
|
466
|
+
proc.stdout.on('data', c => stdout += c.toString());
|
|
467
|
+
proc.stderr.on('data', c => stderr += c.toString());
|
|
468
|
+
proc.on('close', code => resolve({ exitCode: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim(), status: null, body: null, url: null }));
|
|
469
|
+
proc.on('error', err => resolve({ exitCode: 1, stdout: '', stderr: err.message, status: null, body: null, url: null }));
|
|
470
|
+
});
|
|
471
|
+
} else {
|
|
472
|
+
result.stderr = `Unsupported testConfig type: ${testConfig.type}`;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ── AES-256-GCM Encrypt result back to server ─────────────────
|
|
477
|
+
const encryptedResult = runResultToken ? encryptPayload(result, runResultToken) : null;
|
|
478
|
+
const resultPayload = { runId, taskId, runResultToken, encryptedResult, ...result };
|
|
479
|
+
|
|
480
|
+
// Primary: via socket (instant, zero overhead)
|
|
481
|
+
this.socket.emit('thinknsh:test-result', resultPayload);
|
|
482
|
+
console.log(chalk.cyan(` 🔒 [AES-256-GCM] Encrypted result dispatched to TNC server.\n`));
|
|
483
|
+
|
|
484
|
+
} catch (err) {
|
|
485
|
+
console.error('thinknsh:test-dispatch error:', err.message);
|
|
486
|
+
} finally {
|
|
487
|
+
// Clean up after 60s so same runId can be retried if needed
|
|
488
|
+
setTimeout(() => activeTestRunIds.delete(data.runId), 60000);
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
this.socket.on('thinknsh:test-dispatch', handleTestDispatch);
|
|
319
493
|
}
|
|
320
494
|
|
|
495
|
+
|
|
321
496
|
// ─── Join room ────────────────────────────────────────────────────────────
|
|
322
497
|
|
|
323
498
|
joinRoom(roomId) {
|