Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,15 @@ does not manage their location or lifecycle.

## Conventions

- Deno 2, TypeScript, strict mode. `@std` packages are the only allowed
dependencies (runtime included); nothing outside `@std`.
- Deno 2, TypeScript, strict mode. `@std` packages are the default dependency;
the sole exception is the runtime edge, `src/runtime.ts`, which uses `node:`
builtins so the engine also runs on Node through JSR's npm-compatibility layer
(see ADR-0007). It is the only module allowed to import `node:*`, and the only
one permitted to reference `npm:@types/node`. `src/` must not import `@std/fs`
either: that package is Deno-only (`exists` and `walk` call the `Deno` global,
so it works under `deno test` and throws under Node), which is why existence
checks and tree walks go through the runtime edge's `pathExists` and
`walkDirectoryTree`.
- TypeScript source in `src/`, tests in `tests/`. `tests/integration_test.ts`
exercises real local git repositories under a temp dir.
- Machine-readable git: use `--porcelain` output formats and `GitRunner` (an
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ Use the `/cli` subpath export — the runnable CLI entry — not the bare
`jsr:@wazoo/workspace` package (which exposes the library and produces a no-op
`wspace` when installed globally). `-A` grants the read/write/run permissions
`wspace` needs; omit it if you prefer to grant specific flags (e.g.
`--allow-read --allow-write --allow-run=git`) or use Prompts.
`--allow-read --allow-write --allow-run=git,gh,robocopy,rmdir,rm`) or use
Prompts.

Or build a local binary:

Expand All @@ -217,6 +218,19 @@ deno task build

Requires a Deno runtime (v2+). The `wspace` binary has no runtime dependencies.

### Running without a Deno install

JSR also serves the package in Node-compatible form, which needs no Deno
installation:

```sh
npx jsr add @wazoo/workspace
node node_modules/@wazoo/workspace/src/cli.js check --json
```

JSR forwards `exports` but not `bin`, so this runs the CLI rather than putting a
`wspace` command on `PATH`; add a one-line wrapper if you want one.

## Development

```sh
Expand Down
7 changes: 4 additions & 3 deletions deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wazoo/workspace",
"version": "0.7.0",
"version": "0.8.0",
"license": "MIT",
"exports": {
".": "./src/mod.ts",
Expand All @@ -15,10 +15,11 @@
"@std/cli": "jsr:@std/cli@^1.0.0",
"@std/fs": "jsr:@std/fs@^1.0.0",
"@std/path": "jsr:@std/path@^1.0.0",
"@std/yaml": "jsr:@std/yaml@^1.2.0"
"@std/yaml": "jsr:@std/yaml@^1.2.0",
"@types/node": "npm:@types/node@^22"
},
"tasks": {
"build": "deno compile --allow-read --allow-write --allow-run=git --output wspace src/cli.ts",
"build": "deno compile --allow-read --allow-write --allow-run=git,gh,robocopy,rmdir,rm --output wspace src/cli.ts",
"ci": "deno fmt --check && deno lint && deno task lint:imports && deno check src/ tests/ && deno task test",
"lint:imports": "deno run --allow-read scripts/lint_imports.ts",
"test": "deno test --allow-read --allow-write --allow-run --allow-env"
Expand Down
17 changes: 15 additions & 2 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

139 changes: 139 additions & 0 deletions docs/adr/0007-runtime-neutral-engine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# ADR-0007: The engine runs on Deno and Node, through one runtime edge

- Status: accepted
- Date: 2026-10
- Amends: ADR-0006 (its purge strategies named `Deno.remove` directly)
- Related: issues [#160](https://github.com/wazootech/workspace-cli/issues/160),
[#159](https://github.com/wazootech/workspace-cli/issues/159),
[#135](https://github.com/wazootech/workspace-cli/issues/135),
[#29](https://github.com/wazootech/workspace-cli/issues/29)

## Context

`wspace` had exactly one distribution path that did not require a Deno
installation: a compiled binary built by `deno task build`. Everything else —
`deno run jsr:@wazoo/workspace/cli`, `deno install` — presumes Deno on the
machine.

JSR also serves an **npm-compatibility** form of every package, which
`npx jsr
add @wazoo/workspace` installs without Deno present at all. Measured
against the published `0.7.0`, that form is _mechanically_ ready:

- it ships transpiled `.js` beside the `.ts`,
- it rewrites the `@/x.ts` alias to the correct relative path per directory, and
`jsr:@std/fs` to `@jsr/std__fs`,
- Node 24 loaded the whole module graph, resolved `@jsr/std__cli/parse-args`,
and honoured `import.meta.main`.

It then died at `ReferenceError: Deno is not defined`. JSR transpiles; it does
not shim Deno globals. That single fact — not packaging, not the alias, not the
`@std` specs — was the whole barrier between "JSR serves a Node package" and
"JSR serves a Node package that runs".

That error had a second source the first pass missed. The CLI's own `Deno.*`
calls were the visible ones; `@std/fs` — a dependency, not our code — reaches
the `Deno` global too, in `exists` and `walk`.

Two constraints frame the decision. JSR's npm-compat layer forwards only
`exports`, never `bin`
([#135](https://github.com/wazootech/workspace-cli/issues/135)), so a `wspace`
**command** on `PATH` still needs a wrapper; the port buys a runnable **library
and CLI entry**, not a shim. And the alternative route — compiled binaries per
platform ([#29](https://github.com/wazootech/workspace-cli/issues/29)) — ships
no cross-platform runtime code but does ship a large artifact matrix.

## Decision

1. **Remove every `Deno.*` global from `src/`.** `Deno.args` and `Deno.exit` in
`src/cli.ts` were the last two.

2. **Confine all host access to one module, `src/runtime.ts`.** It imports
`node:child_process`, `node:fs/promises`, `node:os` and `node:process`, which
Deno implements natively, so a single implementation serves both runtimes.
Nothing else in `src/` may import `node:*` **or `@std/fs`** (item 8). This is
the one documented exception to the "@std only" rule; `src/runtime.ts` is
also the only module permitted to reference `npm:@types/node`, which is now
the project's sole non-`@std` dependency (type-only, declared in
`deno.json`).

3. **`runProcess` preserves the two subprocess behaviours callers depend on.**
It resolves for _every_ exit code — including non-zero — so callers keep
classifying failure from `code`, and it rejects only when the binary cannot
be spawned at all. That rejection is load-bearing: it is how
`createGitHubRepoWithFallback` recognises "gh CLI is not available". Stdin is
detached so a subprocess cannot block on a credential prompt.

4. **Host differences are normalised in exactly one place each, and never by
hand at a call site.** `Deno.build.os` says `"windows"` where
`process.platform` says `"win32"`, so Windows checks go through
`isWindows()`. `Deno.args` has no Node equivalent, so `cliArgs` derives
arguments from `process.argv.slice(2)`, which is correct in all three
invocation shapes — `deno run` (`[deno, script, ...]`), a compiled binary
(`[exe, exe, ...]`, Deno duplicates the path to mimic Node), and
`node <script>` (`[node, script, ...]`).

5. **Exiting sets `process.exitCode` rather than calling `process.exit`.**
Exiting immediately can truncate buffered stdout when output is piped, which
would corrupt the `--json` contract that tooling consumes. Both runtimes
honour the exit code.

6. **The engine stays Deno-tested.** The existing Deno test suite is not
rewritten; it is the behaviour-parity gate, and Deno loads `node:` builtins
natively. `tests/runtime_test.ts` covers the runtime edge directly, including
the non-zero-exit and missing-binary branches above.

7. **The compiled binary's permission set is corrected.** `deno task build`
allowed only `--allow-run=git`, but the code also spawns `gh`
(`wspace add
--create`) and `robocopy`, `rmdir` and `rm` (the ADR-0006
purge ladder); `deno compile` bakes permissions in, so those paths would
throw `PermissionDenied`. The allowlist now names all five.

8. **`@std/fs` is removed from `src/`, because it is Deno-only.** Its `exists`
and `walk` call the `Deno` global directly — in the TypeScript source _and_
in the JavaScript JSR ships to npm — so a module that imports them
type-checks and tests green on Deno, then throws
`ReferenceError: Deno is not defined` on Node. `src/runtime.ts` now owns the
two shapes the CLI needed, `pathExists` and `walkDirectoryTree`, and keeps
`@std/fs/walk`'s observable semantics on purpose: 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. `@std/path` and `@std/cli`
are runtime-neutral and stay in use. `@std/fs` remains a _test_ dependency
(`exists`, `expandGlob`), and JSR was measured to omit test-only dependencies
from the published package.

## Consequences

- **The npm-compat package becomes runnable, not merely installable.**
`npx jsr
add @wazoo/workspace` still needs no Deno, and
`node .../src/cli.js <cmd>` now executes. ADR-0006's purge ladder,
`wspace check --json`, and the rest of the engine come with it.
- **No `node_modules/` directory is created for the repo.** `@types/node` is
resolved through Deno's global cache, so `deno check`, `deno compile` and the
publish flow are unaffected by the new dependency being npm-shaped.
- **The Deno sandbox no longer constrains Node consumers — a real regression.**
Under Deno, `--allow-run=git,gh,robocopy,rmdir,rm` and `--allow-read`/`-write`
are enforced; the same code run on Node runs with the user's full ambient
authority. That is inherent to "runs on Node", and worth stating plainly
rather than discovering later.
- **Getting `wspace` onto `PATH` is explicitly out of scope.** JSR does not
forward `bin`, and this ADR does not add an npm wrapper; [#135] tracks that
separately.
- **The version bumps.** JSR is immutable, so the port and the previously
unshipped `sweep`/`purge` ([#159]) both require moving off the published
`0.7.0`; the release carries both.
- **The Deno test suite could not have caught the `Deno` dependency, and did
not.** A green `deno task ci` with `@std/fs` imported from `src/` is expected,
because Deno defines the `Deno` global; the defect appears only when the same
module graph runs under Node. It was found by reproducing JSR's specifier
rewrites locally and executing the result on Node, which is the closest
available stand-in for the published tarball. JSR's server-side packaging
remains the one link a local run cannot exercise; it applies the same rewrites
that were reproduced by hand.
- **A `wspace path` traversal is now the CLI's own code.** `walkDirectoryTree`
is ~40 lines to maintain and read in place of a dependency, and it is covered
by tests that pin the semantics `@std/fs/walk` had, so the substitution is
observable rather than assumed.
5 changes: 3 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
manifestPaths,
resolveWorkspaceTree,
} from "./manifest.ts";
import { cliArgs, exit } from "./runtime.ts";
import { flattenResolved, resolveManifestPath } from "./shared.ts";
import type { CliOptions } from "./shared.ts";

Expand Down Expand Up @@ -242,7 +243,7 @@ async function installConverged(

export async function main(): Promise<number> {
try {
return await run(Deno.args);
return await run(cliArgs());
} catch (error) {
if (error instanceof CliHelp) {
return 0;
Expand All @@ -253,5 +254,5 @@ export async function main(): Promise<number> {
}

if (import.meta.main) {
main().then((code) => Deno.exit(code));
main().then((code) => exit(code));
}
5 changes: 3 additions & 2 deletions src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { BARE_DEFAULT_HOST, resolveRepository } from "@/resolve.ts";
import { validateManifestText } from "@/manifest.ts";
import { validateRepositoryName } from "@/names.ts";
import { createGitHubRepo } from "@/remote.ts";
import { readTextFile, writeTextFile } from "@/runtime.ts";
import type { CliOptions, EditRow } from "@/shared.ts";
import {
applyEntryEdit,
Expand Down Expand Up @@ -53,7 +54,7 @@ async function runAdd(
return 2;
}

const raw = await Deno.readTextFile(manifestPath);
const raw = await readTextFile(manifestPath);
const newText = applyEntryEdit(
raw,
manifestExtension(manifestPath),
Expand All @@ -75,7 +76,7 @@ async function runAdd(
rows.push({ name: entryName, action: "ADDED" });
printRows(rows, opts.json);
if (!opts.dryRun) {
await Deno.writeTextFile(manifestPath, newText);
await writeTextFile(manifestPath, newText);
console.log(`Next: run \`wspace install ${entryName}\` to clone it.`);
}
return 0;
Expand Down
7 changes: 4 additions & 3 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type RawManifest,
validateManifest,
} from "@/manifest.ts";
import { currentDirectory, makeDirectory, writeTextFile } from "@/runtime.ts";
import type { CliOptions } from "@/shared.ts";

/**
Expand All @@ -19,7 +20,7 @@ import type { CliOptions } from "@/shared.ts";
* anything is written.
*/
export async function run(opts: CliOptions): Promise<number> {
const cwd = Deno.cwd();
const cwd = currentDirectory();
const target = opts.manifestPath
? resolve(cwd, opts.manifestPath)
: resolve(cwd, DEFAULT_MANIFEST_FILENAMES[0] + MANIFEST_EXTENSIONS[0]);
Expand Down Expand Up @@ -52,8 +53,8 @@ export async function run(opts: CliOptions): Promise<number> {
return 2;
}

await Deno.mkdir(join(targetDir, "repos"), { recursive: true });
await Deno.writeTextFile(target, JSON.stringify(doc, null, 2) + "\n");
await makeDirectory(join(targetDir, "repos"));
await writeTextFile(target, JSON.stringify(doc, null, 2) + "\n");

console.log(`Created ${target} (schema v${CURRENT_SCHEMA_VERSION})`);
console.log("Created repos/");
Expand Down
10 changes: 5 additions & 5 deletions src/commands/install.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { exists } from "@std/fs";
import { join } from "@std/path";
import { clone } from "@/git.ts";
import type { GitRunner } from "@/git.ts";
import { type ManifestPaths, resolveRepositoryPath } from "@/manifest-paths.ts";
import type { CliOptions } from "@/cli-options.ts";
import { printRows } from "@/output.ts";
import { makeDirectory, pathExists } from "@/runtime.ts";
import type { WorkspaceManifest } from "@/types.ts";
import { findExistingManifest } from "@/manifest-discovery.ts";

Expand Down Expand Up @@ -51,9 +51,9 @@ async function cloneMissing(
{ dryRun = false }: { dryRun?: boolean } = {},
): Promise<InstallRow[]> {
if (!dryRun) {
await Deno.mkdir(paths.repositoriesDirectory, { recursive: true });
await makeDirectory(paths.repositoriesDirectory);
if (paths.workspacesDirectory !== undefined) {
await Deno.mkdir(paths.workspacesDirectory, { recursive: true });
await makeDirectory(paths.workspacesDirectory);
}
}
let repositories = manifest.repositories;
Expand All @@ -74,14 +74,14 @@ async function cloneMissing(
const rows: InstallRow[] = [];
for (const repository of repositories) {
const repoPath = resolveRepositoryPath(repository, paths);
if (await exists(repoPath)) {
if (await pathExists(repoPath)) {
const invalidWorkspace = await workspaceManifestMissing(
repository,
repoPath,
);
if (invalidWorkspace) {
rows.push(invalidWorkspace);
} else if (await exists(join(repoPath, ".git"))) {
} else if (await pathExists(join(repoPath, ".git"))) {
rows.push({ name: repository.name, state: "EXISTS" });
} else {
rows.push({
Expand Down
6 changes: 3 additions & 3 deletions src/commands/path.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { relative } from "@std/path";
import { walk } from "@std/fs/walk";
import { walkDirectoryTree } from "@/runtime.ts";
import type { ManifestPaths } from "@/manifest.ts";
import type { CliOptions } from "@/shared.ts";
import type { WorkspaceManifest } from "@/types.ts";
Expand Down Expand Up @@ -46,8 +46,8 @@ async function searchWorkspace(
]);

try {
for await (
const entry of walk(paths.root, {
for (
const entry of await walkDirectoryTree(paths.root, {
maxDepth: 3,
includeFiles: false,
skip: [/^\./, /node_modules/, /^\.git$/],
Expand Down
Loading
Loading