beatrina 0.8.7 → 0.9.43
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.
- package/NOTICES +1 -1
- package/README.md +6 -0
- package/beatrina_V0.9.43.html +1885 -0
- package/beatrina_V0.9.43.html.inputs.json +1 -0
- package/bin/beatrina.mjs +152 -24
- package/bin/browser.mjs +31 -0
- package/bin/cli.mjs +64 -5
- package/bin/doctor-rows.mjs +31 -0
- package/bin/failsafe.mjs +8 -3
- package/bin/finder.mjs +74 -0
- package/bin/lsquery.swift +55 -0
- package/bin/pages.mjs +81 -0
- package/bin/python-setup.mjs +207 -0
- package/bin/runtime-dirs.mjs +27 -0
- package/bin/sessions.mjs +30 -10
- package/bin/shortcut.mjs +250 -42
- package/bin/update-check.mjs +2 -2
- package/build-info.json +1 -1
- package/engines/js/worker.mjs +6 -3
- package/engines/python/adapter.py +14 -13
- package/engines/python/analyze.py +4 -2
- package/engines/python/bootstrap.py +32 -31
- package/engines/python/debugger.py +10 -6
- package/engines/python/engine.json +1 -1
- package/engines/python/handoff.py +4 -2
- package/engines/python/worker.py +39 -10
- package/engines/r/engine.json +1 -1
- package/engines/r/handoff.R +4 -3
- package/failsafe/ai-policy.R +16 -16
- package/failsafe/ai-store.R +6 -6
- package/failsafe/cite.R +11 -11
- package/failsafe/journal.R +11 -11
- package/failsafe/plugins.R +27 -27
- package/failsafe/serve.R +241 -241
- package/host/ai-authority.mjs +55 -0
- package/host/ai-policy.mjs +48 -26
- package/host/bundle.mjs +194 -0
- package/host/deployment.mjs +25 -18
- package/host/engine-js.mjs +12 -17
- package/host/engine-pool.mjs +48 -6
- package/host/engine-python.mjs +80 -31
- package/host/engine-r.mjs +20 -21
- package/host/engine-stdio.mjs +35 -10
- package/host/env-names.mjs +148 -0
- package/host/gateway-token.mjs +51 -0
- package/host/journal-store.mjs +10 -9
- package/host/main.mjs +193 -77
- package/host/payload.mjs +131 -0
- package/host/planes/README.md +1 -1
- package/host/planes/ai-store.mjs +6 -5
- package/host/planes/ai.mjs +49 -29
- package/host/planes/analyze.mjs +6 -5
- package/host/planes/bundle.mjs +344 -0
- package/host/planes/choose.mjs +404 -0
- package/host/planes/cite.mjs +18 -17
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +43 -20
- package/host/planes/latex.mjs +15 -5
- package/host/planes/mcp.mjs +80 -91
- package/host/planes/pair.mjs +24 -24
- package/host/planes/pipe-term.mjs +2 -2
- package/host/planes/plugins.mjs +1 -1
- package/host/planes/recent-documents.mjs +170 -0
- package/host/planes/sessions.mjs +226 -82
- package/host/planes/settings.mjs +4 -4
- package/host/planes/terminal.mjs +13 -11
- package/host/planes/test-file.mjs +2 -2
- package/host/planes/update.mjs +6 -13
- package/host/plugin-store.mjs +35 -52
- package/host/recent-documents.mjs +124 -0
- package/host/runtime-dir.mjs +47 -0
- package/host/server.mjs +75 -18
- package/host/session-keep.mjs +1 -1
- package/host/settings.mjs +85 -37
- package/host/update-record.mjs +3 -2
- package/host/user-dirs.mjs +60 -18
- package/host/which.mjs +39 -0
- package/host/windows-runtime.mjs +6 -3
- package/host/worker-plane.mjs +34 -6
- package/host/ws.mjs +9 -2
- package/host/zip.mjs +237 -0
- package/kernel/analyze.R +1 -1
- package/{check → kernel/check}/acceptance.mjs +1 -1
- package/kernel/check/knit-file.mjs +19639 -0
- package/{check → kernel/check}/session.mjs +49 -8
- package/kernel/deployment.R +20 -20
- package/kernel/examples/NOTICE.md +1 -1
- package/kernel/fileio.R +6 -6
- package/kernel/index.html +2 -2
- package/kernel/job-run.R +83 -22
- package/kernel/jobs.R +28 -12
- package/kernel/kernel-version +1 -1
- package/kernel/kernel.R +18 -18
- package/kernel/knitr-run.R +50 -9
- package/kernel/latex.R +128 -30
- package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
- package/kernel/notebook-page.R +7 -7
- package/kernel/project.R +10 -10
- package/kernel/settings.R +105 -56
- package/kernel/sniff.R +4 -4
- package/kernel/typst.R +121 -0
- package/kernel/worker.R +522 -119
- package/kernel/workspace-keep.R +3 -3
- package/lib/agent-authoring-contract.js +28 -19
- package/lib/cell-kinds.js +3 -3
- package/lib/engine-labels.js +4 -4
- package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
- package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
- package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
- package/package.json +4 -3
- package/carmar_V0.8.7.html +0 -1522
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
// choose.mjs — the operating system's own Open / Save / Folder panel, opened
|
|
2
|
+
// by the HOST and answered while R is busy.
|
|
3
|
+
//
|
|
4
|
+
// One op, `choose`: `{mode: "file"|"files"|"dir"|"save", prompt?, start?,
|
|
5
|
+
// defaultName?, accept?: [".qmd", …]}`. The reply is exactly one of `{path}`
|
|
6
|
+
// / `{paths}` (absolute, canonical), `{cancelled: true}`, `{unsupported: true,
|
|
7
|
+
// reason}` (no tool, no display, the panel switched off) or `{error, busy?}`
|
|
8
|
+
// (a second panel while one is open; a class or agent refusal; a tool that
|
|
9
|
+
// failed, with its first stderr line; an answer that cannot be used — a file
|
|
10
|
+
// where a folder was asked).
|
|
11
|
+
//
|
|
12
|
+
// WHY THE HOST, AND WHY AGAIN. The panel used to be opened by R
|
|
13
|
+
// (`spike/chooser.R`, deleted 2026-09-09 by docs/document-model-plan.md D4):
|
|
14
|
+
// it queued behind a running cell, it talked to System Events — an Apple
|
|
15
|
+
// event, which is what put the Automation prompt in front of every Chrome
|
|
16
|
+
// user — and it read "cannot show a panel" and "the person pressed Cancel"
|
|
17
|
+
// as the same silent non-zero exit. This plane answers from the supervisor
|
|
18
|
+
// (a forty-second fit does not delay File ▸ Open), raises the panel with a
|
|
19
|
+
// BARE `activate` — osascript's own process, no `tell application …`, no
|
|
20
|
+
// Apple event, no prompt, no plist key — and classifies cancel PER TOOL.
|
|
21
|
+
//
|
|
22
|
+
// The pure half (`chooseCommand`, `readChooserResult`) builds argument
|
|
23
|
+
// vectors and reads results without a screen, so every adapter is tested on
|
|
24
|
+
// every platform; only `createPlane` spawns. Never a shell string: the prompt
|
|
25
|
+
// and the start folder reach AppleScript and PowerShell as SOURCE, escaped
|
|
26
|
+
// as the retired chooser.R escaped them, and zenity/kdialog as plain argv.
|
|
27
|
+
//
|
|
28
|
+
// NO TIMEOUT ON A PANEL. A person may read a folder for ten minutes; the
|
|
29
|
+
// child lives until they answer, the page's socket closes (`onClose` kills
|
|
30
|
+
// it — a panel nobody can answer is worse than one that vanishes), or the
|
|
31
|
+
// kernel stops (`shutdown`). `busy()` holds the idle clock open meanwhile.
|
|
32
|
+
// One panel at a time, by a module lock: a second ask is `{error, busy}`
|
|
33
|
+
// and the first still answers.
|
|
34
|
+
//
|
|
35
|
+
// Page-only in both senses (a panel is a UI gesture on THIS person's screen)
|
|
36
|
+
// and refused to agents. `BEATRINA_NO_NATIVE_DIALOG` (any value) switches the
|
|
37
|
+
// panel off: `choose` is then not advertised in `ready.commands` — that is
|
|
38
|
+
// the page's whole face of the flag — and answers `{unsupported}` if asked
|
|
39
|
+
// anyway. Binary overrides for tests and odd machines: BEATRINA_OSASCRIPT,
|
|
40
|
+
// BEATRINA_POWERSHELL, BEATRINA_ZENITY, BEATRINA_KDIALOG. A named binary that
|
|
41
|
+
// is missing is an `error` sentence the page shows, never "unsupported": a
|
|
42
|
+
// misconfiguration must be said, not silently routed around.
|
|
43
|
+
|
|
44
|
+
import fs from "node:fs";
|
|
45
|
+
import path from "node:path";
|
|
46
|
+
import { spawn } from "node:child_process";
|
|
47
|
+
import { envSetName, readEnv } from "../env-names.mjs";
|
|
48
|
+
import { whichSync } from "../which.mjs";
|
|
49
|
+
import { PAGE_ONLY_CLASSES } from "../server.mjs";
|
|
50
|
+
import { canonicalPath, fsAbs, makeRoot } from "./files.mjs";
|
|
51
|
+
|
|
52
|
+
export const CHOOSE_MODES = Object.freeze(["file", "files", "dir", "save"]);
|
|
53
|
+
const OPS = ["choose"];
|
|
54
|
+
/** The env suffix that overrides each tool's binary (BEATRINA_<suffix>). */
|
|
55
|
+
const TOOL_ENV = Object.freeze({ osascript: "OSASCRIPT", powershell: "POWERSHELL", zenity: "ZENITY", kdialog: "KDIALOG" });
|
|
56
|
+
/** Output kept per stream: a path list is small; a runaway tool is not. */
|
|
57
|
+
const OUTPUT_CAP = 64 * 1024;
|
|
58
|
+
const PROMPT_MAX = 120;
|
|
59
|
+
const NAME_MAX = 255;
|
|
60
|
+
|
|
61
|
+
// eslint-disable-next-line no-control-regex
|
|
62
|
+
const oneLine = (x, max) => String(x ?? "").replace(/[\x00-\x1f\x7f]/g, " ").replace(/\s+/g, " ").trim().slice(0, max);
|
|
63
|
+
|
|
64
|
+
/** An AppleScript double-quoted literal: the two characters that end or escape one, and no line breaks. */
|
|
65
|
+
export function appleScriptString(x) {
|
|
66
|
+
return `"${oneLine(x, 4096).replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A PowerShell single-quoted literal: only the quote itself is special, doubled. */
|
|
70
|
+
export function powerShellString(x) {
|
|
71
|
+
return `'${oneLine(x, 4096).replace(/'/g, "''")}'`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The `-EncodedCommand` form PowerShell reads: UTF-16LE, base64. */
|
|
75
|
+
export function encodePowerShell(script) {
|
|
76
|
+
return Buffer.from(script, "utf16le").toString("base64");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read one request off the wire into the five fields the adapters take, or
|
|
81
|
+
* say what is wrong with it. Nothing else travels: no path is executed, no
|
|
82
|
+
* name reaches a shell.
|
|
83
|
+
*
|
|
84
|
+
* @returns {{mode:string, prompt:string, start:string, defaultName:string, accept:string[]}|{error:string}}
|
|
85
|
+
*/
|
|
86
|
+
export function readChooseRequest(cmd) {
|
|
87
|
+
const mode = typeof cmd.mode === "string" ? cmd.mode : "";
|
|
88
|
+
if (!CHOOSE_MODES.includes(mode)) return { error: `\`mode\` must be one of ${CHOOSE_MODES.join(", ")}.` };
|
|
89
|
+
const prompt = oneLine(cmd.prompt, PROMPT_MAX) || { file: "Open", files: "Open", dir: "Choose Folder", save: "Save" }[mode];
|
|
90
|
+
const start = typeof cmd.start === "string" ? cmd.start.trim().slice(0, 4096) : "";
|
|
91
|
+
const defaultName = oneLine(cmd.defaultName, NAME_MAX);
|
|
92
|
+
if (/[\\/]/.test(defaultName)) return { error: "`defaultName` is a file name, not a path." };
|
|
93
|
+
const accept = (Array.isArray(cmd.accept) ? cmd.accept : [])
|
|
94
|
+
.filter((e) => typeof e === "string" && /^\.[A-Za-z0-9]{1,16}$/.test(e))
|
|
95
|
+
.slice(0, 32);
|
|
96
|
+
return { mode, prompt, start, defaultName, accept };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Which binary runs `tool`: an override that exists, else PATH, else "" — or a sentence about an override that does not exist. */
|
|
100
|
+
function binaryFor(tool, env, which) {
|
|
101
|
+
const override = readEnv(env, TOOL_ENV[tool]).trim();
|
|
102
|
+
if (override) {
|
|
103
|
+
let ok = false;
|
|
104
|
+
try { fs.accessSync(override, fs.constants.X_OK); ok = fs.statSync(override).isFile(); } catch { ok = false; }
|
|
105
|
+
if (ok) return { path: override };
|
|
106
|
+
return { error: `${envSetName(env, TOOL_ENV[tool])} names ${override}, which is not an executable file; unset it or point it at ${tool}.` };
|
|
107
|
+
}
|
|
108
|
+
return { path: which(tool) || "" };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `default location` / `InitialDirectory` / `--filename` only for a folder that exists. */
|
|
112
|
+
const defaultDirExists = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The argument vector that opens this platform's panel for `req`.
|
|
116
|
+
*
|
|
117
|
+
* @param {{mode:string, prompt?:string, start?:string, defaultName?:string, accept?:string[]}} req
|
|
118
|
+
* `start` is already absolute (the plane resolves it against the worker's cwd)
|
|
119
|
+
* @param {{platform?:string, env?:object, which?:(name:string)=>string, dirExists?:(p:string)=>boolean}} [deps]
|
|
120
|
+
* @returns {{tool:string, command:string, args:string[], script?:string}
|
|
121
|
+
* | {unsupported:true, reason:string} | {error:string}}
|
|
122
|
+
*/
|
|
123
|
+
export function chooseCommand(req, deps = {}) {
|
|
124
|
+
const platform = deps.platform || process.platform;
|
|
125
|
+
const env = deps.env || process.env;
|
|
126
|
+
const which = deps.which || ((name) => whichSync(name, env.source || env));
|
|
127
|
+
const dirExists = deps.dirExists || defaultDirExists;
|
|
128
|
+
const mode = req.mode;
|
|
129
|
+
const prompt = oneLine(req.prompt, PROMPT_MAX) || "Choose";
|
|
130
|
+
const start = typeof req.start === "string" && req.start && dirExists(req.start) ? req.start : "";
|
|
131
|
+
const name = mode === "save" ? oneLine(req.defaultName, NAME_MAX) : "";
|
|
132
|
+
const accept = Array.isArray(req.accept) ? req.accept.filter((e) => /^\.[A-Za-z0-9]{1,16}$/.test(e)) : [];
|
|
133
|
+
const exts = accept.map((e) => e.slice(1));
|
|
134
|
+
|
|
135
|
+
if (platform === "darwin") {
|
|
136
|
+
const bin = binaryFor("osascript", env, which);
|
|
137
|
+
if (bin.error) return { error: bin.error };
|
|
138
|
+
if (!bin.path) return { unsupported: true, reason: "osascript was not found on PATH, so macOS cannot show its file panel." };
|
|
139
|
+
const verb = mode === "dir" ? "choose folder" : mode === "save" ? "choose file name" : "choose file";
|
|
140
|
+
const parts = [`${verb} with prompt ${appleScriptString(prompt)}`];
|
|
141
|
+
// `of type` only for a door that names types: File ▸ Open (kind "all") sends none.
|
|
142
|
+
if ((mode === "file" || mode === "files") && exts.length) parts.push(`of type {${exts.map(appleScriptString).join(", ")}}`);
|
|
143
|
+
if (mode === "save" && name) parts.push(`default name ${appleScriptString(name)}`);
|
|
144
|
+
// Parentheses are syntax: `default location POSIX file` is read as adjacent
|
|
145
|
+
// parameter/class names; coercing the POSIX path first gives the alias.
|
|
146
|
+
if (start) parts.push(`default location (POSIX file ${appleScriptString(start)})`);
|
|
147
|
+
if (mode === "files") parts.push("with multiple selections allowed");
|
|
148
|
+
// A bare `activate` raises osascript's own process — no `tell application`,
|
|
149
|
+
// no Apple event, no Automation prompt. It must stay bare.
|
|
150
|
+
const script = mode === "files"
|
|
151
|
+
? ["activate",
|
|
152
|
+
`set theItems to ${parts.join(" ")}`,
|
|
153
|
+
"set out to \"\"",
|
|
154
|
+
"repeat with anItem in theItems",
|
|
155
|
+
" set out to out & POSIX path of anItem & linefeed",
|
|
156
|
+
"end repeat",
|
|
157
|
+
"out"].join("\n")
|
|
158
|
+
: ["activate", `POSIX path of (${parts.join(" ")})`].join("\n");
|
|
159
|
+
return { tool: "osascript", command: bin.path, args: ["-e", script], script };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (platform === "win32") {
|
|
163
|
+
const bin = binaryFor("powershell", env, which);
|
|
164
|
+
if (bin.error) return { error: bin.error };
|
|
165
|
+
const found = bin.path || which("powershell.exe");
|
|
166
|
+
if (!found) return { unsupported: true, reason: "PowerShell was not found on PATH, so Windows cannot show its file panel." };
|
|
167
|
+
const filter = exts.length
|
|
168
|
+
? `Matching files (${exts.map((e) => `*.${e}`).join(";")})|${exts.map((e) => `*.${e}`).join(";")}|All files (*.*)|*.*`
|
|
169
|
+
: "All files (*.*)|*.*";
|
|
170
|
+
const lines = [
|
|
171
|
+
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
|
|
172
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
173
|
+
// An owner form that is TopMost is what puts the panel over the browser
|
|
174
|
+
// window; without one it opens behind it and the page looks hung.
|
|
175
|
+
"$f = New-Object System.Windows.Forms.Form; $f.TopMost = $true",
|
|
176
|
+
];
|
|
177
|
+
if (mode === "dir") {
|
|
178
|
+
lines.push("$d = New-Object System.Windows.Forms.FolderBrowserDialog",
|
|
179
|
+
`$d.Description = ${powerShellString(prompt)}`);
|
|
180
|
+
if (start) lines.push(`$d.SelectedPath = ${powerShellString(start)}`);
|
|
181
|
+
lines.push("if ($d.ShowDialog($f) -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) }");
|
|
182
|
+
} else if (mode === "save") {
|
|
183
|
+
lines.push("$d = New-Object System.Windows.Forms.SaveFileDialog",
|
|
184
|
+
`$d.Title = ${powerShellString(prompt)}`, `$d.Filter = ${powerShellString(filter)}`,
|
|
185
|
+
"$d.AddExtension = $true", "$d.OverwritePrompt = $true");
|
|
186
|
+
if (exts.length) lines.push(`$d.DefaultExt = ${powerShellString(exts[0])}`);
|
|
187
|
+
if (start) lines.push(`$d.InitialDirectory = ${powerShellString(start)}`);
|
|
188
|
+
if (name) lines.push(`$d.FileName = ${powerShellString(name)}`);
|
|
189
|
+
lines.push("if ($d.ShowDialog($f) -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.FileName) }");
|
|
190
|
+
} else {
|
|
191
|
+
lines.push("$d = New-Object System.Windows.Forms.OpenFileDialog",
|
|
192
|
+
`$d.Title = ${powerShellString(prompt)}`, `$d.Filter = ${powerShellString(filter)}`,
|
|
193
|
+
`$d.Multiselect = ${mode === "files" ? "$true" : "$false"}`);
|
|
194
|
+
if (start) lines.push(`$d.InitialDirectory = ${powerShellString(start)}`);
|
|
195
|
+
lines.push("if ($d.ShowDialog($f) -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write(($d.FileNames -join \"`n\")) }");
|
|
196
|
+
}
|
|
197
|
+
const script = lines.join("\n");
|
|
198
|
+
return { tool: "powershell", command: found,
|
|
199
|
+
args: ["-NoProfile", "-NonInteractive", "-STA", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encodePowerShell(script)], script };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Linux and everything else: a desktop chooser, if there is a desktop.
|
|
203
|
+
const display = readEnvPlain(env, "DISPLAY") || readEnvPlain(env, "WAYLAND_DISPLAY");
|
|
204
|
+
if (!display) return { unsupported: true, reason: "No display (DISPLAY and WAYLAND_DISPLAY are unset), so no file panel can be shown." };
|
|
205
|
+
const zen = binaryFor("zenity", env, which);
|
|
206
|
+
if (zen.error) return { error: zen.error };
|
|
207
|
+
if (zen.path) {
|
|
208
|
+
const args = ["--file-selection", `--title=${prompt}`];
|
|
209
|
+
if (mode === "dir") args.push("--directory");
|
|
210
|
+
if (mode === "save") args.push("--save", "--confirm-overwrite");
|
|
211
|
+
if (mode === "files") args.push("--multiple", "--separator=\n");
|
|
212
|
+
if (start) args.push(`--filename=${start.replace(/\/+$/, "")}/${mode === "save" ? name : ""}`);
|
|
213
|
+
else if (mode === "save" && name) args.push(`--filename=${name}`);
|
|
214
|
+
if ((mode === "file" || mode === "files") && exts.length) {
|
|
215
|
+
args.push(`--file-filter=Matching files | ${exts.map((e) => `*.${e}`).join(" ")}`, "--file-filter=All files | *");
|
|
216
|
+
}
|
|
217
|
+
return { tool: "zenity", command: zen.path, args };
|
|
218
|
+
}
|
|
219
|
+
const kd = binaryFor("kdialog", env, which);
|
|
220
|
+
if (kd.error) return { error: kd.error };
|
|
221
|
+
if (kd.path) {
|
|
222
|
+
const verb = mode === "dir" ? "--getexistingdirectory" : mode === "save" ? "--getsavefilename" : "--getopenfilename";
|
|
223
|
+
const at = start ? (mode === "save" && name ? `${start.replace(/\/+$/, "")}/${name}` : start) : (mode === "save" && name ? name : ".");
|
|
224
|
+
const args = ["--title", prompt];
|
|
225
|
+
if (mode === "files") args.push("--multiple", "--separate-output");
|
|
226
|
+
args.push(verb, at);
|
|
227
|
+
if ((mode === "file" || mode === "files") && exts.length) args.push(`${exts.map((e) => `*.${e}`).join(" ")}|Matching files\n*|All files`);
|
|
228
|
+
return { tool: "kdialog", command: kd.path, args };
|
|
229
|
+
}
|
|
230
|
+
return { unsupported: true, reason: "Neither zenity nor kdialog was found on PATH, so no file panel can be shown." };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A non-product variable, read from a plain object or the host's env reader. */
|
|
234
|
+
function readEnvPlain(env, name) {
|
|
235
|
+
if (typeof env === "function") return String(env(name, "") || "");
|
|
236
|
+
return env && env[name] != null ? String(env[name]) : "";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* What the tool said, classified — per tool, because "non-zero and silent"
|
|
241
|
+
* means Cancel for zenity and kdialog and "could not show a panel" for
|
|
242
|
+
* osascript, which always names a real cancel (`User canceled. (-128)`).
|
|
243
|
+
*
|
|
244
|
+
* @param {string} tool osascript | powershell | zenity | kdialog
|
|
245
|
+
* @param {string} mode
|
|
246
|
+
* @param {{status:number|null, signal?:string|null, stdout?:string, stderr?:string}} out
|
|
247
|
+
* @returns {{path:string}|{paths:string[]}|{cancelled:true}|{error:string}}
|
|
248
|
+
*/
|
|
249
|
+
export function readChooserResult(tool, mode, { status, signal = null, stdout = "", stderr = "" }) {
|
|
250
|
+
const lines = String(stdout || "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
251
|
+
const said = String(stderr || "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
252
|
+
if (status == null) return { error: `The file panel was closed before it answered${signal ? ` (${signal})` : ""}.` };
|
|
253
|
+
const text = `${said.join(" ")} ${lines.join(" ")}`;
|
|
254
|
+
if (status !== 0) {
|
|
255
|
+
if (tool === "osascript" && /user cancell?ed|\(-128\)/i.test(text)) return { cancelled: true };
|
|
256
|
+
if ((tool === "zenity" || tool === "kdialog") && status === 1 && !lines.length) return { cancelled: true };
|
|
257
|
+
return { error: `The file panel failed: ${said[0] || lines[0] || `exit status ${status}`}` };
|
|
258
|
+
}
|
|
259
|
+
if (!lines.length) {
|
|
260
|
+
// A PowerShell dialog writes nothing on Cancel; the others exit non-zero.
|
|
261
|
+
if (tool === "powershell") return { cancelled: true };
|
|
262
|
+
return { error: "The file panel returned nothing readable." };
|
|
263
|
+
}
|
|
264
|
+
if (mode === "files") return { paths: lines };
|
|
265
|
+
// A GTK warning can share zenity's stream; the path is the LAST line.
|
|
266
|
+
return { path: lines[lines.length - 1] };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Check an answer before the page sees it: absolute, existing as the kind
|
|
271
|
+
* asked for (the folder for a save), canonical, and inside BEATRINA_ROOT when
|
|
272
|
+
* one is set — refused here with the files plane's own sentence, one step
|
|
273
|
+
* before `readfile`/`writefile` would refuse the same path.
|
|
274
|
+
*
|
|
275
|
+
* @returns {{path:string}|{paths:string[]}|{error:string}}
|
|
276
|
+
*/
|
|
277
|
+
export function validateChosen(mode, answer, { root }) {
|
|
278
|
+
const one = (raw) => {
|
|
279
|
+
const p = String(raw).replace(/[\\/]+$/, "") || String(raw);
|
|
280
|
+
if (!path.isAbsolute(p)) return { error: `The file panel answered with a relative path (${p}).` };
|
|
281
|
+
const full = canonicalPath(p);
|
|
282
|
+
let st = null;
|
|
283
|
+
try { st = fs.statSync(full); } catch { st = null; }
|
|
284
|
+
if (mode === "dir") {
|
|
285
|
+
if (!st || !st.isDirectory()) return { error: `${full} is not a folder.` };
|
|
286
|
+
} else if (mode === "save") {
|
|
287
|
+
let parent = null;
|
|
288
|
+
try { parent = fs.statSync(path.dirname(full)); } catch { parent = null; }
|
|
289
|
+
if (!parent || !parent.isDirectory()) return { error: `The folder ${path.dirname(full)} does not exist.` };
|
|
290
|
+
if (st && st.isDirectory()) return { error: `${full} is a folder, not a file name.` };
|
|
291
|
+
} else if (!st || !st.isFile()) {
|
|
292
|
+
return { error: `${full} is not a file.` };
|
|
293
|
+
}
|
|
294
|
+
if (!root.within(full)) return { error: root.outsideMsg() };
|
|
295
|
+
return { path: full };
|
|
296
|
+
};
|
|
297
|
+
if (mode === "files") {
|
|
298
|
+
const paths = [];
|
|
299
|
+
for (const raw of answer.paths || []) { // every path is checked; the first bad one is the answer
|
|
300
|
+
const r = one(raw);
|
|
301
|
+
if (r.error) return r;
|
|
302
|
+
paths.push(r.path);
|
|
303
|
+
}
|
|
304
|
+
if (!paths.length) return { error: "The file panel returned nothing readable." };
|
|
305
|
+
return { paths };
|
|
306
|
+
}
|
|
307
|
+
return one(answer.path);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function createPlane({ env, audit }) {
|
|
311
|
+
const flagged = env("BEATRINA_NO_NATIVE_DIALOG") !== "";
|
|
312
|
+
const offReason = `The OS file panel is switched off (${envSetName(env.source || process.env, "NO_NATIVE_DIALOG")} is set).`;
|
|
313
|
+
// Decided ONCE, at start: whether this machine can show a panel at all.
|
|
314
|
+
// `{error}` (an override naming a missing binary) still counts as available
|
|
315
|
+
// so the page asks and the misconfiguration sentence reaches the person.
|
|
316
|
+
const probe = flagged ? { unsupported: true, reason: offReason } : chooseCommand({ mode: "file", prompt: "Open" }, { env });
|
|
317
|
+
const available = probe.unsupported !== true;
|
|
318
|
+
const root = makeRoot(env("BEATRINA_ROOT"));
|
|
319
|
+
/** The one open panel: {child, rec, id, mode, tool, killed}. */
|
|
320
|
+
let open = null;
|
|
321
|
+
const alive = (child) => child && child.exitCode === null && child.signalCode === null;
|
|
322
|
+
const kill = (why) => {
|
|
323
|
+
if (!open || !alive(open.child)) return;
|
|
324
|
+
open.killed = why;
|
|
325
|
+
try { open.child.kill("SIGKILL"); } catch { /* already gone */ }
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
name: "choose",
|
|
330
|
+
ops: OPS,
|
|
331
|
+
agentRefused: OPS,
|
|
332
|
+
agentReason: () => "Agents cannot open a file panel on this person's screen; ask the page to open or save the file.",
|
|
333
|
+
commands: available ? OPS : [],
|
|
334
|
+
capabilities: available ? ["native-dialog-v1"] : [],
|
|
335
|
+
|
|
336
|
+
handle(cmd, rec, ctx) {
|
|
337
|
+
if (rec.role !== "page" || !ctx.scalarChr(cmd.id)) return undefined;
|
|
338
|
+
const reply = (fields) => {
|
|
339
|
+
try { rec.ws.send(ctx.enc({ type: "choose", id: cmd.id, ...fields })); } catch { /* the page has gone; nothing to tell */ }
|
|
340
|
+
};
|
|
341
|
+
if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
|
|
342
|
+
audit("choose-refused", { reason: "class", class: rec.class || "unknown" });
|
|
343
|
+
return reply({ error: "Only the local notebook page may open the file panel." });
|
|
344
|
+
}
|
|
345
|
+
const req = readChooseRequest(cmd);
|
|
346
|
+
if (req.error) return reply({ error: req.error });
|
|
347
|
+
if (flagged) return reply({ unsupported: true, reason: offReason });
|
|
348
|
+
if (open && alive(open.child)) {
|
|
349
|
+
audit("choose-refused", { reason: "busy" });
|
|
350
|
+
return reply({ error: "A file panel is already open — answer it first.", busy: true });
|
|
351
|
+
}
|
|
352
|
+
// A relative start is the worker's business: resolved against the cwd it
|
|
353
|
+
// last reported, exactly as the files plane resolves a relative path.
|
|
354
|
+
const start = req.start ? fsAbs(req.start, ctx.plane.workerWd) : "";
|
|
355
|
+
const spec = chooseCommand({ ...req, start }, { env });
|
|
356
|
+
if (spec.unsupported) return reply({ unsupported: true, reason: spec.reason });
|
|
357
|
+
if (spec.error) return reply({ error: spec.error });
|
|
358
|
+
|
|
359
|
+
let child;
|
|
360
|
+
try {
|
|
361
|
+
child = spawn(spec.command, spec.args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
362
|
+
} catch (e) {
|
|
363
|
+
return reply({ error: `The file panel could not start: ${e.message}` });
|
|
364
|
+
}
|
|
365
|
+
const mine = { child, rec, id: cmd.id, mode: req.mode, tool: spec.tool, killed: "" };
|
|
366
|
+
open = mine;
|
|
367
|
+
let stdout = "";
|
|
368
|
+
let stderr = "";
|
|
369
|
+
child.stdout.setEncoding("utf8");
|
|
370
|
+
child.stderr.setEncoding("utf8");
|
|
371
|
+
child.stdout.on("data", (d) => { if (stdout.length < OUTPUT_CAP) stdout += d; });
|
|
372
|
+
child.stderr.on("data", (d) => { if (stderr.length < OUTPUT_CAP) stderr += d; });
|
|
373
|
+
let done = false;
|
|
374
|
+
const finish = (answer) => {
|
|
375
|
+
if (done) return;
|
|
376
|
+
done = true;
|
|
377
|
+
if (open === mine) open = null;
|
|
378
|
+
const outcome = answer.cancelled ? "cancelled" : answer.error ? "error" : "chosen";
|
|
379
|
+
audit("choose-answered", { mode: req.mode, tool: spec.tool, outcome, ...(mine.killed ? { killed: mine.killed } : {}) });
|
|
380
|
+
// A page that left while the panel was up has nobody to answer.
|
|
381
|
+
if (ctx.sockets.includes(rec)) reply(answer);
|
|
382
|
+
};
|
|
383
|
+
child.on("error", (e) => finish({ error: `The file panel could not start: ${e.message}` }));
|
|
384
|
+
child.on("close", (status, signal) => {
|
|
385
|
+
if (mine.killed) return finish({ error: `The file panel was closed (${mine.killed}).` });
|
|
386
|
+
const read = readChooserResult(spec.tool, req.mode, { status, signal, stdout, stderr });
|
|
387
|
+
if (read.cancelled || read.error) return finish(read);
|
|
388
|
+
finish(validateChosen(req.mode, read, { root }));
|
|
389
|
+
});
|
|
390
|
+
return undefined;
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
/** A panel that is up holds the idle clock open: a person is looking at it. */
|
|
394
|
+
busy: () => open != null && alive(open.child),
|
|
395
|
+
|
|
396
|
+
/** The page that asked has gone: a panel nobody can answer is worse than one that vanishes. */
|
|
397
|
+
onClose(rec) { if (open && open.rec === rec) kill("page closed"); },
|
|
398
|
+
|
|
399
|
+
/** The kernel is going; no panel may outlive it. */
|
|
400
|
+
shutdown() { kill("kernel stopping"); },
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export default { createPlane };
|
package/host/planes/cite.mjs
CHANGED
|
@@ -33,11 +33,13 @@ import os from "node:os";
|
|
|
33
33
|
import path from "node:path";
|
|
34
34
|
import crypto from "node:crypto";
|
|
35
35
|
import { spawn } from "node:child_process";
|
|
36
|
-
import { appId } from "../user-dirs.mjs";
|
|
36
|
+
import { appId, rUserDir } from "../user-dirs.mjs";
|
|
37
|
+
import { PAGE_ONLY_CLASSES } from "../server.mjs";
|
|
37
38
|
import {
|
|
38
39
|
CSL_NAME_RE, cslInfo, cslIndexUrl, pluginFetch, pluginIdOk, pluginInstall,
|
|
39
|
-
pluginPolicyOnce, pluginPolicyReason, pluginRemove, pluginRows,
|
|
40
|
+
pluginPolicyOnce, pluginPolicyReason, pluginRemove, pluginRows,
|
|
40
41
|
} from "../plugin-store.mjs";
|
|
42
|
+
import { whichSync } from "../which.mjs";
|
|
41
43
|
|
|
42
44
|
export { cslInfo, cslLooksLikeStyle } from "../plugin-store.mjs";
|
|
43
45
|
|
|
@@ -53,7 +55,6 @@ const CSL_MAX_INDEX_BYTES = 8000000;
|
|
|
53
55
|
const CSL_INDEX_MAX_AGE_DAYS = 7;
|
|
54
56
|
const CSL_SEARCH_LIMIT = 40;
|
|
55
57
|
|
|
56
|
-
const PAGE_ONLY_CLASSES = ["served", "file", "local"];
|
|
57
58
|
const OPS = ["cite", "cite_styles", "csl_place", "csl_search", "csl_import", "csl_remove"];
|
|
58
59
|
/** Every op but `cite_styles`: a process, a file, or the network. */
|
|
59
60
|
const AGENT_REFUSED = ["cite", "csl_place", "csl_search", "csl_import", "csl_remove"];
|
|
@@ -207,7 +208,6 @@ export function cslRemove(here, policy, env, file) {
|
|
|
207
208
|
|
|
208
209
|
/* ── pandoc ───────────────────────────────────────────────────────────────── */
|
|
209
210
|
|
|
210
|
-
const isExecutable = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch { return false; } };
|
|
211
211
|
|
|
212
212
|
/**
|
|
213
213
|
* Locate pandoc, the ladder spike/cite.R climbs: an explicit override, what
|
|
@@ -219,23 +219,24 @@ const isExecutable = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return
|
|
|
219
219
|
* starting an R process to find a binary — the host would spawn a worker to
|
|
220
220
|
* answer a question about the file system — so the bundle list below carries
|
|
221
221
|
* the same answers rmarkdown reaches for. On this machine both resolve to the
|
|
222
|
-
* same binary; where they could differ,
|
|
222
|
+
* same binary; where they could differ, BEATRINA_PANDOC settles it.
|
|
223
223
|
*
|
|
224
224
|
* @returns {string} the path, or "".
|
|
225
225
|
*/
|
|
226
226
|
export function findPandoc(env) {
|
|
227
|
-
const explicit = env("
|
|
227
|
+
const explicit = env("BEATRINA_PANDOC", "");
|
|
228
228
|
if (explicit && fs.existsSync(explicit)) return explicit;
|
|
229
229
|
const fromEnv = env("RSTUDIO_PANDOC", "");
|
|
230
230
|
if (fromEnv) {
|
|
231
231
|
const cand = path.join(fromEnv, process.platform === "win32" ? "pandoc.exe" : "pandoc");
|
|
232
232
|
if (fs.existsSync(cand)) return cand;
|
|
233
233
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
234
|
+
// The PATH walk is host/which.mjs's, which was lifted out precisely so two
|
|
235
|
+
// planes could not disagree about what "on PATH" means. This copy tried only
|
|
236
|
+
// `pandoc.exe` on Windows, where whichSync walks PATHEXT. `env` here is a
|
|
237
|
+
// READER, so PATH is handed over as the object whichSync expects.
|
|
238
|
+
const onPath = whichSync("pandoc", { PATH: env("PATH", "") });
|
|
239
|
+
if (onPath) return onPath;
|
|
239
240
|
const arch = ["arm64"].includes(process.arch) ? "aarch64" : "x86_64";
|
|
240
241
|
const bundles = [
|
|
241
242
|
`/Applications/RStudio.app/Contents/Resources/app/quarto/bin/tools/${arch}/pandoc`,
|
|
@@ -327,7 +328,7 @@ export function citeValidate(here, policy, env, cmd) {
|
|
|
327
328
|
export function citeDocument(yaml, groups) {
|
|
328
329
|
const paras = groups.map((g) => {
|
|
329
330
|
const cite = g.narrative ? `@${g.keys[0]}` : `[${g.keys.map((k) => `@${k}`).join("; ")}]`;
|
|
330
|
-
return `[${cite}]{.
|
|
331
|
+
return `[${cite}]{.beatrina-cite-group}`;
|
|
331
332
|
});
|
|
332
333
|
return `---\n${yaml}\n---\n\n${paras.join("\n\n")}\n`;
|
|
333
334
|
}
|
|
@@ -335,13 +336,13 @@ export function citeDocument(yaml, groups) {
|
|
|
335
336
|
/**
|
|
336
337
|
* Split pandoc's HTML into the group labels and the bibliography.
|
|
337
338
|
*
|
|
338
|
-
* Positional: the i-th `
|
|
339
|
+
* Positional: the i-th `beatrina-cite-group` span is the i-th group. A group span
|
|
339
340
|
* holds nested spans of its own, so the closing tag is found by WALKING span
|
|
340
341
|
* tags and counting depth, never by a non-greedy match.
|
|
341
342
|
*/
|
|
342
343
|
export function citeSplit(html, nGroups) {
|
|
343
344
|
const labels = new Array(nGroups).fill("");
|
|
344
|
-
const open = '<span class="
|
|
345
|
+
const open = '<span class="beatrina-cite-group">';
|
|
345
346
|
const starts = [];
|
|
346
347
|
for (let at = html.indexOf(open); at !== -1; at = html.indexOf(open, at + 1)) starts.push(at);
|
|
347
348
|
for (let i = 0; i < Math.min(starts.length, nGroups); i += 1) {
|
|
@@ -369,8 +370,8 @@ export function citeSplit(html, nGroups) {
|
|
|
369
370
|
|
|
370
371
|
/** Run pandoc over one validated request. */
|
|
371
372
|
export async function citeRun(bin, valid, timeoutMs = CITE_TIMEOUT_MS) {
|
|
372
|
-
if (!bin) return { ok: false, error: "pandoc is not installed, or
|
|
373
|
-
const input = path.join(os.tmpdir(), `
|
|
373
|
+
if (!bin) return { ok: false, error: "pandoc is not installed, or Beatrina could not find it." };
|
|
374
|
+
const input = path.join(os.tmpdir(), `beatrina-cite-${crypto.randomBytes(8).toString("hex")}.md`);
|
|
374
375
|
try {
|
|
375
376
|
fs.writeFileSync(input, citeDocument(valid.yaml, valid.groups));
|
|
376
377
|
fs.chmodSync(input, 0o600);
|
|
@@ -482,7 +483,7 @@ export function createPlane({ spike, env, audit }) {
|
|
|
482
483
|
|
|
483
484
|
// csl_place: the style beside the document, as a .bib sits beside a .tex
|
|
484
485
|
// — what makes `csl: apa.csl` in the front matter render the same style
|
|
485
|
-
// with no
|
|
486
|
+
// with no Beatrina present. A dependent style's parent goes too, or pandoc
|
|
486
487
|
// would go looking for it. NEVER overwrites: a different file of that
|
|
487
488
|
// name beside the document is the author's, and is reported.
|
|
488
489
|
const style = cslResolve(here, policy, env, cmd.name);
|
package/host/planes/files.mjs
CHANGED
|
Binary file
|