retold 4.0.2 → 4.0.4
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/.claude/settings.local.json +37 -1
- package/README.md +112 -2
- package/docs/_sidebar.md +4 -1
- package/docs/architecture/architecture.md +2 -2
- package/docs/architecture/comprehensions.md +282 -0
- package/docs/architecture/fluid-models.md +355 -0
- package/docs/architecture/modules.md +3 -4
- package/docs/contributing.md +50 -0
- package/docs/modules/orator.md +0 -7
- package/docs/retold-catalog.json +3944 -878
- package/docs/retold-keyword-index.json +174010 -144308
- package/docs/testing.md +122 -0
- package/modules/Include-Retold-Module-List.sh +1 -1
- package/package.json +7 -4
- package/source/retold-manager/package.json +23 -0
- package/source/retold-manager/retold-manager.js +65 -0
- package/source/retold-manager/source/Retold-Manager-App.js +1532 -0
- package/source/retold-manager/source/Retold-Manager-ModuleCatalog.js +75 -0
- package/source/retold-manager/source/Retold-Manager-ProcessRunner.js +706 -0
- package/source/retold-manager/source/views/PictView-TUI-Checkout.js +45 -0
- package/source/retold-manager/source/views/PictView-TUI-Header.js +41 -0
- package/source/retold-manager/source/views/PictView-TUI-Layout.js +53 -0
- package/source/retold-manager/source/views/PictView-TUI-Status.js +45 -0
- package/source/retold-manager/source/views/PictView-TUI-StatusBar.js +41 -0
- package/source/retold-manager/source/views/PictView-TUI-Update.js +45 -0
- package/examples/quickstart/layer1/package-lock.json +0 -344
- package/examples/quickstart/layer2/package-lock.json +0 -4468
- package/examples/quickstart/layer3/package-lock.json +0 -1936
- package/examples/quickstart/layer4/package-lock.json +0 -13206
- package/examples/quickstart/layer5/package-lock.json +0 -345
- package/examples/todo-list/cli-client/package-lock.json +0 -418
- package/examples/todo-list/console-client/package-lock.json +0 -426
- package/examples/todo-list/server/package-lock.json +0 -6113
- package/examples/todo-list/web-client/package-lock.json +0 -12030
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retold Manager -- Process Runner
|
|
3
|
+
*
|
|
4
|
+
* Wraps child_process.spawn to run commands in a module directory and
|
|
5
|
+
* stream output to a blessed log widget.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const libChildProcess = require('child_process');
|
|
9
|
+
|
|
10
|
+
// Regex to strip ANSI escape codes (covers standard, 256-color, and truecolor sequences)
|
|
11
|
+
const ANSI_REGEX = /\x1B(?:\[[0-9;]*[a-zA-Z]|\].*?(?:\x07|\x1B\\)|\([B0])/g;
|
|
12
|
+
|
|
13
|
+
// Lines to show live at the start of a run before switching to buffer-only mode
|
|
14
|
+
const HEAD_LINE_LIMIT = 80;
|
|
15
|
+
|
|
16
|
+
// Minimum milliseconds between screen renders during streaming output
|
|
17
|
+
const RENDER_THROTTLE_MS = 50;
|
|
18
|
+
|
|
19
|
+
// Minimum milliseconds between status-bar updates while buffering
|
|
20
|
+
const STATUS_THROTTLE_MS = 250;
|
|
21
|
+
|
|
22
|
+
class ProcessRunner
|
|
23
|
+
{
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} pLogWidget - A blessed.log widget instance.
|
|
26
|
+
* @param {object} pScreen - The blessed screen instance.
|
|
27
|
+
* @param {function} pStatusCallback - Called with (pState, pMessage) for status updates.
|
|
28
|
+
* @param {object} pLog - A fable-log instance for activity logging.
|
|
29
|
+
*/
|
|
30
|
+
constructor(pLogWidget, pScreen, pStatusCallback, pLog)
|
|
31
|
+
{
|
|
32
|
+
this.logWidget = pLogWidget;
|
|
33
|
+
this.screen = pScreen;
|
|
34
|
+
this.statusCallback = pStatusCallback || function () {};
|
|
35
|
+
this.log = pLog || null;
|
|
36
|
+
this.activeProcess = null;
|
|
37
|
+
|
|
38
|
+
// Render throttle state
|
|
39
|
+
this._renderTimer = null;
|
|
40
|
+
this._renderPending = false;
|
|
41
|
+
|
|
42
|
+
// Output buffering: all lines go into _outputBuffer.
|
|
43
|
+
// The first HEAD_LINE_LIMIT lines are also sent live to the widget.
|
|
44
|
+
// After that, lines accumulate in the buffer only.
|
|
45
|
+
// On completion, the full buffer is loaded into the widget for scrolling.
|
|
46
|
+
this._outputBuffer = [];
|
|
47
|
+
this._headLineCount = 0;
|
|
48
|
+
this._buffering = false;
|
|
49
|
+
|
|
50
|
+
// Throttle for status-bar line-count updates while buffering
|
|
51
|
+
this._statusTimer = null;
|
|
52
|
+
|
|
53
|
+
// Timer for measuring operation duration
|
|
54
|
+
this._operationStartTime = null;
|
|
55
|
+
this._operationCommand = '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Format elapsed milliseconds into a human-readable duration string.
|
|
60
|
+
* Examples: "1.2s", "1m 23s", "2h 5m 12s"
|
|
61
|
+
*
|
|
62
|
+
* @param {number} pMilliseconds - Elapsed time in milliseconds.
|
|
63
|
+
* @returns {string} Formatted duration.
|
|
64
|
+
*/
|
|
65
|
+
_formatDuration(pMilliseconds)
|
|
66
|
+
{
|
|
67
|
+
let tmpSeconds = Math.floor(pMilliseconds / 1000);
|
|
68
|
+
let tmpMs = pMilliseconds % 1000;
|
|
69
|
+
|
|
70
|
+
if (tmpSeconds < 60)
|
|
71
|
+
{
|
|
72
|
+
return `${tmpSeconds}.${String(tmpMs).padStart(3, '0').slice(0, 1)}s`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let tmpMinutes = Math.floor(tmpSeconds / 60);
|
|
76
|
+
tmpSeconds = tmpSeconds % 60;
|
|
77
|
+
|
|
78
|
+
if (tmpMinutes < 60)
|
|
79
|
+
{
|
|
80
|
+
return `${tmpMinutes}m ${tmpSeconds}s`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let tmpHours = Math.floor(tmpMinutes / 60);
|
|
84
|
+
tmpMinutes = tmpMinutes % 60;
|
|
85
|
+
return `${tmpHours}h ${tmpMinutes}m ${tmpSeconds}s`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Schedule a screen render, throttled to avoid hammering blessed
|
|
90
|
+
* on fast-streaming output.
|
|
91
|
+
*/
|
|
92
|
+
_scheduleRender()
|
|
93
|
+
{
|
|
94
|
+
if (this._renderTimer)
|
|
95
|
+
{
|
|
96
|
+
this._renderPending = true;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.screen.render();
|
|
101
|
+
|
|
102
|
+
this._renderTimer = setTimeout(() =>
|
|
103
|
+
{
|
|
104
|
+
this._renderTimer = null;
|
|
105
|
+
if (this._renderPending)
|
|
106
|
+
{
|
|
107
|
+
this._renderPending = false;
|
|
108
|
+
this.screen.render();
|
|
109
|
+
}
|
|
110
|
+
}, RENDER_THROTTLE_MS);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Buffer a line and, if we are still in the head phase, also send it
|
|
115
|
+
* to the widget for live display. Once HEAD_LINE_LIMIT is reached
|
|
116
|
+
* we switch to buffer-only mode and periodically update the status bar
|
|
117
|
+
* with the running line count.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} pLine - The already-escaped line to log.
|
|
120
|
+
*/
|
|
121
|
+
_logLine(pLine)
|
|
122
|
+
{
|
|
123
|
+
this._outputBuffer.push(pLine);
|
|
124
|
+
|
|
125
|
+
if (!this._buffering)
|
|
126
|
+
{
|
|
127
|
+
// Still in the live-output head phase
|
|
128
|
+
this._headLineCount++;
|
|
129
|
+
this.logWidget.log(pLine);
|
|
130
|
+
this._scheduleRender();
|
|
131
|
+
|
|
132
|
+
if (this._headLineCount >= HEAD_LINE_LIMIT)
|
|
133
|
+
{
|
|
134
|
+
this._buffering = true;
|
|
135
|
+
this.logWidget.log('');
|
|
136
|
+
this.logWidget.log('{yellow-fg}{bold}... buffering remaining output (scrollable when complete){/bold}{/yellow-fg}');
|
|
137
|
+
this._scheduleRender();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else
|
|
141
|
+
{
|
|
142
|
+
// Buffer-only mode -- no widget writes, no screen renders.
|
|
143
|
+
// Just update the status bar periodically so the user sees progress.
|
|
144
|
+
if (!this._statusTimer)
|
|
145
|
+
{
|
|
146
|
+
this._statusTimer = setTimeout(() =>
|
|
147
|
+
{
|
|
148
|
+
this._statusTimer = null;
|
|
149
|
+
this.statusCallback('running', `${this._operationCommand} (${this._outputBuffer.length} lines)`);
|
|
150
|
+
}, STATUS_THROTTLE_MS);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* After the process exits, load the full output buffer into the widget
|
|
157
|
+
* so the user can scroll through everything. Uses a single setContent()
|
|
158
|
+
* call to avoid per-line rendering overhead.
|
|
159
|
+
*/
|
|
160
|
+
_flushBuffer()
|
|
161
|
+
{
|
|
162
|
+
if (!this._buffering)
|
|
163
|
+
{
|
|
164
|
+
// Everything was already displayed live -- nothing to flush
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Build the full content as one string and set it in a single call
|
|
169
|
+
this.logWidget.setContent(this._outputBuffer.join('\n'));
|
|
170
|
+
|
|
171
|
+
// Scroll to the bottom so the user sees the tail / completion block
|
|
172
|
+
this.logWidget.setScrollPerc(100);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Run a command in the given working directory.
|
|
177
|
+
* Kills any currently running process first.
|
|
178
|
+
*
|
|
179
|
+
* Output strategy: the first HEAD_LINE_LIMIT lines are displayed live.
|
|
180
|
+
* After that, lines buffer in memory (no widget pressure). When the
|
|
181
|
+
* process exits the full buffer is loaded into the widget for scrolling.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} pCommand - The command to run (e.g. 'npm', 'git').
|
|
184
|
+
* @param {Array} pArgs - Arguments array (e.g. ['test']).
|
|
185
|
+
* @param {string} pCwd - Working directory for the command.
|
|
186
|
+
* @param {number} pLineLimit - (ignored, kept for API compat).
|
|
187
|
+
* @param {object} pOptions - Optional settings: { append: true } to keep existing widget content.
|
|
188
|
+
*/
|
|
189
|
+
run(pCommand, pArgs, pCwd, pLineLimit, pOptions)
|
|
190
|
+
{
|
|
191
|
+
// Kill any running process first
|
|
192
|
+
this.kill();
|
|
193
|
+
|
|
194
|
+
// Reset buffer state for this run
|
|
195
|
+
this._outputBuffer = [];
|
|
196
|
+
this._headLineCount = 0;
|
|
197
|
+
this._buffering = false;
|
|
198
|
+
if (this._statusTimer)
|
|
199
|
+
{
|
|
200
|
+
clearTimeout(this._statusTimer);
|
|
201
|
+
this._statusTimer = null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Clear any active search state so the new output is not filtered
|
|
205
|
+
this._searchQuery = '';
|
|
206
|
+
this._searchMatches = null;
|
|
207
|
+
this._searchMatchIndex = -1;
|
|
208
|
+
this._searchResultLines = null;
|
|
209
|
+
|
|
210
|
+
// Clear the log widget unless caller asked to append
|
|
211
|
+
if (!pOptions || !pOptions.append)
|
|
212
|
+
{
|
|
213
|
+
this.logWidget.setContent('');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let tmpCommandString = pCommand + ' ' + pArgs.join(' ');
|
|
217
|
+
let tmpRunnable = `cd ${pCwd} && ${tmpCommandString}`;
|
|
218
|
+
|
|
219
|
+
this.logWidget.log(`{bold}{cyan-fg}$ ${tmpCommandString}{/cyan-fg}{/bold}`);
|
|
220
|
+
this.logWidget.log(`{gray-fg} cwd: ${pCwd}{/gray-fg}`);
|
|
221
|
+
this.logWidget.log(`{gray-fg} run: ${tmpRunnable}{/gray-fg}`);
|
|
222
|
+
this.logWidget.log('');
|
|
223
|
+
|
|
224
|
+
this.statusCallback('running', tmpCommandString);
|
|
225
|
+
|
|
226
|
+
// Activity log: record the start time
|
|
227
|
+
this._operationCommand = tmpCommandString;
|
|
228
|
+
this._operationCwd = pCwd;
|
|
229
|
+
this._operationStartTime = this.log ? this.log.getTimeStamp() : Date.now();
|
|
230
|
+
if (this.log)
|
|
231
|
+
{
|
|
232
|
+
this.log.info(`START ${tmpRunnable}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let tmpProcess;
|
|
236
|
+
|
|
237
|
+
try
|
|
238
|
+
{
|
|
239
|
+
tmpProcess = libChildProcess.spawn(pCommand, pArgs,
|
|
240
|
+
{
|
|
241
|
+
cwd: pCwd,
|
|
242
|
+
shell: true,
|
|
243
|
+
env: Object.assign({}, process.env, { NO_COLOR: '1', FORCE_COLOR: '0' })
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
catch (pError)
|
|
247
|
+
{
|
|
248
|
+
this.logWidget.log(`{red-fg}{bold}Failed to start process: ${pError.message}{/red-fg}{/bold}`);
|
|
249
|
+
this.statusCallback('error', pError.message);
|
|
250
|
+
this.screen.render();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
this.activeProcess = tmpProcess;
|
|
255
|
+
|
|
256
|
+
tmpProcess.stdout.on('data', (pData) =>
|
|
257
|
+
{
|
|
258
|
+
let tmpLines = pData.toString().split('\n');
|
|
259
|
+
for (let i = 0; i < tmpLines.length; i++)
|
|
260
|
+
{
|
|
261
|
+
if (tmpLines[i].length > 0)
|
|
262
|
+
{
|
|
263
|
+
// Strip ANSI codes then escape curly braces so blessed
|
|
264
|
+
// doesn't try to parse them as markup tags
|
|
265
|
+
let tmpLine = tmpLines[i].replace(ANSI_REGEX, '').replace(/\{/g, '\\{').replace(/\}/g, '\\}');
|
|
266
|
+
this._logLine(tmpLine);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
tmpProcess.stderr.on('data', (pData) =>
|
|
272
|
+
{
|
|
273
|
+
let tmpLines = pData.toString().split('\n');
|
|
274
|
+
for (let i = 0; i < tmpLines.length; i++)
|
|
275
|
+
{
|
|
276
|
+
if (tmpLines[i].length > 0)
|
|
277
|
+
{
|
|
278
|
+
let tmpLine = tmpLines[i].replace(ANSI_REGEX, '').replace(/\{/g, '\\{').replace(/\}/g, '\\}');
|
|
279
|
+
this._logLine(`{red-fg}${tmpLine}{/red-fg}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
tmpProcess.on('close', (pCode) =>
|
|
285
|
+
{
|
|
286
|
+
this.activeProcess = null;
|
|
287
|
+
|
|
288
|
+
// Compute elapsed time
|
|
289
|
+
let tmpElapsed = this._operationStartTime ? (Date.now() - this._operationStartTime) : 0;
|
|
290
|
+
let tmpDuration = this._formatDuration(tmpElapsed);
|
|
291
|
+
|
|
292
|
+
// Activity log: record the result and duration
|
|
293
|
+
if (this.log && this._operationStartTime)
|
|
294
|
+
{
|
|
295
|
+
let tmpState = (pCode === 0) ? 'OK' : `FAIL(${pCode})`;
|
|
296
|
+
this.log.logTimeDeltaRelativeHuman(this._operationStartTime, `${tmpState} ${tmpRunnable}`);
|
|
297
|
+
this._operationStartTime = null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Flush the full buffer into the widget so the user can scroll
|
|
301
|
+
this._flushBuffer();
|
|
302
|
+
|
|
303
|
+
// Consistent completion block
|
|
304
|
+
this.logWidget.log('');
|
|
305
|
+
this.logWidget.log('{bold}────────────────────────────────────────{/bold}');
|
|
306
|
+
let tmpLineNote = this._outputBuffer.length > HEAD_LINE_LIMIT
|
|
307
|
+
? ` {gray-fg}(${this._outputBuffer.length} lines){/gray-fg}`
|
|
308
|
+
: '';
|
|
309
|
+
if (pCode === 0)
|
|
310
|
+
{
|
|
311
|
+
this.logWidget.log(`{green-fg}{bold}✓ Done{/bold} ${tmpCommandString} ({bold}${tmpDuration}{/bold}){/green-fg}${tmpLineNote}`);
|
|
312
|
+
this.statusCallback('success', `${tmpCommandString} -- ${tmpDuration}`);
|
|
313
|
+
}
|
|
314
|
+
else
|
|
315
|
+
{
|
|
316
|
+
this.logWidget.log(`{red-fg}{bold}✗ Failed (exit ${pCode}){/bold} ${tmpCommandString} ({bold}${tmpDuration}{/bold}){/red-fg}${tmpLineNote}`);
|
|
317
|
+
this.statusCallback('error', `${tmpCommandString} -- exit ${pCode} (${tmpDuration})`);
|
|
318
|
+
}
|
|
319
|
+
this.logWidget.setScrollPerc(100);
|
|
320
|
+
this.screen.render();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
tmpProcess.on('error', (pError) =>
|
|
324
|
+
{
|
|
325
|
+
this.activeProcess = null;
|
|
326
|
+
let tmpElapsed = this._operationStartTime ? (Date.now() - this._operationStartTime) : 0;
|
|
327
|
+
let tmpDuration = this._formatDuration(tmpElapsed);
|
|
328
|
+
if (this.log && this._operationStartTime)
|
|
329
|
+
{
|
|
330
|
+
this.log.logTimeDeltaRelativeHuman(this._operationStartTime, `ERROR ${tmpRunnable} ${pError.message}`);
|
|
331
|
+
this._operationStartTime = null;
|
|
332
|
+
}
|
|
333
|
+
this.logWidget.log('');
|
|
334
|
+
this.logWidget.log('{bold}────────────────────────────────────────{/bold}');
|
|
335
|
+
this.logWidget.log(`{red-fg}{bold}✗ Error{/bold} ${pError.message} ({bold}${tmpDuration}{/bold}){/red-fg}`);
|
|
336
|
+
this.statusCallback('error', `${pError.message} (${tmpDuration})`);
|
|
337
|
+
this.screen.render();
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Run a sequence of commands in order, appending output from each
|
|
343
|
+
* into the same log widget with separator headers between them.
|
|
344
|
+
*
|
|
345
|
+
* @param {Array} pCommands - Array of { command, args, label } objects.
|
|
346
|
+
* @param {string} pCwd - Working directory for all commands.
|
|
347
|
+
*/
|
|
348
|
+
runSequence(pCommands, pCwd)
|
|
349
|
+
{
|
|
350
|
+
// Kill any running process first
|
|
351
|
+
this.kill();
|
|
352
|
+
|
|
353
|
+
// Reset buffer state for this sequence
|
|
354
|
+
this._outputBuffer = [];
|
|
355
|
+
this._headLineCount = 0;
|
|
356
|
+
this._buffering = false;
|
|
357
|
+
if (this._statusTimer)
|
|
358
|
+
{
|
|
359
|
+
clearTimeout(this._statusTimer);
|
|
360
|
+
this._statusTimer = null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Clear any active search state so the new output is not filtered
|
|
364
|
+
this._searchQuery = '';
|
|
365
|
+
this._searchMatches = null;
|
|
366
|
+
this._searchMatchIndex = -1;
|
|
367
|
+
this._searchResultLines = null;
|
|
368
|
+
|
|
369
|
+
// Clear the log widget
|
|
370
|
+
this.logWidget.setContent('');
|
|
371
|
+
|
|
372
|
+
this.statusCallback('running', pCommands[0].label || (pCommands[0].command + ' ' + pCommands[0].args.join(' ')));
|
|
373
|
+
|
|
374
|
+
let tmpRunIndex = 0;
|
|
375
|
+
let tmpSelf = this;
|
|
376
|
+
let tmpSequenceStartTime = Date.now();
|
|
377
|
+
|
|
378
|
+
let fRunNext = function ()
|
|
379
|
+
{
|
|
380
|
+
if (tmpRunIndex >= pCommands.length)
|
|
381
|
+
{
|
|
382
|
+
let tmpTotalElapsed = Date.now() - tmpSequenceStartTime;
|
|
383
|
+
let tmpTotalDuration = tmpSelf._formatDuration(tmpTotalElapsed);
|
|
384
|
+
tmpSelf.logWidget.log('');
|
|
385
|
+
tmpSelf.logWidget.log('{bold}────────────────────────────────────────{/bold}');
|
|
386
|
+
tmpSelf.logWidget.log(`{green-fg}{bold}✓ Done{/bold} ${pCommands.length} commands ({bold}${tmpTotalDuration}{/bold}){/green-fg}`);
|
|
387
|
+
tmpSelf.statusCallback('success', `Sequence complete -- ${tmpTotalDuration}`);
|
|
388
|
+
tmpSelf.screen.render();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let tmpCmd = pCommands[tmpRunIndex];
|
|
393
|
+
let tmpCommandString = tmpCmd.command + ' ' + tmpCmd.args.join(' ');
|
|
394
|
+
let tmpRunnable = `cd ${pCwd} && ${tmpCommandString}`;
|
|
395
|
+
|
|
396
|
+
if (tmpRunIndex > 0)
|
|
397
|
+
{
|
|
398
|
+
tmpSelf.logWidget.log('');
|
|
399
|
+
tmpSelf.logWidget.log('{bold}{blue-fg}────────────────────────────────────────{/blue-fg}{/bold}');
|
|
400
|
+
tmpSelf.logWidget.log('');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (tmpCmd.label)
|
|
404
|
+
{
|
|
405
|
+
tmpSelf.logWidget.log(`{bold}{yellow-fg}${tmpCmd.label}{/yellow-fg}{/bold}`);
|
|
406
|
+
}
|
|
407
|
+
tmpSelf.logWidget.log(`{bold}{cyan-fg}$ ${tmpCommandString}{/cyan-fg}{/bold}`);
|
|
408
|
+
tmpSelf.logWidget.log(`{gray-fg} cwd: ${pCwd}{/gray-fg}`);
|
|
409
|
+
tmpSelf.logWidget.log(`{gray-fg} run: ${tmpRunnable}{/gray-fg}`);
|
|
410
|
+
tmpSelf.logWidget.log('');
|
|
411
|
+
tmpSelf.screen.render();
|
|
412
|
+
|
|
413
|
+
// Activity log: record the step start time
|
|
414
|
+
let tmpStepStartTime = tmpSelf.log ? tmpSelf.log.getTimeStamp() : Date.now();
|
|
415
|
+
if (tmpSelf.log)
|
|
416
|
+
{
|
|
417
|
+
tmpSelf.log.info(`START ${tmpRunnable}`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let tmpProcess;
|
|
421
|
+
|
|
422
|
+
try
|
|
423
|
+
{
|
|
424
|
+
tmpProcess = libChildProcess.spawn(tmpCmd.command, tmpCmd.args,
|
|
425
|
+
{
|
|
426
|
+
cwd: pCwd,
|
|
427
|
+
shell: true,
|
|
428
|
+
env: Object.assign({}, process.env, { NO_COLOR: '1', FORCE_COLOR: '0' })
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
catch (pError)
|
|
432
|
+
{
|
|
433
|
+
tmpSelf.logWidget.log(`{red-fg}{bold}Failed to start: ${pError.message}{/red-fg}{/bold}`);
|
|
434
|
+
tmpSelf.statusCallback('error', pError.message);
|
|
435
|
+
tmpSelf.screen.render();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
tmpSelf.activeProcess = tmpProcess;
|
|
440
|
+
|
|
441
|
+
tmpProcess.stdout.on('data', (pData) =>
|
|
442
|
+
{
|
|
443
|
+
let tmpLines = pData.toString().split('\n');
|
|
444
|
+
for (let i = 0; i < tmpLines.length; i++)
|
|
445
|
+
{
|
|
446
|
+
if (tmpLines[i].length > 0)
|
|
447
|
+
{
|
|
448
|
+
let tmpLine = tmpLines[i].replace(ANSI_REGEX, '').replace(/\{/g, '\\{').replace(/\}/g, '\\}');
|
|
449
|
+
tmpSelf._logLine(tmpLine);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
tmpProcess.stderr.on('data', (pData) =>
|
|
455
|
+
{
|
|
456
|
+
let tmpLines = pData.toString().split('\n');
|
|
457
|
+
for (let i = 0; i < tmpLines.length; i++)
|
|
458
|
+
{
|
|
459
|
+
if (tmpLines[i].length > 0)
|
|
460
|
+
{
|
|
461
|
+
let tmpLine = tmpLines[i].replace(ANSI_REGEX, '').replace(/\{/g, '\\{').replace(/\}/g, '\\}');
|
|
462
|
+
tmpSelf._logLine(`{red-fg}${tmpLine}{/red-fg}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
tmpProcess.on('close', (pCode) =>
|
|
468
|
+
{
|
|
469
|
+
tmpSelf.activeProcess = null;
|
|
470
|
+
|
|
471
|
+
// Compute step elapsed time
|
|
472
|
+
let tmpStepElapsed = tmpStepStartTime ? (Date.now() - tmpStepStartTime) : 0;
|
|
473
|
+
let tmpStepDuration = tmpSelf._formatDuration(tmpStepElapsed);
|
|
474
|
+
|
|
475
|
+
if (tmpSelf.log && tmpStepStartTime)
|
|
476
|
+
{
|
|
477
|
+
let tmpState = (pCode === 0) ? 'OK' : `FAIL(${pCode})`;
|
|
478
|
+
tmpSelf.log.logTimeDeltaRelativeHuman(tmpStepStartTime, `${tmpState} ${tmpRunnable}`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Per-step completion line
|
|
482
|
+
if (pCode === 0)
|
|
483
|
+
{
|
|
484
|
+
tmpSelf.logWidget.log(`{green-fg} ✓ ${tmpCommandString} ({bold}${tmpStepDuration}{/bold}){/green-fg}`);
|
|
485
|
+
}
|
|
486
|
+
else
|
|
487
|
+
{
|
|
488
|
+
tmpSelf.logWidget.log(`{red-fg} ✗ ${tmpCommandString} exit ${pCode} ({bold}${tmpStepDuration}{/bold}){/red-fg}`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
tmpRunIndex++;
|
|
492
|
+
fRunNext();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
tmpProcess.on('error', (pError) =>
|
|
496
|
+
{
|
|
497
|
+
tmpSelf.activeProcess = null;
|
|
498
|
+
if (tmpSelf.log && tmpStepStartTime)
|
|
499
|
+
{
|
|
500
|
+
tmpSelf.log.logTimeDeltaRelativeHuman(tmpStepStartTime, `ERROR ${tmpRunnable} ${pError.message}`);
|
|
501
|
+
}
|
|
502
|
+
tmpSelf.logWidget.log(`{red-fg}{bold}Failed: ${pError.message}{/red-fg}{/bold}`);
|
|
503
|
+
tmpSelf.statusCallback('error', pError.message);
|
|
504
|
+
tmpSelf.screen.render();
|
|
505
|
+
});
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
fRunNext();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Kill the currently running process, if any.
|
|
513
|
+
*/
|
|
514
|
+
kill()
|
|
515
|
+
{
|
|
516
|
+
if (this._statusTimer)
|
|
517
|
+
{
|
|
518
|
+
clearTimeout(this._statusTimer);
|
|
519
|
+
this._statusTimer = null;
|
|
520
|
+
}
|
|
521
|
+
if (this.activeProcess)
|
|
522
|
+
{
|
|
523
|
+
this.activeProcess.kill('SIGTERM');
|
|
524
|
+
this.activeProcess = null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* @returns {boolean} Whether a process is currently running.
|
|
530
|
+
*/
|
|
531
|
+
isRunning()
|
|
532
|
+
{
|
|
533
|
+
return this.activeProcess !== null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Search the output buffer for lines matching a query string.
|
|
538
|
+
* Replaces the widget content with matching lines (with line numbers)
|
|
539
|
+
* and stores match indices for next/prev navigation.
|
|
540
|
+
*
|
|
541
|
+
* @param {string} pQuery - Case-insensitive search string.
|
|
542
|
+
*/
|
|
543
|
+
search(pQuery)
|
|
544
|
+
{
|
|
545
|
+
if (!pQuery)
|
|
546
|
+
{
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (this._outputBuffer.length === 0)
|
|
551
|
+
{
|
|
552
|
+
this.logWidget.setContent('{yellow-fg}{bold}No output to search.{/bold}{/yellow-fg}\n\nRun a command first, then search with [/].');
|
|
553
|
+
this.screen.render();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
this._searchQuery = pQuery;
|
|
558
|
+
this._searchMatches = [];
|
|
559
|
+
// Store individual result lines for navigation rebuilds
|
|
560
|
+
this._searchResultLines = [];
|
|
561
|
+
|
|
562
|
+
let tmpQueryLower = pQuery.toLowerCase();
|
|
563
|
+
|
|
564
|
+
// Build a regex to highlight matches (escape special regex chars in query)
|
|
565
|
+
let tmpEscaped = pQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
566
|
+
let tmpHighlightRegex = new RegExp(`(${tmpEscaped})`, 'gi');
|
|
567
|
+
|
|
568
|
+
for (let i = 0; i < this._outputBuffer.length; i++)
|
|
569
|
+
{
|
|
570
|
+
// Strip blessed markup tags for matching purposes
|
|
571
|
+
let tmpPlain = this._outputBuffer[i].replace(/\{[^}]*\}/g, '');
|
|
572
|
+
if (tmpPlain.toLowerCase().indexOf(tmpQueryLower) !== -1)
|
|
573
|
+
{
|
|
574
|
+
this._searchMatches.push(i);
|
|
575
|
+
let tmpLineNum = String(i + 1).padStart(5, ' ');
|
|
576
|
+
// Highlight the matching text in the display line
|
|
577
|
+
let tmpHighlighted = this._outputBuffer[i].replace(tmpHighlightRegex, '{yellow-fg}{bold}$1{/bold}{/yellow-fg}');
|
|
578
|
+
this._searchResultLines.push(`{gray-fg}${tmpLineNum}:{/gray-fg} ${tmpHighlighted}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
this._searchMatchIndex = -1;
|
|
583
|
+
this._renderSearchResults();
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Rebuild and display the search results view.
|
|
588
|
+
* When a match is selected (via navigation), that line gets a marker.
|
|
589
|
+
*/
|
|
590
|
+
_renderSearchResults()
|
|
591
|
+
{
|
|
592
|
+
let tmpLines = [];
|
|
593
|
+
|
|
594
|
+
tmpLines.push(`{bold}Search: "${this._searchQuery}"{/bold} in ${this._outputBuffer.length} buffered lines`);
|
|
595
|
+
tmpLines.push('');
|
|
596
|
+
|
|
597
|
+
for (let i = 0; i < this._searchResultLines.length; i++)
|
|
598
|
+
{
|
|
599
|
+
if (i === this._searchMatchIndex)
|
|
600
|
+
{
|
|
601
|
+
// Current match: cyan background marker
|
|
602
|
+
tmpLines.push(`{cyan-fg}{bold}>>>{/bold}{/cyan-fg} ${this._searchResultLines[i]}`);
|
|
603
|
+
}
|
|
604
|
+
else
|
|
605
|
+
{
|
|
606
|
+
tmpLines.push(` ${this._searchResultLines[i]}`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
tmpLines.push('');
|
|
611
|
+
if (this._searchMatches.length === 0)
|
|
612
|
+
{
|
|
613
|
+
tmpLines.push(`{yellow-fg}{bold}No matches found{/bold}{/yellow-fg} for "${this._searchQuery}" in ${this._outputBuffer.length} lines`);
|
|
614
|
+
}
|
|
615
|
+
else
|
|
616
|
+
{
|
|
617
|
+
tmpLines.push(`{bold}${this._searchMatches.length} matches{/bold} ] next [ prev`);
|
|
618
|
+
}
|
|
619
|
+
tmpLines.push('[/] search again [Esc] back to full output');
|
|
620
|
+
|
|
621
|
+
this.logWidget.setContent(tmpLines.join('\n'));
|
|
622
|
+
|
|
623
|
+
// Scroll so the current match is visible
|
|
624
|
+
if (this._searchMatchIndex >= 0 && this._searchMatches.length > 0)
|
|
625
|
+
{
|
|
626
|
+
// +2 accounts for header lines before the result lines
|
|
627
|
+
let tmpTargetLine = this._searchMatchIndex + 2;
|
|
628
|
+
let tmpTotalLines = tmpLines.length;
|
|
629
|
+
let tmpScrollPerc = Math.max(0, Math.floor((tmpTargetLine / tmpTotalLines) * 100));
|
|
630
|
+
this.logWidget.setScrollPerc(tmpScrollPerc);
|
|
631
|
+
|
|
632
|
+
let tmpBufferLineIndex = this._searchMatches[this._searchMatchIndex];
|
|
633
|
+
this.statusCallback('search',
|
|
634
|
+
`Match ${this._searchMatchIndex + 1}/${this._searchMatches.length} line ${tmpBufferLineIndex + 1} ] next [ prev [/] search [Esc] done`);
|
|
635
|
+
}
|
|
636
|
+
else
|
|
637
|
+
{
|
|
638
|
+
this.logWidget.setScrollPerc(0);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
this.screen.render();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Move to the next or previous search match within the results view.
|
|
646
|
+
*
|
|
647
|
+
* @param {number} pDirection - 1 for next, -1 for previous.
|
|
648
|
+
*/
|
|
649
|
+
searchNavigate(pDirection)
|
|
650
|
+
{
|
|
651
|
+
if (!this._searchMatches || this._searchMatches.length === 0)
|
|
652
|
+
{
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
this._searchMatchIndex += pDirection;
|
|
657
|
+
|
|
658
|
+
// Wrap around
|
|
659
|
+
if (this._searchMatchIndex >= this._searchMatches.length)
|
|
660
|
+
{
|
|
661
|
+
this._searchMatchIndex = 0;
|
|
662
|
+
}
|
|
663
|
+
else if (this._searchMatchIndex < 0)
|
|
664
|
+
{
|
|
665
|
+
this._searchMatchIndex = this._searchMatches.length - 1;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
this._renderSearchResults();
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Exit search mode and restore the full output buffer.
|
|
673
|
+
*/
|
|
674
|
+
searchClear()
|
|
675
|
+
{
|
|
676
|
+
this._searchQuery = '';
|
|
677
|
+
this._searchMatches = null;
|
|
678
|
+
this._searchMatchIndex = -1;
|
|
679
|
+
this._searchResultLines = null;
|
|
680
|
+
|
|
681
|
+
if (this._outputBuffer.length > 0)
|
|
682
|
+
{
|
|
683
|
+
this.logWidget.setContent(this._outputBuffer.join('\n'));
|
|
684
|
+
this.logWidget.setScrollPerc(100);
|
|
685
|
+
this.screen.render();
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* @returns {boolean} Whether there is a buffer available to search.
|
|
691
|
+
*/
|
|
692
|
+
hasBuffer()
|
|
693
|
+
{
|
|
694
|
+
return this._outputBuffer.length > 0;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* @returns {boolean} Whether we are in search-results mode.
|
|
699
|
+
*/
|
|
700
|
+
isSearchActive()
|
|
701
|
+
{
|
|
702
|
+
return this._searchMatches !== null && this._searchMatches !== undefined && this._searchMatches.length >= 0;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
module.exports = ProcessRunner;
|