diff --git a/server/plugins/headless-sync/auth.js b/server/plugins/headless-sync/auth.js new file mode 100644 index 0000000..4f56c3e --- /dev/null +++ b/server/plugins/headless-sync/auth.js @@ -0,0 +1,127 @@ +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +function getObAuthFile() { + return path.join( + os.homedir(), + ".config", + "obsidian-headless", + "auth_token", + ); +} + +function getInternalTokenFile(dataDir) { + return path.join(dataDir, "auth-token.json"); +} + +function loadToken(dataDir) { + const internalFile = getInternalTokenFile(dataDir); + + try { + if (fs.existsSync(internalFile)) { + const data = JSON.parse(fs.readFileSync(internalFile, "utf-8")); + + if (data && data.token) { + syncToObCli(data.token); + return data; + } + } + } catch {} + + // Fall back to ob CLI's own auth file + const obAuthFile = getObAuthFile(); + + try { + if (fs.existsSync(obAuthFile)) { + const token = fs.readFileSync(obAuthFile, "utf-8").trim(); + + if (token) { + const data = { token }; + saveInternal(dataDir, data); + return data; + } + } + } catch {} + + return null; +} + +function saveToken(dataDir, tokenData) { + saveInternal(dataDir, tokenData); + syncToObCli(tokenData.token); +} + +function clearToken(dataDir) { + const internalFile = getInternalTokenFile(dataDir); + + try { + if (fs.existsSync(internalFile)) { + fs.unlinkSync(internalFile); + } + } catch {} + + const obAuthFile = getObAuthFile(); + + try { + if (fs.existsSync(obAuthFile)) { + fs.unlinkSync(obAuthFile); + } + } catch {} +} + +function isAuthenticated(dataDir) { + const internalFile = getInternalTokenFile(dataDir); + + try { + if (fs.existsSync(internalFile)) { + const data = JSON.parse(fs.readFileSync(internalFile, "utf-8")); + return !!(data && data.token); + } + } catch {} + + return false; +} + +function saveInternal(dataDir, tokenData) { + const internalFile = getInternalTokenFile(dataDir); + const dir = path.dirname(internalFile); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(internalFile, JSON.stringify(tokenData, null, 2), "utf-8"); +} + +function syncToObCli(token) { + const obAuthFile = getObAuthFile(); + + try { + const dir = path.dirname(obAuthFile); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(obAuthFile, token, "utf-8"); + } catch {} +} + +function getTokenInfo(dataDir) { + const internalFile = getInternalTokenFile(dataDir); + + try { + if (fs.existsSync(internalFile)) { + const data = JSON.parse(fs.readFileSync(internalFile, "utf-8")); + + if (data && data.token) { + return { email: data.email || null, name: data.name || null }; + } + } + } catch {} + + return null; +} + +module.exports = { loadToken, saveToken, clearToken, isAuthenticated, getTokenInfo }; diff --git a/server/plugins/headless-sync/index.js b/server/plugins/headless-sync/index.js new file mode 100644 index 0000000..92ad097 --- /dev/null +++ b/server/plugins/headless-sync/index.js @@ -0,0 +1,59 @@ +const path = require("path"); +const obCli = require("./ob-cli"); +const auth = require("./auth"); + +module.exports = { + id: "headless-sync", + name: "Headless Sync", + description: "Server-side vault sync via obsidian-headless CLI", + + obsidianPlugin: path.join(__dirname, "plugin"), + + _ctx: null, + _obStatus: null, + + async register(ctx) { + this._ctx = ctx; + + this._obStatus = obCli.checkInstalled(); + + if (this._obStatus.installed) { + ctx.log(`ob CLI available (${this._obStatus.version})`); + } else { + ctx.log("ob CLI not found. Install obsidian-headless to enable sync."); + } + + const token = auth.loadToken(ctx.dataDir); + + if (token) { + ctx.log("Auth token loaded"); + } + + const { mountRoutes } = require("./routes"); + mountRoutes(ctx.router, this); + }, + + async shutdown() { + this._ctx = null; + }, + + async onVaultEnabled(vaultId, vaultPath) { + if (this._ctx) { + this._ctx.log(`Vault enabled: ${vaultId}`); + } + }, + + async onVaultDisabled(vaultId, vaultPath) { + if (this._ctx) { + this._ctx.log(`Vault disabled: ${vaultId}`); + } + }, + + getObStatus() { + return this._obStatus; + }, + + getCtx() { + return this._ctx; + }, +}; diff --git a/server/plugins/headless-sync/ob-cli.js b/server/plugins/headless-sync/ob-cli.js new file mode 100644 index 0000000..17faf55 --- /dev/null +++ b/server/plugins/headless-sync/ob-cli.js @@ -0,0 +1,52 @@ +const { spawn, execSync } = require("child_process"); +const os = require("os"); + +function checkInstalled() { + try { + const output = execSync("ob --version", { stdio: "pipe" }).toString().trim(); + return { installed: true, version: output || "unknown" }; + } catch { + return { installed: false, version: null }; + } +} + +function runCommand(args, opts = {}) { + return new Promise((resolve, reject) => { + const spawnOpts = { + env: { ...process.env, HOME: os.homedir() }, + }; + + if (opts.cwd) { + spawnOpts.cwd = opts.cwd; + } + + let stdout = ""; + let stderr = ""; + + const proc = spawn("ob", args, spawnOpts); + + proc.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + proc.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + } else { + reject( + new Error(`ob ${args[0]} failed (code ${code}): ${stderr || stdout}`), + ); + } + }); + + proc.on("error", (err) => { + reject(err); + }); + }); +} + +module.exports = { checkInstalled, runCommand }; diff --git a/server/plugins/headless-sync/plugin/main.js b/server/plugins/headless-sync/plugin/main.js new file mode 100644 index 0000000..7a48b7b --- /dev/null +++ b/server/plugins/headless-sync/plugin/main.js @@ -0,0 +1,14 @@ +// Stub - will be replaced with real implementation in Phase 4 +const { Plugin } = require("obsidian"); + +class IgnisHeadlessSyncPlugin extends Plugin { + async onload() { + console.log("[ignis-headless-sync] Loaded (stub)"); + } + + onunload() { + console.log("[ignis-headless-sync] Unloaded (stub)"); + } +} + +module.exports = IgnisHeadlessSyncPlugin; diff --git a/server/plugins/headless-sync/plugin/manifest.json b/server/plugins/headless-sync/plugin/manifest.json new file mode 100644 index 0000000..de46339 --- /dev/null +++ b/server/plugins/headless-sync/plugin/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "ignis-headless-sync", + "name": "Ignis Headless Sync", + "version": "0.6.4", + "minAppVersion": "1.0.0", + "description": "Client-side companion for server-side Obsidian Sync", + "author": "Ignis", + "isDesktopOnly": false +} diff --git a/server/plugins/headless-sync/routes.js b/server/plugins/headless-sync/routes.js new file mode 100644 index 0000000..4404959 --- /dev/null +++ b/server/plugins/headless-sync/routes.js @@ -0,0 +1,89 @@ +const auth = require("./auth"); +const obCli = require("./ob-cli"); + +function mountRoutes(router, plugin) { + router.get("/status", (req, res) => { + const ctx = plugin.getCtx(); + const obStatus = plugin.getObStatus(); + + const tokenInfo = auth.getTokenInfo(ctx.dataDir); + + res.json({ + installed: obStatus?.installed || false, + version: obStatus?.version || null, + authenticated: auth.isAuthenticated(ctx.dataDir), + email: tokenInfo?.email || null, + name: tokenInfo?.name || null, + }); + }); + + router.post("/login", (req, res) => { + const ctx = plugin.getCtx(); + const { token, email, name } = req.body; + + if (!token) { + return res.status(400).json({ error: "Token is required" }); + } + + try { + auth.saveToken(ctx.dataDir, { token, email: email || null, name: name || null }); + ctx.log(`Auth token saved${email ? ` for ${email}` : ""}`); + res.json({ success: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + router.post("/logout", (req, res) => { + const ctx = plugin.getCtx(); + + try { + auth.clearToken(ctx.dataDir); + ctx.log("Auth token cleared"); + res.json({ success: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + router.get("/remote-vaults", async (req, res) => { + const ctx = plugin.getCtx(); + + if (!auth.isAuthenticated(ctx.dataDir)) { + return res.status(401).json({ error: "Not authenticated" }); + } + + try { + const result = await obCli.runCommand(["sync-list-remote"]); + const vaults = parseRemoteVaults(result.stdout); + res.json({ vaults }); + } catch (e) { + ctx.log(`Failed to list remote vaults: ${e.message}`); + res.status(500).json({ error: e.message }); + } + }); +} + +function parseRemoteVaults(stdout) { + const lines = stdout.trim().split("\n"); + const vaults = []; + + for (const line of lines) { + const trimmed = line.trim(); + + if (!trimmed || trimmed.startsWith("Available")) { + continue; + } + + // Format: [vaultId] "[vaultName]" ([region]) + const match = trimmed.match(/^([a-f0-9]+)\s+"([^"]+)"/); + + if (match) { + vaults.push({ id: match[1], name: match[2] }); + } + } + + return vaults; +} + +module.exports = { mountRoutes }; diff --git a/src/shims/fs/watcher-client.js b/src/shims/fs/watcher-client.js index e357bab..3a68415 100644 --- a/src/shims/fs/watcher-client.js +++ b/src/shims/fs/watcher-client.js @@ -70,6 +70,11 @@ export function createWatcherClient(metadataCache, contentCache, fsWatch) { } function handleEvent(msg) { + // Skip channel-based plugin messages, those are for other listeners + if (msg.channel) { + return; + } + const { type, path, stat } = msg; if (!type || !path) return;