diff --git a/src/main.mjs b/src/main.mjs index d97ad77..d15b6cc 100644 --- a/src/main.mjs +++ b/src/main.mjs @@ -17,6 +17,174 @@ 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; + remote = new Remote({ + horde: HORDE, + name: remoteName, + functionsDir: path.join(dir, '.remote', 'functions'), + ctx: { dir, remoteName, spaceName, api: API }, + fallback: CENTER.functions, + }); + 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}`); + } + + if (REMOTES != null) { + const remote = addSpaceRemote(spaceName, absoluteDir); + if (!(await remote.register())) throw new Error('Failed to register space with the hub'); + } + + APP_CONFIG = { ...APP_CONFIG, spaces: { ...APP_CONFIG.spaces, [spaceName]: { path: absoluteDir } } }; + fs.writeFileSync(path.join(WORK_DIR, '.remote', 'config.json'), JSON.stringify(APP_CONFIG, null, 2) + '\n'); + + 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 }); + + fs.cpSync(path.join(TEMPLATES_DIR, 'space'), path.join(absoluteDir, '.remote'), { recursive: true }); + fs.writeFileSync(path.join(absoluteDir, '.remote', 'config.json'), JSON.stringify({ name: spaceName }, null, 2) + '\n'); + + 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 = '.') { @@ -32,194 +200,90 @@ function init(dir = '.') { // --- 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 token = process.env.HORDE_TOKEN || APP_CONFIG.token || null; + const spacesDir = path.join(WORK_DIR, 'spaces'); - 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}`); + console.log(`Running remote-center '${CENTER_NAME}' on ${HOST}, working path ${WORK_DIR}`); 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'), + HORDE = new Horde({ host: HOST, token }); + CENTER = new Remote({ + horde: HORDE, + name: CENTER_NAME, + functionsDir: path.join(WORK_DIR, '.remote', 'functions'), }); - const remotes = new Map([[name, center]]); + REMOTES = new Map([[CENTER_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 = { + API = { createSpace, cloneSpace, - listSpaces: () => [...remotes.keys()].filter((n) => n !== name), + listSpaces: () => [...REMOTES.keys()].filter((n) => n !== CENTER_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(); + 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(); + 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 { - run(); + mainPromise(run()); } \ No newline at end of file