@inteli.city/node-red-contrib-exec-collection 1.0.4 → 1.0.5

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/exec.service.js CHANGED
@@ -1,548 +1,386 @@
1
- /** ////
2
- * Copyright JS Foundation and other contributors, http://js.foundation
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the "License");
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- **///
16
- module.exports = function(RED) {
17
- //* libraries
18
- "use strict";
19
- var mustache = require("mustache");
20
- var fs = require('fs');
21
- var tmp = require('tmp-promise');
22
- var fsPromises = require('fs').promises;
23
- var terminate = require("terminate");
24
- var yaml = require("js-yaml");
25
- var convertXML = require('xml-js');
26
- var exec = require('child_process').exec;
27
- var spawn = require('child_process').spawn;
28
- var Queue = require('queue');
29
- var pm2 = require('pm2');
30
- //* auxiliary functions
31
- //** function: extractTokens
32
- function extractTokens(tokens, set) {
33
- set = set || new Set();
34
- tokens.forEach(function(token) {
35
- if (token[0] !== 'text') {
36
- set.add(token[1]);
37
- if (token.length > 4) {
38
- extractTokens(token[4], set);
39
- }
40
- }
41
- });
42
- return set;
43
- }
1
+ 'use strict';
44
2
 
45
- //** function: parseContext
46
- function parseContext(key) {
47
- var match = /^(flow|global)(\[(\w+)\])?\.(.+)/.exec(key);
48
- if (match) {
49
- var parts = {};
50
- parts.type = match[1];
51
- parts.store = (match[3] === '') ? "default" : match[3];
52
- parts.field = match[4];
53
- return parts;
54
- }
55
- return undefined;
56
- }
3
+ // ── requires ──────────────────────────────────────────────────────────────────
57
4
 
58
- //** function: parseEnv
59
- function parseEnv(key) {
60
- var match = /^env\.(.+)/.exec(key);
61
- if (match) {
62
- return match[1];
63
- }
64
- return undefined;
5
+ const { spawn } = require('child_process');
6
+ const fs = require('fs');
7
+ const tmp = require('tmp-promise');
8
+ const nunjucks = require('nunjucks');
9
+ const yaml = require('js-yaml');
10
+ const convertXML = require('xml-js');
11
+ const { StringDecoder } = require('string_decoder');
12
+ const { buildContext } = require('./utils/context');
13
+
14
+ // ── platform ──────────────────────────────────────────────────────────────────
15
+
16
+ function getPlatformConfig() {
17
+ if (process.platform === 'win32') {
18
+ return { shell: 'cmd.exe', args: ['/d', '/s', '/c'] };
65
19
  }
20
+ return { shell: '/bin/bash', args: ['-c'] };
21
+ }
66
22
 
23
+ const PLATFORM = getPlatformConfig();
67
24
 
68
- /**
69
- * Custom Mustache Context capable to collect message property and node
70
- * flow and global context
71
- */
25
+ // ── shell helpers ─────────────────────────────────────────────────────────────
72
26
 
73
- //** function: remove_by_value (prototype)
74
- Array.prototype.remove_by_value = function(val) {
75
- for (var i = 0; i < this.length; i++) {
76
- if (this[i] === val) {
77
- this.splice(i, 1);
78
- i--;
79
- }
80
- }
81
- return this;
27
+ // Sets $file / %file% before running the command, matching exec.queue behaviour.
28
+ function buildShellcode(filePath, command) {
29
+ if (PLATFORM.shell === 'cmd.exe') {
30
+ return `set "file=${filePath}" && ${command}`;
82
31
  }
83
- //** function: NodeContext
84
- class NodeContext extends mustache.Context {
85
- constructor(msg, nodeContext, parent, escapeStrings, cachedContextTokens) {
86
- super(msg, parent);
87
- this.nodeContext = nodeContext;
88
- this.escapeStrings = escapeStrings;
89
- this.cachedContextTokens = cachedContextTokens;
90
- }
91
-
92
- lookup(name) {
93
- try {
94
- var value = super.lookup(name);
95
- if (value !== undefined) {
96
- if (typeof value === "object") {
97
- value = JSON.stringify(value);
98
- }
99
- if (this.escapeStrings && typeof value === "string") {
100
- value = value.replace(/\\/g, "\\\\")
101
- .replace(/\n/g, "\\n")
102
- .replace(/\t/g, "\\t")
103
- .replace(/\r/g, "\\r")
104
- .replace(/\f/g, "\\f")
105
- .replace(/[\b]/g, "\\b");
106
- }
107
- return value;
108
- }
32
+ return `\nexport NODE_PATH="$NODE_PATH:/usr/local/lib/node_modules"\nfile="${filePath}"\n${command}\n`;
33
+ }
109
34
 
110
- if (parseEnv(name)) {
111
- return this.cachedContextTokens[name];
112
- }
35
+ // ── output parsing ────────────────────────────────────────────────────────────
113
36
 
114
- var context = parseContext(name);
115
- if (context) {
116
- var target = this.nodeContext[context.type];
117
- if (target) {
118
- return this.cachedContextTokens[name];
119
- }
120
- }
121
- return '';
122
- } catch (err) {
123
- throw err;
37
+ // Pure: parses rawValue per outputFormat.
38
+ // Returns { value } on success, { error: string } on failure.
39
+ function parseOutputValue(rawValue, outputFormat) {
40
+ if (outputFormat === 'parsedJSON') {
41
+ try {
42
+ const parsed = JSON.parse(rawValue);
43
+ if (typeof parsed === 'number') {
44
+ return { error: 'Error parsing JSON: result is a plain number' };
124
45
  }
46
+ return { value: parsed };
47
+ } catch (err) {
48
+ return { error: 'Error parsing JSON:\n\n' + err };
125
49
  }
50
+ }
126
51
 
127
- push(view) {
128
- return new NodeContext(view, this.nodeContext, this, undefined, this.cachedContextTokens);
52
+ if (outputFormat === 'parsedXML') {
53
+ try {
54
+ return { value: convertXML.xml2js(rawValue, { compact: true, spaces: 4 }) };
55
+ } catch (err) {
56
+ return { error: 'Error parsing XML:\n\n' + err };
129
57
  }
130
58
  }
131
59
 
132
- //* ExecQueueNode
133
- function ExecQueueNode(n) {
134
- //** setting values
135
- RED.nodes.createNode(this, n);
136
- this.name = n.name;
137
- this.field = n.field || "payload";
138
- // this.template = n.template;
139
- this.template = n.template === "" ? " " : n.template; // gambiarra (arrumar)
140
- this.syntax = n.syntax || "javascript";
141
- this.fieldType = n.fieldType || "msg";
142
- this.outputFormat = n.output || "str";
143
-
144
- this.cmd = (n.command || "").trim();
145
- this.append = (n.append || "").trim();
146
- this.useSpawn = n.useSpawn
147
- this.inputEmpty = n.inputEmpty
148
- this.count = 0
149
- this.state = ""
150
- this.queue = n.queue
151
- this.executingCode = 0
152
- this.waitingForExecuting = 0
153
- this.processKilled = false
154
- this.statusTimerUp = new Date()
155
- this.statusTimerDown = new Date()
156
- // this.addpayCB = n.addpayCB
157
- this.cmdTemplate = n.cmdTemplate
158
- this.cmd = (n.command || "").trim();
159
- //this.parsedJSON = n.parsedJSON
160
- this.splitLine = n.splitLine
161
- this.cleanQueue = n.cleanQueue
162
-
163
- if (n.addpay === undefined) { n.addpay = true; }
164
- this.addpay = n.addpay;
165
- this.append = (n.append || "").trim();
166
- this.useSpawn = (n.useSpawn == "true");
167
- this.timer = Number(n.timer || 0) * 1000;
168
- this.activeProcesses = {};
169
- this.tempFiles = []
170
- this.oldrc = (n.oldrc || false).toString();
171
- //this.execOpt = {maxBuffer:50000000, windowsHide: (n.winHide === true)};
172
- //this.execOpt = {encoding:'binary', maxBuffer:50000000, windowsHide: (n.winHide === true)};
173
- this.execOpt = { maxBuffer: 50000000, windowsHide: (n.winHide === true), detached: true };
174
- this.spawnOpt = { windowsHide: (n.winHide === true), detached: true }
175
- this.executed = false;
176
-
177
- // this.timer = Number(n.timer || 0)*1000;
178
- // this.activeProcesses = {};
179
- // this.oldrc = (n.oldrc || false).toString();
180
- // this.execOpt = {encoding:'binary', maxBuffer:50000000, windowsHide: (n.winHide === true)};
181
- // this.spawnOpt = {windowsHide: (n.winHide === true) }
182
- var node = this;
183
- //** node initialization setup
184
- if (process.platform === 'linux' && fs.existsSync('/bin/bash')) { node.execOpt.shell = '/bin/bash'; }
185
- if ( node.inputEmpty == false ){
186
- node.status({ fill: "yellow", shape: "ring", text: '(0/1)' });
60
+ if (outputFormat === 'parsedYAML') {
61
+ try {
62
+ const parsed = yaml.load(rawValue);
63
+ if (typeof parsed === 'number') {
64
+ return { error: 'Error parsing YAML: result is a plain number' };
65
+ }
66
+ return { value: parsed };
67
+ } catch (err) {
68
+ return { error: 'Error parsing YAML:\n\n' + err };
187
69
  }
70
+ }
188
71
 
72
+ return { value: rawValue };
73
+ }
189
74
 
190
- function emitInputMessage() { node.emit("input", {payload: 'play'}) }
191
- //** node.on('input')
192
- node.on("input", async function(msg, send, done) {
193
- if (!node.executed) {
194
- node.executed = true;
195
- node.status({ fill: "blue", shape: "ring", text: '(1/1)' });
196
- try {
197
- msg.nodeName = node.name;
198
75
 
199
- node.warn(msg.payload);
200
- } catch (err) {
201
- done(err.message);
202
- }
76
+ // ── node definition ───────────────────────────────────────────────────────────
77
+
78
+ module.exports = function(RED) {
79
+
80
+ function ExecServiceNode(n) {
81
+ RED.nodes.createNode(this, n);
82
+
83
+ this.name = n.name;
84
+ this.command = (n.command || '').trim();
85
+ this.template = n.template || '';
86
+ this.outputFormat = n.output || 'str';
87
+ this.field = n.field || 'payload';
88
+ this.fieldType = n.fieldType || 'msg';
89
+ this.streamMode = n.streamMode || 'delimited';
90
+ this.restartDelay = Math.max(0, Number(n.restartDelay) || 3000);
91
+ this.maxRetries = Math.max(0, Number(n.maxRetries) || 0); // 0 = infinite
92
+
93
+ // Interpret escape sequences in the stored delimiter string
94
+ const delimRaw = (n.delimiter !== undefined && n.delimiter !== '') ? n.delimiter : '\\n';
95
+ this.delimiter = delimRaw.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r');
96
+
97
+ const node = this;
98
+
99
+ // ── state ─────────────────────────────────────────────────────────────
100
+
101
+ let desiredState = 'running'; // 'running' | 'stopped'
102
+ let proc = null; // active child process (or null)
103
+ let retryCount = 0;
104
+ let restartTimer = null;
105
+ let successTimer = null;
106
+ let segmentBuffer = '';
107
+ let closing = false;
108
+ let currentTempFile = null; // path of the rendered $file temp file
109
+
110
+ // ── status ────────────────────────────────────────────────────────────
111
+
112
+ function updateStatus(state, retry) {
113
+ node._serviceState = state;
114
+ if (state === 'running') {
115
+ node.status({ fill: 'blue', shape: 'ring', text: 'running' });
116
+ } else if (state === 'restarting') {
117
+ const label = retry ? `restarting (retry ${retry})` : 'restarting';
118
+ node.status({ fill: 'yellow', shape: 'ring', text: label });
203
119
  } else {
204
- node.warn("Service already running.");
120
+ node.status({ fill: 'grey', shape: 'dot', text: 'stopped' });
205
121
  }
206
- });
207
-
122
+ }
208
123
 
209
- //** node.on('close')
210
- node.on('close', async function() {
211
- //// KILL PROCESSES AND ERASE FILES
212
- node.executed = false;
213
- node.status({ fill: "blue", shape: "ring", text: '(1/1)' });
214
- // node.executingCode = 0
215
- // node.waitingForExecuting = 0
216
- // node.processKilled = true
217
- // for (var pid in node.activeProcesses) {
218
- // /* istanbul ignore else */
219
- // if (node.activeProcesses.hasOwnProperty(pid)) {
220
- // // process.kill(-pid, 9)
221
- // terminate(pid)
222
- // node.activeProcesses[pid] = null;
223
- // node.warn(`Killing pid ${pid}`)
224
- // }
225
- // }
226
-
227
- // for (let i = 0, len = node.tempFiles.length; i < len; i++) {
228
- // await fsPromises.unlink(file);
229
- // }
230
- // node.activeProcesses = {};
231
- });
124
+ // ── output helpers ────────────────────────────────────────────────────
232
125
 
233
- //** emmit first message
234
- if ( node.inputEmpty == true ){
235
- emitInputMessage();
126
+ // Emit one parsed segment via node.send(). Guards against post-kill emission.
127
+ function emitSegment(value) {
128
+ if (closing || desiredState === 'stopped') return;
129
+ const result = parseOutputValue(value, node.outputFormat);
130
+ if (result.error) {
131
+ node.error(result.error);
132
+ return;
133
+ }
134
+ if (node.fieldType === 'msg') {
135
+ const outMsg = {};
136
+ RED.util.setMessageProperty(outMsg, node.field, result.value);
137
+ node.send(outMsg);
138
+ } else {
139
+ const context = RED.util.parseContextStore(node.field);
140
+ const target = node.context()[node.fieldType];
141
+ const outMsg = {};
142
+ target.set(context.key, result.value, context.store, (err) => {
143
+ if (err) node.error(err);
144
+ else node.send(outMsg);
145
+ });
146
+ }
236
147
  }
237
148
 
238
- //** executeCode
239
- async function executeCode(msg, send, done, node, resolvedTokens) {
149
+ // Route a stdout chunk: raw → emit immediately; delimited → buffer and split.
150
+ function handleChunk(chunk) {
151
+ if (closing || desiredState === 'stopped') return;
152
+ if (node.streamMode === 'raw') {
153
+ emitSegment(chunk);
154
+ return;
155
+ }
156
+ segmentBuffer += chunk;
157
+ const parts = segmentBuffer.split(node.delimiter);
158
+ segmentBuffer = parts.pop(); // keep incomplete remainder
159
+ for (const part of parts) {
160
+ if (part) emitSegment(part);
161
+ }
162
+ }
240
163
 
241
- // Create a temporary file asynchronously
242
- const is_json = (node.outputFormat === "parsedJSON");
243
- let template = node.template || msg.template;
244
- const value = mustache.render(template, new NodeContext(msg, node.context(), null, is_json, resolvedTokens));
245
- const tmpObj = await tmp.file();
246
- await fsPromises.writeFile(tmpObj.path, value, 'utf8');
164
+ // ── process management ────────────────────────────────────────────────
247
165
 
248
- let shellcode;
249
- let resolvedCommand
250
- if ( node.cmdTemplate ){
251
- resolvedCommand = await resolveCommand(node.cmd, msg);
252
- } else {
253
- resolvedCommand = node.cmd
166
+ function cleanupTempFile() {
167
+ if (currentTempFile) {
168
+ try { fs.unlinkSync(currentTempFile); } catch (_) {}
169
+ currentTempFile = null;
254
170
  }
255
- if (process.platform === 'win32') { // For Windows
256
- shellcode = `${resolvedCommand} ${addPayload} ${tmpObj.path}`;
257
- } else { // For Linux and macOS
258
- shellcode = `
259
- export NODE_PATH="$NODE_PATH:$HOME/.node-red/node_modules:/usr/local/lib/node_modules"
260
- file="${tmpObj.path}"
261
- ${resolvedCommand}
262
- `;
171
+ }
172
+
173
+ function startProcess() {
174
+ if (closing || desiredState === 'stopped') return;
175
+
176
+ if (!node.command) {
177
+ node.warn('exec.service: no command configured');
178
+ desiredState = 'stopped';
179
+ updateStatus('stopped');
180
+ return;
263
181
  }
264
182
 
265
- try {
266
- if (!node.useSpawn) {
267
- await executeWithExec(shellcode, node, msg, send, done);
268
- } else {
269
- await executeWithSpawn(shellcode, node, msg, send, done);
183
+ // ── template → $file ─────────────────────────────────────────────
184
+ // If a template is configured, render it with Nunjucks, write it to
185
+ // a temp file, and wrap the command in a shell snippet that sets
186
+ // $file to that path — identical to how exec.queue works.
187
+ let effectiveCommand = node.command;
188
+ cleanupTempFile();
189
+
190
+ if (node.template) {
191
+ try {
192
+ const context = buildContext({}, node);
193
+ const env = new nunjucks.Environment(null, { autoescape: false });
194
+ const rendered = env.renderString(node.template, context);
195
+ const tmpObj = tmp.fileSync();
196
+ currentTempFile = tmpObj.name;
197
+ fs.writeFileSync(tmpObj.name, rendered, 'utf8');
198
+ effectiveCommand = buildShellcode(tmpObj.name, node.command);
199
+ } catch (e) {
200
+ node.error('exec.service template error: ' + e.message);
201
+ desiredState = 'stopped';
202
+ updateStatus('stopped');
203
+ return;
270
204
  }
271
- } finally {
272
- await tmpObj.cleanup();
273
205
  }
274
- }
275
206
 
276
- async function executeWithExec(shellcode, node, msg, send, done) {
277
- return new Promise((resolve) => {
278
- const child = exec(shellcode, node.execOpt, (err, stdout, stderr) => {
279
- if (err) {
280
- const error = {
281
- type: 'error',
282
- code: err.code,
283
- killed: err.killed
284
- };
285
- msg.error_info = error;
286
- node.error(`error (${msg.nodeName})\n\n${stderr}`, msg);
287
- } else {
288
- if (stderr) {
289
- node.error(`warning (${msg.nodeName})\n\n${stderr}`, msg);
290
- }
291
-
292
- if (stdout) {
293
- stdout = stdout.trim();
294
- if (node.splitLine === false) {
295
- output(msg, stdout, send, done);
296
- } else {
297
- stdout = stdout.split('\n');
298
- for (let i = 0; i < stdout.length; i++) {
299
- node.emit("input", { "message": stdout[i] });
300
- }
301
- }
302
- }
303
- }
304
- delete node.activeProcesses[child.pid];
305
- resolve();
306
- });
307
- node.activeProcesses[child.pid] = child.pid;
207
+ // Fresh decoder per process avoids multibyte state leaking across restarts.
208
+ const decoder = new StringDecoder('utf8');
209
+ segmentBuffer = '';
210
+
211
+ const p = spawn(PLATFORM.shell, [...PLATFORM.args, effectiveCommand], {
212
+ stdio: ['ignore', 'pipe', 'pipe']
308
213
  });
309
- }
214
+ proc = p;
310
215
 
311
- async function executeWithSpawn(shellcode, node, msg, send, done) {
312
- return new Promise((resolve, reject) => {
313
- const child = spawn('/bin/bash', ['-c', shellcode], node.spawnOpt);
314
- node.activeProcesses[child.pid] = child.pid;
315
-
316
- child.stdout.on('data', (data) => {
317
- data = data.toString();
318
- if (node.splitLine === false) {
319
- output(msg, data, send, done);
320
- } else {
321
- const lines = data.split('\n');
322
- for (let line of lines) {
323
- if (line) {
324
- node.emit("input", { "message": line });
325
- }
326
- }
327
- }
328
- });
216
+ updateStatus('running');
329
217
 
330
- child.stderr.on('data', (data) => {
331
- node.error(`warning (${msg.nodeName})\n\n${data.toString()}`, msg);
332
- });
218
+ // Reset retry counter if the process runs stably for 10 s.
219
+ if (successTimer) clearTimeout(successTimer);
220
+ successTimer = setTimeout(() => {
221
+ successTimer = null;
222
+ if (proc === p) retryCount = 0;
223
+ }, 10000);
333
224
 
334
- child.on('close', (code) => {
335
- if (code !== 0) {
336
- const error = {
337
- type: 'error',
338
- code: code
339
- };
340
- msg.error_info = error;
341
- node.error(`error (${msg.nodeName}): The node hasn't finished its execution`, msg);
342
- reject(new Error(`Child process exited with code ${code}`));
343
- }
344
- delete node.activeProcesses[child.pid];
345
- resolve();
346
- });
225
+ p.stdout.on('data', (data) => {
226
+ if (proc !== p) return; // stale event after kill
227
+ const text = decoder.write(data);
228
+ if (text) handleChunk(text);
347
229
  });
348
- }
349
230
 
350
- //** resolveTemplate
351
- async function resolveTemplate(msg) {
352
- var template = node.template;
353
- if (msg.hasOwnProperty("template")) {
354
- if (template == "" || template === null) {
355
- template = msg.template;
356
- }
357
- }
231
+ p.stderr.on('data', (data) => {
232
+ if (proc !== p) return;
233
+ const text = Buffer.isBuffer(data) ? data.toString() : String(data);
234
+ if (text && text.trim()) node.warn(text.trim());
235
+ });
236
+
237
+ p.on('error', (err) => {
238
+ if (proc !== p || closing) return;
239
+ proc = null;
240
+ if (successTimer) { clearTimeout(successTimer); successTimer = null; }
241
+ node.error(`exec.service error: ${(err && err.message) ? err.message : String(err)}`);
242
+ scheduleRestart();
243
+ });
358
244
 
359
- var resolvedTokens = {};
360
- var tokens = extractTokens(mustache.parse(template));
245
+ p.on('close', (code) => {
246
+ if (proc !== p || closing) return;
247
+ proc = null;
248
+ if (successTimer) { clearTimeout(successTimer); successTimer = null; }
361
249
 
362
- // Iterate over the extracted tokens to resolve their values.
363
- for (let name of tokens) {
364
- let env_name = parseEnv(name);
365
- if (env_name) {
366
- resolvedTokens[name] = RED.util.evaluateNodeProperty(env_name, 'env', node);
367
- continue;
250
+ // Flush any remaining buffered segment on clean exit.
251
+ if (node.streamMode === 'delimited' && segmentBuffer && desiredState === 'running') {
252
+ emitSegment(segmentBuffer);
253
+ segmentBuffer = '';
368
254
  }
369
255
 
370
- // Check if the token refers to a flow or global context variable.
371
- let context = parseContext(name);
372
- if (context) {
373
- let type = context.type;
374
- let store = context.store;
375
- let field = context.field;
376
- let target = node.context()[type];
377
- if (target) {
378
- resolvedTokens[name] = await new Promise((resolve, reject) => {
379
- target.get(field, store, (err, val) => {
380
- if (err) reject(err);
381
- else resolve(val);
382
- });
383
- });
256
+ if (desiredState === 'running') {
257
+ if (code !== 0 && code !== null) {
258
+ node.warn(`exec.service: process exited (code ${code})`);
384
259
  }
260
+ scheduleRestart();
385
261
  }
386
- }
387
-
388
- return resolvedTokens;
262
+ });
389
263
  }
390
264
 
391
- //** resolveCommand
392
- async function resolveCommand(command, msg) {
393
- return new Promise(async (resolve, reject) => {
394
- try {
395
- // Extract tokens from the command string
396
- var tokens = extractTokens(mustache.parse(command));
397
-
398
- var resolvedTokens = {};
399
-
400
- // Iterate over the extracted tokens to resolve their values.
401
- for (let name of tokens) {
402
- let env_name = parseEnv(name);
403
- if (env_name) {
404
- resolvedTokens[name] = RED.util.evaluateNodeProperty(env_name, 'env', node);
405
- continue;
406
- }
407
-
408
- // Check if the token refers to a flow or global context variable.
409
- let context = parseContext(name);
410
- if (context) {
411
- let type = context.type;
412
- let store = context.store;
413
- let field = context.field;
414
- let target = node.context()[type];
415
- if (target) {
416
- resolvedTokens[name] = await new Promise((innerResolve, innerReject) => {
417
- target.get(field, store, (err, val) => {
418
- if (err) innerReject(err);
419
- else innerResolve(val);
420
- });
421
- });
422
- }
423
- }
424
- }
265
+ function scheduleRestart() {
266
+ if (closing || desiredState === 'stopped') return;
425
267
 
426
- var parsedCommand = mustache.parse(command);
427
-
428
- // Extract the variable names from the parsed tokens.
429
- var variableNames = parsedCommand
430
- .filter(token => token[0] === '&')
431
- .map(token => token[1]);
432
-
433
- var resolvedValues = {};
434
-
435
- // Resolve the values for each variable.
436
- for (let variableName of variableNames) {
437
- let env_name = parseEnv(variableName);
438
- if (env_name) {
439
- resolvedValues[variableName] = RED.util.evaluateNodeProperty(env_name, 'env', node);
440
- continue;
441
- }
442
-
443
- // Check if the variable refers to a flow or global context variable.
444
- let context = parseContext(variableName);
445
- if (context) {
446
- let type = context.type;
447
- let store = context.store;
448
- let field = context.field;
449
- let target = node.context()[type];
450
- if (target) {
451
- resolvedValues[variableName] = await new Promise((innerResolve, innerReject) => {
452
- target.get(field, store, (err, val) => {
453
- if (err) innerReject(err);
454
- else innerResolve(val);
455
- });
456
- });
457
- }
458
- } else {
459
- // If the variable is not in the context or an env variable, try to get it from the msg object.
460
- resolvedValues[variableName] = msg[variableName];
461
- }
462
- }
268
+ retryCount++;
463
269
 
464
- // Replace the variables in the original command with their resolved values.
465
- for (let variableName in resolvedValues) {
466
- command = command.replace(`{{{${variableName}}}}`, resolvedValues[variableName]);
467
- }
270
+ if (node.maxRetries > 0 && retryCount > node.maxRetries) {
271
+ node.error(`exec.service: max retries (${node.maxRetries}) exceeded stopping`);
272
+ desiredState = 'stopped';
273
+ updateStatus('stopped');
274
+ return;
275
+ }
468
276
 
469
- console.log(command);
470
- resolve(command); // Use the resolve here
471
- } catch (error) {
472
- console.error("Error in resolveCommand:", error);
473
- reject(error); // Use the reject here
474
- }
475
- });
277
+ updateStatus('restarting', retryCount);
278
+
279
+ if (restartTimer) clearTimeout(restartTimer);
280
+ restartTimer = setTimeout(() => {
281
+ restartTimer = null;
282
+ if (!closing && desiredState === 'running') startProcess();
283
+ }, node.restartDelay);
476
284
  }
477
285
 
478
- //** function: output
479
- function output(msg, value, send, done) {
480
- /* istanbul ignore else */
481
- let parseError = false
482
- //*** parse json
483
- if (node.outputFormat === "parsedJSON") {
484
- try {
485
- value = JSON.parse(value);
486
- if (typeof value === 'number') {
487
- parseError = true
488
- node.error('Error parsing JSON: \n\n' + error)
489
- }
490
- } catch (error) {
491
- parseError = true
492
- node.error('Error parsing JSON: \n\n' + error)
493
- }
286
+ // ── killAll ───────────────────────────────────────────────────────────
287
+ // Triggered by: msg.stop, HTTP endpoint, editor button, node.on('close').
288
+
289
+ function killAll() {
290
+ desiredState = 'stopped';
291
+ if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; }
292
+ if (successTimer) { clearTimeout(successTimer); successTimer = null; }
293
+ segmentBuffer = '';
294
+ if (proc) {
295
+ const p = proc;
296
+ proc = null; // nullify before kill so close handler skips restart
297
+ try { p.kill('SIGTERM'); } catch (_) {}
494
298
  }
299
+ cleanupTempFile();
300
+ updateStatus('stopped');
301
+ }
495
302
 
496
- //*** parse xml
497
- if (node.outputFormat === "parsedXML") {
498
- try {
499
- //value = JSON.parse(convertXML.xml2json(value, {compact: true, spaces: 4}))
500
- value = convertXML.xml2js(value, { compact: true, spaces: 4 })
501
- } catch (error) {
502
- parseError = true
503
- node.error('Error parsing XML: \n\n' + error)
504
- }
505
- }
303
+ node.killAll = killAll;
506
304
 
507
- //*** parse yaml
508
- if (node.outputFormat === "parsedYAML") {
509
- try {
510
- value = yaml.load(value);
511
- if (typeof value === 'number') {
512
- parseError = true
513
- node.error('Error parsing YAML: \n\n' + error)
514
- }
515
- } catch (error) {
516
- parseError = true
517
- node.error('Error parsing YAML: \n\n' + error)
518
- }
519
- }
305
+ // ── restartService ────────────────────────────────────────────────────
306
+ // User-driven restart. Kills any running process, resets all failure
307
+ // state, sets desiredState = 'running', and spawns immediately.
308
+ // Distinct from killAll (which sets desiredState = 'stopped') and from
309
+ // the crash-restart path (scheduleRestart) — this is always intentional.
520
310
 
521
- //*** parse error
522
- if (parseError === false) {
523
- if (node.fieldType === 'msg') {
524
- RED.util.setMessageProperty(msg, node.field, value);
525
- send(msg);
526
- done();
527
- } else if ((node.fieldType === 'flow') ||
528
- (node.fieldType === 'global')) {
529
- var context = RED.util.parseContextStore(node.field);
530
- var target = node.context()[node.fieldType];
531
- target.set(context.key, value, context.store, function(err) {
532
- if (err) {
533
- done(err);
534
- } else {
535
- send(msg);
536
- done();
537
- }
538
- });
539
- }
311
+ function restartService() {
312
+ if (closing) return;
313
+
314
+ // Cancel any pending crash-restart timer so it doesn't race.
315
+ if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; }
316
+ if (successTimer) { clearTimeout(successTimer); successTimer = null; }
317
+
318
+ // Kill the current process if alive, marking it stale first so its
319
+ // close handler won't trigger another scheduleRestart.
320
+ if (proc) {
321
+ const p = proc;
322
+ proc = null;
323
+ try { p.kill('SIGTERM'); } catch (_) {}
540
324
  }
325
+
326
+ // Reset failure counters — this is a fresh start, not a retry.
327
+ retryCount = 0;
328
+ segmentBuffer = '';
329
+
330
+ // Ensure desiredState is running even if service was manually stopped.
331
+ desiredState = 'running';
332
+
333
+ updateStatus('restarting');
334
+ startProcess();
541
335
  }
542
336
 
337
+ node.restartService = restartService;
338
+
339
+ // ── close handler ─────────────────────────────────────────────────────
340
+
341
+ node.on('close', function(done) {
342
+ closing = true;
343
+ killAll();
344
+ done();
345
+ });
346
+
347
+ // ── start ─────────────────────────────────────────────────────────────
348
+ // Process starts immediately at deploy time.
349
+ updateStatus('stopped');
350
+ startProcess();
543
351
  }
544
352
 
545
- //* end
546
- RED.nodes.registerType("exec.service", ExecQueueNode);
547
- RED.library.register("templates");
548
- }
353
+ RED.nodes.registerType('exec.service', ExecServiceNode);
354
+
355
+ RED.httpAdmin.post('/exec-service/:id/kill', function(req, res) {
356
+ const n = RED.nodes.getNode(req.params.id);
357
+ if (n && typeof n.killAll === 'function') {
358
+ n.killAll();
359
+ res.sendStatus(200);
360
+ } else {
361
+ res.sendStatus(404);
362
+ }
363
+ });
364
+
365
+ RED.httpAdmin.post('/exec-service/:id/restart', function(req, res) {
366
+ const n = RED.nodes.getNode(req.params.id);
367
+ if (n && typeof n.restartService === 'function') {
368
+ n.restartService();
369
+ res.sendStatus(200);
370
+ } else {
371
+ res.sendStatus(404);
372
+ }
373
+ });
374
+
375
+ RED.httpAdmin.get('/exec-service/:id/status', function(req, res) {
376
+ const n = RED.nodes.getNode(req.params.id);
377
+ if (n) {
378
+ res.json({
379
+ running: n._serviceState === 'running',
380
+ restarting: n._serviceState === 'restarting'
381
+ });
382
+ } else {
383
+ res.sendStatus(404);
384
+ }
385
+ });
386
+ };