Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/globalConfig/accessor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { type GlobalConfig, type GlobalConfigAccessor, type ReadWriteJson } from "./types";
import type { Logger } from "../logging";
import { globalConfigSchema } from "./schemas";
import z from "zod";

type DefaultGlobalConfigAccessorConfig = {
logger: Logger;
json: ReadWriteJson;
filePath: string;
};

/**
* Implements {@link GlobalConfigAccessor} backed by the given json source.
*
* @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.logger = config.logger;
this.json = config.json;
this.filePath = config.filePath;
this.logger.info(`creating global config accessor`);
}

public async get(): Promise<GlobalConfig> {
const logger = this.logger.child({ method: "get" });
logger.debug("reading global config");

if (this.cachedConfig) return this.cachedConfig;
logger.debug("failed to find cache, reading from source");

try {
this.cachedConfig = await this.json.read(this.filePath, globalConfigSchema);
return this.cachedConfig;
} catch (e) {
if (isFileNotFoundError(e)) return {};
throw e;
}
}

public async set(newConfig: Record<string, unknown>): Promise<GlobalConfig> {
this.logger.child({ newConfig, method: "set" }).debug("writing global config");

const validatedConfig = globalConfigSchema.safeParse(newConfig);
if (!validatedConfig.success) {
throw new TypeError(z.prettifyError(validatedConfig.error));
}
this.cachedConfig = await this.json.write(this.filePath, validatedConfig.data);
return this.cachedConfig;
}
}

function isFileNotFoundError(e: unknown): boolean {
return e instanceof Error && "code" in e && e.code === "ENOENT";
}
4 changes: 4 additions & 0 deletions src/globalConfig/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { type GlobalConfigAccessor, type GlobalConfig, type ReadWriteJson } from "./types";
export { globalConfigSchema, isValidKey } from "./schemas";
export { DefaultGlobalConfigAccessor } from "./accessor";
export { FsReadWriteJson } from "./source";
110 changes: 110 additions & 0 deletions src/globalConfig/schemas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import z from "zod";

/** Zod boolean schema that coerces string values (`"true"`, `"1"`, `"false"`, `"0"`) to booleans. */
const coercedBoolean = z.preprocess((val) => {
if (typeof val === "string") {
if (val === "true" || val === "1") return true;
if (val === "false" || val === "0") return false;
}
return val;
}, z.boolean());

/** Creates a Zod object schema that coerces JSON strings into objects before validation. */
const coerceObject = <TShape extends z.ZodRawShape>(shape: TShape) =>
z.preprocess((val) => {
if (typeof val === "string") {
try {
return JSON.parse(val);
} catch {
return val;
}
}
return val;
}, z.object(shape));

/**
* Schema for the global config file. {@link GlobalConfigAccessor} is built from this definition.
* All fields should be optional; missing fields should be overridden with reasonable defaults in the consuming code.
*/
export const globalConfigSchema = z.object({
telemetry: coerceObject({
enabled: coercedBoolean.optional(),
endpoint: z.string().optional(),
audit: coercedBoolean.optional(),
}).optional(),
installationId: z.uuid().optional(),
});

/** Executes `fn` on first call and caches the result for subsequent calls. */
function once<T>(fn: () => T): () => T {
let result: T;
let called = false;
return () => {
if (!called) {
result = fn();
called = true;
}
return result;
};
}

/**
* Returns the internal def for a zod schema, which contains the `type` discriminant
* and structural fields like `innerType`, `shape`, `out`, etc.
* @see https://zod.dev/packages/core
*/
function getDef(schema: z.ZodType): {
type: string;
innerType?: z.ZodType;
out?: z.ZodType;
shape?: Record<string, z.ZodType>;
} {
return (schema as any)._zod.def;
}

/** Peels wrapper types to reach the base schema. */
function unwrapSchema(schema: z.ZodType): z.ZodType {
for (;;) {
const def = getDef(schema);
switch (def.type) {
case "optional":
case "nullable":
case "default":
case "readonly":
schema = def.innerType!;
break;
case "pipe":
// z.preprocess() returns a ZodPipe — take the output schema
schema = def.out!;
break;
default:
return schema;
}
}
}

/** Recursively collects all valid dot-notation paths from a ZodObject schema. */
function buildKeySet(schema: z.ZodObject, prefix = ""): Set<string> {
const paths = new Set<string>();

for (const key of schema.keyof().options) {
const fullPath = prefix ? `${prefix}.${key}` : key;
paths.add(fullPath);

const inner = unwrapSchema(schema.shape[key]);
if (getDef(inner).type === "object") {
for (const nested of buildKeySet(inner as z.ZodObject, fullPath)) {
paths.add(nested);
}
}
}

return paths;
}

const getValidKeys = once(() => buildKeySet(globalConfigSchema));

/** Returns whether a dot-notation path is a known key in {@link globalConfigSchema}. */
export function isValidKey(path: string): boolean {
return getValidKeys().has(path);
}
70 changes: 70 additions & 0 deletions src/globalConfig/source.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import z from "zod";
import { readFile, writeFile } from "fs/promises";
import type { Logger } from "../logging";
import type { ReadWriteJson } from "./types";

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 readJson(filePath: string): Promise<unknown> {
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<TSchema extends z.ZodType>(
filePath: string,
schema: TSchema,
): Promise<z.infer<TSchema>> {
const data = await this.readJson(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<TData extends object>(filePath: string, data: TData): Promise<TData> {
const contents = JSON.stringify(data, undefined, 2);
await writeFile(filePath, contents);
return data;
}
}
20 changes: 20 additions & 0 deletions src/globalConfig/types.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type z from "zod";
import type { globalConfigSchema } from "./schemas";

/** Inferred shape of the global config from {@link globalConfigSchema}. */
export type GlobalConfig = z.infer<typeof globalConfigSchema>;

/** Reads and writes global CLI configuration. */
export interface GlobalConfigAccessor {
/** Returns the current global config. */
get(): Promise<GlobalConfig>;
/** Validates and persists a new config. Throws on invalid shape. */
set(newConfig: Record<string, unknown>): Promise<GlobalConfig>;
}

export interface ReadWriteJson {
/** Reads data from the given file */
read<TSchema extends z.ZodType>(filePath: string, schema: TSchema): Promise<z.infer<TSchema>>;
/** Writes data to the given file */
write<TData extends object>(filePath: string, data: TData): Promise<TData>;
}
Loading
Loading