Problem
core/config.js hand-rolls config normalization + validation (~50 lines): normalizeValue (trim), normalizeBoolean (parse true/1/yes/y/on), per-key alias resolution (nested mcp.host / flat mcpHost / snake mcp_host), port coercion, per-section defaults, and a validateConfig that returns a missing[] array. It's exactly the "reinvent the wheel" glue we keep growing — and it tempts over-abstraction (we recently added then reverted a normalizeServerSection helper because parameterizing two sections cost more than it saved).
Per project philosophy — prefer modern, popular, well-maintained, great-DX packages (the way got superseded request, or mtcute is a fresher take than traditional MTProto libs) over old "battle-tested but poor-DX" ones — we should replace this glue with a declarative schema lib.
Goal
Replace the ~50 lines of normalization + validation in core/config.js with a declarative schema. Keep the existing ~25-line loader/saver (file IO + resolveStoreDir() + ENOENT→null) — that part is already clean and framework-agnostic. This is a schema-validation problem, not a config-framework problem.
Recommended approach: zod (v4)
Live npm data (2026-06-02): zod v4.4.3, ~185M downloads/week, 0 runtime deps, MIT, actively released. Highest-fidelity match to all our needs and the ecosystem default.
Sketch:
- Aliases →
z.preprocess(raw => canonicalKeys(raw), schema) folds the mcp.host/mcpHost/mcp_host trio into canonical keys in one step (replaces normalizeConfig).
- Trim / defaults / ports →
z.string().trim(), .default('127.0.0.1') / .default(false), z.coerce.number().int().positive().
- Booleans → a small
z.preprocess/z.transform for the true/1/yes/on set. ⚠️ Do NOT use z.coerce.boolean() — it's plain truthiness so "false" → true (zod#3924).
- Errors →
schema.safeParse() + z.prettifyError(err) for clear multi-line messages (replaces the missing[] array with better UX).
- Plain-JS ESM works today; we just forgo static type inference (which we don't use anyway).
Runner-up: valibot (v1)
v1.4.1, ~11.8M/week, 0 deps, functional pipe API. Functionally equal; pick it only if we wanted its smaller footprint/style (bundle size is irrelevant for a Node CLI). Slightly more verbose for the nested alias-merge.
Why NOT a config framework
conf (sindresorhus), c12 (unjs), cosmiconfig, convict, node-config/rc solve the wrong axis — managed stores / multi-source layering / file discovery — for a tool that uses one JSON file at a known path with an env override. They'd add the file-location behavior we deliberately customized and still leave us hand-rolling alias mapping. Only revisit conf if we later want a managed, migrating, atomically-written store.
Acceptance criteria
Supply-chain note
zod is single-maintainer (colinhacks) but 0 runtime deps → minimal auditable surface, and ~185M weekly downloads = heavy scrutiny; pin the version. valibot similar (single maintainer, 0 deps, fewer eyes). conf would pull ~9 transitive deps (ajv chain). Figures fetched live from registry.npmjs.org / api.npmjs.org on 2026-06-02.
Scope
Standalone cleanup PR — not part of the #30 backfill work. The current control.* config (added in PR-2a) and mcp.* are the two sections to model.
Problem
core/config.jshand-rolls config normalization + validation (~50 lines):normalizeValue(trim),normalizeBoolean(parsetrue/1/yes/y/on), per-key alias resolution (nestedmcp.host/ flatmcpHost/ snakemcp_host), port coercion, per-section defaults, and avalidateConfigthat returns amissing[]array. It's exactly the "reinvent the wheel" glue we keep growing — and it tempts over-abstraction (we recently added then reverted anormalizeServerSectionhelper because parameterizing two sections cost more than it saved).Per project philosophy — prefer modern, popular, well-maintained, great-DX packages (the way
gotsupersededrequest, ormtcuteis a fresher take than traditional MTProto libs) over old "battle-tested but poor-DX" ones — we should replace this glue with a declarative schema lib.Goal
Replace the ~50 lines of normalization + validation in
core/config.jswith a declarative schema. Keep the existing ~25-line loader/saver (file IO +resolveStoreDir()+ENOENT→null) — that part is already clean and framework-agnostic. This is a schema-validation problem, not a config-framework problem.Recommended approach:
zod(v4)Live npm data (2026-06-02): zod v4.4.3, ~185M downloads/week, 0 runtime deps, MIT, actively released. Highest-fidelity match to all our needs and the ecosystem default.
Sketch:
z.preprocess(raw => canonicalKeys(raw), schema)folds themcp.host/mcpHost/mcp_hosttrio into canonical keys in one step (replacesnormalizeConfig).z.string().trim(),.default('127.0.0.1')/.default(false),z.coerce.number().int().positive().z.preprocess/z.transformfor thetrue/1/yes/onset.z.coerce.boolean()— it's plain truthiness so"false"→true(zod#3924).schema.safeParse()+z.prettifyError(err)for clear multi-line messages (replaces themissing[]array with better UX).Runner-up:
valibot(v1)v1.4.1, ~11.8M/week, 0 deps, functional pipe API. Functionally equal; pick it only if we wanted its smaller footprint/style (bundle size is irrelevant for a Node CLI). Slightly more verbose for the nested alias-merge.
Why NOT a config framework
conf(sindresorhus),c12(unjs),cosmiconfig,convict,node-config/rcsolve the wrong axis — managed stores / multi-source layering / file discovery — for a tool that uses one JSON file at a known path with an env override. They'd add the file-location behavior we deliberately customized and still leave us hand-rolling alias mapping. Only revisitconfif we later want a managed, migrating, atomically-written store.Acceptance criteria
normalizeConfig+validateConfigreimplemented declaratively via the chosen schema lib; behavior preserved (aliases, trim, boolean/port coercion, per-section defaults, requiredapiId/apiHash/phoneNumber).tests/config-control.test.js,tests/cli-auth.test.js, any mcp/control normalization tests) — normalized output byte-identical for the covered cases.apiId is required,control.port must be a positive integer).z.coerce.booleanfootgun avoided.Supply-chain note
zodis single-maintainer (colinhacks) but 0 runtime deps → minimal auditable surface, and ~185M weekly downloads = heavy scrutiny; pin the version.valibotsimilar (single maintainer, 0 deps, fewer eyes).confwould pull ~9 transitive deps (ajv chain). Figures fetched live from registry.npmjs.org / api.npmjs.org on 2026-06-02.Scope
Standalone cleanup PR — not part of the #30 backfill work. The current
control.*config (added in PR-2a) andmcp.*are the two sections to model.