Skip to content

Commit b298004

Browse files
author
Hweinstock
committed
feat(globalConfig): implement config command through new globalConfig module
1 parent 272e50e commit b298004

26 files changed

Lines changed: 723 additions & 48 deletions

src/globalConfig/accessor.tsx

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import {
2+
type DeepPartial,
3+
type GlobalConfig,
4+
type GlobalConfigAccessor,
5+
type GlobalConfigFileData,
6+
type ReadWriteJson,
7+
} from "./types";
8+
import type { Logger } from "../logging";
9+
import z from "zod";
10+
import { globalConfigFileSchema } from "./types";
11+
import { getDefaultGlobalConfig, resolveConfig } from "./config";
12+
13+
type DefaultGlobalConfigAccessorConfig = {
14+
logger: Logger;
15+
json: ReadWriteJson;
16+
filePath: string;
17+
};
18+
19+
/**
20+
* Implements {@link GlobalConfigAccessor} accepting overrides from the given file.
21+
*
22+
* @param config - The logger and json datasource to be used by the config. .
23+
* @returns A {@link GlobalConfigAccessor} instance.
24+
*/
25+
export class DefaultGlobalConfigAccessor implements GlobalConfigAccessor {
26+
private cachedConfig: GlobalConfig | undefined;
27+
private readonly json: ReadWriteJson;
28+
private readonly filePath: string;
29+
private readonly logger: Logger;
30+
31+
constructor(config: DefaultGlobalConfigAccessorConfig) {
32+
this.json = config.json;
33+
this.filePath = config.filePath;
34+
this.logger = config.logger.child({ configFilePath: this.filePath });
35+
this.logger.info(`creating global config accessor`);
36+
}
37+
38+
public async get(): Promise<GlobalConfig> {
39+
const logger = this.logger.child({ method: "get" });
40+
logger.debug("reading global config");
41+
42+
if (this.cachedConfig) return this.cachedConfig;
43+
logger.debug("no config cached, reading from source");
44+
45+
const configFileData = await this.readConfigFile();
46+
47+
// if no installationId is present, generate one and merge it into the file data
48+
if (!configFileData.installationId) {
49+
configFileData.installationId = getDefaultGlobalConfig().installationId;
50+
this.logger.info(`no installationId found, persisting one`);
51+
52+
try {
53+
await this.writeToConfigFile(configFileData);
54+
} catch (e) {
55+
const error = e instanceof Error ? e : new Error(String(e));
56+
this.logger
57+
.child({ errorName: error.name, errorMessage: error.message })
58+
.warn(`failed to write initial config file data`);
59+
// best effort
60+
}
61+
}
62+
63+
this.cachedConfig = resolveConfig(configFileData);
64+
return this.cachedConfig;
65+
}
66+
67+
public async set(newConfig: GlobalConfig): Promise<GlobalConfig> {
68+
this.logger.child({ newConfig, method: "set" }).debug("writing global config");
69+
70+
const configDiff = diff(newConfig, resolveConfig({}));
71+
await this.writeToConfigFile(configDiff);
72+
this.cachedConfig = newConfig;
73+
return this.cachedConfig;
74+
}
75+
76+
private async writeToConfigFile(data: GlobalConfigFileData): Promise<GlobalConfigFileData> {
77+
const dataParseResult = globalConfigFileSchema.safeParse(data);
78+
if (!dataParseResult.success) {
79+
// TODO: mark this as a client-source error.
80+
throw new TypeError(z.prettifyError(dataParseResult.error));
81+
}
82+
83+
await this.json.write(this.filePath, dataParseResult.data);
84+
return data;
85+
}
86+
87+
private async readConfigFile(): Promise<GlobalConfigFileData> {
88+
try {
89+
return await this.json.read(this.filePath, globalConfigFileSchema);
90+
} catch (e) {
91+
if (isFileNotFoundError(e)) return {};
92+
93+
const error = e instanceof Error ? e : new Error(String(e));
94+
this.logger
95+
.child({ errorName: error.name, errorMessage: error.message })
96+
.warn(`failed to read global config file`);
97+
throw e;
98+
}
99+
}
100+
}
101+
102+
function isFileNotFoundError(e: unknown): boolean {
103+
return e instanceof Error && "code" in e && e.code === "ENOENT";
104+
}
105+
106+
/** Recursively diffs two objects, comparing leaf values by reference equality. Returns only the fields in a that differ from b */
107+
function diff<T extends Record<string, unknown>>(a: T, b: T): DeepPartial<T> {
108+
const result: Record<string, unknown> = {};
109+
110+
for (const key of Object.keys(a)) {
111+
const aVal = a[key];
112+
const bVal = b[key];
113+
114+
if (isRecord(aVal) && isRecord(bVal)) {
115+
const nested = diff(aVal, bVal);
116+
if (Object.keys(nested).length > 0) {
117+
result[key] = nested;
118+
}
119+
} else if (aVal !== bVal) {
120+
result[key] = aVal;
121+
}
122+
}
123+
124+
return result as DeepPartial<T>;
125+
}
126+
127+
function isRecord(value: unknown): value is Record<string, unknown> {
128+
return typeof value === "object" && value !== null && !Array.isArray(value);
129+
}

src/globalConfig/config.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { GlobalConfig, GlobalConfigFileData } from "./types";
2+
/**
3+
* Default values for the global config. Includes a unique installationId for each process.
4+
*/
5+
export const getDefaultGlobalConfig = once<GlobalConfig>(() => ({
6+
telemetry: {
7+
enabled: true,
8+
audit: false,
9+
endpoint: "https://telemetry.agentcore.aws.dev",
10+
},
11+
installationId: crypto.randomUUID(),
12+
}));
13+
14+
/**
15+
* Applies the given overrides from given {@link GlobalConfigFileData} to {@link getDefaultGlobalConfig} and returns the merged result
16+
*/
17+
export function resolveConfig(globalConfigFile: GlobalConfigFileData): GlobalConfig {
18+
const defaults = getDefaultGlobalConfig();
19+
return {
20+
telemetry: {
21+
enabled: globalConfigFile.telemetry?.enabled ?? defaults.telemetry.enabled,
22+
audit: globalConfigFile.telemetry?.audit ?? defaults.telemetry.audit,
23+
endpoint: globalConfigFile.telemetry?.endpoint ?? defaults.telemetry.endpoint,
24+
},
25+
installationId: globalConfigFile.installationId ?? defaults.installationId,
26+
};
27+
}
28+
29+
/** Wraps a zero-arg factory so it executes at most once; subsequent calls return the cached result. */
30+
function once<T>(fn: () => T): () => T {
31+
let value: T;
32+
let called = false;
33+
return () => {
34+
if (!called) {
35+
value = fn();
36+
called = true;
37+
}
38+
return value;
39+
};
40+
}

src/globalConfig/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { type GlobalConfigAccessor, type GlobalConfig, type ReadWriteJson } from "./types";
2+
export { DefaultGlobalConfigAccessor } from "./accessor";
3+
export { FsReadWriteJson } from "./json";
4+
export { getDefaultGlobalConfig } from "./config";

src/globalConfig/json.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import z from "zod";
2+
import { mkdir, readFile, writeFile } from "fs/promises";
3+
import type { Logger } from "../logging";
4+
import type { ReadWriteJson } from "./types";
5+
import { dirname } from "path";
6+
7+
type ReadWriteJsonConfig = {
8+
logger: Logger;
9+
};
10+
11+
// TODO: attach telemetry metadata to this error class.
12+
class DeserializationError extends Error {
13+
constructor(path: string, options?: { cause?: unknown }) {
14+
super(`Failed to deserialize JSON at "${path}"`, options);
15+
this.name = "DeserializationError";
16+
}
17+
}
18+
19+
/**
20+
* Implements {@link ReadWriteJson} through node fs.
21+
*
22+
* @param config - logger
23+
*/
24+
export class FsReadWriteJson implements ReadWriteJson {
25+
private readonly logger: Logger;
26+
27+
constructor(config: ReadWriteJsonConfig) {
28+
this.logger = config.logger;
29+
}
30+
31+
private async readJsonFile(filePath: string): Promise<unknown> {
32+
const raw = await readFile(filePath, "utf8");
33+
34+
try {
35+
return JSON.parse(raw);
36+
} catch (e) {
37+
const error = e instanceof Error ? e : new Error(String(e));
38+
this.logger
39+
.child({ filePath, errorName: error.name, errorMessage: error.message })
40+
.error(`failed to parse json file`);
41+
throw new DeserializationError(filePath, { cause: e });
42+
}
43+
}
44+
45+
public async read<TSchema extends z.ZodType>(
46+
filePath: string,
47+
schema: TSchema,
48+
): Promise<z.infer<TSchema>> {
49+
const data = await this.readJsonFile(filePath);
50+
const parseResult = schema.safeParse(data);
51+
52+
if (!parseResult.success) {
53+
this.logger
54+
.child({
55+
filePath,
56+
errorName: parseResult.error.name,
57+
errorMessage: parseResult.error.message,
58+
})
59+
.error(`failed to validate parsed json file`);
60+
throw new DeserializationError(filePath, { cause: parseResult.error });
61+
}
62+
63+
return parseResult.data;
64+
}
65+
66+
public async write<TData extends object>(filePath: string, data: TData): Promise<TData> {
67+
const contents = JSON.stringify(data, undefined, 2);
68+
await mkdir(dirname(filePath), { recursive: true });
69+
await writeFile(filePath, contents);
70+
return data;
71+
}
72+
}

src/globalConfig/types.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import z from "zod";
2+
3+
/** Recursively marks all fields of a given shape as required */
4+
export type DeepRequired<T> = {
5+
[P in keyof T]-?: DeepRequired<T[P]>;
6+
};
7+
8+
/** Recursively marks all fields of a given shape as optional */
9+
export type DeepPartial<T> = { [P in keyof T]?: DeepPartial<T[P]> };
10+
11+
/**
12+
* Schema for the global config file. All fields should be optional with defaults defined.
13+
*/
14+
export const globalConfigFileSchema = z.object({
15+
telemetry: z
16+
.object({
17+
enabled: z.boolean().optional(),
18+
endpoint: z.string().optional(),
19+
audit: z.boolean().optional(),
20+
})
21+
.optional(),
22+
installationId: z.uuid().optional(),
23+
});
24+
25+
/** The raw shape stored on disk for overriding defaults. */
26+
export type GlobalConfigFileData = z.infer<typeof globalConfigFileSchema>;
27+
28+
/** The fully resolved config after applying defaults — all fields required. */
29+
export type GlobalConfig = DeepRequired<GlobalConfigFileData>;
30+
31+
/** Manages access to a set of configuration values for the CLI */
32+
export interface GlobalConfigAccessor {
33+
/** Returns the current global config, with defaults applied. */
34+
get(): Promise<GlobalConfig>;
35+
/** Validates and persists a new config. Throws on invalid shape. */
36+
set(newConfig: GlobalConfig): Promise<GlobalConfig>;
37+
}
38+
39+
export interface ReadWriteJson {
40+
/** Reads data from the given file */
41+
read<TSchema extends z.ZodType>(filePath: string, schema: TSchema): Promise<z.infer<TSchema>>;
42+
/** Writes data to the given file */
43+
write<TData extends object>(filePath: string, data: TData): Promise<TData>;
44+
}

0 commit comments

Comments
 (0)