beatrina 0.8.6

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 (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. package/package.json +32 -0
@@ -0,0 +1,74 @@
1
+ // failsafe.mjs — start the Node host, and the R supervisor when the host cannot start
2
+ // (docs/beatrina-next-plan.md Stage 4, kept by decision 2026-09-15).
3
+ //
4
+ // The host runs as a CHILD of the command so its failure can be seen: a session is up when the host
5
+ // prints its `{"url": …}` line. Three things count as "the host could not start" and bring up the
6
+ // failsafe: it exits before announcing, it does not announce within the deadline (it is then ended
7
+ // by its PID), or BEATRINA_FORCE_FAILSAFE=1 says to skip it. A host that REFUSES to start — a port
8
+ // that must not move, a policy that forbids something — is not a failure of the host: the R
9
+ // supervisor would refuse the same thing, so that refusal is reported and nothing else starts.
10
+ //
11
+ // The failsafe is the retired R supervisor (retired/r-supervisor/spike/serve.R and the R modules
12
+ // only it loads), staged in the package as failsafe/ and assembled at start into a scratch folder
13
+ // beside a copy of kernel/ and the page, the layout serve.R finds its files in. It is told why it
14
+ // is running (BEATRINA_FAILSAFE_REASON), which its /health repeats and the page shows.
15
+
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { spawn } from "node:child_process";
20
+
21
+ export const FAILSAFE_R_PACKAGES = Object.freeze(["httpuv", "processx"]);
22
+ const REFUSAL = /refuses to start/;
23
+
24
+ /** Why a host that exited before announcing did so, from the tail of what it printed. */
25
+ export function exitReason(code, signal, stderrTail) {
26
+ const lines = String(stderrTail || "").split("\n").map((l) => l.trim()).filter(Boolean);
27
+ const said = lines.find((l) => /failed to start:/.test(l)) || lines.find((l) => /^[A-Za-z]*Error\b.*:/.test(l)) || lines.at(-1) || "";
28
+ const how = signal ? `was ended by ${signal}` : `exited with status ${code}`;
29
+ return `the Node host ${how} before it started${said ? ` (${said.replace(/^Beatrina failed to start:\s*/, "").slice(0, 200)})` : ""}`;
30
+ }
31
+
32
+ /**
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}>}
35
+ */
36
+ export function runUntilAnnounced({ cmd, args, env, deadlineMs, spawnImpl = spawn, out = process.stdout, err = process.stderr }) {
37
+ return new Promise((resolve) => {
38
+ const child = spawnImpl(cmd, args, { env, stdio: ["inherit", "pipe", "pipe"], windowsHide: true });
39
+ let stdout = "";
40
+ let stderr = "";
41
+ let settled = false;
42
+ const settle = (value) => { if (!settled) { settled = true; clearTimeout(timer); resolve(value); } };
43
+ child.stdout.on("data", (d) => {
44
+ out.write(d);
45
+ if (!settled) { stdout += d; if (/"url"\s*:/.test(stdout)) settle({ announced: true, child }); }
46
+ });
47
+ child.stderr.on("data", (d) => { err.write(d); if (!settled) stderr = (stderr + d).slice(-8000); });
48
+ child.on("error", (e) => settle({ announced: false, refused: false, reason: `the Node host could not be started (${e.message})`, code: null }));
49
+ child.on("exit", (code, signal) => settle({ announced: false, refused: REFUSAL.test(stderr), reason: exitReason(code, signal, stderr), code }));
50
+ const timer = setTimeout(() => {
51
+ settle({ announced: false, refused: false, reason: `the Node host did not start within ${Math.round(deadlineMs / 1000)} seconds`, code: null });
52
+ try { child.kill("SIGTERM"); } catch { /* gone */ }
53
+ setTimeout(() => { try { if (child.exitCode == null) child.kill("SIGKILL"); } catch { /* gone */ } }, 3000).unref();
54
+ }, deadlineMs);
55
+ });
56
+ }
57
+
58
+ /** Assemble the R supervisor's folder: <scratch>/kernel = kernel/ + failsafe/, the page beside it. */
59
+ export function stageFailsafe(root, scratchParent = os.tmpdir()) {
60
+ const failsafe = path.join(root, "failsafe");
61
+ if (!fs.existsSync(path.join(failsafe, "serve.R"))) throw new Error(`this package carries no failsafe (${failsafe}/serve.R is missing)`);
62
+ const stage = fs.mkdtempSync(path.join(scratchParent, "beatrina-failsafe-"));
63
+ fs.cpSync(path.join(root, "kernel"), path.join(stage, "kernel"), { recursive: true });
64
+ 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));
66
+ return { stage, serve: path.join(stage, "kernel", "serve.R") };
67
+ }
68
+
69
+ /** Relay the stop signals a terminal or `kill` sends to the supervisor child, and exit with its status. */
70
+ export function followChild(child, { onExit = (code) => process.exit(code) } = {}) {
71
+ const relay = (sig) => () => { try { child.kill(sig); } catch { /* gone */ } };
72
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, relay(sig));
73
+ child.on("exit", (code, signal) => onExit(code ?? (signal ? 1 : 0)));
74
+ }
@@ -0,0 +1,45 @@
1
+ // identity.mjs — whose folders a Beatrina session keeps its state in, and the one-time move from
2
+ // the name it had before (Beatrix).
3
+ //
4
+ // The host keys R_user_dir(<app>, data|config|cache) on BEATRIX_APP_ID (host/user-dirs.mjs).
5
+ // Beatrina is "beatrina". Anything a Beatrix build left under R/beatrix is moved across ONCE, the
6
+ // first time a session starts: a folder is renamed only when the new one does not exist yet, so a
7
+ // second start moves nothing and existing Beatrina data is never overwritten. When both exist the
8
+ // old one is left where it is and said so — merging two histories by guess is not a move.
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+
13
+ export const APP_ID = "beatrina";
14
+ export const LEGACY_APP_ID = "beatrix";
15
+ export const WHICH = Object.freeze(["data", "config", "cache"]);
16
+
17
+ /**
18
+ * Move R/beatrix → R/beatrina for each of data, config and cache. `dirOf` is host/user-dirs.mjs
19
+ * rUserDir (passed in: this module is imported from the checkout too, where ../host does not exist).
20
+ * @returns {{which: string, from: string, to: string, outcome: "moved"|"absent"|"kept-both"|"failed", detail?: string}[]}
21
+ */
22
+ export function migrateAppData({ from = LEGACY_APP_ID, to = APP_ID, dirOf }) {
23
+ return WHICH.map((which) => {
24
+ const src = dirOf(from, which);
25
+ const dst = dirOf(to, which);
26
+ const row = { which, from: src, to: dst };
27
+ if (!fs.existsSync(src)) return { ...row, outcome: "absent" };
28
+ if (fs.existsSync(dst)) return { ...row, outcome: "kept-both" };
29
+ try {
30
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
31
+ fs.renameSync(src, dst);
32
+ return { ...row, outcome: "moved" };
33
+ } catch (e) {
34
+ return { ...row, outcome: "failed", detail: e.message };
35
+ }
36
+ });
37
+ }
38
+
39
+ /** What to say about a migration, one line per folder that did something worth saying. */
40
+ export function describeMigration(rows) {
41
+ return rows.flatMap((r) => r.outcome === "moved" ? [`Moved your Beatrix ${r.which} to ${r.to}.`]
42
+ : r.outcome === "kept-both" ? [`Both ${r.from} and ${r.to} exist; Beatrina uses ${r.to} and left the old folder untouched.`]
43
+ : r.outcome === "failed" ? [`Could not move ${r.from} to ${r.to} (${r.detail}); Beatrina starts with ${r.to} and the old folder is untouched.`]
44
+ : []);
45
+ }
@@ -0,0 +1,51 @@
1
+ // prova-post.mjs — send one JUnit report to Prova as a finished run (the sequence of
2
+ // prova/reporters/post-junit.mjs, carried inside the package so `beatrina check` needs nothing else).
3
+ //
4
+ // POST /runs → POST /runs/:id/import/junit → POST /runs/:id/finish
5
+ //
6
+ // A failed import marks the run abandoned, so Prova never shows a half-filled run as finished.
7
+
8
+ import os from "node:os";
9
+ import { spawnSync } from "node:child_process";
10
+
11
+ export const OS_NAMES = Object.freeze({ darwin: "macos", win32: "windows", linux: "linux" });
12
+
13
+ /** R's version, asked of R. null when R cannot say. */
14
+ export function rVersionOf(rscript) {
15
+ const out = spawnSync(rscript, ["--version"], { encoding: "utf8", timeout: 15000 });
16
+ return /version (\d+\.\d+\.\d+)/.exec(`${out.stdout || ""}${out.stderr || ""}`)?.[1] ?? null;
17
+ }
18
+
19
+ /** The run body for one check on this machine. */
20
+ export function runBody({ product = "beatrina", build, rVersion, pythonVersion = null, label, platform = process.platform }) {
21
+ const attributes = { node_version: process.version, ...(rVersion ? { r_version: rVersion } : {}), ...(pythonVersion ? { python_version: pythonVersion } : {}) };
22
+ return {
23
+ product, build, producer: "runner", label, host: os.hostname(),
24
+ environment: { os: OS_NAMES[platform] ?? platform, os_version: os.release(), arch: process.arch, browser: "", attributes },
25
+ };
26
+ }
27
+
28
+ export async function postJunitRun({ url, token, body, xml, fetchImpl = fetch }) {
29
+ const base = `${String(url).replace(/\/+$/, "").replace(/\/api\/v1$/, "")}/api/v1`;
30
+ const call = async (method, p, { json, raw, type } = {}) => {
31
+ const headers = { Authorization: `Bearer ${token}` };
32
+ let payload;
33
+ if (raw !== undefined) { payload = raw; headers["Content-Type"] = type; } else if (json !== undefined) { payload = JSON.stringify(json); headers["Content-Type"] = "application/json"; }
34
+ let res;
35
+ try { res = await fetchImpl(`${base}${p}`, { method, headers, body: payload, signal: AbortSignal.timeout(30000) }); } catch (e) { throw new Error(`${method} ${p} could not reach ${base} (${e.cause?.code || e.message})`); }
36
+ const text = await res.text();
37
+ let parsed = null;
38
+ try { parsed = JSON.parse(text); } catch { /* not JSON */ }
39
+ if (!res.ok) throw new Error(`${method} ${p} → HTTP ${res.status} ${parsed?.error || text.slice(0, 200)}`);
40
+ return parsed;
41
+ };
42
+ const run = await call("POST", "/runs", { json: body });
43
+ try {
44
+ await call("POST", `/runs/${run.run_id}/import/junit`, { raw: xml, type: "application/xml" });
45
+ } catch (e) {
46
+ await call("POST", `/runs/${run.run_id}/finish`, { json: { state: "abandoned" } }).catch(() => {});
47
+ throw e;
48
+ }
49
+ const finished = await call("POST", `/runs/${run.run_id}/finish`, { json: { state: "finished" } });
50
+ return { runId: run.run_id, counts: finished?.counts, url: `${base.replace(/\/api\/v1$/, "")}/runs/${run.run_id}` };
51
+ }
@@ -0,0 +1,95 @@
1
+ // sessions.mjs — `beatrina status` and `beatrina stop`.
2
+ //
3
+ // A running session writes ~/.carmar/run/kernel-<port>.json (host/main.mjs, 0600) and removes it on a
4
+ // clean exit. A record whose /health does not answer is one a session left behind (a crash, a closed
5
+ // laptop). Status lists both kinds and says which is which; stop asks a live session to shut down
6
+ // through POST /shutdown (the same door Session ▸ Quit uses; loopback, no Origin = this user), waits
7
+ // for its port to go quiet, and only then — if it is still up — ends its process by the PID its own
8
+ // /health reports. A left-behind record is removed. Nothing is ever killed by name or pattern.
9
+
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { APP_ID, LEGACY_APP_ID } from "./identity.mjs";
14
+
15
+ // Where host/main.mjs writes its records; CARMAR_RUNTIME_DIR relocates it (tests), as it does for the host.
16
+ export const runtimeDir = (env = process.env) => env.CARMAR_RUNTIME_DIR || path.join(os.homedir(), ".carmar", "run");
17
+ const APPS = new Set([APP_ID, LEGACY_APP_ID]);
18
+
19
+ async function health(port, timeoutMs = 1500) {
20
+ try {
21
+ const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(timeoutMs) });
22
+ return res.ok ? await res.json() : null;
23
+ } catch { return null; }
24
+ }
25
+
26
+ // A port can be reused by another session after a crash: live means THIS session answers. The
27
+ // record's `started` and /health's `started` are the same boot instant (host/main.mjs bootAt).
28
+ export const sameSession = (h, rec) => Boolean(h && h.host === rec.host
29
+ && Number.isFinite(Number(h.started)) && Math.abs(Number(h.started) * 1000 - Date.parse(rec.started)) < 1000);
30
+
31
+ /** Every Beatrina session this user has a record of, live or left behind, by port. */
32
+ export async function listSessions({ dir = runtimeDir(), apps = APPS } = {}) {
33
+ let names = [];
34
+ try { names = fs.readdirSync(dir).filter((n) => /^kernel-\d+\.json$/.test(n)); } catch { return []; }
35
+ const rows = await Promise.all(names.map(async (name) => {
36
+ const record = path.join(dir, name);
37
+ let rec;
38
+ try { rec = JSON.parse(fs.readFileSync(record, "utf8")); } catch { return null; }
39
+ if (!apps.has(rec.app)) return null;
40
+ const h = await health(rec.port);
41
+ const live = sameSession(h, rec);
42
+ return { port: Number(rec.port), pid: rec.pid, started: rec.started || "", document: rec.document || "", url: rec.url || "", file: rec.file || "", app: rec.app, live, record };
43
+ }));
44
+ return rows.filter(Boolean).sort((a, b) => a.port - b.port);
45
+ }
46
+
47
+ /** The status table as lines. */
48
+ export function formatSessions(rows, now = Date.now()) {
49
+ if (!rows.length) return ["No Beatrina session is running."];
50
+ const age = (iso) => {
51
+ const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
52
+ return Number.isFinite(s) ? (s < 90 ? `${s}s` : s < 5400 ? `${Math.round(s / 60)}m` : `${(s / 3600).toFixed(1)}h`) : "?";
53
+ };
54
+ const table = [["PORT", "PID", "UP", "STATE", "DOCUMENT"],
55
+ ...rows.map((r) => [String(r.port), String(r.pid ?? ""), r.started ? age(r.started) : "?", r.live ? "running" : "not answering (left behind)", r.document || "—"])];
56
+ const widths = table[0].map((_, i) => Math.max(...table.map((row) => row[i].length)));
57
+ return table.map((row) => row.map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i]))).join(" "));
58
+ }
59
+
60
+ const waitUntil = async (test, ms) => {
61
+ const end = Date.now() + ms;
62
+ while (Date.now() < end) { if (await test()) return true; await new Promise((r) => setTimeout(r, 150)); }
63
+ return test();
64
+ };
65
+
66
+ /** Stop one session. @returns {{port:number, outcome:"stopped"|"killed"|"removed-stale"|"failed", detail?:string}} */
67
+ export async function stopSession(row, { graceMs = 10000, killImpl = process.kill } = {}) {
68
+ if (!row.live) {
69
+ try { fs.unlinkSync(row.record); } catch { /* already gone */ }
70
+ return { port: row.port, outcome: "removed-stale" };
71
+ }
72
+ try {
73
+ await fetch(`http://127.0.0.1:${row.port}/shutdown`, { method: "POST", signal: AbortSignal.timeout(3000) });
74
+ } catch { /* it may close the connection as it goes */ }
75
+ if (await waitUntil(async () => !(await health(row.port, 800)), graceMs)) return { port: row.port, outcome: "stopped" };
76
+ const h = await health(row.port);
77
+ if (!h) return { port: row.port, outcome: "stopped" };
78
+ if (!sameSession(h, { host: "beatrix", started: row.started }) || !Number.isInteger(row.pid)) return { port: row.port, outcome: "failed", detail: "it did not stop, and the process on that port could not be confirmed as this session, so nothing was killed" };
79
+ try { killImpl(row.pid, "SIGTERM"); } catch (e) { return { port: row.port, outcome: "failed", detail: e.message }; }
80
+ if (await waitUntil(async () => !(await health(row.port, 800)), 5000)) return { port: row.port, outcome: "killed" };
81
+ return { port: row.port, outcome: "failed", detail: `process ${row.pid} did not end` };
82
+ }
83
+
84
+ /** Which sessions a `stop` names: --port, --all, or the only one running. */
85
+ export function chooseToStop(rows, { port = "", all = false } = {}) {
86
+ if (port) {
87
+ const hit = rows.filter((r) => String(r.port) === String(port));
88
+ return hit.length ? { rows: hit } : { error: `No Beatrina session on port ${port}.` };
89
+ }
90
+ if (all) return { rows };
91
+ const live = rows.filter((r) => r.live);
92
+ const stale = rows.filter((r) => !r.live);
93
+ if (live.length > 1) return { error: `${live.length} Beatrina sessions are running (ports ${live.map((r) => r.port).join(", ")}). Name one with --port, or use --all.` };
94
+ return { rows: [...live, ...stale] };
95
+ }
@@ -0,0 +1,151 @@
1
+ // shortcut.mjs — `beatrina shortcut`: an icon to double-click, and .qmd/.Rmd files that open in Beatrina.
2
+ //
3
+ // After `npm install -g beatrina`, one `beatrina shortcut` makes Beatrina an
4
+ // ordinary desktop app:
5
+ // · macOS — ~/Applications/Beatrina.app (an AppleScript applet: no terminal
6
+ // window), declaring .qmd and .Rmd so Finder's "Open With" offers it, and
7
+ // registered with Launch Services;
8
+ // · Windows — a Start-menu shortcut and "Open with" entries for .qmd/.Rmd under
9
+ // HKCU (no administrator), launched through wscript so no console appears;
10
+ // · Linux — a .desktop launcher with the Quarto/R Markdown MIME types.
11
+ // It becomes the DEFAULT app for .qmd only with --default: that changes a choice
12
+ // the user made (RStudio usually holds it), so it is never done by surprise.
13
+ // Windows 10+ keeps the default in a protected UserChoice key no program may
14
+ // set, so there --default says how to choose it instead.
15
+ //
16
+ // Every launch runs `beatrina --from-app [file]`: hand the file to a running
17
+ // Beatrina session if there is one, otherwise start one in the background.
18
+
19
+ import { spawnSync } from "node:child_process";
20
+ import fs from "node:fs";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+
24
+ export const BUNDLE_ID = "me.saqr.beatrina";
25
+ export const DOC_TYPES = Object.freeze([
26
+ { ext: "qmd", name: "Quarto document", mime: "text/x-quarto-markdown" },
27
+ { ext: "Rmd", name: "R Markdown document", mime: "text/x-r-markdown" },
28
+ ]);
29
+
30
+ const q = (s) => `'${String(s).replace(/'/g, "'\\''")}'`; // POSIX single-quote
31
+ const asString = (s) => `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
32
+
33
+ /** The AppleScript applet's source. `launch` is the shell command prefix. */
34
+ export function appleScriptSource(launch) {
35
+ return `on run
36
+ \tdo shell script ${asString(`${launch} --from-app > /dev/null 2>&1 &`)}
37
+ end run
38
+
39
+ on open theItems
40
+ \trepeat with anItem in theItems
41
+ \t\tdo shell script ${asString(launch)} & " --from-app " & quoted form of (POSIX path of anItem) & " > /dev/null 2>&1 &"
42
+ \tend repeat
43
+ end open
44
+ `;
45
+ }
46
+
47
+ /** The document types an Info.plist declares, as plutil-insertable XML. */
48
+ export function documentTypesXml() {
49
+ return `<array>${DOC_TYPES.map((t) => `<dict><key>CFBundleTypeName</key><string>${t.name}</string>`
50
+ + `<key>CFBundleTypeRole</key><string>Editor</string><key>LSHandlerRank</key><string>Alternate</string>`
51
+ + `<key>CFBundleTypeExtensions</key><array><string>${t.ext}</string><string>${t.ext.toLowerCase()}</string></array></dict>`).join("")}</array>`;
52
+ }
53
+
54
+ /** How to start Beatrina from an icon: this Node, this command, and any baked options. */
55
+ export function launchCommand({ node = process.execPath, cli, port = "", noOpen = false }) {
56
+ return `${q(node)} ${q(cli)}${port ? ` --port ${port}` : ""}${noOpen ? " --no-open" : ""}`;
57
+ }
58
+
59
+ function run(cmd, args, opts = {}) {
60
+ const r = spawnSync(cmd, args, { encoding: "utf8", ...opts });
61
+ if (r.status !== 0) throw new Error(`${cmd} ${args.slice(0, 2).join(" ")} failed: ${(r.stderr || r.stdout || "").trim().split("\n").pop()}`);
62
+ return r.stdout;
63
+ }
64
+
65
+ const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
66
+
67
+ function macShortcut({ cli, dir, port, makeDefault, noOpen }) {
68
+ const app = path.join(dir || path.join(os.homedir(), "Applications"), "Beatrina.app");
69
+ fs.mkdirSync(path.dirname(app), { recursive: true });
70
+ fs.rmSync(app, { recursive: true, force: true });
71
+ const src = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "beatrina-app-")), "Beatrina.applescript");
72
+ fs.writeFileSync(src, appleScriptSource(launchCommand({ cli, port, noOpen })));
73
+ run("osacompile", ["-o", app, src]);
74
+ const plist = path.join(app, "Contents", "Info.plist");
75
+ run("plutil", ["-replace", "CFBundleIdentifier", "-string", BUNDLE_ID, plist]);
76
+ run("plutil", ["-replace", "CFBundleName", "-string", "Beatrina", plist]);
77
+ run("plutil", ["-replace", "CFBundleDocumentTypes", "-xml", documentTypesXml(), plist]);
78
+ run("codesign", ["--force", "--sign", "-", app]); // the plist changed after osacompile signed it
79
+ if (fs.existsSync(LSREGISTER)) run(LSREGISTER, ["-f", app]);
80
+ const said = [`Beatrina.app is in ${path.dirname(app)}. Double-click it, or right-click a .qmd and choose Open With ▸ Beatrina.`];
81
+ if (makeDefault) {
82
+ const js = `ObjC.import("CoreServices");
83
+ const exts = ${JSON.stringify(DOC_TYPES.map((t) => t.ext))};
84
+ exts.map((ext) => { const uti = $.UTTypeCreatePreferredIdentifierForTag($.kUTTagClassFilenameExtension, $(ext), $()).js;
85
+ return ext + "=" + $.LSSetDefaultRoleHandlerForContentType($(uti), $.kLSRolesAll, $(${JSON.stringify(BUNDLE_ID)})); }).join(" ");`;
86
+ const out = run("osascript", ["-l", "JavaScript", "-e", js]).trim();
87
+ said.push(`Beatrina is now the default app for .qmd and .Rmd (${out}).`);
88
+ }
89
+ return { app, said };
90
+ }
91
+
92
+ function windowsShortcut({ cli, port, makeDefault }) {
93
+ const data = path.join(process.env.LOCALAPPDATA || os.homedir(), "Beatrina");
94
+ fs.mkdirSync(data, { recursive: true });
95
+ const vbs = path.join(data, "beatrina-launch.vbs");
96
+ // wscript runs node with no console window; the file argument, if any, is passed through.
97
+ fs.writeFileSync(vbs, `Set sh = CreateObject("WScript.Shell")\r\nargs = ""\r\nIf WScript.Arguments.Count > 0 Then args = " """ & WScript.Arguments(0) & """"\r\n`
98
+ + `sh.Run """${process.execPath}"" ""${cli}""${port ? ` --port ${port}` : ""} --from-app" & args, 0, False\r\n`);
99
+ const wscript = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "wscript.exe");
100
+ const menu = path.join(process.env.APPDATA || os.homedir(), "Microsoft", "Windows", "Start Menu", "Programs", "Beatrina.lnk");
101
+ const ps = `$s = (New-Object -ComObject WScript.Shell).CreateShortcut('${menu.replace(/'/g, "''")}'); $s.TargetPath = '${wscript}'; `
102
+ + `$s.Arguments = '"${vbs.replace(/'/g, "''")}"'; $s.Description = 'Beatrina notebooks'; $s.Save()`;
103
+ run("powershell.exe", ["-NoProfile", "-Command", ps]);
104
+ for (const t of DOC_TYPES) {
105
+ const prog = `Beatrina.${t.ext}`;
106
+ run("reg.exe", ["add", `HKCU\\Software\\Classes\\${prog}\\shell\\open\\command`, "/ve", "/d", `"${wscript}" "${vbs}" "%1"`, "/f"]);
107
+ run("reg.exe", ["add", `HKCU\\Software\\Classes\\${prog}`, "/ve", "/d", t.name, "/f"]);
108
+ run("reg.exe", ["add", `HKCU\\Software\\Classes\\.${t.ext}\\OpenWithProgids`, "/v", prog, "/d", "", "/f"]);
109
+ }
110
+ const said = ["Beatrina is in the Start menu, and .qmd/.Rmd files list it under Open with."];
111
+ if (makeDefault) said.push("Windows lets only you choose the default app: right-click a .qmd ▸ Open with ▸ Choose another app ▸ Beatrina ▸ Always.");
112
+ return { app: menu, said };
113
+ }
114
+
115
+ function linuxShortcut({ cli, port, makeDefault }) {
116
+ const apps = path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"), "applications");
117
+ fs.mkdirSync(apps, { recursive: true });
118
+ const desktop = path.join(apps, "beatrina.desktop");
119
+ fs.writeFileSync(desktop, `[Desktop Entry]\nType=Application\nName=Beatrina\nComment=R, Python and JavaScript notebooks\n`
120
+ + `Exec=${launchCommand({ cli, port })} --from-app %f\nTerminal=false\nMimeType=${DOC_TYPES.map((t) => t.mime).join(";")};\nCategories=Development;Science;\n`);
121
+ spawnSync("update-desktop-database", [apps]);
122
+ const said = [`Beatrina is in your applications menu (${desktop}).`];
123
+ if (makeDefault) {
124
+ for (const t of DOC_TYPES) spawnSync("xdg-mime", ["default", "beatrina.desktop", t.mime]);
125
+ said.push("Beatrina is now the default for Quarto and R Markdown files.");
126
+ }
127
+ return { app: desktop, said };
128
+ }
129
+
130
+ /** Make the icon (and, with makeDefault, the default .qmd app). */
131
+ export function makeShortcut({ cli, dir = "", port = "", makeDefault = false, noOpen = false, platform = process.platform }) {
132
+ if (platform === "darwin") return macShortcut({ cli, dir, port, makeDefault, noOpen });
133
+ if (platform === "win32") return windowsShortcut({ cli, port, makeDefault });
134
+ return linuxShortcut({ cli, port, makeDefault });
135
+ }
136
+
137
+ /** A running session of this app (runtime records + a live /health), or null. */
138
+ export async function liveSession({ app = "beatrina", runtimeDir = process.env.CARMAR_RUNTIME_DIR || path.join(os.homedir(), ".carmar", "run"), port = "" } = {}) {
139
+ let names = [];
140
+ try { names = fs.readdirSync(runtimeDir).filter((n) => /^kernel-\d+\.json$/.test(n)); } catch { return null; }
141
+ for (const name of names) {
142
+ let rec;
143
+ try { rec = JSON.parse(fs.readFileSync(path.join(runtimeDir, name), "utf8")); } catch { continue; }
144
+ if (rec.app !== app || (port && String(rec.port) !== String(port))) continue;
145
+ try {
146
+ const res = await fetch(`http://127.0.0.1:${rec.port}/health`, { signal: AbortSignal.timeout(1500) });
147
+ if (res.ok) return rec;
148
+ } catch { /* a record the session left behind */ }
149
+ }
150
+ return null;
151
+ }
@@ -0,0 +1,55 @@
1
+ // update-check.mjs — say when a newer Beatrina is on the npm registry; never update anything
2
+ // (docs/beatrina-next-plan.md Stage 3). `beatrina upgrade` (upgrade.mjs) is the explicit door.
3
+ //
4
+ // At most once a day per user (the last check is recorded in a small JSON file, which the host reads
5
+ // back to put `update_available` on /health — host/update-record.mjs), with a short timeout, and never
6
+ // in the way: a registry that cannot be reached, an unpublished package (404) or a garbled answer all
7
+ // mean "nothing to say". BEATRINA_NO_UPDATE_CHECK=1 or CARMAR_NO_UPDATE_CHECK=1 turns it off (the
8
+ // battery runner sets the second). BEATRINA_REGISTRY points at another registry (tests).
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { fileURLToPath, pathToFileURL } from "node:url";
13
+
14
+ // host/update-record.mjs is the ONE definition of the record and of version order. It sits beside this
15
+ // package (bin/../host) and, in the checkout the tests import from, three levels up — the two layouts
16
+ // identity.mjs meets by having rUserDir passed in; here the module is found instead, so the command
17
+ // and the host cannot drift apart on what "newer" means.
18
+ const here = path.dirname(fileURLToPath(import.meta.url));
19
+ const hostDir = [path.resolve(here, "..", "host"), path.resolve(here, "..", "..", "..", "host")]
20
+ .find((d) => fs.existsSync(path.join(d, "update-record.mjs")));
21
+ if (!hostDir) throw new Error("update-check.mjs: host/update-record.mjs is neither beside this package nor in the checkout");
22
+ export const { compareVersions, updateAvailable, updateCheckOff, updateRecordFile } = await import(pathToFileURL(path.join(hostDir, "update-record.mjs")).href);
23
+
24
+ export const DAY_MS = 24 * 60 * 60 * 1000;
25
+
26
+ /** The registry's `latest` version of beatrina, or null (unreachable, unpublished, garbled). */
27
+ export async function fetchLatest({ env = process.env, fetchImpl = fetch, timeoutMs = 1500 } = {}) {
28
+ const registry = (env.BEATRINA_REGISTRY || "https://registry.npmjs.org").replace(/\/+$/, "");
29
+ try {
30
+ const res = await fetchImpl(`${registry}/beatrina/latest`, { signal: AbortSignal.timeout(timeoutMs), headers: { accept: "application/json" } });
31
+ if (!res.ok) return null;
32
+ const version = (await res.json())?.version;
33
+ return typeof version === "string" && /^\d+\.\d+\.\d+/.test(version) ? version : null;
34
+ } catch { return null; }
35
+ }
36
+
37
+ /** The one sentence a newer version earns, with the door. */
38
+ export const updateNotice = (latest, current) => `Beatrina ${latest} is available (this is ${current}). To upgrade: beatrina upgrade`;
39
+
40
+ /**
41
+ * @returns {Promise<string|null>} the sentence to print, or null
42
+ */
43
+ export async function checkForUpdate({ current, stateFile, env = process.env, now = Date.now(), fetchImpl = fetch, timeoutMs = 1500 }) {
44
+ if (updateCheckOff(env)) return null;
45
+ let state = {};
46
+ try { state = JSON.parse(fs.readFileSync(stateFile, "utf8")); } catch { /* first check */ }
47
+ if (Number.isFinite(state.checked_at) && now - state.checked_at < DAY_MS) return null;
48
+ const latest = await fetchLatest({ env, fetchImpl, timeoutMs });
49
+ try {
50
+ fs.mkdirSync(path.dirname(stateFile), { recursive: true });
51
+ fs.writeFileSync(stateFile, JSON.stringify({ checked_at: now, latest }), { mode: 0o600 });
52
+ } catch { /* a notice is not worth an error */ }
53
+ if (!latest || compareVersions(latest, current) <= 0) return null;
54
+ return updateNotice(latest, current);
55
+ }
@@ -0,0 +1,76 @@
1
+ // upgrade.mjs — `beatrina upgrade`: install the newest Beatrina from npm, on request, and say what
2
+ // keeps running on the old one.
3
+ //
4
+ // Beatrina never updates itself (update-check.mjs only SAYS a newer version exists). Typing
5
+ // `beatrina upgrade` is the request: it asks the registry which version is newest, runs
6
+ // `npm install -g beatrina@<that>` through the npm that owns this Node — the one beside
7
+ // process.execPath, so a machine with two Nodes upgrades the copy that is running — and then names
8
+ // every session still on the old version, because an installed file is not a running process: a
9
+ // session keeps the version it started on until it restarts (Session ▾ ▸ Restart in the notebook, or
10
+ // `beatrina stop` then `beatrina`). Already newest: nothing is run. Registry unreachable: said, exit 1,
11
+ // nothing is run. BEATRINA_NPM substitutes the npm program (tests); BEATRINA_REGISTRY the registry.
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { spawnSync } from "node:child_process";
16
+ import { compareVersions, fetchLatest } from "./update-check.mjs";
17
+
18
+ /** The npm that owns this Node: beside process.execPath, else `npm` on PATH; BEATRINA_NPM overrides. */
19
+ export function npmProgram({ env = process.env, execPath = process.execPath, platform = process.platform } = {}) {
20
+ if (env.BEATRINA_NPM) return env.BEATRINA_NPM;
21
+ const name = platform === "win32" ? "npm.cmd" : "npm";
22
+ const beside = path.join(path.dirname(execPath), name);
23
+ return fs.existsSync(beside) ? beside : name;
24
+ }
25
+
26
+ /**
27
+ * What an upgrade should do, as data — pure, so it can be tested without npm.
28
+ *
29
+ * @param {{current: string, latest: string|null, sessions?: {port: number, live: boolean}[]}} o
30
+ * @returns {{action: "install"|"current"|"unknown", target?: string, said: string[], afterwards?: string[]}}
31
+ */
32
+ export function upgradePlan({ current, latest, sessions = [] }) {
33
+ if (!latest) {
34
+ return { action: "unknown", said: ["The npm registry could not be reached, so whether a newer Beatrina exists is not known. "
35
+ + "Try again when online, or run: npm install -g beatrina@latest"] };
36
+ }
37
+ if (compareVersions(latest, current) <= 0) return { action: "current", said: [`Beatrina ${current} is the newest on npm; nothing to do.`] };
38
+ const live = sessions.filter((s) => s.live);
39
+ return {
40
+ action: "install", target: latest,
41
+ said: [`Beatrina ${latest} is on npm; this is ${current}. Installing it: npm install -g beatrina@${latest}`],
42
+ afterwards: afterUpgrade({ from: current, to: latest, live }),
43
+ };
44
+ }
45
+
46
+ /** What to say once the files are installed: which sessions still run the old version, and the doors out. */
47
+ export function afterUpgrade({ from, to, live }) {
48
+ const lines = [`Beatrina ${to} is installed.`];
49
+ if (!live.length) lines.push(`The next beatrina starts on ${to}.`);
50
+ else {
51
+ const ports = live.map((s) => s.port).join(", ");
52
+ lines.push(`${live.length === 1 ? "A session is" : `${live.length} sessions are`} still running on ${from} (port${live.length === 1 ? "" : "s"} ${ports}). `
53
+ + `In the notebook, Session ▾ ▸ Restart into ${to}; or: beatrina stop, then beatrina.`);
54
+ }
55
+ return lines;
56
+ }
57
+
58
+ /**
59
+ * The command. Returns the exit code.
60
+ *
61
+ * @param {{current: string, env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, listSessions?: () => Promise<object[]>, log?: (s: string) => void, err?: (s: string) => void}} o
62
+ */
63
+ export async function runUpgrade({ current, env = process.env, fetchImpl = fetch, listSessions = async () => [], log = console.log, err = console.error }) {
64
+ const latest = await fetchLatest({ env, fetchImpl, timeoutMs: 5000 });
65
+ const sessions = await listSessions();
66
+ const plan = upgradePlan({ current, latest, sessions });
67
+ for (const line of plan.said) log(line);
68
+ if (plan.action === "current") return 0;
69
+ if (plan.action !== "install") return 1;
70
+ const npm = npmProgram({ env });
71
+ const r = spawnSync(npm, ["install", "-g", `beatrina@${plan.target}`], { stdio: "inherit", env, shell: process.platform === "win32" });
72
+ if (r.error) { err(`beatrina: npm could not be run (${npm}): ${r.error.message}`); return 1; }
73
+ if (r.status !== 0) { err(`beatrina: npm install failed (exit ${r.status}); the messages above say why. Nothing changed unless npm says it did.`); return r.status ?? 1; }
74
+ for (const line of plan.afterwards) log(line);
75
+ return 0;
76
+ }
@@ -0,0 +1 @@
1
+ {"version":"0.8.6","commit":"eb036e63b7b3f0e0e1f696d357d701d43235ce6c","dirty":false}