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