thinkncollab-shell 0.0.14 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkncollab-shell",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "description": "ThinkNCollab Custom Shell with WebSocket collaboration",
5
5
  "main": "src/core/shell.js",
6
6
  "bin": {
@@ -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', {});