feat: actively managed config-based spaces, can link to any directory. refactor main config loading
This commit is contained in:
1 parent
d9d9b7f463
commit
dd9412ef4f
1 file changed
+225
-161
+225
-161
@@ -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 <spaceName> [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 <spaceName> [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 <source> [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());
|
||||
}
|
||||
Reference in new issue
Block a user