// Dedicated test-instance driver for Stashpad. // // Launches a SEPARATE Obsidian instance (its own --user-data-dir) that opens ONLY // the Claude Dev Vault, with a CDP remote-debugging port. Claude drives that // instance exclusively, so your normal Obsidian (real vaults, no debug port) is // completely invisible to it. No more risk of touching real work mid-test. // // Usage (via the scripts/obs-dev wrapper, or `node scripts/obs-dev.mjs `): // obs-dev start launch (or reuse) the dev instance + verify it's the right vault // obs-dev verify confirm the debug-port instance IS the Claude Dev Vault (+ sentinel) // obs-dev eval '' run JS with `app` in scope against the dev instance // obs-dev eval-file run a JS file's contents // obs-dev reload full renderer reload (picks up a fresh `pnpm run deploy`) // obs-dev screenshot PNG screenshot of the dev instance // obs-dev stop quit the dev instance // // Node 18+ (global fetch / WebSocket). macOS paths. import { spawn } from "node:child_process"; import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, copyFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve, join } from "node:path"; import { execSync } from "node:child_process"; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = resolve(HERE, ".."); const PORT = 9222; const OBSIDIAN_BIN = "/Applications/Obsidian.app/Contents/MacOS/Obsidian"; const VAULT = resolve(REPO, "..", "Claude Dev Vault"); const VAULT_NAME = "Claude Dev Vault"; const SENTINEL = "claude-dev-vault-7f3a9c2e-do-not-delete"; const USER_DATA_DIR = join(process.env.HOME, "Library", "Application Support", "obsidian-claude-dev"); const cmd = process.argv[2]; const arg = process.argv[3]; // ---- CDP helpers ---------------------------------------------------------- async function portReady() { try { const r = await fetch(`http://localhost:${PORT}/json/version`, { signal: AbortSignal.timeout(1500) }); return r.ok; } catch { return false; } } async function pickPage() { const targets = await (await fetch(`http://localhost:${PORT}/json`)).json(); return targets.find((t) => t.type === "page" && /obsidian\.md|app:\/\/obsidian/.test(t.url)) || targets.find((t) => t.type === "page" && t.url !== "about:blank") || targets[0]; } async function cdp(method, params) { const page = await pickPage(); if (!page) throw new Error("no CDP page target on port " + PORT); const ws = new WebSocket(page.webSocketDebuggerUrl); let id = 0; const pending = new Map(); await new Promise((res, rej) => { ws.onopen = res; ws.onerror = () => rej(new Error("ws error")); }); ws.onmessage = (m) => { const msg = JSON.parse(m.data); if (msg.id && pending.has(msg.id)) { const { res, rej } = pending.get(msg.id); pending.delete(msg.id); msg.error ? rej(new Error(JSON.stringify(msg.error))) : res(msg.result); } }; const out = await new Promise((res, rej) => { const mid = ++id; pending.set(mid, { res, rej }); ws.send(JSON.stringify({ id: mid, method, params })); }); ws.close(); return out; } async function rawEval(wrapped) { return cdp("Runtime.evaluate", { expression: wrapped, awaitPromise: true, returnByValue: true }); } // Run `body` as an async-function body, JSON-stringifying its return value. // `?? "undefined"` so a void body still prints a string rather than nothing. const wrapBody = (body) => `(async () => { try { const __r = await (async () => { ${body} })(); return JSON.stringify(__r) ?? "undefined"; } catch (e) { return JSON.stringify({ __error: String(e && e.stack || e) }); } })()`; async function evalExpr(code) { // Ergonomics: accept BOTH a bare expression (`String(2+2)` → auto-returned, // like a REPL) and statement code that uses its own `return` (the form // verify/eval-file rely on). Try expression-mode first; if it's a *syntax* // error (i.e. the code is multiple statements / already has `return`), fall // back to statement-mode. Only SyntaxErrors fall back, so a runtime-throwing // expression isn't executed twice. let r = await rawEval(wrapBody(`return (${code})`)); const syntaxErr = r.exceptionDetails && /SyntaxError/.test(r.exceptionDetails.exception?.description || r.exceptionDetails.text || ""); if (syntaxErr) r = await rawEval(wrapBody(code)); if (r.exceptionDetails && !r.result?.value) { const d = r.exceptionDetails; return JSON.stringify({ __error: d.exception?.description || d.text }); } return r.result?.value; } // ---- vault verification (name + sentinel) --------------------------------- async function verifyVault() { const raw = await evalExpr(` const f = app.vault.getAbstractFileByPath("CLAUDE-DEV-VAULT-SENTINEL.md"); let sentinel = false; if (f) { try { sentinel = (await app.vault.cachedRead(f)).includes("${SENTINEL}"); } catch {} } return { name: app.vault.getName(), sentinel }; `); let v; try { v = JSON.parse(raw); } catch { v = null; } if (!v || v.name !== VAULT_NAME || !v.sentinel) { throw new Error(`SAFETY ABORT — debug-port instance is NOT the Claude Dev Vault (got ${raw}). Refusing to drive.`); } return v; } // ---- launch --------------------------------------------------------------- function seedConfig() { mkdirSync(USER_DATA_DIR, { recursive: true }); const cfgPath = join(USER_DATA_DIR, "obsidian.json"); let cfg = { vaults: {} }; if (existsSync(cfgPath)) { try { cfg = JSON.parse(readFileSync(cfgPath, "utf8")); } catch { /* reseed */ } } cfg.vaults = cfg.vaults || {}; // Mark every known vault closed, then open ONLY the Claude Dev Vault. for (const k of Object.keys(cfg.vaults)) cfg.vaults[k].open = false; let key = Object.keys(cfg.vaults).find((k) => cfg.vaults[k].path === VAULT); if (!key) { key = "claudedev"; cfg.vaults[key] = { path: VAULT }; } cfg.vaults[key].open = true; cfg.vaults[key].ts = Date.now(); writeFileSync(cfgPath, JSON.stringify(cfg)); } // Obsidian auto-updates by writing a newer `obsidian-.asar` into the // user-data-dir (NOT the .app bundle), and loads the newest asar present there. // The dev profile is isolated, so it never receives those updates and runs the // bundled (older, stable) version — which can render/behave differently from the // user's main app (e.g. the 1.13 declarative-settings API is absent on stable, so // dev tests silently exercise a different code path). Mirror the main profile's // newest asar into the dev profile so the dev instance matches the real app. const MAIN_OBSIDIAN_DIR = join(process.env.HOME, "Library", "Application Support", "obsidian"); function syncInsiderAsar() { try { if (!existsSync(MAIN_OBSIDIAN_DIR)) return; const verOf = (f) => (f.match(/(\d+)\.(\d+)\.(\d+)/) || []).slice(1).map(Number); const cmp = (a, b) => { for (let i = 0; i < 3; i++) { const d = (verOf(a)[i] ?? 0) - (verOf(b)[i] ?? 0); if (d) return d; } return 0; }; const asars = readdirSync(MAIN_OBSIDIAN_DIR).filter((f) => /^obsidian-.*\.asar$/.test(f)).sort(cmp); if (!asars.length) return; // main app on the bundled version too — nothing to mirror const newest = asars[asars.length - 1]; const dst = join(USER_DATA_DIR, newest); if (!existsSync(dst)) { copyFileSync(join(MAIN_OBSIDIAN_DIR, newest), dst); console.log(`synced ${newest} into the dev profile (matches your main Obsidian version)`); } } catch (e) { console.warn("asar sync skipped:", e.message); } } async function start() { if (await portReady()) { const v = await verifyVault(); console.log(`reuse: dev instance already up on :${PORT} → ${v.name} (sentinel ok)`); return; } seedConfig(); syncInsiderAsar(); console.log(`launching dedicated Obsidian (vault: ${VAULT_NAME}, port :${PORT}, isolated user-data-dir)…`); const child = spawn(OBSIDIAN_BIN, [`--remote-debugging-port=${PORT}`, `--user-data-dir=${USER_DATA_DIR}`], { detached: true, stdio: "ignore", }); child.unref(); // Poll for the port, then for the app to finish booting. const deadline = Date.now() + 30000; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 800)); if (await portReady()) break; } if (!(await portReady())) throw new Error("dev instance did not expose the debug port within 30s"); // Give the renderer a moment to load the vault + plugin, then verify. for (let i = 0; i < 20; i++) { await new Promise((r) => setTimeout(r, 700)); try { const v = await verifyVault(); await ensureStashpad(); console.log(`started: ${v.name} on :${PORT} (sentinel ok, Stashpad enabled)`); return; } catch { /* keep waiting */ } } await verifyVault(); // final attempt — throws with detail if still wrong } // A fresh user-data-dir opens the vault in restricted mode (community plugins // off), so turn it on + enable Stashpad once per launch. No-op if already on. async function ensureStashpad() { await evalExpr(` try { await app.plugins.setEnable?.(true); } catch {} try { if (!app.plugins.plugins.stashpad) await app.plugins.enablePlugin?.("stashpad"); } catch {} return !!app.plugins.plugins.stashpad; `); } function stop() { try { execSync(`pkill -f "obsidian-claude-dev"`); console.log("stopped the dev instance."); } catch { console.log("no dev instance was running."); } } async function screenshot(path) { if (!path) throw new Error("screenshot needs an output path"); await verifyVault(); const r = await cdp("Page.captureScreenshot", { format: "png" }); writeFileSync(path, Buffer.from(r.data, "base64")); console.log(`screenshot → ${path}`); } async function reload() { await verifyVault(); await cdp("Page.reload", { ignoreCache: true }); console.log("reloading dev instance renderer (picks up the latest deploy)…"); } // ---- dispatch ------------------------------------------------------------- try { switch (cmd) { case "start": await start(); break; case "verify": { const v = await verifyVault(); console.log(`OK — ${v.name} on :${PORT} (sentinel ok)`); break; } case "eval": { await verifyVault(); console.log(await evalExpr(arg ?? "")); break; } case "eval-file": { await verifyVault(); console.log(await evalExpr(readFileSync(arg, "utf8"))); break; } case "reload": await reload(); break; case "screenshot": await screenshot(arg); break; case "stop": stop(); break; default: console.log("usage: obs-dev start|verify|eval ''|eval-file |reload|screenshot |stop"); } process.exit(0); } catch (e) { console.error(String(e && e.message || e)); process.exit(1); }