headless-sync-server-plugin

This commit is contained in:
Nystik
2026-03-28 16:22:15 +01:00
parent d5ed898839
commit d8804daf2b
7 changed files with 355 additions and 0 deletions

View File

@@ -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 };

View File

@@ -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;
},
};

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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 };