beatrina 0.9.83 → 1.0.1
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/app/Beatrina.app/Contents/Info.plist +86 -0
- package/app/Beatrina.app/Contents/MacOS/beatrina +0 -0
- package/app/Beatrina.app/Contents/Resources/icon.icns +0 -0
- package/app/beatrina.png +0 -0
- package/app/linux-arm64/beatrina +0 -0
- package/app/win-x64/Beatrina-setup.exe +0 -0
- package/beatrina_V1.0.1.html +1906 -0
- package/beatrina_V1.0.1.html.inputs.json +1 -0
- package/bin/beatrina.mjs +200 -12
- package/bin/cli.mjs +8 -3
- package/bin/finder.mjs +1 -1
- package/bin/shortcut.mjs +15 -15
- package/build-info.json +1 -1
- package/host/engine-node-job.mjs +35 -0
- package/host/engine-pool.mjs +153 -26
- package/host/engine-r.mjs +8 -1
- package/host/jobs/document-child.mjs +271 -0
- package/host/latex-compile.mjs +673 -0
- package/host/main.mjs +58 -18
- package/host/planes/analyze.mjs +18 -2
- package/host/planes/jobs.mjs +38 -5
- package/host/planes/sessions.mjs +5 -1
- package/host/pwa/icon-192.png +0 -0
- package/host/pwa/icon-512.png +0 -0
- package/host/quarto.mjs +118 -0
- package/host/server.mjs +83 -2
- package/host/worker-plane.mjs +7 -1
- package/kernel/check/acceptance.mjs +43 -12
- package/kernel/check/knit-file.mjs +55 -19
- package/kernel/check/session.mjs +19 -4
- package/kernel/job-run.R +4 -1
- package/kernel/kernel-version +1 -1
- package/kernel/worker-boot.R +2 -1
- package/kernel/worker.R +2 -1
- package/lib/engine-labels.js +7 -1
- package/menu/Beatrina Menu.app/Contents/Info.plist +2 -2
- package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
- package/package.json +4 -3
- package/beatrina_V0.9.83.html +0 -1903
- package/beatrina_V0.9.83.html.inputs.json +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"schema":1,"sources":"bb6f92def208844cdcdae81c15aa6b467805474f5af7643329a02082f7832f82","artifact":"09e3f48d132de6ca4045edb229ca8e00f44bda89ff95dcab69ed5b7790da6092"}
|
package/bin/beatrina.mjs
CHANGED
|
@@ -111,7 +111,13 @@ if (args.command === "python") {
|
|
|
111
111
|
process.exit(0);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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]
|
|
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.
|
|
1
|
+
{"version":"1.0.1","commit":"f97df507a6083f9b9c2ba603d7c7055f67b29057","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
|
+
}
|