EXTENSION for TreeOS
scripts
Sandboxed JavaScript execution on tree nodes. Scripts are stored in node metadata as named code blocks (max 2000 characters each) and executed in a Node.js vm context with a 5-second timeout. The sandbox provides a frozen snapshot of the node, a capped console.log (200 lines), and a set of safe mutation functions. Scripts cannot access the filesystem, network (except through getApi), process, or require. They run in an async IIFE, so await is available. The safe functions are the scripting API. setValueForNode and setGoalForNode write to the values extension's metadata. editStatusForNode changes node status with optional inheritance to children. addPrestigeForNode prestiges the node through the prestige extension. updateScheduleForNode sets schedule timestamps and reeffect intervals through the schedules extension. getApi performs outbound GET requests with a blocklist that prevents SSRF attacks against localhost, private IPs, and the land's own domain. All mutation functions are serialized through a per-node queue so concurrent scripts on the same node execute their mutations in order. The AI interacts with scripts through four MCP tools. javascript-scripting-orchestrator establishes intent before any action (create, modify, or execute). node-script-runtime-environment returns documentation of the sandbox API so the AI can write correct code. update-node-script creates or edits a script. execute-node-script runs a script after confirmation. The enrichContext hook injects the script list into AI context so the model knows what scripts exist on the current node. Every script creation, edit, and execution is logged as a contribution with full audit trail. Energy metering charges for both edits and executions when the energy extension is installed. CLI commands expose script listing, viewing, and execution without going through the AI.
v1.0.1 by TreeOS Site 0 downloads 10 files 2,796 lines 71.2 KB published 38d ago
treeos ext install scripts
View changelog

Manifest

Provides

  • routes
  • tools
  • 3 CLI commands
  • 1 energy actions

Requires

  • services: contributions, hooks
  • models: Node, Contribution

Optional

  • services: energy
  • extensions: values, prestige, schedules, html-rendering, treeos-base
SHA256: 172e62f52c66ad9687e0be52119ca229fe3a6f3b519f216d44fd7702d8e6310f

CLI Commands

CommandMethodDescription
scriptsGETList scripts on current node
script <id>GETView a script
run <id>POSTExecute a script

Hooks

Listens To

  • enrichContext

Source Code

1import vm from "node:vm";
2import { v4 as uuidv4 } from "uuid";
3
4// Services wired from init() via setServices()
5let Node = null;
6let Contribution = null;
7let logContribution = async () => {};
8let useEnergy = async () => ({ energyUsed: 0 });
9let _metadata = null;
10
11export function setServices({ models, contributions, metadata }) {
12  Node = models.Node;
13  Contribution = models.Contribution;
14  logContribution = contributions.logContribution;
15  if (metadata) _metadata = metadata;
16}
17export function setEnergyService(energy) { useEnergy = energy.useEnergy; }
18
19import { makeSafeFunctions } from "./scriptsFunctions/safeFunctions.js";
20
21function getScripts(node) {
22  const meta = _metadata.getExtMeta(node, "scripts");
23  return Array.isArray(meta.list) ? meta.list : [];
24}
25
26async function setScripts(node, list) {
27  await _metadata.setExtMeta(node, "scripts", { list });
28}
29
30function findScript(scripts, scriptId) {
31  return scripts.find(s => s._id === scriptId) || null;
32}
33function containsHtml(str) {
34  return /<[a-zA-Z\/][^>]*>/.test(str);
35}
36export async function updateScript({
37  nodeId,
38  scriptId,
39  name,
40  script,
41  userId,
42  wasAi = false,
43  chatId = null,
44  sessionId = null,
45}) {
46  const isCreating = !scriptId;
47
48  // ---------------------------------------------------------
49  // Validate inputs
50  // ---------------------------------------------------------
51  if (isCreating && !name) {
52    throw new Error("Name is required when creating a new script");
53  }
54
55  if (name !== undefined) {
56    if (typeof name !== "string" || !name.trim()) {
57      throw new Error("Script name cannot be empty");
58    }
59    name = name.trim();
60    if (name.length > 150) {
61      throw new Error("Script name must be 150 characters or fewer");
62    }
63    if (containsHtml(name)) {
64      throw new Error("Script name cannot contain HTML tags");
65    }
66  }
67
68  if (!isCreating && script === undefined && name === undefined) {
69    throw new Error("Nothing to update");
70  }
71
72  // Normalize script (allow empty ONLY on creation)
73  let finalScript = "";
74
75  if (script !== undefined) {
76    if (typeof script !== "string") {
77      throw new Error("Script must be a string");
78    }
79
80    finalScript = script.trim();
81
82    if (!isCreating) {
83      if (!finalScript) {
84        throw new Error("Script cannot be empty");
85      }
86
87      if (finalScript.length > 2000) {
88        throw new Error("Script is too long (max 2000 chars)");
89      }
90    }
91  }
92
93  const payload = script !== undefined ? finalScript.length : 0;
94  const { energyUsed } = await useEnergy({
95    userId,
96    action: "editScript",
97    payload,
98  });
99
100  // ---------------------------------------------------------
101  // Load node
102  // ---------------------------------------------------------
103  const node = await Node.findById(nodeId);
104  if (!node) {
105    throw new Error("Node not found by that ID");
106  }
107  if (node.systemRole) throw new Error("Cannot modify system nodes");
108
109  const scripts = getScripts(node);
110  let targetScript;
111
112  // ---------------------------------------------------------
113  // Update existing script
114  // ---------------------------------------------------------
115  if (scriptId) {
116    targetScript = findScript(scripts, scriptId);
117    if (!targetScript) {
118      throw new Error("Script not found by that ID");
119    }
120
121    if (name !== undefined) {
122      targetScript.name = name;
123    }
124
125    if (script !== undefined) {
126      targetScript.script = finalScript;
127    }
128  }
129
130  // ---------------------------------------------------------
131  // Create new script (empty allowed)
132  // ---------------------------------------------------------
133  else {
134    targetScript = {
135      _id: uuidv4(),
136      name,
137      script: finalScript,
138    };
139
140    scripts.push(targetScript);
141  }
142
143  // ---------------------------------------------------------
144  // Persist
145  // ---------------------------------------------------------
146  await setScripts(node, scripts);
147
148  // ---------------------------------------------------------
149  // Log contribution
150  // ---------------------------------------------------------
151  await logContribution({
152    userId,
153    nodeId,
154    wasAi,
155    chatId,
156    sessionId,
157    action: "editScript",
158    nodeVersion: "0",
159    editScript: {
160      scriptId: targetScript._id,
161      scriptName: targetScript.name,
162      contents: finalScript || null,
163    },
164    energyUsed,
165  });
166
167  return {
168    message: isCreating
169      ? "Script created successfully"
170      : "Script updated successfully",
171    scriptId: targetScript._id,
172    node,
173  };
174}
175
176export async function executeScript({
177  nodeId,
178  scriptId,
179  userId,
180  wasAi = false,
181  chatId = null,
182  sessionId = null,
183}) {
184  if (!nodeId || !scriptId || !userId) {
185    throw new Error("Missing required fields: nodeId, scriptId, or userId");
186  }
187
188  const node = await Node.findById(nodeId);
189  if (!node) {
190    throw new Error("Node not found");
191  }
192  if (node.systemRole) throw new Error("Cannot modify system nodes");
193
194  const scripts = getScripts(node);
195  const scriptObj = findScript(scripts, scriptId);
196  if (!scriptObj) {
197    throw new Error("Script not found");
198  }
199  const { energyUsed } = await useEnergy({
200    userId,
201    action: "executeScript",
202  });
203
204  const scriptName = scriptObj.name;
205
206  const sandboxNode = JSON.parse(JSON.stringify(node));
207  const safeFns = makeSafeFunctions(userId);
208  const logs = [];
209
210  const sandbox = {
211    node: sandboxNode,
212    ...safeFns,
213    console: {
214      log: (...args) => {
215        if (logs.length < 200) {
216          logs.push(
217            args
218              .map((a) =>
219                typeof a === "string" ? a : JSON.stringify(a, null, 2),
220              )
221              .join(" "),
222          );
223        }
224      },
225    },
226    // Async support: expose Promise so async/await works in the sandbox
227    Promise,
228    setTimeout: (fn, ms) => setTimeout(fn, Math.min(ms, 3000)),
229  };
230
231  const context = vm.createContext(sandbox);
232
233  const wrappedScript = `
234    (async () => {
235      ${scriptObj.script}
236    })()
237  `;
238
239  const SCRIPT_TIMEOUT_MS = 5000;
240
241  try {
242    const script = new vm.Script(wrappedScript, { filename: `script:${scriptObj.name}` });
243    const resultPromise = script.runInContext(context, { timeout: SCRIPT_TIMEOUT_MS });
244    // The script returns a Promise (async IIFE). Race it against a timeout.
245    await Promise.race([
246      resultPromise,
247      new Promise((_, reject) =>
248        setTimeout(() => reject(new Error("Script timed out (5s)")), SCRIPT_TIMEOUT_MS)
249      ),
250    ]);
251
252    await logContribution({
253      userId,
254      nodeId,
255      wasAi,
256      chatId,
257      sessionId,
258      action: "executeScript",
259      nodeVersion: "0",
260      executeScript: {
261        scriptId,
262        scriptName,
263        logs,
264        success: true,
265      },
266      energyUsed,
267    });
268  } catch (err) {
269    await logContribution({
270      userId,
271      nodeId,
272      wasAi,
273      chatId,
274      sessionId,
275      action: "executeScript",
276      nodeVersion: "0",
277      executeScript: {
278        scriptId,
279        scriptName,
280        logs,
281        success: false,
282        error: err.message,
283      },
284      energyUsed,
285    });
286    throw err;
287  }
288
289  return {
290    message: "Script executed successfully",
291    logs,
292    node,
293  };
294}
295
296export async function getScript({ nodeId, scriptId }) {
297  const node = await Node.findById(nodeId);
298  if (!node) throw new Error("Node not found");
299
300  const scripts = getScripts(node);
301  const scriptObj = findScript(scripts, scriptId);
302  if (!scriptObj) throw new Error("Script not found");
303
304  const contributions = await Contribution.find({
305    nodeId,
306    action: { $in: ["editScript", "executeScript"] },
307    $or: [
308      { "editScript.scriptId": scriptId },
309      { "executeScript.scriptId": scriptId },
310    ],
311  })
312    .sort({ date: -1 })
313    .lean();
314
315  return {
316    script: {
317      id: scriptObj._id,
318      name: scriptObj.name,
319      script: scriptObj.script,
320    },
321
322    contributions: contributions
323      .map((c) => {
324        if (c.action === "editScript") {
325          return {
326            type: "edit",
327            userId: c.userId,
328            nodeVersion: c.nodeVersion,
329            scriptName: c.editScript?.scriptName,
330            contents: c.editScript?.contents,
331            createdAt: c.date,
332          };
333        }
334
335        if (c.action === "executeScript") {
336          return {
337            type: "execute",
338            userId: c.userId,
339            nodeVersion: c.nodeVersion,
340            scriptName: c.executeScript?.scriptName,
341            logs: c.executeScript?.logs || [],
342            success: c.executeScript?.success,
343            error: c.executeScript?.error || null,
344            createdAt: c.date,
345          };
346        }
347
348        return null;
349      })
350      .filter(Boolean),
351  };
352}
353
1import tools from "./tools.js";
2import { setServices, setEnergyService } from "./core.js";
3import { setExtensions } from "./scriptsFunctions/safeFunctions.js";
4import { getExtension } from "../loader.js";
5
6export async function init(core) {
7  setServices({ models: core.models, contributions: core.contributions, metadata: core.metadata });
8  if (core.energy) setEnergyService(core.energy);
9
10  const { default: router, setNodeModel, resolveHtmlAuth } = await import("./routes.js");
11  setNodeModel(core.models.Node);
12  resolveHtmlAuth();
13
14  // Wire optional extension functions for sandboxed scripts
15  setExtensions({
16    values: getExtension("values")?.exports,
17    prestige: getExtension("prestige")?.exports,
18    schedules: getExtension("schedules")?.exports,
19  });
20
21  // Inject script list into AI context
22  core.hooks.register("enrichContext", async ({ context, node, meta }) => {
23    const scripts = meta.scripts?.list || [];
24    if (scripts.length > 0) {
25      context.scripts = scripts.map(s => ({ id: s._id, name: s.name }));
26    }
27  }, "scripts");
28
29  // Register navigation for script tools (if treeos-base installed)
30  try {
31    const { getExtension } = await import("../loader.js");
32    const base = getExtension("treeos-base");
33    if (base?.exports?.registerToolNavigations) {
34      const scriptNav = ({ args, withToken: t }) => t(`/api/v1/node/${args.nodeId}?html`);
35      base.exports.registerToolNavigations({
36        "update-node-script": scriptNav,
37        "execute-node-script": scriptNav,
38      });
39    }
40  } catch {}
41
42  // Register scripts section on node detail page
43  try {
44    const treeos = getExtension("treeos-base");
45    treeos?.exports?.registerSlot?.("node-detail-below", "scripts", ({ node, nodeId, qs }) => {
46      const scripts = (node.metadata instanceof Map ? node.metadata?.get("scripts") : node.metadata?.scripts)?.list || [];
47      return `<div class="scripts-section">
48        <h2><a href="/api/v1/node/${node._id}/scripts/help${qs}">Scripts</a></h2>
49        <form method="POST" action="/api/v1/node/${nodeId}/script/create${qs}"
50              style="display:flex;gap:8px;align-items:center;margin-bottom:16px;">
51          <input type="text" name="name" placeholder="New script name" required
52                 style="padding:12px 16px;border-radius:10px;border:1px solid rgba(255,255,255,0.3);background:rgba(255,255,255,0.2);color:white;font-size:15px;min-width:200px;flex:1;" />
53          <button type="submit" class="primary-button" title="Create script" style="padding:10px 18px;font-size:16px;">+</button>
54        </form>
55        <ul class="scripts-list">
56          ${scripts.length
57            ? scripts.map(s => `<a href="/api/v1/node/${node._id}/script/${s._id}${qs}"><li><strong>${s.name}</strong><pre>${s.script}</pre></li></a>`).join("")
58            : `<li><em>No scripts defined</em></li>`}
59        </ul>
60      </div>`;
61    }, { priority: 20 });
62  } catch {}
63
64  return { router, tools };
65}
66
1export default {
2  name: "scripts",
3  version: "1.0.1",
4  builtFor: "TreeOS",
5  scope: "confined",
6  description:
7    "Sandboxed JavaScript execution on tree nodes. Scripts are stored in node metadata as named " +
8    "code blocks (max 2000 characters each) and executed in a Node.js vm context with a 5-second " +
9    "timeout. The sandbox provides a frozen snapshot of the node, a capped console.log (200 lines), " +
10    "and a set of safe mutation functions. Scripts cannot access the filesystem, network (except " +
11    "through getApi), process, or require. They run in an async IIFE, so await is available.\n\n" +
12    "The safe functions are the scripting API. setValueForNode and setGoalForNode write to the " +
13    "values extension's metadata. editStatusForNode changes node status with optional inheritance " +
14    "to children. addPrestigeForNode prestiges the node through the prestige extension. " +
15    "updateScheduleForNode sets schedule timestamps and reeffect intervals through the schedules " +
16    "extension. getApi performs outbound GET requests with a blocklist that prevents SSRF attacks " +
17    "against localhost, private IPs, and the land's own domain. All mutation functions are " +
18    "serialized through a per-node queue so concurrent scripts on the same node execute their " +
19    "mutations in order.\n\n" +
20    "The AI interacts with scripts through four MCP tools. javascript-scripting-orchestrator " +
21    "establishes intent before any action (create, modify, or execute). node-script-runtime-" +
22    "environment returns documentation of the sandbox API so the AI can write correct code. " +
23    "update-node-script creates or edits a script. execute-node-script runs a script after " +
24    "confirmation. The enrichContext hook injects the script list into AI context so the model " +
25    "knows what scripts exist on the current node. Every script creation, edit, and execution " +
26    "is logged as a contribution with full audit trail. Energy metering charges for both edits " +
27    "and executions when the energy extension is installed. CLI commands expose script listing, " +
28    "viewing, and execution without going through the AI.",
29
30  npm: ["axios@^1.12.2"],
31
32  needs: {
33    services: ["contributions", "hooks"],
34    models: ["Node", "Contribution"],
35  },
36
37  optional: {
38    services: ["energy"],
39    extensions: ["values", "prestige", "schedules", "html-rendering", "treeos-base"],
40  },
41
42  provides: {
43    models: {},
44    routes: "./routes.js",
45    tools: true,
46    jobs: false,
47    orchestrator: false,
48    energyActions: {
49      script: { cost: 2 },
50    },
51    sessionTypes: {},
52    schemaVersion: 1,
53    migrations: "./migrations.js",
54    cli: [
55      { command: "scripts", description: "List scripts on current node", method: "GET", endpoint: "/node/:nodeId/scripts/help" },
56      { command: "script <id>", description: "View a script", method: "GET", endpoint: "/node/:nodeId/script/:id" },
57      { command: "run <id>", description: "Execute a script", method: "POST", endpoint: "/node/:nodeId/script/:id/execute" },
58    ],
59    hooks: {
60      fires: [],
61      listens: ["enrichContext"],
62    },
63  },
64};
65
1// Migration 1 already ran. No further migrations needed.
2export default [];
3
1{
2  "name": "treeos-ext-scripts",
3  "version": "1.0.0",
4  "lockfileVersion": 3,
5  "requires": true,
6  "packages": {
7    "": {
8      "name": "treeos-ext-scripts",
9      "version": "1.0.0",
10      "dependencies": {
11        "axios": "^1.12.2"
12      }
13    },
14    "node_modules/asynckit": {
15      "version": "0.4.0",
16      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
17      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
18      "license": "MIT"
19    },
20    "node_modules/axios": {
21      "version": "1.14.0",
22      "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
23      "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
24      "license": "MIT",
25      "dependencies": {
26        "follow-redirects": "^1.15.11",
27        "form-data": "^4.0.5",
28        "proxy-from-env": "^2.1.0"
29      }
30    },
31    "node_modules/call-bind-apply-helpers": {
32      "version": "1.0.2",
33      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
34      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
35      "license": "MIT",
36      "dependencies": {
37        "es-errors": "^1.3.0",
38        "function-bind": "^1.1.2"
39      },
40      "engines": {
41        "node": ">= 0.4"
42      }
43    },
44    "node_modules/combined-stream": {
45      "version": "1.0.8",
46      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
47      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
48      "license": "MIT",
49      "dependencies": {
50        "delayed-stream": "~1.0.0"
51      },
52      "engines": {
53        "node": ">= 0.8"
54      }
55    },
56    "node_modules/delayed-stream": {
57      "version": "1.0.0",
58      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
59      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
60      "license": "MIT",
61      "engines": {
62        "node": ">=0.4.0"
63      }
64    },
65    "node_modules/dunder-proto": {
66      "version": "1.0.1",
67      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
68      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
69      "license": "MIT",
70      "dependencies": {
71        "call-bind-apply-helpers": "^1.0.1",
72        "es-errors": "^1.3.0",
73        "gopd": "^1.2.0"
74      },
75      "engines": {
76        "node": ">= 0.4"
77      }
78    },
79    "node_modules/es-define-property": {
80      "version": "1.0.1",
81      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
82      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
83      "license": "MIT",
84      "engines": {
85        "node": ">= 0.4"
86      }
87    },
88    "node_modules/es-errors": {
89      "version": "1.3.0",
90      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
91      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
92      "license": "MIT",
93      "engines": {
94        "node": ">= 0.4"
95      }
96    },
97    "node_modules/es-object-atoms": {
98      "version": "1.1.1",
99      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
100      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
101      "license": "MIT",
102      "dependencies": {
103        "es-errors": "^1.3.0"
104      },
105      "engines": {
106        "node": ">= 0.4"
107      }
108    },
109    "node_modules/es-set-tostringtag": {
110      "version": "2.1.0",
111      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
112      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
113      "license": "MIT",
114      "dependencies": {
115        "es-errors": "^1.3.0",
116        "get-intrinsic": "^1.2.6",
117        "has-tostringtag": "^1.0.2",
118        "hasown": "^2.0.2"
119      },
120      "engines": {
121        "node": ">= 0.4"
122      }
123    },
124    "node_modules/follow-redirects": {
125      "version": "1.15.11",
126      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
127      "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
128      "funding": [
129        {
130          "type": "individual",
131          "url": "https://github.com/sponsors/RubenVerborgh"
132        }
133      ],
134      "license": "MIT",
135      "engines": {
136        "node": ">=4.0"
137      },
138      "peerDependenciesMeta": {
139        "debug": {
140          "optional": true
141        }
142      }
143    },
144    "node_modules/form-data": {
145      "version": "4.0.5",
146      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
147      "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
148      "license": "MIT",
149      "dependencies": {
150        "asynckit": "^0.4.0",
151        "combined-stream": "^1.0.8",
152        "es-set-tostringtag": "^2.1.0",
153        "hasown": "^2.0.2",
154        "mime-types": "^2.1.12"
155      },
156      "engines": {
157        "node": ">= 6"
158      }
159    },
160    "node_modules/function-bind": {
161      "version": "1.1.2",
162      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
163      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
164      "license": "MIT",
165      "funding": {
166        "url": "https://github.com/sponsors/ljharb"
167      }
168    },
169    "node_modules/get-intrinsic": {
170      "version": "1.3.0",
171      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
172      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
173      "license": "MIT",
174      "dependencies": {
175        "call-bind-apply-helpers": "^1.0.2",
176        "es-define-property": "^1.0.1",
177        "es-errors": "^1.3.0",
178        "es-object-atoms": "^1.1.1",
179        "function-bind": "^1.1.2",
180        "get-proto": "^1.0.1",
181        "gopd": "^1.2.0",
182        "has-symbols": "^1.1.0",
183        "hasown": "^2.0.2",
184        "math-intrinsics": "^1.1.0"
185      },
186      "engines": {
187        "node": ">= 0.4"
188      },
189      "funding": {
190        "url": "https://github.com/sponsors/ljharb"
191      }
192    },
193    "node_modules/get-proto": {
194      "version": "1.0.1",
195      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
196      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
197      "license": "MIT",
198      "dependencies": {
199        "dunder-proto": "^1.0.1",
200        "es-object-atoms": "^1.0.0"
201      },
202      "engines": {
203        "node": ">= 0.4"
204      }
205    },
206    "node_modules/gopd": {
207      "version": "1.2.0",
208      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
209      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
210      "license": "MIT",
211      "engines": {
212        "node": ">= 0.4"
213      },
214      "funding": {
215        "url": "https://github.com/sponsors/ljharb"
216      }
217    },
218    "node_modules/has-symbols": {
219      "version": "1.1.0",
220      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
221      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
222      "license": "MIT",
223      "engines": {
224        "node": ">= 0.4"
225      },
226      "funding": {
227        "url": "https://github.com/sponsors/ljharb"
228      }
229    },
230    "node_modules/has-tostringtag": {
231      "version": "1.0.2",
232      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
233      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
234      "license": "MIT",
235      "dependencies": {
236        "has-symbols": "^1.0.3"
237      },
238      "engines": {
239        "node": ">= 0.4"
240      },
241      "funding": {
242        "url": "https://github.com/sponsors/ljharb"
243      }
244    },
245    "node_modules/hasown": {
246      "version": "2.0.2",
247      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
248      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
249      "license": "MIT",
250      "dependencies": {
251        "function-bind": "^1.1.2"
252      },
253      "engines": {
254        "node": ">= 0.4"
255      }
256    },
257    "node_modules/math-intrinsics": {
258      "version": "1.1.0",
259      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
260      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
261      "license": "MIT",
262      "engines": {
263        "node": ">= 0.4"
264      }
265    },
266    "node_modules/mime-db": {
267      "version": "1.52.0",
268      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
269      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
270      "license": "MIT",
271      "engines": {
272        "node": ">= 0.6"
273      }
274    },
275    "node_modules/mime-types": {
276      "version": "2.1.35",
277      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
278      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
279      "license": "MIT",
280      "dependencies": {
281        "mime-db": "1.52.0"
282      },
283      "engines": {
284        "node": ">= 0.6"
285      }
286    },
287    "node_modules/proxy-from-env": {
288      "version": "2.1.0",
289      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
290      "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
291      "license": "MIT",
292      "engines": {
293        "node": ">=10"
294      }
295    }
296  }
297}
298
1{
2  "type": "module",
3  "name": "treeos-ext-scripts",
4  "version": "1.0.0",
5  "private": true,
6  "dependencies": {
7    "axios": "^1.12.2"
8  }
9}
1/* ------------------------------------------------------------------ */
2/* renderScriptDetail + renderScriptHelp                               */
3/* ------------------------------------------------------------------ */
4
5import { page } from "../../html-rendering/html/layout.js";
6
7/* ================================================================== */
8/* 1. renderScriptDetail                                               */
9/* ================================================================== */
10
11/* ── page-specific CSS for script detail ── */
12
13const scriptDetailCss = `
14.container { max-width: 1000px; }
15
16/* =========================================================
17   UNIFIED GLASS BUTTON SYSTEM
18   ========================================================= */
19
20.glass-btn,
21button,
22.back-link,
23.btn-copy,
24.btn-execute,
25.btn-save {
26  position: relative;
27  overflow: hidden;
28
29  padding: 10px 20px;
30  border-radius: 980px;
31
32  display: inline-flex;
33  align-items: center;
34  justify-content: center;
35  white-space: nowrap;
36
37  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
38  backdrop-filter: blur(22px) saturate(140%);
39  -webkit-backdrop-filter: blur(22px) saturate(140%);
40
41  color: white;
42  text-decoration: none;
43  font-family: inherit;
44
45  font-size: 15px;
46  font-weight: 500;
47  letter-spacing: -0.2px;
48
49  border: 1px solid rgba(255, 255, 255, 0.28);
50
51  box-shadow:
52    0 8px 24px rgba(0, 0, 0, 0.12),
53    inset 0 1px 0 rgba(255, 255, 255, 0.25);
54
55  cursor: pointer;
56
57  transition:
58    background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
59    transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
60    box-shadow 0.3s ease;
61}
62
63/* Liquid light layer */
64.glass-btn::before,
65button::before,
66.back-link::before,
67.btn-copy::before,
68.btn-execute::before,
69.btn-save::before {
70  content: "";
71  position: absolute;
72  inset: -40%;
73
74  background:
75    radial-gradient(
76      120% 60% at 0% 0%,
77      rgba(255, 255, 255, 0.35),
78      transparent 60%
79    ),
80    linear-gradient(
81      120deg,
82      transparent 30%,
83      rgba(255, 255, 255, 0.25),
84      transparent 70%
85    );
86
87  opacity: 0;
88  transform: translateX(-30%) translateY(-10%);
89  transition:
90    opacity 0.35s ease,
91    transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
92
93  pointer-events: none;
94}
95
96/* Hover motion */
97.glass-btn:hover,
98button:hover,
99.back-link:hover,
100.btn-copy:hover,
101.btn-execute:hover,
102.btn-save:hover {
103  background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
104  transform: translateY(-1px);
105  animation: waterDrift 2.2s ease-in-out infinite alternate;
106}
107
108.glass-btn:hover::before,
109button:hover::before,
110.back-link:hover::before,
111.btn-copy:hover::before,
112.btn-execute:hover::before,
113.btn-save:hover::before {
114  opacity: 1;
115  transform: translateX(30%) translateY(10%);
116}
117
118/* Active press */
119.glass-btn:active,
120button:active,
121.btn-copy:active,
122.btn-execute:active,
123.btn-save:active {
124  background: rgba(var(--glass-water-rgb), 0.45);
125  transform: translateY(0);
126  animation: none;
127}
128
129@keyframes waterDrift {
130  0% { transform: translateY(-1px); }
131  100% { transform: translateY(1px); }
132}
133
134/* Button variants */
135.btn-execute {
136  --glass-water-rgb: 16, 185, 129;
137  font-weight: 600;
138}
139
140.btn-save {
141  --glass-alpha: 0.34;
142  --glass-alpha-hover: 0.46;
143  font-weight: 600;
144}
145
146.btn-copy {
147  padding: 6px 12px;
148  font-size: 13px;
149}
150
151/* =========================================================
152   CONTENT CARDS
153   ========================================================= */
154
155.header,
156.section {
157  background: rgba(255, 255, 255, 0.15);
158  backdrop-filter: blur(22px) saturate(140%);
159  -webkit-backdrop-filter: blur(22px) saturate(140%);
160  border-radius: 14px;
161  padding: 28px;
162  border: 1px solid rgba(255, 255, 255, 0.28);
163  color: white;
164  margin-bottom: 24px;
165}
166
167.header h1 {
168  font-size: 28px;
169  font-weight: 600;
170  letter-spacing: -0.5px;
171  line-height: 1.3;
172  margin-bottom: 12px;
173  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
174  color: white;
175  word-break: break-word;
176}
177
178.header h1::before {
179  content: '⚡ ';
180  font-size: 26px;
181}
182
183.script-id {
184  font-size: 13px;
185  color: rgba(255, 255, 255, 0.8);
186  font-family: 'SF Mono', Monaco, monospace;
187  background: rgba(255, 255, 255, 0.1);
188  padding: 6px 12px;
189  border-radius: 6px;
190  display: inline-block;
191  margin-top: 8px;
192  border: 1px solid rgba(255, 255, 255, 0.2);
193}
194
195.section-title {
196  font-size: 18px;
197  font-weight: 600;
198  color: white;
199  margin-bottom: 20px;
200  letter-spacing: -0.3px;
201  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
202}
203
204/* =========================================================
205   NAV
206   ========================================================= */
207
208.back-nav {
209  display: flex;
210  gap: 12px;
211  margin-bottom: 20px;
212  flex-wrap: wrap;
213}
214
215/* =========================================================
216   CODE DISPLAY
217   ========================================================= */
218
219.code-container {
220  position: relative;
221}
222
223.code-header {
224  display: flex;
225  justify-content: space-between;
226  align-items: center;
227  margin-bottom: 12px;
228}
229
230.code-label {
231  font-size: 14px;
232  font-weight: 600;
233  color: rgba(255, 255, 255, 0.7);
234  text-transform: uppercase;
235  letter-spacing: 0.5px;
236}
237
238pre {
239  background: rgba(0, 0, 0, 0.3);
240  color: #e0e0e0;
241  padding: 20px;
242  border-radius: 12px;
243  overflow-x: auto;
244  font-size: 14px;
245  line-height: 1.6;
246  font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
247  border: 1px solid rgba(255, 255, 255, 0.1);
248}
249
250/* =========================================================
251   ACTION BUTTONS
252   ========================================================= */
253
254.action-bar {
255  display: flex;
256  gap: 12px;
257  margin-top: 20px;
258  flex-wrap: wrap;
259}
260
261.btn-execute::before {
262  content: '▶ ';
263  font-size: 14px;
264}
265
266/* =========================================================
267   FORMS
268   ========================================================= */
269
270.edit-form {
271  display: flex;
272  flex-direction: column;
273  gap: 16px;
274}
275
276.form-group {
277  display: flex;
278  flex-direction: column;
279  gap: 8px;
280}
281
282.form-label {
283  font-size: 14px;
284  font-weight: 600;
285  color: white;
286}
287
288input[type="text"],
289textarea {
290  width: 100%;
291  padding: 12px 16px;
292  border: 1px solid rgba(255, 255, 255, 0.3);
293  border-radius: 10px;
294  font-size: 15px;
295  font-family: inherit;
296  transition: all 0.2s;
297  background: rgba(255, 255, 255, 0.2);
298  color: white;
299}
300
301input[type="text"]::placeholder,
302textarea::placeholder {
303  color: rgba(255, 255, 255, 0.6);
304}
305
306textarea {
307  font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
308  resize: vertical;
309  min-height: 300px;
310}
311
312input[type="text"]:focus,
313textarea:focus {
314  outline: none;
315  border-color: rgba(255, 255, 255, 0.5);
316  background: rgba(255, 255, 255, 0.25);
317  box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1);
318}
319
320/* =========================================================
321   HISTORY
322   ========================================================= */
323
324.history-list {
325  list-style: none;
326  display: flex;
327  flex-direction: column;
328  gap: 12px;
329}
330
331.history-item {
332  background: rgba(255, 255, 255, 0.1);
333  border-radius: 12px;
334  padding: 16px;
335  border: 1px solid rgba(255, 255, 255, 0.2);
336  transition: all 0.2s;
337}
338
339.history-item:hover {
340  background: rgba(255, 255, 255, 0.15);
341  transform: translateX(4px);
342}
343
344.history-item.success {
345  border-left: 4px solid #10b981;
346}
347
348.history-item.failure {
349  border-left: 4px solid #ef4444;
350}
351
352.history-header {
353  display: flex;
354  justify-content: space-between;
355  align-items: center;
356  margin-bottom: 12px;
357  flex-wrap: wrap;
358  gap: 12px;
359}
360
361.history-title {
362  display: flex;
363  align-items: center;
364  gap: 12px;
365  flex-wrap: wrap;
366}
367
368.edit-number {
369  font-weight: 600;
370  color: white;
371  font-size: 15px;
372}
373
374.script-name {
375  font-size: 13px;
376  color: white;
377  background: rgba(255, 255, 255, 0.2);
378  padding: 4px 10px;
379  border-radius: 8px;
380  font-weight: 600;
381  border: 1px solid rgba(255, 255, 255, 0.3);
382}
383
384.current-badge {
385  padding: 4px 10px;
386  background: rgba(16, 185, 129, 0.9);
387  color: white;
388  border-radius: 12px;
389  font-size: 12px;
390  font-weight: 600;
391}
392
393.success-badge {
394  background: rgba(16, 185, 129, 0.9);
395}
396
397.failure-badge {
398  background: rgba(239, 68, 68, 0.9);
399}
400
401.history-meta {
402  display: flex;
403  align-items: center;
404  gap: 12px;
405  flex-wrap: wrap;
406}
407
408.version-badge {
409  padding: 4px 10px;
410  background: rgba(255, 255, 255, 0.2);
411  color: white;
412  border-radius: 8px;
413  font-size: 12px;
414  font-weight: 600;
415  border: 1px solid rgba(255, 255, 255, 0.3);
416}
417
418.timestamp {
419  font-size: 13px;
420  color: rgba(255, 255, 255, 0.8);
421}
422
423details {
424  margin-top: 8px;
425}
426
427details summary {
428  cursor: pointer;
429  font-weight: 600;
430  color: white;
431  font-size: 14px;
432  display: flex;
433  align-items: center;
434  gap: 8px;
435  padding: 8px 0;
436  user-select: none;
437  transition: opacity 0.2s;
438}
439
440details summary:hover {
441  opacity: 0.8;
442}
443
444.summary-icon {
445  font-size: 10px;
446  transition: transform 0.2s;
447}
448
449details[open] .summary-icon {
450  transform: rotate(90deg);
451}
452
453details summary::-webkit-details-marker {
454  display: none;
455}
456
457.history-code {
458  margin-top: 12px;
459  font-size: 13px;
460}
461
462.empty-history {
463  text-align: center;
464  padding: 40px;
465  color: rgba(255, 255, 255, 0.7);
466  font-style: italic;
467  background: rgba(255, 255, 255, 0.1);
468  border-radius: 12px;
469  border: 1px solid rgba(255, 255, 255, 0.2);
470}
471
472.empty-history-item {
473  text-align: center;
474  padding: 20px;
475  color: rgba(255, 255, 255, 0.7);
476  font-style: italic;
477  font-size: 14px;
478}
479
480/* =========================================================
481   ERROR MESSAGES
482   ========================================================= */
483
484.error-message {
485  margin-top: 12px;
486  padding: 12px;
487  background: rgba(239, 68, 68, 0.2);
488  border-left: 3px solid #ef4444;
489  border-radius: 8px;
490}
491
492.error-label {
493  font-size: 12px;
494  font-weight: 600;
495  color: #ff6b6b;
496  text-transform: uppercase;
497  letter-spacing: 0.5px;
498  margin-bottom: 8px;
499}
500
501.error-code {
502  color: #ffcccb;
503  background: rgba(0, 0, 0, 0.2);
504  padding: 12px;
505  border-radius: 6px;
506  font-size: 13px;
507  margin: 0;
508}
509
510/* =========================================================
511   RESPONSIVE
512   ========================================================= */
513
514@media (max-width: 640px) {
515  .container {
516    max-width: 100%;
517  }
518
519  .header,
520  .section {
521    padding: 20px;
522  }
523
524  .action-bar {
525    flex-direction: column;
526  }
527
528  .btn-execute,
529  .btn-save {
530    width: 100%;
531    justify-content: center;
532  }
533
534  .history-header {
535    flex-direction: column;
536    align-items: flex-start;
537  }
538
539  .history-title {
540    width: 100%;
541  }
542
543  pre {
544    font-size: 12px;
545    padding: 16px;
546  }
547
548  textarea {
549    min-height: 200px;
550  }
551}
552
553@media (min-width: 641px) and (max-width: 1024px) {
554  .container {
555    max-width: 800px;
556  }
557}
558`;
559
560export function renderScriptDetail({
561  nodeId,
562  script,
563  contributions,
564  qsWithQ,
565}) {
566  const editHistory = contributions.filter((c) => c.type === "edit");
567  const executionHistory = contributions.filter((c) => c.type === "execute");
568
569  const editHistoryHtml = editHistory.length
570    ? editHistory
571        .map(
572          (c, i) => `
573<li class="history-item">
574  <div class="history-header">
575    <div class="history-title">
576      <span class="edit-number">Edit ${editHistory.length - i}</span>
577      ${c.scriptName ? `<span class="script-name">${c.scriptName}</span>` : ""}
578      ${i === 0 ? `<span class="current-badge">Current</span>` : ""}
579    </div>
580    <div class="history-meta">
581      <span class="version-badge">v${c.nodeVersion}</span>
582      <span class="timestamp">${new Date(c.createdAt).toLocaleString()}</span>
583    </div>
584  </div>
585
586  ${
587    c.contents
588      ? `
589  <details>
590    <summary>
591      <span class="summary-icon">▶</span>
592      View code
593    </summary>
594    <pre class="history-code">${c.contents}</pre>
595  </details>`
596      : `<div class="empty-history-item">Empty script</div>`
597  }
598</li>
599`,
600        )
601        .join("")
602    : `<li class="empty-history">No edit history yet</li>`;
603
604  const executionHistoryHtml = executionHistory.length
605    ? executionHistory
606        .map(
607          (c, i) => `
608<li class="history-item ${c.success ? "success" : "failure"}">
609  <div class="history-header">
610    <div class="history-title">
611      <span class="edit-number">Run ${executionHistory.length - i}</span>
612      ${c.scriptName ? `<span class="script-name">${c.scriptName}</span>` : ""}
613      ${
614        c.success
615          ? `<span class="current-badge success-badge">Success</span>`
616          : `<span class="current-badge failure-badge">Failed</span>`
617      }
618    </div>
619    <div class="history-meta">
620      <span class="version-badge">v${c.nodeVersion}</span>
621      <span class="timestamp">${new Date(c.createdAt).toLocaleString()}</span>
622    </div>
623  </div>
624
625  ${
626    c.logs && c.logs.length
627      ? `
628  <details>
629    <summary>
630      <span class="summary-icon">▶</span>
631      View logs (${c.logs.length} ${c.logs.length === 1 ? "entry" : "entries"})
632    </summary>
633    <pre class="history-code">${c.logs.join("\n")}</pre>
634  </details>`
635      : ""
636  }
637
638  ${
639    c.error
640      ? `<div class="error-message">
641          <div class="error-label">Error:</div>
642          <pre class="error-code">${c.error}</pre>
643        </div>`
644      : ""
645  }
646
647  ${
648    !c.logs?.length && !c.error
649      ? `<div class="empty-history-item">No logs or output</div>`
650      : ""
651  }
652</li>
653`,
654        )
655        .join("")
656    : `<li class="empty-history">No executions yet</li>`;
657
658  const body = `
659  <div class="container">
660    <!-- Back Navigation -->
661    <div class="back-nav">
662      <a href="/api/v1/node/${nodeId}${qsWithQ}" class="back-link">
663        ← Back to Node
664      </a>
665      <a href="/api/v1/node/${nodeId}/scripts/help${qsWithQ}" class="back-link">
666        📚 Help
667      </a>
668    </div>
669
670    <!-- Header -->
671    <div class="header">
672      <h1>${script.name}</h1>
673      <div class="script-id">ID: ${script.id}</div>
674    </div>
675
676    <!-- Current Script -->
677    <div class="section">
678      <div class="code-container">
679        <div class="code-header">
680          <div class="code-label">Current Script</div>
681          <button class="btn-copy" onclick="copyCode()">📋 Copy</button>
682        </div>
683        <pre id="scriptCode">${script.script}</pre>
684      </div>
685
686      <!-- Execute Button -->
687      <div class="action-bar">
688        <form
689          method="POST"
690          action="/api/v1/node/${nodeId}/script/${script.id}/execute${qsWithQ}"
691          onsubmit="return confirm('Execute this script now?')"
692          style="margin: 0;"
693        >
694          <button type="submit" class="btn-execute">Run Script</button>
695        </form>
696      </div>
697    </div>
698
699    <!-- Edit Script -->
700    <div class="section">
701      <div class="section-title">Edit Script</div>
702      <form
703        method="POST"
704        action="/api/v1/node/${nodeId}/script/${script.id}/edit${qsWithQ}"
705        class="edit-form"
706      >
707        <div class="form-group">
708          <label class="form-label">Script Name</label>
709          <input
710            type="text"
711            name="name"
712            value="${script.name}"
713            placeholder="Enter script name"
714            required
715          />
716        </div>
717
718        <div class="form-group">
719          <label class="form-label">Script Code</label>
720          <textarea
721            name="script"
722            rows="14"
723            placeholder="// Enter your script code here"
724            required
725          >${script.script}</textarea>
726        </div>
727
728        <button type="submit" class="btn-save">💾 Save Changes</button>
729      </form>
730    </div>
731
732    <!-- Execution History -->
733    <div class="section">
734      <div class="section-title">Execution History</div>
735      <ul class="history-list">
736        ${executionHistoryHtml}
737      </ul>
738    </div>
739
740    <!-- Edit History -->
741    <div class="section">
742      <div class="section-title">Edit History</div>
743      <ul class="history-list">
744        ${editHistoryHtml}
745      </ul>
746    </div>
747  </div>
748`;
749
750  const jsCode = `
751    function copyCode() {
752      const code = document.getElementById('scriptCode').textContent;
753      navigator.clipboard.writeText(code).then(() => {
754        const btn = document.querySelector('.btn-copy');
755        const originalText = btn.textContent;
756        btn.textContent = '✓ Copied!';
757        setTimeout(() => {
758          btn.textContent = originalText;
759        }, 2000);
760      });
761    }
762`;
763
764  return page({
765    title: `${script.name} — Script`,
766    css: scriptDetailCss,
767    body,
768    js: jsCode,
769  });
770}
771
772/* ================================================================== */
773/* 2. renderScriptHelp                                                 */
774/* ================================================================== */
775
776/* ── page-specific CSS for script help ── */
777
778const scriptHelpCss = `
779.container { max-width: 1100px; }
780
781/* =========================================================
782   UNIFIED GLASS BUTTON SYSTEM
783   ========================================================= */
784
785.glass-btn,
786button,
787.back-link,
788.quick-nav-item,
789.btn-copy {
790  position: relative;
791  overflow: hidden;
792
793  padding: 10px 20px;
794  border-radius: 980px;
795
796  display: inline-flex;
797  align-items: center;
798  justify-content: center;
799  white-space: nowrap;
800
801  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
802  backdrop-filter: blur(22px) saturate(140%);
803  -webkit-backdrop-filter: blur(22px) saturate(140%);
804
805  color: white;
806  text-decoration: none;
807  font-family: inherit;
808
809  font-size: 15px;
810  font-weight: 500;
811  letter-spacing: -0.2px;
812
813  border: 1px solid rgba(255, 255, 255, 0.28);
814
815  box-shadow:
816    0 8px 24px rgba(0, 0, 0, 0.12),
817    inset 0 1px 0 rgba(255, 255, 255, 0.25);
818
819  cursor: pointer;
820
821  transition:
822    background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
823    transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
824    box-shadow 0.3s ease;
825}
826
827/* Liquid light layer */
828.glass-btn::before,
829button::before,
830.back-link::before,
831.quick-nav-item::before,
832.btn-copy::before {
833  content: "";
834  position: absolute;
835  inset: -40%;
836
837  background:
838    radial-gradient(
839      120% 60% at 0% 0%,
840      rgba(255, 255, 255, 0.35),
841      transparent 60%
842    ),
843    linear-gradient(
844      120deg,
845      transparent 30%,
846      rgba(255, 255, 255, 0.25),
847      transparent 70%
848    );
849
850  opacity: 0;
851  transform: translateX(-30%) translateY(-10%);
852  transition:
853    opacity 0.35s ease,
854    transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
855
856  pointer-events: none;
857}
858
859/* Hover motion */
860.glass-btn:hover,
861button:hover,
862.back-link:hover,
863.quick-nav-item:hover,
864.btn-copy:hover {
865  background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
866  transform: translateY(-1px);
867  animation: waterDrift 2.2s ease-in-out infinite alternate;
868}
869
870.glass-btn:hover::before,
871button:hover::before,
872.back-link:hover::before,
873.quick-nav-item:hover::before,
874.btn-copy:hover::before {
875  opacity: 1;
876  transform: translateX(30%) translateY(10%);
877}
878
879/* Active press */
880.glass-btn:active,
881button:active,
882.btn-copy:active,
883.quick-nav-item:active {
884  background: rgba(var(--glass-water-rgb), 0.45);
885  transform: translateY(0);
886  animation: none;
887}
888
889@keyframes waterDrift {
890  0% { transform: translateY(-1px); }
891  100% { transform: translateY(1px); }
892}
893
894/* Button variants */
895.btn-copy {
896  padding: 6px 12px;
897  font-size: 13px;
898}
899
900/* =========================================================
901   CONTENT CARDS
902   ========================================================= */
903
904.header,
905.section {
906  background: rgba(255, 255, 255, 0.15);
907  backdrop-filter: blur(22px) saturate(140%);
908  -webkit-backdrop-filter: blur(22px) saturate(140%);
909  border-radius: 14px;
910  padding: 28px;
911  border: 1px solid rgba(255, 255, 255, 0.28);
912  color: white;
913  margin-bottom: 24px;
914}
915
916.header h1 {
917  font-size: 28px;
918  font-weight: 600;
919  letter-spacing: -0.5px;
920  line-height: 1.3;
921  margin-bottom: 8px;
922  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
923  color: white;
924}
925
926.header h1::before {
927  content: '📚 ';
928  font-size: 26px;
929}
930
931.header-subtitle {
932  font-size: 14px;
933  color: rgba(255, 255, 255, 0.8);
934}
935
936.section-title {
937  font-size: 18px;
938  font-weight: 600;
939  color: white;
940  margin-bottom: 16px;
941  letter-spacing: -0.3px;
942  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
943}
944
945.section-description {
946  font-size: 14px;
947  color: rgba(255, 255, 255, 0.9);
948  line-height: 1.6;
949  margin-bottom: 16px;
950  padding: 12px;
951  background: rgba(255, 255, 255, 0.1);
952  border-radius: 8px;
953  border-left: 3px solid rgba(255, 255, 255, 0.5);
954}
955
956/* =========================================================
957   NAV
958   ========================================================= */
959
960.back-nav {
961  display: flex;
962  gap: 12px;
963  margin-bottom: 20px;
964  flex-wrap: wrap;
965}
966
967/* =========================================================
968   QUICK NAV
969   ========================================================= */
970
971.quick-nav {
972  display: grid;
973  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
974  gap: 12px;
975}
976
977.quick-nav-item {
978  padding: 12px 16px;
979  text-align: center;
980}
981
982/* =========================================================
983   TABLES
984   ========================================================= */
985
986table {
987  width: 100%;
988  border-collapse: collapse;
989  background: rgba(255, 255, 255, 0.1);
990  border-radius: 8px;
991  overflow: hidden;
992  border: 1px solid rgba(255, 255, 255, 0.2);
993}
994
995thead {
996  background: rgba(255, 255, 255, 0.15);
997}
998
999th {
1000  padding: 14px 16px;
1001  text-align: left;
1002  font-weight: 600;
1003  color: white;
1004  font-size: 14px;
1005  text-transform: uppercase;
1006  letter-spacing: 0.5px;
1007  border-bottom: 2px solid rgba(255, 255, 255, 0.2);
1008}
1009
1010td {
1011  padding: 14px 16px;
1012  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
1013  font-size: 14px;
1014  line-height: 1.6;
1015  vertical-align: top;
1016  color: rgba(255, 255, 255, 0.95);
1017}
1018
1019tbody tr:last-child td {
1020  border-bottom: none;
1021}
1022
1023tbody tr {
1024  transition: background 0.2s;
1025}
1026
1027tbody tr:hover {
1028  background: rgba(255, 255, 255, 0.05);
1029}
1030
1031code {
1032  background: rgba(255, 255, 255, 0.2);
1033  padding: 3px 8px;
1034  border-radius: 4px;
1035  font-size: 13px;
1036  font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
1037  color: white;
1038  font-weight: 600;
1039  border: 1px solid rgba(255, 255, 255, 0.3);
1040}
1041
1042pre {
1043  background: rgba(0, 0, 0, 0.3);
1044  color: #e0e0e0;
1045  padding: 20px;
1046  border-radius: 12px;
1047  overflow-x: auto;
1048  font-size: 14px;
1049  line-height: 1.6;
1050  font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
1051  border: 1px solid rgba(255, 255, 255, 0.1);
1052  margin-top: 12px;
1053}
1054
1055pre code {
1056  background: none;
1057  padding: 0;
1058  color: inherit;
1059  font-weight: normal;
1060  border: none;
1061}
1062
1063/* =========================================================
1064   INFO BOX
1065   ========================================================= */
1066
1067.info-box {
1068  background: rgba(255, 193, 7, 0.2);
1069  padding: 16px;
1070  border-radius: 10px;
1071  border-left: 4px solid #ffa500;
1072  margin-bottom: 16px;
1073}
1074
1075.info-box-title {
1076  font-weight: 600;
1077  color: #ffd700;
1078  margin-bottom: 8px;
1079  display: flex;
1080  align-items: center;
1081  gap: 8px;
1082}
1083
1084.info-box-title::before {
1085  content: '⚠️';
1086  font-size: 16px;
1087}
1088
1089.info-box-content {
1090  font-size: 14px;
1091  color: rgba(255, 255, 255, 0.9);
1092  line-height: 1.6;
1093}
1094
1095/* =========================================================
1096   EXAMPLE BOX
1097   ========================================================= */
1098
1099.example-box {
1100  margin-top: 12px;
1101}
1102
1103.example-header {
1104  display: flex;
1105  justify-content: space-between;
1106  align-items: center;
1107  margin-bottom: 12px;
1108}
1109
1110.example-label {
1111  font-size: 14px;
1112  font-weight: 600;
1113  color: rgba(255, 255, 255, 0.7);
1114  text-transform: uppercase;
1115  letter-spacing: 0.5px;
1116}
1117
1118/* =========================================================
1119   RESPONSIVE
1120   ========================================================= */
1121
1122@media (max-width: 640px) {
1123  .container {
1124    max-width: 100%;
1125  }
1126
1127  .header,
1128  .section {
1129    padding: 20px;
1130  }
1131
1132  table {
1133    font-size: 13px;
1134  }
1135
1136  th, td {
1137    padding: 10px;
1138  }
1139
1140  pre {
1141    font-size: 12px;
1142    padding: 16px;
1143  }
1144
1145  .quick-nav {
1146    grid-template-columns: 1fr;
1147  }
1148}
1149
1150@media (min-width: 641px) and (max-width: 1024px) {
1151  .container {
1152    max-width: 900px;
1153  }
1154}
1155`;
1156
1157export function renderScriptHelp({ nodeId, nodeName, data, qsWithQ }) {
1158  const body = `
1159  <div class="container">
1160    <!-- Back Navigation -->
1161    <div class="back-nav">
1162      <a href="/api/v1/node/${nodeId}${qsWithQ}" class="back-link">
1163        ← Back to Node
1164      </a>
1165    </div>
1166
1167    <!-- Header -->
1168    <div class="header">
1169      <h1>Script Help</h1>
1170      <div class="header-subtitle">Learn how to write scripts for your nodes</div>
1171    </div>
1172
1173    <!-- Quick Navigation -->
1174    <div class="section">
1175      <div class="section-title">Quick Jump</div>
1176      <div class="quick-nav">
1177        <a href="#node-data" class="quick-nav-item">Node Data</a>
1178        <a href="#version-properties" class="quick-nav-item">Version Properties</a>
1179        <a href="#other-properties" class="quick-nav-item">Other Properties</a>
1180        <a href="#functions" class="quick-nav-item">Built-in Functions</a>
1181        <a href="#example" class="quick-nav-item">Example Script</a>
1182      </div>
1183    </div>
1184
1185    <!-- Node Data -->
1186    <div class="section" id="node-data">
1187      <div class="section-title">Accessing Node Data</div>
1188
1189      <div class="info-box">
1190        <div class="info-box-title">Important</div>
1191        <div class="info-box-content">
1192          ${data.importantNote}
1193        </div>
1194      </div>
1195
1196      <table>
1197        <thead>
1198          <tr>
1199            <th>Property</th>
1200            <th>Description</th>
1201          </tr>
1202        </thead>
1203        <tbody>
1204          ${data.nodeProperties.basic
1205            .map(
1206              (item) => `
1207            <tr>
1208              <td><code>${item.property}</code></td>
1209              <td>${item.description}</td>
1210            </tr>
1211          `,
1212            )
1213            .join("")}
1214        </tbody>
1215      </table>
1216    </div>
1217
1218    <!-- Version Properties -->
1219    <div class="section" id="version-properties">
1220      <div class="section-title">Version Properties</div>
1221
1222      <div class="section-description">
1223        Access version data using index <code>i</code>. Use <code>0</code> for the first version,
1224        or <code>0</code> for the latest version.
1225      </div>
1226
1227      <table>
1228        <thead>
1229          <tr>
1230            <th>Property</th>
1231            <th>Description</th>
1232          </tr>
1233        </thead>
1234        <tbody>
1235          ${data.nodeProperties.version
1236            .map(
1237              (item) => `
1238            <tr>
1239              <td><code>${item.property}</code></td>
1240              <td>${item.description}${
1241                item.example ? `: <code>${item.example}</code>` : ""
1242              }</td>
1243            </tr>
1244          `,
1245            )
1246            .join("")}
1247        </tbody>
1248      </table>
1249    </div>
1250
1251    <!-- Other Properties -->
1252    <div class="section" id="other-properties">
1253      <div class="section-title">Other Node Properties</div>
1254
1255      <table>
1256        <thead>
1257          <tr>
1258            <th>Property</th>
1259            <th>Description</th>
1260          </tr>
1261        </thead>
1262        <tbody>
1263          ${data.nodeProperties.other
1264            .map(
1265              (item) => `
1266            <tr>
1267              <td><code>${item.property}</code></td>
1268              <td>${item.description}${
1269                item.example ? `: <code>${item.example}</code>` : ""
1270              }</td>
1271            </tr>
1272          `,
1273            )
1274            .join("")}
1275        </tbody>
1276      </table>
1277    </div>
1278
1279    <!-- Built-in Functions -->
1280    <div class="section" id="functions">
1281      <div class="section-title">Built-in Functions</div>
1282
1283      <div class="section-description">
1284        These functions are available globally in all scripts and provide access to node operations.
1285      </div>
1286
1287      <table>
1288        <thead>
1289          <tr>
1290            <th style="width: 40%;">Function</th>
1291            <th>Description</th>
1292          </tr>
1293        </thead>
1294        <tbody>
1295          ${data.builtInFunctions
1296            .map(
1297              (fn) => `
1298            <tr>
1299              <td><code>${fn.name}</code></td>
1300              <td>${fn.description}</td>
1301            </tr>
1302          `,
1303            )
1304            .join("")}
1305        </tbody>
1306      </table>
1307    </div>
1308
1309    <!-- Example Script -->
1310    <div class="section" id="example">
1311      <div class="section-title">Example Script</div>
1312
1313      <div class="section-description">
1314        This example demonstrates a script that tapers a value over time by increasing it by 5%
1315        each time it runs, then schedules itself to run again.
1316      </div>
1317
1318      <div class="example-box">
1319        <div class="example-header">
1320          <div class="example-label">Tapering Script</div>
1321          <button class="btn-copy" onclick="copyExample()">📋 Copy</button>
1322        </div>
1323        <pre id="exampleCode">${data.exampleScript}</pre>
1324      </div>
1325    </div>
1326  </div>
1327`;
1328
1329  const jsCode = `
1330    function copyExample() {
1331      const code = document.getElementById('exampleCode').textContent;
1332      navigator.clipboard.writeText(code).then(() => {
1333        const btn = document.querySelector('.btn-copy');
1334        const originalText = btn.textContent;
1335        btn.textContent = '✓ Copied!';
1336        setTimeout(() => {
1337          btn.textContent = originalText;
1338        }, 2000);
1339      });
1340    }
1341
1342    // Smooth scroll for quick nav
1343    document.querySelectorAll('.quick-nav-item').forEach(link => {
1344      link.addEventListener('click', (e) => {
1345        e.preventDefault();
1346        const target = document.querySelector(link.getAttribute('href'));
1347        target.scrollIntoView({ behavior: 'smooth', block: 'start' });
1348      });
1349    });
1350`;
1351
1352  return page({
1353    title: `Script Help — ${nodeName}`,
1354    css: scriptHelpCss,
1355    body,
1356    js: jsCode,
1357  });
1358}
1359
1import log from "../../seed/log.js";
2import express from "express";
3import authenticate from "../../seed/middleware/authenticate.js";
4
5// Node model wired from init via setNodeModel
6let _Node = null;
7export function setNodeModel(Node) { _Node = Node; }
8import { sendOk, sendError, ERR } from "../../seed/protocol.js";
9import {
10  updateScript,
11  executeScript,
12  getScript,
13} from "./core.js";
14import { getExtension } from "../loader.js";
15import { renderScriptDetail, renderScriptHelp } from "./pages/scripts.js";
16let htmlAuth = authenticate;
17export function resolveHtmlAuth() {
18  const htmlExt = getExtension("html-rendering");
19  if (htmlExt?.exports?.urlAuth) htmlAuth = htmlExt.exports.urlAuth;
20}
21
22const router = express.Router();
23
24const allowedParams = ["token", "html", "error"];
25
26function filterQuery(req) {
27  return Object.entries(req.query)
28    .filter(([key]) => allowedParams.includes(key))
29    .map(([key, val]) => (val === "" ? key : `${key}=${val}`))
30    .join("&");
31}
32
33// GET script detail
34router.get("/node/:nodeId/script/:scriptId", htmlAuth, async (req, res) => {
35  try {
36    const { nodeId, scriptId } = req.params;
37
38    if (!nodeId || !scriptId) {
39      return sendError(res, 400, ERR.INVALID_INPUT, "Missing required fields: nodeId, scriptId");
40    }
41
42    const { script, contributions } = await getScript({ nodeId, scriptId });
43
44    const qs = filterQuery(req);
45    const qsWithQ = qs ? `?${qs}` : "";
46
47    const wantHtml = "html" in req.query;
48
49    if (!wantHtml || !getExtension("html-rendering")) {
50      return sendOk(res, { script, contributions });
51    }
52
53    return res.send(
54      renderScriptDetail({ nodeId, script, contributions, qsWithQ }),
55    );
56  } catch (err) {
57    log.error("Scripts", "Error fetching script:", err);
58
59    if (err.message === "Node not found") {
60      return sendError(res, 404, ERR.NODE_NOT_FOUND, err.message);
61    }
62    if (err.message === "Script not found") {
63      return sendError(res, 404, ERR.NODE_NOT_FOUND, err.message);
64    }
65
66    return sendError(res, 500, ERR.INTERNAL, "Internal server error");
67  }
68});
69
70// Edit script
71router.post(
72  "/node/:nodeId/script/:scriptId/edit",
73  authenticate,
74  async (req, res) => {
75    try {
76      const { nodeId, scriptId } = req.params;
77      const { name, script } = req.body;
78      const userId = req.userId;
79
80      await updateScript({
81        nodeId,
82        scriptId,
83        name,
84        script,
85        userId,
86      });
87      const qs = filterQuery(req);
88
89      return res.redirect(`/api/v1/node/${nodeId}/script/${scriptId}?${qs}`);
90    } catch (err) {
91      log.error("Scripts", "Error editing script:", err);
92      return res.status(500).send("Failed to update script");
93    }
94  },
95);
96
97// Execute script
98router.post(
99  "/node/:nodeId/script/:scriptId/execute",
100  authenticate,
101  async (req, res) => {
102    try {
103      const { nodeId, scriptId } = req.params;
104      const userId = req.userId;
105
106      await executeScript({ nodeId, scriptId, userId });
107
108      const qs = filterQuery(req);
109      return res.redirect(`/api/v1/node/${nodeId}/script/${scriptId}?${qs}`);
110    } catch (err) {
111      log.error("Scripts", "Error executing script:", err);
112
113      let qs = "";
114      try {
115        qs = filterQuery(req);
116      } catch (e) {
117        log.error("Scripts", "filterQuery failed:", e);
118      }
119      const { nodeId, scriptId } = req.params;
120
121      return res.redirect(
122        `/api/v1/node/${nodeId}/script/${scriptId}?${qs}&error=${encodeURIComponent(
123          err.message,
124        )}`,
125      );
126    }
127  },
128);
129
130// Script help/reference page
131router.get("/node/:nodeId/scripts/help", htmlAuth, async (req, res) => {
132  try {
133    const { nodeId } = req.params;
134
135    const node = await _Node.findById(nodeId).lean();
136    if (!node) {
137      return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
138    }
139
140    const data = {
141      nodeProperties: {
142        basic: [
143          { property: "node._id", description: "Node ID (UUID)" },
144          { property: "node.name", description: "Node name" },
145          { property: "node.type", description: "Node type (nullable)" },
146          { property: "node.status", description: "Node status (active, completed, trimmed)" },
147        ],
148        metadata: [
149          {
150            property: "metadata.values",
151            description: "Object mapping string keys to numeric values",
152            example: '{ "health": 100, "gold": 50 }',
153          },
154          {
155            property: "metadata.goals",
156            description: "Object mapping string keys to numeric goals",
157            example: '{ "health": 200, "gold": 100 }',
158          },
159          {
160            property: "metadata.schedules.date",
161            description: "Timestamp (ISO string) for scheduled execution",
162          },
163          {
164            property: "metadata.schedules.reeffectTime",
165            description: "Repeat interval in hours for recurring scripts",
166          },
167        ],
168        other: [
169          {
170            property: "node.scripts",
171            description: "Array of scripts attached to this node",
172            example: "[{ name, script }, ...]",
173          },
174          {
175            property: "node.children",
176            description: "Array of child node IDs (UUIDs)",
177          },
178          {
179            property: "node.parent",
180            description: "Parent node ID (UUID) or null if root",
181          },
182          {
183            property: "node.rootOwner",
184            description: "Root owner user ID (UUID) or null",
185          },
186        ],
187      },
188      builtInFunctions: [
189        {
190          name: "getApi()",
191          description: "Fetches data from API with GET. Returns a promise.",
192        },
193        {
194          name: "setValueForNode(nodeId, key, value)",
195          description: "Sets a value in metadata.values[key]",
196        },
197        {
198          name: "setGoalForNode(nodeId, key, goal)",
199          description: "Sets a goal in metadata.goals[key]",
200        },
201        {
202          name: "editStatusForNode(nodeId, status, version, isInherited)",
203          description:
204            'Updates status: "active", "completed", "trimmed". Can propagate to children.',
205        },
206        {
207          name: "addPrestigeForNode(nodeId)",
208          description: "Prestiges the node by one generation",
209        },
210        {
211          name: "updateScheduleForNode(nodeId, versionIndex, newSchedule, reeffectTime)",
212          description: "Sets schedule timestamp and repeat interval (hours)",
213        },
214      ],
215      exampleScript: `// This script tapers a value over time
216let waitTime = metadata.values.waitTime;
217const newWaitTime = waitTime * 1.05;
218
219// Update the value
220setValueForNode(node._id, "waitTime", newWaitTime);
221
222// Schedule the script to run again after waitTime hours
223const now = new Date();
224const newSchedule = new Date(now.getTime() + waitTime * 3600 * 1000);
225updateScheduleForNode(node._id, newSchedule, 0);
226
227// Update the waitTime value in the new version
228setValueForNode(node._id, "waitTime", newWaitTime, 0 + 1);`,
229      importantNote:
230        "The node object does not auto-update during script execution. Be careful using it after transactions unless you manually refresh it.",
231    };
232
233    const wantHtml = "html" in req.query;
234
235    if (!wantHtml || !getExtension("html-rendering")) {
236      return sendOk(res, data);
237    }
238
239    const qs = filterQuery(req);
240    const qsWithQ = qs ? `?${qs}` : "";
241
242    return res.send(
243      renderScriptHelp({ nodeId, nodeName: node.name, data, qsWithQ }),
244    );
245  } catch (err) {
246    log.error("Scripts", "Error loading script help:", err);
247    return sendError(res, 500, ERR.INTERNAL, "Internal server error");
248  }
249});
250
251// Create new script
252router.post("/node/:nodeId/script/create", authenticate, async (req, res) => {
253  try {
254    const { nodeId } = req.params;
255    const { name } = req.body;
256    const userId = req.userId;
257
258    if (!name) {
259      return res.status(400).send("Script name is required");
260    }
261
262    const result = await updateScript({
263      nodeId,
264      name,
265      userId,
266    });
267
268    const qs = filterQuery(req);
269
270    return res.redirect(
271      `/api/v1/node/${nodeId}/script/${result.scriptId}?${qs}`,
272    );
273  } catch (err) {
274    log.error("Scripts", "Create script error:", err);
275    return res.status(500).send("Failed to create script");
276  }
277});
278
279export default router;
280
1import axios from "axios";
2import { getLandUrl } from "../../../canopy/identity.js";
3
4import { editStatus } from "../../../seed/tree/statuses.js";
5
6// Optional extension functions (wired via setExtensions() from init)
7let setValueForNode = async () => { throw new Error("Values extension not installed"); };
8let setGoalForNode = async () => { throw new Error("Values extension not installed"); };
9let addPrestige = async () => { throw new Error("Prestige extension not installed"); };
10let updateSchedule = async () => { throw new Error("Schedules extension not installed"); };
11
12export function setExtensions({ values, prestige, schedules }) {
13  if (values?.setValueForNode) setValueForNode = values.setValueForNode;
14  if (values?.setGoalForNode) setGoalForNode = values.setGoalForNode;
15  if (prestige?.addPrestige) addPrestige = prestige.addPrestige;
16  if (schedules?.updateSchedule) updateSchedule = schedules.updateSchedule;
17}
18
19async function getApi(url) {
20  const blockedHosts = [
21    "127.0.0.1",
22    "localhost",
23    "10.",
24    "192.168.",
25    "." + ((process.env.CREATOR_DOMAIN || process.env.ROOT_FRONTEND_DOMAIN) ? new URL(process.env.CREATOR_DOMAIN || process.env.ROOT_FRONTEND_DOMAIN).hostname : "tabors.site"),
26    "." + (getLandUrl() ? new URL(getLandUrl()).hostname : "treeos.ai"),
27  ];
28  const host = new URL(url).hostname;
29
30  if (blockedHosts.some((b) => host.startsWith(b))) {
31    throw new Error("Local IPs are blocked");
32  }
33
34  const res = await axios.get(url, { timeout: 10000 });
35  return res.data;
36}
37
38// ---------------- Queue system ----------------
39const nodeQueues = new Map(); // Map<nodeId, Promise>
40
41function enqueue(nodeId, fn) {
42  const last = nodeQueues.get(nodeId) || Promise.resolve();
43  const next = last.then(() => fn());
44  // store the next promise in the queue
45  nodeQueues.set(
46    nodeId,
47    next.catch(() => {}), //if a script fails, it does not send error. need to fix
48  );
49  return next;
50}
51
52//add traverse subtree
53//add note to node
54
55//bound to user id
56function makeSafeFunctions(userId) {
57  return {
58    getApi,
59
60    setValueForNode: (nodeId, key, value, version) =>
61      enqueue(nodeId, () =>
62        setValueForNode({ nodeId, key, value, version, userId }),
63      ),
64
65    setGoalForNode: (nodeId, key, goal, version) =>
66      enqueue(nodeId, () =>
67        setGoalForNode({ nodeId, key, goal, version, userId }),
68      ),
69
70    editStatusForNode: (nodeId, status, version, isInherited) =>
71      enqueue(nodeId, () =>
72        editStatus({ nodeId, status, version, isInherited, userId }),
73      ),
74
75    addPrestigeForNode: (nodeId) =>
76      enqueue(nodeId, () => addPrestige({ nodeId, userId })),
77
78    updateScheduleForNode: (nodeId, versionIndex, newSchedule, reeffectTime) =>
79      enqueue(nodeId, () =>
80        updateSchedule({
81          nodeId,
82          versionIndex,
83          newSchedule,
84          reeffectTime,
85          userId,
86        }),
87      ),
88  };
89}
90
91export { makeSafeFunctions };
92
1import { z } from "zod";
2import { updateScript, executeScript } from "./core.js";
3
4export default [
5  {
6    name: "javascript-scripting-orchestrator",
7    description:
8      "Entry point for javascript node workflows. Establishes intent before any script actions.",
9    schema: {
10      nodeId: z.string().describe("Node ID where scripts are stored."),
11      userId: z.string().describe("Injected by server. Ignore."),
12      chatId: z
13        .string()
14        .nullable()
15        .optional()
16        .describe("Injected by server. Ignore."),
17      sessionId: z
18        .string()
19        .nullable()
20        .optional()
21        .describe("Injected by server. Ignore."),
22    },
23    annotations: {
24      readOnlyHint: true,
25      destructiveHint: false,
26      idempotentHint: true,
27      openWorldHint: false,
28    },
29    handler: async ({ nodeId }) => {
30      const instructions = `
31
32        Use get-node(${nodeId}) to inspect existing scripts and node state.
33  Wait for the user to choose an intent before proceeding.
34  Do not call any other tools yet.
35
36  Here's what I can help with. Choose **one**:
37
38  1️⃣ **Create a new script**
39    - I will ask what behavior you want
40    - I will use node-script-runtime-environment() to learn the functions/tools
41    - I will write the script with you
42    - Then save it using update-node-script
43
44  2️⃣ **Modify an existing script**
45    - View current scripts on the node
46    - Revise logic together
47    - Save changes
48
49  3️⃣ **Execute a script**
50    - Review what the script will do
51    - Ask for confirmation
52    - Run execute-node-script
53
54  Reply with the number, or describe what you want to do.`;
55
56      return {
57        content: [{ type: "text", text: instructions }],
58      };
59    },
60  },
61  {
62    name: "node-script-runtime-environment",
63    description:
64      "Returns the execution environment, APIs, and rules for node scripts. READ-ONLY.",
65    schema: {
66      nodeId: z.string().describe("Node ID whose runtime environment applies."),
67    },
68    annotations: {
69      readOnlyHint: true,
70      destructiveHint: false,
71      idempotentHint: true,
72      openWorldHint: false,
73    },
74    handler: async ({ nodeId }) => {
75      const runtimeDocs = `
76  Node Script Runtime Environment
77
78  Node Object (Snapshot)
79
80  The \`node\` object represents the node state at script start.
81  It does NOT auto-update after mutations.
82  You must reason manually about state changes.
83
84  Core Properties
85
86  node._id
87  node.name
88  node.type
89  0
90
91
92  Versions
93
94  metadata
95
96  i = 0 → first generation
97  i = 0 → most recent
98
99  Version Properties
100
101  values
102  goals
103  schedule (ISO timestamp)
104  prestige
105  reeffectTime (hours)
106  status ("active" | "completed" | "trimmed")
107  dateCreated
108
109  Structure
110
111  node.scripts → [{ name, script }]
112  node.children → child node objects
113  node.parent → parent node ID or null
114  node.rootOwner → root owner ID or null
115
116  Built-in Functions
117
118  All functions run sequentially.
119  Failures do NOT stop later calls.
120
121  API
122
123  getApi() → Performs a GET request
124
125  Node Mutation
126
127  setValueForNode(nodeId, key, value, version)
128  setGoalForNode(nodeId, key, goal, version)
129  editStatusForNode(nodeId, status, version, isInherited)
130  addPrestigeForNode(nodeId)
131  updateScheduleForNode(nodeId, versionIndex, newSchedule, reeffectTime)
132
133  Example Pattern
134
135  // Increase wait time each prestige
136  let waitTime = metadata.values.waitTime;
137  const newWaitTime = waitTime * 1.05;
138
139  addPrestigeForNode(node._id);
140
141  const now = new Date();
142  const newSchedule = new Date(
143    now.getTime() + waitTime * 3600 * 1000
144  );
145
146  updateScheduleForNode(
147    node._id,
148    0 + 1,
149    newSchedule,
150    0
151  );
152
153  setValueForNode(
154    node._id,
155    "waitTime",
156    newWaitTime,
157    0 + 1
158  );
159
160  Execution Notes
161
162  • node reflects initial state only
163  • After addPrestigeForNode, use 0 + 1
164  • Time units are hours
165  • Side effects still occur even if earlier calls fail
166  `;
167
168      return {
169        content: [
170          {
171            type: "text",
172            text: runtimeDocs,
173          },
174        ],
175      };
176    },
177  },
178  {
179    name: "update-node-script",
180    description: "Creates or updates a script attached to a specific node.",
181    schema: {
182      nodeId: z.string().describe("The ID of the node to update."),
183      scriptId: z
184        .string()
185        .describe(
186          "The Id of the script to execute. Found inside of get-node. None if new script",
187        ),
188      userId: z.string().describe("Injected by server. Ignore."),
189      chatId: z
190        .string()
191        .nullable()
192        .optional()
193        .describe("Injected by server. Ignore."),
194      sessionId: z
195        .string()
196        .nullable()
197        .optional()
198        .describe("Injected by server. Ignore."),
199      name: z.string().describe("The name of the script."),
200      script: z
201        .string()
202        .max(2000)
203        .describe("The script content (max 2000 characters)."),
204    },
205    annotations: {
206      readOnlyHint: false,
207      destructiveHint: false,
208      idempotentHint: true,
209      openWorldHint: false,
210    },
211    handler: async ({ nodeId, scriptId, name, script, userId, chatId, sessionId }) => {
212      const result = await updateScript({
213        nodeId,
214        scriptId,
215        name,
216        script,
217        userId,
218        wasAi: true,
219        chatId,
220        sessionId,
221      });
222
223      return {
224        content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
225      };
226    },
227  },
228  {
229    name: "execute-node-script",
230    description:
231      "Always run scripting-orchestrator before to initiate. Executes a stored script attached to a specific node using the secure sandbox system.",
232    schema: {
233      nodeId: z.string().describe("The ID of the node containing the script."),
234      scriptId: z
235        .string()
236        .describe("The Id of the script to execute. Found inside of get-node"),
237      userId: z.string().describe("Injected by server. Ignore."),
238      chatId: z
239        .string()
240        .nullable()
241        .optional()
242        .describe("Injected by server. Ignore."),
243      sessionId: z
244        .string()
245        .nullable()
246        .optional()
247        .describe("Injected by server. Ignore."),
248    },
249    annotations: {
250      readOnlyHint: false,
251      destructiveHint: true,
252      idempotentHint: false,
253      openWorldHint: true,
254    },
255    handler: async ({ nodeId, scriptId, userId, chatId, sessionId }) => {
256      const result = await executeScript({
257        nodeId,
258        scriptId,
259        userId,
260        wasAi: true,
261        chatId,
262        sessionId,
263      });
264
265      return {
266        content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
267      };
268    },
269  },
270];
271

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star scripts

Comments

Loading comments...

Post comments from the CLI: treeos ext comment scripts "your comment"
Max 3 comments per extension. One star and one flag per user.