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 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
- - [`.tncproject` Auto-Boot](#tncproject-auto-boot)
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
- - šŸ”‘ **`.tncproject` Auto-Boot** — Drop a config file in your project root to auto-login and join a room on shell start
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
- ## `.tncproject` Auto-Boot
446
+ ## `tncproject` Auto-Boot
447
447
 
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.
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/.tncproject
459
+ File : /home/user/myproject/tncproject
460
460
 
461
461
  āœ… Raman Singh → room "My Project Room"
462
462
  ```
463
463
 
464
- If `.tncproject` auth fails, the shell falls back to manual login.
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
- # .tncproject auto-boot (no login needed)
564
- cd ~/myproject # contains .tncproject
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkncollab-shell",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "description": "ThinkNCollab Custom Shell with WebSocket collaboration",
5
5
  "main": "src/core/shell.js",
6
6
  "bin": {
@@ -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 = [];
@@ -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', {});