@inteli.city/node-red-plugin-project-files 1.0.0

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.
@@ -0,0 +1,469 @@
1
+ module.exports = function (RED) {
2
+ const path = require("path");
3
+ const fs = require("fs");
4
+ const fsp = require("fs").promises;
5
+
6
+ // ── Config ────────────────────────────────────────────────────────────────
7
+ // The plugin runs in one of two explicit operating modes, decided per request
8
+ // from the live Node-RED environment (see resolveScope()):
9
+ //
10
+ // • Project Mode — an active Node-RED project exists. The authoritative
11
+ // filesystem boundary is that project's directory.
12
+ // • User Directory — no active project (Projects disabled, or enabled but
13
+ // Mode no project selected). The boundary is <userDir>.
14
+ //
15
+ // PROJECTS_DIR is where Node-RED stores projects (and the path-encoding anchor
16
+ // for Project Mode). Override with `projectFiles.baseDir` in settings.js.
17
+ const cfg = RED.settings.get("projectFiles") || {};
18
+ const USER_DIR = path.resolve(RED.settings.userDir || process.cwd());
19
+ const PROJECTS_DIR = cfg.baseDir
20
+ ? path.resolve(cfg.baseDir)
21
+ : path.join(USER_DIR, "projects");
22
+ const MAX_BYTES = cfg.maxBytes || 50 * 1024 * 1024;
23
+
24
+ // External commands. Resolved from PATH by default so the plugin is portable
25
+ // across OSes and installs; override the Python interpreter via
26
+ // `projectFiles.pythonPath` in settings.js.
27
+ const PYTHON = cfg.pythonPath || (process.platform === "win32" ? "python" : "python3");
28
+ const NPM = process.platform === "win32" ? "npm.cmd" : "npm";
29
+
30
+ const PROTECTED_FILES = new Set([
31
+ "flows.json", "flows_cred.json", "package.json",
32
+ ".flows.json.backup", ".flows_cred.json.backup",
33
+ ".git",
34
+ ]);
35
+
36
+ // Best-effort: make sure the projects directory exists so a fresh install in
37
+ // Project Mode lists an empty tree instead of erroring. Never fatal — a
38
+ // failure here must not stop Node-RED from loading. (USER_DIR always exists.)
39
+ try { fs.mkdirSync(PROJECTS_DIR, { recursive: true }); }
40
+ catch (e) { RED.log.warn(`[project-files] could not create projects dir: ${e.code || e.message}`); }
41
+
42
+ // ── Operating-mode resolution ─────────────────────────────────────────────
43
+ // Read fresh on every request so switching/creating/closing a project at
44
+ // runtime (no NR restart) takes effect immediately. We never simulate a
45
+ // project when none is active — the absence of one *is* User Directory Mode.
46
+ function activeProjectName() {
47
+ try { return (RED.settings.get("projects") || {}).activeProject || null; }
48
+ catch (e) { return null; }
49
+ }
50
+ function projectsEnabled() {
51
+ try {
52
+ const et = RED.settings.editorTheme;
53
+ if (et && et.projects && typeof et.projects.enabled === "boolean") return et.projects.enabled;
54
+ } catch (e) { /* unknown */ }
55
+ return null; // undetermined — treated as "not enabled" for messaging only
56
+ }
57
+
58
+ // Returns the scope for the current request:
59
+ // mode — "project" | "user"
60
+ // project — active project name (Project Mode) or null
61
+ // anchor — directory that relative paths are resolved/encoded against
62
+ // boundary — the authoritative security boundary; nothing may escape it
63
+ function resolveScope() {
64
+ const active = activeProjectName();
65
+ if (active) {
66
+ return {
67
+ mode: "project",
68
+ project: active,
69
+ anchor: PROJECTS_DIR,
70
+ boundary: path.join(PROJECTS_DIR, active),
71
+ };
72
+ }
73
+ return {
74
+ mode: "user",
75
+ project: null,
76
+ anchor: USER_DIR,
77
+ boundary: USER_DIR,
78
+ };
79
+ }
80
+
81
+ // Sandbox check — every request goes through this. `abs` must already be an
82
+ // absolute, resolved path (callers use path.resolve(scope.anchor, …)).
83
+ // Rejects anything outside the active boundary, including ../ traversal and
84
+ // sibling directories that merely share a name prefix.
85
+ function within(abs, boundary) {
86
+ const n = path.normalize(abs);
87
+ const b = path.normalize(boundary);
88
+ return n === b || n.startsWith(b + path.sep);
89
+ }
90
+
91
+ // Resolve a client-supplied, anchor-relative path and enforce the boundary in
92
+ // one step. Returns the absolute path, or null if it escapes the boundary.
93
+ function resolveInScope(scope, rel) {
94
+ const abs = path.resolve(scope.anchor, rel || ".");
95
+ return within(abs, scope.boundary) ? abs : null;
96
+ }
97
+ // Encode an absolute path back to an anchor-relative, forward-slash path.
98
+ function toRel(scope, abs) {
99
+ return path.relative(scope.anchor, abs).replace(/\\/g, "/");
100
+ }
101
+
102
+ // Map a thrown error to a generic, path-free message safe to return to the
103
+ // client, and log the real error server-side for operators. This keeps
104
+ // absolute filesystem paths and internal structure out of HTTP responses.
105
+ function safeErr(e) {
106
+ switch (e && e.code) {
107
+ case "ENOENT": return "File or folder not found";
108
+ case "EACCES":
109
+ case "EPERM": return "Permission denied";
110
+ case "EEXIST": return "A file or folder with that name already exists";
111
+ case "ENOTDIR": return "Path is not a directory";
112
+ case "EISDIR": return "Path is a directory";
113
+ case "ENOSPC": return "No space left on device";
114
+ case "EROFS": return "Filesystem is read-only";
115
+ default: return "Operation failed";
116
+ }
117
+ }
118
+ function logErr(where, e) {
119
+ RED.log.warn(`[project-files] ${where}: ${(e && (e.stack || e.message)) || e}`);
120
+ }
121
+ // Generic out-of-scope rejection — never reveals the boundary path itself.
122
+ const OUT_OF_SCOPE = "Path is outside the allowed scope for the current mode";
123
+
124
+ async function trystat(abs) {
125
+ try { return await fsp.stat(abs); } catch { return null; }
126
+ }
127
+
128
+ // ── Auth middleware ───────────────────────────────────────────────────────
129
+ const canRead = RED.auth.needsPermission("projectFiles.read");
130
+ const canWrite = RED.auth.needsPermission("projectFiles.write");
131
+
132
+ // ── Routes ────────────────────────────────────────────────────────────────
133
+
134
+ // Config: tell the client the active mode and the directory scope it manages.
135
+ RED.httpAdmin.get("/project-files/config", canRead, (req, res) => {
136
+ const scope = resolveScope();
137
+ res.json({
138
+ mode: scope.mode, // "project" | "user"
139
+ activeProject: scope.project, // name, or null in User Directory Mode
140
+ projectsEnabled: projectsEnabled(), // true | false | null (undetermined)
141
+ baseDir: scope.anchor, // path-encoding anchor (for "copy path"/labels)
142
+ scopeDir: scope.boundary, // the authoritative boundary for this mode
143
+ userDir: USER_DIR,
144
+ });
145
+ });
146
+
147
+ // List directory contents.
148
+ RED.httpAdmin.get("/project-files/list", canRead, async (req, res) => {
149
+ try {
150
+ const scope = resolveScope();
151
+ const dirAbs = resolveInScope(scope, req.query.path || ".");
152
+ if (!dirAbs) return res.status(400).json({ error: OUT_OF_SCOPE });
153
+
154
+ const ents = await fsp.readdir(dirAbs, { withFileTypes: true });
155
+ const items = [];
156
+ for (const de of ents) {
157
+ const full = path.join(dirAbs, de.name);
158
+ const st = await trystat(full);
159
+ items.push({
160
+ name: de.name,
161
+ path: toRel(scope, full),
162
+ type: de.isDirectory() ? "dir" : "file",
163
+ size: st ? st.size : 0,
164
+ mtime: st ? st.mtimeMs : 0,
165
+ });
166
+ }
167
+ items.sort((a, b) => {
168
+ if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
169
+ return a.name.localeCompare(b.name);
170
+ });
171
+
172
+ const rel = toRel(scope, dirAbs) || ".";
173
+ res.json({
174
+ mode: scope.mode,
175
+ baseDir: scope.anchor,
176
+ scopeDir: scope.boundary,
177
+ cwd: rel,
178
+ items,
179
+ breadcrumb: rel === "." ? [] : rel.split("/").filter(Boolean),
180
+ });
181
+ } catch (e) { logErr("list", e); res.status(400).json({ error: safeErr(e) }); }
182
+ });
183
+
184
+ // Open a text file for reading.
185
+ RED.httpAdmin.get("/project-files/open", canRead, async (req, res) => {
186
+ try {
187
+ const scope = resolveScope();
188
+ const abs = resolveInScope(scope, req.query.path || "");
189
+ if (!abs) return res.status(400).json({ error: OUT_OF_SCOPE });
190
+ const st = await fsp.stat(abs);
191
+ if (!st.isFile()) return res.status(400).json({ error: "Not a file" });
192
+ if (st.size > MAX_BYTES) return res.status(400).json({ error: "File too large" });
193
+ const buf = await fsp.readFile(abs);
194
+ if (buf.includes(0)) return res.status(400).json({ error: "Binary file not supported" });
195
+ res.json({ text: buf.toString("utf8"), size: st.size, mtime: st.mtimeMs });
196
+ } catch (e) { logErr("open", e); res.status(400).json({ error: safeErr(e) }); }
197
+ });
198
+
199
+ // Stat a file (used by the on-disk-change poller).
200
+ RED.httpAdmin.get("/project-files/stat", canRead, async (req, res) => {
201
+ try {
202
+ const scope = resolveScope();
203
+ const abs = resolveInScope(scope, req.query.path || "");
204
+ if (!abs) return res.status(400).json({ error: OUT_OF_SCOPE });
205
+ const st = await fsp.stat(abs);
206
+ if (!st.isFile()) return res.status(400).json({ error: "Not a file" });
207
+ res.json({ size: st.size, mtime: st.mtimeMs });
208
+ } catch (e) { logErr("stat", e); res.status(400).json({ error: safeErr(e) }); }
209
+ });
210
+
211
+ // Write (save) a text file.
212
+ RED.httpAdmin.post("/project-files/save", canWrite, async (req, res) => {
213
+ try {
214
+ const scope = resolveScope();
215
+ const { path: rel, text } = req.body || {};
216
+ if (!rel) return res.status(400).json({ error: "Missing path" });
217
+ const abs = resolveInScope(scope, rel);
218
+ if (!abs) return res.status(400).json({ error: OUT_OF_SCOPE });
219
+ const dirSt = await trystat(path.dirname(abs));
220
+ if (!dirSt || !dirSt.isDirectory()) return res.status(400).json({ error: "Parent folder missing" });
221
+ const buf = Buffer.from(String(text ?? ""), "utf8");
222
+ if (buf.length > MAX_BYTES) return res.status(400).json({ error: "Content too large" });
223
+ await fsp.writeFile(abs, buf);
224
+ const st = await trystat(abs);
225
+ res.json({ ok: true, size: st?.size ?? buf.length, mtime: st?.mtimeMs ?? Date.now() });
226
+ } catch (e) { logErr("save", e); res.status(400).json({ error: safeErr(e) }); }
227
+ });
228
+
229
+ // Create an empty file.
230
+ RED.httpAdmin.post("/project-files/new-file", canWrite, async (req, res) => {
231
+ try {
232
+ const scope = resolveScope();
233
+ const { dir, name } = req.body || {};
234
+ if (!name || /[\\/:*?"<>|]/.test(name)) return res.status(400).json({ error: "Invalid filename" });
235
+ const abs = resolveInScope(scope, path.join(dir || ".", name));
236
+ if (!abs) return res.status(400).json({ error: OUT_OF_SCOPE });
237
+ if (await trystat(abs)) return res.status(400).json({ error: "File already exists" });
238
+ await fsp.writeFile(abs, "");
239
+ const st = await trystat(abs);
240
+ res.json({ ok: true, path: toRel(scope, abs), size: st?.size ?? 0, mtime: st?.mtimeMs ?? Date.now() });
241
+ } catch (e) { logErr("new-file", e); res.status(400).json({ error: safeErr(e) }); }
242
+ });
243
+
244
+ // Move a file into a target directory.
245
+ RED.httpAdmin.post("/project-files/move", canWrite, async (req, res) => {
246
+ try {
247
+ const scope = resolveScope();
248
+ const { path: rel, dir, overwrite } = req.body || {};
249
+ if (!rel || !dir) return res.status(400).json({ error: "Missing path or dir" });
250
+ const srcAbs = resolveInScope(scope, rel);
251
+ if (!srcAbs) return res.status(400).json({ error: OUT_OF_SCOPE });
252
+ if (PROTECTED_FILES.has(path.basename(srcAbs))) return res.status(403).json({ error: "This file is protected and cannot be moved." });
253
+ const st = await trystat(srcAbs);
254
+ if (!st || !st.isFile()) return res.status(400).json({ error: "Source not found or not a file" });
255
+ const destDirAbs = resolveInScope(scope, dir);
256
+ if (!destDirAbs) return res.status(400).json({ error: OUT_OF_SCOPE });
257
+ const destDirSt = await trystat(destDirAbs);
258
+ if (!destDirSt || !destDirSt.isDirectory()) return res.status(400).json({ error: "Destination is not a directory" });
259
+ const filename = path.basename(srcAbs);
260
+ const destAbs = path.join(destDirAbs, filename);
261
+ if (path.normalize(destAbs) === path.normalize(srcAbs))
262
+ return res.status(400).json({ error: "Source and destination are the same" });
263
+ if (await trystat(destAbs) && !overwrite)
264
+ return res.status(409).json({ error: "A file named \"" + filename + "\" already exists there" });
265
+ await fsp.rename(srcAbs, destAbs);
266
+ res.json({ ok: true, path: toRel(scope, destAbs) });
267
+ } catch (e) { logErr("move", e); res.status(400).json({ error: safeErr(e) }); }
268
+ });
269
+
270
+ // Upload a file (base64-encoded body).
271
+ RED.httpAdmin.post("/project-files/upload", canWrite, async (req, res) => {
272
+ try {
273
+ const scope = resolveScope();
274
+ const { dir, name, data } = req.body || {};
275
+ if (!name || /[\\/:*?"<>|]/.test(name)) return res.status(400).json({ error: "Invalid filename" });
276
+ const abs = resolveInScope(scope, path.join(dir || ".", name));
277
+ if (!abs) return res.status(400).json({ error: OUT_OF_SCOPE });
278
+ const buf = Buffer.from(data || "", "base64");
279
+ if (buf.length > MAX_BYTES) return res.status(400).json({ error: "File too large" });
280
+ await fsp.writeFile(abs, buf);
281
+ const st = await trystat(abs);
282
+ res.json({ ok: true, path: toRel(scope, abs), size: st?.size ?? buf.length });
283
+ } catch (e) { logErr("upload", e); res.status(400).json({ error: safeErr(e) }); }
284
+ });
285
+
286
+ // Rename a file or folder (same parent directory).
287
+ RED.httpAdmin.post("/project-files/rename", canWrite, async (req, res) => {
288
+ try {
289
+ const scope = resolveScope();
290
+ const { path: rel, name } = req.body || {};
291
+ if (!rel) return res.status(400).json({ error: "Missing path" });
292
+ if (!name || /[\\/:*?"<>|]/.test(name)) return res.status(400).json({ error: "Invalid name" });
293
+ const abs = resolveInScope(scope, rel);
294
+ if (!abs) return res.status(400).json({ error: OUT_OF_SCOPE });
295
+ const newAbs = path.join(path.dirname(abs), name);
296
+ if (!within(newAbs, scope.boundary)) return res.status(400).json({ error: OUT_OF_SCOPE });
297
+ if (PROTECTED_FILES.has(path.basename(abs))) return res.status(403).json({ error: "This file is protected and cannot be renamed." });
298
+ if (await trystat(newAbs)) return res.status(400).json({ error: "Name already exists" });
299
+ await fsp.rename(abs, newAbs);
300
+ res.json({ ok: true, path: toRel(scope, newAbs) });
301
+ } catch (e) { logErr("rename", e); res.status(400).json({ error: safeErr(e) }); }
302
+ });
303
+
304
+ // Delete a file or folder (folders removed recursively).
305
+ RED.httpAdmin.post("/project-files/delete", canWrite, async (req, res) => {
306
+ try {
307
+ const scope = resolveScope();
308
+ const { path: rel } = req.body || {};
309
+ if (!rel) return res.status(400).json({ error: "Missing path" });
310
+ const abs = resolveInScope(scope, rel);
311
+ if (!abs) return res.status(400).json({ error: OUT_OF_SCOPE });
312
+ if (PROTECTED_FILES.has(path.basename(abs))) return res.status(403).json({ error: "This file is protected and cannot be deleted." });
313
+ const st = await trystat(abs);
314
+ if (!st) return res.status(400).json({ error: "Not found" });
315
+ await fsp.rm(abs, { recursive: true, force: true });
316
+ res.json({ ok: true });
317
+ } catch (e) { logErr("delete", e); res.status(400).json({ error: safeErr(e) }); }
318
+ });
319
+
320
+ // Create a directory.
321
+ RED.httpAdmin.post("/project-files/new-folder", canWrite, async (req, res) => {
322
+ try {
323
+ const scope = resolveScope();
324
+ const { dir, name } = req.body || {};
325
+ if (!name || /[\\/:*?"<>|]/.test(name)) return res.status(400).json({ error: "Invalid folder name" });
326
+ const abs = resolveInScope(scope, path.join(dir || ".", name));
327
+ if (!abs) return res.status(400).json({ error: OUT_OF_SCOPE });
328
+ await fsp.mkdir(abs, { recursive: true });
329
+ res.json({ ok: true, path: toRel(scope, abs) });
330
+ } catch (e) { logErr("new-folder", e); res.status(400).json({ error: safeErr(e) }); }
331
+ });
332
+
333
+ // ── Project dependency routes (Project Mode only) ─────────────────────────
334
+ // Python virtual environments live at <project>/.venv and Node dependencies at
335
+ // <project>/node_modules. Both are inherently project-scoped: they target the
336
+ // *active project* and are UNAVAILABLE in User Directory Mode (no project
337
+ // boundary to attach to — we never simulate one). Each route therefore
338
+ // requires Project Mode and always operates on the active project directory
339
+ // (scope.boundary), ignoring any client-claimed project name for safety.
340
+ const { spawn } = require("child_process");
341
+
342
+ // Guard helper: ensures Project Mode and returns the active project dir, or
343
+ // sends a clear 400 and returns null.
344
+ function requireProject(res, feature) {
345
+ const scope = resolveScope();
346
+ if (scope.mode !== "project") {
347
+ res.status(400).json({ error: `${feature} is only available in Project Mode (no active Node-RED project).` });
348
+ return null;
349
+ }
350
+ return scope; // scope.boundary is the active project directory
351
+ }
352
+
353
+ // Ensure the given entry is listed in <projAbs>/.gitignore. Recognises common
354
+ // equivalent forms (.venv, .venv/, /.venv, /.venv/) so we don't create duplicates.
355
+ // Non-fatal: any I/O error is swallowed — a missing .gitignore line should never
356
+ // block venv creation itself.
357
+ async function ensureGitignored(projAbs, entry) {
358
+ const giAbs = path.join(projAbs, ".gitignore");
359
+ const target = entry.replace(/\/+$/, ""); // ".venv/" → ".venv"
360
+ const equiv = new Set([target, target + "/", "/" + target, "/" + target + "/"]);
361
+ try {
362
+ let existing = "";
363
+ try { existing = await fsp.readFile(giAbs, "utf8"); } catch { /* missing: create below */ }
364
+ const hit = existing.split(/\r?\n/).some((line) => equiv.has(line.trim()));
365
+ if (hit) return;
366
+ const prefix = existing.length && !existing.endsWith("\n") ? "\n" : "";
367
+ await fsp.writeFile(giAbs, existing + prefix + entry + "\n");
368
+ } catch { /* non-fatal */ }
369
+ }
370
+
371
+ function runChild(cmd, args, opts, done) {
372
+ let stdout = "", stderr = "";
373
+ let child;
374
+ try { child = spawn(cmd, args, opts); }
375
+ catch (e) { return done(-1, "", String(e.message || e)); }
376
+ child.stdout.on("data", (d) => { if (stdout.length < 65536) stdout += d.toString(); });
377
+ child.stderr.on("data", (d) => { if (stderr.length < 65536) stderr += d.toString(); });
378
+ child.on("error", (err) => done(-1, stdout, stderr || String(err.message || err)));
379
+ child.on("close", (code) => done(code, stdout, stderr));
380
+ }
381
+
382
+ // Report whether <project>/.venv exists as a directory.
383
+ RED.httpAdmin.get("/project-files/python-env", canRead, async (req, res) => {
384
+ try {
385
+ const scope = requireProject(res, "Python environments");
386
+ if (!scope) return;
387
+ const projAbs = scope.boundary;
388
+ const venvAbs = path.join(projAbs, ".venv");
389
+ const st = await trystat(venvAbs);
390
+ res.json({ present: !!(st && st.isDirectory()), path: scope.project + "/.venv" });
391
+ } catch (e) { logErr("python-env", e); res.status(400).json({ error: safeErr(e) }); }
392
+ });
393
+
394
+ // Create <project>/.venv for the active project using the configured Python.
395
+ RED.httpAdmin.post("/project-files/python-env/create", canWrite, async (req, res) => {
396
+ try {
397
+ const scope = requireProject(res, "Python environments");
398
+ if (!scope) return;
399
+ const projAbs = scope.boundary;
400
+ const projSt = await trystat(projAbs);
401
+ if (!projSt || !projSt.isDirectory()) return res.status(400).json({ error: "Project folder not found" });
402
+ const venvAbs = path.join(projAbs, ".venv");
403
+ if (await trystat(venvAbs)) return res.status(409).json({ error: "Python environment already exists" });
404
+ runChild(PYTHON, ["-m", "venv", venvAbs], { cwd: projAbs }, async (code, _out, err) => {
405
+ if (code !== 0) return res.status(500).json({ error: "venv creation failed: " + (err || "exit " + code).slice(-1000) });
406
+ // Seed an empty requirements.txt so the "Install Python libraries" action
407
+ // has an obvious target. Never overwrite an existing file.
408
+ const reqAbs = path.join(projAbs, "requirements.txt");
409
+ if (!(await trystat(reqAbs))) {
410
+ try { await fsp.writeFile(reqAbs, ""); } catch (e) { /* non-fatal */ }
411
+ }
412
+ // Add .venv/ to the project's .gitignore so the environment never gets committed.
413
+ await ensureGitignored(projAbs, ".venv/");
414
+ res.json({ ok: true, path: scope.project + "/.venv" });
415
+ });
416
+ } catch (e) { logErr("python-env/create", e); res.status(400).json({ error: safeErr(e) }); }
417
+ });
418
+
419
+ // Install a requirements file into the active project's .venv.
420
+ RED.httpAdmin.post("/project-files/python-env/install", canWrite, async (req, res) => {
421
+ try {
422
+ const scope = requireProject(res, "Python environments");
423
+ if (!scope) return;
424
+ const projAbs = scope.boundary;
425
+ const pipAbs = process.platform === "win32"
426
+ ? path.join(projAbs, ".venv", "Scripts", "pip.exe")
427
+ : path.join(projAbs, ".venv", "bin", "pip");
428
+ if (!(await trystat(pipAbs))) return res.status(400).json({ error: "Python environment not found. Create it first." });
429
+
430
+ const { requirements } = req.body || {};
431
+ const reqRel = requirements || (scope.project + "/requirements.txt");
432
+ const reqAbs = resolveInScope(scope, reqRel);
433
+ if (!reqAbs) return res.status(400).json({ error: OUT_OF_SCOPE });
434
+ const relFromProj = path.relative(projAbs, reqAbs);
435
+ if (relFromProj.startsWith("..") || path.isAbsolute(relFromProj)) {
436
+ return res.status(400).json({ error: "Requirements file must be inside the project" });
437
+ }
438
+ const reqSt = await trystat(reqAbs);
439
+ if (!reqSt || !reqSt.isFile()) return res.status(400).json({ error: "Requirements file not found" });
440
+
441
+ runChild(pipAbs, ["install", "-r", reqAbs], { cwd: projAbs }, (code, out, err) => {
442
+ if (code === 0) return res.json({ ok: true });
443
+ res.status(500).json({ error: "pip install failed: " + ((err || out || "exit " + code).slice(-1000)) });
444
+ });
445
+ } catch (e) { logErr("python-env/install", e); res.status(400).json({ error: safeErr(e) }); }
446
+ });
447
+
448
+ // ── Node packages route (Project Mode only) ───────────────────────────────
449
+ // Runs `npm install` with the active project as cwd. Packages land in
450
+ // <project>/node_modules — the Node-RED runtime's own node_modules is never
451
+ // touched.
452
+ RED.httpAdmin.post("/project-files/node-packages/install", canWrite, async (req, res) => {
453
+ try {
454
+ const scope = requireProject(res, "Node package installation");
455
+ if (!scope) return;
456
+ const projAbs = scope.boundary;
457
+ const pkgAbs = path.join(projAbs, "package.json");
458
+ const pkgSt = await trystat(pkgAbs);
459
+ if (!pkgSt || !pkgSt.isFile()) return res.status(400).json({ error: "package.json not found in project root" });
460
+ runChild(NPM, ["install"], { cwd: projAbs }, (code, out, err) => {
461
+ if (code === 0) return res.json({ ok: true });
462
+ res.status(500).json({ error: "npm install failed: " + ((err || out || "exit " + code).slice(-1000)) });
463
+ });
464
+ } catch (e) { logErr("node-packages/install", e); res.status(400).json({ error: safeErr(e) }); }
465
+ });
466
+
467
+ const startMode = resolveScope();
468
+ RED.log.info(`[project-files] ready — mode: ${startMode.mode}, scope: ${startMode.boundary}`);
469
+ };