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.
Files changed (111) hide show
  1. package/NOTICES +1 -1
  2. package/README.md +6 -0
  3. package/beatrina_V0.9.43.html +1885 -0
  4. package/beatrina_V0.9.43.html.inputs.json +1 -0
  5. package/bin/beatrina.mjs +152 -24
  6. package/bin/browser.mjs +31 -0
  7. package/bin/cli.mjs +64 -5
  8. package/bin/doctor-rows.mjs +31 -0
  9. package/bin/failsafe.mjs +8 -3
  10. package/bin/finder.mjs +74 -0
  11. package/bin/lsquery.swift +55 -0
  12. package/bin/pages.mjs +81 -0
  13. package/bin/python-setup.mjs +207 -0
  14. package/bin/runtime-dirs.mjs +27 -0
  15. package/bin/sessions.mjs +30 -10
  16. package/bin/shortcut.mjs +250 -42
  17. package/bin/update-check.mjs +2 -2
  18. package/build-info.json +1 -1
  19. package/engines/js/worker.mjs +6 -3
  20. package/engines/python/adapter.py +14 -13
  21. package/engines/python/analyze.py +4 -2
  22. package/engines/python/bootstrap.py +32 -31
  23. package/engines/python/debugger.py +10 -6
  24. package/engines/python/engine.json +1 -1
  25. package/engines/python/handoff.py +4 -2
  26. package/engines/python/worker.py +39 -10
  27. package/engines/r/engine.json +1 -1
  28. package/engines/r/handoff.R +4 -3
  29. package/failsafe/ai-policy.R +16 -16
  30. package/failsafe/ai-store.R +6 -6
  31. package/failsafe/cite.R +11 -11
  32. package/failsafe/journal.R +11 -11
  33. package/failsafe/plugins.R +27 -27
  34. package/failsafe/serve.R +241 -241
  35. package/host/ai-authority.mjs +55 -0
  36. package/host/ai-policy.mjs +48 -26
  37. package/host/bundle.mjs +194 -0
  38. package/host/deployment.mjs +25 -18
  39. package/host/engine-js.mjs +12 -17
  40. package/host/engine-pool.mjs +48 -6
  41. package/host/engine-python.mjs +80 -31
  42. package/host/engine-r.mjs +20 -21
  43. package/host/engine-stdio.mjs +35 -10
  44. package/host/env-names.mjs +148 -0
  45. package/host/gateway-token.mjs +51 -0
  46. package/host/journal-store.mjs +10 -9
  47. package/host/main.mjs +193 -77
  48. package/host/payload.mjs +131 -0
  49. package/host/planes/README.md +1 -1
  50. package/host/planes/ai-store.mjs +6 -5
  51. package/host/planes/ai.mjs +49 -29
  52. package/host/planes/analyze.mjs +6 -5
  53. package/host/planes/bundle.mjs +344 -0
  54. package/host/planes/choose.mjs +404 -0
  55. package/host/planes/cite.mjs +18 -17
  56. package/host/planes/files.mjs +0 -0
  57. package/host/planes/jobs.mjs +43 -20
  58. package/host/planes/latex.mjs +15 -5
  59. package/host/planes/mcp.mjs +80 -91
  60. package/host/planes/pair.mjs +24 -24
  61. package/host/planes/pipe-term.mjs +2 -2
  62. package/host/planes/plugins.mjs +1 -1
  63. package/host/planes/recent-documents.mjs +170 -0
  64. package/host/planes/sessions.mjs +226 -82
  65. package/host/planes/settings.mjs +4 -4
  66. package/host/planes/terminal.mjs +13 -11
  67. package/host/planes/test-file.mjs +2 -2
  68. package/host/planes/update.mjs +6 -13
  69. package/host/plugin-store.mjs +35 -52
  70. package/host/recent-documents.mjs +124 -0
  71. package/host/runtime-dir.mjs +47 -0
  72. package/host/server.mjs +75 -18
  73. package/host/session-keep.mjs +1 -1
  74. package/host/settings.mjs +85 -37
  75. package/host/update-record.mjs +3 -2
  76. package/host/user-dirs.mjs +60 -18
  77. package/host/which.mjs +39 -0
  78. package/host/windows-runtime.mjs +6 -3
  79. package/host/worker-plane.mjs +34 -6
  80. package/host/ws.mjs +9 -2
  81. package/host/zip.mjs +237 -0
  82. package/kernel/analyze.R +1 -1
  83. package/{check → kernel/check}/acceptance.mjs +1 -1
  84. package/kernel/check/knit-file.mjs +19639 -0
  85. package/{check → kernel/check}/session.mjs +49 -8
  86. package/kernel/deployment.R +20 -20
  87. package/kernel/examples/NOTICE.md +1 -1
  88. package/kernel/fileio.R +6 -6
  89. package/kernel/index.html +2 -2
  90. package/kernel/job-run.R +83 -22
  91. package/kernel/jobs.R +28 -12
  92. package/kernel/kernel-version +1 -1
  93. package/kernel/kernel.R +18 -18
  94. package/kernel/knitr-run.R +50 -9
  95. package/kernel/latex.R +128 -30
  96. package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
  97. package/kernel/notebook-page.R +7 -7
  98. package/kernel/project.R +10 -10
  99. package/kernel/settings.R +105 -56
  100. package/kernel/sniff.R +4 -4
  101. package/kernel/typst.R +121 -0
  102. package/kernel/worker.R +522 -119
  103. package/kernel/workspace-keep.R +3 -3
  104. package/lib/agent-authoring-contract.js +28 -19
  105. package/lib/cell-kinds.js +3 -3
  106. package/lib/engine-labels.js +4 -4
  107. package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
  108. package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
  109. package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
  110. package/package.json +4 -3
  111. 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
- for (const line of formatSessions(rows)) console.log(line);
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
- const rscript = detectRscript(process.env.CARMAR_RSCRIPT || "");
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}` : py.detail]);
81
- let pty = "not installed — the terminal pane runs line programs only";
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, CARMAR_RSCRIPT: rscript } });
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
- const made = makeShortcut({ cli: fileURLToPath(import.meta.url), dir: args.dir, port: args.port, makeDefault: args.makeDefault, noOpen: !args.open });
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
- if (process.platform === "darwin") spawnSync("osascript", ["-e", `display alert "Beatrina" message ${JSON.stringify(msg)}`]);
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
- const live = await liveSession({ app: process.env.BEATRIX_APP_ID || APP_ID, port: args.port });
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 (test/beatrina-sessions.test.mjs).
154
- const opener = process.env.BEATRINA_OPENER ? [process.env.BEATRINA_OPENER, [live.url]]
155
- : process.platform === "darwin" ? ["open", [live.url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", live.url]] : ["xdg-open", [live.url]];
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"])], { detached: true, stdio: "ignore", windowsHide: true });
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
- process.exit(0);
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 CARMAR_RSCRIPT to your Rscript.");
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
- process.env.CARMAR_RSCRIPT = rscript;
187
- if (args.port) process.env.CARMAR_PORT = args.port;
188
- else process.env.CARMAR_PORT ||= "4848";
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.CARMAR_OPEN_FILE = file;
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
- started = await runUntilAnnounced({ cmd: rscript, args: [staged.serve, ...(open ? ["--open"] : [])],
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
  }
@@ -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.file && (out.command === "status" || out.command === "stop" || out.command === "check" || out.command === "upgrade")) throw new Error(`beatrina ${out.command} takes no file\n\n${USAGE}`);
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
- * @returns {Promise<{announced: true, child: import("node:child_process").ChildProcess} | {announced: false, refused: boolean, reason: string, code: number|null}>}
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) { stdout += d; if (/"url"\s*:/.test(stdout)) settle({ announced: true, child }); }
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) => /^carmar_V.*\.html$/.test(f))) fs.copyFileSync(path.join(root, page), path.join(stage, page));
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
+ }