beatrina 0.9.83 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"schema":1,"sources":"a33f44a76c0d333972320d15cc45ddf55b7726569601e3dfeaddc383cb0dfca1","artifact":"ce5aed2f8650b593385976e6f1131fbcff26602f7b5682c3a6314f979d6105d3"}
package/bin/beatrina.mjs CHANGED
@@ -111,7 +111,13 @@ if (args.command === "python") {
111
111
  process.exit(0);
112
112
  }
113
113
 
114
- const rscript = detectRscript(process.env.BEATRINA_RSCRIPT || "");
114
+ // R is OPTIONAL (plans/getting_rid_of_R.md): with none, Beatrina opens, edits, saves and recovers,
115
+ // and an R chunk says why it cannot run. BEATRINA_NO_R=1 is the host's own switch, honoured here so
116
+ // the command and the host agree on whether there is an R.
117
+ const { readEnv } = await import("../host/env-names.mjs");
118
+ const rscript = readEnv(process.env, "NO_R") === "1" ? "" : detectRscript(process.env.BEATRINA_RSCRIPT || "");
119
+ const R_ABSENT = "not found — optional: documents open, edit, save and recover; R chunks say why they cannot run. "
120
+ + "Install R from https://cran.r-project.org to run them.";
115
121
 
116
122
  if (args.command === "doctor") {
117
123
  const rows = [["Beatrina", `${pkg.version} (${root})`], ["Node.js", `${process.version} at ${process.execPath}`]];
@@ -126,7 +132,13 @@ if (args.command === "doctor") {
126
132
  : compareVersions(latest, pkg.version) > 0 ? `Beatrina ${latest} is available (this is ${pkg.version}) — run: beatrina upgrade`
127
133
  : `${pkg.version} is the newest`]);
128
134
  }
129
- if (!rscript) rows.push(["R", "NOT FOUND — install R from https://cran.r-project.org"]);
135
+ // The HOST is the floor: this Node, this package's host and page. R is one engine among three.
136
+ const [nodeMajor] = process.versions.node.split(".").map(Number);
137
+ const hostFile = path.join(root, "host", "main.mjs");
138
+ const hostProblem = nodeMajor < 22 ? `Node ${process.version} is too old — Beatrina needs Node 22 or newer`
139
+ : !fs.existsSync(hostFile) ? `this package has no host (${hostFile})` : "";
140
+ rows.splice(2, 0, ["Host", hostProblem ? `NOT USABLE — ${hostProblem}` : "ready — documents open, edit, save and recover with or without R"]);
141
+ if (!rscript) rows.push(["R (optional)", process.env.BEATRINA_NO_R === "1" ? "switched off (BEATRINA_NO_R=1)" : R_ABSENT]);
130
142
  else {
131
143
  rows.push(["R", rscript]);
132
144
  let missing = [];
@@ -147,7 +159,7 @@ if (args.command === "doctor") {
147
159
  rows.push(["Sessions", sessionsRow(await listSessions(), pkg.version)]);
148
160
  }
149
161
  if (process.platform === "darwin") {
150
- // What Finder does with a .qmd, .Rmd, .carmd, and which Beatrina.app copies Launch Services
162
+ // What Finder does with a .qmd, .Rmd, .carmd, and which Beatrina Note.app copies Launch Services
151
163
  // knows — asked of Launch Services itself (finder.mjs); "not checked" without a Swift toolchain.
152
164
  const { finderRows, lsQuery } = await import("./finder.mjs");
153
165
  const { BUNDLE_ID, DOC_TYPES } = await import("./shortcut.mjs");
@@ -159,12 +171,15 @@ if (args.command === "doctor") {
159
171
  rows.push(["node-pty (optional)", pty]);
160
172
  const width = Math.max(...rows.map((r) => r[0].length));
161
173
  for (const [k, v] of rows) console.log(`${k.padEnd(width)} ${v}`);
162
- process.exit(rscript ? 0 : 1);
174
+ // Healthy means the HOST is usable; no R is a healthy state (K13).
175
+ process.exit(hostProblem ? 1 : 0);
163
176
  }
164
177
 
165
178
  if (args.command === "check") {
166
179
  // The acceptance check (tools/beatrina/acceptance.mjs, staged as check/) against THIS package's host.
167
- if (!rscript) fail("the check needs R and none was found. Install R from https://cran.r-project.org.");
180
+ // With no R the check runs its no-R lane: the host, the document and the other engines are
181
+ // judged, and every R area is reported SKIP with the reason (tools/beatrina/acceptance.mjs).
182
+ if (!rscript) console.error("beatrina: no R here — running the no-R acceptance lane; R areas are skipped, not passed.");
168
183
  const os = await import("node:os");
169
184
  const { spawn } = await import("node:child_process");
170
185
  const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "beatrina-check-"));
@@ -172,7 +187,7 @@ if (args.command === "check") {
172
187
  const junit = process.env.BEATRINA_CHECK_JUNIT || path.join(scratch, "acceptance.xml");
173
188
  const code = await new Promise((resolve) => {
174
189
  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 } });
190
+ { stdio: "inherit", env: { ...process.env, ...(rscript ? { BEATRINA_RSCRIPT: rscript } : { BEATRINA_NO_R: "1" }) } });
176
191
  child.on("exit", (c) => resolve(c ?? 1));
177
192
  child.on("error", () => resolve(1));
178
193
  });
@@ -183,7 +198,7 @@ if (args.command === "check") {
183
198
  let build = { version: pkg.version, commit: "", dirty: false };
184
199
  try { build = { ...build, ...JSON.parse(fs.readFileSync(path.join(root, "build-info.json"), "utf8")) }; } catch { /* a package without one */ }
185
200
  try {
186
- const sent = await postJunitRun({ url, token, xml: fs.readFileSync(junit), body: runBody({ build, rVersion: rVersionOf(rscript), label: `beatrina check on ${process.platform}` }) });
201
+ const sent = await postJunitRun({ url, token, xml: fs.readFileSync(junit), body: runBody({ build, rVersion: rscript ? rVersionOf(rscript) : "", label: `beatrina check on ${process.platform}${rscript ? "" : " (no R)"}` }) });
187
202
  console.log(`Sent to Prova: run ${sent.runId} — pass ${sent.counts?.pass ?? "?"} fail ${sent.counts?.fail ?? "?"} skip ${sent.counts?.skip ?? "?"} → ${sent.url}`);
188
203
  } catch (e) { console.error(`beatrina: the results were not sent to Prova: ${e.message}`); }
189
204
  } else if (url || token) console.error("beatrina: to send results to Prova give both --prova <url> and --token <token>.");
@@ -191,6 +206,141 @@ if (args.command === "check") {
191
206
  process.exit(code);
192
207
  }
193
208
 
209
+ if (args.command === "app") {
210
+ // Beatrina (desktop/tauri): Beatrina in a window of its own. It ships INSIDE this package (app/),
211
+ // so no Rust and no download; npm does not quarantine what it installs, so an unsigned build opens
212
+ // without a Gatekeeper refusal. It records WHICH Node and WHICH Beatrina to start (appRecordFile),
213
+ // because an app started from Finder or a desktop menu has no shell PATH to find either with.
214
+ const os = await import("node:os");
215
+ const { spawn, spawnSync } = await import("node:child_process");
216
+ const home = os.homedir();
217
+ const record = (file) => {
218
+ fs.mkdirSync(path.dirname(file), { recursive: true });
219
+ fs.writeFileSync(file, `${JSON.stringify({ node: process.execPath, root, version: pkg.version })}\n`);
220
+ };
221
+ if (process.platform === "darwin") {
222
+ // The app's own bundle id (desktop/tauri tauri.conf.json) — not Beatrina Note's.
223
+ const APP_BUNDLE_ID = "me.saqr.beatrina.desktop";
224
+ const source = path.join(root, "app", "Beatrina.app");
225
+ if (!fs.existsSync(source)) fail("this Beatrina package does not carry Beatrina. `beatrina` opens the notebook in your browser instead.");
226
+ const target = path.join(home, "Applications", "Beatrina.app");
227
+ const support = path.join(home, "Library", "Application Support", "Beatrina");
228
+ fs.mkdirSync(path.dirname(target), { recursive: true });
229
+ fs.mkdirSync(support, { recursive: true });
230
+ // TWO apps carry the name on a Mac: Beatrina (this window) and Beatrina Note (the older door
231
+ // that opens the notebook in the browser — upgrade:local, `beatrina shortcut`). The door used to
232
+ // be ~/Applications/Beatrina.app; it is MOVED to "Beatrina Note.app", never overwritten or deleted.
233
+ // They are told apart by their executable, not their bundle id: this window's is "beatrina"
234
+ // (earlier builds "beatrina-app"); anything else there is a door — upgrade:local's ("Beatrina")
235
+ // or an older `beatrina shortcut` applet ("applet").
236
+ const executableOf = (app) => {
237
+ const r = spawnSync("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleExecutable", path.join(app, "Contents", "Info.plist")], { encoding: "utf8" });
238
+ return r.status === 0 ? r.stdout.trim() : "";
239
+ };
240
+ const keep = (app, name) => {
241
+ const kept = path.join(support, name);
242
+ fs.rmSync(kept, { recursive: true, force: true });
243
+ fs.renameSync(app, kept);
244
+ };
245
+ if (fs.existsSync(target) && !["beatrina", "beatrina-app"].includes(executableOf(target))) {
246
+ const web = path.join(home, "Applications", "Beatrina Note.app");
247
+ if (!fs.existsSync(web)) { fs.renameSync(target, web); console.log(`Moved the browser door to ${web}`); }
248
+ else keep(target, "Beatrina Note.duplicate.app"); // a second door: one is enough in ~/Applications
249
+ }
250
+ // This window under its first name (BeatrinaApp.app, 2026-09-23..24) goes: one generation is kept.
251
+ const legacy = path.join(home, "Applications", "BeatrinaApp.app");
252
+ if (fs.existsSync(legacy)) keep(legacy, "Beatrina.previous.app");
253
+ // The one it replaces is kept (one generation), outside ~/Applications so Finder shows one app.
254
+ if (fs.existsSync(target)) keep(target, "Beatrina.previous.app");
255
+ const copied = spawnSync("ditto", [source, target]);
256
+ if (copied.status !== 0) fail(`Beatrina could not be installed into ${target}.`);
257
+ record(path.join(support, "app.json"));
258
+ // Registered, and made the DEFAULT for the notebook documents: the app is the main way to
259
+ // open them (owner, 2026-09-24: "the app reigns overall"); Beatrina Note stays on right-click
260
+ // ▸ Open With. Running `beatrina app` is the choice, so it is not asked again. Read back from
261
+ // Launch Services when there is a Swift toolchain to ask with, never inferred.
262
+ // BEATRINA_NO_REGISTER=1 keeps a test's scratch copy out of Launch Services — its defaults are
263
+ // per user, not per HOME, and a test must never change the real ones.
264
+ console.log(`Installed Beatrina ${pkg.version} → ${target}`);
265
+ if (readEnv(process.env, "NO_REGISTER") !== "1") {
266
+ spawnSync("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", ["-f", target]);
267
+ const { DOC_TYPES, setDefaultHandler } = await import("./shortcut.mjs");
268
+ const set = setDefaultHandler(DOC_TYPES, APP_BUNDLE_ID);
269
+ const exts = DOC_TYPES.map((t) => t.ext);
270
+ const { defaultFor, lsQuery } = await import("./finder.mjs");
271
+ const after = lsQuery({ exts, bundleIds: [APP_BUNDLE_ID] });
272
+ const now = after ? defaultFor(after, APP_BUNDLE_ID, exts) : null;
273
+ if (set.failed) console.log(`Beatrina could not be made the default for every notebook type (${set.out}).`);
274
+ else if (!now) console.log(`Finder opens ${exts.map((e) => `.${e}`).join(", ")} with Beatrina (not read back: no Swift toolchain).`);
275
+ else console.log(`Finder opens ${now.map((e) => `.${e}`).join(", ")} with Beatrina${now.length < exts.length ? ` — not yet ${exts.filter((e) => !now.includes(e)).map((e) => `.${e}`).join(", ")}` : ""}.`);
276
+ }
277
+ if (args.open) spawnSync("open", [target]);
278
+ process.exit(0);
279
+ }
280
+ if (process.platform === "linux") {
281
+ // One binary per architecture (app/linux-<arch>/beatrina), a desktop entry so the
282
+ // applications menu and "Open With" know it, and the record under XDG config — the
283
+ // path desktop/tauri/src-tauri/src/main.rs reads (record_file).
284
+ const source = path.join(root, "app", `linux-${process.arch}`, "beatrina");
285
+ if (!fs.existsSync(source)) fail(`this Beatrina package carries no Beatrina for linux-${process.arch}. \`beatrina\` opens the notebook in your browser, and the browser can install it as an app.`);
286
+ const xdg = (name, fallback) => (process.env[name] && path.isAbsolute(process.env[name]) ? process.env[name] : path.join(home, fallback));
287
+ const binary = path.join(xdg("XDG_DATA_HOME", ".local/share"), "beatrina", "beatrina");
288
+ fs.mkdirSync(path.dirname(binary), { recursive: true });
289
+ const staged = `${binary}.new`;
290
+ fs.copyFileSync(source, staged);
291
+ fs.chmodSync(staged, 0o755);
292
+ if (fs.existsSync(binary)) fs.renameSync(binary, `${binary}.previous`);
293
+ fs.renameSync(staged, binary);
294
+ const icon = path.join(root, "app", "beatrina.png");
295
+ const iconTarget = path.join(path.dirname(binary), "beatrina.png");
296
+ if (fs.existsSync(icon)) fs.copyFileSync(icon, iconTarget);
297
+ const apps = path.join(xdg("XDG_DATA_HOME", ".local/share"), "applications");
298
+ fs.mkdirSync(apps, { recursive: true });
299
+ const entry = path.join(apps, "beatrina.desktop");
300
+ fs.writeFileSync(entry, [
301
+ "[Desktop Entry]", "Type=Application", "Name=Beatrina", "Comment=Beatrina in a window of its own",
302
+ `Exec="${binary}" %f`, ...(fs.existsSync(iconTarget) ? [`Icon=${iconTarget}`] : []), "Terminal=false",
303
+ "Categories=Office;Education;", "MimeType=text/markdown;text/x-quarto;text/x-r-markdown;", "",
304
+ ].join("\n"));
305
+ record(path.join(xdg("XDG_CONFIG_HOME", ".config"), "beatrina", "app.json"));
306
+ // The first build installed as "beatrina-app" (2026-09-24, one night): its files go, so the
307
+ // menu shows one Beatrina.
308
+ fs.rmSync(path.join(xdg("XDG_DATA_HOME", ".local/share"), "beatrina-app"), { recursive: true, force: true });
309
+ fs.rmSync(path.join(apps, "beatrina-app.desktop"), { force: true });
310
+ spawnSync("update-desktop-database", [apps], { stdio: "ignore" });
311
+ // The window's engine is the system's WebKitGTK; say so plainly if it is missing, rather
312
+ // than let the first launch die with a loader error nobody can read.
313
+ const ldd = spawnSync("ldd", [binary], { encoding: "utf8" });
314
+ const lacking = String(ldd.stdout || "").split("\n").filter((l) => /not found/.test(l)).map((l) => l.trim().split(" ")[0]);
315
+ console.log(`Installed Beatrina ${pkg.version} → ${binary}`);
316
+ if (lacking.length) {
317
+ console.log(`It needs libraries this system does not have: ${lacking.join(", ")}.`);
318
+ console.log("On Debian/Ubuntu: sudo apt install libwebkit2gtk-4.1-0 — then run `beatrina app` again.");
319
+ process.exit(1);
320
+ }
321
+ if (args.open) spawn(binary, [], { detached: true, stdio: "ignore" }).unref();
322
+ process.exit(0);
323
+ }
324
+ if (process.platform === "win32" && process.arch === "x64") {
325
+ // The package carries Tauri's NSIS installer (app/win-x64/Beatrina-setup.exe). Run silently
326
+ // (/S) it installs for this user only, with no administrator prompt, into
327
+ // %LOCALAPPDATA%\Beatrina, with a Start menu entry and the .qmd/.Rmd/.carmd associations;
328
+ // the record goes where the app reads it (%APPDATA%\Beatrina\app.json — main.rs record_file).
329
+ const setup = path.join(root, "app", "win-x64", "Beatrina-setup.exe");
330
+ if (!fs.existsSync(setup)) fail("this Beatrina package does not carry Beatrina for Windows. `beatrina` opens the notebook in your browser, and the browser can install it as an app.");
331
+ const localAppData = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
332
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
333
+ const installed = path.join(localAppData, "Beatrina", "beatrina.exe");
334
+ const ran = spawnSync(setup, ["/S"], { stdio: "ignore", windowsHide: true });
335
+ if (ran.status !== 0 || !fs.existsSync(installed)) fail(`the Beatrina installer did not finish (exit ${ran.status}); run it by hand: ${setup}`);
336
+ record(path.join(appData, "Beatrina", "app.json"));
337
+ console.log(`Installed Beatrina ${pkg.version} → ${installed}`);
338
+ if (args.open) spawn(installed, [], { detached: true, stdio: "ignore" }).unref();
339
+ process.exit(0);
340
+ }
341
+ fail("Beatrina is not available for this system yet. `beatrina` opens the notebook in your browser, and the browser can install it as an app (Install / Add to Dock).");
342
+ }
343
+
194
344
  if (args.command === "shortcut") {
195
345
  const { makeShortcut, removeMenu } = await import("./shortcut.mjs");
196
346
  try {
@@ -240,7 +390,6 @@ if (args.fromApp) {
240
390
  if (delivered === "refused") alert(`The running Beatrina session could not open ${file}.`);
241
391
  process.exit(0);
242
392
  }
243
- if (!rscript) { alert("Beatrina needs R. Install R from https://cran.r-project.org, then open Beatrina again."); process.exit(1); }
244
393
  // The session starts DETACHED (it must outlive this process), but this
245
394
  // process no longer exits the moment it has spawned it: it waits, bounded,
246
395
  // for the session's own announcement, and a child that dies first is SAID
@@ -277,9 +426,46 @@ if (args.fromApp) {
277
426
  process.exit(1);
278
427
  }
279
428
 
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.");
281
- let missing;
282
- try { missing = missingRPackages(rscript); } catch (e) { fail(e.message); }
429
+ // BEATRINA, THE APP, WHEN IT IS INSTALLED (owner, 2026-09-24: "the app reigns overall"). A person
430
+ // typing `beatrina [file]` gets the window, which starts and supervises its own session; the
431
+ // browser is `beatrina --browser`. Only when a PERSON asked — an interactive terminal — and never
432
+ // when the command line asks for the session itself (--port, --no-open): a script or a test that
433
+ // runs `beatrina` wants the session it asked for, not a window. BEATRINA_NO_APP=1 turns it off.
434
+ {
435
+ const os = await import("node:os");
436
+ const home = os.homedir();
437
+ const xdgData = process.env.XDG_DATA_HOME && path.isAbsolute(process.env.XDG_DATA_HOME) ? process.env.XDG_DATA_HOME : path.join(home, ".local", "share");
438
+ const installedApp = () => {
439
+ if (process.platform === "darwin") {
440
+ const app = path.join(home, "Applications", "Beatrina.app");
441
+ return fs.existsSync(path.join(app, "Contents", "MacOS", "beatrina")) ? app : "";
442
+ }
443
+ if (process.platform === "linux") { const bin = path.join(xdgData, "beatrina", "beatrina"); return fs.existsSync(bin) ? bin : ""; }
444
+ if (process.platform === "win32") {
445
+ const exe = path.join(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Beatrina", "beatrina.exe");
446
+ return fs.existsSync(exe) ? exe : "";
447
+ }
448
+ return "";
449
+ };
450
+ const person = Boolean(process.stdout.isTTY) && readEnv(process.env, "NO_APP") !== "1";
451
+ const app = person && args.open && !args.browser && !args.port ? installedApp() : "";
452
+ const file = args.file ? path.resolve(args.file) : "";
453
+ if (app && (!file || fs.existsSync(file))) {
454
+ const { spawn, spawnSync } = await import("node:child_process");
455
+ if (process.platform === "darwin") {
456
+ const r = spawnSync("open", ["-a", app, ...(file ? [file] : [])], { stdio: "inherit" });
457
+ if (r.status !== 0) fail(`Beatrina (the app) did not open — try: beatrina --browser${file ? ` "${args.file}"` : ""}`);
458
+ } else {
459
+ spawn(app, file ? [file] : [], { detached: true, stdio: "ignore" }).unref();
460
+ }
461
+ console.log(`Opened ${file ? path.basename(file) : "Beatrina"} in Beatrina. For the browser instead: beatrina --browser${file ? ` "${args.file}"` : ""}`);
462
+ process.exit(0);
463
+ }
464
+ }
465
+
466
+ if (!rscript) console.error(`beatrina: R ${process.env.BEATRINA_NO_R === "1" ? "is switched off (BEATRINA_NO_R=1): documents open, edit, save and recover; R chunks say why they cannot run." : R_ABSENT}`);
467
+ let missing = [];
468
+ if (rscript) { try { missing = missingRPackages(rscript); } catch (e) { fail(e.message); } }
283
469
  if (missing.length) {
284
470
  const yes = args.yes || process.env.BEATRIX_YES === "1" ? true
285
471
  : await ask(`R is missing ${missing.join(", ")}, which Beatrina needs. Install from CRAN now?`);
@@ -307,7 +493,7 @@ if (!process.env.BEATRIX_APP_ID) {
307
493
  const { listSessions } = await import("./sessions.mjs");
308
494
  await pruneKeptPages(await listSessions());
309
495
  }
310
- process.env.BEATRINA_RSCRIPT = rscript;
496
+ if (rscript) process.env.BEATRINA_RSCRIPT = rscript;
311
497
  if (args.port) process.env.BEATRINA_PORT = args.port;
312
498
  else process.env.BEATRINA_PORT ||= "4848";
313
499
  if (args.file) {
@@ -332,6 +518,8 @@ let started = forced
332
518
  if (started.announced) followChild(started.child);
333
519
  else {
334
520
  if (started.refused) process.exit(started.code ?? 2);
521
+ // The failsafe is the R supervisor, so with no R there is none: say why the host did not start.
522
+ if (!rscript) fail(`Beatrina could not start: ${started.reason}.`);
335
523
  // The failsafe: the R supervisor, with the reason said here and on the page.
336
524
  console.error(`beatrina: ${started.reason}. Starting the R supervisor instead (failsafe mode).`);
337
525
  let needed;
package/bin/cli.mjs CHANGED
@@ -8,7 +8,9 @@ export const REQUIRED_R_PACKAGES = Object.freeze(["jsonlite", "knitr", "evaluate
8
8
 
9
9
  export const USAGE = `beatrina — R, Python and JavaScript notebooks on your own computer
10
10
 
11
- beatrina [file] start a session and open the notebook (Ctrl+C stops it)
11
+ beatrina [file] open the notebook in Beatrina, the app, when it is installed (\`beatrina app\`);
12
+ otherwise start a session and open it in the browser (Ctrl+C stops it).
13
+ --browser: the browser even when the app is installed
12
14
  beatrina status list running sessions: port, process, uptime, document (--json for scripts and the menu)
13
15
  beatrina stop stop the running session (--port <n> for one of several, --all for every one)
14
16
  beatrina doctor show what Beatrina found: Node, R, R packages, Python, and whether npm has a newer Beatrina
@@ -17,6 +19,8 @@ export const USAGE = `beatrina — R, Python and JavaScript notebooks on your ow
17
19
  environment with ipykernel, pandas and matplotlib; --minimal installs ipykernel only)
18
20
  beatrina check run the acceptance check on this machine (a private session on a spare port);
19
21
  --prova <url> --token <t> (or PROVA_URL / PROVA_TOKEN) sends the results to Prova
22
+ beatrina app install Beatrina — Beatrina in a window of its own, no browser — and open it
23
+ (--no-open installs only). macOS, Linux and Windows (x64).
20
24
  beatrina shortcut add a Beatrina icon, and "Open With ▸ Beatrina" for .qmd and .Rmd files
21
25
  (--default also makes it the default app for them; safe to run again — it repairs
22
26
  a second registered copy). On macOS, --menu also installs the Beatrina menu-bar
@@ -41,18 +45,19 @@ export const USAGE = `beatrina — R, Python and JavaScript notebooks on your ow
41
45
  running session, or start one in the background (--new: start another regardless)
42
46
  --version, --help`;
43
47
 
44
- export const COMMANDS = Object.freeze(["doctor", "shortcut", "status", "stop", "check", "upgrade", "python", "knit"]);
48
+ export const COMMANDS = Object.freeze(["doctor", "shortcut", "status", "stop", "check", "upgrade", "python", "knit", "app"]);
45
49
  /** What `beatrina python` can be asked to do. */
46
50
  export const PYTHON_VERBS = Object.freeze(["setup"]);
47
51
 
48
52
  /** Parse the command line. */
49
53
  export function parseArgs(argv) {
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: "" };
54
+ const out = { command: "run", file: "", port: "", open: true, browser: false, 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: "" };
51
55
  for (let i = 0; i < argv.length; i += 1) {
52
56
  const a = argv[i];
53
57
  if (a === "--help" || a === "-h") out.help = true;
54
58
  else if (a === "--version" || a === "-v") out.version = true;
55
59
  else if (a === "--no-open") out.open = false;
60
+ else if (a === "--browser") out.browser = true;
56
61
  else if (a === "--no-run") out.run = false;
57
62
  else if (a === "--no-echo") out.echo = false;
58
63
  else if (a === "--out") out.out = argv[++i] || "";
package/bin/finder.mjs CHANGED
@@ -56,7 +56,7 @@ export const defaultFor = (answer, bundleId, exts) => (answer ? exts.filter((e)
56
56
  * one), then the app's registered copies (one path, or the copies to repair with `beatrina shortcut`).
57
57
  * @returns {[string, string][]}
58
58
  */
59
- export function finderRows(answer, { bundleId, exts, appName = "Beatrina" }) {
59
+ export function finderRows(answer, { bundleId, exts, appName = "Beatrina Note" }) {
60
60
  if (!answer) return [...exts.map((e) => [`Finder opens .${e}`, NO_TOOLCHAIN]), [`${appName}.app`, NO_TOOLCHAIN]];
61
61
  const rows = exts.map((e) => {
62
62
  const row = answer.ext.find((x) => x.ext === e);
package/bin/shortcut.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // After `npm install -g beatrina`, one `beatrina shortcut` makes Beatrina an
4
4
  // ordinary desktop app:
5
- // · macOS — ~/Applications/Beatrina.app (an AppleScript applet: no terminal
5
+ // · macOS — ~/Applications/Beatrina Note.app (an AppleScript applet: no terminal
6
6
  // window), declaring .qmd and .Rmd so Finder's "Open With" offers it, and
7
7
  // registered with Launch Services;
8
8
  // · Windows — a Start-menu shortcut and "Open with" entries for .qmd/.Rmd under
@@ -25,7 +25,7 @@ import { defaultFor, lsQuery, registeredPaths } from "./finder.mjs";
25
25
 
26
26
  export const BUNDLE_ID = "me.saqr.beatrina";
27
27
  // beatrina-names: keep-start — the other product's identities, reclaimed from, are named here on purpose.
28
- // Each type is EXPORTED by Beatrina.app as its own UTI (UTExportedTypeDeclarations) and named in its
28
+ // Each type is EXPORTED by Beatrina Note.app as its own UTI (UTExportedTypeDeclarations) and named in its
29
29
  // document types (LSItemContentTypes): Launch Services sets a default handler per UTI, and an app that
30
30
  // only lists an extension has no UTI of its own to be the default FOR — the first --default answered
31
31
  // -50 (paramErr) for exactly that reason, and .qmd stayed with the app that had declared it (CarmaR).
@@ -90,7 +90,7 @@ export function documentTypesXml() {
90
90
  + `<key>CFBundleTypeExtensions</key>${extensionsXml(t)}</dict>`).join("")}</array>`;
91
91
  }
92
92
 
93
- /** The UTIs Beatrina.app exports for those types (UTExportedTypeDeclarations), as plutil-insertable XML. */
93
+ /** The UTIs Beatrina Note.app exports for those types (UTExportedTypeDeclarations), as plutil-insertable XML. */
94
94
  export function exportedTypesXml() {
95
95
  return `<array>${DOC_TYPES.map((t) => `<dict><key>UTTypeIdentifier</key><string>${t.uti}</string>`
96
96
  + `<key>UTTypeDescription</key><string>${t.name}</string>`
@@ -143,7 +143,7 @@ export function unregisterCopy(stale, resolveTo, { lsregister = LSREGISTER } = {
143
143
  }
144
144
 
145
145
  /** Point every UTI a type can carry at `bundleId` (the JXA bridge is the only writer; 0 is success). */
146
- function setDefaultHandler(types, bundleId) {
146
+ export function setDefaultHandler(types, bundleId) {
147
147
  // Beatrina's own UTI for each type, CarmaR's legacy one, and whatever UTI Launch Services currently (beatrina-names: keep)
148
148
  // resolves the extension to (another app's declaration, or a dynamic one) — all pointed at Beatrina,
149
149
  // so a .qmd opens here whichever identity the system had attached to it.
@@ -157,7 +157,7 @@ function setDefaultHandler(types, bundleId) {
157
157
  }
158
158
 
159
159
  function macShortcut({ cli, dir, port, makeDefault, noOpen, bundleId }) {
160
- const app = path.join(dir || path.join(os.homedir(), "Applications"), "Beatrina.app");
160
+ const app = path.join(dir || path.join(os.homedir(), "Applications"), "Beatrina Note.app");
161
161
  const exts = DOC_TYPES.map((t) => t.ext);
162
162
  // Before anything changes: what Finder does today (finder.mjs, read-only) — which extensions
163
163
  // Beatrina is already the default for, so a rebuild puts that choice back rather than losing it,
@@ -166,30 +166,30 @@ function macShortcut({ cli, dir, port, makeDefault, noOpen, bundleId }) {
166
166
  const wasDefault = DOC_TYPES.filter((t) => defaultFor(before, bundleId, [t.ext]).length);
167
167
  fs.mkdirSync(path.dirname(app), { recursive: true });
168
168
  fs.rmSync(app, { recursive: true, force: true });
169
- const src = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "beatrina-app-")), "Beatrina.applescript");
169
+ const src = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "beatrina-app-")), "Beatrina Note.applescript");
170
170
  fs.writeFileSync(src, appleScriptSource(launchCommand({ cli, port, noOpen })));
171
171
  run("osacompile", ["-o", app, src]);
172
172
  const plist = path.join(app, "Contents", "Info.plist");
173
173
  run("plutil", ["-replace", "CFBundleIdentifier", "-string", bundleId, plist]);
174
- run("plutil", ["-replace", "CFBundleName", "-string", "Beatrina", plist]);
174
+ run("plutil", ["-replace", "CFBundleName", "-string", "Beatrina Note", plist]);
175
175
  run("plutil", ["-replace", "CFBundleDocumentTypes", "-xml", documentTypesXml(), plist]);
176
176
  run("plutil", ["-replace", "UTExportedTypeDeclarations", "-xml", exportedTypesXml(), plist]);
177
177
  run("plutil", ["-replace", "CFBundleURLTypes", "-xml", urlTypesXml(), plist]);
178
178
  run("codesign", ["--force", "--sign", "-", app]); // the plist changed after osacompile signed it
179
179
  const said = [];
180
180
  // Idempotent: every OTHER copy registered under this id is unregistered before this one is
181
- // registered. Launch Services picks between copies at its own discretion — an older Beatrina.app
181
+ // registered. Launch Services picks between copies at its own discretion — an older Beatrina Note.app
182
182
  // dragged somewhere, a scratch build a killed test left behind, a folder that no longer exists —
183
183
  // so which copy a double-click reaches is its choice until there is one.
184
184
  if (fs.existsSync(LSREGISTER)) {
185
185
  for (const stale of registeredPaths(before, bundleId).filter((p) => path.resolve(p) !== path.resolve(app))) {
186
186
  const done = unregisterCopy(stale, app);
187
- said.push(done.ok ? `Unregistered another copy of Beatrina.app at ${stale}${done.gone ? " (a folder that no longer exists)" : ""}.`
188
- : `Could not unregister the copy of Beatrina.app at ${stale} (${done.detail}); Finder may still pick it.`);
187
+ said.push(done.ok ? `Unregistered another copy of Beatrina Note.app at ${stale}${done.gone ? " (a folder that no longer exists)" : ""}.`
188
+ : `Could not unregister the copy of Beatrina Note.app at ${stale} (${done.detail}); Finder may still pick it.`);
189
189
  }
190
190
  run(LSREGISTER, ["-f", app]);
191
191
  }
192
- said.push(`Beatrina.app is in ${path.dirname(app)}. Double-click it, or right-click a .qmd and choose Open With ▸ Beatrina.`);
192
+ said.push(`Beatrina Note.app is in ${path.dirname(app)}. Double-click it, or right-click a .qmd and choose Open With ▸ Beatrina Note.`);
193
193
  // The default: every type with --default; otherwise only the types Beatrina was already the
194
194
  // default for (a rebuild is not a choice — "never by surprise"), re-asserted because a fresh
195
195
  // registration can lose it.
@@ -254,7 +254,7 @@ function linuxShortcut({ cli, port, makeDefault }) {
254
254
  // ── the menu (docs/launch-menu-plan.md M2) ──────────────────────────────────
255
255
  // `Beatrina Menu.app` ships in the npm package at menu/ (tools/app/build-helper.sh --product
256
256
  // beatrina; a binary and an Info.plist, nothing of its own inside). `beatrina shortcut --menu`
257
- // copies it beside Beatrina.app, writes the exact command line the applet bakes to
257
+ // copies it beside Beatrina Note.app, writes the exact command line the applet bakes to
258
258
  // Contents/Resources/beatrina-command (an absolute Node and CLI path: a menu-bar process has no
259
259
  // PATH), registers it and opens it; --login adds a LaunchAgent with RunAtLoad and NO KeepAlive (a
260
260
  // menu, not a daemon — helper-agent.sh's "additive and explicit" rule); --no-menu removes both.
@@ -323,7 +323,7 @@ function installMenu({ cli, dir, login, noOpen, menuSource }) {
323
323
 
324
324
  /**
325
325
  * `beatrina shortcut --no-menu`: the login agent, the running menu and the bundle, gone. Nothing
326
- * else changes — Beatrina.app, the file types and the sessions are untouched.
326
+ * else changes — Beatrina Note.app, the file types and the sessions are untouched.
327
327
  */
328
328
  export function removeMenu({ dir = "", platform = process.platform } = {}) {
329
329
  if (platform !== "darwin") return { said: ["The menu-bar item is macOS only; nothing to remove."] };
@@ -339,13 +339,13 @@ export function removeMenu({ dir = "", platform = process.platform } = {}) {
339
339
  said.push(`Removed ${menuApp}.`);
340
340
  }
341
341
  if (!said.length) said.push("No Beatrina menu was installed; nothing to remove.");
342
- said.push("Beatrina.app, the file types and any running session are as they were.");
342
+ said.push("Beatrina Note.app, the file types and any running session are as they were.");
343
343
  return { said };
344
344
  }
345
345
 
346
346
  /**
347
347
  * Make the icon (and, with makeDefault, the default .qmd app; with menu, the menu-bar item).
348
- * BEATRINA_BUNDLE_ID substitutes the bundle id (the suites build a scratch Beatrina.app under a
348
+ * BEATRINA_BUNDLE_ID substitutes the bundle id (the suites build a scratch Beatrina Note.app under a
349
349
  * scratch id, so the one in ~/Applications is never a second copy of theirs).
350
350
  */
351
351
  export function makeShortcut({ cli, dir = "", port = "", makeDefault = false, noOpen = false, platform = process.platform,
package/build-info.json CHANGED
@@ -1 +1 @@
1
- {"version":"0.9.83","commit":"9d7ed5ae63cb367b7fd428d71041c44cf91fdbb5","dirty":false}
1
+ {"version":"1.0.0","commit":"a6fc7c8783793a5ab6e7627c077bc10843f0e5a9","dirty":false}
@@ -0,0 +1,35 @@
1
+ // engine-node-job.mjs — a job child written in Node, on the same framed wire as
2
+ // spike/job-run.R (plans/getting_rid_of_R.md Stage 3).
3
+ //
4
+ // host/planes/jobs.mjs drives a job through a StdioEngine: sentinel-framed
5
+ // control frames on stdout, everything else streamed as output, the process
6
+ // group signalled on Stop. That machinery does not care which language the
7
+ // child is written in, so a job that needs no R — rendering a prose document
8
+ // through Quarto — runs host/jobs/render-child.mjs under the Node that already
9
+ // runs the host, with nothing else changed.
10
+
11
+ import fs from "node:fs";
12
+ import { StdioEngine } from "./engine-stdio.mjs";
13
+ import { mirrorEnv } from "./env-names.mjs";
14
+
15
+ export class NodeJobEngine extends StdioEngine {
16
+ /**
17
+ * @param {Object} opts
18
+ * @param {string} opts.workerPath the child script
19
+ * @param {Record<string,string>} [opts.env]
20
+ * @param {string} [opts.cwd]
21
+ */
22
+ constructor({ workerPath, env = {}, cwd, platform = process.platform } = {}) {
23
+ super({ language: "node", workerPath, env, cwd, platform });
24
+ if (!workerPath || !fs.existsSync(workerPath)) throw new Error(`NodeJobEngine: no child at ${workerPath}`);
25
+ this.mode = "batch";
26
+ }
27
+
28
+ get label() { return "Job"; }
29
+ get canInput() { return false; }
30
+ get canDebug() { return false; }
31
+
32
+ spawnPlan() {
33
+ return { bin: process.execPath, args: [this.workerPath], env: mirrorEnv({ BEATRINA_WORKER_MODE: "batch" }) };
34
+ }
35
+ }