Skip to content

Commit f5095a3

Browse files
committed
refactor(parsing): move parsing back down into router
1 parent 7164091 commit f5095a3

17 files changed

Lines changed: 135 additions & 168 deletions

File tree

src/globalConfig/accessor.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,18 @@ export function createGlobalConfigAccessor(
1919
let cachedConfig: GlobalConfig | undefined;
2020
const accessor: GlobalConfigAccessor = {
2121
get: async () => {
22-
config.logger.child({ method: "get" }).debug("reading global config");
23-
return (cachedConfig ??= await config.source.read());
22+
const logger = config.logger.child({ method: "get" });
23+
logger.debug("reading global config");
24+
if (cachedConfig) return cachedConfig;
25+
logger.debug("failed to find cache, reading from source");
26+
cachedConfig = await config.source.read();
27+
return cachedConfig;
2428
},
2529

2630
set: async (newConfig) => {
2731
const logger = config.logger.child({ newConfig, method: "set" });
2832
logger.debug("writing global config");
29-
3033
cachedConfig = await config.source.write(newConfig);
31-
3234
return cachedConfig;
3335
},
3436
};

src/globalConfig/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1-
export { type GlobalConfigAccessor, type GlobalConfig, globalConfigSchema } from "./types";
1+
export { type GlobalConfigAccessor, type GlobalConfig, type JsonDataSource } from "./types";
22
export { createGlobalConfigAccessor } from "./accessor";
33
export { createJsonFileDataSource } from "./source";
4+
export { globalConfigSchema } from "./schemas";

src/globalConfig/schemas.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import z from "zod";
2+
3+
/** Zod boolean schema that coerces string values (`"true"`, `"1"`, `"false"`, `"0"`) to booleans. */
4+
const coercedBoolean = z.preprocess((val) => {
5+
if (typeof val === "string") {
6+
if (val === "true" || val === "1") return true;
7+
if (val === "false" || val === "0") return false;
8+
}
9+
return val;
10+
}, z.boolean());
11+
12+
/** Creates a Zod object schema that coerces JSON strings into objects before validation. */
13+
const coerceObject = <TShape extends z.ZodRawShape>(shape: TShape) =>
14+
z.preprocess((val) => {
15+
if (typeof val === "string") {
16+
try {
17+
return JSON.parse(val);
18+
} catch {
19+
return val;
20+
}
21+
}
22+
return val;
23+
}, z.object(shape));
24+
25+
/**
26+
* Schema for the global config file. {@link GlobalConfigAccessor} is built from this definition.
27+
* Note: all fields should be optional, missing fields in global config should be overriden with reasonable defaults in the consuming code.
28+
*/
29+
export const globalConfigSchema = z.object({
30+
telemetry: coerceObject({
31+
enabled: coercedBoolean.optional(),
32+
endpoint: z.string().optional(),
33+
}).optional(),
34+
installationId: z.uuid().optional(),
35+
});

src/globalConfig/source.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,13 @@ import { readFile, writeFile, mkdir, rename, rm } from "fs/promises";
33
import { dirname } from "path";
44
import type { Logger } from "../logging";
55
import type { JsonDataSource } from "./types";
6-
import { formatZodError } from "../parsing";
76

8-
export interface JsonFileDataSourceConfig<TSchema> {
7+
export interface JsonFileDataSourceConfig<TSchema extends z.ZodObject> {
98
filePath: string;
109
/**
1110
* Describes the data to write to the file if it does not exist or is deleted.
1211
*/
13-
initialData: z.infer<TSchema>;
12+
getDefaultData(): z.infer<TSchema>;
1413
schema: TSchema;
1514
logger: Logger;
1615
}
@@ -58,7 +57,7 @@ export function createJsonFileDataSource<TSchema extends z.ZodObject>(
5857
// If the file doesn't exist, create one for them with the defaults.
5958
if (isNodeError(e) && e.code === "ENOENT") {
6059
logger.warn(`unable to find json file, creating default`);
61-
return source.write(config.initialData);
60+
return await source.write(config.getDefaultData());
6261
}
6362
const error = e instanceof Error ? e : new Error(String(e));
6463

@@ -81,7 +80,7 @@ export function createJsonFileDataSource<TSchema extends z.ZodObject>(
8180
})
8281
.error(`failed to validate data on write`);
8382
// TODO: swap to validation.
84-
throw new TypeError(formatZodError(parseDataResult.error));
83+
throw new TypeError(z.prettifyError(parseDataResult.error));
8584
}
8685

8786
const parsedData = parseDataResult.data;

src/globalConfig/types.tsx

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,5 @@
1-
import z from "zod";
2-
3-
const coeredBoolean = z.preprocess((val) => {
4-
if (typeof val === "string") {
5-
if (val === "true" || val === "1") return true;
6-
if (val === "false" || val === "0") return false;
7-
}
8-
return val;
9-
}, z.boolean());
10-
11-
const coerceObject = <TShape extends z.ZodRawShape>(shape: TShape) =>
12-
z.preprocess((val) => {
13-
if (typeof val === "string") {
14-
return JSON.parse(val);
15-
}
16-
return val;
17-
}, z.object(shape));
18-
/**
19-
* Schema for the global config file. {@link GlobalConfigAccessor} is built from this definition.
20-
* Note: all fields should be optional, missing fields in global config should be overriden with reasonable defaults in the consuming code.
21-
*/
22-
export const globalConfigSchema = z.object({
23-
telemetry: coerceObject({
24-
enabled: coeredBoolean.optional(),
25-
endpoint: z.string().optional(),
26-
}).optional(),
27-
installationId: z.uuid().optional(),
28-
});
1+
import type z from "zod";
2+
import type { globalConfigSchema } from "./schemas";
293

304
/**
315
* Shape of the global config built from {@link globalConfigSchema}
@@ -34,13 +8,16 @@ export type GlobalConfig = z.infer<typeof globalConfigSchema>;
348

359
/**
3610
* Interface for setting and retrieving information from the global configuration for the CLI.
37-
* ex. telemetry settings.
11+
* ex. telemetry settings, endpoint overrides, etc.
3812
*/
3913
export interface GlobalConfigAccessor {
4014
get(): Promise<GlobalConfig>;
4115
set(newConfig: GlobalConfig): Promise<GlobalConfig>;
4216
}
4317

18+
/**
19+
* Generic interface for the source of a typed object
20+
*/
4421
export interface JsonDataSource<TShape> {
4522
read(): Promise<TShape>;
4623
write(data: TShape): Promise<TShape>;

src/handlers/config/config.test.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ describe("config", () => {
4141
source: createJsonFileDataSource({
4242
filePath: configPath,
4343
schema: globalConfigSchema,
44-
initialData: initialConfig,
44+
getDefaultData: () => initialConfig,
4545
logger,
4646
}),
4747
});
@@ -122,17 +122,17 @@ describe("config", () => {
122122
expect(JSON.parse(readEndpointOutput)).toBe("false");
123123
});
124124

125-
test("throws on a single bad field", async () => {
125+
test("throws if any field fails validation", async () => {
126126
await writeFile(
127127
configPath,
128128
JSON.stringify({
129129
telemetry: { enabled: "not-a-bool", endpoint: "https://good.com" },
130130
}),
131131
);
132-
await expect(async () => run(["telemetry.endpoint"])).toThrow("unable to read");
132+
expect(run(["telemetry.endpoint"])).rejects.toThrow("unable to read");
133133
});
134134

135-
test("forward compatible with unsupported fields", async () => {
135+
test("ignores unsupported fields in the config file", async () => {
136136
await writeFile(
137137
configPath,
138138
JSON.stringify({
@@ -160,5 +160,8 @@ describe("config", () => {
160160

161161
const readOutput = await run(["telemetry.endpoint"]);
162162
expect(JSON.parse(readOutput)).toBe(newEndpoint);
163+
164+
const installationIdReadOutput = await run(["installationId"]);
165+
expect(JSON.parse(installationIdReadOutput)).toBe(initialConfig.installationId);
163166
});
164167
});

src/handlers/config/handler.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import z from "zod";
2-
import { createHandler, argument } from "../../router";
3-
import { GlobalConfigKey } from "../../middleware";
2+
import { createHandler, argument, GlobalConfigAccessorKey } from "../../router";
43
import { JsonRendererKey } from "../../tui";
54
import { globalConfigSchema, type GlobalConfig } from "../../globalConfig";
65

@@ -24,7 +23,7 @@ export const createConfigHandler = () =>
2423
argument("value", "value to set for the key", z.string().optional()),
2524
],
2625
handle: async (ctx, _flags, args) => {
27-
const globalConfigAccessor = ctx.require(GlobalConfigKey);
26+
const globalConfigAccessor = ctx.require(GlobalConfigAccessorKey);
2827
const jsonRenderer = ctx.require(JsonRendererKey);
2928

3029
const globalConfig = await globalConfigAccessor.get();
@@ -47,20 +46,21 @@ export const createConfigHandler = () =>
4746
const updatedConfig = await globalConfigAccessor.set(rawUpdatedConfig);
4847
const updatedScopedConfig = getAtPath(updatedConfig, args.key);
4948

50-
if (updatedScopedConfig === undefined && !isValidPath(updatedConfig, args.key))
51-
throw new TypeError(`invalid key ${args.key} for global config`);
5249
jsonRenderer.renderJson(updatedScopedConfig);
5350
},
5451
});
5552

53+
/** Type guard that narrows `value` to a plain object record. */
5654
function isRecord(value: unknown): value is Record<string, unknown> {
5755
return typeof value === "object" && value !== null && !Array.isArray(value);
5856
}
5957

58+
/** Retrieves a nested value from `obj` using dot-notation (e.g. `"telemetry.enabled"`). */
6059
function getAtPath(obj: object, path: string): unknown {
6160
return path.split(".").reduce<unknown>((acc, key) => (isRecord(acc) ? acc[key] : undefined), obj);
6261
}
6362

63+
/** Returns a shallow-cloned object with `value` set at the given dot-notation `path`. */
6464
function setAtPath(
6565
obj: Record<string, unknown>,
6666
path: string,
@@ -80,6 +80,7 @@ function setAtPath(
8080
};
8181
}
8282

83+
/** Checks whether `path` corresponds to a key recognized by {@link globalConfigSchema}. */
8384
function isValidPath(config: GlobalConfig, path: string): boolean {
8485
const parseAttempt = globalConfigSchema.safeParse(setAtPath(config, path, "placeholder"));
8586
// if parse fails --> zod validated the key so it must be known.

src/handlers/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { DebugKey, EndpointKey, JsonKey, RegionKey } from "./keys.tsx";
44
import { createConfigHandler } from "./config/";
55
import { createProjectHandler } from "./project/index.ts";
66
import { renderTui } from "../tui";
7-
import { withRegion, withJsonRenderer, withLogging, withGlobalConfig } from "../middleware";
7+
import { withRegion, withJsonRenderer, withLogging, withGlobalConfigAccessor } from "../middleware";
88
import type { AppIO, Core } from "./types.tsx";
99
import type { Logger } from "../logging";
1010
import type { GlobalConfigAccessor } from "../globalConfig";
@@ -34,7 +34,7 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router
3434
root.use(withLogging({ logger }));
3535

3636
// Pin the global config accessor on the context for any handler that needs it.
37-
root.use(withGlobalConfig(config.globalConfigAccessor));
37+
root.use(withGlobalConfigAccessor(config.globalConfigAccessor));
3838

3939
// Install sub handlers
4040
root.handler(createHarnessHandler(core, io));

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ process.exit(
4242
source: createJsonFileDataSource({
4343
filePath: join(homedir(), ".agentcore", "config.json"),
4444
schema: globalConfigSchema,
45-
initialData: { installationId: crypto.randomUUID() },
45+
getDefaultData: () => ({ installationId: crypto.randomUUID() }),
4646
logger: rootLogger.child({ module: "jsonDataSource" }),
4747
}),
4848
});

src/middleware/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ export { withRegion } from "./withRegion";
22
export { withTuiOnEmptyFlagsAndArgs } from "./withTuiOnEmptyFlagsAndArgs";
33
export { withJsonRenderer } from "./withJsonRenderer";
44
export { withLogging } from "./withLogging";
5-
export { withGlobalConfig, GlobalConfigKey } from "./withGlobalConfig";
5+
export { withGlobalConfigAccessor } from "./withGlobalConfigAccessor";

0 commit comments

Comments
 (0)