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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkncollab-shell",
3
- "version": "0.0.18",
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": "ThinkNCollab",
23
+ "author": "Raman Singh <raman@thinkncollab.com>",
24
24
  "license": "MIT",
25
25
  "dependencies": {
26
26
  "@inquirer/prompts": "^2.3.1",
@@ -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) {