@melaya/runner 1.1.60 → 1.1.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1143 @@
1
+ /**
2
+ * Obsidian app bridge — the "Runs on: App" tools of
3
+ * docs/plans/obsidian-vault-connector.md §7.1 (§6.3 S1 "App bridge").
4
+ *
5
+ * Some Obsidian features only exist in the running app: file recovery
6
+ * (history, diff), Sync, Publish, the command palette, plugins, themes,
7
+ * snippets, workspaces and tabs, the web viewer, developer tools. For a vault
8
+ * registered on THIS computer the runner reaches the user's own Obsidian
9
+ * through one of two backends:
10
+ *
11
+ * - the official Obsidian CLI (1.12.7+, Settings → General → Command line
12
+ * interface), detected automatically. Always spawned with an ARGUMENT
13
+ * ARRAY (never a shell string), `vault=<the app's id for the registered
14
+ * folder>` first, a deadline and an output cap;
15
+ * - optionally the Local REST API plugin (5.4.0). Its URL and API key are
16
+ * stored on this computer only (~/.melaya-runner/vaults/obsidian-app.json,
17
+ * 0600) and never sent to Melaya, logs or models. The plugin serves a
18
+ * self-signed certificate: its SHA-256 fingerprint is pinned at connect
19
+ * time and every request checks it BEFORE any header (the key) is
20
+ * written. TLS verification is never disabled globally.
21
+ *
22
+ * Endpoints come from the user (the plugin's own settings in the vault, or
23
+ * `melaya-runner obsidian-rest`), never from a model's arguments.
24
+ *
25
+ * Authority (capability, grant, read/write, route, revocation, dev_tools) is
26
+ * checked by vaults.ts before an op reaches this module; the bridge re-checks
27
+ * dev_tools and folder bounds. App ops act on the whole vault, so a grant
28
+ * limited to folders cannot use them (Files+App reads then stay on the files
29
+ * engine, which filters by folder).
30
+ *
31
+ * Never logged: the API key, op arguments, app output.
32
+ */
33
+ import { spawn } from "child_process";
34
+ import { X509Certificate, randomBytes, timingSafeEqual } from "crypto";
35
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "fs";
36
+ import http from "http";
37
+ import tls from "tls";
38
+ import { homedir, platform as osPlatform, tmpdir } from "os";
39
+ import path from "path";
40
+ const TARGET = { file: "str", path: "vpath" };
41
+ const ONOFF_STATE = ["on", "off"];
42
+ export const APP_OPS = {
43
+ // General and vault
44
+ app_version: { cmd: "version", write: false, noVault: true, rest: "version" },
45
+ app_reload: { cmd: "reload", write: true, timeoutMs: 60_000 },
46
+ app_restart: { cmd: "restart", write: true, timeoutMs: 60_000 },
47
+ app_vault_open: { cmd: "vault:open", write: true, noVault: true },
48
+ // Files
49
+ app_open: { cmd: "open", write: true, params: { ...TARGET, newtab: "flag" }, rest: "open" },
50
+ app_active_file: { cmd: "file", write: false, rest: "active" },
51
+ // Daily, random, search
52
+ app_daily: { cmd: "daily", write: true, params: { paneType: ["tab", "split", "window"] } },
53
+ app_random: { cmd: "random", write: true, params: { folder: "vpath", newtab: "flag" } },
54
+ app_search_open: { cmd: "search:open", write: true, params: { query: "str" } },
55
+ app_dataview_query: { cmd: null, write: false, params: { query: "code" }, required: ["query"], rest: "dataview" },
56
+ // File history
57
+ app_history: { cmd: "history", write: false, params: { ...TARGET } },
58
+ app_history_list: { cmd: "history:list", write: false },
59
+ app_history_read: { cmd: "history:read", write: false, params: { ...TARGET, version: "int" } },
60
+ app_history_restore: { cmd: "history:restore", write: true, params: { ...TARGET, version: "int" }, required: ["version"] },
61
+ app_history_open: { cmd: "history:open", write: true, params: { ...TARGET } },
62
+ app_diff: { cmd: "diff", write: false, params: { ...TARGET, from: "int", to: "int", filter: ["local", "sync"] } },
63
+ // Sync
64
+ app_sync: { cmd: "sync", write: true, state: "required" },
65
+ app_sync_status: { cmd: "sync:status", write: false },
66
+ app_sync_history: { cmd: "sync:history", write: false, params: { ...TARGET, total: "flag" } },
67
+ app_sync_read: { cmd: "sync:read", write: false, params: { ...TARGET, version: "int" }, required: ["version"] },
68
+ app_sync_deleted: { cmd: "sync:deleted", write: false, params: { total: "flag" } },
69
+ app_sync_restore: { cmd: "sync:restore", write: true, params: { ...TARGET, version: "int" }, required: ["version"] },
70
+ app_sync_open: { cmd: "sync:open", write: true, params: { ...TARGET } },
71
+ // Publish
72
+ app_publish_list: { cmd: "publish:list", write: false, params: { total: "flag" } },
73
+ app_publish_status: { cmd: "publish:status", write: false, params: { total: "flag", new: "flag", changed: "flag", deleted: "flag" } },
74
+ app_publish_site: { cmd: "publish:site", write: false },
75
+ app_publish_add: { cmd: "publish:add", write: true, params: { ...TARGET, changed: "flag" }, timeoutMs: 120_000 },
76
+ app_publish_remove: { cmd: "publish:remove", write: true, params: { ...TARGET }, timeoutMs: 120_000 },
77
+ app_publish_open: { cmd: "publish:open", write: true, params: { ...TARGET } },
78
+ // Command palette
79
+ app_commands: { cmd: "commands", write: false, params: { filter: "str" }, rest: "commands" },
80
+ app_command: { cmd: "command", write: true, params: { id: "str" }, required: ["id"], rest: "command", timeoutMs: 60_000 },
81
+ app_hotkeys: { cmd: "hotkeys", write: false, params: { total: "flag", verbose: "flag", format: ["json", "tsv", "csv"] }, json: true },
82
+ app_hotkey: { cmd: "hotkey", write: false, params: { id: "str", verbose: "flag" }, required: ["id"] },
83
+ // Plugins
84
+ plugins: { cmd: "plugins", write: false, mode: "prefer", params: { filter: ["core", "community"], versions: "flag", format: ["json", "tsv", "csv"] }, json: true, filesArgs: ["filter"] },
85
+ plugins_enabled: { cmd: "plugins:enabled", write: false, mode: "prefer", params: { filter: ["core", "community"], versions: "flag", format: ["json", "tsv", "csv"] }, json: true, filesArgs: [] },
86
+ plugin: { cmd: "plugin", write: false, mode: "prefer", params: { id: "str" }, required: ["id"], filesArgs: ["id"] },
87
+ app_plugin_enable: { cmd: "plugin:enable", write: true, params: { id: "str", filter: ["core", "community"] }, required: ["id"] },
88
+ app_plugin_disable: { cmd: "plugin:disable", write: true, params: { id: "str", filter: ["core", "community"] }, required: ["id"] },
89
+ app_plugin_install: { cmd: "plugin:install", write: true, params: { id: "str", enable: "flag" }, required: ["id"], timeoutMs: 100_000 },
90
+ app_plugin_uninstall: { cmd: "plugin:uninstall", write: true, params: { id: "str" }, required: ["id"], timeoutMs: 60_000 },
91
+ app_plugin_reload: { cmd: "plugin:reload", write: true, params: { id: "str" }, required: ["id"], timeoutMs: 60_000 },
92
+ app_plugins_restrict: { cmd: "plugins:restrict", write: true, state: "required" },
93
+ // Themes and snippets
94
+ themes: { cmd: "themes", write: false, mode: "prefer", params: { versions: "flag" }, filesArgs: [] },
95
+ theme: { cmd: "theme", write: false, mode: "prefer", params: { name: "str" }, noFiles: true },
96
+ snippets: { cmd: "snippets", write: false, mode: "prefer", filesArgs: [] },
97
+ snippets_enabled: { cmd: "snippets:enabled", write: false, mode: "prefer", filesArgs: [] },
98
+ app_theme_set: { cmd: "theme:set", write: true, params: { name: "str" }, required: ["name"] },
99
+ app_theme_install: { cmd: "theme:install", write: true, params: { name: "str", enable: "flag" }, required: ["name"], timeoutMs: 100_000 },
100
+ app_theme_uninstall: { cmd: "theme:uninstall", write: true, params: { name: "str" }, required: ["name"] },
101
+ app_snippet_enable: { cmd: "snippet:enable", write: true, params: { name: "str" }, required: ["name"] },
102
+ app_snippet_disable: { cmd: "snippet:disable", write: true, params: { name: "str" }, required: ["name"] },
103
+ // Workspace, tabs, recents, web viewer
104
+ recents: { cmd: "recents", write: false, mode: "prefer", params: { total: "flag" }, filesArgs: [] },
105
+ workspace: { cmd: "workspace", write: false, mode: "prefer", params: { ids: "flag" }, filesArgs: [] },
106
+ workspaces: { cmd: "workspaces", write: false, mode: "prefer", params: { total: "flag" }, filesArgs: [] },
107
+ tabs: { cmd: "tabs", write: false, mode: "prefer", params: { ids: "flag" }, noFiles: true },
108
+ app_workspace_save: { cmd: "workspace:save", write: true, params: { name: "str" }, required: ["name"] },
109
+ app_workspace_load: { cmd: "workspace:load", write: true, params: { name: "str" }, required: ["name"] },
110
+ app_workspace_delete: { cmd: "workspace:delete", write: true, params: { name: "str" }, required: ["name"] },
111
+ app_tab_open: { cmd: "tab:open", write: true, params: { group: "str", file: "vpath", view: "str" } },
112
+ app_web: { cmd: "web", write: true, params: { url: "url", newtab: "flag" }, required: ["url"] },
113
+ // Files + App writes the running app owns (bookmarks.json is rewritten by
114
+ // the app while it runs) or that target the active note.
115
+ bookmark: {
116
+ cmd: "bookmark", write: true, mode: "prefer",
117
+ params: { file: "vpath", subpath: "str", folder: "vpath", search: "str", url: "url", title: "str" },
118
+ filesArgs: ["file", "subpath", "folder", "search", "url", "title"],
119
+ },
120
+ template_insert: { cmd: "template:insert", write: true, mode: "untargeted", params: { name: "str" }, required: ["name"] },
121
+ base_query: {
122
+ cmd: "base:query", write: false, mode: "fallback",
123
+ params: { ...TARGET, view: "str", format: ["json", "csv", "tsv", "md", "paths"] }, json: true,
124
+ },
125
+ // Developer (vault policy dev_tools)
126
+ app_dev_errors: { cmd: "dev:errors", write: false, dev: true, params: { clear: "flag" } },
127
+ app_dev_console: { cmd: "dev:console", write: false, dev: true, params: { limit: "int", level: ["log", "warn", "error", "info", "debug"], clear: "flag" } },
128
+ app_dev_css: { cmd: "dev:css", write: false, dev: true, params: { selector: "str", prop: "str" }, required: ["selector"] },
129
+ app_dev_dom: {
130
+ cmd: "dev:dom", write: false, dev: true,
131
+ params: { selector: "str", attr: "str", css: "str", total: "flag", text: "flag", inner: "flag", all: "flag" }, required: ["selector"],
132
+ },
133
+ app_dev_screenshot: { cmd: "dev:screenshot", write: false, dev: true, timeoutMs: 60_000 },
134
+ app_eval: { cmd: "eval", write: true, dev: true, params: { code: "code" }, required: ["code"], timeoutMs: 60_000 },
135
+ app_dev_cdp: { cmd: "dev:cdp", write: true, dev: true, params: { method: "str", params: "json" }, required: ["method"], timeoutMs: 60_000 },
136
+ app_dev_debug: { cmd: "dev:debug", write: true, dev: true, state: "optional" },
137
+ app_dev_mobile: { cmd: "dev:mobile", write: true, dev: true, state: "optional" },
138
+ app_devtools: { cmd: "devtools", write: true, dev: true },
139
+ };
140
+ /** App ops that change something (need "write" in the grant; HITL at the tool). */
141
+ export const APP_WRITE_OPS = new Set(Object.entries(APP_OPS).filter(([, s]) => s.write).map(([k]) => k));
142
+ /** Developer ops: only when the vault owner turned on "Developer tools". */
143
+ export const APP_DEV_OPS = new Set(Object.entries(APP_OPS).filter(([, s]) => s.dev).map(([k]) => k));
144
+ /** Ops that only the running app serves (app_* plus Files+App ops without a files implementation). */
145
+ export const APP_ONLY_OPS = new Set(Object.entries(APP_OPS).filter(([k, s]) => k.startsWith("app_") || s.noFiles).map(([k]) => k));
146
+ export function isAppOp(op) {
147
+ return Object.prototype.hasOwnProperty.call(APP_OPS, op);
148
+ }
149
+ export class AppBridgeError extends Error {
150
+ code;
151
+ constructor(code, message) {
152
+ super(message);
153
+ this.code = code;
154
+ }
155
+ }
156
+ export function appErr(code, message) {
157
+ return { ok: false, error: { code, message, retry_after: null } };
158
+ }
159
+ const RETURN_CAP_CHARS = 200_000;
160
+ export function appOk(vaultId, value, o) {
161
+ const env = {
162
+ ok: true,
163
+ vault_id: vaultId,
164
+ path: null,
165
+ revision: null,
166
+ index_rev: null,
167
+ freshness: { as_of: new Date().toISOString(), stale: false, source: "app" },
168
+ };
169
+ if (Array.isArray(value))
170
+ env.items = value;
171
+ else
172
+ env.content = value;
173
+ env.next_cursor = null;
174
+ env.truncated = !!o.truncated;
175
+ env.capabilities = [o.backend === "cli" ? "app_cli" : "app_rest"];
176
+ if (o.status)
177
+ env.status = o.status;
178
+ return { ...env, ...(o.extra ?? {}) };
179
+ }
180
+ // ── CLI arguments (argument arrays only) ─────────────────────────────────
181
+ const MAX_VALUE = 4096;
182
+ const MAX_CODE = 64 * 1024;
183
+ function checkVaultPath(v) {
184
+ const s = v.replace(/\\/g, "/");
185
+ if (s.startsWith("/") || /^[A-Za-z]:/.test(s))
186
+ return "must be a path inside the vault";
187
+ const parts = s.split("/").filter(Boolean);
188
+ if (parts.some((p) => p === ".."))
189
+ return "must stay inside the vault";
190
+ // The configuration folder, .git and .trash are never addressed by agents.
191
+ if (parts.length && parts[0].startsWith("."))
192
+ return "cannot address the vault's hidden folders";
193
+ return null;
194
+ }
195
+ /**
196
+ * Build the argv for one CLI call: [`vault=<ref>`], <command>, key=value…,
197
+ * flags, an optional on|off positional. Each value is ONE argv element, so no
198
+ * value can add a parameter, a flag or a shell construct. Unknown arguments
199
+ * are refused (the model sees which).
200
+ */
201
+ export function buildCliArgs(op, args, vaultRef) {
202
+ const spec = APP_OPS[op];
203
+ if (!spec || !spec.cmd)
204
+ return { ok: false, message: `${op} has no Obsidian CLI command.` };
205
+ const params = spec.params ?? {};
206
+ const allowed = new Set([...Object.keys(params), ...(spec.state ? ["state"] : []), "dry_run"]);
207
+ for (const k of Object.keys(args)) {
208
+ if (!allowed.has(k))
209
+ return { ok: false, message: `Unknown argument "${k.slice(0, 40)}" for ${op}.` };
210
+ }
211
+ const argv = [];
212
+ if (!spec.noVault) {
213
+ if (!vaultRef)
214
+ return { ok: false, message: "The vault is not known to Obsidian on this computer." };
215
+ argv.push(`vault=${vaultRef}`);
216
+ }
217
+ argv.push(spec.cmd);
218
+ for (const r of spec.required ?? []) {
219
+ const v = args[r];
220
+ if (v === undefined || v === null || v === "")
221
+ return { ok: false, message: `${r} is required for ${op}.` };
222
+ }
223
+ for (const [k, kind] of Object.entries(params)) {
224
+ const v = args[k];
225
+ if (v === undefined || v === null || v === "" || v === false)
226
+ continue;
227
+ if (kind === "flag") {
228
+ if (v !== true && v !== "true")
229
+ return { ok: false, message: `${k} is a flag (true or false).` };
230
+ argv.push(k);
231
+ continue;
232
+ }
233
+ let s;
234
+ if (kind === "int") {
235
+ const n = typeof v === "number" ? v : Number(String(v));
236
+ if (!Number.isInteger(n) || n < 0 || n > 1e9)
237
+ return { ok: false, message: `${k} must be a whole number.` };
238
+ s = String(n);
239
+ }
240
+ else if (kind === "json") {
241
+ if (typeof v === "string") {
242
+ try {
243
+ JSON.parse(v);
244
+ }
245
+ catch {
246
+ return { ok: false, message: `${k} must be JSON.` };
247
+ }
248
+ s = v;
249
+ }
250
+ else {
251
+ s = JSON.stringify(v);
252
+ }
253
+ }
254
+ else {
255
+ if (typeof v !== "string" && typeof v !== "number")
256
+ return { ok: false, message: `${k} must be text.` };
257
+ s = String(v);
258
+ }
259
+ if (s.includes("\u0000"))
260
+ return { ok: false, message: `${k} contains a NUL character.` };
261
+ const max = kind === "code" || kind === "json" ? MAX_CODE : MAX_VALUE;
262
+ if (s.length > max)
263
+ return { ok: false, message: `${k} is too long (max ${max} characters).` };
264
+ if (kind !== "code" && kind !== "json" && /[\r\n]/.test(s))
265
+ return { ok: false, message: `${k} must be on one line.` };
266
+ if (Array.isArray(kind) && !kind.includes(s))
267
+ return { ok: false, message: `${k} must be one of ${kind.join(", ")}.` };
268
+ if (kind === "vpath") {
269
+ const bad = checkVaultPath(s);
270
+ if (bad)
271
+ return { ok: false, message: `${k} ${bad}.` };
272
+ }
273
+ if (kind === "url" && !/^https?:\/\//i.test(s))
274
+ return { ok: false, message: `${k} must be an http(s) URL.` };
275
+ argv.push(`${k}=${s}`);
276
+ }
277
+ if (spec.json && !args.format)
278
+ argv.push("format=json");
279
+ if (spec.state) {
280
+ const st = args.state;
281
+ if (st === undefined || st === null || st === "") {
282
+ if (spec.state === "required")
283
+ return { ok: false, message: `state (on or off) is required for ${op}.` };
284
+ }
285
+ else if (!ONOFF_STATE.includes(String(st))) {
286
+ return { ok: false, message: "state must be on or off." };
287
+ }
288
+ else {
289
+ argv.push(String(st));
290
+ }
291
+ }
292
+ return { ok: true, argv };
293
+ }
294
+ // ── CLI process (bounded) ─────────────────────────────────────────────────
295
+ export const CLI_DEFAULT_TIMEOUT_MS = 30_000;
296
+ export const CLI_MAX_OUTPUT_BYTES = 1024 * 1024;
297
+ /** Run the CLI once: argv array, no shell, deadline, output cap (the process
298
+ * is killed past either). Never rejects. */
299
+ export function runCli(bin, argv, o = {}) {
300
+ const timeoutMs = o.timeoutMs ?? CLI_DEFAULT_TIMEOUT_MS;
301
+ const maxBytes = o.maxBytes ?? CLI_MAX_OUTPUT_BYTES;
302
+ return new Promise((resolve) => {
303
+ let out = [];
304
+ let err = [];
305
+ let outLen = 0;
306
+ let errLen = 0;
307
+ let truncated = false;
308
+ let timedOut = false;
309
+ let settled = false;
310
+ let child;
311
+ const finish = (code) => {
312
+ if (settled)
313
+ return;
314
+ settled = true;
315
+ clearTimeout(timer);
316
+ resolve({
317
+ code,
318
+ stdout: Buffer.concat(out).toString("utf-8"),
319
+ stderr: Buffer.concat(err).toString("utf-8").slice(0, 4000),
320
+ timedOut,
321
+ truncated,
322
+ });
323
+ out = [];
324
+ err = [];
325
+ };
326
+ try {
327
+ child = (o.spawnImpl ?? spawn)(bin, argv, {
328
+ shell: false,
329
+ windowsHide: true,
330
+ stdio: ["ignore", "pipe", "pipe"],
331
+ cwd: o.cwd ?? tmpdir(),
332
+ });
333
+ }
334
+ catch {
335
+ resolve({ code: -1, stdout: "", stderr: "spawn failed", timedOut: false, truncated: false });
336
+ return;
337
+ }
338
+ const kill = () => { try {
339
+ child.kill();
340
+ }
341
+ catch { /* gone */ } };
342
+ const timer = setTimeout(() => { timedOut = true; kill(); finish(null); }, timeoutMs);
343
+ timer.unref?.();
344
+ child.stdout?.on("data", (d) => {
345
+ if (truncated)
346
+ return;
347
+ if (outLen + d.length > maxBytes) {
348
+ out.push(d.subarray(0, Math.max(0, maxBytes - outLen)));
349
+ outLen = maxBytes;
350
+ truncated = true;
351
+ kill();
352
+ return;
353
+ }
354
+ out.push(d);
355
+ outLen += d.length;
356
+ });
357
+ child.stderr?.on("data", (d) => {
358
+ if (errLen < 16_384) {
359
+ err.push(d);
360
+ errLen += d.length;
361
+ }
362
+ });
363
+ child.on("error", () => finish(-1));
364
+ child.on("close", (code) => finish(typeof code === "number" ? code : null));
365
+ });
366
+ }
367
+ const CLI_DISABLED_RE = /command line interface is not enabled/i;
368
+ /** Map one CLI result to a value or a typed error. The CLI prints errors on
369
+ * stdout with exit code 0 ("Error: …"), so the text is checked too. */
370
+ export function interpretCliOutput(r, timeoutMs = CLI_DEFAULT_TIMEOUT_MS) {
371
+ if (r.timedOut)
372
+ return { ok: false, code: "app_offline", message: `Obsidian did not answer within ${Math.round(timeoutMs / 1000)} s.` };
373
+ const text = r.stdout.replace(/^/, "");
374
+ const trimmed = text.trim();
375
+ if (CLI_DISABLED_RE.test(text) || CLI_DISABLED_RE.test(r.stderr)) {
376
+ return { ok: false, code: "app_offline", message: "The Obsidian command line interface is off. Turn it on in Obsidian: Settings → General → Advanced → Command line interface." };
377
+ }
378
+ const firstLine = (s) => s.split(/\r?\n/).find((l) => l.trim())?.trim().slice(0, 300) || "";
379
+ const errText = /^error\b[:\s]/i.test(trimmed) ? firstLine(trimmed) : r.code !== 0 ? firstLine(r.stderr) || firstLine(trimmed) || `exit code ${r.code}` : "";
380
+ if (errText) {
381
+ const msg = errText.replace(/^error[:\s]+/i, "");
382
+ const code = /not found|does not exist|no such|unknown (file|note|plugin|theme|snippet|workspace|command)/i.test(msg)
383
+ ? "not_found"
384
+ : /not (enabled|available|supported)|requires|subscription|not signed in|not logged in/i.test(msg) ? "unsupported" : "invalid";
385
+ return { ok: false, code, message: `Obsidian: ${msg}` };
386
+ }
387
+ if (!r.truncated && (trimmed.startsWith("{") || trimmed.startsWith("["))) {
388
+ try {
389
+ return { ok: true, value: JSON.parse(trimmed), truncated: false };
390
+ }
391
+ catch { /* plain text */ }
392
+ }
393
+ if (trimmed.length > RETURN_CAP_CHARS)
394
+ return { ok: true, value: trimmed.slice(0, RETURN_CAP_CHARS), truncated: true };
395
+ return { ok: true, value: trimmed, truncated: r.truncated };
396
+ }
397
+ function defaultHostEnv() {
398
+ return { platform: osPlatform(), env: process.env, home: homedir() };
399
+ }
400
+ /** Where the official installer registers the CLI (help: "Command line
401
+ * interface"). MELAYA_OBSIDIAN_CLI overrides it for custom installs; it is
402
+ * local configuration of this computer, never a model argument. */
403
+ export function cliCandidates(h) {
404
+ const out = [];
405
+ const override = h.env.MELAYA_OBSIDIAN_CLI;
406
+ if (override)
407
+ out.push(override);
408
+ const P = h.platform === "win32" ? path.win32 : path.posix;
409
+ if (h.platform === "win32") {
410
+ const local = h.env.LOCALAPPDATA || P.join(h.home, "AppData", "Local");
411
+ out.push(P.join(local, "Programs", "Obsidian", "Obsidian.com"));
412
+ if (h.env.ProgramFiles)
413
+ out.push(P.join(h.env.ProgramFiles, "Obsidian", "Obsidian.com"));
414
+ for (const dir of String(h.env.PATH || h.env.Path || "").split(";"))
415
+ if (dir)
416
+ out.push(P.join(dir, "Obsidian.com"));
417
+ }
418
+ else {
419
+ if (h.platform === "darwin")
420
+ out.push("/usr/local/bin/obsidian", "/opt/homebrew/bin/obsidian");
421
+ else
422
+ out.push(P.join(h.home, ".local", "bin", "obsidian"), "/usr/local/bin/obsidian", "/usr/bin/obsidian");
423
+ for (const dir of String(h.env.PATH || "").split(":"))
424
+ if (dir)
425
+ out.push(P.join(dir, "obsidian"));
426
+ }
427
+ return Array.from(new Set(out));
428
+ }
429
+ /** A launchable CLI binary. Batch files are refused: Windows runs them
430
+ * through cmd.exe, where an argument array is no longer safe. */
431
+ export function findCli(h, exists = isFile) {
432
+ for (const c of cliCandidates(h)) {
433
+ if (/\.(cmd|bat|ps1)$/i.test(c))
434
+ continue;
435
+ if (exists(c))
436
+ return c;
437
+ }
438
+ return null;
439
+ }
440
+ function isFile(p) {
441
+ try {
442
+ return statSync(p).isFile();
443
+ }
444
+ catch {
445
+ return false;
446
+ }
447
+ }
448
+ /** obsidian.json: the app's own list of vaults ({vaults: {id: {path, open}}}). */
449
+ export function obsidianConfigCandidates(h) {
450
+ const P = h.platform === "win32" ? path.win32 : path.posix;
451
+ if (h.platform === "win32") {
452
+ const roaming = h.env.APPDATA || P.join(h.home, "AppData", "Roaming");
453
+ return [P.join(roaming, "obsidian", "obsidian.json")];
454
+ }
455
+ if (h.platform === "darwin")
456
+ return [P.join(h.home, "Library", "Application Support", "obsidian", "obsidian.json")];
457
+ const xdg = h.env.XDG_CONFIG_HOME || P.join(h.home, ".config");
458
+ return [
459
+ P.join(xdg, "obsidian", "obsidian.json"),
460
+ P.join(h.home, ".var", "app", "md.obsidian.Obsidian", "config", "obsidian", "obsidian.json"),
461
+ P.join(h.home, "snap", "obsidian", "current", ".config", "obsidian", "obsidian.json"),
462
+ ];
463
+ }
464
+ /** The app's id for a vault folder (what `vault=` takes), or null. */
465
+ export function findObsidianVaultId(config, vaultPath, platform) {
466
+ const vaults = config?.vaults;
467
+ if (!vaults || typeof vaults !== "object")
468
+ return null;
469
+ const P = platform === "win32" ? path.win32 : path.posix;
470
+ const fold = (p) => {
471
+ const r = P.resolve(p).replace(/[\\/]+$/, "");
472
+ return platform === "win32" || platform === "darwin" ? r.toLowerCase() : r;
473
+ };
474
+ const want = fold(vaultPath);
475
+ for (const [id, v] of Object.entries(vaults)) {
476
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(id) || typeof v?.path !== "string")
477
+ continue;
478
+ let p = v.path;
479
+ try {
480
+ p = realpathSync.native(p);
481
+ }
482
+ catch { /* compare as written */ }
483
+ if (fold(p) === want || fold(v.path) === want)
484
+ return { id, open: v.open === true };
485
+ }
486
+ return null;
487
+ }
488
+ /** Is the Obsidian desktop app running? null = could not tell. Checked first
489
+ * so a read never launches the app as a side effect. */
490
+ export function defaultRunningProbe(platform, spawnImpl) {
491
+ return async () => {
492
+ const [cmd, args, match] = platform === "win32"
493
+ ? ["tasklist", ["/FI", "IMAGENAME eq Obsidian.exe", "/NH", "/FO", "CSV"], /"obsidian\.exe"/i]
494
+ : platform === "darwin"
495
+ ? ["pgrep", ["-x", "Obsidian"], /\d/]
496
+ : ["pgrep", ["-x", "obsidian"], /\d/];
497
+ const r = await runCli(cmd, args, { timeoutMs: 8000, maxBytes: 256 * 1024, spawnImpl });
498
+ if (r.code === -1 || r.timedOut)
499
+ return null;
500
+ return match.test(r.stdout);
501
+ };
502
+ }
503
+ // ── Local REST API plugin (pinned TLS) ────────────────────────────────────
504
+ export const REST_PLUGIN_ID = "obsidian-local-rest-api";
505
+ const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
506
+ /** Upper-case hex without separators. */
507
+ export function normalizeFingerprint(fp) {
508
+ return String(fp || "").replace(/[^A-Fa-f0-9]/g, "").toUpperCase();
509
+ }
510
+ export function fingerprintsEqual(a, b) {
511
+ const x = Buffer.from(normalizeFingerprint(a));
512
+ const y = Buffer.from(normalizeFingerprint(b));
513
+ return x.length === 64 && x.length === y.length && timingSafeEqual(x, y);
514
+ }
515
+ /** https on a loopback host only: the bridge never talks to another machine. */
516
+ export function parseRestUrl(u) {
517
+ let url;
518
+ try {
519
+ url = new URL(u);
520
+ }
521
+ catch {
522
+ return null;
523
+ }
524
+ if (url.protocol !== "https:" || url.username || url.password)
525
+ return null;
526
+ const host = url.hostname;
527
+ if (!LOOPBACK.has(host))
528
+ return null;
529
+ const port = url.port ? Number(url.port) : 443;
530
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
531
+ return null;
532
+ return { host: host === "localhost" ? "127.0.0.1" : host.replace(/^\[|\]$/g, ""), port };
533
+ }
534
+ /**
535
+ * One HTTPS request to the plugin with the certificate pinned. The TLS
536
+ * session is opened first; the peer certificate's SHA-256 fingerprint must
537
+ * equal the pinned one before the request (and its Authorization header) is
538
+ * written. A mismatch is a `forbidden` refusal, never a silent retry.
539
+ */
540
+ export function pinnedRequest(cfg, req, connectImpl = tls.connect) {
541
+ const target = parseRestUrl(cfg.url);
542
+ if (!target)
543
+ return Promise.reject(new AppBridgeError("invalid", "The Local REST API address must be https on this computer (127.0.0.1)."));
544
+ const timeoutMs = req.timeoutMs ?? 15_000;
545
+ const maxBytes = req.maxBytes ?? CLI_MAX_OUTPUT_BYTES;
546
+ return new Promise((resolve, reject) => {
547
+ let done = false;
548
+ let sock = null;
549
+ const fail = (e) => {
550
+ if (done)
551
+ return;
552
+ done = true;
553
+ clearTimeout(timer);
554
+ try {
555
+ sock?.destroy();
556
+ }
557
+ catch { /* closed */ }
558
+ reject(e);
559
+ };
560
+ const timer = setTimeout(() => fail(new AppBridgeError("app_offline", "The Obsidian Local REST API did not answer in time.")), timeoutMs);
561
+ timer.unref?.();
562
+ try {
563
+ // Verification is replaced by the pin for THIS socket only (the plugin
564
+ // signs with its own CA); nothing is sent before the pin matches.
565
+ sock = connectImpl({ host: target.host, port: target.port, rejectUnauthorized: false, ALPNProtocols: ["http/1.1"] });
566
+ }
567
+ catch {
568
+ fail(new AppBridgeError("app_offline", "The Obsidian Local REST API is not reachable."));
569
+ return;
570
+ }
571
+ sock.once("error", () => fail(new AppBridgeError("app_offline", "The Obsidian Local REST API is not reachable (is Obsidian open with the plugin on?).")));
572
+ sock.once("secureConnect", () => {
573
+ if (done || !sock)
574
+ return;
575
+ const cert = sock.getPeerCertificate();
576
+ const fp = cert && typeof cert.fingerprint256 === "string" ? cert.fingerprint256 : "";
577
+ if (!fp || !fingerprintsEqual(fp, cfg.fingerprint256)) {
578
+ fail(new AppBridgeError("forbidden", "The Local REST API certificate changed since you connected it. Reconnect it in Melaya (vault settings → Obsidian app) to trust the new one."));
579
+ return;
580
+ }
581
+ const s = sock;
582
+ const r = http.request({
583
+ host: target.host,
584
+ port: target.port,
585
+ method: req.method,
586
+ path: req.path,
587
+ headers: { Host: `127.0.0.1:${target.port}`, ...(req.headers ?? {}), ...(req.body !== undefined ? { "Content-Length": String(Buffer.byteLength(req.body)) } : {}) },
588
+ createConnection: () => s,
589
+ });
590
+ r.on("response", (res) => {
591
+ const chunks = [];
592
+ let len = 0;
593
+ res.on("data", (d) => {
594
+ len += d.length;
595
+ if (len > maxBytes) {
596
+ fail(new AppBridgeError("invalid", "The Local REST API answer is too large."));
597
+ return;
598
+ }
599
+ chunks.push(d);
600
+ });
601
+ res.on("end", () => {
602
+ if (done)
603
+ return;
604
+ done = true;
605
+ clearTimeout(timer);
606
+ try {
607
+ s.destroy();
608
+ }
609
+ catch { /* closed */ }
610
+ resolve({ status: res.statusCode || 0, contentType: String(res.headers["content-type"] || ""), body: Buffer.concat(chunks) });
611
+ });
612
+ res.on("error", () => fail(new AppBridgeError("app_offline", "The Local REST API connection broke.")));
613
+ });
614
+ r.on("error", () => fail(new AppBridgeError("app_offline", "The Local REST API connection broke.")));
615
+ r.end(req.body);
616
+ });
617
+ });
618
+ }
619
+ /** SHA-256 of the certificate the endpoint presents (trust-on-first-use for
620
+ * a manual connect; the user confirms it). Sends nothing but a TLS hello. */
621
+ export function peerFingerprint(url, connectImpl = tls.connect, timeoutMs = 8000) {
622
+ const target = parseRestUrl(url);
623
+ if (!target)
624
+ return Promise.reject(new AppBridgeError("invalid", "The address must be https on 127.0.0.1."));
625
+ return new Promise((resolve, reject) => {
626
+ const sock = connectImpl({ host: target.host, port: target.port, rejectUnauthorized: false });
627
+ const t = setTimeout(() => { sock.destroy(); reject(new AppBridgeError("app_offline", "No answer.")); }, timeoutMs);
628
+ sock.once("error", () => { clearTimeout(t); reject(new AppBridgeError("app_offline", "Not reachable.")); });
629
+ sock.once("secureConnect", () => {
630
+ clearTimeout(t);
631
+ const fp = sock.getPeerCertificate()?.fingerprint256 || "";
632
+ sock.destroy();
633
+ fp ? resolve(normalizeFingerprint(fp)) : reject(new AppBridgeError("invalid", "No certificate."));
634
+ });
635
+ });
636
+ }
637
+ /** Read the plugin's own settings from the vault's configuration folder
638
+ * (`<config>/plugins/obsidian-local-rest-api/data.json`): the port, the API
639
+ * key and the certificate it serves, so the fingerprint is pinned from the
640
+ * plugin itself rather than trusted on first use. Local read; nothing leaves
641
+ * this computer. */
642
+ export function readRestPluginSettings(vaultRoot, configDirName) {
643
+ const f = path.join(vaultRoot, configDirName, "plugins", REST_PLUGIN_ID, "data.json");
644
+ let raw;
645
+ try {
646
+ const st = lstatSync(f);
647
+ if (st.isSymbolicLink() || !st.isFile() || st.size > 1024 * 1024)
648
+ return { ok: false, error: "plugin_settings_unreadable" };
649
+ raw = readFileSync(f, "utf-8");
650
+ }
651
+ catch {
652
+ return { ok: false, error: "plugin_not_installed" };
653
+ }
654
+ let d;
655
+ try {
656
+ d = JSON.parse(raw);
657
+ }
658
+ catch {
659
+ return { ok: false, error: "plugin_settings_unreadable" };
660
+ }
661
+ if (d?.enableSecureServer === false)
662
+ return { ok: false, error: "plugin_https_off" };
663
+ const apiKey = typeof d?.apiKey === "string" ? d.apiKey : "";
664
+ if (apiKey.length < 16 || apiKey.length > 512 || /[\r\n]/.test(apiKey))
665
+ return { ok: false, error: "plugin_no_key" };
666
+ const certPem = typeof d?.crypto?.cert === "string" ? d.crypto.cert : "";
667
+ let fingerprint256 = "";
668
+ try {
669
+ fingerprint256 = normalizeFingerprint(new X509Certificate(certPem).fingerprint256);
670
+ }
671
+ catch { /* below */ }
672
+ if (!fingerprint256)
673
+ return { ok: false, error: "plugin_no_certificate" };
674
+ const port = Number.isInteger(d?.port) && d.port > 0 && d.port < 65536 ? d.port : 27124;
675
+ const headerName = typeof d?.authorizationHeaderName === "string" && /^[A-Za-z0-9-]{1,64}$/.test(d.authorizationHeaderName)
676
+ ? d.authorizationHeaderName : "Authorization";
677
+ return { ok: true, settings: { port, apiKey, certPem, fingerprint256, headerName } };
678
+ }
679
+ // ── Local config (~/.melaya-runner/vaults/obsidian-app.json, 0600) ────────
680
+ export class AppLocalConfig {
681
+ dir;
682
+ file;
683
+ cache = null;
684
+ constructor(dir) {
685
+ this.dir = dir;
686
+ this.file = path.join(dir, "obsidian-app.json");
687
+ }
688
+ load() {
689
+ let mtimeMs = -1;
690
+ try {
691
+ mtimeMs = statSync(this.file).mtimeMs;
692
+ }
693
+ catch { /* none yet */ }
694
+ if (this.cache && this.cache.mtimeMs === mtimeMs)
695
+ return this.cache.data;
696
+ let data = { rest: {} };
697
+ if (mtimeMs >= 0) {
698
+ try {
699
+ const p = JSON.parse(readFileSync(this.file, "utf-8"));
700
+ if (p && typeof p.rest === "object" && p.rest)
701
+ data = { rest: p.rest };
702
+ }
703
+ catch { /* corrupt: treated as empty */ }
704
+ }
705
+ this.cache = { mtimeMs, data };
706
+ return data;
707
+ }
708
+ getRest(vaultId) {
709
+ const r = this.load().rest[vaultId];
710
+ if (!r || typeof r.url !== "string" || typeof r.apiKey !== "string" || typeof r.fingerprint256 !== "string")
711
+ return null;
712
+ return parseRestUrl(r.url) ? r : null;
713
+ }
714
+ setRest(vaultId, cfg) {
715
+ const data = { rest: { ...this.load().rest } };
716
+ if (cfg)
717
+ data.rest[vaultId] = cfg;
718
+ else
719
+ delete data.rest[vaultId];
720
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
721
+ const tmp = `${this.file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
722
+ writeFileSync(tmp, JSON.stringify(data, null, 2), { encoding: "utf-8", mode: 0o600 });
723
+ renameSync(tmp, this.file);
724
+ try {
725
+ chmodSync(this.file, 0o600);
726
+ }
727
+ catch { /* not supported on this FS */ }
728
+ this.cache = null;
729
+ }
730
+ }
731
+ const RUNNING_TTL_MS = 10_000;
732
+ const VERSION_TTL_MS = 60_000;
733
+ const IDEMPOTENCY_MAX = 512;
734
+ export class ObsidianAppBridge {
735
+ o;
736
+ config;
737
+ h;
738
+ runningProbe;
739
+ running = null;
740
+ versionProbe = null;
741
+ applied = new Map();
742
+ writeChains = new Map();
743
+ constructor(o) {
744
+ this.o = o;
745
+ this.config = new AppLocalConfig(o.dir);
746
+ this.h = o.host ?? defaultHostEnv();
747
+ this.runningProbe = o.runningProbe ?? defaultRunningProbe(this.h.platform, o.spawnImpl);
748
+ }
749
+ bin() {
750
+ return findCli(this.h, this.o.fileExists ?? isFile);
751
+ }
752
+ obsidianConfig() {
753
+ if (this.o.readObsidianConfig)
754
+ return this.o.readObsidianConfig();
755
+ for (const f of obsidianConfigCandidates(this.h)) {
756
+ try {
757
+ return JSON.parse(readFileSync(f, "utf-8"));
758
+ }
759
+ catch { /* next */ }
760
+ }
761
+ return null;
762
+ }
763
+ async isRunning() {
764
+ const now = Date.now();
765
+ if (this.running && now - this.running.at < RUNNING_TTL_MS)
766
+ return this.running.value;
767
+ const value = await this.runningProbe().catch(() => null);
768
+ this.running = { at: now, value };
769
+ return value;
770
+ }
771
+ /** CLI state for one vault + the vault= reference. */
772
+ async cliState(reg) {
773
+ const bin = this.bin();
774
+ if (!bin)
775
+ return { state: "not_installed", bin: null, vaultRef: null, version: null };
776
+ if ((await this.isRunning()) === false)
777
+ return { state: "not_running", bin, vaultRef: null, version: null };
778
+ const hit = findObsidianVaultId(this.obsidianConfig(), reg.path, this.h.platform);
779
+ const now = Date.now();
780
+ if (!this.versionProbe || this.versionProbe.bin !== bin || now - this.versionProbe.at > VERSION_TTL_MS) {
781
+ const r = await runCli(bin, ["version"], { timeoutMs: 10_000, maxBytes: 64 * 1024, spawnImpl: this.o.spawnImpl });
782
+ const i = interpretCliOutput(r, 10_000);
783
+ this.versionProbe = i.ok
784
+ ? { at: now, bin, state: "ready", version: String(i.value ?? "").split(/\r?\n/)[0].slice(0, 80) || null }
785
+ : { at: now, bin, state: /command line interface/i.test(i.message) ? "cli_disabled" : "error", version: null };
786
+ }
787
+ if (this.versionProbe.state === "cli_disabled")
788
+ return { state: "cli_disabled", bin, vaultRef: null, version: null };
789
+ if (this.versionProbe.state === "error")
790
+ return { state: "not_running", bin, vaultRef: null, version: null };
791
+ if (!hit)
792
+ return { state: "vault_unknown", bin, vaultRef: null, version: this.versionProbe.version };
793
+ return { state: "ready", bin, vaultRef: hit.id, version: this.versionProbe.version };
794
+ }
795
+ async restRequest(cfg, req) {
796
+ const headers = { [cfg.headerName || "Authorization"]: `Bearer ${cfg.apiKey}` };
797
+ if (req.contentType)
798
+ headers["Content-Type"] = req.contentType;
799
+ if (req.accept)
800
+ headers.Accept = req.accept;
801
+ return pinnedRequest(cfg, { method: req.method, path: req.path, headers, body: req.body, timeoutMs: req.timeoutMs }, this.o.tlsConnect);
802
+ }
803
+ async restState(vaultId) {
804
+ const cfg = this.config.getRest(vaultId);
805
+ if (!cfg)
806
+ return { state: "not_configured", version: null, cfg: null };
807
+ try {
808
+ const r = await this.restRequest(cfg, { method: "GET", path: "/", timeoutMs: 4000 });
809
+ let j = null;
810
+ try {
811
+ j = JSON.parse(r.body.toString("utf-8"));
812
+ }
813
+ catch { /* not JSON */ }
814
+ if (r.status === 401 || r.status === 403 || j?.authenticated === false)
815
+ return { state: "key_rejected", version: null, cfg };
816
+ return { state: "connected", version: typeof j?.versions?.obsidian === "string" ? j.versions.obsidian : null, cfg };
817
+ }
818
+ catch (e) {
819
+ if (e instanceof AppBridgeError && e.code === "forbidden")
820
+ return { state: "cert_changed", version: null, cfg };
821
+ return { state: "unreachable", version: null, cfg };
822
+ }
823
+ }
824
+ /** Live App availability for one vault (UI status row, capability report). */
825
+ async status(reg) {
826
+ const [cli, rest] = await Promise.all([this.cliState(reg), this.restState(reg.id)]);
827
+ const features = [];
828
+ if (cli.state === "ready")
829
+ features.push("app_cli");
830
+ if (rest.state === "connected")
831
+ features.push("app_rest");
832
+ return {
833
+ cli: cli.state,
834
+ rest: rest.state,
835
+ version: cli.version ?? rest.version,
836
+ features,
837
+ restFingerprint: rest.cfg ? normalizeFingerprint(rest.cfg.fingerprint256).slice(0, 16) : null,
838
+ };
839
+ }
840
+ /** Connect the Local REST API of this vault from the plugin's own settings. */
841
+ async connectRest(reg) {
842
+ const s = readRestPluginSettings(reg.path, reg.configDirName);
843
+ if (!s.ok)
844
+ return s;
845
+ const cfg = {
846
+ url: `https://127.0.0.1:${s.settings.port}`,
847
+ apiKey: s.settings.apiKey,
848
+ fingerprint256: s.settings.fingerprint256,
849
+ headerName: s.settings.headerName,
850
+ connectedAt: new Date().toISOString(),
851
+ };
852
+ try {
853
+ const r = await this.restRequest(cfg, { method: "GET", path: "/", timeoutMs: 6000 });
854
+ let j = null;
855
+ try {
856
+ j = JSON.parse(r.body.toString("utf-8"));
857
+ }
858
+ catch { /* not JSON */ }
859
+ if (r.status === 401 || r.status === 403 || j?.authenticated === false)
860
+ return { ok: false, error: "key_rejected" };
861
+ }
862
+ catch (e) {
863
+ if (e instanceof AppBridgeError && e.code === "forbidden")
864
+ return { ok: false, error: "cert_mismatch" };
865
+ return { ok: false, error: "unreachable" };
866
+ }
867
+ this.config.setRest(reg.id, cfg);
868
+ return { ok: true, status: await this.status(reg) };
869
+ }
870
+ disconnectRest(vaultId) {
871
+ this.config.setRest(vaultId, null);
872
+ }
873
+ /** Forget cached probes (after the user changed something). */
874
+ invalidate() {
875
+ this.running = null;
876
+ this.versionProbe = null;
877
+ }
878
+ /**
879
+ * Serve an op through the app, or say it belongs to the files engine.
880
+ * Returns null for ops the bridge does not know. `grant` is the already
881
+ * verified grant (vaults.ts authorizeVaultOp).
882
+ */
883
+ async serve(input) {
884
+ const { reg, grant, op } = input;
885
+ const spec = APP_OPS[op];
886
+ if (!spec)
887
+ return null;
888
+ const mode = op.startsWith("app_") ? "app" : spec.mode ?? "app";
889
+ const args = { ...input.args };
890
+ if (spec.dev && grant.dev_tools !== true) {
891
+ return { kind: "env", env: appErr("forbidden", "Developer tools are off for this vault. The owner can turn them on in the vault's settings (Developer tools).") };
892
+ }
893
+ const bounded = Array.isArray(grant.folders) && grant.folders.length > 0;
894
+ const filesArgs = () => {
895
+ const keep = new Set(spec.filesArgs ?? Object.keys(spec.params ?? {}));
896
+ const out = {};
897
+ for (const [k, v] of Object.entries(args))
898
+ if (keep.has(k) || k === "dry_run")
899
+ out[k] = v;
900
+ return out;
901
+ };
902
+ if (mode === "fallback")
903
+ return { kind: "files", args };
904
+ if (mode === "untargeted" && (args.file || args.path))
905
+ return { kind: "files", args };
906
+ if (bounded && op !== "app_version") {
907
+ if (mode === "prefer" && !spec.noFiles)
908
+ return { kind: "files", args: filesArgs() };
909
+ return { kind: "env", env: appErr("forbidden_folder", "Obsidian app tools act on the whole vault; this run may only see some folders.") };
910
+ }
911
+ const cli = spec.cmd ? await this.cliState(reg) : null;
912
+ const restCfg = spec.rest ? this.config.getRest(reg.id) : null;
913
+ const viaCli = !!cli && cli.state === "ready";
914
+ if (!viaCli && !restCfg) {
915
+ if (mode === "prefer" && !spec.noFiles)
916
+ return { kind: "files", args: filesArgs() };
917
+ const why = offlineReason(cli?.state ?? null, !!spec.rest);
918
+ return { kind: "env", env: appErr("app_offline", mode === "untargeted" ? `${why} Or name the note with file= or path=.` : why) };
919
+ }
920
+ if (APP_WRITE_OPS.has(op) && args.dry_run) {
921
+ return { kind: "env", env: appOk(reg.id, { op, command: spec.cmd, arguments: describeArgs(args) }, { backend: viaCli ? "cli" : "rest", status: "proposed", extra: { proposal: { op, vault_id: reg.id, command: spec.cmd } } }) };
922
+ }
923
+ delete args.dry_run;
924
+ const run = async () => {
925
+ try {
926
+ if (viaCli)
927
+ return await this.runViaCli(reg, op, spec, args, cli.bin, cli.vaultRef);
928
+ return await this.runViaRest(reg, op, spec, args, restCfg);
929
+ }
930
+ catch (e) {
931
+ if (e instanceof AppBridgeError)
932
+ return appErr(e.code, e.message);
933
+ return appErr("app_offline", "The Obsidian app did not answer.");
934
+ }
935
+ };
936
+ if (!APP_WRITE_OPS.has(op))
937
+ return { kind: "env", env: await run() };
938
+ return { kind: "env", env: await this.applyOnce(reg.id, op, input.idempotencyKey ?? null, run) };
939
+ }
940
+ /** base_query: the files engine answered `unsupported`; the live app may serve it. */
941
+ async afterFiles(input, filesEnv) {
942
+ const spec = APP_OPS[input.op];
943
+ if (!spec || spec.mode !== "fallback")
944
+ return null;
945
+ const e = filesEnv;
946
+ if (!e || e.ok !== false || e.error?.code !== "unsupported")
947
+ return null;
948
+ if (Array.isArray(input.grant.folders) && input.grant.folders.length)
949
+ return null;
950
+ const cli = await this.cliState(input.reg);
951
+ if (cli.state !== "ready")
952
+ return null;
953
+ try {
954
+ return await this.runViaCli(input.reg, input.op, spec, { ...input.args }, cli.bin, cli.vaultRef);
955
+ }
956
+ catch {
957
+ return null;
958
+ }
959
+ }
960
+ /** One write at a time per vault; a retried idempotency key returns the stored result. */
961
+ applyOnce(vaultId, op, key, run) {
962
+ const k = key ? `${vaultId}\u0000${op}\u0000${key}` : null;
963
+ const prev = this.writeChains.get(vaultId) ?? Promise.resolve();
964
+ const next = prev.catch(() => undefined).then(async () => {
965
+ if (k && this.applied.has(k))
966
+ return this.applied.get(k);
967
+ const env = await run();
968
+ if (k && env.ok === true) {
969
+ this.applied.set(k, env);
970
+ while (this.applied.size > IDEMPOTENCY_MAX)
971
+ this.applied.delete(this.applied.keys().next().value);
972
+ }
973
+ return env;
974
+ });
975
+ this.writeChains.set(vaultId, next);
976
+ void next.finally(() => { if (this.writeChains.get(vaultId) === next)
977
+ this.writeChains.delete(vaultId); });
978
+ return next;
979
+ }
980
+ async runViaCli(reg, op, spec, args, bin, vaultRef) {
981
+ if (op === "app_dev_screenshot")
982
+ return this.screenshot(reg, bin, vaultRef, spec);
983
+ let argv;
984
+ if (op === "app_vault_open") {
985
+ if (Object.keys(args).length)
986
+ return appErr("invalid", "obsidian_vault_open takes no arguments: it opens this vault.");
987
+ // vault:open takes the vault's NAME (its folder name in Obsidian).
988
+ argv = { ok: true, argv: ["vault:open", `name=${path.basename(reg.path)}`] };
989
+ }
990
+ else {
991
+ argv = buildCliArgs(op, args, vaultRef);
992
+ }
993
+ if (!argv.ok)
994
+ return appErr("invalid", argv.message);
995
+ const timeoutMs = spec.timeoutMs ?? CLI_DEFAULT_TIMEOUT_MS;
996
+ const r = await runCli(bin, argv.argv, { timeoutMs, spawnImpl: this.o.spawnImpl });
997
+ const i = interpretCliOutput(r, timeoutMs);
998
+ if (!i.ok) {
999
+ if (i.code === "app_offline")
1000
+ this.invalidate();
1001
+ return appErr(i.code, i.message);
1002
+ }
1003
+ return appOk(reg.id, i.value, { backend: "cli", truncated: i.truncated, status: spec.write ? "applied" : undefined });
1004
+ }
1005
+ async screenshot(reg, bin, vaultRef, spec) {
1006
+ // The image goes to a temp file the runner chooses (never a model path),
1007
+ // is read back, then deleted.
1008
+ const dir = path.join(tmpdir(), `melaya-obsidian-${randomBytes(6).toString("hex")}`);
1009
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
1010
+ const file = path.join(dir, "screenshot.png");
1011
+ try {
1012
+ const argv = buildCliArgs("app_dev_screenshot", {}, vaultRef);
1013
+ if (!argv.ok)
1014
+ return appErr("invalid", argv.message);
1015
+ const timeoutMs = spec.timeoutMs ?? CLI_DEFAULT_TIMEOUT_MS;
1016
+ const r = await runCli(bin, [...argv.argv, `path=${file}`], { timeoutMs, spawnImpl: this.o.spawnImpl, maxBytes: 16 * 1024 * 1024 });
1017
+ const i = interpretCliOutput(r, timeoutMs);
1018
+ if (!i.ok)
1019
+ return appErr(i.code, i.message);
1020
+ let data = null;
1021
+ try {
1022
+ const st = statSync(file);
1023
+ if (st.isFile() && st.size > 0 && st.size <= 12 * 1024 * 1024)
1024
+ data = readFileSync(file);
1025
+ }
1026
+ catch { /* the app may have printed it instead */ }
1027
+ if (!data) {
1028
+ const m = /(?:data:image\/png;base64,)?([A-Za-z0-9+/]{200,}={0,2})/.exec(String(i.value ?? ""));
1029
+ if (m)
1030
+ data = Buffer.from(m[1], "base64");
1031
+ }
1032
+ if (!data || data.length < 8 || data.readUInt32BE(0) !== 0x89504e47)
1033
+ return appErr("unsupported", "Obsidian did not return a screenshot.");
1034
+ return appOk(reg.id, { media_type: "image/png", data_b64: data.toString("base64"), bytes: data.length }, { backend: "cli" });
1035
+ }
1036
+ finally {
1037
+ try {
1038
+ rmSync(dir, { recursive: true, force: true });
1039
+ }
1040
+ catch { /* best-effort */ }
1041
+ }
1042
+ }
1043
+ async runViaRest(reg, op, spec, args, cfg) {
1044
+ const json = (r) => {
1045
+ const t = r.body.toString("utf-8");
1046
+ try {
1047
+ return JSON.parse(t);
1048
+ }
1049
+ catch {
1050
+ return t;
1051
+ }
1052
+ };
1053
+ const check = (r) => {
1054
+ if (r.status === 401 || r.status === 403)
1055
+ throw new AppBridgeError("forbidden", "The Local REST API refused the key. Reconnect it in the vault's settings.");
1056
+ if (r.status === 404)
1057
+ throw new AppBridgeError("not_found", "Obsidian: not found.");
1058
+ if (r.status >= 400) {
1059
+ const j = json(r);
1060
+ throw new AppBridgeError("invalid", `Obsidian Local REST API: ${String(j?.message || r.status).slice(0, 300)}`);
1061
+ }
1062
+ };
1063
+ const allowed = new Set([...Object.keys(spec.params ?? {})]);
1064
+ for (const k of Object.keys(args))
1065
+ if (!allowed.has(k))
1066
+ throw new AppBridgeError("invalid", `Unknown argument "${k.slice(0, 40)}" for ${op}.`);
1067
+ switch (spec.rest) {
1068
+ case "version": {
1069
+ const r = await this.restRequest(cfg, { method: "GET", path: "/" });
1070
+ check(r);
1071
+ const j = json(r);
1072
+ return appOk(reg.id, { obsidian: j?.versions?.obsidian ?? null, plugin: j?.versions?.self ?? null }, { backend: "rest" });
1073
+ }
1074
+ case "active": {
1075
+ const r = await this.restRequest(cfg, { method: "GET", path: "/active/", accept: "application/vnd.olrapi.note+json" });
1076
+ check(r);
1077
+ const j = json(r);
1078
+ return appOk(reg.id, { path: j?.path ?? null, stat: j?.stat ?? null, tags: j?.tags ?? [], frontmatter: j?.frontmatter ?? {} }, { backend: "rest" });
1079
+ }
1080
+ case "dataview": {
1081
+ const q = String(args.query ?? "");
1082
+ if (!q.trim() || q.length > MAX_CODE)
1083
+ throw new AppBridgeError("invalid", "query must be a Dataview DQL query.");
1084
+ const r = await this.restRequest(cfg, { method: "POST", path: "/search/", body: q, contentType: "application/vnd.olrapi.dataview.dql+txt", accept: "application/json" });
1085
+ check(r);
1086
+ return appOk(reg.id, json(r), { backend: "rest" });
1087
+ }
1088
+ case "commands": {
1089
+ const r = await this.restRequest(cfg, { method: "GET", path: "/commands/" });
1090
+ check(r);
1091
+ const list = (json(r)?.commands ?? []);
1092
+ const f = typeof args.filter === "string" ? args.filter : "";
1093
+ return appOk(reg.id, list.filter((c) => !f || String(c.id || "").startsWith(f)), { backend: "rest" });
1094
+ }
1095
+ case "command": {
1096
+ const id = String(args.id ?? "");
1097
+ if (!/^[A-Za-z0-9:_.\-]{1,200}$/.test(id))
1098
+ throw new AppBridgeError("invalid", "id must be a command id (e.g. editor:toggle-bold).");
1099
+ const r = await this.restRequest(cfg, { method: "POST", path: `/commands/${encodeURIComponent(id)}/` });
1100
+ check(r);
1101
+ return appOk(reg.id, { executed: id }, { backend: "rest", status: "applied" });
1102
+ }
1103
+ case "open": {
1104
+ const p = typeof args.path === "string" ? args.path : "";
1105
+ if (!p)
1106
+ throw new AppBridgeError("invalid", "With the Local REST API, pass path= (the exact vault path).");
1107
+ const bad = checkVaultPath(p);
1108
+ if (bad)
1109
+ throw new AppBridgeError("invalid", `path ${bad}.`);
1110
+ const qs = args.newtab === true || args.newtab === "true" ? "?newLeaf=true" : "";
1111
+ const r = await this.restRequest(cfg, { method: "POST", path: `/open/${p.split("/").map(encodeURIComponent).join("/")}${qs}` });
1112
+ check(r);
1113
+ return appOk(reg.id, { opened: p }, { backend: "rest", status: "applied" });
1114
+ }
1115
+ default:
1116
+ throw new AppBridgeError("app_offline", offlineReason(null, false));
1117
+ }
1118
+ }
1119
+ }
1120
+ function describeArgs(args) {
1121
+ const out = {};
1122
+ for (const [k, v] of Object.entries(args))
1123
+ if (k !== "dry_run")
1124
+ out[k] = typeof v === "string" && v.length > 400 ? `${v.slice(0, 400)}…` : v;
1125
+ return out;
1126
+ }
1127
+ export function offlineReason(cli, restPossible) {
1128
+ const rest = restPossible ? " or connect the Local REST API plugin in the vault's settings" : "";
1129
+ switch (cli) {
1130
+ case "not_installed":
1131
+ return `This needs the Obsidian app on the vault's computer with its command line interface on (Settings → General → Advanced → Command line interface)${rest}.`;
1132
+ case "not_running":
1133
+ return `Obsidian is not running on the vault's computer. Open Obsidian there, then retry.`;
1134
+ case "cli_disabled":
1135
+ return `The Obsidian command line interface is off. Turn it on in Obsidian: Settings → General → Advanced → Command line interface${rest}.`;
1136
+ case "vault_unknown":
1137
+ return "Obsidian on that computer does not know this folder as a vault yet. Open the folder once in Obsidian (Open folder as vault), then retry.";
1138
+ default:
1139
+ return restPossible
1140
+ ? "This needs the Obsidian Local REST API plugin connected in the vault's settings."
1141
+ : "This needs the running Obsidian app on the vault's computer.";
1142
+ }
1143
+ }