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
29 changes: 29 additions & 0 deletions .changeset/olive-donkeys-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@cartesi/cli": minor
---

Replace the `cartesi-machine` subprocess with the `@cartesi/machine` bindings

The Cartesi machine is now configured, booted, stored and hashed through
[`@cartesi/machine`](https://github.com/cartesi/rollups-ts), an N-API addon, so `build`, `shell`
and `status` no longer shell out to `cartesi-machine` or `cartesi-machine-stored-hash`, and no
longer fall back to running them inside the SDK Docker image.

Notable consequences:

- **The machine emulator moved from 0.20 to 0.21**, which is the version `@cartesi/machine`
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.
- **A snapshot is read by the linked emulator.** `cartesi hash` and `cartesi status` no longer
run `cartesi-machine-stored-hash` in the project's SDK image, so a snapshot stored by an
incompatible emulator reads as no hash and has to be rebuilt.
- **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 the addon resolves its platform specific `.node` at runtime, so it
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 @@ -70,17 +70,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/`). `@cartesi/machine` is left external — it is a native addon that resolves its 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` and `cartesi-machine-stored-hash` are native N-API bindings (`@cartesi/machine`); `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, nvrams), 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 @@
// the emulator binding resolves its platform binary at runtime, so it can never
// be bundled: it is left as an import, resolved from node_modules
const external = ["@cartesi/machine"];

// 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
// binding resolves its platform specific .node at runtime, so it 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 {};
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"/dist"
],
"dependencies": {
"@cartesi/machine": "^1.0.0-alpha.2",
"@commander-js/extra-typings": "^15.0.0",
"@inquirer/confirm": "^6.3.3",
"@inquirer/core": "^12.0.4",
Expand Down
29 changes: 5 additions & 24 deletions apps/cli/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,33 +41,14 @@ export const getContextPath = (...paths: string[]): string => {
};

/**
* SDK image of the project, which built its machine snapshot
* Read the hash of the cartesi machine snapshot, if one exists. The snapshot is
* loaded by the emulator the bindings link against, so one stored by an
* incompatible emulator reads as undefined and has to be rebuilt.
*/
const getProjectSdk = (): string | undefined => {
try {
return getApplicationConfig(["cartesi.toml"]).sdk;
} catch {
// an invalid config is reported by the commands that build with it
return undefined;
}
};

/**
* Read the hash of the cartesi machine snapshot, if one exists. Without a
* local cartesi-machine-stored-hash, it runs in the SDK image the snapshot was
* built with, as an emulator of another version may not load it.
* @param options sdk image of the project, read from cartesi.toml if not given
*/
export const getMachineHash = async (options?: {
sdk?: string;
}): Promise<Hash | undefined> => {
export const getMachineHash = async (): Promise<Hash | undefined> => {
const imagePath = getContextPath("image");
if (fs.existsSync(imagePath)) {
const image = options?.sdk ?? getProjectSdk();
return await cartesiMachineStoredHash.computeHash(
imagePath,
image ? { image } : undefined,
);
return await cartesiMachineStoredHash.computeHash(imagePath);
}
return undefined;
};
Expand Down
22 changes: 16 additions & 6 deletions apps/cli/src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,20 +188,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);
});
};
13 changes: 4 additions & 9 deletions apps/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import chalk from "chalk";
import { execa } from "execa";
import ora, { type Ora } from "ora";
import semver from "semver";
import { DEFAULT_SDK_IMAGE, DEFAULT_SDK_VERSION } from "../config.js";
import { cartesiMachine } from "../exec/index.js";

const MINIMUM_DOCKER_VERSION = "25.0.0"; // Replace with our minimum required Docker version
Expand Down Expand Up @@ -124,16 +123,12 @@ const checkBuildx = async (progress: Ora): Promise<true | never> => {
const checkCartesiMachine = async (progress: Ora): Promise<true | never> => {
progress.start("Checking Cartesi Machine version...");

// doctor does not read cartesi.toml, so check against the default sdk image. the host binary
// still takes precedence, which is the install most likely to be out of date
const v = await cartesiMachine.version({
image: `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`,
});
// the bindings link against the emulator, so this reports the version
// compiled into the CLI rather than probing an install
const v = cartesiMachine.version();

if (v === null) {
throw new Error(
"Could not determine the Cartesi Machine version. Check that Docker is running.",
);
throw new Error("Could not determine the Cartesi Machine version.");
}
if (!semver.satisfies(v.format(), cartesiMachine.requiredVersion)) {
throw new Error(
Expand Down
9 changes: 2 additions & 7 deletions apps/cli/src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ const shell = async (options: {
projectName: string;
prt?: boolean;
salt: number;
sdk: string;
withdrawalConfig?: WithdrawalConfig;
claimStagingPeriod: number;
}) => {
Expand All @@ -93,7 +92,6 @@ const shell = async (options: {
log,
projectName,
prt,
sdk,
withdrawalConfig,
claimStagingPeriod,
} = options;
Expand Down Expand Up @@ -176,7 +174,7 @@ const shell = async (options: {
await build?.parseAsync([], { from: "user" });

// redeploy
const hash = await getMachineHash({ sdk });
const hash = await getMachineHash();
if (hash) {
if (lastDeployment) {
await undeploy({ projectName });
Expand Down Expand Up @@ -495,9 +493,7 @@ export const createRunCommand = () => {
// deploy the application
let deployment: RollupsDeployment | undefined;
let salt = 0;
const hash = await getMachineHash({
sdk: applicationConfig.sdk,
});
const hash = await getMachineHash();
if (hash) {
deployment = await deploy({
epochLength,
Expand Down Expand Up @@ -548,7 +544,6 @@ export const createRunCommand = () => {
projectName,
prt,
salt,
sdk: applicationConfig.sdk,
claimStagingPeriod,
withdrawalConfig: applicationConfig?.withdrawalConfig,
});
Expand Down
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 @@ -51,26 +50,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 @@ -219,9 +219,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
44 changes: 10 additions & 34 deletions apps/cli/src/exec/cartesi-machine-stored-hash.ts
Original file line number Diff line number Diff line change
@@ -1,45 +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 "@cartesi/machine";
import { bytesToHex, 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) {
// cartesi-machine-stored-hash prints a bare digest up to emulator
// 0.20 and a 0x-prefixed one from 0.21 on.
const digest = stdout.toString().trim();
const hash = digest.startsWith("0x") ? digest : `0x${digest}`;

if (isHash(hash)) {
return hash;
}
const machine = load(machineDir);
try {
return bytesToHex(machine.getRootHash()) as Hash;
} finally {
machine.destroy();
}

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