From b2980041a55c3454c11bb7c765526cf689bc34be Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Thu, 23 Jul 2026 22:10:28 +0000 Subject: [PATCH 1/3] feat(globalConfig): implement config command through new globalConfig module --- src/globalConfig/accessor.tsx | 129 +++++++++++++ src/globalConfig/config.tsx | 40 ++++ src/globalConfig/index.tsx | 4 + src/globalConfig/json.tsx | 72 ++++++++ src/globalConfig/types.tsx | 44 +++++ src/handlers/config/config.test.tsx | 194 +++++++++++++++++--- src/handlers/config/handler.tsx | 122 +++++++++++- src/handlers/harness/exec/exec.test.tsx | 7 +- src/handlers/harness/harness.test.tsx | 7 +- src/handlers/harness/invoke/invoke.test.tsx | 13 +- src/handlers/help.screen.test.tsx | 8 +- src/handlers/identity/identity.test.tsx | 10 +- src/handlers/index.tsx | 9 +- src/handlers/project/project.test.ts | 8 +- src/handlers/root.test.tsx | 3 +- src/handlers/runtime/runtime.test.tsx | 4 + src/index.ts | 14 +- src/middleware/index.tsx | 1 + src/middleware/withGlobalConfigAccessor.tsx | 20 ++ src/middleware/withRegion.test.tsx | 8 +- src/router/index.tsx | 1 + src/router/router.tsx | 3 + src/testing/globalConfig.tsx | 29 +++ src/testing/index.tsx | 1 + src/testing/renderScreen.tsx | 8 +- src/tui/tui.test.tsx | 12 +- 26 files changed, 723 insertions(+), 48 deletions(-) create mode 100644 src/globalConfig/accessor.tsx create mode 100644 src/globalConfig/config.tsx create mode 100644 src/globalConfig/index.tsx create mode 100644 src/globalConfig/json.tsx create mode 100644 src/globalConfig/types.tsx create mode 100644 src/middleware/withGlobalConfigAccessor.tsx create mode 100644 src/testing/globalConfig.tsx diff --git a/src/globalConfig/accessor.tsx b/src/globalConfig/accessor.tsx new file mode 100644 index 000000000..fbf5ff70e --- /dev/null +++ b/src/globalConfig/accessor.tsx @@ -0,0 +1,129 @@ +import { + type DeepPartial, + type GlobalConfig, + type GlobalConfigAccessor, + type GlobalConfigFileData, + type ReadWriteJson, +} from "./types"; +import type { Logger } from "../logging"; +import z from "zod"; +import { globalConfigFileSchema } from "./types"; +import { getDefaultGlobalConfig, resolveConfig } from "./config"; + +type DefaultGlobalConfigAccessorConfig = { + logger: Logger; + json: ReadWriteJson; + filePath: string; +}; + +/** + * Implements {@link GlobalConfigAccessor} accepting overrides from the given file. + * + * @param config - The logger and json datasource to be used by the config. . + * @returns A {@link GlobalConfigAccessor} instance. + */ +export class DefaultGlobalConfigAccessor implements GlobalConfigAccessor { + private cachedConfig: GlobalConfig | undefined; + private readonly json: ReadWriteJson; + private readonly filePath: string; + private readonly logger: Logger; + + constructor(config: DefaultGlobalConfigAccessorConfig) { + this.json = config.json; + this.filePath = config.filePath; + this.logger = config.logger.child({ configFilePath: this.filePath }); + this.logger.info(`creating global config accessor`); + } + + public async get(): Promise { + const logger = this.logger.child({ method: "get" }); + logger.debug("reading global config"); + + if (this.cachedConfig) return this.cachedConfig; + logger.debug("no config cached, reading from source"); + + const configFileData = await this.readConfigFile(); + + // if no installationId is present, generate one and merge it into the file data + if (!configFileData.installationId) { + configFileData.installationId = getDefaultGlobalConfig().installationId; + this.logger.info(`no installationId found, persisting one`); + + try { + await this.writeToConfigFile(configFileData); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + this.logger + .child({ errorName: error.name, errorMessage: error.message }) + .warn(`failed to write initial config file data`); + // best effort + } + } + + this.cachedConfig = resolveConfig(configFileData); + return this.cachedConfig; + } + + public async set(newConfig: GlobalConfig): Promise { + this.logger.child({ newConfig, method: "set" }).debug("writing global config"); + + const configDiff = diff(newConfig, resolveConfig({})); + await this.writeToConfigFile(configDiff); + this.cachedConfig = newConfig; + return this.cachedConfig; + } + + private async writeToConfigFile(data: GlobalConfigFileData): Promise { + const dataParseResult = globalConfigFileSchema.safeParse(data); + if (!dataParseResult.success) { + // TODO: mark this as a client-source error. + throw new TypeError(z.prettifyError(dataParseResult.error)); + } + + await this.json.write(this.filePath, dataParseResult.data); + return data; + } + + private async readConfigFile(): Promise { + try { + return await this.json.read(this.filePath, globalConfigFileSchema); + } catch (e) { + if (isFileNotFoundError(e)) return {}; + + const error = e instanceof Error ? e : new Error(String(e)); + this.logger + .child({ errorName: error.name, errorMessage: error.message }) + .warn(`failed to read global config file`); + throw e; + } + } +} + +function isFileNotFoundError(e: unknown): boolean { + return e instanceof Error && "code" in e && e.code === "ENOENT"; +} + +/** Recursively diffs two objects, comparing leaf values by reference equality. Returns only the fields in a that differ from b */ +function diff>(a: T, b: T): DeepPartial { + const result: Record = {}; + + for (const key of Object.keys(a)) { + const aVal = a[key]; + const bVal = b[key]; + + if (isRecord(aVal) && isRecord(bVal)) { + const nested = diff(aVal, bVal); + if (Object.keys(nested).length > 0) { + result[key] = nested; + } + } else if (aVal !== bVal) { + result[key] = aVal; + } + } + + return result as DeepPartial; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/globalConfig/config.tsx b/src/globalConfig/config.tsx new file mode 100644 index 000000000..662083957 --- /dev/null +++ b/src/globalConfig/config.tsx @@ -0,0 +1,40 @@ +import type { GlobalConfig, GlobalConfigFileData } from "./types"; +/** + * Default values for the global config. Includes a unique installationId for each process. + */ +export const getDefaultGlobalConfig = once(() => ({ + telemetry: { + enabled: true, + audit: false, + endpoint: "https://telemetry.agentcore.aws.dev", + }, + installationId: crypto.randomUUID(), +})); + +/** + * Applies the given overrides from given {@link GlobalConfigFileData} to {@link getDefaultGlobalConfig} and returns the merged result + */ +export function resolveConfig(globalConfigFile: GlobalConfigFileData): GlobalConfig { + const defaults = getDefaultGlobalConfig(); + return { + telemetry: { + enabled: globalConfigFile.telemetry?.enabled ?? defaults.telemetry.enabled, + audit: globalConfigFile.telemetry?.audit ?? defaults.telemetry.audit, + endpoint: globalConfigFile.telemetry?.endpoint ?? defaults.telemetry.endpoint, + }, + installationId: globalConfigFile.installationId ?? defaults.installationId, + }; +} + +/** Wraps a zero-arg factory so it executes at most once; subsequent calls return the cached result. */ +function once(fn: () => T): () => T { + let value: T; + let called = false; + return () => { + if (!called) { + value = fn(); + called = true; + } + return value; + }; +} diff --git a/src/globalConfig/index.tsx b/src/globalConfig/index.tsx new file mode 100644 index 000000000..26b09819a --- /dev/null +++ b/src/globalConfig/index.tsx @@ -0,0 +1,4 @@ +export { type GlobalConfigAccessor, type GlobalConfig, type ReadWriteJson } from "./types"; +export { DefaultGlobalConfigAccessor } from "./accessor"; +export { FsReadWriteJson } from "./json"; +export { getDefaultGlobalConfig } from "./config"; diff --git a/src/globalConfig/json.tsx b/src/globalConfig/json.tsx new file mode 100644 index 000000000..36db9527d --- /dev/null +++ b/src/globalConfig/json.tsx @@ -0,0 +1,72 @@ +import z from "zod"; +import { mkdir, readFile, writeFile } from "fs/promises"; +import type { Logger } from "../logging"; +import type { ReadWriteJson } from "./types"; +import { dirname } from "path"; + +type ReadWriteJsonConfig = { + logger: Logger; +}; + +// TODO: attach telemetry metadata to this error class. +class DeserializationError extends Error { + constructor(path: string, options?: { cause?: unknown }) { + super(`Failed to deserialize JSON at "${path}"`, options); + this.name = "DeserializationError"; + } +} + +/** + * Implements {@link ReadWriteJson} through node fs. + * + * @param config - logger + */ +export class FsReadWriteJson implements ReadWriteJson { + private readonly logger: Logger; + + constructor(config: ReadWriteJsonConfig) { + this.logger = config.logger; + } + + private async readJsonFile(filePath: string): Promise { + const raw = await readFile(filePath, "utf8"); + + try { + return JSON.parse(raw); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + this.logger + .child({ filePath, errorName: error.name, errorMessage: error.message }) + .error(`failed to parse json file`); + throw new DeserializationError(filePath, { cause: e }); + } + } + + public async read( + filePath: string, + schema: TSchema, + ): Promise> { + const data = await this.readJsonFile(filePath); + const parseResult = schema.safeParse(data); + + if (!parseResult.success) { + this.logger + .child({ + filePath, + errorName: parseResult.error.name, + errorMessage: parseResult.error.message, + }) + .error(`failed to validate parsed json file`); + throw new DeserializationError(filePath, { cause: parseResult.error }); + } + + return parseResult.data; + } + + public async write(filePath: string, data: TData): Promise { + const contents = JSON.stringify(data, undefined, 2); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, contents); + return data; + } +} diff --git a/src/globalConfig/types.tsx b/src/globalConfig/types.tsx new file mode 100644 index 000000000..62000a1d6 --- /dev/null +++ b/src/globalConfig/types.tsx @@ -0,0 +1,44 @@ +import z from "zod"; + +/** Recursively marks all fields of a given shape as required */ +export type DeepRequired = { + [P in keyof T]-?: DeepRequired; +}; + +/** Recursively marks all fields of a given shape as optional */ +export type DeepPartial = { [P in keyof T]?: DeepPartial }; + +/** + * Schema for the global config file. All fields should be optional with defaults defined. + */ +export const globalConfigFileSchema = z.object({ + telemetry: z + .object({ + enabled: z.boolean().optional(), + endpoint: z.string().optional(), + audit: z.boolean().optional(), + }) + .optional(), + installationId: z.uuid().optional(), +}); + +/** The raw shape stored on disk for overriding defaults. */ +export type GlobalConfigFileData = z.infer; + +/** The fully resolved config after applying defaults — all fields required. */ +export type GlobalConfig = DeepRequired; + +/** Manages access to a set of configuration values for the CLI */ +export interface GlobalConfigAccessor { + /** Returns the current global config, with defaults applied. */ + get(): Promise; + /** Validates and persists a new config. Throws on invalid shape. */ + set(newConfig: GlobalConfig): Promise; +} + +export interface ReadWriteJson { + /** Reads data from the given file */ + read(filePath: string, schema: TSchema): Promise>; + /** Writes data to the given file */ + write(filePath: string, data: TData): Promise; +} diff --git a/src/handlers/config/config.test.tsx b/src/handlers/config/config.test.tsx index bdb79908c..9ccdaf588 100644 --- a/src/handlers/config/config.test.tsx +++ b/src/handlers/config/config.test.tsx @@ -1,36 +1,186 @@ -import { test, expect, describe } from "bun:test"; +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { createRootHandler } from "../index"; import { createSilentLogger, TestCoreClient, testIO } from "../../testing"; +import { DefaultGlobalConfigAccessor, FsReadWriteJson } from "../../globalConfig"; -// End-to-end tests for the `config` command, driven through the real root -// handler and top-level route(). A TestCoreClient stands in for Core (config -// doesn't touch it, but createRootHandler requires one); an in-memory io -// captures the output. +describe("config", () => { + let tempDir: string; + let configPath: string; + + const validConfigOverrides = { + telemetry: { enabled: true, endpoint: "https://example.com" }, + installationId: "550e8400-e29b-41d4-a716-446655440000", + }; -async function run(args: string[]): Promise { - const io = testIO(); - const root = createRootHandler(new TestCoreClient(), { - io: io.io, - logger: createSilentLogger(), + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "agentcore-config-test-")); + configPath = join(tempDir, "config.json"); + await writeFile(configPath, JSON.stringify(validConfigOverrides)); }); - await root.route(["node", "agentcore", "config", ...args]); - return io.stdout(); -} -describe("config", () => { - test("prints the key and value it was given", async () => { - expect(await run(["telemetry.enabled", "true"])).toBe( - "updating config key=telemetry.enabled, value=true", + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + async function run(args: string[]): Promise { + const io = testIO(); + const logger = createSilentLogger(); + const globalConfigAccessor = new DefaultGlobalConfigAccessor({ + logger, + filePath: configPath, + json: new FsReadWriteJson({ + logger, + }), + }); + const root = createRootHandler(new TestCoreClient(), { + io: io.io, + logger, + globalConfigAccessor, + }); + await root.route(["node", "agentcore", "config", ...args]); + return io.stdout(); + } + + test("prints the full config when no args are passed", async () => { + const output = await run([]); + expect(JSON.parse(output)).toMatchObject(validConfigOverrides); + }); + + test("prints the value at a key when only a key is passed", async () => { + const output = await run(["telemetry.enabled"]); + expect(JSON.parse(output)).toBe(true); + }); + + test("prints a nested object when a branch key is passed", async () => { + const output = await run(["telemetry"]); + expect(JSON.parse(output)).toMatchObject(validConfigOverrides.telemetry); + }); + + test("sets a value when key and value are passed", async () => { + const initialReadValueOutput = await run(["telemetry.endpoint"]); + expect(JSON.parse(initialReadValueOutput)).toBe(validConfigOverrides.telemetry.endpoint); + + const newEndpoint = "http://example2.com"; + + const writeValueOutput = await run(["telemetry.endpoint", newEndpoint]); + expect(JSON.parse(writeValueOutput)).toBe(newEndpoint); + + const readValueOutput = await run(["telemetry.endpoint"]); + expect(JSON.parse(readValueOutput)).toBe(newEndpoint); + }); + + test("accepts json values for non-leaf nodes", async () => { + const initialTelemetrySettingsOutput = await run(["telemetry"]); + const initialTelemetrySettings = JSON.parse(initialTelemetrySettingsOutput); + + // flip telemetry.enabled via json notation input + const newTelemetrySettings = { + ...initialTelemetrySettings, + enabled: !initialTelemetrySettings.enabled, + }; + + const newTelemetrySettingsOutput = await run([ + "telemetry", + JSON.stringify(newTelemetrySettings), + ]); + expect(JSON.parse(newTelemetrySettingsOutput)).toMatchObject(newTelemetrySettings); + + const finalTelemetrySettingsOutput = await run(["telemetry"]); + expect(JSON.parse(finalTelemetrySettingsOutput)).toMatchObject(newTelemetrySettings); + }); + + test("throws on invalid key", async () => { + // TODO: swap to validation error. + await expect(run(["nonexistent.key"])).rejects.toThrow(TypeError); + }); + + test("throws on invalid value for key", async () => { + // TODO: swap to validation error + await expect(run(["telemetry.enabled", "banana"])).rejects.toThrow(TypeError); + }); + + test("coerces values based on schema", async () => { + const setEnabledOutput = await run(["telemetry.enabled", "false"]); + expect(JSON.parse(setEnabledOutput)).toBe(false); + const readEnabledOutput = await run(["telemetry.enabled"]); + expect(JSON.parse(readEnabledOutput)).toBe(false); + + const setEndpointOutput = await run(["telemetry.endpoint", "false"]); + expect(JSON.parse(setEndpointOutput)).toBe("false"); + const readEndpointOutput = await run(["telemetry.endpoint"]); + expect(JSON.parse(readEndpointOutput)).toBe("false"); + }); + + test("throws if any field fails validation", async () => { + await writeFile( + configPath, + JSON.stringify({ + telemetry: { enabled: "not-a-bool", endpoint: "https://good.com" }, + }), ); + await expect(run(["telemetry.endpoint"])).rejects.toThrow("Failed to deserialize"); }); - test("value is undefined when only a key is passed", async () => { - expect(await run(["telemetry.enabled"])).toBe( - "updating config key=telemetry.enabled, value=undefined", + test("ignores unsupported fields in the config file", async () => { + await writeFile( + configPath, + JSON.stringify({ + ...validConfigOverrides, + futureField: "unknown", + telemetry: { ...validConfigOverrides.telemetry, futureNested: 42 }, + }), ); + const output = await run([]); + expect(JSON.parse(output)).toMatchObject(validConfigOverrides); + }); + + test("throws clear error on invalid json", async () => { + await writeFile(configPath, "not { valid json"); + // TODO: assert on error type + await expect(run([])).rejects.toThrow("Failed to deserialize"); + }); + + test("creates an override config if it does not exist", async () => { + await rm(configPath); + const newEndpoint = "http://new-endpoint.command"; + + const output = await run(["telemetry.endpoint", newEndpoint]); + expect(JSON.parse(output)).toBe(newEndpoint); + + const readOutput = await run(["telemetry.endpoint"]); + expect(JSON.parse(readOutput)).toBe(newEndpoint); + }); + + test("writes installationId when config missing, preserves it when present", async () => { + await rm(configPath); + const firstOutput = await run(["installationId"]); + const firstId = JSON.parse(firstOutput); + expect(firstId).toMatch(/^[0-9a-f-]{36}$/); + + const secondOutput = await run(["installationId"]); + expect(JSON.parse(secondOutput)).toBe(firstId); }); - test("both key and value are undefined when none are passed", async () => { - expect(await run([])).toBe("updating config key=undefined, value=undefined"); + test("writes installationId when config exists but installationId is missing", async () => { + await writeFile(configPath, JSON.stringify({ telemetry: { enabled: true } })); + + const firstOutput = await run(["installationId"]); + const firstId = JSON.parse(firstOutput); + + const secondOutput = await run(["installationId"]); + expect(JSON.parse(secondOutput)).toBe(firstId); + }); + + test("creates the config directory if it does not exist", async () => { + await rm(tempDir, { recursive: true, force: true }); + + const output = await run(["telemetry.enabled", "false"]); + expect(JSON.parse(output)).toBe(false); + + const readOutput = await run(["telemetry.enabled"]); + expect(JSON.parse(readOutput)).toBe(false); }); }); diff --git a/src/handlers/config/handler.tsx b/src/handlers/config/handler.tsx index 3cdbeaebb..bb3c47171 100644 --- a/src/handlers/config/handler.tsx +++ b/src/handlers/config/handler.tsx @@ -1,15 +1,16 @@ import z from "zod"; -import { createHandler, argument } from "../../router"; -import type { AppIO } from "../types"; +import { createHandler, argument, GlobalConfigAccessorKey } from "../../router"; +import { JsonRendererKey } from "../../tui"; +import { getDefaultGlobalConfig, type GlobalConfig } from "../../globalConfig"; /* - * read/write global configuration values. ex. telemetry settings, log level, etc. + * read/write global configuration values. ex. telemetry settings, endpoint overrides, etc. * Ex. * `config [key] [value]` sets key to value. * `config [key]` prints the value with key. * `config` prints the full config. */ -export const createConfigHandler = (io: AppIO) => +export const createConfigHandler = () => createHandler({ name: "config", description: "read/write global config values", @@ -17,12 +18,117 @@ export const createConfigHandler = (io: AppIO) => argument( "key", "config key in JSON path notation (e.g. telemetry.enabled)", - z.string().optional(), + z.enum(getKeys(getDefaultGlobalConfig())).optional(), ), argument("value", "value to set for the key", z.string().optional()), ], - handle: async (_ctx, _flags, args) => { - // TODO: implment a real handler that update the global config via injected config accessor. - io.stdout.write(`updating config key=${args.key}, value=${args.value}\n`); + handle: async (ctx, _flags, args) => { + const globalConfigAccessor = ctx.require(GlobalConfigAccessorKey); + const jsonRenderer = ctx.require(JsonRendererKey); + + const globalConfig = await globalConfigAccessor.get(); + // print entire config when key is missing. + if (!args.key) { + jsonRenderer.renderJson(globalConfig); + return; + } + + // print entire value at key when value is missing. + if (!args.value) { + const scopedConfig = getAtPath(globalConfig, args.key); + jsonRenderer.renderJson(scopedConfig); + return; + } + const coercedValue = coerceValue( + getAtPath(getDefaultGlobalConfig(), args.key), + args.value, + args.key, + ); + + // update value at key with given value. + await globalConfigAccessor.set( + setAtPath(globalConfig, args.key, coercedValue) as GlobalConfig, + ); + + jsonRenderer.renderJson(coercedValue); }, }); + +function coerceValue(current: unknown, raw: string, path: string): unknown { + switch (typeof current) { + case "string": + return raw; + + case "boolean": { + const normalized = raw.trim().toLowerCase(); + if (normalized === "true") return true; + if (normalized === "false") return false; + // TODO: mark as validation error. + throw new TypeError(`Cannot coerce "${raw}" to boolean at "${path}"`); + } + + case "number": { + const trimmed = raw.trim(); + const n = Number(trimmed); + if (trimmed === "" || Number.isNaN(n)) { + // TODO: mark as validation error. + throw new TypeError(`Cannot coerce "${raw}" to number at "${path}"`); + } + return n; + } + + case "object": { + try { + return JSON.parse(raw); + } catch (e) { + // TODO: mark as validation error. + + throw new TypeError(`Cannot coerce "${raw}" to object at "${path}"`, { cause: e }); + } + } + + default: + // TODO: mark as validation error. + throw new TypeError(`Unsupported target type "${typeof current}" at "${path}"`); + } +} +/** Type guard that narrows `value` to a plain object record. */ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Retrieves a nested value from `obj` using dot-notation (e.g. `"telemetry.enabled"`). */ +function getAtPath(obj: object, path: string): unknown { + return path.split(".").reduce((acc, key) => (isRecord(acc) ? acc[key] : undefined), obj); +} + +/** Returns a shallow-cloned object with `value` set at the given dot-notation `path`. */ +function setAtPath( + obj: Record, + path: string, + value: unknown, +): Record { + const keys = path.split("."); + if (keys.length === 0) return obj; + const head = keys[0]!; + if (keys.length === 1) return { ...obj, [head]: value }; + return { + ...obj, + [head]: setAtPath( + isRecord(obj[head]) ? (obj[head] as Record) : {}, + keys.slice(1).join("."), + value, + ), + }; +} + +function getKeys(obj: Record, prefix = ""): string[] { + return Object.keys(obj).reduce((acc, key) => { + const fullPath = prefix ? `${prefix}.${key}` : key; + const value = obj[key]; + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return [...acc, fullPath, ...getKeys(value as Record, fullPath)]; + } + return [...acc, fullPath]; + }, []); +} diff --git a/src/handlers/harness/exec/exec.test.tsx b/src/handlers/harness/exec/exec.test.tsx index bb548a98d..96a56d4f0 100644 --- a/src/handlers/harness/exec/exec.test.tsx +++ b/src/handlers/harness/exec/exec.test.tsx @@ -6,6 +6,7 @@ import type { import type { GetHarnessResponse } from "@aws-sdk/client-bedrock-agentcore-control"; import { createRootHandler } from "../../index"; import { createSilentLogger, TestCoreClient, testIO } from "../../../testing"; +import { TestGlobalConfigAccessor } from "../../../testing/"; // Command-flow tests for `harness exec`, driven through the real root handler. // Like the invoke suite, these use a TestCoreClient because the command @@ -31,7 +32,11 @@ async function run(args: string[], configure?: (core: TestCoreClient) => void) { core.harness.setExecEvents(...EXEC_EVENTS); configure?.(core); const io = testIO(); - const root = createRootHandler(core, { io: io.io, logger: createSilentLogger() }); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); await root.route(["node", "agentcore", ...args, "--region", "us-west-2"]); return { core, stdout: io.stdout() }; } diff --git a/src/handlers/harness/harness.test.tsx b/src/handlers/harness/harness.test.tsx index 7f705d84e..cc56448d2 100644 --- a/src/handlers/harness/harness.test.tsx +++ b/src/handlers/harness/harness.test.tsx @@ -7,6 +7,7 @@ import { fixtureFactories, isRecording, matchGolden, + TestGlobalConfigAccessor, testIO, } from "../../testing"; @@ -37,7 +38,11 @@ async function run(args: string[]): Promise { logger: createSilentLogger(), }); const io = testIO(); - const root = createRootHandler(core, { io: io.io, logger: createSilentLogger() }); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); await root.route(["node", "agentcore", ...args, "--region", REGION]); return io.stdout(); } diff --git a/src/handlers/harness/invoke/invoke.test.tsx b/src/handlers/harness/invoke/invoke.test.tsx index 25015719a..2ff072b4d 100644 --- a/src/handlers/harness/invoke/invoke.test.tsx +++ b/src/handlers/harness/invoke/invoke.test.tsx @@ -5,7 +5,12 @@ import type { } from "@aws-sdk/client-bedrock-agentcore"; import type { GetHarnessResponse } from "@aws-sdk/client-bedrock-agentcore-control"; import { createRootHandler } from "../../index"; -import { createSilentLogger, TestCoreClient, testIO } from "../../../testing"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; // Command-flow tests for `harness invoke`, driven through the real root handler // exactly as the CLI runs it. Unlike the get/list suites these use a @@ -41,7 +46,11 @@ async function run(args: string[], configure?: (core: TestCoreClient) => void) { core.harness.setInvokeEvents(...TURN_EVENTS); configure?.(core); const io = testIO(); - const root = createRootHandler(core, { io: io.io, logger: createSilentLogger() }); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); await root.route(["node", "agentcore", ...args, "--region", "us-west-2"]); return { core, stdout: io.stdout() }; } diff --git a/src/handlers/help.screen.test.tsx b/src/handlers/help.screen.test.tsx index 064c68adf..f2792e21b 100644 --- a/src/handlers/help.screen.test.tsx +++ b/src/handlers/help.screen.test.tsx @@ -4,7 +4,7 @@ import { render, cleanup } from "ink-testing-library"; import { ValueContext, compile, CommandKey } from "../router"; import { createRootHandler } from "./index"; import { HelpScreen } from "./screen"; -import { createSilentLogger, TestCoreClient, testIO } from "../testing"; +import { createSilentLogger, TestCoreClient, TestGlobalConfigAccessor, testIO } from "../testing"; afterEach(cleanup); @@ -16,7 +16,11 @@ afterEach(cleanup); describe("HelpScreen", () => { test("renders the command's help text", () => { const command = compile( - createRootHandler(new TestCoreClient(), { io: testIO().io, logger: createSilentLogger() }), + createRootHandler(new TestCoreClient(), { + io: testIO().io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }), ValueContext.EmptyContext(), ); const ctx = ValueContext.EmptyContext().withValue(CommandKey, command); diff --git a/src/handlers/identity/identity.test.tsx b/src/handlers/identity/identity.test.tsx index 2251ce286..02f75a680 100644 --- a/src/handlers/identity/identity.test.tsx +++ b/src/handlers/identity/identity.test.tsx @@ -1,7 +1,13 @@ import { describe, expect, test } from "bun:test"; import { join } from "node:path"; import { CoreClient } from "../../core"; -import { createSilentLogger, fixtureFactories, matchGolden, testIO } from "../../testing"; +import { + createSilentLogger, + fixtureFactories, + matchGolden, + TestGlobalConfigAccessor, + testIO, +} from "../../testing"; import { createRootHandler } from "../index"; const REGION = "us-west-2"; @@ -29,6 +35,7 @@ async function run(args: string[]): Promise { const root = createRootHandler(createFixtureCore(), { io: io.io, logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), }); await root.route(["node", "agentcore", ...args, "--region", REGION]); @@ -40,6 +47,7 @@ describe("identity command hierarchy", () => { const root = createRootHandler(createFixtureCore(), { io: testIO().io, logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), }); const identity = root.children().find((child) => child.name() === "identity"); diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index 047e2f619..b1b141427 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -6,13 +6,15 @@ import { DebugKey, EndpointKey, JsonKey, RegionKey } from "./keys.tsx"; import { createConfigHandler } from "./config/"; import { createProjectHandler } from "./project/index.ts"; import { renderTui } from "../tui"; -import { withRegion, withJsonRenderer, withLogging } from "../middleware"; +import { withRegion, withJsonRenderer, withLogging, withGlobalConfigAccessor } from "../middleware"; import type { AppIO, Core } from "./types.tsx"; import type { Logger } from "../logging"; +import type { GlobalConfigAccessor } from "../globalConfig"; export interface RootHandlerConfig { io: AppIO; logger: Logger; + globalConfigAccessor: GlobalConfigAccessor; } export function createRootHandler(core: Core, config: RootHandlerConfig): Router { @@ -33,11 +35,14 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router // Inject a logger into each handler. root.use(withLogging({ logger })); + // Pin the global config accessor on the context for any handler that needs it. + root.use(withGlobalConfigAccessor(config.globalConfigAccessor)); + // Install sub handlers root.handler(createHarnessHandler(core, io)); root.handler(createIdentityHandler(core, io)); root.handler(createRuntimeHandler(core, io)); - root.handler(createConfigHandler(io)); + root.handler(createConfigHandler()); root.handler(createProjectHandler({ projectManager: core.projectManager })); // Invoking with no subcommand launches the interactive TUI. diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 301853b56..be7115d78 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -1,11 +1,17 @@ import { test, expect, describe } from "bun:test"; import { createRootHandler } from "../index"; -import { createSilentLogger, TestCoreClient, testIO } from "../../testing"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../testing"; async function run(args: string[]): Promise { const io = testIO(); const root = createRootHandler(new TestCoreClient(), { io: io.io, + globalConfigAccessor: new TestGlobalConfigAccessor(), logger: createSilentLogger(), }); await root.route(["node", "agentcore", "project", ...args]); diff --git a/src/handlers/root.test.tsx b/src/handlers/root.test.tsx index e217ef582..11f89a1cb 100644 --- a/src/handlers/root.test.tsx +++ b/src/handlers/root.test.tsx @@ -1,12 +1,13 @@ import { test, expect, describe } from "bun:test"; import { createRootHandler } from "./index"; -import { createSilentLogger, TestCoreClient, testIO } from "../testing"; +import { createSilentLogger, TestCoreClient, TestGlobalConfigAccessor, testIO } from "../testing"; describe("createRootHandler", () => { test("builds the agentcore command tree with its subcommands", () => { const root = createRootHandler(new TestCoreClient(), { io: testIO().io, logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), }); expect(root.name()).toBe("agentcore"); expect(root.children().map((c) => c.name())).toEqual([ diff --git a/src/handlers/runtime/runtime.test.tsx b/src/handlers/runtime/runtime.test.tsx index 987848006..5b2814dae 100644 --- a/src/handlers/runtime/runtime.test.tsx +++ b/src/handlers/runtime/runtime.test.tsx @@ -9,6 +9,7 @@ import { testIO, } from "../../testing"; import { createRootHandler } from "../index"; +import { TestGlobalConfigAccessor } from "../../testing/globalConfig"; const REGION = "us-west-2"; const FIXTURES = join(import.meta.dir, "__fixtures__"); @@ -36,6 +37,7 @@ async function run(args: string[]): Promise { const root = createRootHandler(createFixtureCore(), { io: io.io, logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), }); await root.route(["node", "agentcore", ...args, "--region", REGION]); @@ -48,6 +50,7 @@ function testRuntimeCommand() { const root = createRootHandler(core, { io: io.io, logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), }); return { @@ -61,6 +64,7 @@ describe("runtime command hierarchy", () => { const root = createRootHandler(createFixtureCore(), { io: testIO().io, logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), }); const runtime = root.children().find((child) => child.name() === "runtime"); diff --git a/src/index.ts b/src/index.ts index 2dd9108e3..b76e22fd2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { createControlClient, createDataClient, createIamClient } from "./core/f import { createRootHandler } from "./handlers"; import { createFileLogger, LOG_LEVEL } from "./logging"; import { runWithExitCode } from "./runnable"; +import { DefaultGlobalConfigAccessor, FsReadWriteJson } from "./globalConfig"; process.exit( await runWithExitCode(async (argv: string[]) => { @@ -21,7 +22,6 @@ process.exit( const rootLogger = createFileLogger({ filePath: join(homedir(), ".agentcore", "logs", "output"), - // TODO: allow overriding via global settings logLevel: LOG_LEVEL.DEBUG, bindings: { cliSessionId }, }); @@ -33,7 +33,16 @@ process.exit( }; try { - // Wrap the SDK clients in the CoreClient the handlers consume. Passing + const globalConfigAccessor = new DefaultGlobalConfigAccessor({ + logger: rootLogger.child({ module: "globalConfigAccessor" }), + filePath: join(homedir(), ".agentcore", "config.json"), + json: new FsReadWriteJson({ + logger: rootLogger.child({ module: "jsonDataSource" }), + }), + }); + + rootLogger.info(`running CLI`); + // factories (rather than instances) lets CoreClient build one client per // region on demand. const coreClient = new CoreClient({ @@ -49,6 +58,7 @@ process.exit( const rootHandler = createRootHandler(coreClient, { io, logger: rootLogger, + globalConfigAccessor, }); // Handle the request diff --git a/src/middleware/index.tsx b/src/middleware/index.tsx index e4f9a09f3..e77355898 100644 --- a/src/middleware/index.tsx +++ b/src/middleware/index.tsx @@ -2,3 +2,4 @@ export { withRegion } from "./withRegion"; export { withTuiOnEmptyFlagsAndArgs } from "./withTuiOnEmptyFlagsAndArgs"; export { withJsonRenderer } from "./withJsonRenderer"; export { withLogging } from "./withLogging"; +export { withGlobalConfigAccessor } from "./withGlobalConfigAccessor"; diff --git a/src/middleware/withGlobalConfigAccessor.tsx b/src/middleware/withGlobalConfigAccessor.tsx new file mode 100644 index 000000000..8acd2b056 --- /dev/null +++ b/src/middleware/withGlobalConfigAccessor.tsx @@ -0,0 +1,20 @@ +import { GlobalConfigAccessorKey, type Middleware } from "../router"; +import type { GlobalConfigAccessor } from "../globalConfig"; + +/** + * Middleware that pins a GlobalConfigAccessor on the context, making it + * available to every handler beneath the mount point via + * `ctx.require(GlobalConfigAccessorKey)`. + */ +export function withGlobalConfigAccessor(accessor: GlobalConfigAccessor): Middleware { + return (h) => ({ + name: () => h.name(), + description: () => h.description(), + flags: () => h.flags(), + arguments: () => h.arguments(), + children: () => h.children(), + handle: async (ctx, flags, args) => { + await h.handle(ctx.withValue(GlobalConfigAccessorKey, accessor), flags, args); + }, + }); +} diff --git a/src/middleware/withRegion.test.tsx b/src/middleware/withRegion.test.tsx index 93a15d8ac..1404f93ac 100644 --- a/src/middleware/withRegion.test.tsx +++ b/src/middleware/withRegion.test.tsx @@ -3,7 +3,7 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createRootHandler } from "../handlers"; -import { TestCoreClient, testIO } from "../testing"; +import { TestCoreClient, TestGlobalConfigAccessor, testIO } from "../testing"; import { createSilentLogger } from "../testing/"; // writeConfigFile writes an AWS shared-config file with the given contents to a @@ -24,7 +24,11 @@ function writeConfigFile(contents: string): string { // opening the TUI) and returns the region the handler passed to Core. async function resolvedRegion(args: string[]): Promise { const core = new TestCoreClient(); - const root = createRootHandler(core, { io: testIO().io, logger: createSilentLogger() }); + const root = createRootHandler(core, { + io: testIO().io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); await root.route(["node", "agentcore", "harness", "list", "--json", ...args]); const call = core.harness.calls.at(-1); const options = call?.args[2] as { region: string }; diff --git a/src/router/index.tsx b/src/router/index.tsx index fb8c31a88..3764b1020 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -4,6 +4,7 @@ export { CommandKey, PathKey, LoggerKey, + GlobalConfigAccessorKey, ProjectKey, type DefaultHandle, type DefaultHandlerProvider, diff --git a/src/router/router.tsx b/src/router/router.tsx index eab6778b2..5245b5f01 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -6,6 +6,7 @@ import { parseArguments, toCommanderArgument } from "./args"; import { Command } from "commander"; import type { Logger } from "../logging"; +import type { GlobalConfigAccessor } from "../globalConfig"; import type { Project } from "../handlers/project/types"; // CommandKey exposes the Commander Command for the executing leaf via context. @@ -15,6 +16,8 @@ export const PathKey: ContextKey = contextKey("path"); export const LoggerKey = contextKey("logger"); +export const GlobalConfigAccessorKey: ContextKey = + contextKey("globalConfigAccessor"); export const ProjectKey = contextKey("project"); // DefaultHandle runs when a group is selected without a subcommand (e.g. diff --git a/src/testing/globalConfig.tsx b/src/testing/globalConfig.tsx new file mode 100644 index 000000000..0fd9922a9 --- /dev/null +++ b/src/testing/globalConfig.tsx @@ -0,0 +1,29 @@ +import { + getDefaultGlobalConfig, + type GlobalConfig, + type GlobalConfigAccessor, +} from "../globalConfig"; + +type TestGlobalConfigAccessorOptions = { + initialConfigData?: GlobalConfig; +}; + +/** + * In-memory GlobalConfigAccessor for tests. + */ +export class TestGlobalConfigAccessor implements GlobalConfigAccessor { + private configData: GlobalConfig; + + constructor(options?: TestGlobalConfigAccessorOptions) { + this.configData = options?.initialConfigData ?? getDefaultGlobalConfig(); + } + + public async get(): Promise { + return this.configData; + } + + public async set(newConfig: GlobalConfig): Promise { + this.configData = newConfig; + return this.configData; + } +} diff --git a/src/testing/index.tsx b/src/testing/index.tsx index e3e711ee6..56f62a123 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -18,3 +18,4 @@ export { type RenderScreenResult, } from "./renderScreen"; export { createSilentLogger, assertLogsMatch, type LogQuery } from "./logging"; +export { TestGlobalConfigAccessor } from "./globalConfig"; diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index 4ed219ade..9e10ac754 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { render, cleanup } from "ink-testing-library"; import { QueryClient } from "@tanstack/react-query"; import { ValueContext, compile, CommandKey, type Context } from "../router"; @@ -10,6 +9,7 @@ import { TestCoreClient } from "./TestCoreClient"; import { testIO } from "./testIO"; import { tick, waitFor } from "./timing"; import { createSilentLogger } from "./logging"; +import { TestGlobalConfigAccessor } from "./globalConfig"; // TUI test harness. // @@ -29,7 +29,11 @@ import { createSilentLogger } from "./logging"; // keeps the command menus faithful to the production command structure. function baseContext(core: TestCoreClient, endpointUrl?: string): Context { const rootCommand = compile( - createRootHandler(core, { io: testIO().io, logger: createSilentLogger() }), + createRootHandler(core, { + io: testIO().io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }), ValueContext.EmptyContext(), ); diff --git a/src/tui/tui.test.tsx b/src/tui/tui.test.tsx index 9fb274b11..4c261e2f7 100644 --- a/src/tui/tui.test.tsx +++ b/src/tui/tui.test.tsx @@ -1,7 +1,14 @@ import { test, expect, describe } from "bun:test"; import { createRootHandler } from "../handlers"; import { renderJson } from "./index"; -import { createSilentLogger, TestCoreClient, testIO, tick, waitFor } from "../testing"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, + tick, + waitFor, +} from "../testing"; interface TtyInput extends NodeJS.ReadStream { write(chunk: string): boolean; @@ -44,6 +51,7 @@ describe("--json short-circuits the TUI", () => { const root = createRootHandler(new TestCoreClient(), { io: io.io, logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), }); await root.route(["node", "agentcore", ...args, "--json"]); return io.stdout(); @@ -78,6 +86,7 @@ describe("TUI stream boundary", () => { const root = createRootHandler(new TestCoreClient(), { io: io.io, logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), }); await expect(root.route(["node", "agentcore"])).rejects.toThrow( @@ -106,6 +115,7 @@ describe("TUI stream boundary", () => { const root = createRootHandler(core, { io: streams.io, logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), }); const routePromise = root.route(["node", "agentcore", "runtime", "list"]); const listCalls = () => core.runtime.calls.filter((call) => call.method === "listRuntimes"); From 2259b29a625f9391dac20a8ce43cef6b1f25617f Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 24 Jul 2026 13:44:32 +0000 Subject: [PATCH 2/3] refactor(globalConfig): avoid redundant once that module resolution handles --- src/globalConfig/accessor.tsx | 4 ++-- src/globalConfig/config.tsx | 29 ++++++++--------------------- src/globalConfig/index.tsx | 2 +- src/handlers/config/handler.tsx | 6 +++--- src/testing/globalConfig.tsx | 4 ++-- 5 files changed, 16 insertions(+), 29 deletions(-) diff --git a/src/globalConfig/accessor.tsx b/src/globalConfig/accessor.tsx index fbf5ff70e..0c855d14e 100644 --- a/src/globalConfig/accessor.tsx +++ b/src/globalConfig/accessor.tsx @@ -8,7 +8,7 @@ import { import type { Logger } from "../logging"; import z from "zod"; import { globalConfigFileSchema } from "./types"; -import { getDefaultGlobalConfig, resolveConfig } from "./config"; +import { DEFAULT_GLOBAL_CONFIG, resolveConfig } from "./config"; type DefaultGlobalConfigAccessorConfig = { logger: Logger; @@ -46,7 +46,7 @@ export class DefaultGlobalConfigAccessor implements GlobalConfigAccessor { // if no installationId is present, generate one and merge it into the file data if (!configFileData.installationId) { - configFileData.installationId = getDefaultGlobalConfig().installationId; + configFileData.installationId = DEFAULT_GLOBAL_CONFIG.installationId; this.logger.info(`no installationId found, persisting one`); try { diff --git a/src/globalConfig/config.tsx b/src/globalConfig/config.tsx index 662083957..fb1a7de86 100644 --- a/src/globalConfig/config.tsx +++ b/src/globalConfig/config.tsx @@ -1,40 +1,27 @@ import type { GlobalConfig, GlobalConfigFileData } from "./types"; + /** * Default values for the global config. Includes a unique installationId for each process. */ -export const getDefaultGlobalConfig = once(() => ({ +export const DEFAULT_GLOBAL_CONFIG: GlobalConfig = { telemetry: { enabled: true, audit: false, endpoint: "https://telemetry.agentcore.aws.dev", }, installationId: crypto.randomUUID(), -})); +}; /** - * Applies the given overrides from given {@link GlobalConfigFileData} to {@link getDefaultGlobalConfig} and returns the merged result + * Applies the given overrides from given {@link GlobalConfigFileData} to {@link DEFAULT_GLOBAL_CONFIG} and returns the merged result */ export function resolveConfig(globalConfigFile: GlobalConfigFileData): GlobalConfig { - const defaults = getDefaultGlobalConfig(); return { telemetry: { - enabled: globalConfigFile.telemetry?.enabled ?? defaults.telemetry.enabled, - audit: globalConfigFile.telemetry?.audit ?? defaults.telemetry.audit, - endpoint: globalConfigFile.telemetry?.endpoint ?? defaults.telemetry.endpoint, + enabled: globalConfigFile.telemetry?.enabled ?? DEFAULT_GLOBAL_CONFIG.telemetry.enabled, + audit: globalConfigFile.telemetry?.audit ?? DEFAULT_GLOBAL_CONFIG.telemetry.audit, + endpoint: globalConfigFile.telemetry?.endpoint ?? DEFAULT_GLOBAL_CONFIG.telemetry.endpoint, }, - installationId: globalConfigFile.installationId ?? defaults.installationId, - }; -} - -/** Wraps a zero-arg factory so it executes at most once; subsequent calls return the cached result. */ -function once(fn: () => T): () => T { - let value: T; - let called = false; - return () => { - if (!called) { - value = fn(); - called = true; - } - return value; + installationId: globalConfigFile.installationId ?? DEFAULT_GLOBAL_CONFIG.installationId, }; } diff --git a/src/globalConfig/index.tsx b/src/globalConfig/index.tsx index 26b09819a..9d34a5569 100644 --- a/src/globalConfig/index.tsx +++ b/src/globalConfig/index.tsx @@ -1,4 +1,4 @@ export { type GlobalConfigAccessor, type GlobalConfig, type ReadWriteJson } from "./types"; export { DefaultGlobalConfigAccessor } from "./accessor"; export { FsReadWriteJson } from "./json"; -export { getDefaultGlobalConfig } from "./config"; +export { DEFAULT_GLOBAL_CONFIG } from "./config"; diff --git a/src/handlers/config/handler.tsx b/src/handlers/config/handler.tsx index bb3c47171..1dc0ca755 100644 --- a/src/handlers/config/handler.tsx +++ b/src/handlers/config/handler.tsx @@ -1,7 +1,7 @@ import z from "zod"; import { createHandler, argument, GlobalConfigAccessorKey } from "../../router"; import { JsonRendererKey } from "../../tui"; -import { getDefaultGlobalConfig, type GlobalConfig } from "../../globalConfig"; +import { DEFAULT_GLOBAL_CONFIG, type GlobalConfig } from "../../globalConfig"; /* * read/write global configuration values. ex. telemetry settings, endpoint overrides, etc. @@ -18,7 +18,7 @@ export const createConfigHandler = () => argument( "key", "config key in JSON path notation (e.g. telemetry.enabled)", - z.enum(getKeys(getDefaultGlobalConfig())).optional(), + z.enum(getKeys(DEFAULT_GLOBAL_CONFIG)).optional(), ), argument("value", "value to set for the key", z.string().optional()), ], @@ -40,7 +40,7 @@ export const createConfigHandler = () => return; } const coercedValue = coerceValue( - getAtPath(getDefaultGlobalConfig(), args.key), + getAtPath(DEFAULT_GLOBAL_CONFIG, args.key), args.value, args.key, ); diff --git a/src/testing/globalConfig.tsx b/src/testing/globalConfig.tsx index 0fd9922a9..456c7164c 100644 --- a/src/testing/globalConfig.tsx +++ b/src/testing/globalConfig.tsx @@ -1,5 +1,5 @@ import { - getDefaultGlobalConfig, + DEFAULT_GLOBAL_CONFIG, type GlobalConfig, type GlobalConfigAccessor, } from "../globalConfig"; @@ -15,7 +15,7 @@ export class TestGlobalConfigAccessor implements GlobalConfigAccessor { private configData: GlobalConfig; constructor(options?: TestGlobalConfigAccessorOptions) { - this.configData = options?.initialConfigData ?? getDefaultGlobalConfig(); + this.configData = options?.initialConfigData ?? DEFAULT_GLOBAL_CONFIG; } public async get(): Promise { From 0531cce6611a107ef6a6b3818f6d4bb4239987f7 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 24 Jul 2026 13:49:41 +0000 Subject: [PATCH 3/3] refactor(globalConfig): rename resolveConfig to applyOverrides --- src/globalConfig/accessor.tsx | 6 +++--- src/globalConfig/config.tsx | 17 ++++++++++------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/globalConfig/accessor.tsx b/src/globalConfig/accessor.tsx index 0c855d14e..6e2a26be7 100644 --- a/src/globalConfig/accessor.tsx +++ b/src/globalConfig/accessor.tsx @@ -8,7 +8,7 @@ import { import type { Logger } from "../logging"; import z from "zod"; import { globalConfigFileSchema } from "./types"; -import { DEFAULT_GLOBAL_CONFIG, resolveConfig } from "./config"; +import { DEFAULT_GLOBAL_CONFIG, applyOverrides } from "./config"; type DefaultGlobalConfigAccessorConfig = { logger: Logger; @@ -60,14 +60,14 @@ export class DefaultGlobalConfigAccessor implements GlobalConfigAccessor { } } - this.cachedConfig = resolveConfig(configFileData); + this.cachedConfig = applyOverrides(DEFAULT_GLOBAL_CONFIG, configFileData); return this.cachedConfig; } public async set(newConfig: GlobalConfig): Promise { this.logger.child({ newConfig, method: "set" }).debug("writing global config"); - const configDiff = diff(newConfig, resolveConfig({})); + const configDiff = diff(newConfig, DEFAULT_GLOBAL_CONFIG); await this.writeToConfigFile(configDiff); this.cachedConfig = newConfig; return this.cachedConfig; diff --git a/src/globalConfig/config.tsx b/src/globalConfig/config.tsx index fb1a7de86..290462754 100644 --- a/src/globalConfig/config.tsx +++ b/src/globalConfig/config.tsx @@ -1,4 +1,4 @@ -import type { GlobalConfig, GlobalConfigFileData } from "./types"; +import type { DeepPartial, GlobalConfig } from "./types"; /** * Default values for the global config. Includes a unique installationId for each process. @@ -13,15 +13,18 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfig = { }; /** - * Applies the given overrides from given {@link GlobalConfigFileData} to {@link DEFAULT_GLOBAL_CONFIG} and returns the merged result + * Applies the given overrides from a partial config on top of the provided defaults and returns the merged result. */ -export function resolveConfig(globalConfigFile: GlobalConfigFileData): GlobalConfig { +export function applyOverrides( + defaults: GlobalConfig, + overrides: DeepPartial, +): GlobalConfig { return { telemetry: { - enabled: globalConfigFile.telemetry?.enabled ?? DEFAULT_GLOBAL_CONFIG.telemetry.enabled, - audit: globalConfigFile.telemetry?.audit ?? DEFAULT_GLOBAL_CONFIG.telemetry.audit, - endpoint: globalConfigFile.telemetry?.endpoint ?? DEFAULT_GLOBAL_CONFIG.telemetry.endpoint, + enabled: overrides.telemetry?.enabled ?? defaults.telemetry.enabled, + audit: overrides.telemetry?.audit ?? defaults.telemetry.audit, + endpoint: overrides.telemetry?.endpoint ?? defaults.telemetry.endpoint, }, - installationId: globalConfigFile.installationId ?? DEFAULT_GLOBAL_CONFIG.installationId, + installationId: overrides.installationId ?? defaults.installationId, }; }