#!/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, Functions } 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';
// Global runtime state
let WORK_DIR = null;
let APP_CONFIG = null;
let HOST = null;
let CENTER_NAME = null;
let HORDE = null;
let CENTER = null;
let REMOTES = null;
let API = null;
// --- config loading ---
function loadConfig() {
if (!WORK_DIR) WORK_DIR = path.resolve(process.cwd());
try {
return JSON.parse(fs.readFileSync(path.join(WORK_DIR, '.remote', 'config.json'), 'utf8'));
} catch {
return {};
}
}
// --- utility ---
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)}`);
}
}
// --- space operations ---
function addSpaceRemote(spaceName, dir) {
const remoteName = `${CENTER_NAME}/${spaceName}`;
let remote = REMOTES.get(remoteName);
if (remote) return remote;
// Resolve center functions for this space
let spaceConfig = APP_CONFIG.spaces?.[spaceName] || {};
let centerFunctions = spaceConfig.centerFunctions;
if (centerFunctions == null) {
centerFunctions = CENTER ? CENTER.spaceFunctions.names() : [];
}
remote = new Remote({
horde: HORDE,
name: remoteName,
functionsDir: path.join(dir, '.remote', 'functions'),
ctx: { dir, remoteName, spaceName, api: API },
centerFunctions,
fallback: CENTER && CENTER.spaceFunctions,
});
REMOTES.set(remoteName, remote);
return remote;
}
async function initSpace(spaceName, dir) {
checkSpaceName(spaceName);
const absoluteDir = path.resolve(WORK_DIR, dir || path.join('spaces', spaceName));
if (!fs.existsSync(absoluteDir)) {
throw new Error(`Space directory does not exist: ${absoluteDir}`);
}
const remoteDir = path.join(absoluteDir, '.remote');
if (!fs.existsSync(remoteDir)) {
fs.mkdirSync(remoteDir, { recursive: true });
}
// Create empty functions directory
const functionsDir = path.join(remoteDir, 'functions');
if (!fs.existsSync(functionsDir)) {
fs.mkdirSync(functionsDir, { recursive: true });
}
// Get center function names for this space
const spaceConfig = APP_CONFIG.spaces?.[spaceName] || {};
const centerFunctions = spaceConfig.centerFunctions || [];
// Update config with center functions
APP_CONFIG = { ...APP_CONFIG, spaces: { ...APP_CONFIG.spaces, [spaceName]: { path: absoluteDir, centerFunctions } } };
fs.writeFileSync(path.join(WORK_DIR, '.remote', 'config.json'), JSON.stringify(APP_CONFIG, null, 2) + '\n');
if (REMOTES != null) {
const remote = addSpaceRemote(spaceName, absoluteDir);
if (!(await remote.register())) throw new Error('Failed to register space with the hub');
}
console.log(`Initialized space '${spaceName}' at ${absoluteDir}`);
}
async function createSpace(spaceName, dir = null) {
checkSpaceName(spaceName);
const finalDir = dir || path.join(WORK_DIR, 'spaces', spaceName);
const absoluteDir = path.resolve(WORK_DIR, finalDir);
if (fs.existsSync(absoluteDir)) throw new Error(`Space directory already exists: ${absoluteDir}`);
fs.mkdirSync(absoluteDir, { recursive: true });
await initSpace(spaceName, absoluteDir);
}
async function cloneSpace(source, newName, dir = null) {
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 finalDir = dir || path.join(WORK_DIR, 'spaces', newName);
const absoluteDir = path.resolve(WORK_DIR, finalDir);
if (fs.existsSync(absoluteDir)) throw new Error(`Space directory already exists: ${absoluteDir}`);
if (isUrl) {
await new Promise((resolve, reject) => {
execFile('git', ['clone', source, absoluteDir], (err, _out, stderr) => {
if (err) reject(new Error(`git clone failed: ${stderr.trim() || err.message}`));
else resolve();
});
});
if (!fs.existsSync(path.join(absoluteDir, '.remote'))) {
fs.cpSync(path.join(TEMPLATES_DIR, 'space'), path.join(absoluteDir, '.remote'), { recursive: true });
}
} else {
const sourceDir = path.join(WORK_DIR, 'spaces', source);
if (!fs.existsSync(sourceDir)) throw new Error(`Unknown space: ${source}`);
fs.cpSync(sourceDir, absoluteDir, { recursive: true });
}
await initSpace(newName, absoluteDir);
}
function reconcileSpaces() {
const definedSpaces = APP_CONFIG.spaces || {};
const present = new Set();
for (const [spaceName, spaceConfig] of Object.entries(definedSpaces)) {
let absoluteDir;
if (typeof spaceConfig === 'string') {
absoluteDir = path.resolve(WORK_DIR, spaceConfig);
} else {
absoluteDir = path.resolve(WORK_DIR, spaceConfig.path);
}
if (fs.existsSync(absoluteDir)) {
present.add(spaceName);
addSpaceRemote(spaceName, absoluteDir);
}
}
for (const [remoteName, remote] of REMOTES) {
if (remoteName === CENTER_NAME) continue;
const spaceName = remoteName.slice(CENTER_NAME.length + 1);
if (!present.has(spaceName)) {
REMOTES.delete(remoteName);
remote.disconnect();
console.log(`Space '${spaceName}' deleted; disconnected`);
}
}
}
// --- remote call handling ---
function handleRemoteCall(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) }));
}
// --- 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 ---
async function run() {
const token = process.env.HORDE_TOKEN || APP_CONFIG.token || null;
const spacesDir = path.join(WORK_DIR, 'spaces');
const spaceFunctionsDir = path.join(WORK_DIR, 'space-functions');
console.log(`Running remote-center '${CENTER_NAME}' on ${HOST}, working path ${WORK_DIR}`);
if (!token) console.warn('No token set in .remote/config.json');
HORDE = new Horde({ host: HOST, token });
API = {
createSpace,
cloneSpace,
listSpaces: () => [...REMOTES.keys()].filter((n) => n !== CENTER_NAME),
};
CENTER = new Remote({
horde: HORDE,
name: CENTER_NAME,
functionsDir: path.join(WORK_DIR, '.remote', 'functions'),
ctx: { dir: WORK_DIR, remoteName: CENTER_NAME, spaceName: null, api: API },
});
REMOTES = new Map([[CENTER_NAME, CENTER]]);
// Load space functions
CENTER.spaceFunctions = new Functions(spaceFunctionsDir);
await CENTER.spaceFunctions.scan();
new Watcher(spaceFunctionsDir, async () => {
CENTER.spaceFunctions.scan();
// TODO: Reconnect all space remotes
}).start();
reconcileSpaces();
new Watcher(spacesDir, () => reconcileSpaces()).start();
HORDE.connect();
HORDE.onConnect(() => {
for (const remote of REMOTES.values()) remote.register();
});
HORDE.onAction('remote-call', handleRemoteCall);
process.on('SIGINT', async () => {
console.log('Shutting down...');
for (const remote of REMOTES.values()) await remote.disconnect();
HORDE.close();
process.exit(0);
});
}
// --- entry point ---
async function mainPromise (p) {
try {
await p;
} catch (e) {
console.error(e.stack);
process.exit(1);
}
}
WORK_DIR = path.resolve(process.cwd());
APP_CONFIG = loadConfig();
HOST = process.env.HORDE_HOST || APP_CONFIG.host || DEFAULT_HOST;
CENTER_NAME = APP_CONFIG.name || DEFAULT_NAME;
const [command, ...rest] = process.argv.slice(2);
if (command === 'init') {
init(rest[0]);
} else if (command === 'init-space') {
if (!rest[0]) {
console.error('Usage: node src/main.js init-space [path]');
process.exit(1);
}
mainPromise(
initSpace(rest[0], rest[1])
).then(()=>process.exit(0));
} else if (command === 'create-space') {
if (!rest[0]) {
console.error('Usage: node src/main.js create-space [path]');
process.exit(1);
}
createSpace(rest[0], rest[1]);
process.exit(0);
} else if (command === 'clone-space') {
if (!rest[0]) {
console.error('Usage: node src/main.js clone-space [newName] [path]');
process.exit(1);
}
cloneSpace(rest[0], rest[1], rest[2]);
process.exit(0);
} else if (command) {
console.error(`Unknown command: ${command}`);
console.error('Usage: node src/main.js [init [directory]]');
process.exit(1);
} else {
mainPromise(run());
}