From d9d9b7f463034596dcea0c28c262fb622d128c61 Mon Sep 17 00:00:00 2001 From: Shivering Ocelot Date: Thu, 3 Sep 2026 23:43:33 -0500 Subject: [PATCH] full initial implementation --- .gitignore | 2 + design.md | 10 +- package.json | 6 + readme.md | 17 ++ src/horde.mjs | 163 +++++++++++++ src/main.mjs | 225 ++++++++++++++++++ src/runtime.mjs | 179 ++++++++++++++ templates/center/functions/clone_space.mjs | 16 ++ templates/center/functions/create_space.mjs | 15 ++ templates/space/functions/create_function.mjs | 38 +++ templates/space/functions/delete_file.mjs | 29 +++ templates/space/functions/delete_function.mjs | 26 ++ templates/space/functions/list_files.mjs | 21 ++ templates/space/functions/read_file.mjs | 26 ++ templates/space/functions/write_file.mjs | 30 +++ 15 files changed, 799 insertions(+), 4 deletions(-) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 readme.md create mode 100644 src/horde.mjs create mode 100644 src/main.mjs create mode 100644 src/runtime.mjs create mode 100644 templates/center/functions/clone_space.mjs create mode 100644 templates/center/functions/create_space.mjs create mode 100644 templates/space/functions/create_function.mjs create mode 100644 templates/space/functions/delete_file.mjs create mode 100644 templates/space/functions/delete_function.mjs create mode 100644 templates/space/functions/list_files.mjs create mode 100644 templates/space/functions/read_file.mjs create mode 100644 templates/space/functions/write_file.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d5d398 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +spaces/ +.remote/ \ No newline at end of file diff --git a/design.md b/design.md index 501f16d..20e402a 100644 --- a/design.md +++ b/design.md @@ -23,11 +23,13 @@ These are the initial functions for all remote types: ## Implementation -Application uses `deno` with only 2 commands: 'init' to copy the template directory into the local remote, and 'start' to start in the current directory or a given one. +Application is written in javascript using only `node`, no npm required. Readme explans how to run the code. +Main function simply runs the application loading defaults. Also accepts 'init' command that copies the center template remote in the loaded directory, without running. File structure: -- src/: main.ts (cli, config), runtime.ts (remotes, functions), horde.ts (backend hub connection) -- templates/functions/: create_space.ts and clone_space.ts -- templates/space_functions/: all space functions +- src/: main.js (cli, config), runtime.js (remotes, functions), horde.js (backend hub connection) +- templates/: center and space are template remotes, these are copied as '.remote' in new spaces and initializing the center +- templates/center/functions: create_space.ts and functions/clone_space.ts +- templates/space/functions: all space functions - spaces/: untracked directory where spaces are stored - readme.md - .remote/config.json: holds host, token, name diff --git a/package.json b/package.json new file mode 100644 index 0000000..f7c208f --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "name": "remote-center", + "version": "0.1.0", + "private": true, + "type": "module" +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..8885a00 --- /dev/null +++ b/readme.md @@ -0,0 +1,17 @@ +# Remote Center + +`remote-center` is a slim application that exposes user-written functions to a horde hub as **remotes**. It has no npm dependencies and runs on a plain Node.js install. + +A **center** is the main remote: it connects to the hub, exposes its own functions, and manages **spaces** — named sub-remotes, each with a working subdirectory of the center's working path. Spaces share the center's functions and may define their own. + +## Requirements + +- Node.js >= 22 (uses the built-in global `WebSocket`). +- `git` on PATH, if you want `clone_space` to work with git URLs. + +## Quick start + +```sh +node src/main.js init # copies the center template into ./.remote +# edit .remote/config.json and set your hub token +node src/main.js # runs the center from the current directory \ No newline at end of file diff --git a/src/horde.mjs b/src/horde.mjs new file mode 100644 index 0000000..d413ab2 --- /dev/null +++ b/src/horde.mjs @@ -0,0 +1,163 @@ +// Horde hub WebSocket client for Node.js (>= 22, uses the global WebSocket). +// Modeled on the browser client in reference/backend.js: nonce-tracked actions, +// action listeners, orphan handlers for the auth response, auto-reconnect. + +export class Horde { + constructor({ host, token = null }) { + this.host = host; + this.token = token; + this.socket = null; + this.nextNonce = 0; + this.pending = new Map(); // nonce -> { resolve, reject } + this.actionListeners = new Map(); // action -> [callback(packet)] + this.orphanHandlers = []; // callbacks for nonce-less messages (auth replies) + this.connectListeners = []; + this.reconnectTimeout = null; + this.stopped = false; + } + + connect() { + if (this.stopped) return; + if (this.socket && (this.socket.readyState === WebSocket.CONNECTING || this.socket.readyState === WebSocket.OPEN)) return; + + const socket = new WebSocket(this.host + "/ws"); + this.socket = socket; + + socket.onopen = async () => { + console.log(`Connected to ${this.host}`); + try { + if (this.token) { + socket.send(JSON.stringify({ token: this.token })); + const response = await this.waitOrphan(); + if (response && response.error) { + console.error('Hub authentication failed:', response.error); + socket.close(); + return; + } + } else { + console.warn('No auth token configured; the hub may reject this session'); + } + for (const cb of this.connectListeners) { + try { cb(); } catch (err) { console.error('Connect listener failed:', err); } + } + } catch (err) { + console.error('Error during hub login:', err); + socket.close(); + } + }; + + socket.onmessage = (event) => { + if (typeof event.data === 'string') this.handleMessage(event.data); + }; + + socket.onclose = () => { + this.socket = null; + this.clearPending(); + if (!this.stopped) { + console.log('Disconnected from hub. Reconnecting in 1s...'); + this.reconnectTimeout = setTimeout(() => this.connect(), 1000); + } + }; + + socket.onerror = (event) => { + console.error('WebSocket error:', event.message || event.error || event); + }; + } + + close() { + this.stopped = true; + if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout); + if (this.socket) { + this.socket.onclose = null; + this.socket.close(); + this.socket = null; + } + this.clearPending(); + } + + clearPending() { + for (const { reject } of this.pending.values()) { + reject(new Error('Connection lost')); + } + this.pending.clear(); + } + + onConnect(cb) { + this.connectListeners.push(cb); + } + + onAction(action, cb) { + let list = this.actionListeners.get(action); + if (!list) { + list = []; + this.actionListeners.set(action, list); + } + list.push(cb); + } + + waitOrphan() { + return new Promise((resolve) => this.orphanHandlers.push(resolve)); + } + + send(value) { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket is not open'); + } + this.socket.send(JSON.stringify(value)); + } + + sendAction(action, body = {}) { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error('WebSocket is not open')); + } + const nonce = this.nextNonce++; + const promise = new Promise((resolve, reject) => { + this.pending.set(nonce, { resolve, reject }); + }); + promise.catch(() => {}); // avoid unhandled rejections when nobody awaits + this.send({ action, nonce, body }); + return promise; + } + + handleMessage(raw) { + let packet; + try { + packet = JSON.parse(raw); + } catch (err) { + console.error('Failed to parse hub message:', raw, err); + return; + } + + if (packet.nonce != null) { + const pending = this.pending.get(packet.nonce); + if (!pending) { + console.warn(`Orphan response for nonce ${packet.nonce}`); + return; + } + this.pending.delete(packet.nonce); + if (packet.error) pending.reject(new Error(packet.error)); + else pending.resolve(packet); + return; + } + + if (packet.action) { + const listeners = this.actionListeners.get(packet.action); + if (listeners && listeners.length) { + for (const cb of listeners) { + try { cb(packet); } catch (err) { + console.error(`Listener for '${packet.action}' failed:`, err); + } + } + return; + } + } + + if (this.orphanHandlers.length) { + this.orphanHandlers.shift()(packet); + return; + } + + if (packet.error) console.error('Hub error:', packet.error); + else console.warn('Ignored hub message:', raw); + } +} \ No newline at end of file diff --git a/src/main.mjs b/src/main.mjs new file mode 100644 index 0000000..d97ad77 --- /dev/null +++ b/src/main.mjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node +// remote-center: connects local functions to a horde hub as remotes. +// +// Usage: +// node src/main.js run the center from the current directory +// node src/main.js init [dir] copy the center template into /.remote and exit + +import fs from 'node:fs'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { Horde } from './horde.mjs'; +import { Remote, Watcher } from './runtime.mjs'; + +const APP_DIR = path.resolve(fileURLToPath(new URL('..', import.meta.url))); +const TEMPLATES_DIR = path.join(APP_DIR, 'templates'); +const DEFAULT_HOST = 'wss://hub.hordelab.com'; +const DEFAULT_NAME = 'remote-center'; + +// --- init command --- + +function init(dir = '.') { + const remoteDir = path.resolve(dir, '.remote'); + if (fs.existsSync(remoteDir)) { + console.error(`Refusing to overwrite existing ${remoteDir}`); + process.exit(1); + } + fs.cpSync(path.join(TEMPLATES_DIR, 'center'), remoteDir, { recursive: true }); + console.log(`Initialized center remote at ${remoteDir}`); + console.log('Set your hub token in .remote/config.json, then run: node src/main.js'); +} + +// --- run --- + +function loadConfig(workPath) { + try { + return JSON.parse(fs.readFileSync(path.join(workPath, '.remote', 'config.json'), 'utf8')); + } catch { + return {}; + } +} + +function run() { + const workPath = process.cwd(); + const config = loadConfig(workPath); + + const host = process.env.HORDE_HOST || config.host || DEFAULT_HOST; + const name = config.name || DEFAULT_NAME; + const token = process.env.HORDE_TOKEN || config.token || null; + const spacesDir = path.join(workPath, 'spaces'); + + console.log(`Running remote-center '${name}' on ${host}, working path ${workPath}`); + if (!token) console.warn('No token set in .remote/config.json'); + + const horde = new Horde({ host, token }); + const center = new Remote({ + horde, + name, + functionsDir: path.join(workPath, '.remote', 'functions'), + }); + const remotes = new Map([[name, center]]); + + function checkSpaceName(spaceName) { + if (typeof spaceName !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(spaceName)) { + throw new Error(`Invalid space name: ${JSON.stringify(spaceName)}`); + } + } + + async function createSpace(spaceName) { + checkSpaceName(spaceName); + const dir = path.join(spacesDir, spaceName); + if (fs.existsSync(dir)) throw new Error(`Space already exists: ${spaceName}`); + fs.cpSync(path.join(TEMPLATES_DIR, 'space'), path.join(dir, '.remote'), { recursive: true }); + fs.writeFileSync( + path.join(dir, '.remote', 'config.json'), + JSON.stringify({ name: spaceName }, null, 2) + '\n', + ); + const remote = addSpaceRemote(spaceName, dir); + if (!(await remote.register())) throw new Error('Failed to register space with the hub'); + console.log(`Created space '${spaceName}' at ${dir}`); + return { name: spaceName, dir }; + } + + async function cloneSpace(source, newName) { + const isUrl = /^https?:\/\//.test(source) || source.endsWith('.git') || /@[^@]+:/.test(source); + if (!newName) { + if (!isUrl) throw new Error('A new space name is required when cloning an existing space'); + newName = path.basename(source).replace(/\.git$/, ''); + } + checkSpaceName(newName); + const dir = path.join(spacesDir, newName); + if (fs.existsSync(dir)) throw new Error(`Space already exists: ${newName}`); + + if (isUrl) { + await new Promise((resolve, reject) => { + execFile('git', ['clone', source, dir], (err, _out, stderr) => { + if (err) reject(new Error(`git clone failed: ${stderr.trim() || err.message}`)); + else resolve(); + }); + }); + if (!fs.existsSync(path.join(dir, '.remote'))) { + fs.cpSync(path.join(TEMPLATES_DIR, 'space'), path.join(dir, '.remote'), { recursive: true }); + } + } else { + const sourceDir = path.join(spacesDir, source); + if (!fs.existsSync(sourceDir)) throw new Error(`Unknown space: ${source}`); + fs.cpSync(sourceDir, dir, { recursive: true }); + } + + const remote = addSpaceRemote(newName, dir); + if (!(await remote.register())) throw new Error('Failed to register space with the hub'); + console.log(`Cloned space '${newName}' from ${source}`); + return { name: newName, dir }; + } + + const api = { + createSpace, + cloneSpace, + listSpaces: () => [...remotes.keys()].filter((n) => n !== name), + }; + + center.ctx = { dir: workPath, remoteName: name, api }; + + function addSpaceRemote(spaceName, dir) { + const remoteName = `${name}/${spaceName}`; + let remote = remotes.get(remoteName); + if (remote) return remote; + remote = new Remote({ + horde, + name: remoteName, + functionsDir: path.join(dir, '.remote', 'functions'), + ctx: { dir, remoteName, spaceName, api }, + fallback: center.functions, // spaces share the center's functions + }); + remotes.set(remoteName, remote); + return remote; + } + + // Registers new spaces and disconnects deleted ones. Safe to call repeatedly. + function reconcileSpaces() { + let entries = []; + try { + entries = fs.readdirSync(spacesDir, { withFileTypes: true }); + } catch { + // spaces/ does not exist yet + } + + const present = new Set(); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const dir = path.join(spacesDir, entry.name); + if (!fs.existsSync(path.join(dir, '.remote'))) continue; + present.add(entry.name); + addSpaceRemote(entry.name, dir).register(); + } + + for (const [remoteName, remote] of remotes) { + if (remoteName === name) continue; + const spaceName = remoteName.slice(name.length + 1); + if (!present.has(spaceName)) { + remotes.delete(remoteName); + remote.disconnect(); + console.log(`Space '${spaceName}' deleted; disconnected`); + } + } + } + + horde.onAction('remote-call', (packet) => { + const body = packet.body ?? packet; + const remoteName = body.remotename; + const fnName = body.function; + const args = body.args ?? {}; + const requestId = body.requestId ?? packet.requestId; + + const respond = (fields) => { + try { + horde.send({ + action: 'remote-call-result', + nonce: packet.nonce, + body: { requestId, ...fields }, + }); + } catch (err) { + console.error('Failed to send remote-call-result:', err.message); + } + }; + + Promise.resolve() + .then(() => { + const remote = remotes.get(remoteName); + if (!remote) throw new Error(`Unknown remote: ${remoteName}`); + return remote.call(fnName, args); + }) + .then((result) => respond({ result: result ?? null })) + .catch((err) => respond({ error: String(err?.message || err) })); + }); + + horde.onConnect(() => { + // (Re)register every remote after each (re)connection + for (const remote of remotes.values()) remote.register(); + }); + + reconcileSpaces(); + new Watcher(spacesDir, () => reconcileSpaces()).start(); + horde.connect(); + + process.on('SIGINT', async () => { + console.log('Shutting down...'); + for (const remote of remotes.values()) await remote.disconnect(); + horde.close(); + process.exit(0); + }); +} + +// --- entry point --- + +const [command, ...rest] = process.argv.slice(2); +if (command === 'init') { + init(rest[0]); +} else if (command) { + console.error(`Unknown command: ${command}`); + console.error('Usage: node src/main.js [init [directory]]'); + process.exit(1); +} else { + run(); +} \ No newline at end of file diff --git a/src/runtime.mjs b/src/runtime.mjs new file mode 100644 index 0000000..aef1731 --- /dev/null +++ b/src/runtime.mjs @@ -0,0 +1,179 @@ +// Remote runtime: loads function modules from directories, watches them for +// changes, and registers remotes on a horde connection. + +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const FUNCTION_EXTENSIONS = ['.mjs', '.js']; + +// A directory of function modules. Each module exports `spec` +// ({ name, description, parameters }) and a default async function (args, ctx). +export class Functions { + constructor(dir) { + this.dir = dir; + this.map = new Map(); // spec.name -> { spec, fn, file } + } + + async scan() { + const map = new Map(); + let entries = []; + try { + entries = fs.readdirSync(this.dir, { withFileTypes: true }); + } catch { + this.map = map; // no directory yet: no functions + return map; + } + + // Load .mjs first so it wins over a same-named .js module + entries.sort((a, b) => + FUNCTION_EXTENSIONS.indexOf(path.extname(a.name)) - + FUNCTION_EXTENSIONS.indexOf(path.extname(b.name))); + + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!FUNCTION_EXTENSIONS.includes(path.extname(entry.name))) continue; + const file = path.join(this.dir, entry.name); + try { + // Cache-busting query: node caches modules by URL + const mod = await import(`${pathToFileURL(file).href}?t=${Date.now()}`); + const { spec } = mod; + if (!spec || typeof spec.name !== 'string') { + throw new Error('module must export a `spec` object with a `name` string'); + } + if (typeof mod.default !== 'function') { + throw new Error('module must export a default function'); + } + if (map.has(spec.name)) continue; + map.set(spec.name, { spec, fn: mod.default, file }); + } catch (err) { + console.error(`Failed to load function module ${file}: ${err.message}`); + } + } + + this.map = map; + return map; + } + + specs() { + return [...this.map.values()].map((f) => f.spec); + } + + has(name) { + return this.map.has(name); + } + + async call(name, args, ctx) { + const fn = this.map.get(name); + if (!fn) throw new Error(`Unknown function: ${name}`); + return await fn.fn(args, ctx); + } +} + +// Watches a directory using fs.watch, with a polling fallback every 5 seconds. +export class Watcher { + constructor(dir, onChange, intervalMs = 5000) { + this.dir = dir; + this.onChange = onChange; + this.intervalMs = intervalMs; + this.fsWatcher = null; + this.timer = null; + this.signature = null; + this.debounce = null; + } + + signatureOf() { + try { + return fs.readdirSync(this.dir, { withFileTypes: true }) + .filter((e) => e.isFile()) + .map((e) => { + const st = fs.statSync(path.join(this.dir, e.name)); + return `${e.name}:${st.mtimeMs}`; + }) + .sort() + .join('|'); + } catch { + return null; + } + } + + check() { + const signature = this.signatureOf(); + if (signature !== this.signature) { + const changed = this.signature !== null; + this.signature = signature; + if (changed) this.onChange(); + } + } + + start() { + this.signature = this.signatureOf(); + try { + this.fsWatcher = fs.watch(this.dir, () => { + if (this.debounce) clearTimeout(this.debounce); + this.debounce = setTimeout(() => this.check(), 100); + }); + } catch (err) { + console.warn(`fs.watch unavailable for ${this.dir}, relying on polling: ${err.message}`); + } + this.timer = setInterval(() => this.check(), this.intervalMs); + } + + stop() { + if (this.fsWatcher) this.fsWatcher.close(); + if (this.timer) clearInterval(this.timer); + if (this.debounce) clearTimeout(this.debounce); + } +} + +// A named collection of functions in a directory, registered on the hub. +export class Remote { + constructor({ horde, name, functionsDir, ctx = null, fallback = null }) { + this.horde = horde; + this.name = name; + this.ctx = ctx; + this.fallback = fallback; // shared Functions instance (center functions) + this.functions = new Functions(functionsDir); + this.watcher = null; + } + + // (Re)scans functions and (re)registers the remote. Returns success. + async register() { + await this.functions.scan(); + try { + await this.horde.sendAction('remote-connect', { + remotename: this.name, + functions: this.functions.specs(), + }); + console.log(`Registered remote '${this.name}' with ${this.functions.map.size} function(s)`); + } catch (err) { + console.error(`Failed to register remote '${this.name}': ${err.message}`); + return false; + } + if (!this.watcher) { + this.watcher = new Watcher(this.functions.dir, () => this.register()); + this.watcher.start(); + } + return true; + } + + async disconnect() { + if (this.watcher) this.watcher.stop(); + try { + await this.horde.sendAction('remote-disconnect', { remotename: this.name }); + } catch { + // socket may already be gone + } + } + + // Handles an incoming remote-call. Falls back to shared center functions. + async call(functionName, args) { + if (this.functions.has(functionName)) { + return await this.functions.call(functionName, args, this.ctx); + } + if (this.fallback) { + return await this.fallback.call(functionName, args, this.ctx); + } + throw new Error(`Unknown function '${functionName}' on remote '${this.name}'`); + } +} \ No newline at end of file diff --git a/templates/center/functions/clone_space.mjs b/templates/center/functions/clone_space.mjs new file mode 100644 index 0000000..0473d9f --- /dev/null +++ b/templates/center/functions/clone_space.mjs @@ -0,0 +1,16 @@ +export const spec = { + name: 'clone_space', + description: 'Clone a git repository or an existing space into a new space.', + parameters: { + type: 'object', + properties: { + source: { type: 'string', description: 'Name of an existing space, or a git URL to clone.' }, + name: { type: 'string', description: 'Optional name for the new space (required when cloning an existing space).' }, + }, + required: ['source'], + }, +}; + +export default async function (args, ctx) { + return await ctx.api.cloneSpace(args.source, args.name); +} \ No newline at end of file diff --git a/templates/center/functions/create_space.mjs b/templates/center/functions/create_space.mjs new file mode 100644 index 0000000..ec09162 --- /dev/null +++ b/templates/center/functions/create_space.mjs @@ -0,0 +1,15 @@ +export const spec = { + name: 'create_space', + description: 'Create a new space: a named sub-remote with its own working directory.', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'Name of the new space.' }, + }, + required: ['name'], + }, +}; + +export default async function (args, ctx) { + return await ctx.api.createSpace(args.name); +} \ No newline at end of file diff --git a/templates/space/functions/create_function.mjs b/templates/space/functions/create_function.mjs new file mode 100644 index 0000000..8b219d6 --- /dev/null +++ b/templates/space/functions/create_function.mjs @@ -0,0 +1,38 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const spec = { + name: 'create_function', + description: 'Create a new function on this remote by writing a function module.', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'Function identifier.' }, + description: { type: 'string', description: 'Description shown to the AI.' }, + parameters: { type: 'object', description: 'OpenAI tool parameter schema for the function arguments.' }, + code: { type: 'string', description: 'JavaScript statements forming the function body; `args` and `ctx` are in scope.' }, + }, + required: ['name', 'description', 'parameters', 'code'], + }, +}; + +export default async function (args, ctx) { + const name = args.name; + if (typeof name !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name)) { + throw new Error(`Invalid function name: ${JSON.stringify(name)}`); + } + const functionsDir = path.join(ctx.dir, '.remote', 'functions'); + fs.mkdirSync(functionsDir, { recursive: true }); + const moduleSource = `export const spec = ${JSON.stringify({ + name, + description: args.description, + parameters: args.parameters, + }, null, 2)}; + +export default async function (args, ctx) { +${args.code} +} +`; + fs.writeFileSync(path.join(functionsDir, `${name}.mjs`), moduleSource); + return { created: name }; +} \ No newline at end of file diff --git a/templates/space/functions/delete_file.mjs b/templates/space/functions/delete_file.mjs new file mode 100644 index 0000000..8503a7c --- /dev/null +++ b/templates/space/functions/delete_file.mjs @@ -0,0 +1,29 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const spec = { + name: 'delete_file', + description: 'Delete a file (or directory) in the space.', + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: 'Path relative to the space directory.' }, + }, + required: ['path'], + }, +}; + +function resolveIn(base, relPath) { + const resolved = path.resolve(base, relPath); + if (resolved !== base && !resolved.startsWith(base + path.sep)) { + throw new Error('Path escapes the space directory'); + } + return resolved; +} + +export default async function (args, ctx) { + const file = resolveIn(ctx.dir, args.path); + if (!fs.existsSync(file)) throw new Error(`No such file: ${args.path}`); + fs.rmSync(file, { recursive: true }); + return { deleted: args.path }; +} \ No newline at end of file diff --git a/templates/space/functions/delete_function.mjs b/templates/space/functions/delete_function.mjs new file mode 100644 index 0000000..5bb15fc --- /dev/null +++ b/templates/space/functions/delete_function.mjs @@ -0,0 +1,26 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const spec = { + name: 'delete_function', + description: 'Delete a function module from this remote.', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'Name of the function to delete.' }, + }, + required: ['name'], + }, +}; + +export default async function (args, ctx) { + const functionsDir = path.join(ctx.dir, '.remote', 'functions'); + for (const ext of ['.mjs', '.js']) { + const file = path.join(functionsDir, `${args.name}${ext}`); + if (fs.existsSync(file)) { + fs.unlinkSync(file); + return { deleted: args.name }; + } + } + throw new Error(`No such function: ${args.name}`); +} \ No newline at end of file diff --git a/templates/space/functions/list_files.mjs b/templates/space/functions/list_files.mjs new file mode 100644 index 0000000..592d865 --- /dev/null +++ b/templates/space/functions/list_files.mjs @@ -0,0 +1,21 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const spec = { + name: 'list_files', + description: 'List every file in the space, as paths relative to the space directory.', + parameters: { type: 'object', properties: {} }, +}; + +export default async function (args, ctx) { + const files = []; + const walk = (dir, prefix) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) walk(path.join(dir, entry.name), rel); + else files.push(rel); + } + }; + walk(ctx.dir, ''); + return { files }; +} \ No newline at end of file diff --git a/templates/space/functions/read_file.mjs b/templates/space/functions/read_file.mjs new file mode 100644 index 0000000..86c363b --- /dev/null +++ b/templates/space/functions/read_file.mjs @@ -0,0 +1,26 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const spec = { + name: 'read_file', + description: 'Read the text content of a file in the space.', + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: 'File path relative to the space directory.' }, + }, + required: ['path'], + }, +}; + +function resolveIn(base, relPath) { + const resolved = path.resolve(base, relPath); + if (resolved !== base && !resolved.startsWith(base + path.sep)) { + throw new Error('Path escapes the space directory'); + } + return resolved; +} + +export default async function (args, ctx) { + return { content: fs.readFileSync(resolveIn(ctx.dir, args.path), 'utf8') }; +} \ No newline at end of file diff --git a/templates/space/functions/write_file.mjs b/templates/space/functions/write_file.mjs new file mode 100644 index 0000000..168e005 --- /dev/null +++ b/templates/space/functions/write_file.mjs @@ -0,0 +1,30 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const spec = { + name: 'write_file', + description: 'Write text content to a file in the space, creating parent directories as needed.', + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: 'File path relative to the space directory.' }, + content: { type: 'string', description: 'Text content to write.' }, + }, + required: ['path', 'content'], + }, +}; + +function resolveIn(base, relPath) { + const resolved = path.resolve(base, relPath); + if (resolved !== base && !resolved.startsWith(base + path.sep)) { + throw new Error('Path escapes the space directory'); + } + return resolved; +} + +export default async function (args, ctx) { + const file = resolveIn(ctx.dir, args.path); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, args.content ?? ''); + return { written: args.path }; +} \ No newline at end of file