thinkncollab-shell 0.0.13 ā 0.0.15
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 +8 -8
- package/package.json +1 -1
- package/src/commands/tasks/complete.js +180 -0
- package/src/commands/tasks/task.js +3 -0
- package/src/core/local-runner.js +226 -0
- package/src/core/shell.js +1 -1
- package/src/core/websocket.js +42 -0
package/Readme.md
CHANGED
|
@@ -22,7 +22,7 @@ A real-time collaborative terminal shell with WebSocket integration, built on to
|
|
|
22
22
|
- [Notification Commands](#notification-commands)
|
|
23
23
|
- [Shell Commands](#shell-commands)
|
|
24
24
|
- [GitHub Integration Commands](#github-integration-commands)
|
|
25
|
-
- [
|
|
25
|
+
- [`tncproject` Auto-Boot](#tncproject-auto-boot)
|
|
26
26
|
- [Notification Window](#notification-window)
|
|
27
27
|
- [Built-in Shell Features](#built-in-shell-features)
|
|
28
28
|
- [Single-Command Mode](#single-command-mode)
|
|
@@ -38,7 +38,7 @@ A real-time collaborative terminal shell with WebSocket integration, built on to
|
|
|
38
38
|
- š **Task Management** ā Accept, start, complete, and reject tasks from the CLI
|
|
39
39
|
- šŗ **Terminal Sharing** ā Share your live terminal session with teammates (read + write access)
|
|
40
40
|
- š **Notification Window** ā Background TCP-based popup window for live events
|
|
41
|
-
- š
|
|
41
|
+
- š **`tncproject` Auto-Boot** ā Drop a config file in your project root to auto-login and join a room on shell start
|
|
42
42
|
- š”ļø **Shell Token Auth** ā Secure HMAC-SHA256 token validation for shell connections
|
|
43
43
|
- š **File System** ā Built-in `ls`, `cd`, `pwd`, plus passthrough to your system shell
|
|
44
44
|
- š **History** ā Persisted command history at `~/.thinknsh_history`
|
|
@@ -443,9 +443,9 @@ Create a new issue in the linked GitHub repository. If arguments are missing, th
|
|
|
443
443
|
|
|
444
444
|
---
|
|
445
445
|
|
|
446
|
-
##
|
|
446
|
+
## `tncproject` Auto-Boot
|
|
447
447
|
|
|
448
|
-
Place a
|
|
448
|
+
Place a `tncproject` file in your project root to auto-login and auto-join a room when `thinknsh` starts in that directory ā no manual `login` or `join` needed.
|
|
449
449
|
|
|
450
450
|
```
|
|
451
451
|
.tncproject
|
|
@@ -456,12 +456,12 @@ On startup the shell sends the encrypted config to the server, receives a shell
|
|
|
456
456
|
```
|
|
457
457
|
š tncproject found
|
|
458
458
|
Room : room-abc123
|
|
459
|
-
File : /home/user/myproject
|
|
459
|
+
File : /home/user/myproject/tncproject
|
|
460
460
|
|
|
461
461
|
ā
Raman Singh ā room "My Project Room"
|
|
462
462
|
```
|
|
463
463
|
|
|
464
|
-
If
|
|
464
|
+
If `tncproject` auth fails, the shell falls back to manual login.
|
|
465
465
|
|
|
466
466
|
---
|
|
467
467
|
|
|
@@ -560,8 +560,8 @@ thinknsh> notify "Daily standup in 5 mins"
|
|
|
560
560
|
thinknsh> status
|
|
561
561
|
thinknsh> exit
|
|
562
562
|
|
|
563
|
-
#
|
|
564
|
-
cd ~/myproject # contains
|
|
563
|
+
# tncproject auto-boot (no login needed)
|
|
564
|
+
cd ~/myproject # contains tncproject
|
|
565
565
|
thinknsh # auto-logs in + auto-joins room
|
|
566
566
|
|
|
567
567
|
# Quick one-liners from CI / scripts
|
package/package.json
CHANGED
|
@@ -497,6 +497,14 @@ function judgeLocally(testConfig, result) {
|
|
|
497
497
|
break;
|
|
498
498
|
}
|
|
499
499
|
|
|
500
|
+
case 'tnc-build': {
|
|
501
|
+
const passed = result.exitCode === 0;
|
|
502
|
+
return {
|
|
503
|
+
passed,
|
|
504
|
+
reason: passed ? 'TNC Local CI pipeline passed' : 'TNC Local CI pipeline failed'
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
500
508
|
default:
|
|
501
509
|
return { passed: false, reason: `Unknown test type: ${type}` };
|
|
502
510
|
}
|
|
@@ -718,6 +726,178 @@ const authConfig = { ...boardAuth, ...(testConfig.auth || {}) };
|
|
|
718
726
|
json: loadtestResult
|
|
719
727
|
};
|
|
720
728
|
|
|
729
|
+
} else if (testConfig.type === 'tnc-build') {
|
|
730
|
+
const fs = require('fs');
|
|
731
|
+
const path = require('path');
|
|
732
|
+
const { spawn } = require('child_process');
|
|
733
|
+
|
|
734
|
+
const projectRoot = process.cwd();
|
|
735
|
+
const workflowsDir = path.join(projectRoot, '.tnc/workflows');
|
|
736
|
+
|
|
737
|
+
if (!fs.existsSync(workflowsDir)) {
|
|
738
|
+
throw new Error('No .tnc/workflows directory found in project root.');
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const files = fs.readdirSync(workflowsDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
742
|
+
if (files.length === 0) {
|
|
743
|
+
throw new Error('No workflow files found in .tnc/workflows/');
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const workflowFile = files[0];
|
|
747
|
+
console.log(chalk.dim(` ā Found workflow file: ${workflowFile}`));
|
|
748
|
+
const content = fs.readFileSync(path.join(workflowsDir, workflowFile), 'utf8');
|
|
749
|
+
|
|
750
|
+
// Parse steps
|
|
751
|
+
const lines = content.split('\n');
|
|
752
|
+
let workflowName = workflowFile;
|
|
753
|
+
let steps = [];
|
|
754
|
+
let currentStepName = '';
|
|
755
|
+
|
|
756
|
+
for (let i = 0; i < lines.length; i++) {
|
|
757
|
+
const line = lines[i].trim();
|
|
758
|
+
if (line.startsWith('name:')) {
|
|
759
|
+
workflowName = line.substring(5).trim().replace(/['"]/g, '');
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
if (line.startsWith('-')) {
|
|
763
|
+
const cleaned = line.substring(1).trim();
|
|
764
|
+
if (cleaned.startsWith('name:')) {
|
|
765
|
+
currentStepName = cleaned.substring(5).trim().replace(/['"]/g, '');
|
|
766
|
+
} else if (cleaned.startsWith('run:')) {
|
|
767
|
+
let runCmd = cleaned.substring(4).trim();
|
|
768
|
+
if ((runCmd.startsWith('"') && runCmd.endsWith('"')) || (runCmd.startsWith("'") && runCmd.endsWith("'"))) {
|
|
769
|
+
runCmd = runCmd.substring(1, runCmd.length - 1);
|
|
770
|
+
}
|
|
771
|
+
if (runCmd === '|') {
|
|
772
|
+
let block = [];
|
|
773
|
+
let j = i + 1;
|
|
774
|
+
while (j < lines.length) {
|
|
775
|
+
const next = lines[j];
|
|
776
|
+
const match = next.match(/^(\s+)(.*)/);
|
|
777
|
+
if (match && match[1].length > 4) {
|
|
778
|
+
block.push(next.trim());
|
|
779
|
+
j++;
|
|
780
|
+
} else {
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
runCmd = block.join('\n');
|
|
785
|
+
i = j - 1;
|
|
786
|
+
}
|
|
787
|
+
steps.push({
|
|
788
|
+
name: currentStepName || `run: ${runCmd.substring(0, 30)}`,
|
|
789
|
+
run: runCmd
|
|
790
|
+
});
|
|
791
|
+
currentStepName = '';
|
|
792
|
+
}
|
|
793
|
+
} else if (line.startsWith('name:')) {
|
|
794
|
+
currentStepName = line.substring(5).trim().replace(/['"]/g, '');
|
|
795
|
+
} else if (line.startsWith('run:')) {
|
|
796
|
+
let runCmd = line.substring(4).trim();
|
|
797
|
+
if ((runCmd.startsWith('"') && runCmd.endsWith('"')) || (runCmd.startsWith("'") && runCmd.endsWith("'"))) {
|
|
798
|
+
runCmd = runCmd.substring(1, runCmd.length - 1);
|
|
799
|
+
}
|
|
800
|
+
if (runCmd === '|') {
|
|
801
|
+
let block = [];
|
|
802
|
+
let j = i + 1;
|
|
803
|
+
while (j < lines.length) {
|
|
804
|
+
const next = lines[j];
|
|
805
|
+
const match = next.match(/^(\s+)(.*)/);
|
|
806
|
+
if (match && match[1].length > 4) {
|
|
807
|
+
block.push(next.trim());
|
|
808
|
+
j++;
|
|
809
|
+
} else {
|
|
810
|
+
break;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
runCmd = block.join('\n');
|
|
814
|
+
i = j - 1;
|
|
815
|
+
}
|
|
816
|
+
steps.push({
|
|
817
|
+
name: currentStepName || `run: ${runCmd.substring(0, 30)}`,
|
|
818
|
+
run: runCmd
|
|
819
|
+
});
|
|
820
|
+
currentStepName = '';
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (steps.length === 0) {
|
|
825
|
+
throw new Error('No steps found in the workflow file.');
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
console.log(chalk.cyan(`\nš [TNC Actions] Starting Local Workflow: "${workflowName}"\n`));
|
|
829
|
+
|
|
830
|
+
const stepResults = [];
|
|
831
|
+
let overallPassed = true;
|
|
832
|
+
let logsBuffer = '';
|
|
833
|
+
|
|
834
|
+
for (let s = 0; s < steps.length; s++) {
|
|
835
|
+
const step = steps[s];
|
|
836
|
+
console.log(chalk.cyan(`āļø [Step ${s+1}/${steps.length}] "${step.name}"`));
|
|
837
|
+
console.log(chalk.gray(`$ ${step.run}`));
|
|
838
|
+
|
|
839
|
+
// Run command shell execution
|
|
840
|
+
const exitCode = await new Promise((resolve) => {
|
|
841
|
+
const shellCmd = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh';
|
|
842
|
+
const shellArgs = process.platform === 'win32' ? ['/s', '/c', step.run] : ['-c', step.run];
|
|
843
|
+
|
|
844
|
+
const proc = spawn(shellCmd, shellArgs, {
|
|
845
|
+
cwd: projectRoot,
|
|
846
|
+
env: { ...process.env, PATH: process.env.PATH, PUPPETEER_SKIP_DOWNLOAD: 'true' }
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
proc.stdout.on('data', (chunk) => {
|
|
850
|
+
const chunkStr = chunk.toString();
|
|
851
|
+
process.stdout.write(chunkStr);
|
|
852
|
+
logsBuffer += chunkStr;
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
proc.stderr.on('data', (chunk) => {
|
|
856
|
+
const chunkStr = chunk.toString();
|
|
857
|
+
process.stderr.write(chalk.red(chunkStr));
|
|
858
|
+
logsBuffer += chunkStr;
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
proc.on('close', (code) => {
|
|
862
|
+
resolve(code);
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
const stepPassed = (exitCode === 0);
|
|
867
|
+
stepResults.push({
|
|
868
|
+
name: step.name,
|
|
869
|
+
command: step.run,
|
|
870
|
+
exitCode,
|
|
871
|
+
passed: stepPassed
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
if (!stepPassed) {
|
|
875
|
+
overallPassed = false;
|
|
876
|
+
console.log(chalk.red(`\nā Step "${step.name}" failed with exit code: ${exitCode}. Aborting.\n`));
|
|
877
|
+
break;
|
|
878
|
+
} else {
|
|
879
|
+
console.log(chalk.green(`\nā
Step "${step.name}" passed successfully.\n`));
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
result = {
|
|
884
|
+
exitCode: overallPassed ? 0 : 1,
|
|
885
|
+
stdout: logsBuffer,
|
|
886
|
+
stderr: '',
|
|
887
|
+
output: {
|
|
888
|
+
workflow: workflowName,
|
|
889
|
+
status: overallPassed ? 'passed' : 'failed',
|
|
890
|
+
stepsCount: steps.length,
|
|
891
|
+
stepsRun: stepResults
|
|
892
|
+
},
|
|
893
|
+
json: {
|
|
894
|
+
workflow: workflowName,
|
|
895
|
+
status: overallPassed ? 'passed' : 'failed',
|
|
896
|
+
stepsCount: steps.length,
|
|
897
|
+
stepsRun: stepResults
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
|
|
721
901
|
} else {
|
|
722
902
|
console.log(chalk.red(`ā Unsupported test type: ${testConfig.type}`));
|
|
723
903
|
return;
|
|
@@ -140,6 +140,9 @@ console.log(` ${chalk.dim('id ')} ${chalk.dim(task._id)}${task.taskID ? c
|
|
|
140
140
|
if (tc.type === 'regex')
|
|
141
141
|
console.log(` ${chalk.dim('expect ')} regex ${chalk.green(tc.expect?.regex || '')}`);
|
|
142
142
|
|
|
143
|
+
if (tc.type === 'tnc-build')
|
|
144
|
+
console.log(` ${chalk.dim('runner ')} ${chalk.green('Local tnc-ci.yml workflow execution')}`);
|
|
145
|
+
|
|
143
146
|
} else {
|
|
144
147
|
console.log(chalk.yellow('\n ā ļø No test config ā task will need manual completion'));
|
|
145
148
|
console.log(chalk.dim(' Use: complete <task-id>'));
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/core/local-runner.js
|
|
3
|
+
* Executes the local .tnc/workflows/*.yml when triggered from browser via thinknsh socket.
|
|
4
|
+
* Reads workflow from process.cwd() (user's project directory), runs each step,
|
|
5
|
+
* streams logs to terminal, and submits verdict back to TNC via webhook.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { spawn } = require('child_process');
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
|
|
13
|
+
function getFetch() {
|
|
14
|
+
return globalThis.fetch || require('node-fetch');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse .tnc/workflows/*.yml into an array of { name, run } step objects.
|
|
19
|
+
*/
|
|
20
|
+
function parseWorkflow(ymlContent) {
|
|
21
|
+
const lines = ymlContent.split('\n');
|
|
22
|
+
let workflowName = 'TNC Local CI';
|
|
23
|
+
let steps = [];
|
|
24
|
+
let currentStepName = '';
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < lines.length; i++) {
|
|
27
|
+
const line = lines[i].trim();
|
|
28
|
+
|
|
29
|
+
if (!line || line.startsWith('#') || line.startsWith('on:') ||
|
|
30
|
+
line.startsWith('jobs:') || line.startsWith('build:') ||
|
|
31
|
+
line.startsWith('runs-on:') || line.startsWith('uses:')) continue;
|
|
32
|
+
|
|
33
|
+
if (line.startsWith('name:') && !currentStepName) {
|
|
34
|
+
workflowName = line.slice(5).trim().replace(/['"]/g, '');
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (line.startsWith('- name:')) {
|
|
39
|
+
currentStepName = line.slice(7).trim().replace(/['"]/g, '');
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (line.startsWith('name:') && currentStepName === '') {
|
|
44
|
+
currentStepName = line.slice(5).trim().replace(/['"]/g, '');
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (line.startsWith('- run:') || line.startsWith('run:')) {
|
|
49
|
+
const prefix = line.startsWith('- run:') ? '- run:' : 'run:';
|
|
50
|
+
let runCmd = line.slice(prefix.length).trim();
|
|
51
|
+
|
|
52
|
+
if (runCmd === '|') {
|
|
53
|
+
// Multiline block
|
|
54
|
+
const block = [];
|
|
55
|
+
let j = i + 1;
|
|
56
|
+
while (j < lines.length) {
|
|
57
|
+
const next = lines[j];
|
|
58
|
+
const m = next.match(/^(\s+)(.*)/);
|
|
59
|
+
if (m && m[1].length >= 10) { block.push(next.trim()); j++; }
|
|
60
|
+
else break;
|
|
61
|
+
}
|
|
62
|
+
runCmd = block.join('\n');
|
|
63
|
+
i = j - 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (runCmd) {
|
|
67
|
+
steps.push({ name: currentStepName || runCmd.slice(0, 40), run: runCmd });
|
|
68
|
+
currentStepName = '';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { workflowName, steps };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Run a single shell command, stream output to terminal, resolve with exitCode.
|
|
78
|
+
*/
|
|
79
|
+
function runStep(cmd, cwd, onData) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
const isWin = process.platform === 'win32';
|
|
82
|
+
const shell = isWin ? 'cmd.exe' : '/bin/sh';
|
|
83
|
+
const args = isWin ? ['/s', '/c', cmd] : ['-c', cmd];
|
|
84
|
+
const proc = spawn(shell, args, {
|
|
85
|
+
cwd,
|
|
86
|
+
env: { ...process.env, PUPPETEER_SKIP_DOWNLOAD: 'true' }
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
proc.stdout.on('data', d => onData(d.toString()));
|
|
90
|
+
proc.stderr.on('data', d => onData(d.toString()));
|
|
91
|
+
proc.on('close', code => resolve(code ?? 0));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Submit result back to TNC via webhook.
|
|
97
|
+
*/
|
|
98
|
+
async function submitResult(apiUrl, taskId, webhookSecret, result) {
|
|
99
|
+
const fetch = getFetch();
|
|
100
|
+
const body = JSON.stringify(result);
|
|
101
|
+
const sig = 'sha256=' + crypto.createHmac('sha256', webhookSecret).update(body).digest('hex');
|
|
102
|
+
const res = await fetch(`${apiUrl}/webhooks/tasks/${taskId}/judge`, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: { 'Content-Type': 'application/json', 'x-tnc-signature': sig },
|
|
105
|
+
body,
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
const e = await res.json().catch(() => ({}));
|
|
109
|
+
throw new Error(e.error || `Webhook ${res.status}`);
|
|
110
|
+
}
|
|
111
|
+
return res.json();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Main entry ā triggered when server emits task:run-local to CLI socket.
|
|
116
|
+
* @param {Object} opts { taskId, webhookSecret, apiUrl, chalk, shell }
|
|
117
|
+
*/
|
|
118
|
+
async function runLocal({ taskId, webhookSecret, apiUrl, chalk, shell }) {
|
|
119
|
+
const projectRoot = process.cwd();
|
|
120
|
+
const workflowsDir = path.join(projectRoot, '.tnc', 'workflows');
|
|
121
|
+
|
|
122
|
+
console.log(chalk.cyan(`\nš” TNC Build triggered for task: ${taskId}`));
|
|
123
|
+
console.log(chalk.dim(` Project: ${projectRoot}`));
|
|
124
|
+
|
|
125
|
+
// 1. Find workflow file
|
|
126
|
+
if (!fs.existsSync(workflowsDir)) {
|
|
127
|
+
console.log(chalk.red(`ā No .tnc/workflows/ directory found in ${projectRoot}`));
|
|
128
|
+
console.log(chalk.yellow(` Create one: mkdir -p .tnc/workflows && touch .tnc/workflows/tnc-ci.yml`));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const files = fs.readdirSync(workflowsDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
133
|
+
if (!files.length) {
|
|
134
|
+
console.log(chalk.red('ā No .yml workflow files found in .tnc/workflows/'));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const workflowFile = files[0];
|
|
139
|
+
const content = fs.readFileSync(path.join(workflowsDir, workflowFile), 'utf8');
|
|
140
|
+
const { workflowName, steps } = parseWorkflow(content);
|
|
141
|
+
|
|
142
|
+
if (!steps.length) {
|
|
143
|
+
console.log(chalk.red(`ā No runnable steps found in ${workflowFile}`));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log(chalk.cyan(`\nš [TNC Actions] Running: "${workflowName}" (${workflowFile})`));
|
|
148
|
+
console.log(chalk.dim(` ${steps.length} step(s)\n`));
|
|
149
|
+
|
|
150
|
+
// 2. Execute each step
|
|
151
|
+
const stepResults = [];
|
|
152
|
+
let overallPassed = true;
|
|
153
|
+
let logsBuffer = '';
|
|
154
|
+
|
|
155
|
+
for (let s = 0; s < steps.length; s++) {
|
|
156
|
+
const step = steps[s];
|
|
157
|
+
console.log(chalk.cyan(`āļø [Step ${s + 1}/${steps.length}] "${step.name}"`));
|
|
158
|
+
console.log(chalk.gray(`$ ${step.run}\n`));
|
|
159
|
+
|
|
160
|
+
const onData = chunk => {
|
|
161
|
+
process.stdout.write(chunk);
|
|
162
|
+
logsBuffer += chunk;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const exitCode = await runStep(step.run, projectRoot, onData);
|
|
166
|
+
const stepPassed = exitCode === 0;
|
|
167
|
+
|
|
168
|
+
stepResults.push({ name: step.name, command: step.run, exitCode, passed: stepPassed });
|
|
169
|
+
|
|
170
|
+
if (stepPassed) {
|
|
171
|
+
console.log(chalk.green(`\nā
"${step.name}" passed\n`));
|
|
172
|
+
} else {
|
|
173
|
+
overallPassed = false;
|
|
174
|
+
console.log(chalk.red(`\nā "${step.name}" failed (exit ${exitCode}). Aborting.\n`));
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 3. Print summary
|
|
180
|
+
const border = overallPassed ? chalk.green('ā'.repeat(50)) : chalk.red('ā'.repeat(50));
|
|
181
|
+
const badge = overallPassed ? chalk.bgGreen.black(' PASS ') : chalk.bgRed.white(' FAIL ');
|
|
182
|
+
console.log(border);
|
|
183
|
+
console.log(` ${badge} ${workflowName}`);
|
|
184
|
+
console.log(border + '\n');
|
|
185
|
+
|
|
186
|
+
// 4. Submit verdict to TNC via webhook
|
|
187
|
+
const result = {
|
|
188
|
+
exitCode: overallPassed ? 0 : 1,
|
|
189
|
+
stdout: logsBuffer,
|
|
190
|
+
stderr: '',
|
|
191
|
+
output: {
|
|
192
|
+
workflow: workflowName,
|
|
193
|
+
status: overallPassed ? 'passed' : 'failed',
|
|
194
|
+
stepsCount: steps.length,
|
|
195
|
+
stepsRun: stepResults
|
|
196
|
+
},
|
|
197
|
+
json: {
|
|
198
|
+
workflow: workflowName,
|
|
199
|
+
status: overallPassed ? 'passed' : 'failed',
|
|
200
|
+
stepsCount: steps.length,
|
|
201
|
+
stepsRun: stepResults
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
console.log(chalk.dim(' Syncing verdict with TNC...'));
|
|
207
|
+
await submitResult(apiUrl, taskId, webhookSecret, result);
|
|
208
|
+
console.log(chalk.green(' ā Verdict saved ā task updated on TNC!\n'));
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.log(chalk.yellow(` ā ļø Sync failed: ${err.message}\n`));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 5. Push notification
|
|
214
|
+
if (shell && shell.pushNotification) {
|
|
215
|
+
shell.pushNotification({
|
|
216
|
+
type: 'verdict',
|
|
217
|
+
passed: overallPassed,
|
|
218
|
+
title: workflowName,
|
|
219
|
+
reason: overallPassed ? 'TNC Local CI pipeline passed' : `Failed at: ${stepResults[stepResults.length - 1]?.name}`,
|
|
220
|
+
taskId,
|
|
221
|
+
at: new Date().toISOString(),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { runLocal };
|
package/src/core/shell.js
CHANGED
|
@@ -43,7 +43,7 @@ class ThinkNCollabShell extends EventEmitter {
|
|
|
43
43
|
|
|
44
44
|
fs.ensureDirSync(this.config.configDir);
|
|
45
45
|
|
|
46
|
-
this.ws = new WebSocketManager(this.config.websocket);
|
|
46
|
+
this.ws = new WebSocketManager({ ...this.config.websocket, _shellRef: this });
|
|
47
47
|
this.currentDir = process.cwd();
|
|
48
48
|
this.username = os.userInfo().username;
|
|
49
49
|
this.history = [];
|
package/src/core/websocket.js
CHANGED
|
@@ -25,6 +25,7 @@ class WebSocketManager extends EventEmitter {
|
|
|
25
25
|
this._authToken = null;
|
|
26
26
|
this._handlersSetup = false;
|
|
27
27
|
this._serverReady = false;
|
|
28
|
+
this._shellRef = config._shellRef || null; // ref to Shell instance for api access
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
// āāā Connect āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
@@ -78,6 +79,47 @@ class WebSocketManager extends EventEmitter {
|
|
|
78
79
|
this.emit('task:verdict', data);
|
|
79
80
|
});
|
|
80
81
|
|
|
82
|
+
// Triggered by TNC server when user clicks "run test" on browser (production)
|
|
83
|
+
// CLI reads .tnc/workflows/*.yml from process.cwd() (client's project dir) and runs it locally
|
|
84
|
+
this.socket.on('task:run-local', async (data) => {
|
|
85
|
+
try {
|
|
86
|
+
const chalk = require('chalk');
|
|
87
|
+
const { runLocal } = require('./local-runner');
|
|
88
|
+
const taskId = data.taskId;
|
|
89
|
+
const shellRef = this._shellRef;
|
|
90
|
+
const api = shellRef ? shellRef.api : null;
|
|
91
|
+
|
|
92
|
+
if (!api) {
|
|
93
|
+
console.log(chalk.red('\nā CLI not authenticated. Run: login\n'));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Fetch task info (webhookSecret) from TNC API
|
|
98
|
+
let task;
|
|
99
|
+
try {
|
|
100
|
+
task = await api._request('GET', `/thinknsh/${taskId}/cli`);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
console.log(chalk.red(`\nā Could not fetch task info: ${e.message}\n`));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!task || !task.webhookSecret) {
|
|
107
|
+
console.log(chalk.red('\nā Task has no webhookSecret ā cannot submit result.\n'));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await runLocal({
|
|
112
|
+
taskId,
|
|
113
|
+
webhookSecret: task.webhookSecret,
|
|
114
|
+
apiUrl: this.config.serverUrl || 'https://thinkncollab.com',
|
|
115
|
+
chalk,
|
|
116
|
+
shell: shellRef,
|
|
117
|
+
});
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.error('task:run-local error:', err.message);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
81
123
|
this.socket.on('reconnect', () => {
|
|
82
124
|
this.connected = true;
|
|
83
125
|
this.emit('reconnected', {});
|