Skip to content

Commit 8e9b1c3

Browse files
authored
fix: Refactor config handler using zod4 (#45)
1 parent f80ff19 commit 8e9b1c3

File tree

3 files changed

+312
-408
lines changed

3 files changed

+312
-408
lines changed

src/commands/utils.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { type Command, Option } from "commander";
2121
import { diffWordsWithSpace } from "diff";
2222
import ora from "ora";
2323
import type z from "zod";
24-
import { ZodArray, ZodBoolean, ZodDefault } from "zod";
24+
import { ZodArray, ZodBoolean, ZodDefault, ZodOptional } from "zod";
2525
import type { ContextStrategy, Exemplar, TokenUsage } from "../ai/index.js";
2626
import type {
2727
CheckCategory,
@@ -402,33 +402,30 @@ export function printTokenUsage(usage: TokenUsage) {
402402
return `Usage: Input ${usage.inputTokens}${cacheUsage} | Output ${usage.outputTokens} | Total ${usage.totalTokens} | Estimated ${usage.estimatedTokens}`;
403403
}
404404

405-
export function optionForConfigSchema(
406-
command: Command,
407-
// biome-ignore lint/suspicious/noExplicitAny: Better handling of zod?
408-
schema: z.ZodDefault<any> | z.ZodOptional<any>,
409-
) {
410-
const flags = camelToOptionFlag(schema.cli);
405+
export function optionForConfigSchema(command: Command, schema: z.ZodType) {
406+
const meta = schema.meta() as z.GlobalMeta | undefined;
407+
const cli = meta?.cli ?? "";
408+
const flags = camelToOptionFlag(cli);
409+
const inner = unwrapSchema(schema);
411410

412411
let flagString = `${flags} <value>`;
413412

414-
if (schema._zod.def.innerType instanceof ZodBoolean) {
413+
if (inner instanceof ZodBoolean) {
415414
flagString = flags;
416415
}
417416

418417
const option = new Option(flagString, optionDescriptionFromSchema(schema));
419418

420-
if (schema._zod.def.innerType instanceof ZodArray) {
419+
if (inner instanceof ZodArray) {
421420
option.argParser(collect);
422421
}
423422

424423
return command.addOption(option);
425424
}
426425

427-
export function optionDescriptionFromSchema(
428-
// biome-ignore lint/suspicious/noExplicitAny: Better way?
429-
schema: z.ZodDefault<any> | z.ZodOptional<any>,
430-
) {
431-
const env = schema.envPrefix ? `${schema.envPrefix}_*` : schema.env;
426+
export function optionDescriptionFromSchema(schema: z.ZodType) {
427+
const meta = schema.meta() as z.GlobalMeta | undefined;
428+
const env = meta?.envPrefix ? `${meta.envPrefix}_*` : meta?.env;
432429

433430
let defaultValue = "";
434431

@@ -441,6 +438,16 @@ export function optionDescriptionFromSchema(
441438
return `${schema.description} (${env}) ${defaultValue}`;
442439
}
443440

441+
function unwrapSchema(schema: z.ZodType): z.ZodType {
442+
if (schema instanceof ZodDefault) {
443+
return (schema as ZodDefault<any>)._zod.def.innerType as z.ZodType;
444+
}
445+
if (schema instanceof ZodOptional) {
446+
return (schema as any)._zod.def.innerType as z.ZodType;
447+
}
448+
return schema;
449+
}
450+
444451
/**
445452
* Converts a camel case string to a kebab case string with a "--" prefix
446453
*

src/config/manager.ts

Lines changed: 59 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,14 @@
1818
import fs from "node:fs";
1919
import os from "node:os";
2020
import path from "node:path";
21-
import { z } from "zod";
21+
import {
22+
ZodArray,
23+
ZodDefault,
24+
ZodError,
25+
ZodObject,
26+
ZodOptional,
27+
type z,
28+
} from "zod";
2229
import { type Config, configSchema } from "./schema.js";
2330

2431
const CONFIG_FILE_NAMES = [
@@ -27,15 +34,27 @@ const CONFIG_FILE_NAMES = [
2734
"toolkit-md.config.json",
2835
];
2936

37+
interface ConfigEntry {
38+
schema: z.ZodType;
39+
cli?: string;
40+
env?: string;
41+
envPrefix?: string;
42+
isArray: boolean;
43+
}
44+
3045
export class ConfigManager {
3146
private fileConfig: Partial<Config> = {};
3247
private configFilePath: string | null = null;
3348
private cliOptions: any = {};
49+
private entries: Map<string, ConfigEntry>;
3450

3551
constructor(
3652
private cwd: string,
3753
private schema = configSchema,
38-
) {}
54+
) {
55+
this.entries = new Map();
56+
this.buildEntries(this.schema, "");
57+
}
3958

4059
public getCwd() {
4160
return this.cwd;
@@ -50,10 +69,7 @@ export class ConfigManager {
5069
cliOptions: any = {},
5170
configPath?: string,
5271
): Promise<void> {
53-
// Store CLI options for later use
5472
this.cliOptions = cliOptions;
55-
56-
// Load configuration from file(s)
5773
await this.loadFromConfigFile(configPath);
5874
}
5975

@@ -71,25 +87,18 @@ export class ConfigManager {
7187
* @throws Error if validation fails
7288
*/
7389
public get<T>(path: string, defaultOverride?: T): T {
74-
// Find the schema node for this path
75-
const schemaNode = this.getSchemaNodeForPath(path);
90+
const entry = this.entries.get(path);
7691

77-
if (!schemaNode) {
92+
if (!entry) {
7893
throw new Error(`Config path ${path} is not valid`);
7994
}
8095

8196
let value: any;
8297

83-
// 1. Check CLI options (highest priority)
84-
if (schemaNode.cli && this.cliOptions[schemaNode.cli] !== undefined) {
85-
value = this.cliOptions[schemaNode.cli];
86-
}
87-
// 2. Check environment variables
88-
else if (schemaNode.env) {
89-
const envVars = Array.isArray(schemaNode.env)
90-
? schemaNode.env
91-
: [schemaNode.env];
92-
98+
if (entry.cli && this.cliOptions[entry.cli] !== undefined) {
99+
value = this.cliOptions[entry.cli];
100+
} else if (entry.env) {
101+
const envVars = Array.isArray(entry.env) ? entry.env : [entry.env];
93102
for (const envVar of envVars) {
94103
if (process.env[envVar] !== undefined) {
95104
value = process.env[envVar];
@@ -98,47 +107,33 @@ export class ConfigManager {
98107
}
99108
}
100109

101-
// Special handling for array values from environment variables with prefix
102-
if (
103-
value === undefined &&
104-
schemaNode._zod.def.innerType instanceof z.ZodArray &&
105-
schemaNode.envPrefix
106-
) {
110+
if (value === undefined && entry.isArray && entry.envPrefix) {
107111
const values: any[] = [];
108-
109-
// Collect all environment variables with the prefix
110112
for (const [key, val] of Object.entries(process.env)) {
111-
if (key.startsWith(schemaNode.envPrefix + ("_" as const)) && val) {
113+
if (key.startsWith(`${entry.envPrefix}_`) && val) {
112114
values.push(val);
113115
}
114116
}
115-
116-
// If we found any values, use them
117117
if (values.length > 0) {
118118
value = values;
119119
}
120120
}
121121

122-
// 3. Check config file
123122
if (value === undefined) {
124123
const fileValue = this.getNestedProperty<any>(this.fileConfig, path);
125124
if (fileValue !== undefined) {
126125
value = fileValue;
127126
}
128127
}
129128

130-
// 4. Use default value from schema or provided override
131129
if (value === undefined) {
132130
value = defaultOverride;
133131
}
134132

135-
// Validate and transform using Zod
136133
try {
137-
// Parse the value through the schema node
138-
const result = schemaNode.parse(value);
139-
return result as T;
134+
return entry.schema.parse(value) as T;
140135
} catch (error) {
141-
if (error instanceof z.ZodError) {
136+
if (error instanceof ZodError) {
142137
const errorMessages = error.issues.map((e) => e.message).join(", ");
143138
throw new Error(`Invalid configuration for ${path}: ${errorMessages}`);
144139
}
@@ -153,30 +148,37 @@ export class ConfigManager {
153148
return this.configFilePath;
154149
}
155150

156-
/**
157-
* Find the schema node for a given path
158-
*/
159-
private getSchemaNodeForPath(path: string): z.ZodDefault<any> | undefined {
160-
const keys = path.split(".");
161-
let current: any = this.schema;
162-
163-
for (const key of keys) {
164-
if (!current) return undefined;
165-
166-
// Handle object schemas
167-
if (current instanceof z.ZodObject) {
168-
const shape = current.shape;
169-
current = shape[key];
170-
}
171-
// Handle array schemas
172-
else if (current instanceof z.ZodArray && !Number.isNaN(key)) {
173-
current = current.element;
174-
} else {
175-
return undefined;
151+
private buildEntries(schema: z.ZodType, prefix: string): void {
152+
if (schema instanceof ZodObject) {
153+
for (const [key, value] of Object.entries(
154+
(schema as ZodObject<any>).shape,
155+
)) {
156+
const fullPath = prefix ? `${prefix}.${key}` : key;
157+
this.buildEntries(value as z.ZodType, fullPath);
176158
}
159+
return;
177160
}
178161

179-
return current;
162+
const meta = schema.meta() as z.GlobalMeta | undefined;
163+
const isArray = this.unwrapSchema(schema) instanceof ZodArray;
164+
165+
this.entries.set(prefix, {
166+
schema,
167+
cli: meta?.cli,
168+
env: meta?.env,
169+
envPrefix: meta?.envPrefix,
170+
isArray,
171+
});
172+
}
173+
174+
private unwrapSchema(schema: z.ZodType): z.ZodType {
175+
if (schema instanceof ZodDefault) {
176+
return (schema as ZodDefault<any>)._zod.def.innerType as z.ZodType;
177+
}
178+
if (schema instanceof ZodOptional) {
179+
return (schema as ZodOptional<any>)._zod.def.innerType as z.ZodType;
180+
}
181+
return schema;
180182
}
181183

182184
/**
@@ -187,16 +189,12 @@ export class ConfigManager {
187189
try {
188190
let configFilePath: string | null = null;
189191

190-
// 1. Try explicit path if provided
191192
if (explicitPath) {
192193
if (fs.existsSync(explicitPath)) {
193194
configFilePath = explicitPath;
194-
} else {
195-
// Silently continue to other config file locations
196195
}
197196
}
198197

199-
// 2. Try current directory
200198
if (!configFilePath) {
201199
for (const fileName of CONFIG_FILE_NAMES) {
202200
const filePath = path.join(this.cwd, fileName);
@@ -207,7 +205,6 @@ export class ConfigManager {
207205
}
208206
}
209207

210-
// 3. Try home directory
211208
if (!configFilePath) {
212209
for (const fileName of CONFIG_FILE_NAMES) {
213210
const filePath = path.join(os.homedir(), fileName);
@@ -218,13 +215,11 @@ export class ConfigManager {
218215
}
219216
}
220217

221-
// If we found a config file, load and parse it
222218
if (configFilePath) {
223219
const fileContent = await fs.promises.readFile(configFilePath, "utf8");
224220
this.fileConfig = JSON.parse(fileContent);
225221
this.configFilePath = configFilePath;
226222
} else {
227-
// No config file found, use empty object
228223
this.fileConfig = {};
229224
}
230225
} catch (error: any) {

0 commit comments

Comments
 (0)