@melaya/runner 1.1.61 → 1.1.63

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,139 @@
1
+ /**
2
+ * Native OS folder dialog, shared by runner:pick-folder (static-context / RAG
3
+ * folders) and runner:vault-pick-folder (Obsidian vaults).
4
+ *
5
+ * Browsers can't reveal absolute paths for security, so the runner — which
6
+ * lives on the user's box — pops the dialog. Fixed argv; the prompt is a
7
+ * constant chosen by the caller, never user or model input.
8
+ *
9
+ * - Windows: the Explorer folder dialog (IFileOpenDialog + FOS_PICKFOLDERS)
10
+ * through PowerShell. WinForms' FolderBrowserDialog is the old tree view
11
+ * on Windows PowerShell (.NET Framework), so it is only the fallback. The
12
+ * dialog is owned by a small topmost window so it opens in front of the
13
+ * browser instead of behind it.
14
+ * - macOS: AppleScript `choose folder` run by osascript itself. Wrapping it
15
+ * in `tell application "System Events"` needs the Automation permission
16
+ * and fails (-1743) or hides the dialog when it is not granted.
17
+ * - Linux: zenity, then kdialog.
18
+ */
19
+ import { execFile } from "child_process";
20
+ // Selected path on stdout, nothing on cancel. Paths are written as UTF-8 so
21
+ // non-ASCII folder names survive the console code page.
22
+ const WINDOWS_PICKER = String.raw `
23
+ $ErrorActionPreference = 'Stop'
24
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
25
+ $src = @"
26
+ using System;
27
+ using System.Drawing;
28
+ using System.Runtime.InteropServices;
29
+ using System.Windows.Forms;
30
+ public static class MelFolderPicker {
31
+ [ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")] class FileOpenDialogCoClass {}
32
+ [ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
33
+ interface IShellItem {
34
+ void BindToHandler(); void GetParent();
35
+ void GetDisplayName(uint sigdn, [MarshalAs(UnmanagedType.LPWStr)] out string name);
36
+ void GetAttributes(); void Compare();
37
+ }
38
+ [ComImport, Guid("D57C7288-D4AD-4768-BE02-9D969532D960"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
39
+ interface IFileOpenDialog {
40
+ [PreserveSig] int Show(IntPtr owner);
41
+ void SetFileTypes(); void SetFileTypeIndex(); void GetFileTypeIndex(); void Advise(); void Unadvise();
42
+ void SetOptions(uint fos); void GetOptions(out uint fos);
43
+ void SetDefaultFolder(); void SetFolder(); void GetFolder(); void GetCurrentSelection();
44
+ void SetFileName(); void GetFileName();
45
+ void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string title);
46
+ void SetOkButtonLabel(); void SetFileNameLabel();
47
+ void GetResult(out IShellItem item);
48
+ }
49
+ public static string Pick(string title) {
50
+ var owner = new Form {
51
+ TopMost = true, ShowInTaskbar = false, FormBorderStyle = FormBorderStyle.None,
52
+ StartPosition = FormStartPosition.CenterScreen, Size = new Size(1, 1), Opacity = 0,
53
+ };
54
+ owner.Show(); owner.Activate();
55
+ try {
56
+ var dlg = (IFileOpenDialog)new FileOpenDialogCoClass();
57
+ uint fos; dlg.GetOptions(out fos);
58
+ dlg.SetOptions(fos | 0x20 | 0x40 | 0x800); // PICKFOLDERS | FORCEFILESYSTEM | PATHMUSTEXIST
59
+ dlg.SetTitle(title);
60
+ if (dlg.Show(owner.Handle) != 0) return ""; // cancelled
61
+ IShellItem item; dlg.GetResult(out item);
62
+ string path; item.GetDisplayName(0x80058000, out path); // SIGDN_FILESYSPATH
63
+ return path ?? "";
64
+ } finally { owner.Close(); }
65
+ }
66
+ }
67
+ "@
68
+ Add-Type -TypeDefinition $src -ReferencedAssemblies System.Windows.Forms, System.Drawing
69
+ try {
70
+ [Console]::Out.Write([MelFolderPicker]::Pick($env:MEL_PICK_TITLE))
71
+ } catch {
72
+ # Very old shells without the COM dialog: the WinForms tree dialog.
73
+ Add-Type -AssemblyName System.Windows.Forms
74
+ $f = New-Object System.Windows.Forms.FolderBrowserDialog
75
+ $f.Description = $env:MEL_PICK_TITLE
76
+ $f.ShowNewFolderButton = $true
77
+ if ($f.ShowDialog() -eq 'OK') { [Console]::Out.Write($f.SelectedPath) }
78
+ }
79
+ `;
80
+ function run(cmd, args, env, exit1IsCancel = false) {
81
+ return new Promise((resolve, reject) => {
82
+ execFile(cmd, args, { maxBuffer: 1024 * 64, encoding: "utf8", windowsHide: false, env: env ?? process.env }, (err, stdout, stderr) => {
83
+ const out = (stdout || "").trim();
84
+ if (err) {
85
+ const errMsg = (stderr || err.message || "").trim();
86
+ // osascript: "User canceled. (-128)"; zenity / kdialog: exit 1, no output.
87
+ if (!out && (/User cancel+ed|\(-128\)/i.test(errMsg) || (exit1IsCancel && err.code === 1)))
88
+ return resolve("");
89
+ return reject(new Error(errMsg || "pick failed"));
90
+ }
91
+ resolve(out);
92
+ });
93
+ });
94
+ }
95
+ export async function pickNativeFolder(prompt) {
96
+ // Quotes and backslashes would break out of the AppleScript string literal.
97
+ const safePrompt = prompt.replace(/["'`$\\]/g, "");
98
+ let picked = "";
99
+ try {
100
+ if (process.platform === "darwin") {
101
+ // `activate` brings osascript's own dialog to the front; the result is a
102
+ // POSIX path ("/Users/me/Vault/").
103
+ picked = await run("osascript", [
104
+ "-e", "activate",
105
+ "-e", `POSIX path of (choose folder with prompt "${safePrompt}")`,
106
+ ]);
107
+ picked = picked.replace(/\/$/, "");
108
+ }
109
+ else if (process.platform === "win32") {
110
+ // The title goes through the environment, never into the script text.
111
+ const encoded = Buffer.from(WINDOWS_PICKER, "utf16le").toString("base64");
112
+ picked = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-STA", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded], { ...process.env, MEL_PICK_TITLE: safePrompt });
113
+ }
114
+ else {
115
+ try {
116
+ picked = await run("zenity", ["--file-selection", "--directory", `--title=${safePrompt}`], undefined, true);
117
+ }
118
+ catch (e) {
119
+ if (!/ENOENT/i.test(String(e?.message)))
120
+ throw e;
121
+ picked = await run("kdialog", ["--getexistingdirectory", process.env.HOME || "/", "--title", safePrompt], undefined, true);
122
+ }
123
+ }
124
+ }
125
+ catch (e) {
126
+ const msg = String(e?.message || "pick failed");
127
+ let hint = "";
128
+ if (process.platform === "linux" && /ENOENT|not found|spawn/i.test(msg)) {
129
+ hint = "Install 'zenity' (apt install zenity) or type the path manually.";
130
+ }
131
+ else if (process.platform === "darwin" && /-1743|not allowed|No user interaction/i.test(msg)) {
132
+ hint = "macOS blocked the dialog: run the runner from a logged-in session (Terminal), or type the path manually.";
133
+ }
134
+ throw new Error(hint ? `${msg} — ${hint}` : msg);
135
+ }
136
+ if (!picked)
137
+ return { cancelled: true };
138
+ return { path: picked };
139
+ }
@@ -9,6 +9,7 @@
9
9
  * - Per-run nonce required in the URL path to prevent injection from
10
10
  * other processes on the same machine
11
11
  */
12
+ import http from "http";
12
13
  import type { Socket } from "socket.io-client";
13
14
  export interface LocalRelay {
14
15
  port: number;
@@ -16,4 +17,10 @@ export interface LocalRelay {
16
17
  close: () => void;
17
18
  }
18
19
  export declare function setActiveProject(project: string): void;
19
- export declare function startLocalRelay(socket: Socket, verbose: boolean, serverUrl?: string): Promise<LocalRelay>;
20
+ export interface LocalRelayRoutes {
21
+ /** POST /vaults/op (Obsidian vault tools, protocol §2). Authenticated by the
22
+ * vault capability in `Authorization: Vault <cap>`, not by the nonce: the
23
+ * handler verifies it (local device vaults) or forwards it to the server. */
24
+ vaultOp?: (req: http.IncomingMessage, res: http.ServerResponse) => Promise<void> | void;
25
+ }
26
+ export declare function startLocalRelay(socket: Socket, verbose: boolean, serverUrl?: string, routes?: LocalRelayRoutes): Promise<LocalRelay>;
@@ -55,7 +55,7 @@ function _flushThrottle(socket, verbose) {
55
55
  _throttleQueue = [];
56
56
  _throttleTimer = null;
57
57
  }
58
- export function startLocalRelay(socket, verbose, serverUrl) {
58
+ export function startLocalRelay(socket, verbose, serverUrl, routes = {}) {
59
59
  const nonce = crypto.randomUUID().replace(/-/g, "");
60
60
  return new Promise((resolve, reject) => {
61
61
  const expectedPath = `/events/relay/${nonce}`;
@@ -147,6 +147,26 @@ export function startLocalRelay(socket, verbose, serverUrl) {
147
147
  });
148
148
  return;
149
149
  }
150
+ // ── Obsidian vault ops (docs/plans/obsidian-vault-protocol.md §2) ──
151
+ if (req.method === "POST" && req.url && /^\/vaults\/op\/?(\?.*)?$/.test(req.url)) {
152
+ if (!routes.vaultOp) {
153
+ res.writeHead(404);
154
+ res.end("Not found");
155
+ return;
156
+ }
157
+ Promise.resolve(routes.vaultOp(req, res)).catch(() => {
158
+ if (res.headersSent) {
159
+ try {
160
+ res.end();
161
+ }
162
+ catch { /* closed */ }
163
+ return;
164
+ }
165
+ res.writeHead(500, { "Content-Type": "application/json" });
166
+ res.end('{"ok":false,"error":"relay_failed"}');
167
+ });
168
+ return;
169
+ }
150
170
  if (req.method !== "POST" || req.url !== expectedPath) {
151
171
  res.writeHead(404);
152
172
  res.end("Not found");