thinkncollab-shell 0.0.14 → 0.0.16

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.16",
4
4
  "description": "ThinkNCollab Custom Shell with WebSocket collaboration",
5
5
  "main": "src/core/shell.js",
6
6
  "bin": {
@@ -0,0 +1,272 @@
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, onLog }) {
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
+ // Stream initial workflow setup
151
+ if (onLog) {
152
+ onLog({
153
+ workflowName,
154
+ file: workflowFile,
155
+ steps: steps.map(st => ({ name: st.name })),
156
+ data: `\nšŸš€ [TNC Actions] Starting Local Workflow: "${workflowName}" (${workflowFile})\n`
157
+ });
158
+ }
159
+
160
+ // 2. Execute each step
161
+ const stepResults = [];
162
+ let overallPassed = true;
163
+ let logsBuffer = '';
164
+
165
+ for (let s = 0; s < steps.length; s++) {
166
+ const step = steps[s];
167
+ console.log(chalk.cyan(`āš™ļø [Step ${s + 1}/${steps.length}] "${step.name}"`));
168
+ console.log(chalk.gray(`$ ${step.run}\n`));
169
+
170
+ const stepHeader = `\nāš™ļø [Step ${s + 1}/${steps.length}] "${step.name}"\n$ ${step.run}\n`;
171
+ logsBuffer += stepHeader;
172
+
173
+ if (onLog) {
174
+ onLog({
175
+ stepIndex: s,
176
+ state: 'running',
177
+ data: stepHeader
178
+ });
179
+ }
180
+
181
+ const onData = chunk => {
182
+ process.stdout.write(chunk);
183
+ logsBuffer += chunk;
184
+ if (onLog) {
185
+ onLog({
186
+ stepIndex: s,
187
+ state: 'running',
188
+ data: chunk
189
+ });
190
+ }
191
+ };
192
+
193
+ const exitCode = await runStep(step.run, projectRoot, onData);
194
+ const stepPassed = exitCode === 0;
195
+
196
+ stepResults.push({ name: step.name, command: step.run, exitCode, passed: stepPassed });
197
+
198
+ if (stepPassed) {
199
+ console.log(chalk.green(`\nāœ… "${step.name}" passed\n`));
200
+ const passMsg = `\nāœ… Step "${step.name}" passed successfully.\n`;
201
+ logsBuffer += passMsg;
202
+ if (onLog) {
203
+ onLog({
204
+ stepIndex: s,
205
+ state: 'passed',
206
+ data: passMsg
207
+ });
208
+ }
209
+ } else {
210
+ overallPassed = false;
211
+ console.log(chalk.red(`\nāŒ "${step.name}" failed (exit ${exitCode}). Aborting.\n`));
212
+ const failMsg = `\nāŒ Step "${step.name}" failed with exit code: ${exitCode}. Aborting workflow.\n`;
213
+ logsBuffer += failMsg;
214
+ if (onLog) {
215
+ onLog({
216
+ stepIndex: s,
217
+ state: 'failed',
218
+ data: failMsg
219
+ });
220
+ }
221
+ break;
222
+ }
223
+ }
224
+
225
+ // 3. Print summary
226
+ const border = overallPassed ? chalk.green('─'.repeat(50)) : chalk.red('─'.repeat(50));
227
+ const badge = overallPassed ? chalk.bgGreen.black(' PASS ') : chalk.bgRed.white(' FAIL ');
228
+ console.log(border);
229
+ console.log(` ${badge} ${workflowName}`);
230
+ console.log(border + '\n');
231
+
232
+ // 4. Submit verdict to TNC via webhook
233
+ const result = {
234
+ exitCode: overallPassed ? 0 : 1,
235
+ stdout: logsBuffer,
236
+ stderr: '',
237
+ output: {
238
+ workflow: workflowName,
239
+ status: overallPassed ? 'passed' : 'failed',
240
+ stepsCount: steps.length,
241
+ stepsRun: stepResults
242
+ },
243
+ json: {
244
+ workflow: workflowName,
245
+ status: overallPassed ? 'passed' : 'failed',
246
+ stepsCount: steps.length,
247
+ stepsRun: stepResults
248
+ }
249
+ };
250
+
251
+ try {
252
+ console.log(chalk.dim(' Syncing verdict with TNC...'));
253
+ await submitResult(apiUrl, taskId, webhookSecret, result);
254
+ console.log(chalk.green(' āœ“ Verdict saved — task updated on TNC!\n'));
255
+ } catch (err) {
256
+ console.log(chalk.yellow(` āš ļø Sync failed: ${err.message}\n`));
257
+ }
258
+
259
+ // 5. Push notification
260
+ if (shell && shell.pushNotification) {
261
+ shell.pushNotification({
262
+ type: 'verdict',
263
+ passed: overallPassed,
264
+ title: workflowName,
265
+ reason: overallPassed ? 'TNC Local CI pipeline passed' : `Failed at: ${stepResults[stepResults.length - 1]?.name}`,
266
+ taskId,
267
+ at: new Date().toISOString(),
268
+ });
269
+ }
270
+ }
271
+
272
+ module.exports = { runLocal };
@@ -86,6 +86,15 @@ function printNotification(data) {
86
86
  console.log(`${ts} šŸ“¢ ${color(`${data.title}: ${data.message}`)}`);
87
87
  break;
88
88
  }
89
+ case 'verdict':
90
+ beep();
91
+ {
92
+ const color = data.passed ? chalk.green : chalk.red;
93
+ const icon = data.passed ? 'āœ…' : 'āŒ';
94
+ const label = data.passed ? 'PASSED' : 'FAILED';
95
+ console.log(`${ts} ${icon} ${color(`Build Verdict [${label}]`)}: ${chalk.white(data.reason || '')}`);
96
+ }
97
+ break;
89
98
  case 'typing':
90
99
  beep();
91
100
  process.stdout.write(`\r${chalk.dim(`āœļø ${data.username} is typing... `)}`);
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,55 @@ 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
+ onLog: (logData) => {
118
+ // Emit log stream to server via the terminal socket
119
+ this.socket.emit('task:log', {
120
+ taskId,
121
+ boardId: task.boardId,
122
+ ...logData
123
+ });
124
+ }
125
+ });
126
+ } catch (err) {
127
+ console.error('task:run-local error:', err.message);
128
+ }
129
+ });
130
+
81
131
  this.socket.on('reconnect', () => {
82
132
  this.connected = true;
83
133
  this.emit('reconnected', {});