@tbrandenburg/node-red-agents 0.1.1
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/LICENSE +21 -0
- package/README.md +34 -0
- package/nodes/agent/agent.html +550 -0
- package/nodes/agent/agent.js +396 -0
- package/nodes/agent/icons/agent.svg +27 -0
- package/nodes/agent/lib/agents/base.js +42 -0
- package/nodes/agent/lib/agents/opencode.js +141 -0
- package/nodes/agent/lib/agents/pi.js +220 -0
- package/nodes/agent/lib/execution/lifecycle.js +69 -0
- package/nodes/agent/lib/execution/scheduler.js +97 -0
- package/nodes/agent/lib/execution/status.js +24 -0
- package/nodes/agent/lib/mcp/normalize.js +31 -0
- package/nodes/agent/lib/runtimes/base.js +21 -0
- package/nodes/agent/lib/runtimes/direct.js +28 -0
- package/nodes/agent/lib/runtimes/process-exec.js +105 -0
- package/nodes/agent/lib/runtimes/srt.js +63 -0
- package/nodes/agent-server/agent-server.html +365 -0
- package/nodes/agent-server/agent-server.js +481 -0
- package/nodes/agent-server/icons/agent.svg +27 -0
- package/nodes/agent-server/lib/daemon.js +149 -0
- package/nodes/agent-server/lib/http.js +60 -0
- package/nodes/agent-server/lib/model.js +20 -0
- package/nodes/agent-server/lib/port.js +31 -0
- package/nodes/agent-server/lib/registry.js +77 -0
- package/nodes/agent-server/lib/status.js +15 -0
- package/nodes/gh/README.md +75 -0
- package/nodes/gh/examples/list-pull-requests.json +48 -0
- package/nodes/gh/examples/run-workflow.json +42 -0
- package/nodes/gh/gh.html +146 -0
- package/nodes/gh/gh.js +237 -0
- package/nodes/gh/icons/gh.svg +15 -0
- package/nodes/gh/lib/parse-args.js +67 -0
- package/package.json +60 -0
- package/shared/srt-settings.js +71 -0
package/nodes/gh/gh.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
const { parseArgs } = require('./lib/parse-args');
|
|
5
|
+
|
|
6
|
+
const GH_BINARY = 'gh';
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 60000;
|
|
8
|
+
|
|
9
|
+
// Resolves a typed-input style config field (str/msg/flow/global), honoring
|
|
10
|
+
// the Node-RED typedInput convention used elsewhere in this repo (see
|
|
11
|
+
// opencode-run.js's prompt/cwd handling). An empty 'str' path is treated as
|
|
12
|
+
// "not configured" rather than an evaluation error.
|
|
13
|
+
function evalField(RED, node, msg, value, type) {
|
|
14
|
+
if (type === 'str' && (value === undefined || value === null || value === '')) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
return RED.util.evaluateNodeProperty(value, type, node, msg);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// A valid gh top-level command is a single token (e.g. "pr", "issue",
|
|
21
|
+
// "api") -- never the literal "gh" itself and never something containing
|
|
22
|
+
// whitespace (which would indicate a whole command line was pasted in).
|
|
23
|
+
function validateCommand(command) {
|
|
24
|
+
if (typeof command !== 'string' || command.trim() === '') {
|
|
25
|
+
return 'no command configured (set the Command field or msg.gh.command)';
|
|
26
|
+
}
|
|
27
|
+
const trimmed = command.trim();
|
|
28
|
+
if (/\s/.test(trimmed)) {
|
|
29
|
+
return 'Command must be a single gh subcommand (e.g. "pr"), not a full command line: "' + trimmed + '"';
|
|
30
|
+
}
|
|
31
|
+
if (trimmed.toLowerCase() === 'gh') {
|
|
32
|
+
return '"gh" is the executable itself, not a command -- use e.g. "pr" with gh already implied';
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Resolves an "args" value (from config or msg.gh.args) into a string[].
|
|
38
|
+
// Strings are tokenized (quote-aware, no shell evaluation); arrays are used
|
|
39
|
+
// as-is (each element coerced to a string); anything else is rejected.
|
|
40
|
+
function resolveArgs(value) {
|
|
41
|
+
if (value === undefined || value === null) return [];
|
|
42
|
+
if (Array.isArray(value)) return value.map(String);
|
|
43
|
+
if (typeof value === 'string') return parseArgs(value);
|
|
44
|
+
throw new Error('args must be a string or an array of strings, got ' + typeof value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = function (RED) {
|
|
48
|
+
function GhNode(config) {
|
|
49
|
+
RED.nodes.createNode(this, config);
|
|
50
|
+
const node = this;
|
|
51
|
+
|
|
52
|
+
node.command = config.command || '';
|
|
53
|
+
node.commandType = config.commandType || 'str';
|
|
54
|
+
node.args = config.args || '';
|
|
55
|
+
node.argsType = config.argsType || 'str';
|
|
56
|
+
node.repo = config.repo || '';
|
|
57
|
+
node.repoType = config.repoType || 'str';
|
|
58
|
+
node.host = config.host || '';
|
|
59
|
+
node.timeoutMs = Number(config.timeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
60
|
+
|
|
61
|
+
node.on('input', function (msg, send, done) {
|
|
62
|
+
const ghOverride = (msg.gh && typeof msg.gh === 'object') ? msg.gh : {};
|
|
63
|
+
|
|
64
|
+
// --- command ---
|
|
65
|
+
let command;
|
|
66
|
+
try {
|
|
67
|
+
command = Object.prototype.hasOwnProperty.call(ghOverride, 'command')
|
|
68
|
+
? ghOverride.command
|
|
69
|
+
: evalField(RED, node, msg, node.command, node.commandType);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
node.status({ fill: 'red', shape: 'ring', text: 'bad command config' });
|
|
72
|
+
done(err);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const commandError = validateCommand(command);
|
|
77
|
+
if (commandError) {
|
|
78
|
+
node.status({ fill: 'red', shape: 'ring', text: 'bad command' });
|
|
79
|
+
done(new Error('gh: ' + commandError));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
command = command.trim();
|
|
83
|
+
|
|
84
|
+
// --- args ---
|
|
85
|
+
let rawArgs;
|
|
86
|
+
try {
|
|
87
|
+
rawArgs = Object.prototype.hasOwnProperty.call(ghOverride, 'args')
|
|
88
|
+
? ghOverride.args
|
|
89
|
+
: evalField(RED, node, msg, node.args, node.argsType);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
node.status({ fill: 'red', shape: 'ring', text: 'bad args config' });
|
|
92
|
+
done(err);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let args;
|
|
97
|
+
try {
|
|
98
|
+
args = resolveArgs(rawArgs);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
node.status({ fill: 'red', shape: 'ring', text: 'bad args' });
|
|
101
|
+
done(new Error('gh: ' + err.message));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- repo ---
|
|
106
|
+
let repo;
|
|
107
|
+
try {
|
|
108
|
+
repo = Object.prototype.hasOwnProperty.call(ghOverride, 'repo')
|
|
109
|
+
? ghOverride.repo
|
|
110
|
+
: evalField(RED, node, msg, node.repo, node.repoType);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
node.status({ fill: 'red', shape: 'ring', text: 'bad repo config' });
|
|
113
|
+
done(err);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
repo = (repo === undefined || repo === null || repo === '') ? undefined : String(repo);
|
|
117
|
+
|
|
118
|
+
// --- host (Advanced; simple str field, no typed input) ---
|
|
119
|
+
const host = ghOverride.host || node.host || undefined;
|
|
120
|
+
|
|
121
|
+
node.status({ fill: 'blue', shape: 'dot', text: 'running ' + command });
|
|
122
|
+
|
|
123
|
+
const env = Object.assign({}, process.env);
|
|
124
|
+
if (repo) env.GH_REPO = repo;
|
|
125
|
+
if (host) env.GH_HOST = host;
|
|
126
|
+
|
|
127
|
+
let stdout = '';
|
|
128
|
+
let stderr = '';
|
|
129
|
+
let child;
|
|
130
|
+
try {
|
|
131
|
+
child = spawn(GH_BINARY, [command, ...args], {
|
|
132
|
+
env,
|
|
133
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
134
|
+
shell: false
|
|
135
|
+
});
|
|
136
|
+
} catch (err) {
|
|
137
|
+
node.status({ fill: 'red', shape: 'ring', text: 'spawn failed' });
|
|
138
|
+
done(err);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Own explicit timeout instead of spawn()'s built-in timeout
|
|
143
|
+
// option: it's simpler to reason about (we control exactly when
|
|
144
|
+
// the timer is armed/cleared) and sidesteps that option's timer
|
|
145
|
+
// not always being cleared promptly when the child never
|
|
146
|
+
// actually starts (e.g. ENOENT).
|
|
147
|
+
let timedOut = false;
|
|
148
|
+
const timer = setTimeout(() => {
|
|
149
|
+
timedOut = true;
|
|
150
|
+
child.kill('SIGTERM');
|
|
151
|
+
}, node.timeoutMs);
|
|
152
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
153
|
+
|
|
154
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
155
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
156
|
+
|
|
157
|
+
child.on('error', (err) => {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
node.status({ fill: 'red', shape: 'ring', text: 'error' });
|
|
160
|
+
const message = (err && err.code === 'ENOENT')
|
|
161
|
+
? 'gh executable not found on PATH'
|
|
162
|
+
: 'failed to run gh: ' + err.message;
|
|
163
|
+
const wrapped = new Error('gh: ' + message);
|
|
164
|
+
wrapped.command = command;
|
|
165
|
+
wrapped.args = args;
|
|
166
|
+
done(wrapped);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
child.on('close', (code, signal) => {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
if (signal) {
|
|
172
|
+
node.status({ fill: 'red', shape: 'ring', text: timedOut ? 'timeout' : 'killed' });
|
|
173
|
+
const err = new Error(
|
|
174
|
+
timedOut
|
|
175
|
+
? 'gh: timed out after ' + node.timeoutMs + 'ms and was killed'
|
|
176
|
+
: 'gh: process killed by signal ' + signal
|
|
177
|
+
);
|
|
178
|
+
err.command = command;
|
|
179
|
+
err.args = args;
|
|
180
|
+
err.signal = signal;
|
|
181
|
+
err.timedOut = timedOut;
|
|
182
|
+
done(err);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (code !== 0) {
|
|
187
|
+
node.status({ fill: 'red', shape: 'dot', text: 'exit ' + code });
|
|
188
|
+
const err = new Error(
|
|
189
|
+
'gh: command failed (exit ' + code + ')' + (stderr.trim() ? ': ' + stderr.trim() : '')
|
|
190
|
+
);
|
|
191
|
+
err.command = command;
|
|
192
|
+
err.args = args;
|
|
193
|
+
err.exitCode = code;
|
|
194
|
+
err.stderr = stderr.trim();
|
|
195
|
+
done(err);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const text = stdout.replace(/\r?\n$/, '');
|
|
200
|
+
let payload;
|
|
201
|
+
try {
|
|
202
|
+
payload = JSON.parse(text.trim());
|
|
203
|
+
} catch (e) {
|
|
204
|
+
payload = text;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
msg.payload = payload;
|
|
208
|
+
msg.gh = {
|
|
209
|
+
command,
|
|
210
|
+
args,
|
|
211
|
+
repo: repo || '',
|
|
212
|
+
host: host || '',
|
|
213
|
+
exitCode: 0,
|
|
214
|
+
stderr: stderr.trim()
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
node.status({ fill: 'green', shape: 'dot', text: 'done' });
|
|
218
|
+
const clearStatus = setTimeout(() => node.status({}), 2000);
|
|
219
|
+
if (typeof clearStatus.unref === 'function') clearStatus.unref();
|
|
220
|
+
send(msg);
|
|
221
|
+
done();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
node._child = child;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
node.on('close', function (done) {
|
|
228
|
+
if (node._child && !node._child.killed) {
|
|
229
|
+
node._child.kill();
|
|
230
|
+
}
|
|
231
|
+
node.status({});
|
|
232
|
+
done();
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
RED.nodes.registerType('gh', GhNode);
|
|
237
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 60">
|
|
2
|
+
<!--
|
|
3
|
+
Node-RED custom node icon requirements (docs/creating-nodes/appearance.md):
|
|
4
|
+
white on transparent background, 2:3 aspect ratio, >= 40x60px (same
|
|
5
|
+
convention as nodes/agent/icons/agent.svg).
|
|
6
|
+
|
|
7
|
+
Artwork is the GitHub "octocat" mark from the Simple Icons project
|
|
8
|
+
(https://simpleicons.org/, CC0-1.0 license, public domain, free to
|
|
9
|
+
redistribute), recolored white and centered in the 40x60 canvas.
|
|
10
|
+
Original 24x24 path source: https://cdn.simpleicons.org/github/ffffff
|
|
11
|
+
-->
|
|
12
|
+
<g fill="#ffffff" transform="translate(4,14) scale(1.3333)">
|
|
13
|
+
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
|
|
14
|
+
</g>
|
|
15
|
+
</svg>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Splits a string of gh CLI arguments into an argv array without any shell
|
|
4
|
+
// evaluation. Single- and double-quoted spans are kept as one argument
|
|
5
|
+
// (quotes themselves are stripped); everything else -- including shell
|
|
6
|
+
// metacharacters like &&, |, ;, `, $() -- is passed through as literal
|
|
7
|
+
// characters, never interpreted.
|
|
8
|
+
//
|
|
9
|
+
// Examples:
|
|
10
|
+
// 'list --state open' -> ['list', '--state', 'open']
|
|
11
|
+
// 'list --label "needs review"' -> ['list', '--label', 'needs review']
|
|
12
|
+
// 'pr list && rm -rf /' -> ['pr', 'list', '&&', 'rm', '-rf', '/']
|
|
13
|
+
function parseArgs(str) {
|
|
14
|
+
if (str === undefined || str === null) return [];
|
|
15
|
+
if (typeof str !== 'string') {
|
|
16
|
+
throw new Error('parseArgs: expected a string, got ' + typeof str);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const args = [];
|
|
20
|
+
let current = '';
|
|
21
|
+
let inArg = false;
|
|
22
|
+
let quote = null; // '"' or "'" while inside a quoted span
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < str.length; i += 1) {
|
|
25
|
+
const ch = str[i];
|
|
26
|
+
|
|
27
|
+
if (quote) {
|
|
28
|
+
if (ch === quote) {
|
|
29
|
+
quote = null;
|
|
30
|
+
} else {
|
|
31
|
+
current += ch;
|
|
32
|
+
}
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (ch === '"' || ch === "'") {
|
|
37
|
+
quote = ch;
|
|
38
|
+
inArg = true;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
|
43
|
+
if (inArg) {
|
|
44
|
+
args.push(current);
|
|
45
|
+
current = '';
|
|
46
|
+
inArg = false;
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
current += ch;
|
|
52
|
+
inArg = true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (quote) {
|
|
56
|
+
// Unterminated quote: fail safe by treating the rest of the
|
|
57
|
+
// string as literal content rather than throwing, since this is
|
|
58
|
+
// config text a user can fix, not something to crash a flow over.
|
|
59
|
+
args.push(current);
|
|
60
|
+
} else if (inArg) {
|
|
61
|
+
args.push(current);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return args;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { parseArgs };
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tbrandenburg/node-red-agents",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Node-RED nodes for running coding agents (opencode, pi) and GitHub CLI operations from flows.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"node-red",
|
|
7
|
+
"agent",
|
|
8
|
+
"ai-agent",
|
|
9
|
+
"opencode",
|
|
10
|
+
"pi",
|
|
11
|
+
"coding-agent",
|
|
12
|
+
"sandbox",
|
|
13
|
+
"srt",
|
|
14
|
+
"ai",
|
|
15
|
+
"llm",
|
|
16
|
+
"github",
|
|
17
|
+
"gh",
|
|
18
|
+
"github-cli"
|
|
19
|
+
],
|
|
20
|
+
"homepage": "https://github.com/tbrandenburg/node-red-agents#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/tbrandenburg/node-red-agents/issues"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/tbrandenburg/node-red-agents.git",
|
|
27
|
+
"directory": "packages/node-red-agents"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"nodes/**/*.js",
|
|
35
|
+
"nodes/**/*.html",
|
|
36
|
+
"nodes/**/icons/**",
|
|
37
|
+
"nodes/**/examples/**",
|
|
38
|
+
"nodes/gh/README.md",
|
|
39
|
+
"shared/**/*.js",
|
|
40
|
+
"!**/test/**",
|
|
41
|
+
"!**/fixtures/**"
|
|
42
|
+
],
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=22"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "node --test --experimental-test-coverage=false 'nodes/**/test/**/*.spec.js' 'shared/**/test/**/*.spec.js'"
|
|
48
|
+
},
|
|
49
|
+
"node-red": {
|
|
50
|
+
"version": ">=4.0.0",
|
|
51
|
+
"nodes": {
|
|
52
|
+
"agent": "nodes/agent/agent.js",
|
|
53
|
+
"agent-server": "nodes/agent-server/agent-server.js",
|
|
54
|
+
"gh": "nodes/gh/gh.js"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"node-red-node-test-helper": "^0.3.6"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
// Translates the simple "Inline" SRT settings UI (two string lists + one
|
|
8
|
+
// checkbox), used by both the `agent` and `agent-server` nodes, into the
|
|
9
|
+
// JSON shape `srt` itself requires, and writes it to a temp file (srt only
|
|
10
|
+
// accepts a file path via -s, not inline JSON). Kept separate from
|
|
11
|
+
// lib/runtimes/srt.js (agent) and lib/daemon.js (agent-server), which stay
|
|
12
|
+
// completely runtime-source-agnostic -- they only ever see a settingsPath
|
|
13
|
+
// string and don't care whether it came from this module or a hand-authored
|
|
14
|
+
// file (spec: "SRT-specific configuration... implemented by the runtime
|
|
15
|
+
// adapter rather than embedded in the Agent node core").
|
|
16
|
+
//
|
|
17
|
+
// Shared here (not duplicated) because both consumers now live in the same
|
|
18
|
+
// publishable package (packages/node-red-agents) -- see docs/260817_Refactoring.md
|
|
19
|
+
// step 8. Previously this was a verbatim copy in each node's lib/, kept
|
|
20
|
+
// separate only because each node used to be its own standalone npm package.
|
|
21
|
+
//
|
|
22
|
+
// srt requires network.allowedDomains, network.deniedDomains,
|
|
23
|
+
// filesystem.denyRead, and filesystem.denyWrite to all be *present* (even
|
|
24
|
+
// as empty arrays) -- verified empirically: srt refuses to run and prints
|
|
25
|
+
// "<field>: Required" for each one missing. This function always includes
|
|
26
|
+
// all four.
|
|
27
|
+
function buildSettingsJson({ allowedDomains, allowedWriteDirs, strictAllowlist } = {}) {
|
|
28
|
+
return {
|
|
29
|
+
network: {
|
|
30
|
+
allowedDomains: Array.isArray(allowedDomains) ? allowedDomains : [],
|
|
31
|
+
deniedDomains: [],
|
|
32
|
+
strictAllowlist: strictAllowlist !== false
|
|
33
|
+
},
|
|
34
|
+
filesystem: {
|
|
35
|
+
allowWrite: Array.isArray(allowedWriteDirs) ? allowedWriteDirs : [],
|
|
36
|
+
denyRead: [],
|
|
37
|
+
denyWrite: []
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// config: { allowedDomains, allowedWriteDirs, strictAllowlist, advancedJson }
|
|
43
|
+
// Returns the JSON string that will be written (and validated as parseable
|
|
44
|
+
// JSON) -- does not touch the filesystem. Kept separate from
|
|
45
|
+
// writeInlineSettingsFile so tests can check the generated content without
|
|
46
|
+
// needing a real filesystem.
|
|
47
|
+
function resolveSettingsJsonString(config = {}) {
|
|
48
|
+
const raw = config.advancedJson && config.advancedJson.trim() ? config.advancedJson : JSON.stringify(buildSettingsJson(config));
|
|
49
|
+
// Throws a clear SyntaxError if the (usually hand-edited advanced) JSON
|
|
50
|
+
// is invalid -- deliberately no deeper validation than "is this valid
|
|
51
|
+
// JSON"; srt's own runtime error for a structurally-wrong-but-valid-JSON
|
|
52
|
+
// settings file is already clear and surfaces through the normal
|
|
53
|
+
// execution/spawn-failure path.
|
|
54
|
+
JSON.parse(raw);
|
|
55
|
+
return raw;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// nodeId is used only to make the temp filename recognizable/stable per
|
|
59
|
+
// node instance; process.pid is appended so a fast redeploy sequence can
|
|
60
|
+
// never have two live Node-RED processes racing on the same file.
|
|
61
|
+
// filePrefix distinguishes callers (e.g. 'agent' vs 'agent-server') so two
|
|
62
|
+
// different node types configuring the same nodeId-shaped id can never
|
|
63
|
+
// collide on the same temp file.
|
|
64
|
+
function writeInlineSettingsFile(nodeId, config, filePrefix = 'srt-settings') {
|
|
65
|
+
const json = resolveSettingsJsonString(config);
|
|
66
|
+
const filePath = path.join(os.tmpdir(), `${filePrefix}-${nodeId}-${process.pid}.json`);
|
|
67
|
+
fs.writeFileSync(filePath, json);
|
|
68
|
+
return filePath;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { buildSettingsJson, resolveSettingsJsonString, writeInlineSettingsFile };
|