Skip to content
Draft
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
28 changes: 28 additions & 0 deletions .changeset/cuddly-pugs-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@cartesi/cli": minor
---

Replace the `xgenext2fs` and `cartesi-machine` subprocesses with native bindings

ext2 drives are now built with [`@deroll/genext2fs`](https://deroll.dev/genext2fs), and the
Cartesi machine is configured, booted, stored and hashed with
[`@deroll/cm`](https://deroll.dev/cm). Both are N-API addons, so `build`, `shell` and `status`
no longer shell out to `xgenext2fs`, `cartesi-machine` or `cartesi-machine-stored-hash`, and no
longer fall back to running them inside the SDK Docker image. Docker is still required to build
the root drive from a Dockerfile, and for squashfs drives (`mksquashfs`).

Notable consequences:

- **The machine emulator moved from 0.20 to 0.21**, which is the version `@deroll/cm` links
against. Machine hashes change, and applications have to be redeployed.
- **The Linux kernel image is downloaded and cached.** With no SDK image to take it from, the
default `ram_image` now comes from the pinned `cartesi/machine-linux-image` v0.21.0 release,
fetched on first use into `$XDG_CACHE_HOME/cartesi/images` (`~/.cache/cartesi/images`) and verified
against its SHA-256. A `CARTESI_IMAGES_PATH` directory containing the image is used when set,
and `machine.ram_image` in `cartesi.toml` still takes precedence over both.
- **Boot args are no longer double quoted.** The old code passed `--append-bootargs="<arg>"` to
the CLI without a shell, so the quotes ended up in the kernel command line. They are gone now.
- **The standalone binaries are no longer built or released.** A Bun single file executable has
no `node_modules`, and both addons resolve their platform specific `.node` at runtime, so they
cannot be embedded — not even for the host platform. npm is the only distribution now, and the
homebrew formula has to install the package from there.
11 changes: 0 additions & 11 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,6 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Release CLI binaries
if: ${{ steps.changeset.outputs.published == 'true' && contains(fromJSON(steps.changeset.outputs.publishedPackages).*.name, '@cartesi/cli') }}
run: |
for f in cartesi-*; do tar -czf "$f.tar.gz" "$f"; done
VERSION=$(jq -r '.[] | select(.name=="@cartesi/cli") | .version' <<< '${{ steps.changeset.outputs.publishedPackages }}')
TAG="@cartesi/cli@${VERSION}"
gh release upload "$TAG" cartesi-*.tar.gz
working-directory: ./apps/cli/bin
env:
GH_TOKEN: ${{ github.token }}

build_sdk:
name: Build SDK
needs: release
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ bun test apps/cli/tests/unit/config.test.ts # Run a single test
bun run build --filter @cartesi/devnet
```

The CLI build pipeline (`apps/cli`): `clean` → `codegen` (wagmi ABI generation) → `compile` (Bun bundler → `dist/`). It also produces native binaries for darwin-arm64, darwin-x64, linux-arm64, linux-x64 in `apps/cli/bin/`.
The CLI build pipeline (`apps/cli`): `clean` → `codegen` (wagmi ABI generation) → `compile` (Bun bundler → `dist/`). `@deroll/cm` and `@deroll/genext2fs` are left external — they are native addons that resolve their platform binary at runtime and cannot be bundled, which is also why there are no standalone `bun --compile` binaries.

## Architecture

Expand All @@ -55,7 +55,9 @@ The CLI build pipeline (`apps/cli`): `clean` → `codegen` (wagmi ABI generation
- **`commands/`** — Each file exports a `create*Command()` function returning a Commander command. Main commands: `build`, `run`, `deploy`, `send`, `deposit`, `create`, `doctor`, `shell`, `clean`, `hash`, `logs`, `status`, `address-book`.
- **`builder/`** — Drive builder implementations (directory, docker, tar, empty, none). Each builder produces ext2 or SquashFS filesystems for Cartesi Machine drives.
- **`compose/`** — Docker Compose service definitions generated as TypeScript objects (anvil, node, bundler, database, paymaster, proxy, explorer, etc.).
- **`exec/`** — Wrappers around subprocess execution (cartesi-machine, rollups) using `execa`.
- **`exec/`** — Machine and filesystem tooling. `cartesi-machine`, `cartesi-machine-stored-hash` and `genext2fs` are native N-API bindings (`@deroll/cm`, `@deroll/genext2fs`); `mksquashfs` and `rollups` still spawn subprocesses via `execa`, falling back to `docker run` against the SDK image.
- **`machine.ts`** — Translates a `cartesi.toml` `Config` into an emulator `MachineConfig` (bootargs, `dtb.init`, flash drives), mirroring what the `cartesi-machine` CLI does with its command line.
- **`images.ts`** — Downloads and caches the Linux kernel image the machine boots, from a pinned `cartesi/machine-linux-image` release.
- **`config.ts`** — Parses `cartesi.toml` (TOML-based project config) into typed `Config` objects. Defines drive configs, machine configs, and SDK versions.
- **`contracts.ts`** — Generated contract addresses and ABI bindings (via `@wagmi/cli`).
- **`wallet.ts`** — Wallet utilities using `viem` for Ethereum interaction.
Expand Down
33 changes: 10 additions & 23 deletions apps/cli/build.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,22 @@
// native addons resolve their platform binary at runtime, so they can never be
// bundled: they are left as imports, resolved from node_modules
const external = ["@deroll/cm", "@deroll/genext2fs"];

// build for npm package
await Bun.build({
banner: "#!/usr/bin/env node",
entrypoints: ["./src/index.ts"],
external,
minify: true,
outdir: "dist",
sourcemap: true,
target: "node",
});

// build bun binaries for all supported platforms
const targets: Bun.Build.CompileTarget[] = [
"bun-darwin-arm64",
"bun-darwin-x64",
"bun-linux-arm64",
"bun-linux-x64",
];

await Promise.all(
targets.map((target) =>
Bun.build({
bytecode: true,
compile: {
outfile: `bin/cartesi-${target.replace("bun-", "")}`,
target,
},
entrypoints: ["./src/index.ts"],
minify: true,
sourcemap: "linked",
target: "bun",
}),
),
);
// NOTE: the standalone binaries this used to cross-compile (bin/cartesi-*)
// are gone. A single file executable has no node_modules, and the emulator and
// ext2 bindings resolve their platform specific .node at runtime, so they
// cannot be embedded — not even for the host platform. The npm package is the
// only distribution now, and the homebrew formula has to install it from there.

export {};
2 changes: 2 additions & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
],
"dependencies": {
"@commander-js/extra-typings": "^14.0.0",
"@deroll/cm": "^0.2.0-alpha.4",
"@deroll/genext2fs": "^0.2.0-alpha.0",
"@inquirer/confirm": "^6.0.6",
"@inquirer/core": "^11.1.3",
"@inquirer/input": "^5.0.6",
Expand Down
1 change: 0 additions & 1 deletion apps/cli/src/builder/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export const build = async (
input: name,
output: filename,
cwd: destination,
image: sdkImage,
reporter,
});
break;
Expand Down
1 change: 0 additions & 1 deletion apps/cli/src/builder/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,6 @@ export const build = async (
input: tar,
output: filename,
cwd: destination,
image: sdkImage,
reporter,
});
break;
Expand Down
2 changes: 0 additions & 2 deletions apps/cli/src/builder/empty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { genext2fs } from "../exec/index.js";
export const build = async (
name: string,
drive: EmptyDriveConfig,
sdkImage: string,
destination: string,
): Promise<void> => {
const filename = `${name}.${drive.format}`;
Expand All @@ -16,7 +15,6 @@ export const build = async (
output: filename,
size: drive.size,
cwd: destination,
image: sdkImage,
});
break;
}
Expand Down
1 change: 0 additions & 1 deletion apps/cli/src/builder/tar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ export const build = async (
input: tar,
output: filename,
cwd: destination,
image: sdkImage,
reporter,
});
break;
Expand Down
24 changes: 17 additions & 7 deletions apps/cli/src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const buildDriveTask = (
break;
}
case "empty": {
await buildEmpty(name, drive, sdk, destination);
await buildEmpty(name, drive, destination);
break;
}
case "tar": {
Expand Down Expand Up @@ -154,20 +154,30 @@ export const createBuildCommand = () => {
}

// create machine snapshot
await bootMachine(
const { exitCode, rootHash } = await bootMachine(
config,
result.imageInfo,
{
cwd: destination,
finalHash: true,
reporter: (line) => console.error(line),
store: "image",
},
{
cwd: destination,
stdio: "inherit",
},
);

// make snapshot readable by all users, because cartesi-machine sets to 600
if (exitCode !== 0) {
throw new Error(
exitCode === 2
? "Machine did not stop at a rollup accept, it is not a valid rolling template"
: `Machine stopped with exit code ${exitCode}`,
);
}

if (rootHash) {
console.error(`Machine hash: ${chalk.cyan(rootHash)}`);
}

// make snapshot readable by all users, because the emulator sets to 600
await fs.chmod(path.join(destination, "image"), 0o755);
});
};
31 changes: 10 additions & 21 deletions apps/cli/src/commands/shell.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Command } from "@commander-js/extra-typings";
import { ExecaError } from "execa";
import fs from "fs-extra";
import path from "node:path";
import { getApplicationConfig, getContextPath } from "../base.js";
Expand Down Expand Up @@ -39,26 +38,16 @@ export const createShellCommand = () => {
// run as root if flag is set
config.machine.user = runAsRoot ? "root" : undefined;

// boot machine
try {
await bootMachine(
config,
undefined,
{ interactive: true }, // start with interactive mode on
{
cwd: destination,
stdio: "inherit",
tty: true,
},
);
} catch (error: unknown) {
if (error instanceof ExecaError) {
// just continue gracefully
if (error.exitCode === 130) {
return;
}
throw error;
}
// boot machine, in interactive mode
const { exitCode } = await bootMachine(config, undefined, {
cwd: destination,
interactive: true,
reporter: (line) => console.error(line),
});

// 130 is the shell being interrupted, which is not a failure
if (exitCode !== 0 && exitCode !== 130) {
throw new Error(`Machine stopped with exit code ${exitCode}`);
}
});
};
4 changes: 2 additions & 2 deletions apps/cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,9 @@ export type MachineConfig = {
entrypoint?: string;
env: Record<string, string>; // explicit environment variables injected into cartesi-machine ENV
envFile?: string; // path to a .env file with environment variables injected into cartesi-machine ENV
maxMCycle?: bigint; // default given by cartesi-machine
maxMCycle?: bigint; // default is no limit
ramLength: string;
ramImage?: string; // default given by cartesi-machine
ramImage?: string; // default is the pinned cartesi machine-linux-image release
useDockerEnv: boolean; // inject docker image ENV into cartesi-machine ENV
useDockerWorkdir: boolean; // inject docker image WORKDIR into cartesi-machine WORKDIR
user?: string; // default given by cartesi-machine
Expand Down
41 changes: 10 additions & 31 deletions apps/cli/src/exec/cartesi-machine-stored-hash.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,21 @@
import { isHash, type Hash } from "viem";
import { DEFAULT_SDK_IMAGE, DEFAULT_SDK_VERSION } from "../config.js";
import { execaDockerFallback, type DockerFallbackOptions } from "./util.js";

type ComputeHashOptions = { cwd?: string } & DockerFallbackOptions;
import { load } from "@deroll/cm";
import type { Hash } from "viem";

/**
*
* @param machineDir
* @param options
* @returns
* Reads the root hash of a stored Cartesi machine snapshot.
* @param machineDir directory holding the machine snapshot
* @returns the machine hash, or undefined if the snapshot can't be read
*/
export const computeHash = async (
machineDir: string,
options?: ComputeHashOptions,
): Promise<Hash | undefined> => {
const defaultImage = `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`;
const execaOptions = Object.assign(
{},
{ image: defaultImage, cwd: process.cwd() },
options,
);

try {
const { stdout } = await execaDockerFallback(
"cartesi-machine-stored-hash",
[machineDir],
execaOptions,
);

if (undefined !== stdout) {
const hash = `0x${stdout.toString().trim()}`;

if (isHash(hash)) {
return hash;
}
const machine = load(machineDir);
try {
return `0x${machine.getRootHash().toString("hex")}`;
} finally {
machine.destroy();
}

return undefined;
} catch {
return undefined;
}
Expand Down
Loading
Loading