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 @@
|
|
|
1
|
+
{"schema":1,"sources":"846c329ab149b159e5984bb6bcbf336828381df44094a015a69f5a4b2da526bb","artifact":"23fedd72d63de1b612b4760addaa827f88d84ccb35909a947ae0ade8c29d2ec0"}
|
package/bin/beatrina.mjs
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
// (asked once, installed from CRAN into the user's own library on yes).
|
|
8
8
|
|
|
9
9
|
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
10
11
|
import path from "node:path";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
11
13
|
import { fileURLToPath } from "node:url";
|
|
12
14
|
import { USAGE, ask, installRPackages, missingRPackages, parseArgs, REQUIRED_R_PACKAGES } from "./cli.mjs";
|
|
15
|
+
import { setupManagedPython } from "./python-setup.mjs";
|
|
13
16
|
|
|
14
17
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
18
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
@@ -25,11 +28,26 @@ if (major < 22) fail(`Beatrina needs Node.js 22 or newer; this is ${process.vers
|
|
|
25
28
|
|
|
26
29
|
const { APP_ID, describeMigration, migrateAppData } = await import("./identity.mjs");
|
|
27
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The kept notebook pages (pages.mjs): drop the versions no live session runs, other than this
|
|
33
|
+
* one. `rows` are `listSessions()`'s; a live row that cannot say its build keeps everything.
|
|
34
|
+
*/
|
|
35
|
+
async function pruneKeptPages(rows) {
|
|
36
|
+
const { pagesDir, prunePages, versionsInUse } = await import("./pages.mjs");
|
|
37
|
+
const { rUserDir } = await import("../host/user-dirs.mjs");
|
|
38
|
+
const keep = versionsInUse(rows);
|
|
39
|
+
if (!keep) return [];
|
|
40
|
+
return prunePages({ dir: pagesDir(process.env.BEATRIX_APP_ID || APP_ID, rUserDir), installed: pkg.version, keep });
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
if (args.command === "status" || args.command === "stop") {
|
|
29
44
|
const { chooseToStop, formatSessions, listSessions, stopSession } = await import("./sessions.mjs");
|
|
30
45
|
const rows = await listSessions();
|
|
31
46
|
if (args.command === "status") {
|
|
32
|
-
|
|
47
|
+
// --json: the rows as listSessions returns them, one array — what the menu and a script read;
|
|
48
|
+
// the table is for a person.
|
|
49
|
+
if (args.json) console.log(JSON.stringify(rows));
|
|
50
|
+
else for (const line of formatSessions(rows)) console.log(line);
|
|
33
51
|
process.exit(0);
|
|
34
52
|
}
|
|
35
53
|
const chosen = chooseToStop(rows, { port: args.port, all: args.all });
|
|
@@ -42,9 +60,38 @@ if (args.command === "status" || args.command === "stop") {
|
|
|
42
60
|
"removed-stale": `Removed the record of a session on port ${row.port} that was no longer running.`, failed: `Could not stop the session on port ${row.port}: ${done.detail}` }[done.outcome];
|
|
43
61
|
if (done.outcome === "failed") { failed += 1; console.error(said); } else console.log(said);
|
|
44
62
|
}
|
|
63
|
+
// The pages kept for the sessions that just stopped are not needed any more (pages.mjs).
|
|
64
|
+
await pruneKeptPages(await listSessions());
|
|
45
65
|
process.exit(failed ? 1 : 0);
|
|
46
66
|
}
|
|
47
67
|
|
|
68
|
+
if (args.command === "knit") {
|
|
69
|
+
// The report is the page's — knitFile() builds the same cells the window
|
|
70
|
+
// would and calls the same knitDocument(), so a knit from the terminal and
|
|
71
|
+
// a knit from the menu are the same file.
|
|
72
|
+
// Installed, the knitter is one bundled file beside the acceptance check
|
|
73
|
+
// (tools/npm/build-npm.mjs); in a checkout it is its own source tree. Trying
|
|
74
|
+
// the staged path FIRST means the published package never depends on a
|
|
75
|
+
// `tools/` directory it does not ship — `beatrina knit` was unusable from an
|
|
76
|
+
// npm install until 2026-09-18 for exactly that reason.
|
|
77
|
+
const staged = path.join(root, "kernel", "check", "knit-file.mjs");
|
|
78
|
+
const { knitFile } = await import(
|
|
79
|
+
fs.existsSync(staged) ? pathToFileURL(staged).href : "../../tools/beatrina/knit-file.mjs");
|
|
80
|
+
try {
|
|
81
|
+
const r = await knitFile(args.file, {
|
|
82
|
+
run: args.run, echo: args.echo, out: args.out, width: args.width, port: args.port,
|
|
83
|
+
rebuild: args.rebuild, code: args.code, toc: args.toc, numberSections: args.numberSections, rows: args.rows,
|
|
84
|
+
say: (line) => console.log(line),
|
|
85
|
+
});
|
|
86
|
+
// The last line is the file, alone, so `open "$(beatrina knit x.qmd | tail -1)"` works.
|
|
87
|
+
console.log(r.out);
|
|
88
|
+
process.exit(r.failed ? 1 : 0);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
console.error(`beatrina knit: ${e.message}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
48
95
|
if (args.command === "upgrade") {
|
|
49
96
|
// The explicit door (upgrade.mjs): npm's work, so R is not needed for it.
|
|
50
97
|
const { runUpgrade } = await import("./upgrade.mjs");
|
|
@@ -53,8 +100,18 @@ if (args.command === "upgrade") {
|
|
|
53
100
|
}
|
|
54
101
|
|
|
55
102
|
const { detectRscript } = await import("../host/engine-r.mjs");
|
|
56
|
-
const { detectPython } = await import("../host/engine-python.mjs");
|
|
57
|
-
|
|
103
|
+
const { detectPython, pythonCandidates } = await import("../host/engine-python.mjs");
|
|
104
|
+
|
|
105
|
+
if (args.command === "python") {
|
|
106
|
+
// `beatrina python setup` (python-setup.mjs): the interpreter discovery looks for first. R is not needed.
|
|
107
|
+
const os = await import("node:os");
|
|
108
|
+
const done = await setupManagedPython({ home: os.homedir(), env: process.env, platform: process.platform,
|
|
109
|
+
candidates: pythonCandidates(process.env), minimal: Boolean(args.minimal), log: (line) => console.log(line) });
|
|
110
|
+
if (!done.ok) fail(done.refusal);
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const rscript = detectRscript(process.env.BEATRINA_RSCRIPT || "");
|
|
58
115
|
|
|
59
116
|
if (args.command === "doctor") {
|
|
60
117
|
const rows = [["Beatrina", `${pkg.version} (${root})`], ["Node.js", `${process.version} at ${process.execPath}`]];
|
|
@@ -76,9 +133,28 @@ if (args.command === "doctor") {
|
|
|
76
133
|
try { missing = missingRPackages(rscript); } catch (e) { missing = [`(${e.message})`]; }
|
|
77
134
|
rows.push(["R packages", missing.length ? `missing: ${missing.join(", ")} — run beatrina, which offers to install them` : `${REQUIRED_R_PACKAGES.join(", ")}: installed`]);
|
|
78
135
|
}
|
|
136
|
+
// detectPython, the same ladder the host runs, so doctor and the session agree; the row says
|
|
137
|
+
// which rung chose the interpreter, or what to run when none has ipykernel.
|
|
79
138
|
const py = await detectPython(process.env);
|
|
80
|
-
rows.push(["Python (optional)", py.available ? `${py.version} at ${py.path}
|
|
81
|
-
|
|
139
|
+
rows.push(["Python (optional)", py.available ? `${py.version} at ${py.path} (${py.why})`
|
|
140
|
+
: `none with ipykernel — run \`beatrina python setup\`. ${py.detail}`]);
|
|
141
|
+
{
|
|
142
|
+
// What is running, on which version, and the doors out of a session that predates the installed
|
|
143
|
+
// files — from the one discovery `status` and `stop` read (sessions.mjs). Before node-pty: the
|
|
144
|
+
// npm-cli suite reads the LAST line for that row.
|
|
145
|
+
const { listSessions } = await import("./sessions.mjs");
|
|
146
|
+
const { sessionsRow } = await import("./doctor-rows.mjs");
|
|
147
|
+
rows.push(["Sessions", sessionsRow(await listSessions(), pkg.version)]);
|
|
148
|
+
}
|
|
149
|
+
if (process.platform === "darwin") {
|
|
150
|
+
// What Finder does with a .qmd, .Rmd, .carmd, and which Beatrina.app copies Launch Services
|
|
151
|
+
// knows — asked of Launch Services itself (finder.mjs); "not checked" without a Swift toolchain.
|
|
152
|
+
const { finderRows, lsQuery } = await import("./finder.mjs");
|
|
153
|
+
const { BUNDLE_ID, DOC_TYPES } = await import("./shortcut.mjs");
|
|
154
|
+
const exts = DOC_TYPES.map((t) => t.ext);
|
|
155
|
+
for (const row of finderRows(lsQuery({ exts, bundleIds: [BUNDLE_ID] }), { bundleId: BUNDLE_ID, exts })) rows.push(row);
|
|
156
|
+
}
|
|
157
|
+
let pty ="not installed — the terminal pane runs line programs only";
|
|
82
158
|
try { await import("node-pty"); pty = "installed — full terminal"; } catch { /* optional */ }
|
|
83
159
|
rows.push(["node-pty (optional)", pty]);
|
|
84
160
|
const width = Math.max(...rows.map((r) => r[0].length));
|
|
@@ -95,8 +171,8 @@ if (args.command === "check") {
|
|
|
95
171
|
// BEATRINA_CHECK_JUNIT keeps the report where the caller wants it (fordist/beatrina-windows-check.ps1).
|
|
96
172
|
const junit = process.env.BEATRINA_CHECK_JUNIT || path.join(scratch, "acceptance.xml");
|
|
97
173
|
const code = await new Promise((resolve) => {
|
|
98
|
-
const child = spawn(process.execPath, [path.join(root, "check", "acceptance.mjs"), `--host=${path.join(root, "host", "main.mjs")}`, `--junit=${junit}`],
|
|
99
|
-
{ stdio: "inherit", env: { ...process.env,
|
|
174
|
+
const child = spawn(process.execPath, [path.join(root, "kernel", "check", "acceptance.mjs"), `--host=${path.join(root, "host", "main.mjs")}`, `--junit=${junit}`],
|
|
175
|
+
{ stdio: "inherit", env: { ...process.env, BEATRINA_RSCRIPT: rscript } });
|
|
100
176
|
child.on("exit", (c) => resolve(c ?? 1));
|
|
101
177
|
child.on("error", () => resolve(1));
|
|
102
178
|
});
|
|
@@ -116,9 +192,12 @@ if (args.command === "check") {
|
|
|
116
192
|
}
|
|
117
193
|
|
|
118
194
|
if (args.command === "shortcut") {
|
|
119
|
-
const { makeShortcut } = await import("./shortcut.mjs");
|
|
195
|
+
const { makeShortcut, removeMenu } = await import("./shortcut.mjs");
|
|
120
196
|
try {
|
|
121
|
-
|
|
197
|
+
// --no-menu removes the menu-bar item and its login agent, and nothing else.
|
|
198
|
+
const made = args.noMenu ? removeMenu({ dir: args.dir })
|
|
199
|
+
: makeShortcut({ cli: fileURLToPath(import.meta.url), dir: args.dir, port: args.port, makeDefault: args.makeDefault, noOpen: !args.open,
|
|
200
|
+
menu: args.menu, login: args.login, menuSource: path.join(root, "menu", "Beatrina Menu.app") });
|
|
122
201
|
for (const line of made.said) console.log(line);
|
|
123
202
|
process.exit(0);
|
|
124
203
|
} catch (e) { fail(`the shortcut could not be made: ${e.message}`); }
|
|
@@ -131,12 +210,16 @@ if (args.fromApp) {
|
|
|
131
210
|
const { liveSession } = await import("./shortcut.mjs");
|
|
132
211
|
const { spawn, spawnSync } = await import("node:child_process");
|
|
133
212
|
const alert = (msg) => {
|
|
134
|
-
|
|
213
|
+
// BEATRINA_NO_ALERT: the suites, where a modal dialog would block the run.
|
|
214
|
+
if (process.env.BEATRINA_NO_ALERT === "1") console.error(`beatrina: ${msg}`);
|
|
215
|
+
else if (process.platform === "darwin") spawnSync("osascript", ["-e", `display alert "Beatrina" message ${JSON.stringify(msg)}`]);
|
|
135
216
|
else if (process.platform === "win32") spawnSync("powershell.exe", ["-NoProfile", "-Command", `Add-Type -AssemblyName PresentationFramework; [System.Windows.MessageBox]::Show(${JSON.stringify(msg).replace(/^"|"$/g, "'")}, 'Beatrina')`]);
|
|
136
217
|
else console.error(msg);
|
|
137
218
|
};
|
|
138
219
|
const file = args.file ? path.resolve(args.file) : "";
|
|
139
|
-
|
|
220
|
+
// --new (the menu's New Session, launch.sh's --new): another session even when one is running —
|
|
221
|
+
// the background start below, with no look for a live one.
|
|
222
|
+
const live = args.new ? null : await liveSession({ app: process.env.BEATRIX_APP_ID || APP_ID, port: args.port });
|
|
140
223
|
if (live) {
|
|
141
224
|
let delivered = "none";
|
|
142
225
|
if (file) {
|
|
@@ -150,22 +233,51 @@ if (args.fromApp) {
|
|
|
150
233
|
if (delivered !== "page" && args.open && live.url) {
|
|
151
234
|
// The page the session SERVES (http://127.0.0.1:<port>/), never the file: macOS `open` drops a
|
|
152
235
|
// file URL's #kernel=…&pair=… fragment and the page then cannot find its kernel. The served
|
|
153
|
-
// origin needs no pairing. BEATRINA_OPENER substitutes the program
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
spawnSync(opener[0], opener[1]);
|
|
236
|
+
// origin needs no pairing (browser.mjs; BEATRINA_OPENER substitutes the program in the suites).
|
|
237
|
+
const { openUrl } = await import("./browser.mjs");
|
|
238
|
+
openUrl(live.url);
|
|
157
239
|
}
|
|
158
240
|
if (delivered === "refused") alert(`The running Beatrina session could not open ${file}.`);
|
|
159
241
|
process.exit(0);
|
|
160
242
|
}
|
|
161
243
|
if (!rscript) { alert("Beatrina needs R. Install R from https://cran.r-project.org, then open Beatrina again."); process.exit(1); }
|
|
244
|
+
// The session starts DETACHED (it must outlive this process), but this
|
|
245
|
+
// process no longer exits the moment it has spawned it: it waits, bounded,
|
|
246
|
+
// for the session's own announcement, and a child that dies first is SAID
|
|
247
|
+
// with the tail of its log (hardening.md 2). Until 0.9.6 stdio was "ignore"
|
|
248
|
+
// and the exit was immediate, so a missing package, a refused port or a
|
|
249
|
+
// host that crashed at start ended in success and an empty screen.
|
|
250
|
+
const logPath = path.join(os.tmpdir(), `beatrina-from-app-${process.pid}.log`);
|
|
251
|
+
let logFd = "ignore";
|
|
252
|
+
try { logFd = fs.openSync(logPath, "a"); } catch { logFd = "ignore"; }
|
|
253
|
+
const readLog = () => { try { return fs.readFileSync(logPath, "utf8"); } catch { return ""; } };
|
|
162
254
|
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...(file ? [file] : []), "--yes",
|
|
163
|
-
...(args.port ? ["--port", args.port] : []), ...(args.open ? [] : ["--no-open"])],
|
|
255
|
+
...(args.port ? ["--port", args.port] : []), ...(args.open ? [] : ["--no-open"])],
|
|
256
|
+
{ detached: true, stdio: ["ignore", logFd, logFd], windowsHide: true });
|
|
257
|
+
if (typeof logFd === "number") fs.closeSync(logFd);
|
|
258
|
+
let spawnError = null;
|
|
259
|
+
child.on("error", (e) => { spawnError = e; });
|
|
164
260
|
child.unref();
|
|
165
|
-
|
|
261
|
+
// Under the menu helper's own 180 s limit, so the helper hears THIS answer.
|
|
262
|
+
const waitS = Number(process.env.BEATRINA_FROM_APP_DEADLINE || 150);
|
|
263
|
+
const until = Date.now() + (Number.isFinite(waitS) && waitS > 0 ? waitS : 150) * 1000;
|
|
264
|
+
const tail = () => readLog().trim().split("\n").filter((l) => !/"url"\s*:/.test(l)).slice(-5).join("\n");
|
|
265
|
+
while (Date.now() < until) {
|
|
266
|
+
const url = /"url"\s*:\s*"([^"]*)"/.exec(readLog());
|
|
267
|
+
if (url) { console.log(JSON.stringify({ url: url[1], pid: child.pid, log: logPath })); process.exit(0); }
|
|
268
|
+
if (spawnError) { alert(`Beatrina could not start a session: ${spawnError.message}`); process.exit(1); }
|
|
269
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
270
|
+
const said = tail();
|
|
271
|
+
alert(`Beatrina could not start a session (${child.signalCode || `exit ${child.exitCode}`}).${said ? `\n\n${said}` : ""}\n\nThe log is at ${logPath}`);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
275
|
+
}
|
|
276
|
+
alert(`Beatrina is still starting after ${Math.round(waitS)} seconds. It may yet open; the log is at ${logPath}`);
|
|
277
|
+
process.exit(1);
|
|
166
278
|
}
|
|
167
279
|
|
|
168
|
-
if (!rscript) fail("Beatrina needs R and none was found. Install R from https://cran.r-project.org, or set
|
|
280
|
+
if (!rscript) fail("Beatrina needs R and none was found. Install R from https://cran.r-project.org, or set BEATRINA_RSCRIPT to your Rscript.");
|
|
169
281
|
let missing;
|
|
170
282
|
try { missing = missingRPackages(rscript); } catch (e) { fail(e.message); }
|
|
171
283
|
if (missing.length) {
|
|
@@ -178,18 +290,30 @@ if (missing.length) {
|
|
|
178
290
|
|
|
179
291
|
// Beatrina's own data folders and port, unless the caller chose otherwise. Data a Beatrix build
|
|
180
292
|
// left under R/beatrix moves across once (identity.mjs).
|
|
293
|
+
const { rUserDir } = await import("../host/user-dirs.mjs");
|
|
181
294
|
if (!process.env.BEATRIX_APP_ID) {
|
|
182
295
|
process.env.BEATRIX_APP_ID = APP_ID;
|
|
183
|
-
const { rUserDir } = await import("../host/user-dirs.mjs");
|
|
184
296
|
for (const line of describeMigration(migrateAppData({ dirOf: rUserDir }))) console.error(`beatrina: ${line}`);
|
|
185
297
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
298
|
+
// The page this session will serve, kept once per version under the app's cache, where the host
|
|
299
|
+
// reads it as its third location (pages.mjs): `npm install -g beatrina@<next>` replaces the package
|
|
300
|
+
// folder wholesale, and without the copy a session still running the old version answers GET /
|
|
301
|
+
// with the development index. Then the pages of versions nobody runs any more are dropped.
|
|
302
|
+
{
|
|
303
|
+
const { keepPage, pageName, pagesDir } = await import("./pages.mjs");
|
|
304
|
+
const dir = pagesDir(process.env.BEATRIX_APP_ID, rUserDir);
|
|
305
|
+
const kept = keepPage({ from: path.join(root, pageName(pkg.version)), dir });
|
|
306
|
+
if (kept.outcome === "failed") console.error(`beatrina: the notebook page could not be kept under ${dir} (${kept.detail}); upgrading while this session runs will take its page until it restarts.`);
|
|
307
|
+
const { listSessions } = await import("./sessions.mjs");
|
|
308
|
+
await pruneKeptPages(await listSessions());
|
|
309
|
+
}
|
|
310
|
+
process.env.BEATRINA_RSCRIPT = rscript;
|
|
311
|
+
if (args.port) process.env.BEATRINA_PORT = args.port;
|
|
312
|
+
else process.env.BEATRINA_PORT ||= "4848";
|
|
189
313
|
if (args.file) {
|
|
190
314
|
const file = path.resolve(args.file);
|
|
191
315
|
if (!fs.existsSync(file)) fail(`no such file: ${file}`);
|
|
192
|
-
process.env.
|
|
316
|
+
process.env.BEATRINA_OPEN_FILE = file;
|
|
193
317
|
}
|
|
194
318
|
const open = args.open && process.env.BEATRIX_NO_OPEN !== "1";
|
|
195
319
|
// Once a day: is a newer Beatrina on the registry? Said, never acted on, and never waited for.
|
|
@@ -218,8 +342,12 @@ else {
|
|
|
218
342
|
}
|
|
219
343
|
let staged;
|
|
220
344
|
try { staged = stageFailsafe(root); } catch (e) { fail(`Beatrina could not start: ${started.reason}, and ${e.message}.`); }
|
|
221
|
-
|
|
345
|
+
// No `--open` for serve.R: its own open is `browseURL` on the FILE URL, whose #kernel=…&pair=…
|
|
346
|
+
// fragment macOS `open` drops (the page then says "needs R"). The command opens the served origin
|
|
347
|
+
// itself once the supervisor has announced it — exactly what the host path opens (browser.mjs).
|
|
348
|
+
started = await runUntilAnnounced({ cmd: rscript, args: [staged.serve],
|
|
222
349
|
env: { ...process.env, BEATRINA_FAILSAFE_REASON: started.reason }, deadlineMs: 120000 });
|
|
223
350
|
if (!started.announced) fail(`the failsafe did not start either: ${started.reason.replace("the Node host", "the R supervisor")}.`);
|
|
351
|
+
if (open && started.url) { const { openUrl } = await import("./browser.mjs"); openUrl(started.url); }
|
|
224
352
|
followChild(started.child, { onExit: (code) => { fs.rmSync(staged.stage, { recursive: true, force: true }); process.exit(code); } });
|
|
225
353
|
}
|
package/bin/browser.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// browser.mjs — open a URL in the person's browser, from the command (docs/launch-menu-plan.md L3).
|
|
2
|
+
//
|
|
3
|
+
// ONE platform switch for everything the `beatrina` command opens itself: `--from-app` reopening a
|
|
4
|
+
// running session's page, and the failsafe once the R supervisor has announced. The host keeps its
|
|
5
|
+
// own `--open*` handling (the dev door, the handoff, Beatrina's launchers); this is the command's.
|
|
6
|
+
//
|
|
7
|
+
// What is opened is always the page a session SERVES (http://127.0.0.1:<port>/), never a file URL:
|
|
8
|
+
// macOS `open` drops a file URL's #kernel=…&pair=… fragment and the page then cannot find its kernel
|
|
9
|
+
// (LEARNINGS 2026-09-16). BEATRINA_OPENER substitutes the program (the suites), and its older
|
|
10
|
+
// spelling — the host's seam before the rename — is honoured too, so a test that sets either sees
|
|
11
|
+
// the same thing open.
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
|
|
15
|
+
/** The program and arguments that open `url` on this platform, or through the seam. */
|
|
16
|
+
export function openerFor(url, { env = process.env, platform = process.platform } = {}) {
|
|
17
|
+
const seam = env.BEATRINA_OPENER || env.CARMAR_OPENER || ""; // beatrina-names: keep
|
|
18
|
+
if (seam) return [seam, [url]];
|
|
19
|
+
return platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Open `url`. Nothing is waited for beyond the opener handing the URL over; a desktop with no
|
|
24
|
+
* opener is not an error (the URL was printed by the announce line).
|
|
25
|
+
* @returns {{cmd: string, args: string[], ok: boolean}}
|
|
26
|
+
*/
|
|
27
|
+
export function openUrl(url, { env = process.env, platform = process.platform, spawnImpl = spawnSync } = {}) {
|
|
28
|
+
const [cmd, args] = openerFor(url, { env, platform });
|
|
29
|
+
const r = spawnImpl(cmd, args, { stdio: "ignore" }) || {};
|
|
30
|
+
return { cmd, args, ok: !r.error && (r.status == null || r.status === 0) };
|
|
31
|
+
}
|
package/bin/cli.mjs
CHANGED
|
@@ -9,40 +9,81 @@ export const REQUIRED_R_PACKAGES = Object.freeze(["jsonlite", "knitr", "evaluate
|
|
|
9
9
|
export const USAGE = `beatrina — R, Python and JavaScript notebooks on your own computer
|
|
10
10
|
|
|
11
11
|
beatrina [file] start a session and open the notebook (Ctrl+C stops it)
|
|
12
|
-
beatrina status list running sessions: port, process, uptime, document
|
|
12
|
+
beatrina status list running sessions: port, process, uptime, document (--json for scripts and the menu)
|
|
13
13
|
beatrina stop stop the running session (--port <n> for one of several, --all for every one)
|
|
14
14
|
beatrina doctor show what Beatrina found: Node, R, R packages, Python, and whether npm has a newer Beatrina
|
|
15
|
+
beatrina python setup
|
|
16
|
+
create the Python interpreter Beatrina manages (~/.beatrina/python: a virtual
|
|
17
|
+
environment with ipykernel, pandas and matplotlib; --minimal installs ipykernel only)
|
|
15
18
|
beatrina check run the acceptance check on this machine (a private session on a spare port);
|
|
16
19
|
--prova <url> --token <t> (or PROVA_URL / PROVA_TOKEN) sends the results to Prova
|
|
17
20
|
beatrina shortcut add a Beatrina icon, and "Open With ▸ Beatrina" for .qmd and .Rmd files
|
|
18
|
-
(--default also makes it the default app for them
|
|
21
|
+
(--default also makes it the default app for them; safe to run again — it repairs
|
|
22
|
+
a second registered copy). On macOS, --menu also installs the Beatrina menu-bar
|
|
23
|
+
item (--login: at every login too); --no-menu removes it
|
|
24
|
+
beatrina knit <file>
|
|
25
|
+
knit a .qmd/.Rmd/.md to one self-contained HTML file, without opening a window:
|
|
26
|
+
every chunk runs in a private session first (--no-run keeps the code and says in
|
|
27
|
+
the file that the results are missing), --no-echo writes a results-only report,
|
|
28
|
+
--width standard|wide|full (or a CSS length) sets the report page width,
|
|
29
|
+
--code chunk|show|fold|hide how code appears (chunk: each chunk's echo decides),
|
|
30
|
+
--toc / --no-toc, --number-sections, --rows 50|200|all rows per table,
|
|
31
|
+
--rebuild re-runs cached chunks and refreshes knitr's cache (shared with the
|
|
32
|
+
window: a chunk cached there is restored here), --out <file.html> chooses where
|
|
33
|
+
it goes. One line per chunk: ✓ ran, ↺ cached, ✗ failed, then what it wrote
|
|
19
34
|
beatrina upgrade install the newest Beatrina from npm (npm install -g beatrina@latest);
|
|
20
35
|
a session already running keeps this version until it restarts
|
|
21
36
|
|
|
22
37
|
--port <n> port for the session (default 4848)
|
|
23
38
|
--no-open do not open a browser
|
|
24
39
|
--yes install missing R packages without asking
|
|
40
|
+
--from-app [file] what the icon, a double-clicked document and the menu run: hand the file to the
|
|
41
|
+
running session, or start one in the background (--new: start another regardless)
|
|
25
42
|
--version, --help`;
|
|
26
43
|
|
|
27
|
-
export const COMMANDS = Object.freeze(["doctor", "shortcut", "status", "stop", "check", "upgrade"]);
|
|
44
|
+
export const COMMANDS = Object.freeze(["doctor", "shortcut", "status", "stop", "check", "upgrade", "python", "knit"]);
|
|
45
|
+
/** What `beatrina python` can be asked to do. */
|
|
46
|
+
export const PYTHON_VERBS = Object.freeze(["setup"]);
|
|
28
47
|
|
|
29
48
|
/** Parse the command line. */
|
|
30
49
|
export function parseArgs(argv) {
|
|
31
|
-
const out = { command: "run", file: "", port: "", open: true, yes: false, help: false, version: false, fromApp: false, makeDefault: false, dir: "", all: false, prova: "", token: "" };
|
|
50
|
+
const out = { command: "run", file: "", port: "", open: true, yes: false, help: false, version: false, fromApp: false, makeDefault: false, dir: "", all: false, prova: "", token: "", json: false, new: false, menu: false, noMenu: false, login: false, run: true, echo: true, out: "", width: "" };
|
|
32
51
|
for (let i = 0; i < argv.length; i += 1) {
|
|
33
52
|
const a = argv[i];
|
|
34
53
|
if (a === "--help" || a === "-h") out.help = true;
|
|
35
54
|
else if (a === "--version" || a === "-v") out.version = true;
|
|
36
55
|
else if (a === "--no-open") out.open = false;
|
|
56
|
+
else if (a === "--no-run") out.run = false;
|
|
57
|
+
else if (a === "--no-echo") out.echo = false;
|
|
58
|
+
else if (a === "--out") out.out = argv[++i] || "";
|
|
59
|
+
else if (a === "--width") out.width = argv[++i] || "";
|
|
60
|
+
else if (a.startsWith("--out=")) out.out = a.slice(6);
|
|
61
|
+
// The knit vocabulary (lib/knit.js KNIT_CODE …), set only when given so
|
|
62
|
+
// the document's own front matter decides otherwise.
|
|
63
|
+
else if (a === "--rebuild") out.rebuild = true;
|
|
64
|
+
else if (a === "--code" || a.startsWith("--code=")) out.code = a === "--code" ? argv[++i] || "" : a.slice(7);
|
|
65
|
+
else if (a === "--toc") out.toc = true;
|
|
66
|
+
else if (a === "--no-toc") out.toc = false;
|
|
67
|
+
else if (a === "--number-sections") out.numberSections = true;
|
|
68
|
+
else if (a === "--rows" || a.startsWith("--rows=")) out.rows = a === "--rows" ? argv[++i] || "" : a.slice(7);
|
|
37
69
|
else if (a === "--yes" || a === "-y") out.yes = true;
|
|
38
70
|
else if (a === "--port") out.port = argv[++i] || "";
|
|
39
71
|
else if (a.startsWith("--port=")) out.port = a.slice(7);
|
|
40
72
|
else if (COMMANDS.includes(a) && out.command === "run" && !out.file) out.command = a;
|
|
73
|
+
// `beatrina python <verb>`: the verb and its one option are set only when
|
|
74
|
+
// given, so the shape of every other command's result is unchanged.
|
|
75
|
+
else if (out.command === "python" && !out.verb && PYTHON_VERBS.includes(a)) out.verb = a;
|
|
76
|
+
else if (a === "--minimal") out.minimal = true;
|
|
41
77
|
else if (a === "--all") out.all = true;
|
|
42
78
|
else if (a === "--prova") out.prova = argv[++i] || "";
|
|
43
79
|
else if (a === "--token") out.token = argv[++i] || "";
|
|
44
80
|
else if (a === "--from-app") out.fromApp = true;
|
|
81
|
+
else if (a === "--new") out.new = true;
|
|
82
|
+
else if (a === "--json") out.json = true;
|
|
45
83
|
else if (a === "--default") out.makeDefault = true;
|
|
84
|
+
else if (a === "--menu") out.menu = true;
|
|
85
|
+
else if (a === "--no-menu") out.noMenu = true;
|
|
86
|
+
else if (a === "--login") out.login = true;
|
|
46
87
|
else if (a === "--dir") out.dir = argv[++i] || "";
|
|
47
88
|
else if (a.startsWith("-")) throw new Error(`unknown option ${a}\n\n${USAGE}`);
|
|
48
89
|
else if (!out.file) out.file = a;
|
|
@@ -50,8 +91,26 @@ export function parseArgs(argv) {
|
|
|
50
91
|
}
|
|
51
92
|
if (out.port && !/^\d{2,5}$/.test(out.port)) throw new Error(`--port must be a number, not ${JSON.stringify(out.port)}`);
|
|
52
93
|
if (out.all && out.command !== "stop") throw new Error(`--all belongs to beatrina stop\n\n${USAGE}`);
|
|
94
|
+
if (out.json && out.command !== "status") throw new Error(`--json belongs to beatrina status\n\n${USAGE}`);
|
|
95
|
+
if (out.new && !out.fromApp) throw new Error(`--new belongs to beatrina --from-app\n\n${USAGE}`);
|
|
96
|
+
if ((out.menu || out.noMenu || out.login) && out.command !== "shortcut") throw new Error(`--menu, --no-menu and --login belong to beatrina shortcut\n\n${USAGE}`);
|
|
97
|
+
if (out.menu && out.noMenu) throw new Error(`--menu and --no-menu are opposites; give one\n\n${USAGE}`);
|
|
98
|
+
if (out.login && !out.menu) throw new Error(`--login goes with --menu\n\n${USAGE}`);
|
|
53
99
|
if ((out.prova || out.token) && out.command !== "check") throw new Error(`--prova and --token belong to beatrina check\n\n${USAGE}`);
|
|
54
|
-
if (out.
|
|
100
|
+
if (out.command === "knit" && !out.file) throw new Error(`beatrina knit takes the document to knit\n\n${USAGE}`);
|
|
101
|
+
const knitFlags = out.out || out.width || out.run === false || out.echo === false || out.rebuild
|
|
102
|
+
|| out.code !== undefined || out.toc !== undefined || out.numberSections || out.rows !== undefined;
|
|
103
|
+
if (knitFlags && out.command !== "knit") throw new Error(`--out, --width, --no-run, --no-echo, --code, --toc, --number-sections, --rows and --rebuild belong to beatrina knit\n\n${USAGE}`);
|
|
104
|
+
if (out.code !== undefined && !["chunk", "show", "fold", "hide"].includes(out.code)) throw new Error(`--code is one of chunk, show, fold, hide — not ${JSON.stringify(out.code)}`);
|
|
105
|
+
if (out.rows !== undefined) {
|
|
106
|
+
if (out.rows === "all") out.rows = 0;
|
|
107
|
+
else if (/^\d+$/.test(String(out.rows)) && Number(out.rows) > 0) out.rows = Number(out.rows);
|
|
108
|
+
else throw new Error(`--rows is a positive number or "all", not ${JSON.stringify(out.rows)}`);
|
|
109
|
+
}
|
|
110
|
+
if (out.rebuild && out.run === false) throw new Error("--rebuild runs the chunks; it cannot go with --no-run");
|
|
111
|
+
if (out.command === "python" && !out.verb) throw new Error(`beatrina python takes a verb: ${PYTHON_VERBS.join(", ")}\n\n${USAGE}`);
|
|
112
|
+
if (out.file && (out.command === "status" || out.command === "stop" || out.command === "check" || out.command === "upgrade" || out.command === "python")) throw new Error(`beatrina ${out.command} takes no file\n\n${USAGE}`);
|
|
113
|
+
if (out.minimal && !(out.command === "python" && out.verb === "setup")) throw new Error(`--minimal belongs to beatrina python setup\n\n${USAGE}`);
|
|
55
114
|
return out;
|
|
56
115
|
}
|
|
57
116
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// doctor-rows.mjs — the rows `beatrina doctor` builds from what other doors already know: pure
|
|
2
|
+
// functions over `listSessions()`' rows, so the sentences are pinned without a session
|
|
3
|
+
// (docs/launch-menu-plan.md L5).
|
|
4
|
+
|
|
5
|
+
import { compareVersions } from "./update-check.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The Sessions row: what is running, on which version, and — when a session predates the installed
|
|
9
|
+
* files — the two doors out of it. Left-behind records are counted with the command that removes them.
|
|
10
|
+
*
|
|
11
|
+
* none
|
|
12
|
+
* 1 running — port 4848, 0.8.7 (installed 0.8.8: Session ▾ ▸ Restart into 0.8.8, or beatrina stop then beatrina)
|
|
13
|
+
* 2 running — port 4848, 0.8.8; port 4849, 0.8.8; 1 left-behind record (beatrina stop removes it)
|
|
14
|
+
*/
|
|
15
|
+
export function sessionsRow(rows, installed) {
|
|
16
|
+
const live = rows.filter((r) => r.live);
|
|
17
|
+
const stale = rows.filter((r) => !r.live);
|
|
18
|
+
if (!rows.length) return "none";
|
|
19
|
+
const parts = [];
|
|
20
|
+
if (live.length) {
|
|
21
|
+
const each = live.map((r) => {
|
|
22
|
+
const build = r.kernel_build || "?";
|
|
23
|
+
const behind = Boolean(r.kernel_build && installed && r.kernel_build !== installed && compareVersions(installed, r.kernel_build) > 0);
|
|
24
|
+
const stop = live.length > 1 ? `beatrina stop --port ${r.port}` : "beatrina stop";
|
|
25
|
+
return `port ${r.port}, ${build}${behind ? ` (installed ${installed}: Session ▾ ▸ Restart into ${installed}, or ${stop} then beatrina)` : ""}`;
|
|
26
|
+
});
|
|
27
|
+
parts.push(`${live.length} running — ${each.join("; ")}`);
|
|
28
|
+
} else parts.push("none running");
|
|
29
|
+
if (stale.length) parts.push(`${stale.length} left-behind record${stale.length === 1 ? "" : "s"} (beatrina stop removes ${stale.length === 1 ? "it" : "them"})`);
|
|
30
|
+
return parts.join("; ");
|
|
31
|
+
}
|
package/bin/failsafe.mjs
CHANGED
|
@@ -31,7 +31,8 @@ export function exitReason(code, signal, stderrTail) {
|
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Run a supervisor child, mirroring its output, until it announces its URL, exits, or misses the deadline.
|
|
34
|
-
*
|
|
34
|
+
* `url` is the announced one (the served origin), read from the line, for the caller that opens it.
|
|
35
|
+
* @returns {Promise<{announced: true, child: import("node:child_process").ChildProcess, url: string} | {announced: false, refused: boolean, reason: string, code: number|null}>}
|
|
35
36
|
*/
|
|
36
37
|
export function runUntilAnnounced({ cmd, args, env, deadlineMs, spawnImpl = spawn, out = process.stdout, err = process.stderr }) {
|
|
37
38
|
return new Promise((resolve) => {
|
|
@@ -42,7 +43,11 @@ export function runUntilAnnounced({ cmd, args, env, deadlineMs, spawnImpl = spaw
|
|
|
42
43
|
const settle = (value) => { if (!settled) { settled = true; clearTimeout(timer); resolve(value); } };
|
|
43
44
|
child.stdout.on("data", (d) => {
|
|
44
45
|
out.write(d);
|
|
45
|
-
if (!settled) {
|
|
46
|
+
if (!settled) {
|
|
47
|
+
stdout += d;
|
|
48
|
+
const m = /"url"\s*:\s*"([^"]*)"/.exec(stdout);
|
|
49
|
+
if (m) settle({ announced: true, child, url: m[1] });
|
|
50
|
+
}
|
|
46
51
|
});
|
|
47
52
|
child.stderr.on("data", (d) => { err.write(d); if (!settled) stderr = (stderr + d).slice(-8000); });
|
|
48
53
|
child.on("error", (e) => settle({ announced: false, refused: false, reason: `the Node host could not be started (${e.message})`, code: null }));
|
|
@@ -62,7 +67,7 @@ export function stageFailsafe(root, scratchParent = os.tmpdir()) {
|
|
|
62
67
|
const stage = fs.mkdtempSync(path.join(scratchParent, "beatrina-failsafe-"));
|
|
63
68
|
fs.cpSync(path.join(root, "kernel"), path.join(stage, "kernel"), { recursive: true });
|
|
64
69
|
for (const f of fs.readdirSync(failsafe)) fs.copyFileSync(path.join(failsafe, f), path.join(stage, "kernel", f));
|
|
65
|
-
for (const page of fs.readdirSync(root).filter((f) => /^
|
|
70
|
+
for (const page of fs.readdirSync(root).filter((f) => /^beatrina_V.*\.html$/.test(f))) fs.copyFileSync(path.join(root, page), path.join(stage, page));
|
|
66
71
|
return { stage, serve: path.join(stage, "kernel", "serve.R") };
|
|
67
72
|
}
|
|
68
73
|
|
package/bin/finder.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// finder.mjs — what Finder does with a .qmd, read from Launch Services (docs/launch-menu-plan.md F2).
|
|
2
|
+
//
|
|
3
|
+
// `lsquery.swift` beside this file is the read: extension → UTI → default handler → every handler,
|
|
4
|
+
// and bundle id → every registered path. It runs through the Swift toolchain when one answers
|
|
5
|
+
// `xcrun --find swift`; without one the rows say so rather than guess. `finderRows` is pure over the
|
|
6
|
+
// script's lines, so the sentences are pinned with canned answers (test/beatrina-doctor-ls.test.mjs).
|
|
7
|
+
// Nothing here changes a registration or a default: `beatrina shortcut` does, and reads this back.
|
|
8
|
+
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
export const QUERY = path.join(path.dirname(fileURLToPath(import.meta.url)), "lsquery.swift");
|
|
15
|
+
export const NO_TOOLCHAIN = "not checked (no Swift toolchain — install the Xcode Command Line Tools: xcode-select --install)";
|
|
16
|
+
|
|
17
|
+
/** The swift that runs the query, or "" (not macOS, or no toolchain). */
|
|
18
|
+
export function swiftToolchain({ platform = process.platform, spawnImpl = spawnSync } = {}) {
|
|
19
|
+
if (platform !== "darwin") return "";
|
|
20
|
+
const r = spawnImpl("xcrun", ["--find", "swift"], { encoding: "utf8", timeout: 20000 });
|
|
21
|
+
const found = r && r.status === 0 ? String(r.stdout).trim() : "";
|
|
22
|
+
return found && fs.existsSync(found) ? found : "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The query's lines, parsed.
|
|
27
|
+
* @returns {{ext: {ext: string, uti: string, handler: string, name: string, handlers: string[]}[], bundle: {id: string, paths: string[]}[]}}
|
|
28
|
+
*/
|
|
29
|
+
export function parseQuery(text) {
|
|
30
|
+
const out = { ext: [], bundle: [] };
|
|
31
|
+
for (const line of String(text).split("\n")) {
|
|
32
|
+
const f = line.split("\t");
|
|
33
|
+
if (f[0] === "ext" && f.length >= 6) out.ext.push({ ext: f[1], uti: f[2], handler: f[3], name: f[4], handlers: f[5] ? f[5].split(",").filter(Boolean) : [] });
|
|
34
|
+
else if (f[0] === "bundle" && f.length >= 2) out.bundle.push({ id: f[1], paths: f.slice(2).filter(Boolean) });
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Ask Launch Services. Null when there is no toolchain or the query did not answer (the caller says so). */
|
|
40
|
+
export function lsQuery({ exts = [], bundleIds = [], swift = swiftToolchain(), spawnImpl = spawnSync } = {}) {
|
|
41
|
+
if (!swift) return null;
|
|
42
|
+
const args = [QUERY, ...(exts.length ? ["ext", ...exts] : []), ...(bundleIds.length ? ["bundle", ...bundleIds] : [])];
|
|
43
|
+
const r = spawnImpl(swift, args, { encoding: "utf8", timeout: 90000 });
|
|
44
|
+
if (!r || r.status !== 0) return null;
|
|
45
|
+
return parseQuery(r.stdout);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The registered paths of a bundle id in an answer. */
|
|
49
|
+
export const registeredPaths = (answer, bundleId) => (answer && answer.bundle.find((b) => b.id === bundleId) || { paths: [] }).paths;
|
|
50
|
+
|
|
51
|
+
/** The extensions of `exts` whose default handler is `bundleId`, in an answer. */
|
|
52
|
+
export const defaultFor = (answer, bundleId, exts) => (answer ? exts.filter((e) => answer.ext.find((x) => x.ext === e)?.handler === bundleId) : []);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The doctor's Finder rows: one per extension (which app opens it, and the door when it is not this
|
|
56
|
+
* one), then the app's registered copies (one path, or the copies to repair with `beatrina shortcut`).
|
|
57
|
+
* @returns {[string, string][]}
|
|
58
|
+
*/
|
|
59
|
+
export function finderRows(answer, { bundleId, exts, appName = "Beatrina" }) {
|
|
60
|
+
if (!answer) return [...exts.map((e) => [`Finder opens .${e}`, NO_TOOLCHAIN]), [`${appName}.app`, NO_TOOLCHAIN]];
|
|
61
|
+
const rows = exts.map((e) => {
|
|
62
|
+
const row = answer.ext.find((x) => x.ext === e);
|
|
63
|
+
const label = `Finder opens .${e}`;
|
|
64
|
+
if (!row || !row.uti) return [label, `no type is registered for .${e} — run: beatrina shortcut`];
|
|
65
|
+
if (row.handler === bundleId) return [label, appName];
|
|
66
|
+
if (!row.handler) return [label, "no default app — run: beatrina shortcut --default"];
|
|
67
|
+
return [label, `${row.name || row.handler} — run: beatrina shortcut --default`];
|
|
68
|
+
});
|
|
69
|
+
const copies = registeredPaths(answer, bundleId);
|
|
70
|
+
rows.push([`${appName}.app`, !copies.length ? "not registered — run: beatrina shortcut"
|
|
71
|
+
: copies.length === 1 ? copies[0]
|
|
72
|
+
: `${copies.length} copies registered: ${copies.join(", ")} — run: beatrina shortcut`]);
|
|
73
|
+
return rows;
|
|
74
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// lsquery.swift — ask Launch Services, read-only (docs/launch-menu-plan.md F2).
|
|
2
|
+
//
|
|
3
|
+
// swift lsquery.swift ext qmd Rmd carmd bundle me.saqr.beatrina
|
|
4
|
+
//
|
|
5
|
+
// One tab-separated line per question:
|
|
6
|
+
// ext <ext> <uti> <default handler bundle id> <that app's name> <every handler, comma-separated>
|
|
7
|
+
// bundle <id> <registered path>...
|
|
8
|
+
//
|
|
9
|
+
// The API is the only reliable read: `defaults read …LSHandlers` lags and the JXA bridge hands back
|
|
10
|
+
// Refs it cannot unwrap (LEARNINGS 2026-09-16). Launch Services makes an app the default per UTI, not
|
|
11
|
+
// per extension, which is why the extension is resolved to its UTI first. Nothing here changes a
|
|
12
|
+
// registration or a default; `beatrina shortcut --default` does that and then reads this back.
|
|
13
|
+
|
|
14
|
+
import CoreServices
|
|
15
|
+
import Foundation
|
|
16
|
+
|
|
17
|
+
func utiFor(_ ext: String) -> String {
|
|
18
|
+
guard let u = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, ext as CFString, nil) else { return "" }
|
|
19
|
+
return u.takeRetainedValue() as String
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func defaultHandler(_ uti: String) -> String {
|
|
23
|
+
if uti.isEmpty { return "" }
|
|
24
|
+
guard let h = LSCopyDefaultRoleHandlerForContentType(uti as CFString, LSRolesMask.all) else { return "" }
|
|
25
|
+
return h.takeRetainedValue() as String
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func allHandlers(_ uti: String) -> [String] {
|
|
29
|
+
if uti.isEmpty { return [] }
|
|
30
|
+
guard let a = LSCopyAllRoleHandlersForContentType(uti as CFString, LSRolesMask.all) else { return [] }
|
|
31
|
+
return (a.takeRetainedValue() as? [String]) ?? []
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func bundlePaths(_ id: String) -> [String] {
|
|
35
|
+
guard let a = LSCopyApplicationURLsForBundleIdentifier(id as CFString, nil) else { return [] }
|
|
36
|
+
return ((a.takeRetainedValue() as? [URL]) ?? []).map { $0.path }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func appName(_ id: String) -> String {
|
|
40
|
+
guard let first = bundlePaths(id).first else { return "" }
|
|
41
|
+
let last = (first as NSString).lastPathComponent
|
|
42
|
+
return last.hasSuffix(".app") ? String(last.dropLast(4)) : last
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
var mode = ""
|
|
46
|
+
for a in CommandLine.arguments.dropFirst() {
|
|
47
|
+
if a == "ext" || a == "bundle" { mode = a; continue }
|
|
48
|
+
if mode == "ext" {
|
|
49
|
+
let uti = utiFor(a)
|
|
50
|
+
let handler = defaultHandler(uti)
|
|
51
|
+
print(["ext", a, uti, handler, appName(handler), allHandlers(uti).joined(separator: ",")].joined(separator: "\t"))
|
|
52
|
+
} else if mode == "bundle" {
|
|
53
|
+
print((["bundle", a] + bundlePaths(a)).joined(separator: "\t"))
|
|
54
|
+
}
|
|
55
|
+
}
|