From 0471fc934d2325bd5718407ed5a34a05cad10989 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 13:41:46 +0000 Subject: [PATCH] feat(cli): configure applications with defineConfig Replace `cartesi.toml` with 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, without any annotation. 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 `build` and `run`. TypeScript configuration files are read by the runtime itself, which covers the standalone binaries (bun) and recent versions of node; older versions of node fall back to jiti, kept external so it can transpile at runtime. 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 last one read as YAML so it accepts JSON too. Because those are not type checked, the whole configuration is validated at load time, whichever format it came from. The configuration gained a `run` section with the project defaults of the local development environment, so they do not have to be repeated on every `cartesi run`. Command line options take precedence over it, which is why the commander defaults were moved into `run` itself. `cartesi.toml` keeps working, and is read when a project has no other configuration file, but is deprecated and prints a warning. Its parser moves to `config/toml.ts` and is otherwise left frozen. Along the way: - sizes are parsed strictly and understand the IEC units, so `"64Mi"` is 64 MiB instead of being silently read as 64 bytes by `bytes.parse`, which falls back to `parseInt`. This drops the `bytes` dependency, now unused; - errors of an asynchronous command action are reported without a stack trace, as intended: they surfaced as unhandled rejections, which had no handler, and the handler that did exist was dead code because the bundler replaces `process.env.NODE_ENV` at build time. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BHFyMTf1k57qtDwcbwVjrs --- .changeset/tidy-eagles-shout.md | 32 ++ apps/cli/README.md | 109 ++++- apps/cli/build.ts | 12 +- apps/cli/package.json | 7 +- apps/cli/src/api/build.ts | 6 +- apps/cli/src/api/run.ts | 89 +++- apps/cli/src/api/shell.ts | 4 +- apps/cli/src/api/types.ts | 46 +- apps/cli/src/base.ts | 14 - apps/cli/src/builder/directory.ts | 2 +- apps/cli/src/builder/docker.ts | 2 +- apps/cli/src/builder/empty.ts | 2 +- apps/cli/src/builder/none.ts | 2 +- apps/cli/src/builder/tar.ts | 2 +- apps/cli/src/commands/build.ts | 15 +- apps/cli/src/commands/run.ts | 64 +-- apps/cli/src/commands/shell.ts | 17 +- apps/cli/src/config/errors.ts | 143 ++++++ apps/cli/src/config/index.ts | 23 + apps/cli/src/config/load.ts | 264 ++++++++++ apps/cli/src/config/merge.ts | 63 +++ apps/cli/src/config/normalize.ts | 456 ++++++++++++++++++ apps/cli/src/config/size.ts | 46 ++ apps/cli/src/{config.ts => config/toml.ts} | 289 ++--------- apps/cli/src/config/types.ts | 252 ++++++++++ apps/cli/src/config/user.ts | 288 +++++++++++ apps/cli/src/defineConfig.ts | 58 +++ .../src/exec/cartesi-machine-stored-hash.ts | 2 +- apps/cli/src/exec/rollups.ts | 2 +- apps/cli/src/index.ts | 20 +- apps/cli/src/lib.ts | 29 +- apps/cli/src/machine.ts | 2 +- apps/cli/src/wallet.ts | 2 +- .../integration/builder/directory.test.ts | 2 +- .../tests/integration/builder/docker.test.ts | 2 +- .../tests/integration/builder/empty.test.ts | 2 +- .../tests/integration/builder/none.test.ts | 2 +- .../cli/tests/integration/builder/tar.test.ts | 2 +- apps/cli/tests/integration/config.ts | 5 +- apps/cli/tests/unit/api/run.test.ts | 104 ++++ apps/cli/tests/unit/api/types.test.ts | 34 +- apps/cli/tests/unit/config.test.ts | 2 +- .../unit/config/fixtures/files/bare.config | 3 + .../config/fixtures/files/cartesi.config.js | 3 + .../config/fixtures/files/cartesi.config.json | 5 + .../config/fixtures/files/cartesi.config.ts | 10 + .../config/fixtures/files/cartesi.config.yaml | 10 + .../unit/config/fixtures/files/empty.config | 0 .../config/fixtures/files/function.config.ts | 6 + .../config/fixtures/files/invalid.config.ts | 1 + .../unit/config/fixtures/files/override.yaml | 4 + .../config/fixtures/files/unsupported.ini | 1 + .../fixtures/project-legacy/cartesi.toml | 2 + .../unit/config/fixtures/project-none/.keep | 0 .../fixtures/project-yaml/cartesi.config.yaml | 4 + apps/cli/tests/unit/config/load.test.ts | 178 +++++++ apps/cli/tests/unit/config/normalize.test.ts | 319 ++++++++++++ apps/cli/tests/unit/config/size.test.ts | 56 +++ bun.lock | 9 +- 59 files changed, 2731 insertions(+), 399 deletions(-) create mode 100644 .changeset/tidy-eagles-shout.md create mode 100644 apps/cli/src/config/errors.ts create mode 100644 apps/cli/src/config/index.ts create mode 100644 apps/cli/src/config/load.ts create mode 100644 apps/cli/src/config/merge.ts create mode 100644 apps/cli/src/config/normalize.ts create mode 100644 apps/cli/src/config/size.ts rename apps/cli/src/{config.ts => config/toml.ts} (66%) create mode 100644 apps/cli/src/config/types.ts create mode 100644 apps/cli/src/config/user.ts create mode 100644 apps/cli/src/defineConfig.ts create mode 100644 apps/cli/tests/unit/api/run.test.ts create mode 100644 apps/cli/tests/unit/config/fixtures/files/bare.config create mode 100644 apps/cli/tests/unit/config/fixtures/files/cartesi.config.js create mode 100644 apps/cli/tests/unit/config/fixtures/files/cartesi.config.json create mode 100644 apps/cli/tests/unit/config/fixtures/files/cartesi.config.ts create mode 100644 apps/cli/tests/unit/config/fixtures/files/cartesi.config.yaml create mode 100644 apps/cli/tests/unit/config/fixtures/files/empty.config create mode 100644 apps/cli/tests/unit/config/fixtures/files/function.config.ts create mode 100644 apps/cli/tests/unit/config/fixtures/files/invalid.config.ts create mode 100644 apps/cli/tests/unit/config/fixtures/files/override.yaml create mode 100644 apps/cli/tests/unit/config/fixtures/files/unsupported.ini create mode 100644 apps/cli/tests/unit/config/fixtures/project-legacy/cartesi.toml create mode 100644 apps/cli/tests/unit/config/fixtures/project-none/.keep create mode 100644 apps/cli/tests/unit/config/fixtures/project-yaml/cartesi.config.yaml create mode 100644 apps/cli/tests/unit/config/load.test.ts create mode 100644 apps/cli/tests/unit/config/normalize.test.ts create mode 100644 apps/cli/tests/unit/config/size.test.ts diff --git a/.changeset/tidy-eagles-shout.md b/.changeset/tidy-eagles-shout.md new file mode 100644 index 00000000..6b34754f --- /dev/null +++ b/.changeset/tidy-eagles-shout.md @@ -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. diff --git a/apps/cli/README.md b/apps/cli/README.md index 3290c21e..d37bc1c6 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -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 @@ -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; @@ -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`. diff --git a/apps/cli/build.ts b/apps/cli/build.ts index 133a836e..b60a22dc 100644 --- a/apps/cli/build.ts +++ b/apps/cli/build.ts @@ -1,7 +1,13 @@ +// 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, @@ -9,7 +15,8 @@ await Bun.build({ }); await Bun.build({ - entrypoints: ["./src/lib.ts"], + entrypoints: ["./src/lib.ts", "./src/defineConfig.ts"], + external, minify: true, outdir: "dist", sourcemap: true, @@ -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", diff --git a/apps/cli/package.json b/apps/cli/package.json index 16d11875..ad8115a8 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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", @@ -28,7 +32,6 @@ "@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", @@ -36,6 +39,7 @@ "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", @@ -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", diff --git a/apps/cli/src/api/build.ts b/apps/cli/src/api/build.ts index 05714e9b..ede08f52 100644 --- a/apps/cli/src/api/build.ts +++ b/apps/cli/src/api/build.ts @@ -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, @@ -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()); diff --git a/apps/cli/src/api/run.ts b/apps/cli/src/api/run.ts index b1072369..8543b074 100644 --- a/apps/cli/src/api/run.ts +++ b/apps/cli/src/api/run.ts @@ -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, @@ -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 & { /** @@ -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. @@ -277,32 +316,46 @@ const deployMachine = async (options: { */ export const run = async (options: RunOptions = {}): Promise => { 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), })); @@ -311,7 +364,7 @@ export const run = async (options: RunOptions = {}): Promise => { 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 }); diff --git a/apps/cli/src/api/shell.ts b/apps/cli/src/api/shell.ts index 17cf5a35..db367ee0 100644 --- a/apps/cli/src/api/shell.ts +++ b/apps/cli/src/api/shell.ts @@ -31,8 +31,8 @@ export type ShellOptions = ConfigOptions & { export const shell = async (options: ShellOptions = {}): Promise => { 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()); diff --git a/apps/cli/src/api/types.ts b/apps/cli/src/api/types.ts index 3b3fe59c..9ca4b057 100644 --- a/apps/cli/src/api/types.ts +++ b/apps/cli/src/api/types.ts @@ -1,5 +1,10 @@ -import { getApplicationConfig } from "../base.js"; -import type { Config } from "../config.js"; +import { + type Config, + type ConfigCommand, + loadConfig, + normalizeConfig, + type UserConfig, +} from "../config/index.js"; /** * Verbosity of the progress information written to the terminal while an API @@ -23,38 +28,47 @@ export type ProgressOptions = { }; /** - * Application configuration, either already parsed, or a path (or list of - * paths) of TOML configuration files to be read and merged, in order. + * Application configuration, either written inline in the same shape a + * `cartesi.config.ts` file exports, or a path (or list of paths) of + * configuration files to be read and merged, in order. Files of any of the + * supported formats are accepted. An already resolved {@link Config} is a + * valid inline configuration as well. */ -export type ConfigInput = Config | string | string[]; +export type ConfigInput = UserConfig | string | string[]; export type ConfigOptions = { /** - * Application configuration, or path of the configuration file(s). - * @default "cartesi.toml" + * Application configuration, or path of the configuration file(s). When + * not given, the configuration file of the project is looked up by name. */ config?: ConfigInput; }; /** * Resolve the application configuration from the several ways it can be - * provided to the API: an already parsed {@link Config}, one configuration file - * path, a list of configuration file paths, or nothing (which falls back to - * `cartesi.toml` of the current directory). + * provided to the API: a configuration written inline, one configuration file + * path, a list of configuration file paths, or nothing (which looks the + * configuration file of the project up, and falls back to the defaults when + * there is none). * @param config configuration or path of configuration file(s) - * @returns parsed application configuration + * @param command command being run, given to a configuration file that exports + * a function + * @returns resolved application configuration */ -export const resolveConfig = (config?: ConfigInput): Config => { +export const resolveConfig = async ( + config?: ConfigInput, + command: ConfigCommand = "build", +): Promise => { if (config === undefined) { - return getApplicationConfig(["cartesi.toml"]); + return loadConfig({ command }); } if (typeof config === "string") { - return getApplicationConfig([config]); + return loadConfig({ command, files: [config] }); } if (Array.isArray(config)) { - return getApplicationConfig(config); + return loadConfig({ command, files: config }); } - return config; + return normalizeConfig(config); }; /** diff --git a/apps/cli/src/base.ts b/apps/cli/src/base.ts index 0a7421d5..8b136f1e 100644 --- a/apps/cli/src/base.ts +++ b/apps/cli/src/base.ts @@ -11,7 +11,6 @@ import { zeroHash, } from "viem"; import { foundry } from "viem/chains"; -import { type Config, parse } from "./config.js"; import { applicationFactoryAddress, authorityFactoryAddress, @@ -46,19 +45,6 @@ export const getMachineHash = async (): Promise => { return undefined; }; -export const getApplicationConfig = (configPaths: string[]): Config => { - const tomls = configPaths.map((configPath) => { - if (fs.existsSync(configPath)) { - return fs.readFileSync(configPath).toString(); - } - if (configPath === "cartesi.toml") { - return ""; - } - throw new Error(`Config file ${configPath} does not exist`); - }); - return parse(tomls); -}; - export const getProjectName = (options: { projectName?: string }) => { return options.projectName ?? path.basename(process.cwd()); }; diff --git a/apps/cli/src/builder/directory.ts b/apps/cli/src/builder/directory.ts index 55eb5398..041b097e 100644 --- a/apps/cli/src/builder/directory.ts +++ b/apps/cli/src/builder/directory.ts @@ -1,6 +1,6 @@ import fs from "fs-extra"; import path from "node:path"; -import type { DirectoryDriveConfig } from "../config.js"; +import type { DirectoryDriveConfig } from "../config/index.js"; import { genext2fs, mksquashfs } from "../exec/index.js"; import type { Reporter } from "../exec/util.js"; diff --git a/apps/cli/src/builder/docker.ts b/apps/cli/src/builder/docker.ts index 908b3acd..1b12d850 100644 --- a/apps/cli/src/builder/docker.ts +++ b/apps/cli/src/builder/docker.ts @@ -2,7 +2,7 @@ import { execa } from "execa"; import fs from "fs-extra"; import path from "node:path"; import tmp from "tmp"; -import type { DockerDriveConfig } from "../config.js"; +import type { DockerDriveConfig } from "../config/index.js"; import { genext2fs, mksquashfs } from "../exec/index.js"; import type { Reporter } from "../exec/util.js"; import type { BuildxMetadata } from "../types/docker.js"; diff --git a/apps/cli/src/builder/empty.ts b/apps/cli/src/builder/empty.ts index 9ddc0475..8deba733 100644 --- a/apps/cli/src/builder/empty.ts +++ b/apps/cli/src/builder/empty.ts @@ -1,6 +1,6 @@ import fs from "fs-extra"; import path from "node:path"; -import type { EmptyDriveConfig } from "../config.js"; +import type { EmptyDriveConfig } from "../config/index.js"; import { genext2fs } from "../exec/index.js"; export const build = async ( diff --git a/apps/cli/src/builder/none.ts b/apps/cli/src/builder/none.ts index fe8733a5..33aa9dfd 100644 --- a/apps/cli/src/builder/none.ts +++ b/apps/cli/src/builder/none.ts @@ -1,6 +1,6 @@ import fs from "fs-extra"; import path from "node:path"; -import { type ExistingDriveConfig, getDriveFormat } from "../config.js"; +import { type ExistingDriveConfig, getDriveFormat } from "../config/index.js"; export const build = async ( name: string, diff --git a/apps/cli/src/builder/tar.ts b/apps/cli/src/builder/tar.ts index 34633ecc..b189f45e 100644 --- a/apps/cli/src/builder/tar.ts +++ b/apps/cli/src/builder/tar.ts @@ -1,6 +1,6 @@ import fs from "fs-extra"; import path from "node:path"; -import type { TarDriveConfig } from "../config.js"; +import type { TarDriveConfig } from "../config/index.js"; import { genext2fs, mksquashfs } from "../exec/index.js"; import type { Reporter } from "../exec/util.js"; diff --git a/apps/cli/src/commands/build.ts b/apps/cli/src/commands/build.ts index 166e7f13..6c975718 100755 --- a/apps/cli/src/commands/build.ts +++ b/apps/cli/src/commands/build.ts @@ -6,11 +6,16 @@ export const createBuildCommand = () => { .description( "Build application by building Cartesi machine drives, configuring a machine and booting it.", ) - .option( - "-c, --config ", - "path to the configuration file", - (value, prev) => prev.concat([value]), - ["cartesi.toml"], + .addOption( + new Option( + "-c, --config ", + "path to the configuration file", + ) + .argParser((value, prev) => prev.concat([value])) + .default( + [] as string[], + "the configuration file of the project", + ), ) .addOption( new Option( diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index e9e855e2..813ffce5 100755 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -7,7 +7,6 @@ import { build } from "../api/build.js"; import { logs } from "../api/logs.js"; import { run, type RunResult } from "../api/run.js"; import { nodeAllowedEnvironmentVariables } from "../compose/node.js"; -import { DEFAULT_SDK_VERSION } from "../config.js"; import { AVAILABLE_SERVICES } from "../exec/rollups.js"; import { keySelect } from "../prompts.js"; @@ -16,7 +15,7 @@ const commaSeparatedList = (value: string) => value.split(","); const shell = async (options: { config: string[]; node: RunResult; - verbose: boolean; + verbose?: boolean; }) => { const { config, node, verbose } = options; const { projectName } = node; @@ -81,19 +80,12 @@ const shell = async (options: { export const createRunCommand = () => { return new Command("run") .description("Run a local cartesi node for the application.") - .addOption( - new Option( - "--prt", - "deploy application with PRT consensus", - ).default(false), - ) + .addOption(new Option("--prt", "deploy application with PRT consensus")) .addOption( new Option( "--block-time ", - "interval between blocks (in seconds)", - ) - .argParser(Number) - .default(2), + "interval between blocks (in seconds) (default: 2)", + ).argParser(Number), ) .addOption( new Option( @@ -104,10 +96,8 @@ export const createRunCommand = () => { .addOption( new Option( "--default-block ", - "default block to be used when fetching new blocks.", - ) - .choices(["latest", "safe", "pending", "finalized"]) - .default("latest"), + "default block to be used when fetching new blocks. (default: latest)", + ).choices(["latest", "safe", "pending", "finalized"]), ) .option("--dry-run", "show the docker compose configuration", false) .option( @@ -131,33 +121,32 @@ export const createRunCommand = () => { .addOption( new Option( "--epoch-length ", - "length of an epoch (in blocks)", - ) - .argParser(Number) - .default(720), + "length of an epoch (in blocks) (default: 720)", + ).argParser(Number), ) .option("-p, --port ", "port to listen on", Number) .addOption( new Option( "--claim-staging-period ", - "claim staging period (in blocks). Number of blocks between a claim being submitted and accepted (Authority/Quorum Only)", - ) - .argParser(Number) - .default(0), + "claim staging period (in blocks). Number of blocks between a claim being submitted and accepted (Authority/Quorum Only) (default: 0)", + ).argParser(Number), ) - .option( - "-c, --config ", - "Path to the configuration file (.toml)", - (value, prev) => prev.concat([value]), - ["cartesi.toml"], + .addOption( + new Option( + "-c, --config ", + "path to the configuration file", + ) + .argParser((value, prev) => prev.concat([value])) + .default( + [] as string[], + "the configuration file of the project", + ), ) .addOption( new Option( "--runtime-version ", "version for Cartesi Rollups Runtime to use", - ) - .default(DEFAULT_SDK_VERSION) - .hideHelp(), + ).hideHelp(), ) .option( "--project-name ", @@ -167,9 +156,8 @@ export const createRunCommand = () => { "--services ", `optional services to start, comma separated list from [${AVAILABLE_SERVICES.join(", ")}]`, commaSeparatedList, - [], ) - .option("-v, --verbose", "verbose output", false) + .option("-v, --verbose", "verbose output") .action(async (options) => { const { prt, @@ -203,14 +191,6 @@ export const createRunCommand = () => { return; } - if (defaultBlock !== "finalized") { - console.warn( - chalk.yellow( - `WARNING: default block is set to '${defaultBlock}', production configuration will likely use 'finalized'`, - ), - ); - } - // if TTY is not attached, run on foreground (not detached) const detach = !!process.stdin.isTTY; diff --git a/apps/cli/src/commands/shell.ts b/apps/cli/src/commands/shell.ts index 7131971b..81ac9459 100755 --- a/apps/cli/src/commands/shell.ts +++ b/apps/cli/src/commands/shell.ts @@ -1,14 +1,19 @@ -import { Command } from "@commander-js/extra-typings"; +import { Command, Option } from "@commander-js/extra-typings"; import { shell } from "../api/shell.js"; export const createShellCommand = () => { return new Command("shell") .option("--command ", "shell command to run", "/bin/sh") - .option( - "-c, --config ", - "path to the configuration file", - (value, prev) => prev.concat([value]), - ["cartesi.toml"], + .addOption( + new Option( + "-c, --config ", + "path to the configuration file", + ) + .argParser((value, prev) => prev.concat([value])) + .default( + [] as string[], + "the configuration file of the project", + ), ) .option("--run-as-root", "run as root user", false) .action(async (options) => { diff --git a/apps/cli/src/config/errors.ts b/apps/cli/src/config/errors.ts new file mode 100644 index 00000000..f0e08914 --- /dev/null +++ b/apps/cli/src/config/errors.ts @@ -0,0 +1,143 @@ +/** + * Typed errors thrown while reading and validating the configuration of an + * application, regardless of the format it was written in. + */ + +export class InvalidBuilderError extends Error { + constructor(builder: unknown) { + super(`Invalid builder: ${String(builder)}`); + this.name = "InvalidBuilder"; + } +} + +export class InvalidDriveFormatError extends Error { + constructor(format: unknown) { + super(`Invalid drive format: ${String(format)}`); + this.name = "InvalidDriveFormatError"; + } +} + +export class InvalidEmptyDriveFormatError extends Error { + constructor(format: unknown) { + super(`Invalid empty drive format: ${String(format)}`); + this.name = "InvalidEmptyDriveFormatError"; + } +} + +export class InvalidStringValueError extends Error { + constructor(value: unknown) { + super(`Invalid string value: ${String(value)}`); + this.name = "InvalidStringValueError"; + } +} + +export class InvalidBooleanValueError extends Error { + constructor(value: unknown) { + super(`Invalid boolean value: ${String(value)}`); + this.name = "InvalidBooleanValueError"; + } +} + +export class InvalidNumberValueError extends Error { + constructor(value: unknown, key?: string) { + super( + `Invalid number value: ${String(value)}${key ? ` for key: ${key}` : ""}`, + ); + this.name = "InvalidNumberValueError"; + } +} + +export class InvalidAddressValueError extends Error { + constructor(value: unknown, key?: string) { + super( + `Invalid address value: ${String(value)}${key ? ` for key: ${key}` : ""}`, + ); + this.name = "InvalidAddressValueError"; + } +} + +export class InvalidBytesValueError extends Error { + constructor(value: unknown) { + super(`Invalid bytes value: ${String(value)}`); + this.name = "InvalidBytesValueError"; + } +} + +export class RequiredFieldError extends Error { + constructor(key: unknown) { + super(`Missing required field: ${String(key)}`); + this.name = "RequiredFieldError"; + } +} + +export class InvalidStringArrayError extends Error { + constructor() { + super("Invalid string array"); + this.name = "InvalidStringArrayError"; + } +} + +export class InvalidEnvError extends Error { + constructor(value: unknown) { + super(`Invalid env configuration: ${String(value)}`); + this.name = "InvalidEnvError"; + } +} + +/** + * Thrown when a section of the configuration is expected to be an object, but + * is something else. + */ +export class InvalidSectionError extends Error { + constructor(section: string, value: unknown) { + super(`Invalid '${section}' configuration: ${String(value)}`); + this.name = "InvalidSectionError"; + } +} + +/** + * Thrown when a field only accepts a fixed set of values, and is given + * something else. + */ +export class InvalidEnumValueError extends Error { + constructor(key: string, value: unknown, allowed: readonly string[]) { + super( + `Invalid value for '${key}': ${String(value)}, must be one of ${allowed.join(", ")}`, + ); + this.name = "InvalidEnumValueError"; + } +} + +/** + * Thrown when a configuration file explicitly asked for does not exist. + */ +export class ConfigFileNotFoundError extends Error { + constructor(filename: string) { + super(`Config file ${filename} does not exist`); + this.name = "ConfigFileNotFoundError"; + } +} + +/** + * Thrown when a configuration file has an extension the CLI cannot read. + */ +export class UnsupportedConfigFormatError extends Error { + constructor(filename: string) { + super(`Unsupported configuration file format: ${filename}`); + this.name = "UnsupportedConfigFormatError"; + } +} + +/** + * Thrown when a TypeScript or JavaScript configuration file does not export a + * configuration as its default export. + */ +export class InvalidConfigExportError extends Error { + constructor(filename: string) { + super( + `Configuration file ${filename} must have a default export with the application configuration, ` + + "as in 'export default defineConfig({ ... })'", + ); + this.name = "InvalidConfigExportError"; + } +} diff --git a/apps/cli/src/config/index.ts b/apps/cli/src/config/index.ts new file mode 100644 index 00000000..dee2a36f --- /dev/null +++ b/apps/cli/src/config/index.ts @@ -0,0 +1,23 @@ +/** + * Configuration of a Cartesi application. + * + * An application is configured by a `cartesi.config.ts` file that exports a + * configuration built with {@link defineConfig}, which gives it type checking + * and editor completion. 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 deprecated `cartesi.toml` file is still read when no other configuration + * file is present. + */ + +export * from "./errors.js"; +export { loadConfig, type LoadConfigOptions } from "./load.js"; +export { CONFIG_FILES, findConfigFile, LEGACY_CONFIG_FILE } from "./load.js"; +export { loadConfigFile } from "./load.js"; +export { mergeConfig } from "./merge.js"; +export { normalizeConfig } from "./normalize.js"; +export { mergeTomlTables, mergeTomlValues, parse } from "./toml.js"; +export { parseSize } from "./size.js"; +export * from "./types.js"; +export * from "./user.js"; diff --git a/apps/cli/src/config/load.ts b/apps/cli/src/config/load.ts new file mode 100644 index 00000000..5934af92 --- /dev/null +++ b/apps/cli/src/config/load.ts @@ -0,0 +1,264 @@ +import chalk from "chalk"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { parse as parseYaml } from "yaml"; +import { + ConfigFileNotFoundError, + InvalidConfigExportError, + UnsupportedConfigFormatError, +} from "./errors.js"; +import { mergeConfig } from "./merge.js"; +import { normalizeConfig } from "./normalize.js"; +import { parse as parseTomlConfig } from "./toml.js"; +import type { Config } from "./types.js"; +import type { + ConfigCommand, + ConfigEnv, + UserConfig, + UserConfigFn, +} from "./user.js"; + +/** + * Names of the configuration files looked up in the project directory, in + * order of precedence. The first one that exists is the configuration of the + * application. + * + * TypeScript and JavaScript files export their configuration with + * `defineConfig`. The remaining formats are for applications not written in + * TypeScript or JavaScript, and describe the very same configuration as plain + * data. A file with no format in its name (`cartesi.config`) is read as YAML, + * which also makes it valid JSON. + */ +export const CONFIG_FILES = [ + "cartesi.config.ts", + "cartesi.config.mts", + "cartesi.config.cts", + "cartesi.config.js", + "cartesi.config.mjs", + "cartesi.config.cjs", + "cartesi.config.json", + "cartesi.config.yaml", + "cartesi.config.yml", + "cartesi.config", + "cartesi.toml", // deprecated +] as const; + +/** Name of the deprecated configuration file. */ +export const LEGACY_CONFIG_FILE = "cartesi.toml"; + +const MODULE_EXTENSIONS = [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"]; +const TYPESCRIPT_EXTENSIONS = [".ts", ".mts", ".cts"]; + +const isLegacyConfigFile = (filename: string): boolean => + path.extname(filename) === ".toml"; + +let legacyWarned = false; + +/** + * Warn, once per process, that the legacy configuration file is deprecated. + */ +const warnLegacyConfig = (filename: string): void => { + if (legacyWarned) { + return; + } + legacyWarned = true; + console.warn( + chalk.yellow( + `WARNING: the TOML configuration format (${path.basename(filename)}) is deprecated, migrate to cartesi.config.ts, or to cartesi.config.yaml for applications not written in TypeScript`, + ), + ); +}; + +/** + * Find the configuration file of the application. + * @param cwd directory to look the configuration file up at + * @returns absolute path of the configuration file, or `undefined` if the + * application has no configuration file, in which case the defaults apply + */ +export const findConfigFile = ( + cwd: string = process.cwd(), +): string | undefined => { + for (const name of CONFIG_FILES) { + const filename = path.resolve(cwd, name); + if (fs.existsSync(filename)) { + return filename; + } + } + return undefined; +}; + +/** + * Whether the runtime can import a TypeScript file directly. Bun always can, + * and so does node from the version that strips types on its own. + */ +const canImportTypeScript = (): boolean => + process.versions.bun !== undefined || Boolean(process.features.typescript); + +/** + * Import a TypeScript or JavaScript configuration file and return its default + * export. + * + * The file is imported by the runtime itself whenever it can handle + * TypeScript, which covers the standalone binaries (Bun) and recent versions + * of node. Older versions of node fall back to `jiti`, which transpiles the + * file before evaluating it. + */ +const importConfigFile = async (filename: string): Promise => { + let module: unknown; + + if ( + TYPESCRIPT_EXTENSIONS.includes(path.extname(filename)) && + !canImportTypeScript() + ) { + const { createJiti } = await import("jiti"); + const jiti = createJiti(import.meta.url, { interopDefault: true }); + module = await jiti.import(filename); + } else { + // the query string busts the module cache, so a configuration file + // edited between two builds of the same process is picked up + const url = `${pathToFileURL(filename).href}?t=${Date.now()}`; + module = await import(url); + } + + // a CommonJS file assigning to 'module.exports' also lands on 'default' + return (module as { default?: unknown })?.default; +}; + +/** + * Read one configuration file, of any of the supported formats, without + * applying any default. + * @param filename path of the configuration file + * @param env context given to a configuration file that exports a function + * @returns configuration as written in the file + */ +export const loadConfigFile = async ( + filename: string, + env: ConfigEnv, +): Promise => { + const extension = path.extname(filename); + + if (MODULE_EXTENSIONS.includes(extension)) { + const exported = await importConfigFile(filename); + if (exported === undefined || exported === null) { + throw new InvalidConfigExportError(filename); + } + + // a configuration file may export the configuration itself, a promise + // of one, or a function computing it from the environment + const config = + typeof exported === "function" + ? await (exported as UserConfigFn)(env) + : await exported; + + if (typeof config !== "object" || config === null) { + throw new InvalidConfigExportError(filename); + } + return config as UserConfig; + } + + const contents = fs.readFileSync(filename, "utf8"); + + switch (extension) { + case ".json": + return (JSON.parse(contents) ?? {}) as UserConfig; + case ".yaml": + case ".yml": + // a file with no format in its name, such as 'cartesi.config', is read + // as YAML, which accepts JSON as well + case ".config": + return (parseYaml(contents) ?? {}) as UserConfig; + case ".toml": + warnLegacyConfig(filename); + return parseTomlConfig([contents]); + default: + throw new UnsupportedConfigFormatError(filename); + } +}; + +export type LoadConfigOptions = { + /** + * Command being run, given to a configuration file that exports a + * function. + * @default "build" + */ + command?: ConfigCommand; + + /** + * Directory the configuration is resolved from. + * @default process.cwd() + */ + cwd?: string; + + /** + * Configuration files to read, merged in order. When empty, the + * configuration file of the project is looked up by name. + */ + files?: string[]; + + /** + * Mode the application is being built or run in. + * @default `CARTESI_ENV`, `NODE_ENV`, or "development" + */ + mode?: string; +}; + +/** + * Load the configuration of an application. + * + * Unless configuration files are explicitly given, the project directory is + * searched for one of the {@link CONFIG_FILES}. An application without a + * configuration file is perfectly valid, and gets the default configuration. + * + * @param options where to load the configuration from + * @returns resolved application configuration + */ +export const loadConfig = async ( + options: LoadConfigOptions = {}, +): Promise => { + const cwd = options.cwd ?? process.cwd(); + const env: ConfigEnv = { + command: options.command ?? "build", + cwd, + mode: + options.mode ?? + process.env.CARTESI_ENV ?? + process.env.NODE_ENV ?? + "development", + }; + + let files: string[]; + if (options.files?.length) { + files = options.files.map((file) => path.resolve(cwd, file)); + for (const file of files) { + if (!fs.existsSync(file)) { + throw new ConfigFileNotFoundError(file); + } + } + } else { + const found = findConfigFile(cwd); + files = found ? [found] : []; + } + + if (files.length === 0) { + // an application without a configuration file uses the defaults + return normalizeConfig(undefined); + } + + if (files.every(isLegacyConfigFile)) { + // legacy files are merged as TOML tables before being parsed, which is + // how the deprecated format has always behaved + for (const file of files) { + warnLegacyConfig(file); + } + return parseTomlConfig( + files.map((file) => fs.readFileSync(file, "utf8")), + ); + } + + let config: UserConfig = {}; + for (const file of files) { + config = mergeConfig(config, await loadConfigFile(file, env)); + } + return normalizeConfig(config); +}; diff --git a/apps/cli/src/config/merge.ts b/apps/cli/src/config/merge.ts new file mode 100644 index 00000000..8007fe85 --- /dev/null +++ b/apps/cli/src/config/merge.ts @@ -0,0 +1,63 @@ +import type { UserConfig } from "./user.js"; + +/** + * Checks if a value is a plain object, and not an array, a class instance or + * any other exotic object. + */ +const isPlainObject = (value: unknown): value is Record => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +const merge = ( + base: Record, + other: Record, +): Record => { + const result: Record = { ...base }; + + for (const [key, otherValue] of Object.entries(other)) { + if (otherValue === undefined) { + // an undefined value does not override anything, so a partial + // configuration can be built with optional fields left out + continue; + } + + const baseValue = result[key]; + if (isPlainObject(baseValue) && isPlainObject(otherValue)) { + result[key] = merge(baseValue, otherValue); + } else { + // arrays and scalars are replaced, not concatenated + result[key] = otherValue; + } + } + + return result; +}; + +/** + * Deep merge two configurations, with the values of `other` taking precedence + * over the ones of `base`. Objects are merged recursively, while arrays and + * scalars are replaced. Useful to share a base configuration between + * applications, or to layer environment specific overrides: + * + * ```ts + * export default defineConfig( + * mergeConfig(base, { machine: { ramLength: "256Mi" } }), + * ); + * ``` + * + * @param base base configuration + * @param other configuration merged on top of `base` + * @returns a new merged configuration + */ +export const mergeConfig = ( + base: T, + other: UserConfig, +): T => + merge( + base as Record, + other as Record, + ) as T; diff --git a/apps/cli/src/config/normalize.ts b/apps/cli/src/config/normalize.ts new file mode 100644 index 00000000..f1c8fdba --- /dev/null +++ b/apps/cli/src/config/normalize.ts @@ -0,0 +1,456 @@ +import { type Address, getAddress, isAddress, isHex } from "viem"; +import { + InvalidAddressValueError, + InvalidBooleanValueError, + InvalidBuilderError, + InvalidDriveFormatError, + InvalidEmptyDriveFormatError, + InvalidEnumValueError, + InvalidEnvError, + InvalidNumberValueError, + InvalidSectionError, + InvalidStringArrayError, + InvalidStringValueError, + RequiredFieldError, +} from "./errors.js"; +import { parseSize } from "./size.js"; +import { + type Builder, + type Config, + DEFAULT_BLOCKS, + DEFAULT_FORMAT, + DEFAULT_RAM, + DEFAULT_SDK_IMAGE, + DEFAULT_SDK_VERSION, + type DefaultBlock, + defaultRootDriveConfig, + type DriveConfig, + type DriveFormat, + getDriveFormat, + type MachineConfig, + type RunConfig, + type WithdrawalConfig, +} from "./types.js"; + +type Record_ = Record; + +const isRecord = (value: unknown): value is Record_ => + typeof value === "object" && value !== null && !Array.isArray(value); + +const asRecord = (section: string, value: unknown): Record_ => { + if (value === undefined || value === null) { + return {}; + } + if (!isRecord(value)) { + throw new InvalidSectionError(section, value); + } + return value; +}; + +const asBoolean = (value: unknown, defaultValue: boolean): boolean => { + if (value === undefined) { + return defaultValue; + } + if (typeof value === "boolean") { + return value; + } + throw new InvalidBooleanValueError(value); +}; + +const asOptionalBoolean = (value: unknown): boolean | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "boolean") { + return value; + } + throw new InvalidBooleanValueError(value); +}; + +const asString = (value: unknown, defaultValue: string): string => { + if (value === undefined) { + return defaultValue; + } + if (typeof value === "string") { + return value; + } + throw new InvalidStringValueError(value); +}; + +const asOptionalString = (value: unknown): string | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "string") { + return value; + } + throw new InvalidStringValueError(value); +}; + +const asRequiredString = (value: unknown, key: string): string => { + if (value === undefined) { + throw new RequiredFieldError(key); + } + if (typeof value === "string") { + return value; + } + throw new InvalidStringValueError(value); +}; + +const asOptionalStringBoolean = ( + value: unknown, +): string | boolean | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "string" || typeof value === "boolean") { + return value; + } + throw new InvalidStringValueError(value); +}; + +const asStringArray = (value: unknown): string[] => { + if (value === undefined) { + return []; + } + if (typeof value === "string") { + return [value]; + } + if (Array.isArray(value)) { + return value.map((v) => { + if (typeof v === "string") { + return v; + } + throw new InvalidStringValueError(v); + }); + } + throw new InvalidStringArrayError(); +}; + +const asOptionalStringArray = (value: unknown): string[] | undefined => + value === undefined ? undefined : asStringArray(value); + +/** + * Coerce a table of environment variables into a record of strings, since the + * value of an environment variable is always a string. + */ +const asStringRecord = (value: unknown): Record => { + if (value === undefined) { + return {}; + } + if (!isRecord(value)) { + throw new InvalidEnvError(value); + } + return Object.entries(value).reduce>( + (acc, [key, val]) => { + if (typeof val === "string") { + acc[key] = val; + } else if ( + typeof val === "number" || + typeof val === "bigint" || + typeof val === "boolean" + ) { + acc[key] = String(val); + } else { + throw new InvalidStringValueError(val); + } + return acc; + }, + {}, + ); +}; + +const asOptionalNumber = (value: unknown, key?: string): number | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "bigint") { + return Number(value); + } + throw new InvalidNumberValueError(value, key); +}; + +const asRequiredNumber = (value: unknown, key: string): number => { + if (value === undefined) { + throw new RequiredFieldError(key); + } + if (typeof value === "number") { + return value; + } + if (typeof value === "bigint") { + return Number(value); + } + + // numbers of the withdrawal configuration are commonly written as hex + const val = + typeof value === "string" && isHex(value) ? parseInt(value, 16) : null; + if (val !== null && !Number.isNaN(val)) { + return val; + } + + throw new InvalidNumberValueError(value, key); +}; + +const asOptionalBigInt = (value: unknown): bigint | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "bigint") { + return value; + } + if (typeof value === "number" && Number.isInteger(value)) { + return BigInt(value); + } + throw new InvalidNumberValueError(value); +}; + +const asRequiredAddress = (value: unknown, key: string): Address => { + if (value === undefined) { + throw new RequiredFieldError(key); + } + if (typeof value === "string" && isAddress(value)) { + return getAddress(value); + } + throw new InvalidAddressValueError(value, key); +}; + +const asBytes = (value: unknown, defaultValue: number): number => + value === undefined ? defaultValue : parseSize(value); + +const asBuilder = (value: unknown): Builder => { + if (value === undefined) { + return "docker"; + } + if (typeof value === "string") { + switch (value) { + case "directory": + case "docker": + case "empty": + case "none": + case "tar": + return value; + } + } + throw new InvalidBuilderError(value); +}; + +const asFormat = (value: unknown): DriveFormat => { + if (value === undefined) { + return DEFAULT_FORMAT; + } + if (value === "ext2" || value === "sqfs") { + return value; + } + throw new InvalidDriveFormatError(value); +}; + +const asEmptyFormat = (value: unknown): "ext2" | "raw" => { + if (value === undefined) { + return DEFAULT_FORMAT; + } + if (value === "ext2" || value === "raw") { + return value; + } + throw new InvalidEmptyDriveFormatError(value); +}; + +const normalizeDrive = (value: unknown): DriveConfig => { + const drive = asRecord("drive", value); + const mount = asOptionalStringBoolean(drive.mount); + const shared = asOptionalBoolean(drive.shared); + const user = asOptionalString(drive.user); + + switch (asBuilder(drive.builder)) { + case "directory": + return { + builder: "directory", + directory: asRequiredString(drive.directory, "directory"), + extraSize: asBytes(drive.extraSize, 0), + format: asFormat(drive.format), + mount, + shared, + user, + }; + case "docker": + return { + builder: "docker", + buildArgs: asStringArray(drive.buildArgs), + context: asString(drive.context, "."), + dockerfile: asString(drive.dockerfile, "Dockerfile"), + extraSize: asBytes(drive.extraSize, 0), + format: asFormat(drive.format), + image: asOptionalString(drive.image), + mount, + shared, + tags: asStringArray(drive.tags), + target: asOptionalString(drive.target), + user, + }; + case "empty": + return { + builder: "empty", + format: asEmptyFormat(drive.format), + mount, + shared, + size: asBytes(drive.size, 0), + user, + }; + case "tar": + return { + builder: "tar", + extraSize: asBytes(drive.extraSize, 0), + filename: asRequiredString(drive.filename, "filename"), + format: asFormat(drive.format), + mount, + shared, + user, + }; + case "none": { + const filename = asRequiredString(drive.filename, "filename"); + return { + builder: "none", + filename, + // the format of an existing drive comes from its extension, + // unless it is explicitly given + format: + drive.format === undefined + ? getDriveFormat(filename) + : asFormat(drive.format), + mount, + shared, + user, + }; + } + } +}; + +const normalizeDrives = (value: unknown): Record => { + const drives = Object.entries(asRecord("drives", value)).reduce< + Record + >((acc, [name, drive]) => { + acc[name] = normalizeDrive(drive); + return acc; + }, {}); + + if (drives.root === undefined) { + // every machine needs a root drive, add a default one + drives.root = defaultRootDriveConfig(); + } + return drives; +}; + +const normalizeMachine = (value: unknown): MachineConfig => { + const machine = asRecord("machine", value); + return { + assertRollingTemplate: asOptionalBoolean(machine.assertRollingTemplate), + bootargs: asStringArray(machine.bootargs), + entrypoint: asOptionalString(machine.entrypoint), + env: asStringRecord(machine.env), + envFile: asOptionalString(machine.envFile), + maxMCycle: asOptionalBigInt(machine.maxMCycle), + ramLength: asString(machine.ramLength, DEFAULT_RAM), + ramImage: asOptionalString(machine.ramImage), + useDockerEnv: asBoolean(machine.useDockerEnv, true), + useDockerWorkdir: asBoolean(machine.useDockerWorkdir, true), + user: asOptionalString(machine.user), + }; +}; + +const asDefaultBlock = (value: unknown): DefaultBlock | undefined => { + if (value === undefined) { + return undefined; + } + if (DEFAULT_BLOCKS.includes(value as DefaultBlock)) { + return value as DefaultBlock; + } + throw new InvalidEnumValueError("defaultBlock", value, DEFAULT_BLOCKS); +}; + +const normalizeRun = (value: unknown): RunConfig => { + const run = asRecord("run", value); + return { + blockTime: asOptionalNumber(run.blockTime, "blockTime"), + claimStagingPeriod: asOptionalNumber( + run.claimStagingPeriod, + "claimStagingPeriod", + ), + cpus: asOptionalNumber(run.cpus, "cpus"), + defaultBlock: asDefaultBlock(run.defaultBlock), + epochLength: asOptionalNumber(run.epochLength, "epochLength"), + forkBlockNumber: asOptionalNumber( + run.forkBlockNumber, + "forkBlockNumber", + ), + forkUrl: asOptionalString(run.forkUrl), + memory: asOptionalNumber(run.memory, "memory"), + port: asOptionalNumber(run.port, "port"), + projectName: asOptionalString(run.projectName), + prt: asOptionalBoolean(run.prt), + runtimeVersion: asOptionalString(run.runtimeVersion), + services: asOptionalStringArray(run.services), + verbose: asOptionalBoolean(run.verbose), + }; +}; + +const normalizeWithdrawal = (value: unknown): WithdrawalConfig | undefined => { + if (value === undefined || value === null) { + return undefined; + } + const withdrawal = asRecord("withdrawal", value); + if (Object.keys(withdrawal).length === 0) { + return undefined; + } + return { + guardian: asRequiredAddress(withdrawal.guardian, "guardian"), + log2_leaves_per_account: asRequiredNumber( + withdrawal.log2_leaves_per_account, + "log2_leaves_per_account", + ), + log2_max_num_of_accounts: asRequiredNumber( + withdrawal.log2_max_num_of_accounts, + "log2_max_num_of_accounts", + ), + accounts_drive_start_index: asRequiredNumber( + withdrawal.accounts_drive_start_index, + "accounts_drive_start_index", + ), + withdrawal_output_builder: asRequiredAddress( + withdrawal.withdrawal_output_builder, + "withdrawal_output_builder", + ), + }; +}; + +/** + * Validate a configuration written by a developer and resolve it into the + * fully defaulted {@link Config} the rest of the CLI works with. + * + * The input is deliberately typed as `unknown`: a `cartesi.config.ts` file is + * type checked at author time, but a `cartesi.config.json` or + * `cartesi.config.yaml` file is not, so every field is validated here. + * + * An already resolved {@link Config} is a valid input, and normalizing it again + * yields an equivalent configuration. + * + * @param input configuration as written, or `undefined` for the defaults + * @returns resolved application configuration + */ +export const normalizeConfig = (input: unknown): Config => { + const config = asRecord("config", input); + + return { + drives: normalizeDrives(config.drives), + machine: normalizeMachine(config.machine), + run: normalizeRun(config.run), + sdk: asString( + config.sdk, + `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`, + ), + withdrawalConfig: normalizeWithdrawal( + config.withdrawal ?? config.withdrawalConfig, + ), + }; +}; diff --git a/apps/cli/src/config/size.ts b/apps/cli/src/config/size.ts new file mode 100644 index 00000000..64e05212 --- /dev/null +++ b/apps/cli/src/config/size.ts @@ -0,0 +1,46 @@ +import { InvalidBytesValueError } from "./errors.js"; + +/** + * Multiples accepted by {@link parseSize}. All of them are binary, which is + * what the tools building the drives expect, so `kb` and `KiB` are the same + * 1024 bytes. + */ +const SIZE_UNITS: Record = { + b: 1, + k: 1024, + m: 1024 ** 2, + g: 1024 ** 3, + t: 1024 ** 4, + p: 1024 ** 5, +}; + +const SIZE_PATTERN = /^([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*([kmgtp]i?b?|b)?$/i; + +/** + * Parse a size into a number of bytes, accepting both a number of bytes and a + * human readable string such as `"64Mb"`, `"64MB"`, `"64Mi"` or `"64MiB"`. + * + * Unrecognized strings are rejected rather than coerced, which is why this + * does not use `bytes.parse`: that one falls back to `parseInt`, and silently + * reads `"64Mi"` as 64 bytes. + * + * @param value size as written in the configuration + * @returns size in bytes + */ +export const parseSize = (value: unknown): number => { + if (typeof value === "bigint") { + return Number(value); + } + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const match = SIZE_PATTERN.exec(value.trim()); + if (match) { + const [, amount, unit = "b"] = match; + const multiple = SIZE_UNITS[unit[0].toLowerCase()]; + return Math.floor(Number(amount) * multiple); + } + } + throw new InvalidBytesValueError(value); +}; diff --git a/apps/cli/src/config.ts b/apps/cli/src/config/toml.ts similarity index 66% rename from apps/cli/src/config.ts rename to apps/cli/src/config/toml.ts index af2b65f6..1165ef08 100644 --- a/apps/cli/src/config.ts +++ b/apps/cli/src/config/toml.ts @@ -1,227 +1,46 @@ -import bytes from "bytes"; -import { extname } from "node:path"; import { parse as parseToml, type TomlPrimitive } from "smol-toml"; -import { getAddress, isAddress, isHex, type Address } from "viem"; +import { type Address, getAddress, isAddress, isHex } from "viem"; +import { + InvalidAddressValueError, + InvalidBooleanValueError, + InvalidBuilderError, + InvalidDriveFormatError, + InvalidEmptyDriveFormatError, + InvalidEnvError, + InvalidNumberValueError, + InvalidStringArrayError, + InvalidStringValueError, + RequiredFieldError, +} from "./errors.js"; +import { parseSize } from "./size.js"; +import { + type Builder, + type Config, + DEFAULT_FORMAT, + DEFAULT_RAM, + DEFAULT_SDK_IMAGE, + DEFAULT_SDK_VERSION, + defaultMachineConfig, + defaultRootDriveConfig, + defaultRunConfig, + type DriveConfig, + type DriveFormat, + getDriveFormat, + type MachineConfig, + type WithdrawalConfig, +} from "./types.js"; /** - * Typed Errors - */ -export class InvalidBuilderError extends Error { - constructor(builder: TomlPrimitive) { - super(`Invalid builder: ${builder}`); - this.name = "InvalidBuilder"; - } -} - -export class InvalidDriveFormatError extends Error { - constructor(format: TomlPrimitive) { - super(`Invalid drive format: ${format}`); - this.name = "InvalidDriveFormatError"; - } -} - -export class InvalidEmptyDriveFormatError extends Error { - constructor(format: TomlPrimitive) { - super(`Invalid empty drive format: ${format}`); - this.name = "InvalidEmptyDriveFormatError"; - } -} - -export class InvalidStringValueError extends Error { - constructor(value: TomlPrimitive) { - super(`Invalid string value: ${value}`); - this.name = "InvalidStringValueError"; - } -} - -export class InvalidBooleanValueError extends Error { - constructor(value: TomlPrimitive) { - super(`Invalid boolean value: ${value}`); - this.name = "InvalidBooleanValueError"; - } -} - -export class InvalidNumberValueError extends Error { - constructor(value: TomlPrimitive, key?: string) { - super(`Invalid number value: ${value}${key ? ` for key: ${key}` : ""}`); - this.name = "InvalidNumberValueError"; - } -} - -export class InvalidAddressValueError extends Error { - constructor(value: TomlPrimitive, key?: string) { - super( - `Invalid address value: ${value}${key ? ` for key: ${key}` : ""}`, - ); - this.name = "InvalidAddressValueError"; - } -} - -export class InvalidBytesValueError extends Error { - constructor(value: TomlPrimitive) { - super(`Invalid bytes value: ${value}`); - this.name = "InvalidBytesValueError"; - } -} - -export class RequiredFieldError extends Error { - constructor(key: TomlPrimitive) { - super(`Missing required field: ${key}`); - this.name = "RequiredFieldError"; - } -} - -export class InvalidStringArrayError extends Error { - constructor() { - super("Invalid string array"); - this.name = "InvalidStringArrayError"; - } -} - -export class InvalidEnvError extends Error { - constructor(value: TomlPrimitive) { - super(`Invalid env configuration: ${value}`); - this.name = "InvalidEnvError"; - } -} - -/** - * Configuration for drives of a Cartesi Machine. A drive may already exist or be built by a builder - */ -const DEFAULT_FORMAT = "ext2"; -const DEFAULT_RAM = "128Mi"; -export const DEFAULT_SDK_VERSION = "0.12.0-alpha.41"; -export const DEFAULT_SDK_IMAGE = "cartesi/sdk"; -export const PREFERRED_PORT = 6751; - -type Builder = "directory" | "docker" | "empty" | "none" | "tar"; -export type DriveFormat = "ext2" | "sqfs"; - -export type ImageInfo = { - cmd: string[]; - entrypoint: string[]; - env: string[]; - workdir: string; -}; - -export type DriveResult = ImageInfo | undefined; - -export type DirectoryDriveConfig = { - builder: "directory"; - extraSize: number; // default is 0 (no extra size) - format: DriveFormat; - directory: string; // required -}; - -export type DockerDriveConfig = { - builder: "docker"; - buildArgs: string[]; // default is empty array - context: string; - dockerfile: string; - extraSize: number; // default is 0 (no extra size) - format: DriveFormat; - image?: string; // default is to build an image from a Dockerfile - tags: string[]; // default is empty array - target?: string; // default is last stage of multi-stage -}; - -export type EmptyDriveConfig = { - builder: "empty"; - format: "ext2" | "raw"; - size: number; // in bytes -}; - -export type ExistingDriveConfig = { - builder: "none"; - filename: string; // required - format: DriveFormat; -}; - -export type TarDriveConfig = { - builder: "tar"; - filename: string; // required - format: DriveFormat; - extraSize: number; // default is 0 (no extra size) -}; - -export type DriveConfig = ( - | DirectoryDriveConfig - | DockerDriveConfig - | EmptyDriveConfig - | ExistingDriveConfig - | TarDriveConfig -) & { - mount?: string | boolean; // default given by cartesi-machine - shared?: boolean; // default given by cartesi-machine - user?: string; // default given by cartesi-machine -}; - -export type MachineConfig = { - assertRollingTemplate?: boolean; // default given by cartesi-machine - bootargs: string[]; - entrypoint?: string; - env: Record; // 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 - ramLength: string; - ramImage?: string; // default given by cartesi-machine - 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 -}; - -/** - * Configuration for Emergercy-withdrawals that will be passed down to the - * cartesi-rollups-cli. This is a All or nothing kind of configuration. - * The properties are kept snake_case to match the expected input in the cartesi-rollups-cli. + * Parser of the legacy `cartesi.toml` configuration file. + * + * This format is deprecated in favour of `cartesi.config.ts` (and its plain + * data equivalents), which is why it is kept frozen here: it uses snake_case + * keys and has no `[run]` section. It is still fully supported so existing + * applications keep building. */ -export type WithdrawalConfig = { - guardian: Address; - log2_leaves_per_account: number; - log2_max_num_of_accounts: number; - accounts_drive_start_index: number; - withdrawal_output_builder: Address; -}; - -export type Config = { - drives: Record; - machine: MachineConfig; - sdk: string; - withdrawalConfig?: WithdrawalConfig; -}; type TomlTable = { [key: string]: TomlPrimitive }; -export const defaultRootDriveConfig = (): DriveConfig => ({ - builder: "docker", - buildArgs: [], - context: ".", - dockerfile: "Dockerfile", // file on current working directory - extraSize: 0, - format: DEFAULT_FORMAT, - tags: [], -}); - -export const defaultMachineConfig = (): MachineConfig => ({ - assertRollingTemplate: undefined, - bootargs: [], - entrypoint: undefined, - env: {}, - envFile: undefined, - maxMCycle: undefined, - ramLength: DEFAULT_RAM, - useDockerEnv: true, - useDockerWorkdir: true, - user: undefined, -}); - -export const defaultConfig = (): Config => ({ - drives: { root: defaultRootDriveConfig() }, - machine: defaultMachineConfig(), - sdk: `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`, - withdrawalConfig: undefined, -}); - const parseBoolean = (value: TomlPrimitive, defaultValue: boolean): boolean => { if (value === undefined) { return defaultValue; @@ -378,21 +197,8 @@ const parseOptionalNumber = (value: TomlPrimitive): bigint | undefined => { throw new InvalidNumberValueError(value); }; -const parseBytes = (value: TomlPrimitive, defaultValue: number): number => { - if (value === undefined) { - return defaultValue; - } - if (typeof value === "bigint") { - return Number(value); - } - if (typeof value === "number" || typeof value === "string") { - const output = bytes.parse(value); - if (output !== null) { - return output; - } - } - throw new InvalidBytesValueError(value); -}; +const parseBytes = (value: TomlPrimitive, defaultValue: number): number => + value === undefined ? defaultValue : parseSize(value); const parseBuilder = (value: TomlPrimitive): Builder => { if (value === undefined) { @@ -472,18 +278,6 @@ const parseMachine = (value: TomlPrimitive): MachineConfig => { }; }; -export const getDriveFormat = (filename: string): DriveFormat => { - const extension = extname(filename); - switch (extension) { - case ".ext2": - return "ext2"; - case ".sqfs": - return "sqfs"; - default: - throw new InvalidDriveFormatError(extension); - } -}; - const parseDrive = (drive: TomlPrimitive): DriveConfig => { const builder = parseBuilder((drive as TomlTable).builder); switch (builder) { @@ -633,6 +427,12 @@ const parseOptionalWithdrawalConfig = ( return parseWithdrawalConfig(config as TomlTable); }; +/** + * Parse the contents of one or more legacy `cartesi.toml` files, merged in + * order, into a resolved application configuration. + * @param str contents of the TOML files + * @returns resolved application configuration + */ export const parse = (str: string[]): Config => { let toml: TomlTable = {}; for (const s of str) { @@ -643,6 +443,7 @@ export const parse = (str: string[]): Config => { withdrawalConfig: parseOptionalWithdrawalConfig(toml.withdrawal), drives: parseDrives(toml.drives), machine: parseMachine(toml.machine), + run: defaultRunConfig(), sdk: parseString( toml.sdk, `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`, diff --git a/apps/cli/src/config/types.ts b/apps/cli/src/config/types.ts new file mode 100644 index 00000000..d9128086 --- /dev/null +++ b/apps/cli/src/config/types.ts @@ -0,0 +1,252 @@ +import { extname } from "node:path"; +import type { Address } from "viem"; +import { InvalidDriveFormatError } from "./errors.js"; + +/** + * Resolved configuration of an application. This is the fully normalized and + * validated shape the rest of the CLI works with, produced from a + * {@link UserConfig} written by hand in a configuration file, or built + * programmatically. + */ + +export const DEFAULT_FORMAT = "ext2"; +export const DEFAULT_RAM = "128Mi"; +export const DEFAULT_SDK_VERSION = "0.12.0-alpha.41"; +export const DEFAULT_SDK_IMAGE = "cartesi/sdk"; +export const PREFERRED_PORT = 6751; + +export type Builder = "directory" | "docker" | "empty" | "none" | "tar"; +export type DriveFormat = "ext2" | "sqfs"; + +/** Block used by the node when fetching new blocks. */ +export type DefaultBlock = "latest" | "safe" | "pending" | "finalized"; + +export const DEFAULT_BLOCKS: readonly DefaultBlock[] = [ + "latest", + "safe", + "pending", + "finalized", +]; + +export type ImageInfo = { + cmd: string[]; + entrypoint: string[]; + env: string[]; + workdir: string; +}; + +export type DriveResult = ImageInfo | undefined; + +export type DirectoryDriveConfig = { + builder: "directory"; + extraSize: number; // default is 0 (no extra size) + format: DriveFormat; + directory: string; // required +}; + +export type DockerDriveConfig = { + builder: "docker"; + buildArgs: string[]; // default is empty array + context: string; + dockerfile: string; + extraSize: number; // default is 0 (no extra size) + format: DriveFormat; + image?: string; // default is to build an image from a Dockerfile + tags: string[]; // default is empty array + target?: string; // default is last stage of multi-stage +}; + +export type EmptyDriveConfig = { + builder: "empty"; + format: "ext2" | "raw"; + size: number; // in bytes +}; + +export type ExistingDriveConfig = { + builder: "none"; + filename: string; // required + format: DriveFormat; +}; + +export type TarDriveConfig = { + builder: "tar"; + filename: string; // required + format: DriveFormat; + extraSize: number; // default is 0 (no extra size) +}; + +export type DriveConfig = ( + | DirectoryDriveConfig + | DockerDriveConfig + | EmptyDriveConfig + | ExistingDriveConfig + | TarDriveConfig +) & { + mount?: string | boolean; // default given by cartesi-machine + shared?: boolean; // default given by cartesi-machine + user?: string; // default given by cartesi-machine +}; + +export type MachineConfig = { + assertRollingTemplate?: boolean; // default given by cartesi-machine + bootargs: string[]; + entrypoint?: string; + env: Record; // 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 + ramLength: string; + ramImage?: string; // default given by cartesi-machine + 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 +}; + +/** + * Configuration of the local development environment started by `cartesi run`. + * + * Every field is optional: it defines a project level default for the + * equivalent command line option, which always takes precedence when given. + */ +export type RunConfig = { + /** + * Interval between blocks, in seconds. + * @default 2 + */ + blockTime?: number; + + /** + * Number of blocks between a claim being submitted and accepted + * (Authority/Quorum only). + * @default 0 + */ + claimStagingPeriod?: number; + + /** Number of cpu limits for the rollups-node. */ + cpus?: number; + + /** + * Block used when fetching new blocks. + * @default "latest" + */ + defaultBlock?: DefaultBlock; + + /** + * Length of an epoch, in blocks. + * @default 720 + */ + epochLength?: number; + + /** Block number to fork from. */ + forkBlockNumber?: number; + + /** RPC URL to fork from. */ + forkUrl?: string; + + /** Memory limit for the rollups-node, in MB. */ + memory?: number; + + /** + * Port to listen on. + * @default first free port from 6751 + */ + port?: number; + + /** + * Name of the project, used by docker compose and by the rollups node. + * @default basename of the current working directory + */ + projectName?: string; + + /** + * Deploy the application with PRT consensus. + * @default false + */ + prt?: boolean; + + /** Version of the Cartesi Rollups Runtime to use. */ + runtimeVersion?: string; + + /** + * Optional services to start. The single value `all` starts every optional + * service. + * @default [] + */ + services?: string[]; + + /** + * Increase the log level of the environment services. + * @default false + */ + verbose?: boolean; +}; + +/** + * Configuration for Emergercy-withdrawals that will be passed down to the + * cartesi-rollups-cli. This is a All or nothing kind of configuration. + * The properties are kept snake_case to match the expected input in the cartesi-rollups-cli. + */ +export type WithdrawalConfig = { + guardian: Address; + log2_leaves_per_account: number; + log2_max_num_of_accounts: number; + accounts_drive_start_index: number; + withdrawal_output_builder: Address; +}; + +export type Config = { + drives: Record; + machine: MachineConfig; + run: RunConfig; + sdk: string; + withdrawalConfig?: WithdrawalConfig; +}; + +export const defaultRootDriveConfig = (): DriveConfig => ({ + builder: "docker", + buildArgs: [], + context: ".", + dockerfile: "Dockerfile", // file on current working directory + extraSize: 0, + format: DEFAULT_FORMAT, + tags: [], +}); + +export const defaultMachineConfig = (): MachineConfig => ({ + assertRollingTemplate: undefined, + bootargs: [], + entrypoint: undefined, + env: {}, + envFile: undefined, + maxMCycle: undefined, + ramLength: DEFAULT_RAM, + useDockerEnv: true, + useDockerWorkdir: true, + user: undefined, +}); + +export const defaultRunConfig = (): RunConfig => ({}); + +export const defaultConfig = (): Config => ({ + drives: { root: defaultRootDriveConfig() }, + machine: defaultMachineConfig(), + run: defaultRunConfig(), + sdk: `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`, + withdrawalConfig: undefined, +}); + +/** + * Infer the format of a drive from the extension of its filename. + * @param filename name of the drive file + * @returns format of the drive + */ +export const getDriveFormat = (filename: string): DriveFormat => { + const extension = extname(filename); + switch (extension) { + case ".ext2": + return "ext2"; + case ".sqfs": + return "sqfs"; + default: + throw new InvalidDriveFormatError(extension); + } +}; diff --git a/apps/cli/src/config/user.ts b/apps/cli/src/config/user.ts new file mode 100644 index 00000000..6a3910b4 --- /dev/null +++ b/apps/cli/src/config/user.ts @@ -0,0 +1,288 @@ +import type { DriveFormat, RunConfig, WithdrawalConfig } from "./types.js"; + +/** + * Input types of the application configuration: the shape a developer actually + * writes, either in a `cartesi.config.ts` file through {@link defineConfig}, or + * in one of the plain data formats (`cartesi.config.json`, + * `cartesi.config.yaml`, `cartesi.config`). + * + * Everything that has a default is optional here. Sizes accept human readable + * strings (`"128Mi"`) as well as a number of bytes. + */ + +/** A size, either a number of bytes or a human readable string like `"128Mi"`. */ +export type Size = number | string; + +/** A drive built from a directory of the host filesystem. */ +export type UserDirectoryDriveConfig = { + builder: "directory"; + + /** Directory to copy into the drive. */ + directory: string; + + /** + * Extra free space to add to the drive, beyond the size of its contents. + * @default 0 + */ + extraSize?: Size; + + /** @default "ext2" */ + format?: DriveFormat; +}; + +/** A drive built from the filesystem of a docker image. */ +export type UserDockerDriveConfig = { + /** @default "docker" */ + builder?: "docker"; + + /** + * Arguments passed to the docker build, as `NAME=value` strings. + * @default [] + */ + buildArgs?: string[]; + + /** @default "." */ + context?: string; + + /** @default "Dockerfile" */ + dockerfile?: string; + + /** + * Extra free space to add to the drive, beyond the size of its contents. + * @default 0 + */ + extraSize?: Size; + + /** @default "ext2" */ + format?: DriveFormat; + + /** Use an existing image instead of building one from a Dockerfile. */ + image?: string; + + /** + * Tags applied to the image built. + * @default [] + */ + tags?: string[]; + + /** Stage to build, defaults to the last stage of a multi-stage build. */ + target?: string; +}; + +/** An empty drive, to be used as writable storage by the application. */ +export type UserEmptyDriveConfig = { + builder: "empty"; + + /** @default "ext2" */ + format?: "ext2" | "raw"; + + /** + * Size of the drive. + * @default 0 + */ + size?: Size; +}; + +/** A drive that already exists as a filesystem image on disk. */ +export type UserExistingDriveConfig = { + builder: "none"; + + /** Name of the drive file, its extension defines the format. */ + filename: string; + + /** @default inferred from the extension of `filename` */ + format?: DriveFormat; +}; + +/** A drive built from the contents of a tar archive. */ +export type UserTarDriveConfig = { + builder: "tar"; + + /** Name of the tar file. */ + filename: string; + + /** + * Extra free space to add to the drive, beyond the size of its contents. + * @default 0 + */ + extraSize?: Size; + + /** @default "ext2" */ + format?: DriveFormat; +}; + +export type UserDriveConfig = ( + | UserDirectoryDriveConfig + | UserDockerDriveConfig + | UserEmptyDriveConfig + | UserExistingDriveConfig + | UserTarDriveConfig +) & { + /** Where the drive is mounted inside the machine. */ + mount?: string | boolean; + + /** Whether writes to the drive are visible to the host. */ + shared?: boolean; + + /** User that owns the files of the drive. */ + user?: string; +}; + +/** Configuration of the Cartesi machine of the application. */ +export type UserMachineConfig = { + assertRollingTemplate?: boolean; + + /** + * Extra arguments appended to the kernel command line. + * @default [] + */ + bootargs?: string[]; + + /** Command the machine runs on boot, overriding the docker image one. */ + entrypoint?: string; + + /** + * Environment variables injected into the machine. Values that are not + * strings are coerced to strings. + * @default {} + */ + env?: Record; + + /** Path of a `.env` file with environment variables injected into the machine. */ + envFile?: string; + + /** Maximum number of machine cycles to run. */ + maxMCycle?: bigint | number; + + /** + * Amount of RAM of the machine. + * @default "128Mi" + */ + ramLength?: string; + + /** Kernel image of the machine. */ + ramImage?: string; + + /** + * Inject the `ENV` of the docker image of the root drive into the machine. + * @default true + */ + useDockerEnv?: boolean; + + /** + * Inject the `WORKDIR` of the docker image of the root drive into the machine. + * @default true + */ + useDockerWorkdir?: boolean; + + /** User the entrypoint runs as. */ + user?: string; +}; + +/** + * Configuration of an application, as written by a developer. + * + * @see {@link defineConfig} + */ +export type UserConfig = { + /** + * Drives of the Cartesi machine. A `root` drive built from a `Dockerfile` + * of the current directory is used when none is defined. + */ + drives?: Record; + + /** Configuration of the Cartesi machine. */ + machine?: UserMachineConfig; + + /** + * Project level defaults of the local development environment started by + * `cartesi run`. Command line options always take precedence. + */ + run?: RunConfig; + + /** + * Docker image with the build tools used to build the drives and the + * machine. + * @default "cartesi/sdk:" + */ + sdk?: string; + + /** Configuration of emergency withdrawals. */ + withdrawal?: WithdrawalConfig; + + /** + * @deprecated use {@link UserConfig.withdrawal} instead. Accepted so an + * already resolved {@link Config} can be given back as a `UserConfig`. + */ + withdrawalConfig?: WithdrawalConfig; +}; + +/** Command being run, given to a configuration file that exports a function. */ +export type ConfigCommand = "build" | "run" | "shell"; + +/** + * Context given to a configuration file that exports a function instead of an + * object, so the configuration can depend on what is being run. + */ +export type ConfigEnv = { + /** Command being run. */ + command: ConfigCommand; + + /** Directory the configuration is being resolved from. */ + cwd: string; + + /** + * Mode the application is being built or run in, from `CARTESI_ENV` or + * `NODE_ENV`. + * @default "development" + */ + mode: string; +}; + +/** A configuration file that computes its configuration from the environment. */ +export type UserConfigFn = (env: ConfigEnv) => UserConfig | Promise; + +/** Anything a configuration file is allowed to export as its default export. */ +export type UserConfigExport = UserConfig | Promise | UserConfigFn; + +/** + * Define the configuration of a Cartesi application. + * + * This is an identity function that exists purely to give a `cartesi.config.ts` + * file type checking and editor completion, without the developer having to + * annotate the exported value: + * + * ```ts + * import { defineConfig } from "@cartesi/cli/config"; + * + * export default defineConfig({ + * drives: { + * root: { builder: "docker", dockerfile: "Dockerfile" }, + * }, + * machine: { ramLength: "256Mi" }, + * run: { epochLength: 10 }, + * }); + * ``` + * + * A function can be exported instead, to configure the application differently + * depending on the command being run: + * + * ```ts + * export default defineConfig(({ command }) => ({ + * run: { epochLength: command === "run" ? 10 : 720 }, + * })); + * ``` + * + * The function may be asynchronous. + * + * @param config configuration object, promise of one, or a function that + * computes one from the {@link ConfigEnv} + * @returns the configuration, unchanged + */ +export function defineConfig(config: UserConfig): UserConfig; +export function defineConfig(config: Promise): Promise; +export function defineConfig(config: UserConfigFn): UserConfigFn; +export function defineConfig(config: UserConfigExport): UserConfigExport; +export function defineConfig(config: UserConfigExport): UserConfigExport { + return config; +} diff --git a/apps/cli/src/defineConfig.ts b/apps/cli/src/defineConfig.ts new file mode 100644 index 00000000..74c7fede --- /dev/null +++ b/apps/cli/src/defineConfig.ts @@ -0,0 +1,58 @@ +/** + * Entrypoint of `@cartesi/cli/config`, the module a `cartesi.config.ts` file + * imports: + * + * ```ts + * import { defineConfig } from "@cartesi/cli/config"; + * + * export default defineConfig({ + * machine: { ramLength: "256Mi" }, + * }); + * ``` + * + * It only carries the configuration types and the helpers that operate on + * them, so importing it from a configuration file does not pull in the rest of + * the CLI. + */ + +export { mergeConfig } from "./config/merge.js"; +export type { + Config, + DefaultBlock, + DirectoryDriveConfig, + DockerDriveConfig, + DriveConfig, + DriveFormat, + EmptyDriveConfig, + ExistingDriveConfig, + ImageInfo, + MachineConfig, + RunConfig, + TarDriveConfig, + WithdrawalConfig, +} from "./config/types.js"; +export { + DEFAULT_SDK_IMAGE, + DEFAULT_SDK_VERSION, + defaultConfig, + defaultMachineConfig, + defaultRootDriveConfig, + defaultRunConfig, + PREFERRED_PORT, +} from "./config/types.js"; +export { + type ConfigCommand, + type ConfigEnv, + defineConfig, + type Size, + type UserConfig, + type UserConfigExport, + type UserConfigFn, + type UserDirectoryDriveConfig, + type UserDockerDriveConfig, + type UserDriveConfig, + type UserEmptyDriveConfig, + type UserExistingDriveConfig, + type UserMachineConfig, + type UserTarDriveConfig, +} from "./config/user.js"; diff --git a/apps/cli/src/exec/cartesi-machine-stored-hash.ts b/apps/cli/src/exec/cartesi-machine-stored-hash.ts index 6c360f3f..310da8dc 100644 --- a/apps/cli/src/exec/cartesi-machine-stored-hash.ts +++ b/apps/cli/src/exec/cartesi-machine-stored-hash.ts @@ -1,5 +1,5 @@ import { isHash, type Hash } from "viem"; -import { DEFAULT_SDK_IMAGE, DEFAULT_SDK_VERSION } from "../config.js"; +import { DEFAULT_SDK_IMAGE, DEFAULT_SDK_VERSION } from "../config/index.js"; import { execaDockerFallback, type DockerFallbackOptions } from "./util.js"; type ComputeHashOptions = { cwd?: string } & DockerFallbackOptions; diff --git a/apps/cli/src/exec/rollups.ts b/apps/cli/src/exec/rollups.ts index ea2c19ce..32f68441 100644 --- a/apps/cli/src/exec/rollups.ts +++ b/apps/cli/src/exec/rollups.ts @@ -28,7 +28,7 @@ import node from "../compose/node.js"; import passkey from "../compose/passkey.js"; import paymaster from "../compose/paymaster.js"; import proxy from "../compose/proxy.js"; -import type { WithdrawalConfig } from "../config.js"; +import type { WithdrawalConfig } from "../config/index.js"; import type { ForkConfig } from "../types/chain.js"; type ApplicationStatus = "OK" | "FAILED" | "DIVERGED" | "CORRUPTED"; diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 1045a545..c47f099d 100755 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -43,14 +43,26 @@ const program = new Command() .addCommand(createStatusCommand()); // Global error handling -process.on("uncaughtException", (err) => { - if (process.env.NODE_ENV === "development") { +// +// read through an alias of 'process.env', because the bundler replaces +// 'process.env.NODE_ENV' with its value at build time, which would make this a +// constant and always print the stack trace +const { env } = process; + +const reportError = (err: unknown) => { + if (env.NODE_ENV === "development") { console.error(err); } else { // in production, only print the error message, not the stack trace - console.error(err.message); + console.error(err instanceof Error ? err.message : String(err)); } process.exit(1); -}); +}; + +process.on("uncaughtException", reportError); + +// command actions are asynchronous, and commander does not await them, so an +// error thrown by one surfaces here rather than as an uncaught exception +process.on("unhandledRejection", reportError); program.parse(); diff --git a/apps/cli/src/lib.ts b/apps/cli/src/lib.ts index 24ce8efd..ba192d3b 100644 --- a/apps/cli/src/lib.ts +++ b/apps/cli/src/lib.ts @@ -21,27 +21,52 @@ */ export * from "./api/index.js"; -// configuration of an application ('cartesi.toml') +// configuration of an application ('cartesi.config.ts' and friends) export { + CONFIG_FILES, type Config, + type ConfigCommand, + type ConfigEnv, DEFAULT_SDK_IMAGE, DEFAULT_SDK_VERSION, + type DefaultBlock, defaultConfig, defaultMachineConfig, defaultRootDriveConfig, + defaultRunConfig, + defineConfig, type DirectoryDriveConfig, type DockerDriveConfig, type DriveConfig, type DriveFormat, type EmptyDriveConfig, type ExistingDriveConfig, + findConfigFile, type ImageInfo, + LEGACY_CONFIG_FILE, + loadConfig, + type LoadConfigOptions, + loadConfigFile, type MachineConfig, + mergeConfig, + normalizeConfig, parse as parseConfig, PREFERRED_PORT, + type RunConfig, + type Size, type TarDriveConfig, + type UserConfig, + type UserConfigExport, + type UserConfigFn, + type UserDirectoryDriveConfig, + type UserDockerDriveConfig, + type UserDriveConfig, + type UserEmptyDriveConfig, + type UserExistingDriveConfig, + type UserMachineConfig, + type UserTarDriveConfig, type WithdrawalConfig, -} from "./config.js"; +} from "./config/index.js"; // runtime environment export { diff --git a/apps/cli/src/machine.ts b/apps/cli/src/machine.ts index b7f347ee..bf829d92 100644 --- a/apps/cli/src/machine.ts +++ b/apps/cli/src/machine.ts @@ -1,6 +1,6 @@ import dotenv from "dotenv"; import fs from "node:fs"; -import type { Config, DriveConfig, ImageInfo } from "./config.js"; +import type { Config, DriveConfig, ImageInfo } from "./config/index.js"; import { cartesiMachine } from "./exec/index.js"; import type { ExecaOptionsDockerFallback } from "./exec/util.js"; diff --git a/apps/cli/src/wallet.ts b/apps/cli/src/wallet.ts index 00bceb50..b68bf3d7 100644 --- a/apps/cli/src/wallet.ts +++ b/apps/cli/src/wallet.ts @@ -8,7 +8,7 @@ import { } from "viem"; import { anvil } from "viem/chains"; import { getProjectName } from "./base.js"; -import { PREFERRED_PORT } from "./config.js"; +import { PREFERRED_PORT } from "./config/index.js"; import { getProjectPort } from "./exec/rollups.js"; export const cartesi = defineChain({ diff --git a/apps/cli/tests/integration/builder/directory.test.ts b/apps/cli/tests/integration/builder/directory.test.ts index c2664dd6..2e53503d 100644 --- a/apps/cli/tests/integration/builder/directory.test.ts +++ b/apps/cli/tests/integration/builder/directory.test.ts @@ -9,7 +9,7 @@ import { import fs from "fs-extra"; import path from "node:path"; import { build } from "../../../src/builder/directory.js"; -import type { DirectoryDriveConfig } from "../../../src/config.js"; +import type { DirectoryDriveConfig } from "../../../src/config/index.js"; import { setupIntegrationTests, TEST_SDK } from "../config.js"; import { cleanupTempDir, createTempDir } from "./tmpdirTest.js"; diff --git a/apps/cli/tests/integration/builder/docker.test.ts b/apps/cli/tests/integration/builder/docker.test.ts index 488b59fd..1f57f804 100644 --- a/apps/cli/tests/integration/builder/docker.test.ts +++ b/apps/cli/tests/integration/builder/docker.test.ts @@ -10,7 +10,7 @@ import fs from "fs-extra"; import path from "node:path"; import tmp from "tmp"; import { build } from "../../../src/builder/docker.js"; -import type { DockerDriveConfig } from "../../../src/config.js"; +import type { DockerDriveConfig } from "../../../src/config/index.js"; import { setupIntegrationTests, TEST_SDK } from "../config.js"; import { cleanupTempDir, createTempDir } from "./tmpdirTest.js"; diff --git a/apps/cli/tests/integration/builder/empty.test.ts b/apps/cli/tests/integration/builder/empty.test.ts index 88186ca4..9563cb1f 100644 --- a/apps/cli/tests/integration/builder/empty.test.ts +++ b/apps/cli/tests/integration/builder/empty.test.ts @@ -9,7 +9,7 @@ import { import fs from "fs-extra"; import path from "node:path"; import { build } from "../../../src/builder/empty.js"; -import type { EmptyDriveConfig } from "../../../src/config.js"; +import type { EmptyDriveConfig } from "../../../src/config/index.js"; import { setupIntegrationTests, TEST_SDK } from "../config.js"; import { cleanupTempDir, createTempDir } from "./tmpdirTest.js"; diff --git a/apps/cli/tests/integration/builder/none.test.ts b/apps/cli/tests/integration/builder/none.test.ts index 729cf458..b7b8f089 100644 --- a/apps/cli/tests/integration/builder/none.test.ts +++ b/apps/cli/tests/integration/builder/none.test.ts @@ -9,7 +9,7 @@ import { import fs from "fs-extra"; import path from "node:path"; import { build } from "../../../src/builder/none.js"; -import type { ExistingDriveConfig } from "../../../src/config.js"; +import type { ExistingDriveConfig } from "../../../src/config/index.js"; import { setupIntegrationTests } from "../config.js"; import { cleanupTempDir, createTempDir } from "./tmpdirTest.js"; diff --git a/apps/cli/tests/integration/builder/tar.test.ts b/apps/cli/tests/integration/builder/tar.test.ts index 5690d063..2f52af52 100644 --- a/apps/cli/tests/integration/builder/tar.test.ts +++ b/apps/cli/tests/integration/builder/tar.test.ts @@ -9,7 +9,7 @@ import { import fs from "fs-extra"; import path from "node:path"; import { build } from "../../../src/builder/tar.js"; -import type { TarDriveConfig } from "../../../src/config.js"; +import type { TarDriveConfig } from "../../../src/config/index.js"; import { setupIntegrationTests, TEST_SDK } from "../config.js"; import { cleanupTempDir, createTempDir } from "./tmpdirTest.js"; diff --git a/apps/cli/tests/integration/config.ts b/apps/cli/tests/integration/config.ts index 57ebadce..f8726614 100644 --- a/apps/cli/tests/integration/config.ts +++ b/apps/cli/tests/integration/config.ts @@ -2,7 +2,10 @@ import { execa } from "execa"; import fs from "node:fs"; import path from "node:path"; import tmp from "tmp"; -import { DEFAULT_SDK_IMAGE, DEFAULT_SDK_VERSION } from "../../src/config.js"; +import { + DEFAULT_SDK_IMAGE, + DEFAULT_SDK_VERSION, +} from "../../src/config/index.js"; export const TEST_SDK = `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`; diff --git a/apps/cli/tests/unit/api/run.test.ts b/apps/cli/tests/unit/api/run.test.ts new file mode 100644 index 00000000..74be3f75 --- /dev/null +++ b/apps/cli/tests/unit/api/run.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "bun:test"; +import { resolveRunOptions } from "../../../src/api/run.js"; +import { DEFAULT_SDK_VERSION } from "../../../src/config/index.js"; + +describe("api/run", () => { + describe("resolveRunOptions", () => { + it("should fall back to the defaults", () => { + expect(resolveRunOptions({})).toEqual({ + blockTime: 2, + claimStagingPeriod: 0, + cpus: undefined, + defaultBlock: "latest", + epochLength: 720, + forkBlockNumber: undefined, + forkUrl: undefined, + memory: undefined, + port: undefined, + projectName: undefined, + prt: false, + runtimeVersion: DEFAULT_SDK_VERSION, + services: [], + verbose: false, + }); + }); + + it("should take the values of the configuration", () => { + const resolved = resolveRunOptions( + {}, + { + blockTime: 1, + defaultBlock: "finalized", + epochLength: 10, + forkUrl: "https://rpc.example.com", + port: 8080, + projectName: "my-app", + prt: true, + services: ["explorer"], + }, + ); + expect(resolved).toMatchObject({ + blockTime: 1, + defaultBlock: "finalized", + epochLength: 10, + forkUrl: "https://rpc.example.com", + port: 8080, + projectName: "my-app", + prt: true, + services: ["explorer"], + }); + // not set anywhere, so still the default + expect(resolved.claimStagingPeriod).toBe(0); + }); + + it("should let the options take precedence over the configuration", () => { + const resolved = resolveRunOptions( + { + blockTime: 5, + epochLength: 100, + projectName: "from-option", + services: [], + }, + { + blockTime: 1, + epochLength: 10, + projectName: "from-config", + services: ["explorer"], + }, + ); + expect(resolved).toMatchObject({ + blockTime: 5, + epochLength: 100, + projectName: "from-option", + // an empty list is a value, and does not fall back + services: [], + }); + }); + + it("should let a falsy option override the configuration", () => { + expect( + resolveRunOptions( + { blockTime: 0, prt: false }, + { blockTime: 1, prt: true }, + ), + ).toMatchObject({ blockTime: 0, prt: false }); + }); + + it("should resolve a free port later when none is given", () => { + // zero is not a usable port, and neither is it a request for one + expect(resolveRunOptions({ port: 0 }, { port: 0 }).port).toBe(0); + expect(resolveRunOptions({}, {}).port).toBeUndefined(); + }); + + it("should be verbose when the progress is verbose", () => { + expect(resolveRunOptions({ progress: "verbose" }).verbose).toBe( + true, + ); + expect( + resolveRunOptions({ progress: "verbose" }, { verbose: false }) + .verbose, + ).toBe(false); + expect(resolveRunOptions({}, { verbose: true }).verbose).toBe(true); + }); + }); +}); diff --git a/apps/cli/tests/unit/api/types.test.ts b/apps/cli/tests/unit/api/types.test.ts index d528cb20..21158f39 100644 --- a/apps/cli/tests/unit/api/types.test.ts +++ b/apps/cli/tests/unit/api/types.test.ts @@ -1,26 +1,34 @@ import { describe, expect, it } from "bun:test"; import * as path from "node:path"; import { listrRenderer, resolveConfig } from "../../../src/api/types.js"; -import { defaultConfig } from "../../../src/config.js"; +import { defaultConfig } from "../../../src/config/index.js"; const fixture = (...paths: string[]) => path.join(__dirname, "..", "config", "fixtures", ...paths); describe("api/types", () => { describe("resolveConfig", () => { - it("should default to the default configuration", () => { - // there is no cartesi.toml at the root of the repository - expect(resolveConfig()).toEqual(defaultConfig()); + it("should default to the default configuration", async () => { + // there is no configuration file at the root of the repository + expect(await resolveConfig()).toEqual(defaultConfig()); }); - it("should return a configuration object as is", () => { + it("should normalize a configuration given inline", async () => { + const config = await resolveConfig({ sdk: "my/sdk:1.0.0" }); + expect(config).toEqual({ + ...defaultConfig(), + sdk: "my/sdk:1.0.0", + }); + }); + + it("should accept an already resolved configuration", async () => { const config = defaultConfig(); config.sdk = "my/sdk:1.0.0"; - expect(resolveConfig(config)).toBe(config); + expect(await resolveConfig(config)).toEqual(config); }); - it("should read a configuration file", () => { - const config = resolveConfig(fixture("drives", "rives.toml")); + it("should read a configuration file", async () => { + const config = await resolveConfig(fixture("drives", "rives.toml")); expect(Object.keys(config.drives)).toEqual([ "root", "doom", @@ -29,8 +37,8 @@ describe("api/types", () => { expect(config.withdrawalConfig).toBeUndefined(); }); - it("should merge a list of configuration files", () => { - const config = resolveConfig([ + it("should merge a list of configuration files", async () => { + const config = await resolveConfig([ fixture("drives", "rives.toml"), fixture("withdrawal", "config.toml"), ]); @@ -44,9 +52,9 @@ describe("api/types", () => { ); }); - it("should fail for a configuration file that does not exist", () => { - expect(() => resolveConfig("undefined.toml")).toThrow( - "Config file undefined.toml does not exist", + it("should fail for a configuration file that does not exist", async () => { + expect(resolveConfig("undefined.toml")).rejects.toThrow( + "does not exist", ); }); }); diff --git a/apps/cli/tests/unit/config.test.ts b/apps/cli/tests/unit/config.test.ts index 9da8006f..2e61f9e9 100644 --- a/apps/cli/tests/unit/config.test.ts +++ b/apps/cli/tests/unit/config.test.ts @@ -15,7 +15,7 @@ import { InvalidStringValueError, parse, RequiredFieldError, -} from "../../src/config.js"; +} from "../../src/config/index.js"; const loadDriveConfig = (driveName: string) => { const filePath = path.join( diff --git a/apps/cli/tests/unit/config/fixtures/files/bare.config b/apps/cli/tests/unit/config/fixtures/files/bare.config new file mode 100644 index 00000000..151d74fa --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/files/bare.config @@ -0,0 +1,3 @@ +# a configuration file with no format in its name, read as YAML +machine: + ramLength: 256Mi diff --git a/apps/cli/tests/unit/config/fixtures/files/cartesi.config.js b/apps/cli/tests/unit/config/fixtures/files/cartesi.config.js new file mode 100644 index 00000000..90e7cac3 --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/files/cartesi.config.js @@ -0,0 +1,3 @@ +export default { + machine: { ramLength: "512Mi" }, +}; diff --git a/apps/cli/tests/unit/config/fixtures/files/cartesi.config.json b/apps/cli/tests/unit/config/fixtures/files/cartesi.config.json new file mode 100644 index 00000000..82089bd5 --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/files/cartesi.config.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://cartesi.io/schema/cartesi.config.json", + "machine": { "ramLength": "256Mi" }, + "run": { "epochLength": 10 } +} diff --git a/apps/cli/tests/unit/config/fixtures/files/cartesi.config.ts b/apps/cli/tests/unit/config/fixtures/files/cartesi.config.ts new file mode 100644 index 00000000..c3ff5944 --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/files/cartesi.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "../../../../../src/defineConfig.js"; + +export default defineConfig({ + drives: { + data: { builder: "empty", size: "64Mb" }, + }, + machine: { entrypoint: "dapp", ramLength: "256Mi" }, + run: { epochLength: 10 }, + sdk: "my/sdk:1.0.0", +}); diff --git a/apps/cli/tests/unit/config/fixtures/files/cartesi.config.yaml b/apps/cli/tests/unit/config/fixtures/files/cartesi.config.yaml new file mode 100644 index 00000000..22a6a92a --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/files/cartesi.config.yaml @@ -0,0 +1,10 @@ +machine: + ramLength: 256Mi +drives: + data: + builder: empty + size: 64Mb +run: + epochLength: 10 + services: + - explorer diff --git a/apps/cli/tests/unit/config/fixtures/files/empty.config b/apps/cli/tests/unit/config/fixtures/files/empty.config new file mode 100644 index 00000000..e69de29b diff --git a/apps/cli/tests/unit/config/fixtures/files/function.config.ts b/apps/cli/tests/unit/config/fixtures/files/function.config.ts new file mode 100644 index 00000000..a0ba6f99 --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/files/function.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "../../../../../src/defineConfig.js"; + +export default defineConfig(async ({ command, mode }) => ({ + machine: { entrypoint: `${command}:${mode}` }, + run: { epochLength: command === "run" ? 10 : 720 }, +})); diff --git a/apps/cli/tests/unit/config/fixtures/files/invalid.config.ts b/apps/cli/tests/unit/config/fixtures/files/invalid.config.ts new file mode 100644 index 00000000..ddfdfea9 --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/files/invalid.config.ts @@ -0,0 +1 @@ +export const config = { sdk: "my/sdk:1.0.0" }; diff --git a/apps/cli/tests/unit/config/fixtures/files/override.yaml b/apps/cli/tests/unit/config/fixtures/files/override.yaml new file mode 100644 index 00000000..4b00cb5d --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/files/override.yaml @@ -0,0 +1,4 @@ +machine: + entrypoint: overridden +run: + epochLength: 20 diff --git a/apps/cli/tests/unit/config/fixtures/files/unsupported.ini b/apps/cli/tests/unit/config/fixtures/files/unsupported.ini new file mode 100644 index 00000000..3b8d5eb2 --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/files/unsupported.ini @@ -0,0 +1 @@ +ram_length = 256Mi diff --git a/apps/cli/tests/unit/config/fixtures/project-legacy/cartesi.toml b/apps/cli/tests/unit/config/fixtures/project-legacy/cartesi.toml new file mode 100644 index 00000000..b866db65 --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/project-legacy/cartesi.toml @@ -0,0 +1,2 @@ +[machine] +ram_length = "256Mi" diff --git a/apps/cli/tests/unit/config/fixtures/project-none/.keep b/apps/cli/tests/unit/config/fixtures/project-none/.keep new file mode 100644 index 00000000..e69de29b diff --git a/apps/cli/tests/unit/config/fixtures/project-yaml/cartesi.config.yaml b/apps/cli/tests/unit/config/fixtures/project-yaml/cartesi.config.yaml new file mode 100644 index 00000000..3f01502a --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/project-yaml/cartesi.config.yaml @@ -0,0 +1,4 @@ +machine: + ramLength: 256Mi +run: + epochLength: 10 diff --git a/apps/cli/tests/unit/config/load.test.ts b/apps/cli/tests/unit/config/load.test.ts new file mode 100644 index 00000000..fbadfb7c --- /dev/null +++ b/apps/cli/tests/unit/config/load.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { + CONFIG_FILES, + ConfigFileNotFoundError, + defaultConfig, + findConfigFile, + InvalidConfigExportError, + loadConfig, + loadConfigFile, + UnsupportedConfigFormatError, +} from "../../../src/config/index.js"; + +const fixtures = path.join(__dirname, "fixtures"); +const files = path.join(fixtures, "files"); +const file = (name: string) => path.join(files, name); + +const env = { command: "build", cwd: files, mode: "test" } as const; + +describe("findConfigFile", () => { + it("should find a configuration file by name", () => { + expect(findConfigFile(path.join(fixtures, "project-yaml"))).toBe( + path.join(fixtures, "project-yaml", "cartesi.config.yaml"), + ); + }); + + it("should find the deprecated configuration file", () => { + expect(findConfigFile(path.join(fixtures, "project-legacy"))).toBe( + path.join(fixtures, "project-legacy", "cartesi.toml"), + ); + }); + + it("should return undefined when the project has no configuration file", () => { + expect(findConfigFile(path.join(fixtures, "project-none"))).toBe( + undefined, + ); + }); + + it("should prefer the first of the supported names", () => { + // the fixture directory has a file of every supported format + expect(findConfigFile(files)).toBe(file(CONFIG_FILES[0])); + }); +}); + +describe("loadConfigFile", () => { + it("should load a TypeScript configuration file", async () => { + const config = await loadConfigFile(file("cartesi.config.ts"), env); + expect(config).toEqual({ + drives: { data: { builder: "empty", size: "64Mb" } }, + machine: { entrypoint: "dapp", ramLength: "256Mi" }, + run: { epochLength: 10 }, + sdk: "my/sdk:1.0.0", + }); + }); + + it("should load a JavaScript configuration file", async () => { + const config = await loadConfigFile(file("cartesi.config.js"), env); + expect(config).toEqual({ machine: { ramLength: "512Mi" } }); + }); + + it("should call a configuration file that exports a function", async () => { + const config = await loadConfigFile(file("function.config.ts"), { + command: "run", + cwd: files, + mode: "production", + }); + expect(config).toEqual({ + machine: { entrypoint: "run:production" }, + run: { epochLength: 10 }, + }); + }); + + it("should load a JSON configuration file", async () => { + const config = await loadConfigFile(file("cartesi.config.json"), env); + expect(config).toMatchObject({ + machine: { ramLength: "256Mi" }, + run: { epochLength: 10 }, + }); + }); + + it("should load a YAML configuration file", async () => { + const config = await loadConfigFile(file("cartesi.config.yaml"), env); + expect(config).toEqual({ + drives: { data: { builder: "empty", size: "64Mb" } }, + machine: { ramLength: "256Mi" }, + run: { epochLength: 10, services: ["explorer"] }, + }); + }); + + it("should read a file with no format in its name as YAML", async () => { + expect(await loadConfigFile(file("bare.config"), env)).toEqual({ + machine: { ramLength: "256Mi" }, + }); + }); + + it("should load an empty configuration file", async () => { + expect(await loadConfigFile(file("empty.config"), env)).toEqual({}); + }); + + it("should fail for a module without a default export", async () => { + expect(loadConfigFile(file("invalid.config.ts"), env)).rejects.toThrow( + new InvalidConfigExportError(file("invalid.config.ts")), + ); + }); + + it("should fail for an unsupported format", async () => { + expect(loadConfigFile(file("unsupported.ini"), env)).rejects.toThrow( + new UnsupportedConfigFormatError(file("unsupported.ini")), + ); + }); +}); + +describe("loadConfig", () => { + it("should use the defaults when the project has no configuration file", async () => { + const config = await loadConfig({ + cwd: path.join(fixtures, "project-none"), + }); + expect(config).toEqual(defaultConfig()); + }); + + it("should find and resolve the configuration file of the project", async () => { + const config = await loadConfig({ + cwd: path.join(fixtures, "project-yaml"), + }); + expect(config.machine.ramLength).toBe("256Mi"); + expect(config.run.epochLength).toBe(10); + }); + + it("should still read the deprecated configuration file", async () => { + const config = await loadConfig({ + cwd: path.join(fixtures, "project-legacy"), + }); + expect(config.machine.ramLength).toBe("256Mi"); + }); + + it("should resolve and validate an explicit configuration file", async () => { + const config = await loadConfig({ + cwd: files, + files: ["cartesi.config.ts"], + }); + expect(config.sdk).toBe("my/sdk:1.0.0"); + expect(config.machine.ramLength).toBe("256Mi"); + expect(config.run.epochLength).toBe(10); + // sizes written as strings are resolved to a number of bytes + expect(config.drives.data).toMatchObject({ size: 64 * 1024 * 1024 }); + // a root drive is always present + expect(config.drives.root).toMatchObject({ builder: "docker" }); + }); + + it("should merge a list of configuration files, in order", async () => { + const config = await loadConfig({ + cwd: files, + files: ["cartesi.config.ts", "override.yaml"], + }); + expect(config.machine.entrypoint).toBe("overridden"); + // untouched by the override + expect(config.machine.ramLength).toBe("256Mi"); + expect(config.run.epochLength).toBe(20); + }); + + it("should give the command and the mode to a configuration file", async () => { + const config = await loadConfig({ + command: "run", + cwd: files, + files: ["function.config.ts"], + mode: "production", + }); + expect(config.machine.entrypoint).toBe("run:production"); + }); + + it("should fail for a configuration file that does not exist", async () => { + expect( + loadConfig({ cwd: files, files: ["missing.config.ts"] }), + ).rejects.toThrow( + new ConfigFileNotFoundError(file("missing.config.ts")), + ); + }); +}); diff --git a/apps/cli/tests/unit/config/normalize.test.ts b/apps/cli/tests/unit/config/normalize.test.ts new file mode 100644 index 00000000..5c716def --- /dev/null +++ b/apps/cli/tests/unit/config/normalize.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it } from "bun:test"; +import { + defaultConfig, + defaultMachineConfig, + defaultRootDriveConfig, + defineConfig, + type DockerDriveConfig, + InvalidAddressValueError, + InvalidBooleanValueError, + InvalidBuilderError, + InvalidBytesValueError, + InvalidDriveFormatError, + InvalidEnumValueError, + InvalidNumberValueError, + InvalidSectionError, + InvalidStringValueError, + normalizeConfig, + RequiredFieldError, + type WithdrawalConfig, +} from "../../../src/config/index.js"; + +describe("defineConfig", () => { + it("should return the configuration unchanged", () => { + const config = { sdk: "my/sdk:1.0.0" }; + expect(defineConfig(config)).toBe(config); + }); + + it("should return a configuration function unchanged", () => { + const fn = () => ({ sdk: "my/sdk:1.0.0" }); + expect(defineConfig(fn)).toBe(fn); + }); +}); + +// the default root drive is built by docker, narrowed so the expectations +// below can spread it and still be checked against that member of the union +const defaultRootDrive = () => defaultRootDriveConfig() as DockerDriveConfig; + +describe("normalizeConfig", () => { + it("should apply the defaults to an empty configuration", () => { + expect(normalizeConfig(undefined)).toEqual(defaultConfig()); + expect(normalizeConfig({})).toEqual(defaultConfig()); + }); + + it("should be idempotent on an already resolved configuration", () => { + const config = normalizeConfig({ + drives: { data: { builder: "empty", size: "64Mb" } }, + machine: { entrypoint: "dapp", ramLength: "256Mi" }, + run: { epochLength: 10 }, + sdk: "my/sdk:1.0.0", + }); + expect(normalizeConfig(config)).toEqual(config); + }); + + it("should ignore the '$schema' key of a JSON configuration", () => { + expect( + normalizeConfig({ $schema: "https://cartesi.io/schema.json" }), + ).toEqual(defaultConfig()); + }); + + it("should fail for a configuration that is not an object", () => { + expect(() => normalizeConfig(42)).toThrowError( + new InvalidSectionError("config", 42), + ); + }); + + describe("drives", () => { + it("should add a default root drive", () => { + const config = normalizeConfig({ + drives: { data: { builder: "empty", size: 128 } }, + }); + expect(config.drives.root).toEqual(defaultRootDrive()); + expect(config.drives.data).toEqual({ + builder: "empty", + format: "ext2", + mount: undefined, + shared: undefined, + size: 128, + user: undefined, + }); + }); + + it("should default the builder to docker", () => { + const config = normalizeConfig({ + drives: { root: { dockerfile: "backend/Dockerfile" } }, + }); + expect(config.drives.root).toEqual({ + ...defaultRootDrive(), + dockerfile: "backend/Dockerfile", + }); + }); + + it("should parse human readable sizes", () => { + const config = normalizeConfig({ + drives: { + data: { builder: "directory", directory: "data" }, + root: { extraSize: "10Mb" }, + }, + }); + expect(config.drives.root).toEqual({ + ...defaultRootDrive(), + extraSize: 10 * 1024 * 1024, + }); + expect(config.drives.data).toMatchObject({ extraSize: 0 }); + }); + + it("should infer the format of an existing drive from its filename", () => { + const config = normalizeConfig({ + drives: { + doom: { builder: "none", filename: "doom.sqfs" }, + }, + }); + expect(config.drives.doom).toMatchObject({ format: "sqfs" }); + }); + + it("should keep the drive options common to every builder", () => { + const config = normalizeConfig({ + drives: { + data: { + builder: "tar", + filename: "data.tar", + mount: "/mnt/data", + shared: true, + user: "dapp", + }, + }, + }); + expect(config.drives.data).toEqual({ + builder: "tar", + extraSize: 0, + filename: "data.tar", + format: "ext2", + mount: "/mnt/data", + shared: true, + user: "dapp", + }); + }); + + it("should fail for an invalid builder", () => { + expect(() => + normalizeConfig({ drives: { root: { builder: "invalid" } } }), + ).toThrowError(new InvalidBuilderError("invalid")); + }); + + it("should fail for an invalid format", () => { + expect(() => + normalizeConfig({ drives: { root: { format: "invalid" } } }), + ).toThrowError(new InvalidDriveFormatError("invalid")); + }); + + it("should fail for an invalid size", () => { + expect(() => + normalizeConfig({ + drives: { root: { extraSize: "abc" } }, + }), + ).toThrowError(new InvalidBytesValueError("abc")); + }); + + it("should fail when a required field is missing", () => { + expect(() => + normalizeConfig({ drives: { data: { builder: "directory" } } }), + ).toThrowError(new RequiredFieldError("directory")); + expect(() => + normalizeConfig({ drives: { data: { builder: "tar" } } }), + ).toThrowError(new RequiredFieldError("filename")); + }); + + it("should fail when drives is not an object", () => { + expect(() => normalizeConfig({ drives: 42 })).toThrowError( + new InvalidSectionError("drives", 42), + ); + }); + }); + + describe("machine", () => { + it("should apply the machine defaults", () => { + expect(normalizeConfig({ machine: {} }).machine).toEqual( + defaultMachineConfig(), + ); + }); + + it("should coerce non-string environment variables", () => { + expect( + normalizeConfig({ + machine: { env: { ENABLED: true, PORT: 8080 } }, + }).machine.env, + ).toEqual({ ENABLED: "true", PORT: "8080" }); + }); + + it("should accept a number as the maximum cycle count", () => { + expect( + normalizeConfig({ machine: { maxMCycle: 100 } }).machine + .maxMCycle, + ).toBe(100n); + }); + + it("should fail for an invalid boolean", () => { + expect(() => + normalizeConfig({ machine: { useDockerEnv: 42 } }), + ).toThrowError(new InvalidBooleanValueError(42)); + }); + + it("should fail for an invalid entrypoint", () => { + expect(() => + normalizeConfig({ machine: { entrypoint: 42 } }), + ).toThrowError(new InvalidStringValueError(42)); + }); + }); + + describe("run", () => { + it("should be empty by default", () => { + expect(normalizeConfig({}).run).toEqual({}); + }); + + it("should keep the run options", () => { + expect( + normalizeConfig({ + run: { + blockTime: 1, + defaultBlock: "finalized", + epochLength: 10, + projectName: "my-app", + prt: true, + services: ["explorer", "bundler"], + }, + }).run, + ).toEqual({ + blockTime: 1, + claimStagingPeriod: undefined, + cpus: undefined, + defaultBlock: "finalized", + epochLength: 10, + forkBlockNumber: undefined, + forkUrl: undefined, + memory: undefined, + port: undefined, + projectName: "my-app", + prt: true, + runtimeVersion: undefined, + services: ["explorer", "bundler"], + verbose: undefined, + }); + }); + + it("should fail for an invalid default block", () => { + expect(() => + normalizeConfig({ run: { defaultBlock: "invalid" } }), + ).toThrowError( + new InvalidEnumValueError("defaultBlock", "invalid", [ + "latest", + "safe", + "pending", + "finalized", + ]), + ); + }); + + it("should fail for an invalid number", () => { + expect(() => + normalizeConfig({ run: { epochLength: "ten" } }), + ).toThrowError(new InvalidNumberValueError("ten", "epochLength")); + }); + + it("should fail for invalid services", () => { + expect(() => + normalizeConfig({ run: { services: [42] } }), + ).toThrowError(new InvalidStringValueError(42)); + }); + }); + + describe("withdrawal", () => { + const withdrawal: WithdrawalConfig = { + guardian: "0x1111111111111111111111111111111111111111", + log2_leaves_per_account: 0, + log2_max_num_of_accounts: 20, + accounts_drive_start_index: 33554432, + withdrawal_output_builder: + "0x2222222222222222222222222222222222222222", + }; + + it("should parse a valid withdrawal configuration", () => { + expect(normalizeConfig({ withdrawal }).withdrawalConfig).toEqual( + withdrawal, + ); + }); + + it("should accept the resolved 'withdrawalConfig' name, so a resolved configuration can be given back", () => { + expect( + normalizeConfig({ withdrawalConfig: withdrawal }) + .withdrawalConfig, + ).toEqual(withdrawal); + }); + + it("should be undefined when not given or empty", () => { + expect(normalizeConfig({}).withdrawalConfig).toBeUndefined(); + expect( + normalizeConfig({ withdrawal: {} }).withdrawalConfig, + ).toBeUndefined(); + }); + + it("should fail when a field is missing", () => { + expect(() => + normalizeConfig({ + withdrawal: { + ...withdrawal, + guardian: undefined, + }, + }), + ).toThrowError(new RequiredFieldError("guardian")); + }); + + it("should fail when an address is invalid", () => { + expect(() => + normalizeConfig({ + withdrawal: { ...withdrawal, guardian: "invalid" }, + }), + ).toThrowError(new InvalidAddressValueError("invalid", "guardian")); + }); + }); +}); diff --git a/apps/cli/tests/unit/config/size.test.ts b/apps/cli/tests/unit/config/size.test.ts new file mode 100644 index 00000000..76cb3562 --- /dev/null +++ b/apps/cli/tests/unit/config/size.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "bun:test"; +import { + InvalidBytesValueError, + parseSize, +} from "../../../src/config/index.js"; + +describe("parseSize", () => { + it("should take a number of bytes as is", () => { + expect(parseSize(0)).toBe(0); + expect(parseSize(128)).toBe(128); + expect(parseSize(128n)).toBe(128); + expect(parseSize("128")).toBe(128); + }); + + it("should parse the decimal looking units as binary multiples", () => { + expect(parseSize("1kb")).toBe(1024); + expect(parseSize("128MB")).toBe(128 * 1024 ** 2); + expect(parseSize("2Gb")).toBe(2 * 1024 ** 3); + }); + + it("should parse the IEC units", () => { + // 'bytes.parse' reads these as a plain number of bytes, which silently + // makes a drive several orders of magnitude too small + expect(parseSize("64Mi")).toBe(64 * 1024 ** 2); + expect(parseSize("64MiB")).toBe(64 * 1024 ** 2); + expect(parseSize("1Ki")).toBe(1024); + }); + + it("should parse a unit with no prefix", () => { + expect(parseSize("512b")).toBe(512); + expect(parseSize("4M")).toBe(4 * 1024 ** 2); + }); + + it("should accept fractions and spacing", () => { + expect(parseSize("1.5Mb")).toBe(Math.floor(1.5 * 1024 ** 2)); + expect(parseSize(" 10 Mb ")).toBe(10 * 1024 ** 2); + }); + + it("should reject anything it does not understand", () => { + expect(() => parseSize("abc")).toThrowError( + new InvalidBytesValueError("abc"), + ); + expect(() => parseSize("64Xy")).toThrowError( + new InvalidBytesValueError("64Xy"), + ); + expect(() => parseSize("")).toThrowError( + new InvalidBytesValueError(""), + ); + expect(() => parseSize(undefined)).toThrowError( + new InvalidBytesValueError(undefined), + ); + expect(() => parseSize({})).toThrowError( + new InvalidBytesValueError({}), + ); + }); +}); diff --git a/bun.lock b/bun.lock index ce41d597..7b1d0e9e 100644 --- a/bun.lock +++ b/bun.lock @@ -24,7 +24,6 @@ "@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", @@ -32,6 +31,7 @@ "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", @@ -48,7 +48,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", @@ -363,8 +362,6 @@ "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], - "@types/bytes": ["@types/bytes@3.1.5", "", {}, "sha512-VgZkrJckypj85YxEsEavcMmmSOIzkUHqWmM4CCyia5dc54YwsXzJ5uT4fYxBQNEXx+oF1krlhgCbvfubXqZYsQ=="], - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/fs-extra": ["@types/fs-extra@11.0.4", "", { "dependencies": { "@types/jsonfile": "*", "@types/node": "*" } }, "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ=="], @@ -443,8 +440,6 @@ "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], @@ -745,6 +740,8 @@ "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],