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=="],