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

Add a `defineConfig` function, and configure applications from
`cartesi.config.ts` instead of `cartesi.toml`.

An application is now configured by a `cartesi.config.ts` file that exports its
configuration through `defineConfig`, imported from `@cartesi/cli/config`. The
helper does nothing at runtime, and exists so the configuration is type checked
and completed by the editor. A configuration file may also export a function,
which receives the command being run and the mode, so a project can configure
itself differently for `cartesi build` and `cartesi run`.

Applications not written in TypeScript or JavaScript describe the same
configuration as plain data, in a `cartesi.config.json`, `cartesi.config.yaml`
or `cartesi.config` file.

The configuration gained a `run` section with the project defaults of the local
development environment (`epochLength`, `services`, `blockTime`, `forkUrl`,
...), so they do not have to be repeated on every `cartesi run`. Command line
options take precedence over it.

`cartesi.toml` keeps working, and is read when a project has no other
configuration file, but it is deprecated and now prints a warning.

Sizes are parsed strictly, and understand the IEC units: `"64Mi"` is 64 MiB
instead of being silently read as 64 bytes.

`resolveConfig` is now asynchronous, since a configuration file has to be
imported, and the `config` option of the API functions accepts a configuration
written inline, in the same shape a `cartesi.config.ts` file exports.
109 changes: 104 additions & 5 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,97 @@ cartesi --help

More documentation at [https://docs.cartesi.io](https://docs.cartesi.io).

## Configuration

An application is configured by a `cartesi.config.ts` file at its root, which
exports its configuration through `defineConfig`:

```ts
import { defineConfig } from "@cartesi/cli/config";

export default defineConfig({
drives: {
root: { builder: "docker", dockerfile: "Dockerfile" },
data: { builder: "empty", size: "64Mi", mount: "/mnt/data" },
},
machine: {
entrypoint: "dapp",
ramLength: "256Mi",
},
run: {
epochLength: 10,
services: ["explorer"],
},
});
```

`defineConfig` does nothing at runtime: it exists so the configuration is type
checked and completed by the editor, without any annotation. A configuration
file is free to compute its configuration, and to export a function instead of
an object when it depends on what is being run:

```ts
export default defineConfig(async ({ command, mode }) => ({
machine: { envFile: `.env.${mode}` },
run: { epochLength: command === "run" ? 10 : 720 },
}));
```

`mode` comes from `CARTESI_ENV` (or `NODE_ENV`), and defaults to
`development`.

The `run` section holds the defaults of the local development environment
started by `cartesi run`, so a project does not have to repeat the same
options on every invocation. Command line options always take precedence over
it.

An application has no obligation to have a configuration file: without one, it
is built from a `Dockerfile` of its directory, which is what most applications
need.

### Applications not written in TypeScript

The very same configuration can be written as plain data, for applications not
written in TypeScript or JavaScript. The keys are the ones above, and a
`$schema` key is accepted and ignored:

```yaml
# cartesi.config.yaml
machine:
entrypoint: dapp
ramLength: 256Mi
drives:
data:
builder: empty
size: 64Mi
mount: /mnt/data
run:
epochLength: 10
```

The configuration file of a project is the first of these that exists:

| File | Format |
| -------------------------------------------------------------------------- | ----------------------------------------- |
| `cartesi.config.ts`, `.mts`, `.cts`, `.js`, `.mjs`, `.cjs` | module exporting `defineConfig({ ... })` |
| `cartesi.config.json` | JSON |
| `cartesi.config.yaml`, `cartesi.config.yml` | YAML |
| `cartesi.config` | YAML, which accepts JSON as well |
| `cartesi.toml` | deprecated |

TypeScript configuration files are read by the runtime itself, and do not need
a build step. Sizes accept a number of bytes or a human readable string, and
every unit is a binary multiple: `64Mi`, `64MiB`, `64Mb` and `64MB` are all
the same 67108864 bytes.

### Migrating from `cartesi.toml`

`cartesi.toml` still works, and is read when a project has no other
configuration file, but it is deprecated and prints a warning. The new formats
describe the same configuration, with two differences: keys are camelCase
(`extra_size` becomes `extraSize`, `boot_args` becomes `bootargs`), and
`[withdrawal.config]` becomes a `withdrawal` object.

## Library

Every command of the CLI is also available as a function, so applications can be
Expand Down Expand Up @@ -57,9 +148,15 @@ The following functions are available: `addressBook`, `build`, `clean`,
A few things to keep in mind:

- functions operate on the current working directory, just like the CLI, and
read `cartesi.toml` from it by default. Functions that take a configuration
accept a path, a list of paths (merged in order), or an already parsed
`Config` object;
look the configuration file of the project up in it by default. Functions
that take a configuration accept a path, a list of paths (merged in order),
or the configuration written inline, in the same shape a `cartesi.config.ts`
file exports:

```ts
await build({ config: { machine: { ramLength: "256Mi" } } });
await build({ config: "cartesi.config.production.ts" });
```
- functions are silent, and never write to the terminal. Pass
`progress: "default"` (or `"verbose"`) to get the same output as the CLI;
- functions throw on error, and never terminate the process;
Expand All @@ -73,5 +170,7 @@ A few things to keep in mind:
`DepositError` (`InsufficientBalanceError`, `InvalidAmountError` or
`TokenNotFoundError`).

The package is typed, and the types of the configuration file (`Config`,
`DriveConfig`, `MachineConfig`, ...) are exported as well.
The package is typed, and the types of the configuration file (`UserConfig`,
`Config`, `DriveConfig`, `MachineConfig`, `RunConfig`, ...) are exported as
well, along with `defineConfig`, `loadConfig`, `findConfigFile`,
`normalizeConfig` and `mergeConfig`.
12 changes: 11 additions & 1 deletion apps/cli/build.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
// jiti transpiles TypeScript configuration files on node versions that cannot
// import them directly, and lazily requires its own transform at runtime, so it
// must be resolved from node_modules instead of being bundled
const external = ["jiti"];

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

await Bun.build({
entrypoints: ["./src/lib.ts"],
entrypoints: ["./src/lib.ts", "./src/defineConfig.ts"],
external,
minify: true,
outdir: "dist",
sourcemap: true,
Expand All @@ -33,6 +40,9 @@ await Promise.all(
target,
},
entrypoints: ["./src/index.ts"],
// the bun runtime imports TypeScript configuration files on its
// own, so the jiti fallback is never reached in these binaries
external,
minify: true,
sourcemap: "linked",
target: "bun",
Expand Down
7 changes: 5 additions & 2 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"types": "./dist/types/lib.d.ts",
"default": "./dist/lib.js"
},
"./config": {
"types": "./dist/types/defineConfig.d.ts",
"default": "./dist/defineConfig.js"
},
"./package.json": "./package.json"
},
"repository": "cartesi/cli",
Expand All @@ -28,14 +32,14 @@
"@inquirer/input": "^5.0.6",
"@inquirer/select": "^5.0.6",
"@inquirer/type": "^4.0.3",
"bytes": "^3.1.2",
"chalk": "^5.6.2",
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
"dotenv": "^16.6.1",
"execa": "^9.6.0",
"fs-extra": "^11.3.2",
"get-port": "^7.1.0",
"jiti": "^2.7.0",
"listr2": "^10.1.0",
"lookpath": "^1.2.3",
"modern-tar": "^0.7.3",
Expand All @@ -52,7 +56,6 @@
"@cartesi/devnet": "2.0.0-alpha.14",
"@sunodo/wagmi-plugin-hardhat-deploy": "^0.4.0",
"@types/bun": "^1.3.6",
"@types/bytes": "^3.1.5",
"@types/fs-extra": "^11.0.4",
"@types/inquirer": "^9.0.9",
"@types/node": "^25.2.3",
Expand Down
6 changes: 3 additions & 3 deletions apps/cli/src/api/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
buildNone,
buildTar,
} from "../builder/index.js";
import type { Config, DriveConfig, ImageInfo } from "../config.js";
import type { Config, DriveConfig, ImageInfo } from "../config/index.js";
import { bootMachine } from "../machine.js";
import {
type ConfigOptions,
Expand Down Expand Up @@ -128,8 +128,8 @@ export const build = async (
// clean up temp files we create along the process
tmp.setGracefulCleanup();

// get application configuration, from a Config object or 'cartesi.toml'
const config = resolveConfig(options.config);
// get application configuration, written inline or read from a file
const config = await resolveConfig(options.config, "build");

// destination directory for image and intermediate files
const destination = path.resolve(getContextPath());
Expand Down
89 changes: 71 additions & 18 deletions apps/cli/src/api/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import { getMachineHash, getProjectName } from "../base.js";
import {
DEFAULT_SDK_VERSION,
PREFERRED_PORT,
type RunConfig,
type WithdrawalConfig,
} from "../config.js";
} from "../config/index.js";
import {
deployApplication,
host,
Expand All @@ -33,6 +34,11 @@ import {
resolveConfig,
} from "./types.js";

/**
* Options of {@link run}. Every option that is not about this particular
* invocation can also be set in the `run` section of the application
* configuration, which these take precedence over.
*/
export type RunOptions = ConfigOptions &
ProgressOptions & {
/**
Expand Down Expand Up @@ -265,6 +271,39 @@ const deployMachine = async (options: {
return application;
};

/**
* Layer the three sources of every option of the environment: the options
* given to {@link run} (which is where the command line options end up), the
* `run` section of the application configuration, and the defaults.
*
* @param options options given to {@link run}
* @param config `run` section of the application configuration
* @returns the option values the environment is started with
*/
export const resolveRunOptions = (
options: RunOptions,
config: RunConfig = {},
) => ({
blockTime: options.blockTime ?? config.blockTime ?? 2,
claimStagingPeriod:
options.claimStagingPeriod ?? config.claimStagingPeriod ?? 0,
cpus: options.cpus ?? config.cpus,
defaultBlock: options.defaultBlock ?? config.defaultBlock ?? "latest",
epochLength: options.epochLength ?? config.epochLength ?? 720,
forkBlockNumber: options.forkBlockNumber ?? config.forkBlockNumber,
forkUrl: options.forkUrl ?? config.forkUrl,
memory: options.memory ?? config.memory,
// a port of zero is not a port, so the first free one is resolved later
port: options.port || config.port,
projectName: options.projectName ?? config.projectName,
prt: options.prt ?? config.prt ?? false,
runtimeVersion:
options.runtimeVersion ?? config.runtimeVersion ?? DEFAULT_SDK_VERSION,
services: options.services ?? config.services ?? [],
verbose:
options.verbose ?? config.verbose ?? options.progress === "verbose",
});

/**
* Run a local Cartesi node for the application, and deploy to it the machine
* snapshot built at `.cartesi/image`, if there is one.
Expand All @@ -277,32 +316,46 @@ const deployMachine = async (options: {
*/
export const run = async (options: RunOptions = {}): Promise<RunResult> => {
const {
blockTime = 2,
claimStagingPeriod = 0,
cpus,
defaultBlock = "latest",
deploy: deployOnStart = true,
detach = true,
dryRun = false,
epochLength = 720,
memory,
progress = "silent",
prt = false,
runtimeVersion = DEFAULT_SDK_VERSION,
services = [],
} = options;

const verbose = options.verbose ?? progress === "verbose";

// project name explicitly defined or the current directory name
const projectName = getProjectName(options);

// get application configuration (e.g. use withdrawal config if present)
const applicationConfig = resolveConfig(options.config);
const applicationConfig = await resolveConfig(options.config, "run");

// the 'run' section of the configuration defines project level defaults,
// which the options given to this function always take precedence over
const {
blockTime,
claimStagingPeriod,
cpus,
defaultBlock,
epochLength,
memory,
prt,
runtimeVersion,
services,
verbose,
...resolved
} = resolveRunOptions(options, applicationConfig.run);

// project name explicitly defined, defined by the configuration, or the
// current directory name
const projectName = getProjectName(resolved);

if (defaultBlock !== "finalized" && progress !== "silent") {
console.warn(
chalk.yellow(
`WARNING: default block is set to '${defaultBlock}', production configuration will likely use 'finalized'`,
),
);
}

// resolve port number, using the first free port in a range, unless explicitly set
const port =
options.port ||
resolved.port ||
(await getPort({
port: portNumbers(PREFERRED_PORT, PREFERRED_PORT + 10),
}));
Expand All @@ -311,7 +364,7 @@ export const run = async (options: RunOptions = {}): Promise<RunResult> => {
const url = `${host}:${port}`;

// configure optional anvil fork
const forkConfig = await configureFork(options);
const forkConfig = await configureFork(resolved);

if (forkConfig) {
await assertForkConfig(forkConfig, { includePRT: prt });
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/api/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ export type ShellOptions = ConfigOptions & {
export const shell = async (options: ShellOptions = {}): Promise<void> => {
const { command = "/bin/sh", runAsRoot = false } = options;

// get application configuration, from a Config object or 'cartesi.toml'
const config = resolveConfig(options.config);
// get application configuration, written inline or read from a file
const config = await resolveConfig(options.config, "shell");

// destination directory for image and intermediate files
const destination = path.resolve(getContextPath());
Expand Down
Loading
Loading