Skip to content

feat: run the engine on Node through JSR npm-compat, off Deno globals - #161

Merged
EthanThatOneKid merged 2 commits into
mainfrom
cross-runtime-engine
Oct 13, 2026
Merged

EthanThatOneKid merged 2 commits into
mainfrom
cross-runtime-engine

Conversation

@EthanThatOneKid

@EthanThatOneKid EthanThatOneKid commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

What this does

Removes every Deno.* global from src/ so JSR's npm-compatibility package
runs on Node, not just installs there.

npx jsr add @wazoo/workspace already required no Deno, and Node 24 already
loaded the entire module graph (transpiled .js beside the .ts, @/x.ts
rewritten to correct relative paths, jsr:@std/cli rewritten to
@jsr/std__cli, import.meta.main honoured). It then died at
ReferenceError: Deno is not defined. JSR transpiles but does not shim Deno
globals — that was the whole barrier.

Closes #160. Also closes #159: the version moves to 0.8.0, which is what makes
the already-merged sweep/purge reachable at all (JSR is immutable, so they
could never ship from 0.7.0).

Two commits, because the first attempt was not enough

a6d4191 ports src/ off Deno.*. 0ab53ef fixes what that missed, and it is
the more interesting half: the engine still could not run on Node, because
@std/fs is Deno-only. Its exists and walk call the Deno global
directly, in the TypeScript source and in the JavaScript JSR ships to npm.
Seven modules imported exists and one imported walk, so the CLI died at
ReferenceError: Deno is not defined during manifest discovery, before any
command could run.

deno test could not catch this and did not: Deno defines the Deno global, so
193 tests were green with the dependency in place. It appeared only when the
module graph was executed on Node — which is the entire point of the change, and
the reason the verification below is not "the suite passes".

Shape of the change

  • One runtime edge. src/runtime.ts is the only module allowed to import
    node:* (child_process, fs/promises, os, process). Deno implements
    those specifiers natively, so a single implementation serves both runtimes.
  • That edge also owns traversal, because @std/fs cannot. pathExists and
    walkDirectoryTree replace exists and walk. walkDirectoryTree keeps
    @std/fs/walk's observable semantics on purpose, since wspace path ranks
    what it is handed: the root is returned first, maxDepth counts the root as
    depth 0, skip patterns match the whole path rather than the basename, a
    skipped directory is neither returned nor descended into, and symlinks are
    never followed. pathExists documents its one divergence: a path that cannot
    be stat'd reads as absent, where exists reports it as present.
  • Subprocess semantics preserved. runProcess resolves for every exit code
    including non-zero, and rejects only when the binary cannot be spawned. That
    rejection is load-bearing: it is how createGitHubRepoWithFallback recognises
    "gh CLI is not available". Stdin is detached so git cannot hang on a
    credential prompt.
  • Host differences normalised in exactly one place each. isWindows() for
    "windows" vs "win32"; cliArgs() for process.argv.slice(2); exit via
    process.exitCode rather than process.exit so piped --json output is not
    truncated.
  • @types/node is the only new dependency — type-only, resolved through
    Deno's global cache, so no node_modules/ directory appears in the repo.
  • Bonus fix: deno task build allowed only --allow-run=git, but the code
    also spawns gh and robocopy/rmdir/rm. Since deno compile bakes
    permissions in, those paths would have thrown PermissionDenied.
  • docs/adr/0007-runtime-neutral-engine.md records the decision, the sandbox
    regression, and the @std/fs finding; AGENTS.md amends the "@std only"
    convention; the README documents the no-Deno install.

Verification

Gate Result
deno task ci 199 passed / 0 failed / 1 ignored (the ignored test creates a symlink, which needs Developer Mode on Windows)
cold-cache deno check src/ tests/ (fresh DENO_DIR, the CI path) green, no node_modules
runtime edge under Node (direct) non-zero exit resolves (128), missing binary rejects, cwd/fs/cliArgs/exit-code all correct
check --json on Node vs the published 0.7.0 binary identical across all 39 entries
path worlds-api on Node vs the binary identical stdout
path wiki (ambiguous) on Node vs the binary identical stderr, identical exit 1
sweep --dry-run --json on Node exits 0
@jsr/std__fs in the staged graph unreachable; @std/path and @std/cli are runtime-neutral (std__path guards with global.process?.cwd ?? global.Deno?.cwd, std__cli with "Deno" in globalThis)

The Node rows come from reproducing JSR's specifier rewrites locally (the @/
alias, and jsr:@std/* → @jsr/std__*) and executing the resulting graph with
node --experimental-transform-types. The pre-existing Deno tests are untouched
and remain the behaviour-parity gate.

What I could not verify

Only JSR's server-side packaging. It builds the npm-compat tarball at publish
time, and deno transpile output is not directly Node-loadable (it leaves
jsr: specifiers), so the final link is a real
npx jsr add @wazoo/workspace@0.8.0 followed by
node node_modules/@wazoo/workspace/src/cli.js check --json on the published
artifact. The rewrites that publish applies were reproduced by hand for every
row above; the packaging step itself is the one part not exercised.

The merge that would prove it also publishes 0.8.0, and so is approval-gated.

Known and accepted

Port every `Deno.*` call in `src/` to a single runtime edge, `src/runtime.ts`,
so JSR's npm-compatibility package executes on Node with no wrapper and no
compiled binary. `npx jsr add @wazoo/workspace` already needed no Deno and
Node 24 already loaded the whole module graph; the only blocker was
`ReferenceError: Deno is not defined`.

- `src/runtime.ts` is the only module permitted to import `node:*`. It uses
  `node:child_process`, `node:fs/promises`, `node:os` and `node:process`, and
  preserves `Deno.Command#output()` semantics that callers depend on: it
  resolves for every exit code, and rejects only when the binary cannot be
  spawned, which is how `createGitHubRepoWithFallback` reports a missing `gh`.
- Host differences are normalised once, never at call sites: `isWindows()`
  (`Deno.build.os` says "windows", `process.platform` says "win32"),
  `cliArgs()` (`process.argv.slice(2)`, correct for `deno run`, a compiled
  binary, and `node <script>`), and exit via `process.exitCode` so piped
  `--json` output is never truncated by an immediate exit.
- `@types/node` is the sole non-`@std` dependency: type-only, resolved through
  Deno's global cache, creating no `node_modules/` directory.
- Fix the compiled binary's baked permissions. The code spawns `gh` (`add
  --create`) and `robocopy`/`rmdir`/`rm` (the ADR-0006 purge ladder) as well as
  `git`, so `--allow-run=git` alone would throw `PermissionDenied`.
- Move to 0.8.0, which also delivers the already-merged but unpublished
  `sweep`/`purge` commands to users.
- ADR-0007 records the decision, including the Deno sandbox regression for Node
  consumers; the README documents the no-Deno install.

Verified: `deno task ci` 193 passed / 0 failed; a cold-cache `deno check src/
tests/` is green (the CI path, with no `node_modules`); and the runtime edge was
exercised directly under Node — non-zero exit resolves, a missing binary
rejects, and `cwd`/fs/argv/exit behaviour all hold. End-to-end proof on the
published artifact is an `-rc` publish, pending approval.

Closes #160
Closes #159
The previous commit removed every `Deno.*` call from `src/`, and the engine
still could not run on Node: `@std/fs` — a dependency, not our code — calls the
`Deno` global directly. Its `exists` and `walk` reach `Deno.stat`, `Deno.readDir`
and `Deno.errors.NotFound` in the TypeScript source *and* in the JavaScript JSR
ships to npm. Seven modules imported `exists` and one imported `walk`, so the CLI
died at `ReferenceError: Deno is not defined` during manifest discovery, before
any command could run. `deno test` could not catch this and did not: Deno defines
the `Deno` global, so the suite stays green with the dependency in place. It
surfaced only when the module graph was actually executed on Node.

- `src/runtime.ts` now owns the two shapes `@std/fs` provided, as `pathExists`
  and `walkDirectoryTree`, so the runtime edge covers host access *and*
  filesystem traversal. `src/` no longer imports `@std/fs` at all.
- `walkDirectoryTree` keeps `@std/fs/walk`'s observable semantics, because
  `wspace path` ranks what it is handed: the root is returned first, `maxDepth`
  counts the root as depth 0, `skip` patterns match the whole path rather than the
  basename, a skipped directory is neither returned nor descended into, and
  symlinks are never followed.
- `pathExists` documents the one deliberate divergence: a path that cannot be
  stat'd reads as absent, where `exists` reports it as present.
- Seven call sites move from `exists` to `pathExists`; `src/commands/path.ts`
  moves from `walk` to `walkDirectoryTree`.
- `@std/fs` stays a *test* dependency (`exists`, `expandGlob`); JSR omits
  test-only dependencies from the published package.
- ADR-0007 records the finding, and replaces its claim that an `-rc` publish is
  the only end-to-end proof, since a local run caught this one.

Verified: `deno task ci` is 199 passed / 0 failed / 1 ignored (a symlink test
skipped on Windows). Reproducing JSR's specifier rewrites locally and executing
the resulting graph on Node 24 gives parity with the published 0.7.0 binary:
`check --json` identical across all 39 entries, `path worlds-api` identical,
`path wiki` identical including its ambiguous-match stderr and exit 1, and
`sweep --dry-run --json` exits 0. `@jsr/std__fs` is unreachable from the graph,
and the remaining `@std/path` and `@std/cli` dependencies are runtime-neutral
(`std__path` guards with `global.process?.cwd ?? global.Deno?.cwd`, `std__cli`
with `"Deno" in globalThis`).

Refs #160
@EthanThatOneKid
EthanThatOneKid merged commit b2b6f01 into main Oct 13, 2026
1 check passed
@EthanThatOneKid
EthanThatOneKid deleted the cross-runtime-engine branch October 13, 2026 19:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant