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 +1 -1
- package/src/core/local-runner.js +226 -0
- package/src/core/shell.js +1 -1
- package/src/core/websocket.js +42 -0
package/package.json
CHANGED
|
@@ -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', {});
|