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
Loading comments...