From 98967f413733762cff85387b38a5726b16eff64f Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Wed, 28 Oct 2026 14:12:56 +0000 Subject: [PATCH 1/5] refactor(cli): move the toml helpers, errors and constants out of config.ts Drops mergeTomlValues, which nothing used. --- apps/cli/src/config.ts | 297 +++++-------------------------- apps/cli/src/config/constants.ts | 8 + apps/cli/src/config/errors.ts | 167 +++++++++++++++++ apps/cli/src/config/toml.ts | 57 ++++++ 4 files changed, 276 insertions(+), 253 deletions(-) create mode 100644 apps/cli/src/config/constants.ts create mode 100644 apps/cli/src/config/errors.ts create mode 100644 apps/cli/src/config/toml.ts diff --git a/apps/cli/src/config.ts b/apps/cli/src/config.ts index af94c240..899d0ecf 100644 --- a/apps/cli/src/config.ts +++ b/apps/cli/src/config.ts @@ -1,188 +1,54 @@ import { extname } from "node:path"; import { parse as parseToml, type TomlPrimitive } from "smol-toml"; import { getAddress, isAddress, type Address } from "viem"; - -export const NVRAM_ALIGNMENT = 4096; // cartesi-machine requires length % 4Ki == 0 -const MAX_NVRAMS = 8; // guest exposes nvrams as /dev/uio0 to /dev/uio7 - -/** - * Typed Errors - */ - -// smol-toml creates tables with a null prototype, which can't be coerced to a -// string by template literals, so format objects (tables, arrays) as JSON. -const formatValue = (value: TomlPrimitive): string => - typeof value === "object" && value !== null && !(value instanceof Date) - ? JSON.stringify(value, (_, v) => - typeof v === "bigint" ? v.toString() : v, - ) - : String(value); - -export class InvalidBuilderError extends Error { - constructor(builder: TomlPrimitive) { - super(`Invalid builder: ${formatValue(builder)}`); - this.name = "InvalidBuilder"; - } -} - -export class InvalidDriveFormatError extends Error { - constructor(format: TomlPrimitive) { - super(`Invalid drive format: ${formatValue(format)}`); - this.name = "InvalidDriveFormatError"; - } -} - -export class InvalidEmptyDriveFormatError extends Error { - constructor(format: TomlPrimitive) { - super(`Invalid empty drive format: ${formatValue(format)}`); - this.name = "InvalidEmptyDriveFormatError"; - } -} - -export class InvalidStringValueError extends Error { - constructor(value: TomlPrimitive) { - super(`Invalid string value: ${formatValue(value)}`); - this.name = "InvalidStringValueError"; - } -} - -export class InvalidBooleanValueError extends Error { - constructor(value: TomlPrimitive) { - super(`Invalid boolean value: ${formatValue(value)}`); - this.name = "InvalidBooleanValueError"; - } -} - -export class InvalidNumberValueError extends Error { - constructor(value: TomlPrimitive, key?: string) { - super( - `Invalid number value: ${formatValue(value)}${key ? ` for key: ${key}` : ""}`, - ); - this.name = "InvalidNumberValueError"; - } -} - -export class InvalidAddressValueError extends Error { - constructor(value: TomlPrimitive, key?: string) { - super( - `Invalid address value: ${formatValue(value)}${key ? ` for key: ${key}` : ""}`, - ); - this.name = "InvalidAddressValueError"; - } -} - -export class InvalidBytesValueError extends Error { - constructor(value: TomlPrimitive) { - super(`Invalid bytes value: ${formatValue(value)}`); - this.name = "InvalidBytesValueError"; - } -} - -export class RequiredFieldError extends Error { - constructor(key: TomlPrimitive) { - super(`Missing required field: ${formatValue(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: ${formatValue(value)}`); - this.name = "InvalidEnvError"; - } -} - -export class MissingNvramSourceError extends Error { - constructor(label: string) { - super(`Nvram '${label}' must define either 'size' or 'filename'`); - this.name = "MissingNvramSourceError"; - } -} - -export class InvalidNvramSizeError extends Error { - constructor(label: string, size: number) { - super( - `Invalid size ${size} for nvram '${label}': must be a positive multiple of ${NVRAM_ALIGNMENT}`, - ); - this.name = "InvalidNvramSizeError"; - } -} - -export class TooManyNvramsError extends Error { - constructor(count: number) { - super(`Too many nvrams: ${count}, maximum is ${MAX_NVRAMS}`); - this.name = "TooManyNvramsError"; - } -} - -export class DuplicateLabelError extends Error { - constructor(label: string) { - super(`Label '${label}' is used by both a drive and an nvram`); - this.name = "DuplicateLabelError"; - } -} - -export class MultipleAccountsDrivesError extends Error { - constructor(labels: string[]) { - super( - `Only one drive or nvram can be the accounts drive, but ${labels.map((label) => `'${label}'`).join(", ")} are marked with accounts_drive = true`, - ); - this.name = "MultipleAccountsDrivesError"; - } -} - -export class InvalidAccountsDriveError extends Error { - constructor(label: string, reason: string) { - super(`Invalid accounts drive '${label}': ${reason}`); - this.name = "InvalidAccountsDriveError"; - } -} - -export class InvalidWithdrawalConfigError extends Error { - constructor(reason: string) { - super(`Invalid [withdrawal]: ${reason}`); - this.name = "InvalidWithdrawalConfigError"; - } -} - -export class UnknownConfigKeyError extends Error { - /** - * @param where where the keys are, e.g. "in [machine]" or "at the top level" - */ - constructor(keys: string[], known: readonly string[], where: string) { - const list = keys.map((key) => `'${key}'`).join(", "); - super( - `Unknown ${keys.length > 1 ? "keys" : "key"} ${list} ${where}, expected one of: ${[...known].sort().join(", ")}`, - ); - this.name = "UnknownConfigKeyError"; - } -} - -export class InvalidTableError extends Error { - constructor(location: string, value: TomlPrimitive) { - super( - `Invalid ${location}: expected a table, got ${formatValue(value)}`, - ); - this.name = "InvalidTableError"; - } -} +import { + DEFAULT_FORMAT, + DEFAULT_RAM, + DEFAULT_SDK_IMAGE, + DEFAULT_SDK_VERSION, + MAX_NVRAMS, + NVRAM_ALIGNMENT, +} from "./config/constants.js"; +import { + DuplicateLabelError, + InvalidAccountsDriveError, + InvalidAddressValueError, + InvalidBooleanValueError, + InvalidBuilderError, + InvalidBytesValueError, + InvalidDriveFormatError, + InvalidEmptyDriveFormatError, + InvalidEnvError, + InvalidNumberValueError, + InvalidNvramSizeError, + InvalidStringArrayError, + InvalidStringValueError, + InvalidTableError, + InvalidWithdrawalConfigError, + MissingNvramSourceError, + MultipleAccountsDrivesError, + RequiredFieldError, + TooManyNvramsError, + formatValue, +} from "./config/errors.js"; +import { + assertKnownKeys, + isTomlTable, + mergeTomlTables, + type TomlTable, +} from "./config/toml.js"; + +export { + DEFAULT_SDK_IMAGE, + DEFAULT_SDK_VERSION, + NVRAM_ALIGNMENT, + PREFERRED_PORT, +} from "./config/constants.js"; +export * from "./config/errors.js"; /** * 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.44"; -export const DEFAULT_SDK_IMAGE = "cartesi/sdk"; -export const PREFERRED_PORT = 6751; - type Builder = "directory" | "docker" | "empty" | "none" | "tar"; type DriveFormat = "ext2" | "sqfs"; @@ -441,23 +307,6 @@ export const assertAccountsDriveLength = ( return { length, log2Length }; }; -type TomlTable = { [key: string]: TomlPrimitive }; - -/** - * Fails on any key of a table that its parser doesn't read, naming the keys the table accepts. - * @param where where the table is, e.g. "in [machine]" or "at the top level" - */ -const assertKnownKeys = ( - table: TomlTable, - known: readonly string[], - where: string, -): void => { - const unknown = Object.keys(table).filter((key) => !known.includes(key)); - if (unknown.length > 0) { - throw new UnknownConfigKeyError(unknown, known, where); - } -}; - export const defaultRootDriveConfig = (): DriveConfig => ({ builder: "docker", buildArgs: [], @@ -1175,61 +1024,3 @@ export const parse = (str: string[]): Config => { return config; }; - -/** - * Checks if a value is a plain object (TOML table) - */ -function isTomlTable(value: TomlPrimitive): value is TomlTable { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - !("toISOString" in value) - ); // Check for TomlDate (has toISOString method) -} - -/** - * Recursively merges two TOML table objects - * Values from 'other' take precedence over 'base' - * - * @param base - The base TOML table - * @param other - The TOML table to merge into base (takes precedence) - * @returns A new merged TOML table - */ -export function mergeTomlTables(base: TomlTable, other: TomlTable): TomlTable { - const result: TomlTable = { ...base }; - - for (const [key, otherValue] of Object.entries(other)) { - const baseValue = result[key]; - - // If both values are tables, merge them recursively - if (isTomlTable(baseValue) && isTomlTable(otherValue)) { - result[key] = mergeTomlTables(baseValue, otherValue); - } else { - // For all other cases, other value takes precedence - result[key] = otherValue; - } - } - - return result; -} - -/** - * Merges two TOML values of any type - * - * @param base - The base TOML value - * @param other - The TOML value to merge into base (takes precedence) - * @returns The merged TOML value - */ -export function mergeTomlValues( - base: TomlPrimitive, - other: TomlPrimitive, -): TomlPrimitive { - // If both are tables, merge recursively - if (isTomlTable(base) && isTomlTable(other)) { - return mergeTomlTables(base, other); - } - - // For arrays, replaces entirely - return other; -} diff --git a/apps/cli/src/config/constants.ts b/apps/cli/src/config/constants.ts new file mode 100644 index 00000000..97b879f1 --- /dev/null +++ b/apps/cli/src/config/constants.ts @@ -0,0 +1,8 @@ +export const NVRAM_ALIGNMENT = 4096; // cartesi-machine requires length % 4Ki == 0 +export const MAX_NVRAMS = 8; // guest exposes nvrams as /dev/uio0 to /dev/uio7 + +export const DEFAULT_FORMAT = "ext2"; +export const DEFAULT_RAM = "128Mi"; +export const DEFAULT_SDK_VERSION = "0.12.0-alpha.44"; +export const DEFAULT_SDK_IMAGE = "cartesi/sdk"; +export const PREFERRED_PORT = 6751; diff --git a/apps/cli/src/config/errors.ts b/apps/cli/src/config/errors.ts new file mode 100644 index 00000000..623e92aa --- /dev/null +++ b/apps/cli/src/config/errors.ts @@ -0,0 +1,167 @@ +import type { TomlPrimitive } from "smol-toml"; +import { MAX_NVRAMS, NVRAM_ALIGNMENT } from "./constants.js"; + +// smol-toml creates tables with a null prototype, which can't be coerced to a +// string by template literals, so format objects (tables, arrays) as JSON. +export const formatValue = (value: TomlPrimitive): string => + typeof value === "object" && value !== null && !(value instanceof Date) + ? JSON.stringify(value, (_, v) => + typeof v === "bigint" ? v.toString() : v, + ) + : String(value); + +export class InvalidBuilderError extends Error { + constructor(builder: TomlPrimitive) { + super(`Invalid builder: ${formatValue(builder)}`); + this.name = "InvalidBuilder"; + } +} + +export class InvalidDriveFormatError extends Error { + constructor(format: TomlPrimitive) { + super(`Invalid drive format: ${formatValue(format)}`); + this.name = "InvalidDriveFormatError"; + } +} + +export class InvalidEmptyDriveFormatError extends Error { + constructor(format: TomlPrimitive) { + super(`Invalid empty drive format: ${formatValue(format)}`); + this.name = "InvalidEmptyDriveFormatError"; + } +} + +export class InvalidStringValueError extends Error { + constructor(value: TomlPrimitive) { + super(`Invalid string value: ${formatValue(value)}`); + this.name = "InvalidStringValueError"; + } +} + +export class InvalidBooleanValueError extends Error { + constructor(value: TomlPrimitive) { + super(`Invalid boolean value: ${formatValue(value)}`); + this.name = "InvalidBooleanValueError"; + } +} + +export class InvalidNumberValueError extends Error { + constructor(value: TomlPrimitive, key?: string) { + super( + `Invalid number value: ${formatValue(value)}${key ? ` for key: ${key}` : ""}`, + ); + this.name = "InvalidNumberValueError"; + } +} + +export class InvalidAddressValueError extends Error { + constructor(value: TomlPrimitive, key?: string) { + super( + `Invalid address value: ${formatValue(value)}${key ? ` for key: ${key}` : ""}`, + ); + this.name = "InvalidAddressValueError"; + } +} + +export class InvalidBytesValueError extends Error { + constructor(value: TomlPrimitive) { + super(`Invalid bytes value: ${formatValue(value)}`); + this.name = "InvalidBytesValueError"; + } +} + +export class RequiredFieldError extends Error { + constructor(key: TomlPrimitive) { + super(`Missing required field: ${formatValue(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: ${formatValue(value)}`); + this.name = "InvalidEnvError"; + } +} + +export class MissingNvramSourceError extends Error { + constructor(label: string) { + super(`Nvram '${label}' must define either 'size' or 'filename'`); + this.name = "MissingNvramSourceError"; + } +} + +export class InvalidNvramSizeError extends Error { + constructor(label: string, size: number) { + super( + `Invalid size ${size} for nvram '${label}': must be a positive multiple of ${NVRAM_ALIGNMENT}`, + ); + this.name = "InvalidNvramSizeError"; + } +} + +export class TooManyNvramsError extends Error { + constructor(count: number) { + super(`Too many nvrams: ${count}, maximum is ${MAX_NVRAMS}`); + this.name = "TooManyNvramsError"; + } +} + +export class DuplicateLabelError extends Error { + constructor(label: string) { + super(`Label '${label}' is used by both a drive and an nvram`); + this.name = "DuplicateLabelError"; + } +} + +export class MultipleAccountsDrivesError extends Error { + constructor(labels: string[]) { + super( + `Only one drive or nvram can be the accounts drive, but ${labels.map((label) => `'${label}'`).join(", ")} are marked with accounts_drive = true`, + ); + this.name = "MultipleAccountsDrivesError"; + } +} + +export class InvalidAccountsDriveError extends Error { + constructor(label: string, reason: string) { + super(`Invalid accounts drive '${label}': ${reason}`); + this.name = "InvalidAccountsDriveError"; + } +} + +export class InvalidWithdrawalConfigError extends Error { + constructor(reason: string) { + super(`Invalid [withdrawal]: ${reason}`); + this.name = "InvalidWithdrawalConfigError"; + } +} + +export class UnknownConfigKeyError extends Error { + /** + * @param where where the keys are, e.g. "in [machine]" or "at the top level" + */ + constructor(keys: string[], known: readonly string[], where: string) { + const list = keys.map((key) => `'${key}'`).join(", "); + super( + `Unknown ${keys.length > 1 ? "keys" : "key"} ${list} ${where}, expected one of: ${[...known].sort().join(", ")}`, + ); + this.name = "UnknownConfigKeyError"; + } +} + +export class InvalidTableError extends Error { + constructor(location: string, value: TomlPrimitive) { + super( + `Invalid ${location}: expected a table, got ${formatValue(value)}`, + ); + this.name = "InvalidTableError"; + } +} diff --git a/apps/cli/src/config/toml.ts b/apps/cli/src/config/toml.ts new file mode 100644 index 00000000..65aeda45 --- /dev/null +++ b/apps/cli/src/config/toml.ts @@ -0,0 +1,57 @@ +import type { TomlPrimitive } from "smol-toml"; +import { UnknownConfigKeyError } from "./errors.js"; + +export type TomlTable = { [key: string]: TomlPrimitive }; + +/** + * Fails on any key of a table that its parser doesn't read, naming the keys the table accepts. + * @param where where the table is, e.g. "in [machine]" or "at the top level" + */ +export const assertKnownKeys = ( + table: TomlTable, + known: readonly string[], + where: string, +): void => { + const unknown = Object.keys(table).filter((key) => !known.includes(key)); + if (unknown.length > 0) { + throw new UnknownConfigKeyError(unknown, known, where); + } +}; + +/** + * Checks if a value is a plain object (TOML table) + */ +export function isTomlTable(value: TomlPrimitive): value is TomlTable { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + !("toISOString" in value) + ); // Check for TomlDate (has toISOString method) +} + +/** + * Recursively merges two TOML table objects + * Values from 'other' take precedence over 'base' + * + * @param base - The base TOML table + * @param other - The TOML table to merge into base (takes precedence) + * @returns A new merged TOML table + */ +export function mergeTomlTables(base: TomlTable, other: TomlTable): TomlTable { + const result: TomlTable = { ...base }; + + for (const [key, otherValue] of Object.entries(other)) { + const baseValue = result[key]; + + // If both values are tables, merge them recursively + if (isTomlTable(baseValue) && isTomlTable(otherValue)) { + result[key] = mergeTomlTables(baseValue, otherValue); + } else { + // For all other cases, other value takes precedence + result[key] = otherValue; + } + } + + return result; +} From e49fabccbe4c2bd05f2198ada3b91e10030ce90f Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Wed, 28 Oct 2026 17:09:36 +0000 Subject: [PATCH 2/5] refactor(cli): move the nvram image helpers to the nvram builder --- apps/cli/src/builder/nvram.ts | 15 +++++++++++++-- apps/cli/src/commands/shell.ts | 2 +- apps/cli/src/config.ts | 13 ------------- apps/cli/src/machine.ts | 10 ++-------- 4 files changed, 16 insertions(+), 24 deletions(-) diff --git a/apps/cli/src/builder/nvram.ts b/apps/cli/src/builder/nvram.ts index 5ef5e8b7..6676cbc7 100644 --- a/apps/cli/src/builder/nvram.ts +++ b/apps/cli/src/builder/nvram.ts @@ -4,10 +4,21 @@ import { MissingNvramSourceError, NVRAM_ALIGNMENT, type NvramConfig, - nvramHasImage, - nvramImageFilename, } from "../config.js"; +/** + * Filename, relative to the build destination directory, of the image backing an nvram. + */ +export const nvramImageFilename = (label: string): string => `${label}.raw`; + +/** + * Whether an nvram is backed by an image file. A pristine nvram needs no image, as + * cartesi-machine fills its range with zeros. A `shared` one does, as there must be a file for + * the guest writes to be persisted to. + */ +export const nvramHasImage = (nvram: NvramConfig): boolean => + nvram.filename !== undefined || nvram.shared === true; + export const build = async ( label: string, nvram: NvramConfig, diff --git a/apps/cli/src/commands/shell.ts b/apps/cli/src/commands/shell.ts index fb071a86..2a022938 100755 --- a/apps/cli/src/commands/shell.ts +++ b/apps/cli/src/commands/shell.ts @@ -3,7 +3,7 @@ import { ExecaError } from "execa"; import fs from "fs-extra"; import path from "node:path"; import { getApplicationConfig, getContextPath } from "../base.js"; -import { nvramHasImage, nvramImageFilename } from "../config.js"; +import { nvramHasImage, nvramImageFilename } from "../builder/nvram.js"; import { bootMachine } from "../machine.js"; export const createShellCommand = () => { diff --git a/apps/cli/src/config.ts b/apps/cli/src/config.ts index 899d0ecf..8db85e67 100644 --- a/apps/cli/src/config.ts +++ b/apps/cli/src/config.ts @@ -603,19 +603,6 @@ const parseNvrams = (config: TomlPrimitive): Record => { ); }; -/** - * Filename, relative to the build destination directory, of the image backing an nvram. - */ -export const nvramImageFilename = (label: string): string => `${label}.raw`; - -/** - * Whether an nvram is backed by an image file. A pristine nvram needs no image, as - * cartesi-machine fills its range with zeros. A `shared` one does, as there must be a file for - * the guest writes to be persisted to. - */ -export const nvramHasImage = (nvram: NvramConfig): boolean => - nvram.filename !== undefined || nvram.shared === true; - const parseBuilder = (value: TomlPrimitive): Builder => { if (value === undefined) { return "docker"; diff --git a/apps/cli/src/machine.ts b/apps/cli/src/machine.ts index b45ff54b..9a96043b 100644 --- a/apps/cli/src/machine.ts +++ b/apps/cli/src/machine.ts @@ -1,13 +1,7 @@ import dotenv from "dotenv"; import fs from "node:fs"; -import { - type Config, - type DriveConfig, - type ImageInfo, - type NvramConfig, - nvramHasImage, - nvramImageFilename, -} from "./config.js"; +import { nvramHasImage, nvramImageFilename } from "./builder/nvram.js"; +import type { Config, DriveConfig, ImageInfo, NvramConfig } from "./config.js"; import { cartesiMachine } from "./exec/index.js"; import type { ExecaOptionsDockerFallback } from "./exec/util.js"; From 63f44fd4a359cc56442731b87877f5be1ccfad99 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Wed, 28 Oct 2026 17:10:12 +0000 Subject: [PATCH 3/5] refactor(cli): move the config types, sizes and accounts drive out of config.ts --- apps/cli/src/config.ts | 347 +++----------------------------- apps/cli/src/config/accounts.ts | 174 ++++++++++++++++ apps/cli/src/config/size.ts | 24 +++ apps/cli/src/config/types.ts | 133 ++++++++++++ 4 files changed, 356 insertions(+), 322 deletions(-) create mode 100644 apps/cli/src/config/accounts.ts create mode 100644 apps/cli/src/config/size.ts create mode 100644 apps/cli/src/config/types.ts diff --git a/apps/cli/src/config.ts b/apps/cli/src/config.ts index 8db85e67..f921ad00 100644 --- a/apps/cli/src/config.ts +++ b/apps/cli/src/config.ts @@ -1,6 +1,6 @@ import { extname } from "node:path"; import { parse as parseToml, type TomlPrimitive } from "smol-toml"; -import { getAddress, isAddress, type Address } from "viem"; +import { type Address, getAddress, isAddress } from "viem"; import { DEFAULT_FORMAT, DEFAULT_RAM, @@ -9,9 +9,13 @@ import { MAX_NVRAMS, NVRAM_ALIGNMENT, } from "./config/constants.js"; +import { + assertAccountsDrive, + LOG2_LEAF_SIZE, + log2OfPowerOfTwo, +} from "./config/accounts.js"; import { DuplicateLabelError, - InvalidAccountsDriveError, InvalidAddressValueError, InvalidBooleanValueError, InvalidBuilderError, @@ -26,7 +30,6 @@ import { InvalidTableError, InvalidWithdrawalConfigError, MissingNvramSourceError, - MultipleAccountsDrivesError, RequiredFieldError, TooManyNvramsError, formatValue, @@ -37,6 +40,16 @@ import { mergeTomlTables, type TomlTable, } from "./config/toml.js"; +import type { + AccountsDriveMarker, + Builder, + Config, + DriveConfig, + DriveFormat, + MachineConfig, + NvramConfig, + WithdrawalOptions, +} from "./config/types.js"; export { DEFAULT_SDK_IMAGE, @@ -44,268 +57,16 @@ export { NVRAM_ALIGNMENT, PREFERRED_PORT, } from "./config/constants.js"; +export { + type AccountsDrive, + assertAccountsDriveLength, + getAccountsDrive, + getLog2LeavesPerAccount, + LOG2_LEAF_SIZE, +} from "./config/accounts.js"; export * from "./config/errors.js"; - -/** - * Configuration for drives of a Cartesi Machine. A drive may already exist or be built by a builder - */ -type Builder = "directory" | "docker" | "empty" | "none" | "tar"; -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) -}; - -/** - * Marks the drive or nvram that holds the accounts of emergency withdrawal, whose layout is - * derived from the built machine. - */ -type AccountsDriveMarker = { - accountsDrive?: boolean; // holds the accounts of emergency withdrawal - accountsDriveSize?: number; // accounts only in this many bytes at the beginning, default is all of it -}; - -export type DriveConfig = ( - | DirectoryDriveConfig - | DockerDriveConfig - | EmptyDriveConfig - | ExistingDriveConfig - | TarDriveConfig -) & - AccountsDriveMarker & { - mount?: string | boolean; // default given by cartesi-machine - shared?: boolean; // default given by cartesi-machine - user?: string; // default given by cartesi-machine - }; - -/** - * Configuration for an NVRAM of a Cartesi Machine. Unlike a flash drive, an nvram is a raw - * range of bytes exposed to the guest as a /dev/uio* device, with no filesystem and no mount - * point. Either `size` or `filename` must be defined. - */ -export type NvramConfig = AccountsDriveMarker & { - filename?: string; // path to an existing raw image with the initial contents - size?: number; // in bytes, a positive multiple of 4Ki - 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. - */ -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; -}; - -/** - * The optional [withdrawal] section of an application with an accounts drive. The accounts - * drive layout is derived from the built machine; these keys override the defaults. - */ -export type WithdrawalOptions = { - guardian?: Address; // default is a devnet account - withdrawal_output_builder?: Address; // default is the devnet TestUsdWithdrawalOutputBuilder - account_size?: number; // in bytes, default is 32 -}; - -export type Config = { - drives: Record; - machine: MachineConfig; - nvrams: Record; - sdk: string; - withdrawal?: WithdrawalOptions; -}; - -export const LOG2_LEAF_SIZE = 5; // accounts are made of 32-byte leaves - -/** - * Number of 32-byte leaves of each account, in log2, from `account_size`. Defaults to one leaf. - */ -export const getLog2LeavesPerAccount = (options?: WithdrawalOptions): number => - options?.account_size !== undefined - ? Math.log2(options.account_size) - LOG2_LEAF_SIZE // a power of two, so exact - : 0; - -/** - * The drive or nvram marked with `accounts_drive = true`. - */ -export type AccountsDrive = { - kind: "flash_drive" | "nvram"; // section of the stored machine config that holds it - label: string; - config: DriveConfig | NvramConfig; // its entry in cartesi.toml -}; - -/** - * Every drive and nvram marked with `accounts_drive = true`. Parsing rejects more than one, - * and lists them all to say which. - */ -const getMarkedDrives = (config: Config): AccountsDrive[] => - [ - ...Object.entries(config.drives).map(([label, drive]) => ({ - kind: "flash_drive" as const, - label, - config: drive, - })), - ...Object.entries(config.nvrams).map(([label, nvram]) => ({ - kind: "nvram" as const, - label, - config: nvram, - })), - ].filter((drive) => drive.config.accountsDrive); - -export const getAccountsDrive = (config: Config): AccountsDrive | undefined => - getMarkedDrives(config)[0]; - -/** - * Splits a number of bytes into the largest IEC unit that divides it exactly, e.g. 4194304 is - * 4 "Mi", and 1000 is 1000 "". - */ -export const splitSize = (bytes: number): { value: number; unit: string } => { - let value = bytes; - let unit = ""; - for (const next of ["Ki", "Mi", "Gi", "Ti"]) { - if (value === 0 || value % 1024 !== 0) { - break; - } - value /= 1024; - unit = next; - } - return { value, unit }; -}; - -/** - * Formats a number of bytes as a size cartesi.toml accepts, e.g. "4Mi". - */ -const formatSize = (bytes: number): string => { - const { value, unit } = splitSize(bytes); - return `${value}${unit}`; -}; - -/** - * The log2 of a positive power of two, or undefined for any other number. It reads the bits, - * which is exact for every safe integer, where `&` wraps at 32 bits and Math.log2 rounds near - * 2^53. - */ -const log2OfPowerOfTwo = (value: number): number | undefined => { - const bits = Number.isInteger(value) ? value.toString(2) : ""; - return /^10*$/.test(bits) ? bits.length - 1 : undefined; -}; - -const nextPowerOfTwo = (value: number): number => - 2 ** (value - 1).toString(2).length; - -/** - * Checks the accounts drive can be divided into accounts. The accounts drive is either all of - * the drive or nvram marked with `accounts_drive = true`, or its first `accounts_drive_size` - * bytes, the rest being free for other application state. - * @param driveLength length of the whole drive, if known - * @returns the length of the accounts drive and its log2, if known - */ -export const assertAccountsDriveLength = ( - config: Config, - accountsDrive: AccountsDrive, - driveLength?: number, -): { length: number; log2Length: number } | undefined => { - const { label } = accountsDrive; - const { accountsDriveSize } = accountsDrive.config; - const log2LeavesPerAccount = getLog2LeavesPerAccount(config.withdrawal); - const length = accountsDriveSize ?? driveLength; - if (length === undefined) { - return undefined; - } - - const log2Length = log2OfPowerOfTwo(length); - if (log2Length === undefined) { - const suggestion = - length > 0 ? `, use "${formatSize(nextPowerOfTwo(length))}"` : ""; - throw new InvalidAccountsDriveError( - label, - accountsDriveSize !== undefined - ? `accounts_drive_size ${length} is not a power of two${suggestion}` - : `size ${length} is not a power of two${suggestion}, or set accounts_drive_size to keep the accounts at the beginning of a larger drive`, - ); - } - const log2AccountSize = LOG2_LEAF_SIZE + log2LeavesPerAccount; - if (log2Length < log2AccountSize) { - throw new InvalidAccountsDriveError( - label, - `size ${length} is smaller than one account of ${2 ** log2AccountSize} bytes`, - ); - } - if ( - accountsDriveSize !== undefined && - driveLength !== undefined && - accountsDriveSize > driveLength - ) { - throw new InvalidAccountsDriveError( - label, - `accounts_drive_size ${accountsDriveSize} is larger than the drive itself (${driveLength} bytes)`, - ); - } - return { length, log2Length }; -}; +export { splitSize } from "./config/size.js"; +export type * from "./config/types.js"; export const defaultRootDriveConfig = (): DriveConfig => ({ builder: "docker", @@ -913,64 +674,6 @@ const parseOptionalWithdrawal = ( return options; }; -/** - * Checks what can be known about the accounts drive before the machine is built. Its placement - * is only known after, see `resolveAccountsDriveLayout`. - */ -const assertAccountsDrive = (config: Config) => { - const marked = getMarkedDrives(config); - if (marked.length > 1) { - throw new MultipleAccountsDrivesError(marked.map(({ label }) => label)); - } - - for (const [label, drive] of [ - ...Object.entries(config.drives), - ...Object.entries(config.nvrams), - ]) { - if (drive.accountsDriveSize !== undefined && !drive.accountsDrive) { - throw new InvalidAccountsDriveError( - label, - "accounts_drive_size requires accounts_drive = true", - ); - } - } - - const [accountsDrive] = marked; - if (!accountsDrive) { - if (config.withdrawal) { - throw new InvalidWithdrawalConfigError( - "emergency withdrawal needs an accounts drive, mark a raw drive or an nvram with accounts_drive = true", - ); - } - return; - } - - const { label } = accountsDrive; - - let size: number | undefined; - if (accountsDrive.kind === "flash_drive") { - const drive = config.drives[label]; - if (drive.builder !== "empty" || drive.format !== "raw") { - throw new InvalidAccountsDriveError( - label, - `it must not have a filesystem, use builder = "empty" and format = "raw", or declare it under [nvrams.${label}]`, - ); - } - if (drive.mount !== undefined && drive.mount !== false) { - throw new InvalidAccountsDriveError( - label, - "it can't be mounted, remove 'mount' or set it to false", - ); - } - size = drive.size; - } else { - // the size of an nvram backed by an existing image is only known once built - size = config.nvrams[label].size; - } - - assertAccountsDriveLength(config, accountsDrive, size); -}; - const CONFIG_KEYS: readonly string[] = [ "drives", "machine", diff --git a/apps/cli/src/config/accounts.ts b/apps/cli/src/config/accounts.ts new file mode 100644 index 00000000..1db11cc5 --- /dev/null +++ b/apps/cli/src/config/accounts.ts @@ -0,0 +1,174 @@ +import { + InvalidAccountsDriveError, + InvalidWithdrawalConfigError, + MultipleAccountsDrivesError, +} from "./errors.js"; +import { formatSize } from "./size.js"; +import type { + Config, + DriveConfig, + NvramConfig, + WithdrawalOptions, +} from "./types.js"; + +export const LOG2_LEAF_SIZE = 5; // accounts are made of 32-byte leaves + +/** + * Number of 32-byte leaves of each account, in log2, from `account_size`. Defaults to one leaf. + */ +export const getLog2LeavesPerAccount = (options?: WithdrawalOptions): number => + options?.account_size !== undefined + ? Math.log2(options.account_size) - LOG2_LEAF_SIZE // a power of two, so exact + : 0; + +/** + * The drive or nvram marked with `accounts_drive = true`. + */ +export type AccountsDrive = { + kind: "flash_drive" | "nvram"; // section of the stored machine config that holds it + label: string; + config: DriveConfig | NvramConfig; // its entry in cartesi.toml +}; + +/** + * Every drive and nvram marked with `accounts_drive = true`. Parsing rejects more than one, + * and lists them all to say which. + */ +const getMarkedDrives = (config: Config): AccountsDrive[] => + [ + ...Object.entries(config.drives).map(([label, drive]) => ({ + kind: "flash_drive" as const, + label, + config: drive, + })), + ...Object.entries(config.nvrams).map(([label, nvram]) => ({ + kind: "nvram" as const, + label, + config: nvram, + })), + ].filter((drive) => drive.config.accountsDrive); + +export const getAccountsDrive = (config: Config): AccountsDrive | undefined => + getMarkedDrives(config)[0]; + +/** + * The log2 of a positive power of two, or undefined for any other number. It reads the bits, + * which is exact for every safe integer, where `&` wraps at 32 bits and Math.log2 rounds near + * 2^53. + */ +export const log2OfPowerOfTwo = (value: number): number | undefined => { + const bits = Number.isInteger(value) ? value.toString(2) : ""; + return /^10*$/.test(bits) ? bits.length - 1 : undefined; +}; + +const nextPowerOfTwo = (value: number): number => + 2 ** (value - 1).toString(2).length; + +/** + * Checks the accounts drive can be divided into accounts. The accounts drive is either all of + * the drive or nvram marked with `accounts_drive = true`, or its first `accounts_drive_size` + * bytes, the rest being free for other application state. + * @param driveLength length of the whole drive, if known + * @returns the length of the accounts drive and its log2, if known + */ +export const assertAccountsDriveLength = ( + config: Config, + accountsDrive: AccountsDrive, + driveLength?: number, +): { length: number; log2Length: number } | undefined => { + const { label } = accountsDrive; + const { accountsDriveSize } = accountsDrive.config; + const log2LeavesPerAccount = getLog2LeavesPerAccount(config.withdrawal); + const length = accountsDriveSize ?? driveLength; + if (length === undefined) { + return undefined; + } + + const log2Length = log2OfPowerOfTwo(length); + if (log2Length === undefined) { + const suggestion = + length > 0 ? `, use "${formatSize(nextPowerOfTwo(length))}"` : ""; + throw new InvalidAccountsDriveError( + label, + accountsDriveSize !== undefined + ? `accounts_drive_size ${length} is not a power of two${suggestion}` + : `size ${length} is not a power of two${suggestion}, or set accounts_drive_size to keep the accounts at the beginning of a larger drive`, + ); + } + const log2AccountSize = LOG2_LEAF_SIZE + log2LeavesPerAccount; + if (log2Length < log2AccountSize) { + throw new InvalidAccountsDriveError( + label, + `size ${length} is smaller than one account of ${2 ** log2AccountSize} bytes`, + ); + } + if ( + accountsDriveSize !== undefined && + driveLength !== undefined && + accountsDriveSize > driveLength + ) { + throw new InvalidAccountsDriveError( + label, + `accounts_drive_size ${accountsDriveSize} is larger than the drive itself (${driveLength} bytes)`, + ); + } + return { length, log2Length }; +}; + +/** + * Checks what can be known about the accounts drive before the machine is built. Its placement + * is only known after, see `resolveAccountsDriveLayout`. + */ +export const assertAccountsDrive = (config: Config) => { + const marked = getMarkedDrives(config); + if (marked.length > 1) { + throw new MultipleAccountsDrivesError(marked.map(({ label }) => label)); + } + + for (const [label, drive] of [ + ...Object.entries(config.drives), + ...Object.entries(config.nvrams), + ]) { + if (drive.accountsDriveSize !== undefined && !drive.accountsDrive) { + throw new InvalidAccountsDriveError( + label, + "accounts_drive_size requires accounts_drive = true", + ); + } + } + + const [accountsDrive] = marked; + if (!accountsDrive) { + if (config.withdrawal) { + throw new InvalidWithdrawalConfigError( + "emergency withdrawal needs an accounts drive, mark a raw drive or an nvram with accounts_drive = true", + ); + } + return; + } + + const { label } = accountsDrive; + + let size: number | undefined; + if (accountsDrive.kind === "flash_drive") { + const drive = config.drives[label]; + if (drive.builder !== "empty" || drive.format !== "raw") { + throw new InvalidAccountsDriveError( + label, + `it must not have a filesystem, use builder = "empty" and format = "raw", or declare it under [nvrams.${label}]`, + ); + } + if (drive.mount !== undefined && drive.mount !== false) { + throw new InvalidAccountsDriveError( + label, + "it can't be mounted, remove 'mount' or set it to false", + ); + } + size = drive.size; + } else { + // the size of an nvram backed by an existing image is only known once built + size = config.nvrams[label].size; + } + + assertAccountsDriveLength(config, accountsDrive, size); +}; diff --git a/apps/cli/src/config/size.ts b/apps/cli/src/config/size.ts new file mode 100644 index 00000000..9f98c8ae --- /dev/null +++ b/apps/cli/src/config/size.ts @@ -0,0 +1,24 @@ +/** + * Splits a number of bytes into the largest IEC unit that divides it exactly, e.g. 4194304 is + * 4 "Mi", and 1000 is 1000 "". + */ +export const splitSize = (bytes: number): { value: number; unit: string } => { + let value = bytes; + let unit = ""; + for (const next of ["Ki", "Mi", "Gi", "Ti"]) { + if (value === 0 || value % 1024 !== 0) { + break; + } + value /= 1024; + unit = next; + } + return { value, unit }; +}; + +/** + * Formats a number of bytes as a size cartesi.toml accepts, e.g. "4Mi". + */ +export const formatSize = (bytes: number): string => { + const { value, unit } = splitSize(bytes); + return `${value}${unit}`; +}; diff --git a/apps/cli/src/config/types.ts b/apps/cli/src/config/types.ts new file mode 100644 index 00000000..92941f04 --- /dev/null +++ b/apps/cli/src/config/types.ts @@ -0,0 +1,133 @@ +import type { Address } from "viem"; + +/** + * Configuration for drives of a Cartesi Machine. A drive may already exist or be built by a builder + */ +export 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) +}; + +/** + * Marks the drive or nvram that holds the accounts of emergency withdrawal, whose layout is + * derived from the built machine. + */ +export type AccountsDriveMarker = { + accountsDrive?: boolean; // holds the accounts of emergency withdrawal + accountsDriveSize?: number; // accounts only in this many bytes at the beginning, default is all of it +}; + +export type DriveConfig = ( + | DirectoryDriveConfig + | DockerDriveConfig + | EmptyDriveConfig + | ExistingDriveConfig + | TarDriveConfig +) & + AccountsDriveMarker & { + mount?: string | boolean; // default given by cartesi-machine + shared?: boolean; // default given by cartesi-machine + user?: string; // default given by cartesi-machine + }; + +/** + * Configuration for an NVRAM of a Cartesi Machine. Unlike a flash drive, an nvram is a raw + * range of bytes exposed to the guest as a /dev/uio* device, with no filesystem and no mount + * point. Either `size` or `filename` must be defined. + */ +export type NvramConfig = AccountsDriveMarker & { + filename?: string; // path to an existing raw image with the initial contents + size?: number; // in bytes, a positive multiple of 4Ki + 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. + */ +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; +}; + +/** + * The optional [withdrawal] section of an application with an accounts drive. The accounts + * drive layout is derived from the built machine; these keys override the defaults. + */ +export type WithdrawalOptions = { + guardian?: Address; // default is a devnet account + withdrawal_output_builder?: Address; // default is the devnet TestUsdWithdrawalOutputBuilder + account_size?: number; // in bytes, default is 32 +}; + +export type Config = { + drives: Record; + machine: MachineConfig; + nvrams: Record; + sdk: string; + withdrawal?: WithdrawalOptions; +}; From c4bbb63e345f999f3d1b2c69f9930c93c92b5112 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Wed, 28 Oct 2026 17:10:58 +0000 Subject: [PATCH 4/5] refactor(cli): move the toml parsers under config/toml --- apps/cli/src/config.ts | 683 +----------------- apps/cli/src/config/defaults.ts | 38 + apps/cli/src/config/toml/accounts.ts | 87 +++ apps/cli/src/config/toml/drives.ts | 238 ++++++ apps/cli/src/config/toml/machine.ts | 57 ++ apps/cli/src/config/toml/nvrams.ts | 68 ++ .../src/config/{toml.ts => toml/tables.ts} | 2 +- apps/cli/src/config/toml/values.ts | 233 ++++++ 8 files changed, 745 insertions(+), 661 deletions(-) create mode 100644 apps/cli/src/config/defaults.ts create mode 100644 apps/cli/src/config/toml/accounts.ts create mode 100644 apps/cli/src/config/toml/drives.ts create mode 100644 apps/cli/src/config/toml/machine.ts create mode 100644 apps/cli/src/config/toml/nvrams.ts rename apps/cli/src/config/{toml.ts => toml/tables.ts} (97%) create mode 100644 apps/cli/src/config/toml/values.ts diff --git a/apps/cli/src/config.ts b/apps/cli/src/config.ts index f921ad00..62ca16fa 100644 --- a/apps/cli/src/config.ts +++ b/apps/cli/src/config.ts @@ -1,62 +1,19 @@ -import { extname } from "node:path"; -import { parse as parseToml, type TomlPrimitive } from "smol-toml"; -import { type Address, getAddress, isAddress } from "viem"; -import { - DEFAULT_FORMAT, - DEFAULT_RAM, - DEFAULT_SDK_IMAGE, - DEFAULT_SDK_VERSION, - MAX_NVRAMS, - NVRAM_ALIGNMENT, -} from "./config/constants.js"; -import { - assertAccountsDrive, - LOG2_LEAF_SIZE, - log2OfPowerOfTwo, -} from "./config/accounts.js"; -import { - DuplicateLabelError, - InvalidAddressValueError, - InvalidBooleanValueError, - InvalidBuilderError, - InvalidBytesValueError, - InvalidDriveFormatError, - InvalidEmptyDriveFormatError, - InvalidEnvError, - InvalidNumberValueError, - InvalidNvramSizeError, - InvalidStringArrayError, - InvalidStringValueError, - InvalidTableError, - InvalidWithdrawalConfigError, - MissingNvramSourceError, - RequiredFieldError, - TooManyNvramsError, - formatValue, -} from "./config/errors.js"; +import { parse as parseToml } from "smol-toml"; +import { assertAccountsDrive } from "./config/accounts.js"; +import { DEFAULT_SDK_IMAGE, DEFAULT_SDK_VERSION } from "./config/constants.js"; +import { DuplicateLabelError } from "./config/errors.js"; +import { parseDrives } from "./config/toml/drives.js"; +import { parseMachine } from "./config/toml/machine.js"; +import { parseNvrams } from "./config/toml/nvrams.js"; import { assertKnownKeys, - isTomlTable, mergeTomlTables, type TomlTable, -} from "./config/toml.js"; -import type { - AccountsDriveMarker, - Builder, - Config, - DriveConfig, - DriveFormat, - MachineConfig, - NvramConfig, - WithdrawalOptions, -} from "./config/types.js"; +} from "./config/toml/tables.js"; +import { parseString } from "./config/toml/values.js"; +import { parseOptionalWithdrawal } from "./config/toml/accounts.js"; +import type { Config } from "./config/types.js"; -export { - DEFAULT_SDK_IMAGE, - DEFAULT_SDK_VERSION, - NVRAM_ALIGNMENT, - PREFERRED_PORT, -} from "./config/constants.js"; export { type AccountsDrive, assertAccountsDriveLength, @@ -64,616 +21,22 @@ export { getLog2LeavesPerAccount, LOG2_LEAF_SIZE, } from "./config/accounts.js"; +export { + DEFAULT_SDK_IMAGE, + DEFAULT_SDK_VERSION, + NVRAM_ALIGNMENT, + PREFERRED_PORT, +} from "./config/constants.js"; +export { + defaultConfig, + defaultMachineConfig, + defaultRootDriveConfig, +} from "./config/defaults.js"; export * from "./config/errors.js"; export { splitSize } from "./config/size.js"; +export { getDriveFormat } from "./config/toml/drives.js"; export type * from "./config/types.js"; -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(), - nvrams: {}, - sdk: `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`, - withdrawal: undefined, -}); - -const parseBoolean = (value: TomlPrimitive, defaultValue: boolean): boolean => { - if (value === undefined) { - return defaultValue; - } - if (typeof value === "boolean") { - return value; - } - throw new InvalidBooleanValueError(value); -}; - -const parseOptionalBoolean = (value: TomlPrimitive): boolean | undefined => { - if (value === undefined) { - return undefined; - } - if (typeof value === "boolean") { - return value; - } - throw new InvalidBooleanValueError(value); -}; - -const parseString = (value: TomlPrimitive, defaultValue: string): string => { - if (value === undefined) { - return defaultValue; - } - if (typeof value === "string") { - return value; - } - throw new InvalidStringValueError(value); -}; - -const parseStringArray = (value: TomlPrimitive): string[] => { - if (value === undefined) { - return []; - } - if (typeof value === "string") { - return [value]; - } - if (typeof value === "object" && Array.isArray(value)) { - return value.map((v) => { - if (typeof v === "string") { - return v; - } - throw new InvalidStringValueError(v); - }); - } - throw new InvalidStringArrayError(); -}; - -/** - * Parses a TOML table into a record of string environment variables. - * Non-string scalar values (number, bigint, boolean) are coerced to string, - * since environment variable values are always strings. - */ -const parseStringRecord = (value: TomlPrimitive): Record => { - if (value === undefined) { - return {}; - } - if (!isTomlTable(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 parseRequiredString = (value: TomlPrimitive, key: string): string => { - if (value === undefined) { - throw new RequiredFieldError(key); - } - if (typeof value === "string") { - return value; - } - throw new InvalidStringValueError(value); -}; - -const parseRequiredAddress = (value: TomlPrimitive, 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 parseOptionalString = (value: TomlPrimitive): string | undefined => { - if (value === undefined) { - return undefined; - } - if (typeof value === "string") { - return value; - } - throw new InvalidStringValueError(value); -}; - -const parseOptionalStringBoolean = ( - value: TomlPrimitive, -): string | boolean | undefined => { - if (value === undefined) { - return undefined; - } - if (typeof value === "string") { - return value; - } - if (typeof value === "boolean") { - return value; - } - throw new InvalidStringValueError(value); -}; - -const parseOptionalNumber = (value: TomlPrimitive): bigint | undefined => { - if (value === undefined) { - return undefined; - } - if (typeof value === "bigint") { - return value; - } - if (typeof value === "number") { - return BigInt(value); - } - throw new InvalidNumberValueError(value); -}; - -const IEC_MULTIPLIERS: Record = { - "": 1, - b: 1, - k: 1024, - ki: 1024, - kb: 1024, - kib: 1024, - m: 1024 ** 2, - mi: 1024 ** 2, - mb: 1024 ** 2, - mib: 1024 ** 2, - g: 1024 ** 3, - gi: 1024 ** 3, - gb: 1024 ** 3, - gib: 1024 ** 3, - t: 1024 ** 4, - ti: 1024 ** 4, - tb: 1024 ** 4, - tib: 1024 ** 4, - p: 1024 ** 5, - pi: 1024 ** 5, - pb: 1024 ** 5, - pib: 1024 ** 5, -}; - -/** - * Parses a byte size of a drive or nvram, accepting the IEC suffixes used by cartesi-machine - * ("4Ki", "1MiB") and their decimal spellings ("4kb", "100Mb"), all as powers of 1024, up to - * petabytes. Sizes are numbers, so the ones beyond 2^53 bytes, which they can't hold exactly, - * are rejected. - */ -const parseSize = (value: TomlPrimitive): number | undefined => { - if (value === undefined) { - return undefined; - } - let size: number | undefined; - if (typeof value === "number") { - size = value; - } else if (typeof value === "string") { - const match = /^\s*(\d+(?:\.\d+)?)\s*([a-z]*)\s*$/i.exec(value); - const multiplier = match - ? IEC_MULTIPLIERS[match[2].toLowerCase()] - : undefined; - if (match && multiplier !== undefined) { - size = Number(match[1]) * multiplier; - } - } - // a whole number of bytes, e.g. "1.5Ki" is but "1.1Ki" isn't - if ( - size !== undefined && - Number.isInteger(size) && - size <= Number.MAX_SAFE_INTEGER - ) { - return size; - } - throw new InvalidBytesValueError(value); -}; - -const parseBytes = (value: TomlPrimitive, defaultValue: number): number => - parseSize(value) ?? defaultValue; - -const ACCOUNTS_DRIVE_KEYS: readonly string[] = [ - "accounts_drive", - "accounts_drive_size", -]; - -/** - * Parses the keys that mark a drive or nvram as the accounts drive, leaving them out when unset. - */ -const parseAccountsDriveMarker = (toml: TomlTable): AccountsDriveMarker => { - const accountsDrive = parseOptionalBoolean(toml.accounts_drive); - const accountsDriveSize = parseSize(toml.accounts_drive_size); - return { - ...(accountsDrive !== undefined && { accountsDrive }), - ...(accountsDriveSize !== undefined && { accountsDriveSize }), - }; -}; - -const NVRAM_KEYS: readonly string[] = [ - ...ACCOUNTS_DRIVE_KEYS, - "filename", - "shared", - "size", - "user", -]; - -const parseNvram = (label: string, value: TomlPrimitive): NvramConfig => { - if (!isTomlTable(value)) { - throw new InvalidTableError(`[nvrams.${label}]`, value); - } - const toml = value; - assertKnownKeys(toml, NVRAM_KEYS, `in [nvrams.${label}]`); - const size = parseSize(toml.size); - const filename = parseOptionalString(toml.filename); - - if (size === undefined && filename === undefined) { - throw new MissingNvramSourceError(label); - } - if (size !== undefined && (size <= 0 || size % NVRAM_ALIGNMENT !== 0)) { - throw new InvalidNvramSizeError(label, size); - } - - return { - ...parseAccountsDriveMarker(toml), - filename, - size, - shared: parseOptionalBoolean(toml.shared), - user: parseOptionalString(toml.user), - }; -}; - -const parseNvrams = (config: TomlPrimitive): Record => { - if (config !== undefined && !isTomlTable(config)) { - throw new InvalidTableError("[nvrams]", config); - } - const entries = Object.entries(config ?? {}); - if (entries.length > MAX_NVRAMS) { - throw new TooManyNvramsError(entries.length); - } - return entries.reduce>( - (acc, [label, nvram]) => { - acc[label] = parseNvram(label, nvram); - return acc; - }, - {}, - ); -}; - -const parseBuilder = (value: TomlPrimitive): Builder => { - if (value === undefined) { - return "docker"; - } - if (typeof value === "string") { - switch (value) { - case "directory": - return "directory"; - case "docker": - return "docker"; - case "empty": - return "empty"; - case "none": - return "none"; - case "tar": - return "tar"; - } - } - throw new InvalidBuilderError(value); -}; - -const parseFormat = (value: TomlPrimitive): DriveFormat => { - if (value === undefined) { - return DEFAULT_FORMAT; - } - if (typeof value === "string") { - switch (value) { - case "ext2": - return "ext2"; - case "sqfs": - return "sqfs"; - } - } - throw new InvalidDriveFormatError(value); -}; - -const parseEmptyFormat = (value: TomlPrimitive): "ext2" | "raw" => { - if (value === undefined) { - return DEFAULT_FORMAT; - } - if (typeof value === "string") { - switch (value) { - case "ext2": - return "ext2"; - case "raw": - return "raw"; - } - } - throw new InvalidEmptyDriveFormatError(value); -}; - -const MACHINE_KEYS: readonly string[] = [ - "assert_rolling_template", - "boot_args", - "entrypoint", - "env", - "env_file", - "max_mcycle", - "ram_image", - "ram_length", - "use_docker_env", - "use_docker_workdir", - "user", -]; - -const parseMachine = (value: TomlPrimitive): MachineConfig => { - if (value === undefined) { - // default machine - return defaultMachineConfig(); - } - if (!isTomlTable(value)) { - throw new InvalidTableError("[machine]", value); - } - assertKnownKeys(value, MACHINE_KEYS, "in [machine]"); - const toml = value; - - return { - assertRollingTemplate: parseOptionalBoolean( - toml.assert_rolling_template, - ), - bootargs: parseStringArray(toml.boot_args), - entrypoint: parseOptionalString(toml.entrypoint), - env: parseStringRecord(toml.env), - envFile: parseOptionalString(toml.env_file), - maxMCycle: parseOptionalNumber(toml.max_mcycle), - ramLength: parseString(toml.ram_length, DEFAULT_RAM), - ramImage: parseOptionalString(toml.ram_image), - useDockerEnv: parseBoolean(toml.use_docker_env, true), - useDockerWorkdir: parseBoolean(toml.use_docker_workdir, true), - user: parseOptionalString(toml.user), - }; -}; - -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 DRIVE_KEYS: readonly string[] = [ - ...ACCOUNTS_DRIVE_KEYS, - "builder", - "mount", - "shared", - "user", -]; - -/** - * The keys each builder reads, on top of DRIVE_KEYS - */ -const BUILDER_KEYS: Record = { - directory: ["directory", "extra_size", "format"], - docker: [ - "build_args", - "context", - "dockerfile", - "extra_size", - "format", - "image", - "tags", - "target", - ], - empty: ["format", "size"], - none: ["filename"], - tar: ["extra_size", "filename", "format"], -}; - -const parseDrive = (name: string, drive: TomlPrimitive): DriveConfig => { - if (!isTomlTable(drive)) { - throw new InvalidTableError(`[drives.${name}]`, drive); - } - const toml = drive; - // the keys depend on the builder, so an invalid one is reported first - const builder = parseBuilder(toml.builder); - const implicit = toml.builder === undefined ? " by default" : ""; - assertKnownKeys( - toml, - [...DRIVE_KEYS, ...BUILDER_KEYS[builder]], - `in [drives.${name}] (builder = "${builder}"${implicit})`, - ); - return { - ...parseBuilderDrive(builder, toml), - ...parseAccountsDriveMarker(toml), - }; -}; - -const parseBuilderDrive = (builder: Builder, drive: TomlTable): DriveConfig => { - switch (builder) { - case "directory": { - const { extra_size, format, mount, directory, shared, user } = - drive; - return { - builder: "directory", - extraSize: parseBytes(extra_size, 0), - format: parseFormat(format), - mount: parseOptionalStringBoolean(mount), - directory: parseRequiredString(directory, "directory"), - shared: parseOptionalBoolean(shared), - user: parseOptionalString(user), - }; - } - case "docker": { - const { - build_args, - context, - dockerfile, - extra_size, - format, - image, - mount, - shared, - tags, - target, - user, - } = drive; - return { - builder: "docker", - buildArgs: parseStringArray(build_args), - image: parseOptionalString(image), - context: parseString(context, "."), - dockerfile: parseString(dockerfile, "Dockerfile"), - extraSize: parseBytes(extra_size, 0), - format: parseFormat(format), - mount: parseOptionalStringBoolean(mount), - shared: parseOptionalBoolean(shared), - user: parseOptionalString(user), - tags: parseStringArray(tags), - target: parseOptionalString(target), - }; - } - case "empty": { - const { format, mount, size, shared, user } = drive; - return { - builder: "empty", - format: parseEmptyFormat(format), - mount: parseOptionalStringBoolean(mount), - shared: parseOptionalBoolean(shared), - size: parseBytes(size, 0), - user: parseOptionalString(user), - }; - } - case "tar": { - const { extra_size, filename, format, mount, shared, user } = drive; - return { - builder: "tar", - extraSize: parseBytes(extra_size, 0), - filename: parseRequiredString(filename, "filename"), - format: parseFormat(format), - mount: parseOptionalStringBoolean(mount), - shared: parseOptionalBoolean(shared), - user: parseOptionalString(user), - }; - } - case "none": { - const { shared, mount, user } = drive; - const filename = parseRequiredString(drive.filename, "filename"); - const format = getDriveFormat(filename); - return { - builder: "none", - filename, - format, - mount: parseOptionalStringBoolean(mount), - shared: parseOptionalBoolean(shared), - user: parseOptionalString(user), - }; - } - } -}; - -const parseDrives = (config: TomlPrimitive): Record => { - if (config !== undefined && !isTomlTable(config)) { - throw new InvalidTableError("[drives]", config); - } - // load drives from configuration - const drives = Object.entries(config ?? {}).reduce< - Record - >((acc, [name, drive]) => { - acc[name] = parseDrive(name, drive); - return acc; - }, {}); - - // check if there is a root drive - const hasRoot = drives.root !== undefined; - if (!hasRoot) { - // there is no root drive, add a default one - drives.root = defaultRootDriveConfig(); - } - return drives; -}; - -const parseAccountSize = (value: TomlPrimitive): number => { - const size = parseSize(value); - if ( - size === undefined || - size < 2 ** LOG2_LEAF_SIZE || - log2OfPowerOfTwo(size) === undefined - ) { - throw new InvalidWithdrawalConfigError( - `account_size ${formatValue(value)} must be a power of two of at least 32 bytes, such as 32 or 64`, - ); - } - return size; -}; - -const WITHDRAWAL_KEYS: readonly string[] = [ - "account_size", - "guardian", - "withdrawal_output_builder", -]; - -/** - * Parses [withdrawal], which only applies to an application with an accounts drive. Like the - * other tables, it reads the keys it uses and rejects any other. - */ -const parseOptionalWithdrawal = ( - withdrawal: TomlPrimitive, -): WithdrawalOptions | undefined => { - if (withdrawal === undefined) { - return undefined; - } - if (!isTomlTable(withdrawal)) { - throw new InvalidTableError("[withdrawal]", withdrawal); - } - assertKnownKeys(withdrawal, WITHDRAWAL_KEYS, "in [withdrawal]"); - - const optional = ( - key: string, - parser: (value: TomlPrimitive, key: string) => T, - ): T | undefined => - withdrawal[key] === undefined - ? undefined - : parser(withdrawal[key], key); - - const options: WithdrawalOptions = { - guardian: optional("guardian", parseRequiredAddress), - withdrawal_output_builder: optional( - "withdrawal_output_builder", - parseRequiredAddress, - ), - account_size: optional("account_size", parseAccountSize), - }; - return options; -}; - const CONFIG_KEYS: readonly string[] = [ "drives", "machine", diff --git a/apps/cli/src/config/defaults.ts b/apps/cli/src/config/defaults.ts new file mode 100644 index 00000000..67dd95b7 --- /dev/null +++ b/apps/cli/src/config/defaults.ts @@ -0,0 +1,38 @@ +import { + DEFAULT_FORMAT, + DEFAULT_RAM, + DEFAULT_SDK_IMAGE, + DEFAULT_SDK_VERSION, +} from "./constants.js"; +import type { Config, DriveConfig, MachineConfig } from "./types.js"; + +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(), + nvrams: {}, + sdk: `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`, + withdrawal: undefined, +}); diff --git a/apps/cli/src/config/toml/accounts.ts b/apps/cli/src/config/toml/accounts.ts new file mode 100644 index 00000000..fd8d8344 --- /dev/null +++ b/apps/cli/src/config/toml/accounts.ts @@ -0,0 +1,87 @@ +import type { TomlPrimitive } from "smol-toml"; +import { LOG2_LEAF_SIZE, log2OfPowerOfTwo } from "../accounts.js"; +import { + InvalidTableError, + InvalidWithdrawalConfigError, + formatValue, +} from "../errors.js"; +import type { AccountsDriveMarker, WithdrawalOptions } from "../types.js"; +import { assertKnownKeys, isTomlTable, type TomlTable } from "./tables.js"; +import { + parseOptionalBoolean, + parseRequiredAddress, + parseSize, +} from "./values.js"; + +export const ACCOUNTS_DRIVE_KEYS: readonly string[] = [ + "accounts_drive", + "accounts_drive_size", +]; + +/** + * Parses the keys that mark a drive or nvram as the accounts drive, leaving them out when unset. + */ +export const parseAccountsDriveMarker = ( + toml: TomlTable, +): AccountsDriveMarker => { + const accountsDrive = parseOptionalBoolean(toml.accounts_drive); + const accountsDriveSize = parseSize(toml.accounts_drive_size); + return { + ...(accountsDrive !== undefined && { accountsDrive }), + ...(accountsDriveSize !== undefined && { accountsDriveSize }), + }; +}; + +const parseAccountSize = (value: TomlPrimitive): number => { + const size = parseSize(value); + if ( + size === undefined || + size < 2 ** LOG2_LEAF_SIZE || + log2OfPowerOfTwo(size) === undefined + ) { + throw new InvalidWithdrawalConfigError( + `account_size ${formatValue(value)} must be a power of two of at least 32 bytes, such as 32 or 64`, + ); + } + return size; +}; + +const WITHDRAWAL_KEYS: readonly string[] = [ + "account_size", + "guardian", + "withdrawal_output_builder", +]; + +/** + * Parses [withdrawal], which only applies to an application with an accounts drive. Like the + * other tables, it reads the keys it uses and rejects any other. + */ +export const parseOptionalWithdrawal = ( + withdrawal: TomlPrimitive, +): WithdrawalOptions | undefined => { + if (withdrawal === undefined) { + return undefined; + } + if (!isTomlTable(withdrawal)) { + throw new InvalidTableError("[withdrawal]", withdrawal); + } + assertKnownKeys(withdrawal, WITHDRAWAL_KEYS, "in [withdrawal]"); + + const optional = ( + key: string, + parser: (value: TomlPrimitive, key: string) => T, + ): T | undefined => + withdrawal[key] === undefined + ? undefined + : parser(withdrawal[key], key); + + const options: WithdrawalOptions = { + guardian: optional("guardian", parseRequiredAddress), + withdrawal_output_builder: optional( + "withdrawal_output_builder", + parseRequiredAddress, + ), + account_size: optional("account_size", parseAccountSize), + }; + return options; +}; diff --git a/apps/cli/src/config/toml/drives.ts b/apps/cli/src/config/toml/drives.ts new file mode 100644 index 00000000..eb08f0cc --- /dev/null +++ b/apps/cli/src/config/toml/drives.ts @@ -0,0 +1,238 @@ +import { extname } from "node:path"; +import type { TomlPrimitive } from "smol-toml"; +import { DEFAULT_FORMAT } from "../constants.js"; +import { defaultRootDriveConfig } from "../defaults.js"; +import { + InvalidBuilderError, + InvalidDriveFormatError, + InvalidEmptyDriveFormatError, + InvalidTableError, +} from "../errors.js"; +import type { Builder, DriveConfig, DriveFormat } from "../types.js"; +import { assertKnownKeys, isTomlTable, type TomlTable } from "./tables.js"; +import { + parseBytes, + parseOptionalBoolean, + parseOptionalString, + parseOptionalStringBoolean, + parseRequiredString, + parseString, + parseStringArray, +} from "./values.js"; +import { ACCOUNTS_DRIVE_KEYS, parseAccountsDriveMarker } from "./accounts.js"; + +const parseBuilder = (value: TomlPrimitive): Builder => { + if (value === undefined) { + return "docker"; + } + if (typeof value === "string") { + switch (value) { + case "directory": + return "directory"; + case "docker": + return "docker"; + case "empty": + return "empty"; + case "none": + return "none"; + case "tar": + return "tar"; + } + } + throw new InvalidBuilderError(value); +}; + +const parseFormat = (value: TomlPrimitive): DriveFormat => { + if (value === undefined) { + return DEFAULT_FORMAT; + } + if (typeof value === "string") { + switch (value) { + case "ext2": + return "ext2"; + case "sqfs": + return "sqfs"; + } + } + throw new InvalidDriveFormatError(value); +}; + +const parseEmptyFormat = (value: TomlPrimitive): "ext2" | "raw" => { + if (value === undefined) { + return DEFAULT_FORMAT; + } + if (typeof value === "string") { + switch (value) { + case "ext2": + return "ext2"; + case "raw": + return "raw"; + } + } + throw new InvalidEmptyDriveFormatError(value); +}; + +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 DRIVE_KEYS: readonly string[] = [ + ...ACCOUNTS_DRIVE_KEYS, + "builder", + "mount", + "shared", + "user", +]; + +/** + * The keys each builder reads, on top of DRIVE_KEYS + */ +const BUILDER_KEYS: Record = { + directory: ["directory", "extra_size", "format"], + docker: [ + "build_args", + "context", + "dockerfile", + "extra_size", + "format", + "image", + "tags", + "target", + ], + empty: ["format", "size"], + none: ["filename"], + tar: ["extra_size", "filename", "format"], +}; + +const parseDrive = (name: string, drive: TomlPrimitive): DriveConfig => { + if (!isTomlTable(drive)) { + throw new InvalidTableError(`[drives.${name}]`, drive); + } + const toml = drive; + // the keys depend on the builder, so an invalid one is reported first + const builder = parseBuilder(toml.builder); + const implicit = toml.builder === undefined ? " by default" : ""; + assertKnownKeys( + toml, + [...DRIVE_KEYS, ...BUILDER_KEYS[builder]], + `in [drives.${name}] (builder = "${builder}"${implicit})`, + ); + return { + ...parseBuilderDrive(builder, toml), + ...parseAccountsDriveMarker(toml), + }; +}; + +const parseBuilderDrive = (builder: Builder, drive: TomlTable): DriveConfig => { + switch (builder) { + case "directory": { + const { extra_size, format, mount, directory, shared, user } = + drive; + return { + builder: "directory", + extraSize: parseBytes(extra_size, 0), + format: parseFormat(format), + mount: parseOptionalStringBoolean(mount), + directory: parseRequiredString(directory, "directory"), + shared: parseOptionalBoolean(shared), + user: parseOptionalString(user), + }; + } + case "docker": { + const { + build_args, + context, + dockerfile, + extra_size, + format, + image, + mount, + shared, + tags, + target, + user, + } = drive; + return { + builder: "docker", + buildArgs: parseStringArray(build_args), + image: parseOptionalString(image), + context: parseString(context, "."), + dockerfile: parseString(dockerfile, "Dockerfile"), + extraSize: parseBytes(extra_size, 0), + format: parseFormat(format), + mount: parseOptionalStringBoolean(mount), + shared: parseOptionalBoolean(shared), + user: parseOptionalString(user), + tags: parseStringArray(tags), + target: parseOptionalString(target), + }; + } + case "empty": { + const { format, mount, size, shared, user } = drive; + return { + builder: "empty", + format: parseEmptyFormat(format), + mount: parseOptionalStringBoolean(mount), + shared: parseOptionalBoolean(shared), + size: parseBytes(size, 0), + user: parseOptionalString(user), + }; + } + case "tar": { + const { extra_size, filename, format, mount, shared, user } = drive; + return { + builder: "tar", + extraSize: parseBytes(extra_size, 0), + filename: parseRequiredString(filename, "filename"), + format: parseFormat(format), + mount: parseOptionalStringBoolean(mount), + shared: parseOptionalBoolean(shared), + user: parseOptionalString(user), + }; + } + case "none": { + const { shared, mount, user } = drive; + const filename = parseRequiredString(drive.filename, "filename"); + const format = getDriveFormat(filename); + return { + builder: "none", + filename, + format, + mount: parseOptionalStringBoolean(mount), + shared: parseOptionalBoolean(shared), + user: parseOptionalString(user), + }; + } + } +}; + +export const parseDrives = ( + config: TomlPrimitive, +): Record => { + if (config !== undefined && !isTomlTable(config)) { + throw new InvalidTableError("[drives]", config); + } + // load drives from configuration + const drives = Object.entries(config ?? {}).reduce< + Record + >((acc, [name, drive]) => { + acc[name] = parseDrive(name, drive); + return acc; + }, {}); + + // check if there is a root drive + const hasRoot = drives.root !== undefined; + if (!hasRoot) { + // there is no root drive, add a default one + drives.root = defaultRootDriveConfig(); + } + return drives; +}; diff --git a/apps/cli/src/config/toml/machine.ts b/apps/cli/src/config/toml/machine.ts new file mode 100644 index 00000000..a8ec30da --- /dev/null +++ b/apps/cli/src/config/toml/machine.ts @@ -0,0 +1,57 @@ +import type { TomlPrimitive } from "smol-toml"; +import { DEFAULT_RAM } from "../constants.js"; +import { defaultMachineConfig } from "../defaults.js"; +import { InvalidTableError } from "../errors.js"; +import type { MachineConfig } from "../types.js"; +import { assertKnownKeys, isTomlTable } from "./tables.js"; +import { + parseBoolean, + parseOptionalBoolean, + parseOptionalNumber, + parseOptionalString, + parseString, + parseStringArray, + parseStringRecord, +} from "./values.js"; + +const MACHINE_KEYS: readonly string[] = [ + "assert_rolling_template", + "boot_args", + "entrypoint", + "env", + "env_file", + "max_mcycle", + "ram_image", + "ram_length", + "use_docker_env", + "use_docker_workdir", + "user", +]; + +export const parseMachine = (value: TomlPrimitive): MachineConfig => { + if (value === undefined) { + // default machine + return defaultMachineConfig(); + } + if (!isTomlTable(value)) { + throw new InvalidTableError("[machine]", value); + } + assertKnownKeys(value, MACHINE_KEYS, "in [machine]"); + const toml = value; + + return { + assertRollingTemplate: parseOptionalBoolean( + toml.assert_rolling_template, + ), + bootargs: parseStringArray(toml.boot_args), + entrypoint: parseOptionalString(toml.entrypoint), + env: parseStringRecord(toml.env), + envFile: parseOptionalString(toml.env_file), + maxMCycle: parseOptionalNumber(toml.max_mcycle), + ramLength: parseString(toml.ram_length, DEFAULT_RAM), + ramImage: parseOptionalString(toml.ram_image), + useDockerEnv: parseBoolean(toml.use_docker_env, true), + useDockerWorkdir: parseBoolean(toml.use_docker_workdir, true), + user: parseOptionalString(toml.user), + }; +}; diff --git a/apps/cli/src/config/toml/nvrams.ts b/apps/cli/src/config/toml/nvrams.ts new file mode 100644 index 00000000..7263148a --- /dev/null +++ b/apps/cli/src/config/toml/nvrams.ts @@ -0,0 +1,68 @@ +import type { TomlPrimitive } from "smol-toml"; +import { MAX_NVRAMS, NVRAM_ALIGNMENT } from "../constants.js"; +import { + InvalidNvramSizeError, + InvalidTableError, + MissingNvramSourceError, + TooManyNvramsError, +} from "../errors.js"; +import type { NvramConfig } from "../types.js"; +import { assertKnownKeys, isTomlTable } from "./tables.js"; +import { + parseOptionalBoolean, + parseOptionalString, + parseSize, +} from "./values.js"; +import { ACCOUNTS_DRIVE_KEYS, parseAccountsDriveMarker } from "./accounts.js"; + +const NVRAM_KEYS: readonly string[] = [ + ...ACCOUNTS_DRIVE_KEYS, + "filename", + "shared", + "size", + "user", +]; + +const parseNvram = (label: string, value: TomlPrimitive): NvramConfig => { + if (!isTomlTable(value)) { + throw new InvalidTableError(`[nvrams.${label}]`, value); + } + const toml = value; + assertKnownKeys(toml, NVRAM_KEYS, `in [nvrams.${label}]`); + const size = parseSize(toml.size); + const filename = parseOptionalString(toml.filename); + + if (size === undefined && filename === undefined) { + throw new MissingNvramSourceError(label); + } + if (size !== undefined && (size <= 0 || size % NVRAM_ALIGNMENT !== 0)) { + throw new InvalidNvramSizeError(label, size); + } + + return { + ...parseAccountsDriveMarker(toml), + filename, + size, + shared: parseOptionalBoolean(toml.shared), + user: parseOptionalString(toml.user), + }; +}; + +export const parseNvrams = ( + config: TomlPrimitive, +): Record => { + if (config !== undefined && !isTomlTable(config)) { + throw new InvalidTableError("[nvrams]", config); + } + const entries = Object.entries(config ?? {}); + if (entries.length > MAX_NVRAMS) { + throw new TooManyNvramsError(entries.length); + } + return entries.reduce>( + (acc, [label, nvram]) => { + acc[label] = parseNvram(label, nvram); + return acc; + }, + {}, + ); +}; diff --git a/apps/cli/src/config/toml.ts b/apps/cli/src/config/toml/tables.ts similarity index 97% rename from apps/cli/src/config/toml.ts rename to apps/cli/src/config/toml/tables.ts index 65aeda45..63f8ba0d 100644 --- a/apps/cli/src/config/toml.ts +++ b/apps/cli/src/config/toml/tables.ts @@ -1,5 +1,5 @@ import type { TomlPrimitive } from "smol-toml"; -import { UnknownConfigKeyError } from "./errors.js"; +import { UnknownConfigKeyError } from "../errors.js"; export type TomlTable = { [key: string]: TomlPrimitive }; diff --git a/apps/cli/src/config/toml/values.ts b/apps/cli/src/config/toml/values.ts new file mode 100644 index 00000000..1389b614 --- /dev/null +++ b/apps/cli/src/config/toml/values.ts @@ -0,0 +1,233 @@ +import type { TomlPrimitive } from "smol-toml"; +import { type Address, getAddress, isAddress } from "viem"; +import { + InvalidAddressValueError, + InvalidBooleanValueError, + InvalidBytesValueError, + InvalidEnvError, + InvalidNumberValueError, + InvalidStringArrayError, + InvalidStringValueError, + RequiredFieldError, +} from "../errors.js"; +import { isTomlTable } from "./tables.js"; + +export const parseBoolean = ( + value: TomlPrimitive, + defaultValue: boolean, +): boolean => { + if (value === undefined) { + return defaultValue; + } + if (typeof value === "boolean") { + return value; + } + throw new InvalidBooleanValueError(value); +}; + +export const parseOptionalBoolean = ( + value: TomlPrimitive, +): boolean | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "boolean") { + return value; + } + throw new InvalidBooleanValueError(value); +}; + +export const parseString = ( + value: TomlPrimitive, + defaultValue: string, +): string => { + if (value === undefined) { + return defaultValue; + } + if (typeof value === "string") { + return value; + } + throw new InvalidStringValueError(value); +}; + +export const parseStringArray = (value: TomlPrimitive): string[] => { + if (value === undefined) { + return []; + } + if (typeof value === "string") { + return [value]; + } + if (typeof value === "object" && Array.isArray(value)) { + return value.map((v) => { + if (typeof v === "string") { + return v; + } + throw new InvalidStringValueError(v); + }); + } + throw new InvalidStringArrayError(); +}; + +/** + * Parses a TOML table into a record of string environment variables. + * Non-string scalar values (number, bigint, boolean) are coerced to string, + * since environment variable values are always strings. + */ +export const parseStringRecord = ( + value: TomlPrimitive, +): Record => { + if (value === undefined) { + return {}; + } + if (!isTomlTable(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; + }, + {}, + ); +}; + +export const parseRequiredString = ( + value: TomlPrimitive, + key: string, +): string => { + if (value === undefined) { + throw new RequiredFieldError(key); + } + if (typeof value === "string") { + return value; + } + throw new InvalidStringValueError(value); +}; + +export const parseRequiredAddress = ( + value: TomlPrimitive, + key: string, +): Address => { + if (value === undefined) { + throw new RequiredFieldError(key); + } + if (typeof value === "string" && isAddress(value)) { + return getAddress(value); + } + throw new InvalidAddressValueError(value, key); +}; + +export const parseOptionalString = ( + value: TomlPrimitive, +): string | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "string") { + return value; + } + throw new InvalidStringValueError(value); +}; + +export const parseOptionalStringBoolean = ( + value: TomlPrimitive, +): string | boolean | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "boolean") { + return value; + } + throw new InvalidStringValueError(value); +}; + +export const parseOptionalNumber = ( + value: TomlPrimitive, +): bigint | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "bigint") { + return value; + } + if (typeof value === "number") { + return BigInt(value); + } + throw new InvalidNumberValueError(value); +}; + +const IEC_MULTIPLIERS: Readonly> = { + "": 1, + b: 1, + k: 1024, + ki: 1024, + kb: 1024, + kib: 1024, + m: 1024 ** 2, + mi: 1024 ** 2, + mb: 1024 ** 2, + mib: 1024 ** 2, + g: 1024 ** 3, + gi: 1024 ** 3, + gb: 1024 ** 3, + gib: 1024 ** 3, + t: 1024 ** 4, + ti: 1024 ** 4, + tb: 1024 ** 4, + tib: 1024 ** 4, + p: 1024 ** 5, + pi: 1024 ** 5, + pb: 1024 ** 5, + pib: 1024 ** 5, +}; + +/** + * Parses a byte size of a drive or nvram, accepting the IEC suffixes used by cartesi-machine + * ("4Ki", "1MiB") and their decimal spellings ("4kb", "100Mb"), all as powers of 1024, up to + * petabytes. Sizes are numbers, so the ones beyond 2^53 bytes, which they can't hold exactly, + * are rejected. + */ +export const parseSize = (value: TomlPrimitive): number | undefined => { + if (value === undefined) { + return undefined; + } + let size: number | undefined; + if (typeof value === "number") { + size = value; + } else if (typeof value === "string") { + const match = /^\s*(\d+(?:\.\d+)?)\s*([a-z]*)\s*$/i.exec(value); + const multiplier = match + ? IEC_MULTIPLIERS[match[2].toLowerCase()] + : undefined; + if (match && multiplier !== undefined) { + size = Number(match[1]) * multiplier; + } + } + // a whole number of bytes, e.g. "1.5Ki" is but "1.1Ki" isn't + if ( + size !== undefined && + Number.isInteger(size) && + size <= Number.MAX_SAFE_INTEGER + ) { + return size; + } + throw new InvalidBytesValueError(value); +}; + +export const parseBytes = ( + value: TomlPrimitive, + defaultValue: number, +): number => parseSize(value) ?? defaultValue; From 549cf2609ce310198b557473bb9b21a0a4a1f256 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Tue, 3 Nov 2026 14:03:35 +0000 Subject: [PATCH 5/5] refactor(cli): Replace deprecated TomlPrimitive with TomlValue --- apps/cli/src/config.ts | 8 ++--- apps/cli/src/config/errors.ts | 26 ++++++++-------- apps/cli/src/config/toml/accounts.ts | 10 +++---- apps/cli/src/config/toml/drives.ts | 16 +++++----- apps/cli/src/config/toml/machine.ts | 4 +-- apps/cli/src/config/toml/nvrams.ts | 8 ++--- apps/cli/src/config/toml/tables.ts | 6 ++-- apps/cli/src/config/toml/values.ts | 44 +++++++++------------------- 8 files changed, 48 insertions(+), 74 deletions(-) diff --git a/apps/cli/src/config.ts b/apps/cli/src/config.ts index 62ca16fa..ad079583 100644 --- a/apps/cli/src/config.ts +++ b/apps/cli/src/config.ts @@ -1,15 +1,11 @@ -import { parse as parseToml } from "smol-toml"; +import { parse as parseToml, type TomlTable } from "smol-toml"; import { assertAccountsDrive } from "./config/accounts.js"; import { DEFAULT_SDK_IMAGE, DEFAULT_SDK_VERSION } from "./config/constants.js"; import { DuplicateLabelError } from "./config/errors.js"; import { parseDrives } from "./config/toml/drives.js"; import { parseMachine } from "./config/toml/machine.js"; import { parseNvrams } from "./config/toml/nvrams.js"; -import { - assertKnownKeys, - mergeTomlTables, - type TomlTable, -} from "./config/toml/tables.js"; +import { assertKnownKeys, mergeTomlTables } from "./config/toml/tables.js"; import { parseString } from "./config/toml/values.js"; import { parseOptionalWithdrawal } from "./config/toml/accounts.js"; import type { Config } from "./config/types.js"; diff --git a/apps/cli/src/config/errors.ts b/apps/cli/src/config/errors.ts index 623e92aa..46ae0346 100644 --- a/apps/cli/src/config/errors.ts +++ b/apps/cli/src/config/errors.ts @@ -1,9 +1,9 @@ -import type { TomlPrimitive } from "smol-toml"; +import type { TomlValue } from "smol-toml"; import { MAX_NVRAMS, NVRAM_ALIGNMENT } from "./constants.js"; // smol-toml creates tables with a null prototype, which can't be coerced to a // string by template literals, so format objects (tables, arrays) as JSON. -export const formatValue = (value: TomlPrimitive): string => +export const formatValue = (value: TomlValue): string => typeof value === "object" && value !== null && !(value instanceof Date) ? JSON.stringify(value, (_, v) => typeof v === "bigint" ? v.toString() : v, @@ -11,42 +11,42 @@ export const formatValue = (value: TomlPrimitive): string => : String(value); export class InvalidBuilderError extends Error { - constructor(builder: TomlPrimitive) { + constructor(builder: TomlValue) { super(`Invalid builder: ${formatValue(builder)}`); this.name = "InvalidBuilder"; } } export class InvalidDriveFormatError extends Error { - constructor(format: TomlPrimitive) { + constructor(format: TomlValue) { super(`Invalid drive format: ${formatValue(format)}`); this.name = "InvalidDriveFormatError"; } } export class InvalidEmptyDriveFormatError extends Error { - constructor(format: TomlPrimitive) { + constructor(format: TomlValue) { super(`Invalid empty drive format: ${formatValue(format)}`); this.name = "InvalidEmptyDriveFormatError"; } } export class InvalidStringValueError extends Error { - constructor(value: TomlPrimitive) { + constructor(value: TomlValue) { super(`Invalid string value: ${formatValue(value)}`); this.name = "InvalidStringValueError"; } } export class InvalidBooleanValueError extends Error { - constructor(value: TomlPrimitive) { + constructor(value: TomlValue) { super(`Invalid boolean value: ${formatValue(value)}`); this.name = "InvalidBooleanValueError"; } } export class InvalidNumberValueError extends Error { - constructor(value: TomlPrimitive, key?: string) { + constructor(value: TomlValue, key?: string) { super( `Invalid number value: ${formatValue(value)}${key ? ` for key: ${key}` : ""}`, ); @@ -55,7 +55,7 @@ export class InvalidNumberValueError extends Error { } export class InvalidAddressValueError extends Error { - constructor(value: TomlPrimitive, key?: string) { + constructor(value: TomlValue, key?: string) { super( `Invalid address value: ${formatValue(value)}${key ? ` for key: ${key}` : ""}`, ); @@ -64,14 +64,14 @@ export class InvalidAddressValueError extends Error { } export class InvalidBytesValueError extends Error { - constructor(value: TomlPrimitive) { + constructor(value: TomlValue) { super(`Invalid bytes value: ${formatValue(value)}`); this.name = "InvalidBytesValueError"; } } export class RequiredFieldError extends Error { - constructor(key: TomlPrimitive) { + constructor(key: TomlValue) { super(`Missing required field: ${formatValue(key)}`); this.name = "RequiredFieldError"; } @@ -85,7 +85,7 @@ export class InvalidStringArrayError extends Error { } export class InvalidEnvError extends Error { - constructor(value: TomlPrimitive) { + constructor(value: TomlValue) { super(`Invalid env configuration: ${formatValue(value)}`); this.name = "InvalidEnvError"; } @@ -158,7 +158,7 @@ export class UnknownConfigKeyError extends Error { } export class InvalidTableError extends Error { - constructor(location: string, value: TomlPrimitive) { + constructor(location: string, value: TomlValue) { super( `Invalid ${location}: expected a table, got ${formatValue(value)}`, ); diff --git a/apps/cli/src/config/toml/accounts.ts b/apps/cli/src/config/toml/accounts.ts index fd8d8344..a61a8745 100644 --- a/apps/cli/src/config/toml/accounts.ts +++ b/apps/cli/src/config/toml/accounts.ts @@ -1,4 +1,4 @@ -import type { TomlPrimitive } from "smol-toml"; +import type { TomlTable, TomlValue } from "smol-toml"; import { LOG2_LEAF_SIZE, log2OfPowerOfTwo } from "../accounts.js"; import { InvalidTableError, @@ -6,7 +6,7 @@ import { formatValue, } from "../errors.js"; import type { AccountsDriveMarker, WithdrawalOptions } from "../types.js"; -import { assertKnownKeys, isTomlTable, type TomlTable } from "./tables.js"; +import { assertKnownKeys, isTomlTable } from "./tables.js"; import { parseOptionalBoolean, parseRequiredAddress, @@ -32,7 +32,7 @@ export const parseAccountsDriveMarker = ( }; }; -const parseAccountSize = (value: TomlPrimitive): number => { +const parseAccountSize = (value: TomlValue): number => { const size = parseSize(value); if ( size === undefined || @@ -57,7 +57,7 @@ const WITHDRAWAL_KEYS: readonly string[] = [ * other tables, it reads the keys it uses and rejects any other. */ export const parseOptionalWithdrawal = ( - withdrawal: TomlPrimitive, + withdrawal: TomlValue, ): WithdrawalOptions | undefined => { if (withdrawal === undefined) { return undefined; @@ -69,7 +69,7 @@ export const parseOptionalWithdrawal = ( const optional = ( key: string, - parser: (value: TomlPrimitive, key: string) => T, + parser: (value: TomlValue, key: string) => T, ): T | undefined => withdrawal[key] === undefined ? undefined diff --git a/apps/cli/src/config/toml/drives.ts b/apps/cli/src/config/toml/drives.ts index eb08f0cc..4f01199a 100644 --- a/apps/cli/src/config/toml/drives.ts +++ b/apps/cli/src/config/toml/drives.ts @@ -1,5 +1,5 @@ import { extname } from "node:path"; -import type { TomlPrimitive } from "smol-toml"; +import type { TomlTable, TomlValue } from "smol-toml"; import { DEFAULT_FORMAT } from "../constants.js"; import { defaultRootDriveConfig } from "../defaults.js"; import { @@ -9,7 +9,7 @@ import { InvalidTableError, } from "../errors.js"; import type { Builder, DriveConfig, DriveFormat } from "../types.js"; -import { assertKnownKeys, isTomlTable, type TomlTable } from "./tables.js"; +import { assertKnownKeys, isTomlTable } from "./tables.js"; import { parseBytes, parseOptionalBoolean, @@ -21,7 +21,7 @@ import { } from "./values.js"; import { ACCOUNTS_DRIVE_KEYS, parseAccountsDriveMarker } from "./accounts.js"; -const parseBuilder = (value: TomlPrimitive): Builder => { +const parseBuilder = (value: TomlValue): Builder => { if (value === undefined) { return "docker"; } @@ -42,7 +42,7 @@ const parseBuilder = (value: TomlPrimitive): Builder => { throw new InvalidBuilderError(value); }; -const parseFormat = (value: TomlPrimitive): DriveFormat => { +const parseFormat = (value: TomlValue): DriveFormat => { if (value === undefined) { return DEFAULT_FORMAT; } @@ -57,7 +57,7 @@ const parseFormat = (value: TomlPrimitive): DriveFormat => { throw new InvalidDriveFormatError(value); }; -const parseEmptyFormat = (value: TomlPrimitive): "ext2" | "raw" => { +const parseEmptyFormat = (value: TomlValue): "ext2" | "raw" => { if (value === undefined) { return DEFAULT_FORMAT; } @@ -112,7 +112,7 @@ const BUILDER_KEYS: Record = { tar: ["extra_size", "filename", "format"], }; -const parseDrive = (name: string, drive: TomlPrimitive): DriveConfig => { +const parseDrive = (name: string, drive: TomlValue): DriveConfig => { if (!isTomlTable(drive)) { throw new InvalidTableError(`[drives.${name}]`, drive); } @@ -214,9 +214,7 @@ const parseBuilderDrive = (builder: Builder, drive: TomlTable): DriveConfig => { } }; -export const parseDrives = ( - config: TomlPrimitive, -): Record => { +export const parseDrives = (config: TomlValue): Record => { if (config !== undefined && !isTomlTable(config)) { throw new InvalidTableError("[drives]", config); } diff --git a/apps/cli/src/config/toml/machine.ts b/apps/cli/src/config/toml/machine.ts index a8ec30da..3dbfd7a4 100644 --- a/apps/cli/src/config/toml/machine.ts +++ b/apps/cli/src/config/toml/machine.ts @@ -1,4 +1,4 @@ -import type { TomlPrimitive } from "smol-toml"; +import type { TomlValue } from "smol-toml"; import { DEFAULT_RAM } from "../constants.js"; import { defaultMachineConfig } from "../defaults.js"; import { InvalidTableError } from "../errors.js"; @@ -28,7 +28,7 @@ const MACHINE_KEYS: readonly string[] = [ "user", ]; -export const parseMachine = (value: TomlPrimitive): MachineConfig => { +export const parseMachine = (value: TomlValue): MachineConfig => { if (value === undefined) { // default machine return defaultMachineConfig(); diff --git a/apps/cli/src/config/toml/nvrams.ts b/apps/cli/src/config/toml/nvrams.ts index 7263148a..4c170c62 100644 --- a/apps/cli/src/config/toml/nvrams.ts +++ b/apps/cli/src/config/toml/nvrams.ts @@ -1,4 +1,4 @@ -import type { TomlPrimitive } from "smol-toml"; +import type { TomlValue } from "smol-toml"; import { MAX_NVRAMS, NVRAM_ALIGNMENT } from "../constants.js"; import { InvalidNvramSizeError, @@ -23,7 +23,7 @@ const NVRAM_KEYS: readonly string[] = [ "user", ]; -const parseNvram = (label: string, value: TomlPrimitive): NvramConfig => { +const parseNvram = (label: string, value: TomlValue): NvramConfig => { if (!isTomlTable(value)) { throw new InvalidTableError(`[nvrams.${label}]`, value); } @@ -48,9 +48,7 @@ const parseNvram = (label: string, value: TomlPrimitive): NvramConfig => { }; }; -export const parseNvrams = ( - config: TomlPrimitive, -): Record => { +export const parseNvrams = (config: TomlValue): Record => { if (config !== undefined && !isTomlTable(config)) { throw new InvalidTableError("[nvrams]", config); } diff --git a/apps/cli/src/config/toml/tables.ts b/apps/cli/src/config/toml/tables.ts index 63f8ba0d..1fa1c5c6 100644 --- a/apps/cli/src/config/toml/tables.ts +++ b/apps/cli/src/config/toml/tables.ts @@ -1,8 +1,6 @@ -import type { TomlPrimitive } from "smol-toml"; +import type { TomlTable, TomlValue } from "smol-toml"; import { UnknownConfigKeyError } from "../errors.js"; -export type TomlTable = { [key: string]: TomlPrimitive }; - /** * Fails on any key of a table that its parser doesn't read, naming the keys the table accepts. * @param where where the table is, e.g. "in [machine]" or "at the top level" @@ -21,7 +19,7 @@ export const assertKnownKeys = ( /** * Checks if a value is a plain object (TOML table) */ -export function isTomlTable(value: TomlPrimitive): value is TomlTable { +export function isTomlTable(value: TomlValue): value is TomlTable { return ( typeof value === "object" && value !== null && diff --git a/apps/cli/src/config/toml/values.ts b/apps/cli/src/config/toml/values.ts index 1389b614..101444a8 100644 --- a/apps/cli/src/config/toml/values.ts +++ b/apps/cli/src/config/toml/values.ts @@ -1,4 +1,4 @@ -import type { TomlPrimitive } from "smol-toml"; +import type { TomlValue } from "smol-toml"; import { type Address, getAddress, isAddress } from "viem"; import { InvalidAddressValueError, @@ -13,7 +13,7 @@ import { import { isTomlTable } from "./tables.js"; export const parseBoolean = ( - value: TomlPrimitive, + value: TomlValue, defaultValue: boolean, ): boolean => { if (value === undefined) { @@ -25,9 +25,7 @@ export const parseBoolean = ( throw new InvalidBooleanValueError(value); }; -export const parseOptionalBoolean = ( - value: TomlPrimitive, -): boolean | undefined => { +export const parseOptionalBoolean = (value: TomlValue): boolean | undefined => { if (value === undefined) { return undefined; } @@ -37,10 +35,7 @@ export const parseOptionalBoolean = ( throw new InvalidBooleanValueError(value); }; -export const parseString = ( - value: TomlPrimitive, - defaultValue: string, -): string => { +export const parseString = (value: TomlValue, defaultValue: string): string => { if (value === undefined) { return defaultValue; } @@ -50,7 +45,7 @@ export const parseString = ( throw new InvalidStringValueError(value); }; -export const parseStringArray = (value: TomlPrimitive): string[] => { +export const parseStringArray = (value: TomlValue): string[] => { if (value === undefined) { return []; } @@ -73,9 +68,7 @@ export const parseStringArray = (value: TomlPrimitive): string[] => { * Non-string scalar values (number, bigint, boolean) are coerced to string, * since environment variable values are always strings. */ -export const parseStringRecord = ( - value: TomlPrimitive, -): Record => { +export const parseStringRecord = (value: TomlValue): Record => { if (value === undefined) { return {}; } @@ -101,10 +94,7 @@ export const parseStringRecord = ( ); }; -export const parseRequiredString = ( - value: TomlPrimitive, - key: string, -): string => { +export const parseRequiredString = (value: TomlValue, key: string): string => { if (value === undefined) { throw new RequiredFieldError(key); } @@ -115,7 +105,7 @@ export const parseRequiredString = ( }; export const parseRequiredAddress = ( - value: TomlPrimitive, + value: TomlValue, key: string, ): Address => { if (value === undefined) { @@ -127,9 +117,7 @@ export const parseRequiredAddress = ( throw new InvalidAddressValueError(value, key); }; -export const parseOptionalString = ( - value: TomlPrimitive, -): string | undefined => { +export const parseOptionalString = (value: TomlValue): string | undefined => { if (value === undefined) { return undefined; } @@ -140,7 +128,7 @@ export const parseOptionalString = ( }; export const parseOptionalStringBoolean = ( - value: TomlPrimitive, + value: TomlValue, ): string | boolean | undefined => { if (value === undefined) { return undefined; @@ -154,9 +142,7 @@ export const parseOptionalStringBoolean = ( throw new InvalidStringValueError(value); }; -export const parseOptionalNumber = ( - value: TomlPrimitive, -): bigint | undefined => { +export const parseOptionalNumber = (value: TomlValue): bigint | undefined => { if (value === undefined) { return undefined; } @@ -200,7 +186,7 @@ const IEC_MULTIPLIERS: Readonly> = { * petabytes. Sizes are numbers, so the ones beyond 2^53 bytes, which they can't hold exactly, * are rejected. */ -export const parseSize = (value: TomlPrimitive): number | undefined => { +export const parseSize = (value: TomlValue): number | undefined => { if (value === undefined) { return undefined; } @@ -227,7 +213,5 @@ export const parseSize = (value: TomlPrimitive): number | undefined => { throw new InvalidBytesValueError(value); }; -export const parseBytes = ( - value: TomlPrimitive, - defaultValue: number, -): number => parseSize(value) ?? defaultValue; +export const parseBytes = (value: TomlValue, defaultValue: number): number => + parseSize(value) ?? defaultValue;