@damurka/jovian 0.1.0 → 0.1.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/README.md CHANGED
@@ -9,11 +9,11 @@ import { SessionManager } from '@damurka/jovian';
9
9
 
10
10
  const manager = new SessionManager();
11
11
 
12
- const r = await manager.createSession({ kernelType: 'r', rHome: process.env.R_HOME, workingDirectory: '/projects/analysis' });
12
+ const r = await manager.createSession({ kernelType: 'r', workingDirectory: '/projects/analysis' });
13
13
  const result = await r.execute('x <- 1:10; mean(x)');
14
14
  console.log(result.success, result.output);
15
15
 
16
- const py = await manager.createSession({ kernelType: 'python', pythonHome: '/path/to/python' });
16
+ const py = await manager.createSession({ kernelType: 'python' });
17
17
  console.log((await py.execute('sum(range(1, 11))')).success);
18
18
 
19
19
  await manager.stopAll();
@@ -40,13 +40,9 @@ The package ships **prebuilt** `themisto`, `elara` and `carpo` binaries — no c
40
40
 
41
41
  What you must already have on the machine:
42
42
 
43
- - **R** (4.2 or newer; a build with a shared library, which the CRAN/Posit binaries and distribution packages are) for R sessions. Pass its location as `rHome` (`R RHOME` prints it). The `hera` R package that every R session needs ships inside the npm package and is installed into R on a session's first start, which needs the `remotes` package and `hera`'s CRAN dependencies (`cli`, `evaluate`, `glue`, `IRdisplay`, `jsonlite`, `R6`, `repr`, `rlang`):
43
+ - **R** (4.2 or newer; a build with a shared library, which the CRAN/Posit binaries and distribution packages are) for R sessions. If `rHome` is not passed, it is found from `$R_HOME`, then `R RHOME` (R on `PATH`), then the Windows registry; pass `rHome` to choose a specific installation. You do not need to install any R packages yourself. The `hera` R package every R session needs ships inside the npm package, and before the **first** R session the library installs it, together with its CRAN dependencies (`cli`, `evaluate`, `glue`, `IRdisplay`, `jsonlite`, `R6`, `repr`, `rlang` and what they need), into your R library. Nothing else (not even `remotes`) has to be installed first. This needs an internet connection, takes about 20 seconds where CRAN has binaries (Windows, macOS) and a few minutes on Linux, where packages are compiled from source and need a compiler (Ubuntu: `sudo apt install build-essential`). It happens once; later sessions start straight away. A package that is installed but cannot be loaded (Debian/Ubuntu `r-cran-*` packages built for an older R fail with `undefined symbol: SETLENGTH`) is reinstalled from CRAN into your own library. If it cannot finish, `createSession()` rejects with R's own reason. Set `JOVIAN_SKIP_R_SETUP=1` to skip this step and manage the packages yourself.
44
44
 
45
- ```r
46
- install.packages(c("remotes", "cli", "evaluate", "glue", "IRdisplay", "jsonlite", "R6", "repr", "rlang"))
47
- ```
48
-
49
- - **Python 3** with its shared library (optional, for Python sessions); pass `pythonHome` (`python3 -c "import sys; print(sys.prefix)"`).
45
+ - **Python 3** with its shared library (optional, for Python sessions); if `pythonHome` is not passed, it is found from `$PYTHONHOME`, then the first `python3` / `python` on `PATH` (its `sys.base_prefix`); pass `pythonHome` to choose one.
50
46
  - **Linux:** `libuuid` (`libuuid1`, present on nearly every system) and a glibc at least as new as the one the binaries were built against (Ubuntu 24.04's, 2.39). On an older distribution, [build from source](#requirements).
51
47
  - **macOS:** 14 or newer.
52
48
  - **Windows:** the [Microsoft Visual C++ Redistributable](https://learn.microsoft.com/cpp/windows/latest-supported-vc-redist) (x64, 2015–2022) — the binaries use the dynamic C++ runtime; most machines already have it.
@@ -117,9 +113,9 @@ Jovian builds C++ (Adrastea, Elara, Carpo, Themisto) and TypeScript. R and Pytho
117
113
  | Variable | Used by | Meaning |
118
114
  |---|---|---|
119
115
  | `VCPKG_ROOT` | build | vcpkg checkout; used by `npm run build` and the CMake presets. |
120
- | `R_HOME` | runtime, tests, examples | R installation to use when `rHome` is not passed. `R RHOME` is used as a fallback by the tests and the playground. |
116
+ | `R_HOME` | runtime, tests, examples | R installation to use when `rHome` is not passed; otherwise the library asks `R RHOME`. |
121
117
  | `R_PATH`, `R_LIBS` | examples, playground | Passed as `rPath` / `rLibs`. |
122
- | `PYTHONHOME` | runtime, tests | Python installation prefix when `pythonHome` is not passed. |
118
+ | `PYTHONHOME` | runtime, tests | Python installation prefix when `pythonHome` is not passed; otherwise the library asks `python3` / `python` for its `sys.base_prefix`. |
123
119
  | `JOVIAN_NATIVE_DIR` | `lib/` | Directory holding `themisto`, `elara` and `carpo`. Default: the installed `@damurka/jovian-<os>-<cpu>` package, else `dist/native/Release` in a source checkout. Use it to run against a *copy* of the binaries (Windows will not let you overwrite a running `.exe`). |
124
120
  | `ELARA_HERA_SRC` | Elara | Set for you from the `heraSrcPath` option: where Elara installs `hera` from if it is missing or older than the source. |
125
121
 
package/docs/api/types.md CHANGED
@@ -9,12 +9,12 @@ Passed to `SessionManager.createSession()`; also `Partial<EngineOptions>` to `Se
9
9
  | Field | Type | Default | Meaning |
10
10
  |---|---|---|---|
11
11
  | `kernelType` | `'r'` \| `'python'` | `'r'` | Which kernel the session runs. Selects which of the fields below apply and which executable (`elara` / `carpo`) is spawned. |
12
- | `rHome` | string | | R installation (`R_HOME`). Required to run R sessions unless `R_HOME` is discoverable by the kernel's environment pass it explicitly. |
12
+ | `rHome` | string | discovered | R installation (`R RHOME`). When omitted it is found from `$R_HOME`, then `R RHOME` (R on PATH), then the Windows registry; pass it to pick a specific installation. |
13
13
  | `rPath` | string | `<rHome>/bin/x64` (Windows) | Directory containing `R.dll`; put on the kernel's `PATH`. |
14
14
  | `rLibs` | string | — | Extra library path (`R_LIBS`, `R_LIBS_USER`, and `R_LIBS_SITE` on Windows); where `hera` is looked up and installed. |
15
15
  | `pandocPath` | string | — | Directory with a `pandoc` binary, for bundled R installs that do not ship it on `PATH` (`RSTUDIO_PANDOC`). |
16
16
  | `heraSrcPath` | string | — | Source directory of the `hera` package. If set, Elara installs it (`remotes::install_local`, needs `remotes`) when it is missing or older than the source. **There is no built-in default**: if `hera` is not installed and this is unset, R code cannot run (see [Troubleshooting](../troubleshooting.md#hera-is-not-installed)). |
17
- | `pythonHome` | string | | Python installation prefix (`PYTHONHOME`). Its shared library is loaded from here. |
17
+ | `pythonHome` | string | discovered | Python installation prefix (`PYTHONHOME`). Its shared library is loaded from here. When omitted it is found from `$PYTHONHOME`, then `python3` / `python` on PATH (`sys.base_prefix`). |
18
18
  | `pythonPath` | string | — | Extra `PYTHONPATH`. |
19
19
  | `venvPath` | string | — | A venv whose `site-packages` is added to `sys.path`. `pythonHome` must still point at the *base* install. |
20
20
  | `workingDirectory` | string | supervisor's cwd | Directory the kernel process starts in (`getwd()` / `os.getcwd()`); relative paths resolve against it. Must exist, or `createSession()` rejects with `workingDirectory does not exist or is not a directory: <path>`. Kept across `restart()`. |
@@ -55,7 +55,7 @@ import { SessionManager } from './dist/lib/index.js';
55
55
  const manager = new SessionManager();
56
56
  const session = await manager.createSession({
57
57
  kernelType: 'r',
58
- rHome: process.env.R_HOME, // e.g. "C:/Program Files/R/R-4.6.0" or the output of `R RHOME`
58
+ rHome: process.env.R_HOME, // optional: found from $R_HOME / `R RHOME` / the Windows registry when omitted
59
59
  rPath: process.env.R_PATH, // Windows only: e.g. "C:/Program Files/R/R-4.6.0/bin/x64"
60
60
  workingDirectory: process.cwd(), // where getwd() will point
61
61
  });
@@ -78,7 +78,7 @@ await manager.stopAll(); // always: it also end
78
78
  ```javascript
79
79
  const py = await manager.createSession({
80
80
  kernelType: 'python',
81
- pythonHome: process.env.PYTHONHOME, // the prefix that contains libpython python -c "import sys; print(sys.base_prefix)"
81
+ pythonHome: process.env.PYTHONHOME, // optional: found from $PYTHONHOME / python3 / python when omitted (the prefix containing libpython)
82
82
  workingDirectory: process.cwd(),
83
83
  });
84
84
  py.on('error', () => {});
package/docs/releasing.md CHANGED
@@ -15,10 +15,25 @@ The repository's own `package.json` is `"private": true` — it is the developme
15
15
  ## One-time setup
16
16
 
17
17
  1. **The scope.** `@damurka` must be a user or organization you can publish to on npmjs.com. If you use a different scope, change it in two places — `SCOPE` in `scripts/release.mjs` and `PACKAGE_SCOPE` in `lib/session/native-paths.ts` (a unit test fails if they differ) — plus the names in `README.md`, `docs/`, and the tarball globs in `.github/workflows/release.yml`.
18
- 2. **A token.** On npmjs.com create an *automation* access token (or a granular token with read/write on the `@damurka` packages) and add it to the GitHub repository as the secret **`NPM_TOKEN`** (Settings → Secrets and variables → Actions). If your account enforces 2FA for publishing, the token must be of the automation kind, which bypasses the prompt.
18
+ 2. **A token.** On npmjs.com create an *automation* access token (or a granular token with read/write on the `@damurka` packages) and add it to the GitHub repository as the secret **`NPM_TOKEN`** (Settings → Secrets and variables → Actions). Staging needs no 2FA and works with any token type (npm also offers stage-only tokens, which cannot publish directly); the 2FA happens when you approve. A token that still asks for a one-time password on a direct `npm publish` fails in CI with `EOTP`.
19
19
  3. Scoped packages are private by default on npm; the packages carry `publishConfig.access: public`, and the workflow passes `--access public`.
20
20
  4. The workflow publishes with **provenance** (`--provenance`, needs the `id-token: write` permission it declares), which requires the GitHub repository to be public.
21
21
 
22
+ ## The first release is published by hand
23
+
24
+ npm's staged publishing (below) only works for a package that **already exists** on the registry; a first-time publish of a new package cannot be staged. So the very first version (`0.1.0`) is published from your machine, where npm can ask for your 2FA code:
25
+
26
+ ```bash
27
+ npm login
28
+ # the tarballs CI built and smoke-tested (download them from the Release run's artifacts, or `gh run download <run-id>`):
29
+ npm publish ./tarballs-win32-x64/damurka-jovian-win32-x64-0.1.0.tgz --access public
30
+ npm publish ./tarballs-linux-x64/damurka-jovian-linux-x64-0.1.0.tgz --access public
31
+ npm publish ./tarballs-darwin-arm64/damurka-jovian-darwin-arm64-0.1.0.tgz --access public
32
+ npm publish ./tarballs-linux-x64/damurka-jovian-0.1.0.tgz --access public # last: it depends on the three above
33
+ ```
34
+
35
+ The main package goes last so nothing depends on a version that is not there yet. This first version carries no provenance badge. From the next version on, use the workflow.
36
+
22
37
  ## Cutting a release
23
38
 
24
39
  1. Make sure `main` is green on CI.
@@ -31,7 +46,8 @@ The repository's own `package.json` is `"private": true` — it is the developme
31
46
  ```
32
47
 
33
48
  4. `release.yml` runs. For each platform it builds the native binaries in Release, compiles the library, stages both packages, packs them, and **smoke-tests the packed tarballs**: `scripts/release-smoke.mjs` installs the platform tarball and the main tarball into an empty project (outside the repository, with an empty R library so the bundled `hera` has to install from the package) and starts a real R kernel and a real Python kernel from them.
34
- 5. Only if every platform passed does the `publish` job run: the platform packages first, then the main package (so nothing ever depends on a version that is not there yet).
49
+ 5. Only if every platform passed does the `publish` job run. It **stages** the packages with `npm stage publish` (npm requires this for CI; needs npm ≥ 11.15, which the job installs), platform packages first, then the main package. Nothing is public yet.
50
+ 6. **Approve them**, with 2FA, in the same order: `npm stage list` shows the queue, `npm stage approve <stage-id>` publishes one (or approve on npmjs.com). Approve the main package last. `npm stage reject <stage-id>` discards one.
35
51
 
36
52
  A version with a hyphen (`v0.2.0-rc.1`) is published under the `next` dist-tag, so it does not become what `npm install` picks by default.
37
53
 
@@ -35,7 +35,11 @@ Messages below are quoted from the code. **First habit:** a kernel that fails to
35
35
  | Symptom | Cause / fix |
36
36
  |---|---|
37
37
  | `Could not load R.dll (…). Is R installed? Checked PATH and R_HOME=… Install R from https://cran.r-project.org, or make sure R_HOME/the R bin directory is configured correctly.` | Windows: `rHome` wrong, or `rPath` (the folder containing `R.dll`, normally `<R_HOME>\bin\x64`) is not right. |
38
- | `R_HOME is not set -- elara needs a working R installation to run. …` | Linux/macOS: pass `rHome` (find it with `R RHOME`). |
38
+ | `R_HOME is not set -- elara needs a working R installation to run. …` | The library could not find R (it tries `$R_HOME`, `R RHOME`, and on Windows the registry): put R on `PATH` or pass `rHome` (`R RHOME` prints it). |
39
+ | `No libpython3.*.so* … was found under '/lib' … Is Python installed at ''?` | The library could not find Python (it tries `$PYTHONHOME`, then `python3`/`python` on `PATH`): install Python 3 (`sudo apt install python3`) or pass `pythonHome`. |
40
+ | `Could not set up the R packages the kernel needs: …` | The one-time install of `hera` and its dependencies (see the README's Install section) failed; the text after the colon is R's own reason (no internet access to CRAN, no compiler on Linux, a library that cannot be written to). Fix that and call `createSession()` again; it retries. `JOVIAN_SKIP_R_SETUP=1` skips the step. |
41
+ | `unable to load shared object '/usr/lib/R/site-library/<pkg>/libs/<pkg>.so': undefined symbol: SETLENGTH` (or another `undefined symbol`) | Only when the automatic setup is skipped or you install by hand. Debian/Ubuntu: a package installed with `apt` (`r-cran-*`, in `/usr/lib/R/site-library`) was built for an older R than yours (the automatic setup reinstalls these itself). Reinstall it, and everything it needs, from CRAN into your own library, which R searches first. Needs a compiler (`sudo apt install build-essential`):<br>`wanted <- c("remotes","cli","evaluate","glue","IRdisplay","jsonlite","R6","repr","rlang")`<br>`all <- unique(c(wanted, unlist(tools::package_dependencies(wanted, db = available.packages(), recursive = TRUE), use.names = FALSE)))`<br>`ip <- installed.packages(); install.packages(setdiff(all, rownames(ip)[!is.na(ip[, "Priority"])]), repos = "https://cloud.r-project.org")` |
42
+ | `WARNING: 'hera' package could not be loaded (status: install_failed: …)` | Elara could not install the bundled `hera` R package; the text after `install_failed:` is R's own reason. Usually one of `hera`'s CRAN dependencies is missing or cannot be built (install them first: `install.packages(c("remotes", "cli", "evaluate", "glue", "IRdisplay", "jsonlite", "R6", "repr", "rlang"))`), or the R library is not writable. To see the full output run `R CMD INSTALL node_modules/@damurka/jovian/packages/hera`. |
39
43
  | `Could not load …/lib/libR.so (…). Is R installed at '…'? If this R was built from source, it needs to have been configured with --enable-R-shlib, or no libR.so exists at all` | Use a distribution/CRAN R, or rebuild R with `--enable-R-shlib`. |
40
44
  | No `libR.dylib` on macOS | Point `rHome` at the framework's `Resources` directory (what `R RHOME` prints). |
41
45
 
@@ -0,0 +1,46 @@
1
+ import type { EngineOptions } from '../types/index.js';
2
+ /**
3
+ * Runs once, before the first R session, when the library was given a copy of
4
+ * the 'hera' R package (always the case for an npm install): makes sure hera
5
+ * and everything it needs are installed, so a person who has just installed R
6
+ * has nothing to do by hand.
7
+ *
8
+ * It is a separate step, not part of the kernel's start-up, because the
9
+ * supervisor gives a kernel 60 seconds to register and installing a dozen
10
+ * packages (compiling some, on Linux) takes longer. A kernel started after
11
+ * this finds hera already there.
12
+ *
13
+ * Only what is missing is installed, into the first writable library, and
14
+ * nothing is installed at all when hera is already current. A package that is
15
+ * installed but cannot be loaded (Debian/Ubuntu `r-cran-*` packages built for
16
+ * an older R fail with "undefined symbol: SETLENGTH") counts as missing and is
17
+ * reinstalled from CRAN into the user's own library, which R searches first.
18
+ */
19
+ export declare const R_SETUP_SCRIPT: string;
20
+ export interface SetupLogger {
21
+ debug(message: string): void;
22
+ info(message: string): void;
23
+ }
24
+ export interface SetupProcessOutcome {
25
+ code: number | null;
26
+ timedOut: boolean;
27
+ }
28
+ export interface SetupDeps {
29
+ env: NodeJS.ProcessEnv;
30
+ platform: string;
31
+ exists: (path: string) => boolean;
32
+ /** Runs `rscript scriptFile ...args`, reporting each stdout/stderr line. */
33
+ run: (rscript: string, scriptFile: string, args: string[], env: NodeJS.ProcessEnv, onLine: (line: string) => void, timeoutMs: number) => Promise<SetupProcessOutcome>;
34
+ timeoutMs: number;
35
+ }
36
+ export declare function rscriptPath(rHome: string, deps?: Pick<SetupDeps, 'platform' | 'exists'>): string | undefined;
37
+ /**
38
+ * Makes sure hera and its dependencies are installed for this R (see the
39
+ * comment on R_SETUP_SCRIPT). Resolves immediately when there is nothing to
40
+ * do: a Python session, no bundled hera (a source checkout), no R found, or
41
+ * JOVIAN_SKIP_R_SETUP set. Concurrent calls for the same R share one run, and
42
+ * a run that succeeded is not repeated by this process. Rejects with R's own
43
+ * explanation when the packages could not be installed.
44
+ */
45
+ export declare function ensureRPackages(options: EngineOptions, logger: SetupLogger, deps?: SetupDeps): Promise<void>;
46
+ //# sourceMappingURL=r-setup.d.ts.map
@@ -0,0 +1,207 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { createInterface } from 'node:readline';
7
+ /**
8
+ * Runs once, before the first R session, when the library was given a copy of
9
+ * the 'hera' R package (always the case for an npm install): makes sure hera
10
+ * and everything it needs are installed, so a person who has just installed R
11
+ * has nothing to do by hand.
12
+ *
13
+ * It is a separate step, not part of the kernel's start-up, because the
14
+ * supervisor gives a kernel 60 seconds to register and installing a dozen
15
+ * packages (compiling some, on Linux) takes longer. A kernel started after
16
+ * this finds hera already there.
17
+ *
18
+ * Only what is missing is installed, into the first writable library, and
19
+ * nothing is installed at all when hera is already current. A package that is
20
+ * installed but cannot be loaded (Debian/Ubuntu `r-cran-*` packages built for
21
+ * an older R fail with "undefined symbol: SETLENGTH") counts as missing and is
22
+ * reinstalled from CRAN into the user's own library, which R searches first.
23
+ */
24
+ export const R_SETUP_SCRIPT = String.raw `
25
+ src <- commandArgs(trailingOnly = TRUE)[1]
26
+ say <- function(...) cat("JOVIAN_R_SETUP: ", ..., "\n", sep = "")
27
+ fail <- function(...) {
28
+ cat("JOVIAN_R_SETUP_ERROR: ", ..., "\n", sep = "")
29
+ quit(save = "no", status = 1)
30
+ }
31
+ field <- function(path, name) {
32
+ value <- tryCatch(read.dcf(path, fields = name)[1, 1], error = function(e) NA_character_)
33
+ if (is.na(value)) NA_character_ else value
34
+ }
35
+ stamp_wanted <- field(file.path(src, "DESCRIPTION"), "Config/jovian/release")
36
+
37
+ hera_current <- function() {
38
+ installed <- tryCatch(find.package("hera", quiet = TRUE), error = function(e) character(0))
39
+ if (!length(installed)) return(FALSE)
40
+ if (!is.na(stamp_wanted) && !identical(field(file.path(installed, "DESCRIPTION"), "Config/jovian/release"), stamp_wanted)) return(FALSE)
41
+ suppressWarnings(suppressMessages(requireNamespace("hera", quietly = TRUE)))
42
+ }
43
+ if (hera_current()) {
44
+ say("hera is already installed")
45
+ quit(save = "no", status = 0)
46
+ }
47
+
48
+ repos <- getOption("repos")
49
+ if (is.null(repos) || is.na(repos["CRAN"]) || identical(unname(repos["CRAN"]), "@CRAN@")) {
50
+ options(repos = c(CRAN = "https://cloud.r-project.org"))
51
+ }
52
+
53
+ is_writable <- function(path) dir.exists(path) && file.access(path, 2) == 0
54
+ lib <- Filter(is_writable, .libPaths())[1]
55
+ if (is.na(lib)) {
56
+ lib <- Sys.getenv("R_LIBS_USER")
57
+ if (!nzchar(lib)) lib <- file.path(Sys.getenv("HOME"), "R", "library")
58
+ dir.create(lib, recursive = TRUE, showWarnings = FALSE)
59
+ .libPaths(c(lib, .libPaths()))
60
+ }
61
+
62
+ ip <- installed.packages()
63
+ base_packages <- rownames(ip)[!is.na(ip[, "Priority"])]
64
+ fields <- tryCatch(read.dcf(file.path(src, "DESCRIPTION"), fields = c("Depends", "Imports", "LinkingTo")), error = function(e) NULL)
65
+ direct <- unique(trimws(sub("[(].*$", "", unlist(strsplit(paste(stats::na.omit(as.vector(fields)), collapse = ","), ",")))))
66
+ direct <- setdiff(direct[nzchar(direct)], c("R", base_packages))
67
+
68
+ available <- tryCatch(suppressWarnings(available.packages()), error = function(e) NULL)
69
+ wanted <- direct
70
+ if (!is.null(available) && nrow(available) > 0) {
71
+ everything <- tools::package_dependencies(direct, db = available, recursive = TRUE)
72
+ wanted <- setdiff(unique(c(direct, unlist(everything, use.names = FALSE))), base_packages)
73
+ }
74
+
75
+ usable <- function(package) suppressWarnings(suppressMessages(requireNamespace(package, quietly = TRUE)))
76
+ missing <- wanted[!vapply(wanted, usable, logical(1))]
77
+ warnings_seen <- character()
78
+ collect <- function(expr) withCallingHandlers(expr, warning = function(w) {
79
+ warnings_seen <<- c(warnings_seen, conditionMessage(w))
80
+ invokeRestart("muffleWarning")
81
+ })
82
+
83
+ if (length(missing) > 0) {
84
+ if (is.null(available) || nrow(available) == 0) {
85
+ fail("these R packages are needed but not installed: ", paste(missing, collapse = ", "),
86
+ " -- and CRAN could not be reached to install them (check the internet connection)")
87
+ }
88
+ say("installing ", length(missing), " R package(s) from CRAN into ", lib, " (first run only): ", paste(missing, collapse = ", "))
89
+ tryCatch(collect(utils::install.packages(missing, lib = lib, repos = getOption("repos"), quiet = TRUE)),
90
+ error = function(e) fail("installing R packages failed: ", conditionMessage(e)))
91
+ still_missing <- missing[!vapply(missing, usable, logical(1))]
92
+ if (length(still_missing) > 0) {
93
+ fail("could not install these R packages: ", paste(still_missing, collapse = ", "),
94
+ " -- on Linux they are compiled from source, which needs a compiler (Ubuntu: sudo apt install build-essential)",
95
+ if (length(warnings_seen)) paste0(" | ", substr(paste(unique(warnings_seen), collapse = " | "), 1, 400)) else "")
96
+ }
97
+ }
98
+
99
+ say("installing hera into ", lib)
100
+ tryCatch(collect(utils::install.packages(src, repos = NULL, type = "source", lib = lib, quiet = TRUE)),
101
+ error = function(e) NULL)
102
+ if (!hera_current()) {
103
+ output <- tryCatch(
104
+ suppressWarnings(system2(file.path(R.home("bin"), "R"), c("CMD", "INSTALL", paste0("--library=", shQuote(lib)), shQuote(src)), stdout = TRUE, stderr = TRUE)),
105
+ error = function(e) conditionMessage(e))
106
+ lines <- grep("ERROR|error|not available|cannot|denied|failed", output, value = TRUE)
107
+ reason <- paste(utils::tail(if (length(lines)) lines else output, 6), collapse = " | ")
108
+ fail("hera could not be installed: ", substr(gsub("[[:space:]]+", " ", reason), 1, 800))
109
+ }
110
+ say("done")
111
+ `;
112
+ const runRscript = async (rscript, scriptFile, args, env, onLine, timeoutMs) => {
113
+ const child = spawn(rscript, [scriptFile, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
114
+ for (const stream of [child.stdout, child.stderr]) {
115
+ createInterface({ input: stream }).on('line', onLine);
116
+ }
117
+ let timedOut = false;
118
+ const timer = setTimeout(() => {
119
+ timedOut = true;
120
+ child.kill();
121
+ }, timeoutMs);
122
+ return new Promise((resolve, reject) => {
123
+ child.once('error', (error) => {
124
+ clearTimeout(timer);
125
+ reject(error);
126
+ });
127
+ child.once('close', (code) => {
128
+ clearTimeout(timer);
129
+ resolve({ code, timedOut });
130
+ });
131
+ });
132
+ };
133
+ const defaultDeps = () => ({
134
+ env: process.env,
135
+ platform: process.platform,
136
+ exists: existsSync,
137
+ run: runRscript,
138
+ timeoutMs: 30 * 60 * 1000
139
+ });
140
+ export function rscriptPath(rHome, deps = defaultDeps()) {
141
+ const name = deps.platform === 'win32' ? 'Rscript.exe' : 'Rscript';
142
+ const candidates = [join(rHome, 'bin', name)];
143
+ if (deps.platform === 'win32')
144
+ candidates.push(join(rHome, 'bin', 'x64', name));
145
+ return candidates.find((candidate) => deps.exists(candidate));
146
+ }
147
+ const inFlight = new Map();
148
+ /**
149
+ * Makes sure hera and its dependencies are installed for this R (see the
150
+ * comment on R_SETUP_SCRIPT). Resolves immediately when there is nothing to
151
+ * do: a Python session, no bundled hera (a source checkout), no R found, or
152
+ * JOVIAN_SKIP_R_SETUP set. Concurrent calls for the same R share one run, and
153
+ * a run that succeeded is not repeated by this process. Rejects with R's own
154
+ * explanation when the packages could not be installed.
155
+ */
156
+ export function ensureRPackages(options, logger, deps = defaultDeps()) {
157
+ const { rHome, heraSrcPath } = options;
158
+ if (options.kernelType === 'python' || !rHome || !heraSrcPath)
159
+ return Promise.resolve();
160
+ if (deps.env.JOVIAN_SKIP_R_SETUP)
161
+ return Promise.resolve();
162
+ const rscript = rscriptPath(rHome, deps);
163
+ if (!rscript) {
164
+ logger.debug(`No Rscript under ${rHome}; leaving the R packages to the kernel`);
165
+ return Promise.resolve();
166
+ }
167
+ const key = [rHome, heraSrcPath, options.rLibs ?? ''].join('|');
168
+ let pending = inFlight.get(key);
169
+ if (!pending) {
170
+ pending = setUp(rscript, heraSrcPath, options.rLibs, logger, deps).catch((error) => {
171
+ inFlight.delete(key);
172
+ throw error;
173
+ });
174
+ inFlight.set(key, pending);
175
+ }
176
+ return pending;
177
+ }
178
+ async function setUp(rscript, heraSrcPath, rLibs, logger, deps) {
179
+ const directory = await mkdtemp(join(tmpdir(), 'jovian-r-setup-'));
180
+ try {
181
+ const scriptFile = join(directory, 'setup.R');
182
+ await writeFile(scriptFile, R_SETUP_SCRIPT);
183
+ const env = { ...deps.env, ...(rLibs ? { R_LIBS: rLibs } : {}) };
184
+ let failure;
185
+ const otherOutput = [];
186
+ const onLine = (line) => {
187
+ if (line.startsWith('JOVIAN_R_SETUP_ERROR: '))
188
+ failure = line.slice('JOVIAN_R_SETUP_ERROR: '.length);
189
+ else if (line.startsWith('JOVIAN_R_SETUP: '))
190
+ logger.info(`R setup: ${line.slice('JOVIAN_R_SETUP: '.length)}`);
191
+ else if (line.trim())
192
+ otherOutput.push(line.trim());
193
+ };
194
+ const outcome = await deps.run(rscript, scriptFile, [heraSrcPath], env, onLine, deps.timeoutMs);
195
+ if (outcome.timedOut) {
196
+ throw new Error(`Setting up the R packages the kernel needs took longer than ${Math.round(deps.timeoutMs / 60000)} minutes and was stopped.`);
197
+ }
198
+ if (outcome.code !== 0) {
199
+ const unexplained = `Rscript exited with code ${outcome.code}${otherOutput.length ? `: ${otherOutput.slice(-5).join(' | ')}` : ''}`;
200
+ throw new Error(`Could not set up the R packages the kernel needs: ${failure ?? unexplained}`);
201
+ }
202
+ }
203
+ finally {
204
+ await rm(directory, { recursive: true, force: true });
205
+ }
206
+ }
207
+ //# sourceMappingURL=r-setup.js.map
@@ -0,0 +1,29 @@
1
+ import type { EngineOptions } from '../types/index.js';
2
+ /** Runs a command and returns its last non-empty stdout line, or undefined if it failed or printed nothing. */
3
+ export type Runner = (command: string, args: string[]) => string | undefined;
4
+ export interface DiscoveryContext {
5
+ run: Runner;
6
+ env: Record<string, string | undefined>;
7
+ platform: string;
8
+ }
9
+ /**
10
+ * Where R lives, when the caller did not say: $R_HOME, else what `R RHOME`
11
+ * prints (R's own answer, valid on every platform, if R is on PATH), else on
12
+ * Windows the install path R's installer records in the registry.
13
+ */
14
+ export declare function discoverRHome(context?: DiscoveryContext): string | undefined;
15
+ /**
16
+ * Which Python to embed, when the caller did not say: $PYTHONHOME, else the
17
+ * installation prefix of the first python on PATH. sys.base_prefix, not
18
+ * sys.prefix: inside a virtual environment the latter is the venv, which has
19
+ * no libpython to load.
20
+ */
21
+ export declare function discoverPythonHome(context?: DiscoveryContext): string | undefined;
22
+ /**
23
+ * The options with rHome (R sessions) or pythonHome (Python sessions) filled
24
+ * in when the caller left them out and the runtime could be found. Anything
25
+ * the caller passed is kept as it is; if nothing is found the field stays
26
+ * unset, and the kernel reports what it could not find.
27
+ */
28
+ export declare function withDiscoveredRuntime(options: EngineOptions, context?: DiscoveryContext): EngineOptions;
29
+ //# sourceMappingURL=runtimes.d.ts.map
@@ -0,0 +1,80 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ const runCommand = (command, args) => {
3
+ try {
4
+ const output = execFileSync(command, args, {
5
+ encoding: 'utf8',
6
+ timeout: 10_000,
7
+ stdio: ['ignore', 'pipe', 'ignore'],
8
+ windowsHide: true
9
+ });
10
+ const lines = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
11
+ return lines[lines.length - 1];
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ };
17
+ const defaultContext = () => ({ run: runCommand, env: process.env, platform: process.platform });
18
+ /**
19
+ * Where R lives, when the caller did not say: $R_HOME, else what `R RHOME`
20
+ * prints (R's own answer, valid on every platform, if R is on PATH), else on
21
+ * Windows the install path R's installer records in the registry.
22
+ */
23
+ export function discoverRHome(context = defaultContext()) {
24
+ if (context.env.R_HOME)
25
+ return context.env.R_HOME;
26
+ const fromR = context.run('R', ['RHOME']);
27
+ if (fromR)
28
+ return fromR;
29
+ if (context.platform === 'win32') {
30
+ for (const hive of ['HKLM', 'HKCU']) {
31
+ const line = context.run('reg', ['query', `${hive}\\SOFTWARE\\R-core\\R`, '/v', 'InstallPath']);
32
+ const match = line?.match(/InstallPath\s+REG_SZ\s+(.+)$/);
33
+ if (match?.[1])
34
+ return match[1].trim();
35
+ }
36
+ }
37
+ return undefined;
38
+ }
39
+ /**
40
+ * Which Python to embed, when the caller did not say: $PYTHONHOME, else the
41
+ * installation prefix of the first python on PATH. sys.base_prefix, not
42
+ * sys.prefix: inside a virtual environment the latter is the venv, which has
43
+ * no libpython to load.
44
+ */
45
+ export function discoverPythonHome(context = defaultContext()) {
46
+ if (context.env.PYTHONHOME)
47
+ return context.env.PYTHONHOME;
48
+ const script = 'import sys; print(sys.base_prefix)';
49
+ const candidates = [
50
+ ['python3', ['-c', script]],
51
+ ['python', ['-c', script]]
52
+ ];
53
+ if (context.platform === 'win32')
54
+ candidates.push(['py', ['-3', '-c', script]]);
55
+ for (const [command, args] of candidates) {
56
+ const home = context.run(command, args);
57
+ if (home)
58
+ return home;
59
+ }
60
+ return undefined;
61
+ }
62
+ /**
63
+ * The options with rHome (R sessions) or pythonHome (Python sessions) filled
64
+ * in when the caller left them out and the runtime could be found. Anything
65
+ * the caller passed is kept as it is; if nothing is found the field stays
66
+ * unset, and the kernel reports what it could not find.
67
+ */
68
+ export function withDiscoveredRuntime(options, context = defaultContext()) {
69
+ if (options.kernelType === 'python') {
70
+ if (options.pythonHome)
71
+ return options;
72
+ const pythonHome = discoverPythonHome(context);
73
+ return pythonHome ? { ...options, pythonHome } : options;
74
+ }
75
+ if (options.rHome)
76
+ return options;
77
+ const rHome = discoverRHome(context);
78
+ return rHome ? { ...options, rHome } : options;
79
+ }
80
+ //# sourceMappingURL=runtimes.js.map
@@ -122,7 +122,7 @@ export declare class Session extends EventEmitter {
122
122
  * execute() timed out) first.
123
123
  */
124
124
  interrupt(options?: {
125
- timeout?: number;
125
+ timeout?: number | undefined;
126
126
  }): Promise<boolean>;
127
127
  /**
128
128
  * The latest iopub `status` the kernel reported ('busy' while it is
@@ -145,7 +145,7 @@ export declare class Session extends EventEmitter {
145
145
  * shutdown_request (stop()/restart()) -- the supervisor rejects those.
146
146
  */
147
147
  request<T = any>(msgType: string, content?: Record<string, unknown>, options?: {
148
- timeout?: number;
148
+ timeout?: number | undefined;
149
149
  }): Promise<T>;
150
150
  private trackExecutionState;
151
151
  private routeComm;
@@ -209,11 +209,12 @@ export declare class Session extends EventEmitter {
209
209
  kill(): void;
210
210
  }
211
211
  export declare class SessionManager {
212
+ private readonly logger;
212
213
  private readonly supervisor;
213
214
  private readonly sessions;
214
215
  private exitHandlerRegistered;
215
216
  /** Creates a new R session in its own OS process and waits for it to be ready. */
216
- createSession(options?: EngineOptions): Promise<Session>;
217
+ createSession(requested?: EngineOptions): Promise<Session>;
217
218
  /** Gracefully stops every session managed by this instance. */
218
219
  stopAll(): Promise<void>;
219
220
  /**
@@ -12,6 +12,9 @@ import { ErrorHandler } from '../handlers/error-handler.js';
12
12
  import { DisplayHandler } from '../handlers/display-handler.js';
13
13
  import { findFreePort, waitForPort } from '../utils/network.js';
14
14
  import { SupervisorClient } from './supervisor-client.js';
15
+ import { withDiscoveredRuntime } from './runtimes.js';
16
+ import { ensureRPackages } from './r-setup.js';
17
+ import { bundledHeraSource } from './native-paths.js';
15
18
  import { Comm } from './comm.js';
16
19
  const DEFAULT_REQUEST_TIMEOUT_MS = 10000;
17
20
  // How long stop() waits for the kernel's shutdown_reply after the
@@ -213,7 +216,7 @@ export class Session extends EventEmitter {
213
216
  // The supervisor replaces a session's options wholesale, so send the
214
217
  // merge -- a restart that only switches rHome must keep the
215
218
  // workingDirectory, rLibs, ... the session was created with.
216
- const mergedOptions = options ? { ...this.currentOptions, ...options } : undefined;
219
+ const mergedOptions = options ? withDiscoveredRuntime({ ...this.currentOptions, ...options }) : undefined;
217
220
  // Reassigned synchronously, before awaiting anything below, so a
218
221
  // concurrent execute()/createShiny() call that reads this.readyPromise
219
222
  // while the restart is still in flight waits for the new connection
@@ -222,6 +225,8 @@ export class Session extends EventEmitter {
222
225
  const shutdownReply = this.watchFor('shutdown_reply');
223
226
  this.readyPromise = (async () => {
224
227
  try {
228
+ if (mergedOptions)
229
+ await ensureRPackages(mergedOptions, this.logger);
225
230
  await this.supervisor.restartSession(this.info, mergedOptions);
226
231
  if (mergedOptions) {
227
232
  this.currentOptions = mergedOptions;
@@ -776,11 +781,17 @@ export class Session extends EventEmitter {
776
781
  }
777
782
  }
778
783
  export class SessionManager {
779
- supervisor = new SupervisorClient(new Logger());
784
+ logger = new Logger();
785
+ supervisor = new SupervisorClient(this.logger);
780
786
  sessions = new Set();
781
787
  exitHandlerRegistered = false;
782
788
  /** Creates a new R session in its own OS process and waits for it to be ready. */
783
- async createSession(options = {}) {
789
+ async createSession(requested = {}) {
790
+ // Finds R / Python when rHome / pythonHome were not given (see runtimes.ts).
791
+ // An installed package brings its own copy of hera (none in a source checkout).
792
+ const options = withDiscoveredRuntime(requested.heraSrcPath ? requested : { ...requested, heraSrcPath: bundledHeraSource() });
793
+ // First R session only: installs hera and what it needs (see r-setup.ts).
794
+ await ensureRPackages(options, this.logger);
784
795
  const info = await this.supervisor.createSession(options);
785
796
  const session = new Session(info, options, this.supervisor);
786
797
  this.sessions.add(session);
@@ -9,12 +9,13 @@ export interface EngineOptions {
9
9
  * supervisor actually uses (native/src/themisto/session_registry.cpp's
10
10
  * SessionOptions::kernelType) and which kernel executable it spawns.
11
11
  */
12
- kernelType?: 'r' | 'python';
13
- rHome?: string;
14
- rPath?: string;
15
- rLibs?: string;
12
+ kernelType?: 'r' | 'python' | undefined;
13
+ /** R installation (`R RHOME`). Found from $R_HOME, `R RHOME` or the Windows registry when omitted. */
14
+ rHome?: string | undefined;
15
+ rPath?: string | undefined;
16
+ rLibs?: string | undefined;
16
17
  /** Directory containing the pandoc binary, for bundled R installs that don't ship it on PATH. */
17
- pandocPath?: string;
18
+ pandocPath?: string | undefined;
18
19
  /**
19
20
  * Source directory of the 'hera' R package (packages/hera in this repo).
20
21
  * When set, Elara installs it via remotes::install_local() if it is
@@ -23,13 +24,13 @@ export interface EngineOptions {
23
24
  * already installed in the library -- install or update it with
24
25
  * `npm run hera:install`.
25
26
  */
26
- heraSrcPath?: string;
27
- /** Only used when kernelType is 'python' -- Carpo's equivalent of rHome. */
28
- pythonHome?: string;
27
+ heraSrcPath?: string | undefined;
28
+ /** Only used when kernelType is 'python' -- Carpo's equivalent of rHome. Found from $PYTHONHOME or `python3`/`python` on PATH when omitted. */
29
+ pythonHome?: string | undefined;
29
30
  /** Only used when kernelType is 'python' -- Carpo's equivalent of rPath. */
30
- pythonPath?: string;
31
+ pythonPath?: string | undefined;
31
32
  /** Only used when kernelType is 'python' -- not yet consulted by Carpo itself (see carpo::EnvironmentConfig). */
32
- venvPath?: string;
33
+ venvPath?: string | undefined;
33
34
  /**
34
35
  * Directory the kernel process starts in -- what `getwd()` (R) /
35
36
  * `os.getcwd()` (Python) report and what relative paths resolve against.
@@ -37,44 +38,44 @@ export interface EngineOptions {
37
38
  * (i.e. the calling process's), which is rarely what you want for a
38
39
  * notebook/project: set it to the project or document folder.
39
40
  */
40
- workingDirectory?: string;
41
- queueSize?: number;
42
- enableLogging?: boolean;
43
- enableMetrics?: boolean;
44
- logger?: LoggerFunction;
41
+ workingDirectory?: string | undefined;
42
+ queueSize?: number | undefined;
43
+ enableLogging?: boolean | undefined;
44
+ enableMetrics?: boolean | undefined;
45
+ logger?: LoggerFunction | undefined;
45
46
  }
46
47
  export type EngineState = 'idle' | 'starting' | 'running' | 'stopping' | 'stopped' | 'error';
47
48
  export interface ExecutionOptions {
48
- silent?: boolean;
49
- storeHistory?: boolean;
50
- allowStdin?: boolean;
49
+ silent?: boolean | undefined;
50
+ storeHistory?: boolean | undefined;
51
+ allowStdin?: boolean | undefined;
51
52
  /**
52
53
  * When this execution fails, abort every execute() still waiting behind
53
54
  * it in the queue instead of running them (Jupyter's stop_on_error) --
54
55
  * their results come back with `aborted: true` and nothing having run.
55
56
  * Also forwarded to the kernel in the execute_request itself.
56
57
  */
57
- stopOnError?: boolean;
58
+ stopOnError?: boolean | undefined;
58
59
  /**
59
60
  * Expressions to evaluate in the kernel right after the code runs, as
60
61
  * {name: expression} (Jupyter's user_expressions). Only evaluated when
61
62
  * the code succeeded; each result -- or its own error -- comes back in
62
63
  * `ExecutionResult.userExpressions` under the same name.
63
64
  */
64
- userExpressions?: Record<string, string>;
65
+ userExpressions?: Record<string, string> | undefined;
65
66
  /**
66
67
  * Milliseconds to wait for the execution to finish before giving up
67
68
  * (default 30000; 0 = no timeout, for calls meant to run indefinitely
68
69
  * such as a Shiny app).
69
70
  */
70
- timeout?: number;
71
+ timeout?: number | undefined;
71
72
  /**
72
73
  * When the timeout fires, also send the kernel an interrupt (default
73
74
  * true) so it stops the code instead of carrying on with work no one is
74
75
  * waiting for -- which would otherwise block everything queued behind it.
75
76
  * Set false to leave the kernel running after a timeout.
76
77
  */
77
- interruptOnTimeout?: boolean;
78
+ interruptOnTimeout?: boolean | undefined;
78
79
  }
79
80
  /** One evaluated user expression: its rich value, or the error evaluating it raised. */
80
81
  export type UserExpressionResult = {
@@ -131,19 +132,19 @@ export interface ExecutionHistoryEntry {
131
132
  */
132
133
  export interface KernelHistoryOptions {
133
134
  /** Defaults to 'tail' -- the n most recent executions. */
134
- histAccessType?: 'tail' | 'range' | 'search';
135
+ histAccessType?: 'tail' | 'range' | 'search' | undefined;
135
136
  /** Include each entry's output alongside its input. Defaults to false -- the kernel doesn't actually record output today either way, so this currently only ever comes back empty. */
136
- output?: boolean;
137
- raw?: boolean;
137
+ output?: boolean | undefined;
138
+ raw?: boolean | undefined;
138
139
  /** Max entries to return ('tail'/'search'). Defaults to 100. */
139
- n?: number;
140
+ n?: number | undefined;
140
141
  /** 'range' only. */
141
- session?: number;
142
- start?: number;
143
- stop?: number;
142
+ session?: number | undefined;
143
+ start?: number | undefined;
144
+ stop?: number | undefined;
144
145
  /** 'search' only -- a glob pattern (*, ?). */
145
- pattern?: string;
146
- unique?: boolean;
146
+ pattern?: string | undefined;
147
+ unique?: boolean | undefined;
147
148
  }
148
149
  /**
149
150
  * One entry from a real history_reply: [session, line_number, input], or
@@ -179,13 +180,13 @@ export interface ShinyAppOptions {
179
180
  /** Directory containing the Shiny app (server.R/ui.R or app.R). */
180
181
  appDir: string;
181
182
  /** Defaults to an OS-assigned free port. */
182
- port?: number;
183
+ port?: number | undefined;
183
184
  /** Defaults to '127.0.0.1'. */
184
- host?: string;
185
+ host?: string | undefined;
185
186
  /** Defaults to false -- the caller decides how/where to display the app. */
186
- launchBrowser?: boolean;
187
+ launchBrowser?: boolean | undefined;
187
188
  /** Max time to wait for the app to start accepting connections, in ms. Defaults to 10000. */
188
- readyTimeout?: number;
189
+ readyTimeout?: number | undefined;
189
190
  /**
190
191
  * Environment variables to set (via Sys.setenv()) in the R session
191
192
  * before launching the app -- e.g. rmncah's app.R reads
@@ -195,7 +196,7 @@ export interface ShinyAppOptions {
195
196
  * that reads them after runApp() starts, since Sys.setenv() itself runs
196
197
  * synchronously right before it in the same execute() call.
197
198
  */
198
- env?: Record<string, string>;
199
+ env?: Record<string, string> | undefined;
199
200
  }
200
201
  export interface ShinyAppHandle {
201
202
  host: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@damurka/jovian",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "license": "MIT",
5
5
  "author": "David Kariuki",
6
6
  "repository": {
@@ -50,8 +50,8 @@
50
50
  "LICENSE"
51
51
  ],
52
52
  "optionalDependencies": {
53
- "@damurka/jovian-win32-x64": "0.1.0",
54
- "@damurka/jovian-linux-x64": "0.1.0",
55
- "@damurka/jovian-darwin-arm64": "0.1.0"
53
+ "@damurka/jovian-win32-x64": "0.1.1",
54
+ "@damurka/jovian-linux-x64": "0.1.1",
55
+ "@damurka/jovian-darwin-arm64": "0.1.1"
56
56
  }
57
57
  }
@@ -26,4 +26,4 @@ Imports:
26
26
  URL: https://github.com/damurka/jovian
27
27
  BugReports: https://github.com/damurka/jovian/issues
28
28
  Config/roxygen2/version: 8.0.0
29
- Config/jovian/release: 0.1.0
29
+ Config/jovian/release: 0.1.1