headless-sync-server-plugin
This commit is contained in:
parent
d5ed898839
commit
d8804daf2b
7 changed files with 355 additions and 0 deletions
127
server/plugins/headless-sync/auth.js
Normal file
127
server/plugins/headless-sync/auth.js
Normal 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 };
|
||||||
59
server/plugins/headless-sync/index.js
Normal file
59
server/plugins/headless-sync/index.js
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
52
server/plugins/headless-sync/ob-cli.js
Normal file
52
server/plugins/headless-sync/ob-cli.js
Normal 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 };
|
||||||
14
server/plugins/headless-sync/plugin/main.js
Normal file
14
server/plugins/headless-sync/plugin/main.js
Normal 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;
|
||||||
9
server/plugins/headless-sync/plugin/manifest.json
Normal file
9
server/plugins/headless-sync/plugin/manifest.json
Normal 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
|
||||||
|
}
|
||||||
89
server/plugins/headless-sync/routes.js
Normal file
89
server/plugins/headless-sync/routes.js
Normal 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 };
|
||||||
|
|
@ -70,6 +70,11 @@ export function createWatcherClient(metadataCache, contentCache, fsWatch) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEvent(msg) {
|
function handleEvent(msg) {
|
||||||
|
// Skip channel-based plugin messages, those are for other listeners
|
||||||
|
if (msg.channel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { type, path, stat } = msg;
|
const { type, path, stat } = msg;
|
||||||
|
|
||||||
if (!type || !path) return;
|
if (!type || !path) return;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue