diff --git a/src/globalConfig/accessor.tsx b/src/globalConfig/accessor.tsx index 6e2a26be7..18ea6d316 100644 --- a/src/globalConfig/accessor.tsx +++ b/src/globalConfig/accessor.tsx @@ -3,8 +3,8 @@ import { type GlobalConfig, type GlobalConfigAccessor, type GlobalConfigFileData, - type ReadWriteJson, } from "./types"; +import type { ReadWriteJson } from "../io"; import type { Logger } from "../logging"; import z from "zod"; import { globalConfigFileSchema } from "./types"; diff --git a/src/globalConfig/index.tsx b/src/globalConfig/index.tsx index 9d34a5569..9328b32f2 100644 --- a/src/globalConfig/index.tsx +++ b/src/globalConfig/index.tsx @@ -1,4 +1,3 @@ -export { type GlobalConfigAccessor, type GlobalConfig, type ReadWriteJson } from "./types"; +export { type GlobalConfigAccessor, type GlobalConfig } from "./types"; export { DefaultGlobalConfigAccessor } from "./accessor"; -export { FsReadWriteJson } from "./json"; export { DEFAULT_GLOBAL_CONFIG } from "./config"; diff --git a/src/globalConfig/types.tsx b/src/globalConfig/types.tsx index 62000a1d6..b171666d3 100644 --- a/src/globalConfig/types.tsx +++ b/src/globalConfig/types.tsx @@ -35,10 +35,3 @@ export interface GlobalConfigAccessor { /** 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 9ccdaf588..a74da3b70 100644 --- a/src/handlers/config/config.test.tsx +++ b/src/handlers/config/config.test.tsx @@ -4,7 +4,8 @@ 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"; +import { DefaultGlobalConfigAccessor } from "../../globalConfig"; +import { FsReadWriteJson } from "../../io"; describe("config", () => { let tempDir: string; diff --git a/src/handlers/harness/endpoint/index.tsx b/src/handlers/harness/endpoint/index.tsx index 5263e4116..6d028c98d 100644 --- a/src/handlers/harness/endpoint/index.tsx +++ b/src/handlers/harness/endpoint/index.tsx @@ -1,6 +1,7 @@ import { renderTui } from "../../../tui"; import { Router } from "../../../router"; -import type { AppIO, Core } from "../../types"; +import type { AppIO } from "../../../io"; +import type { Core } from "../../types"; import { createCreateEndpointHandler } from "./create"; import { createGetEndpointHandler } from "./get"; import { createListEndpointsHandler } from "./list"; diff --git a/src/handlers/harness/exec/index.tsx b/src/handlers/harness/exec/index.tsx index 2c2dcf970..4ef2594e8 100644 --- a/src/handlers/harness/exec/index.tsx +++ b/src/handlers/harness/exec/index.tsx @@ -1,6 +1,7 @@ import z from "zod"; import { createHandler, flag, PathKey } from "../../../router"; -import type { AppIO, Core } from "../../types.tsx"; +import type { AppIO } from "../../../io"; +import type { Core } from "../../types.tsx"; import { coreOptsFromCtx } from "../../utils.tsx"; import { JsonKey } from "../../keys.tsx"; import { JsonRendererKey, renderTuiAt } from "../../../tui"; diff --git a/src/handlers/harness/index.tsx b/src/handlers/harness/index.tsx index 02c4eb6ba..e835c0b9b 100644 --- a/src/handlers/harness/index.tsx +++ b/src/handlers/harness/index.tsx @@ -1,7 +1,8 @@ import { renderTui } from "../../tui"; import { withTuiOnEmptyFlagsAndArgs } from "../../middleware"; import { Router } from "../../router"; -import type { AppIO, Core } from "../types"; +import type { AppIO } from "../../io"; +import type { Core } from "../types"; import { createGetHarnessHandler } from "./get"; import { createListHarnessHandler } from "./list"; import { createCreateHarnessHandler } from "./create"; diff --git a/src/handlers/harness/invoke/index.tsx b/src/handlers/harness/invoke/index.tsx index e0c2704b3..82895bfd2 100644 --- a/src/handlers/harness/invoke/index.tsx +++ b/src/handlers/harness/invoke/index.tsx @@ -1,6 +1,7 @@ import z from "zod"; import { createHandler, flag, PathKey } from "../../../router"; -import type { AppIO, Core } from "../../types.tsx"; +import type { AppIO } from "../../../io"; +import type { Core } from "../../types.tsx"; import { coreOptsFromCtx } from "../../utils.tsx"; import { JsonKey } from "../../keys.tsx"; import { JsonRendererKey, renderTuiAt } from "../../../tui"; diff --git a/src/handlers/harness/version/index.tsx b/src/handlers/harness/version/index.tsx index db226c02c..11a3b0c62 100644 --- a/src/handlers/harness/version/index.tsx +++ b/src/handlers/harness/version/index.tsx @@ -1,6 +1,7 @@ import { renderTui } from "../../../tui"; import { Router } from "../../../router"; -import type { AppIO, Core } from "../../types"; +import type { AppIO } from "../../../io"; +import type { Core } from "../../types"; import { createGetVersionHandler } from "./get"; import { createListVersionsHandler } from "./list"; diff --git a/src/handlers/help.tsx b/src/handlers/help.tsx index 4b12af384..31f0d7d3b 100644 --- a/src/handlers/help.tsx +++ b/src/handlers/help.tsx @@ -1,5 +1,5 @@ import { CommandKey, type DefaultHandle } from "../router"; -import type { AppIO } from "./types"; +import type { AppIO } from "../io"; export function createHelpDefault(io: AppIO): DefaultHandle { return async (ctx) => { diff --git a/src/handlers/identity/api-key-credential-provider/index.tsx b/src/handlers/identity/api-key-credential-provider/index.tsx index 685acf94e..ffb308599 100644 --- a/src/handlers/identity/api-key-credential-provider/index.tsx +++ b/src/handlers/identity/api-key-credential-provider/index.tsx @@ -1,5 +1,6 @@ import { Router } from "../../../router"; -import type { AppIO, Core } from "../../types"; +import type { AppIO } from "../../../io"; +import type { Core } from "../../types"; import { createHelpDefault } from "../../help"; import { createCreateApiKeyCredentialProviderHandler } from "./create"; import { createDeleteApiKeyCredentialProviderHandler } from "./delete"; diff --git a/src/handlers/identity/index.tsx b/src/handlers/identity/index.tsx index d52548260..664967752 100644 --- a/src/handlers/identity/index.tsx +++ b/src/handlers/identity/index.tsx @@ -1,5 +1,6 @@ import { Router } from "../../router"; -import type { AppIO, Core } from "../types"; +import type { AppIO } from "../../io"; +import type { Core } from "../types"; import { createApiKeyCredentialProviderHandler } from "./api-key-credential-provider"; import { createHelpDefault } from "../help"; diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index b1b141427..0d5723486 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -7,7 +7,8 @@ import { createConfigHandler } from "./config/"; import { createProjectHandler } from "./project/index.ts"; import { renderTui } from "../tui"; import { withRegion, withJsonRenderer, withLogging, withGlobalConfigAccessor } from "../middleware"; -import type { AppIO, Core } from "./types.tsx"; +import type { AppIO } from "../io"; +import type { Core } from "./types.tsx"; import type { Logger } from "../logging"; import type { GlobalConfigAccessor } from "../globalConfig"; diff --git a/src/handlers/runtime/endpoint/index.tsx b/src/handlers/runtime/endpoint/index.tsx index 4e35e5a19..ef7d7b5fe 100644 --- a/src/handlers/runtime/endpoint/index.tsx +++ b/src/handlers/runtime/endpoint/index.tsx @@ -1,6 +1,7 @@ import { Router } from "../../../router"; import { renderTui } from "../../../tui"; -import type { AppIO, Core } from "../../types"; +import type { AppIO } from "../../../io"; +import type { Core } from "../../types"; import { createGetRuntimeEndpointHandler } from "./get"; import { createListRuntimeEndpointsHandler } from "./list"; diff --git a/src/handlers/runtime/index.tsx b/src/handlers/runtime/index.tsx index dfe2b73bb..d2e8b2885 100644 --- a/src/handlers/runtime/index.tsx +++ b/src/handlers/runtime/index.tsx @@ -1,7 +1,8 @@ import { withTuiOnEmptyFlagsAndArgs } from "../../middleware"; import { Router } from "../../router"; import { renderTui } from "../../tui"; -import type { AppIO, Core } from "../types"; +import type { AppIO } from "../../io"; +import type { Core } from "../types"; import { createRuntimeEndpointHandler } from "./endpoint"; import { createGetRuntimeHandler } from "./get"; import { createListRuntimesHandler } from "./list"; diff --git a/src/handlers/runtime/version/index.tsx b/src/handlers/runtime/version/index.tsx index 6f82bd3b6..d107d4595 100644 --- a/src/handlers/runtime/version/index.tsx +++ b/src/handlers/runtime/version/index.tsx @@ -1,6 +1,7 @@ import { Router } from "../../../router"; import { renderTui } from "../../../tui"; -import type { AppIO, Core } from "../../types"; +import type { AppIO } from "../../../io"; +import type { Core } from "../../types"; import { createGetRuntimeVersionHandler } from "./get"; import { createListRuntimeVersionsHandler } from "./list"; diff --git a/src/handlers/types.tsx b/src/handlers/types.tsx index 8f8371d7a..e609c78ec 100644 --- a/src/handlers/types.tsx +++ b/src/handlers/types.tsx @@ -11,17 +11,6 @@ export interface Core { projectManager: ProjectManager; } -// AppIO is the set of standard streams the app reads from and writes to. It is -// injected at the edge (createRootHandler, from src/index.ts) and threaded down -// to the TUI renderer and handlers, so nothing reaches for the process streams -// (or console.*) directly. Production wiring passes the real process streams; -// tests pass in-memory fakes to capture output and drive input. -export interface AppIO { - stdin: NodeJS.ReadStream; - stdout: NodeJS.WriteStream; - stderr: NodeJS.WriteStream; -} - // ScreenProps is the common prop set every TUI screen receives. `ctx` carries the // request context (resolved flags, command, path) and `core` the service clients, // both threaded down by Root. diff --git a/src/index.ts b/src/index.ts index b76e22fd2..348fb81ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,9 +10,10 @@ import { join } from "path"; import { CoreClient } from "./core"; import { createControlClient, createDataClient, createIamClient } from "./core/factories"; import { createRootHandler } from "./handlers"; +import { FsReadWriteJson } from "./io"; import { createFileLogger, LOG_LEVEL } from "./logging"; import { runWithExitCode } from "./runnable"; -import { DefaultGlobalConfigAccessor, FsReadWriteJson } from "./globalConfig"; +import { DefaultGlobalConfigAccessor } from "./globalConfig"; process.exit( await runWithExitCode(async (argv: string[]) => { diff --git a/src/io/index.ts b/src/io/index.ts new file mode 100644 index 000000000..07095428c --- /dev/null +++ b/src/io/index.ts @@ -0,0 +1,3 @@ +export { FsReadWriteJson } from "./json"; +export { SourceResolutionError, SourceResolver, type SourceResolverConfig } from "./source"; +export type { AppIO, ReadWriteJson } from "./types"; diff --git a/src/globalConfig/json.tsx b/src/io/json.ts similarity index 93% rename from src/globalConfig/json.tsx rename to src/io/json.ts index 36db9527d..2517d42f8 100644 --- a/src/globalConfig/json.tsx +++ b/src/io/json.ts @@ -1,8 +1,10 @@ -import z from "zod"; -import { mkdir, readFile, writeFile } from "fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +import type z from "zod"; + import type { Logger } from "../logging"; import type { ReadWriteJson } from "./types"; -import { dirname } from "path"; type ReadWriteJsonConfig = { logger: Logger; diff --git a/src/io/source.test.ts b/src/io/source.test.ts new file mode 100644 index 000000000..55b9ed863 --- /dev/null +++ b/src/io/source.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough, Readable } from "node:stream"; + +import { SourceResolutionError, SourceResolver } from "./source"; + +const tempDirectories: string[] = []; + +async function tempFile(contents: string | Uint8Array): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-source-")); + tempDirectories.push(directory); + const path = join(directory, "source"); + await writeFile(path, contents); + return path; +} + +function stdin(...chunks: Uint8Array[]): NodeJS.ReadStream { + return Readable.from(chunks) as NodeJS.ReadStream; +} + +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("SourceResolver bytes", () => { + test("passes undefined through", async () => { + const resolver = new SourceResolver({ stdin: stdin() }); + + expect(await resolver.resolveBytes("payload", undefined)).toBeUndefined(); + }); + + test("encodes inline values as UTF-8", async () => { + const resolver = new SourceResolver({ stdin: stdin() }); + + expect(await resolver.resolveBytes("payload", "hello \u20ac")).toEqual( + new TextEncoder().encode("hello \u20ac"), + ); + }); + + test("preserves arbitrary file bytes", async () => { + const bytes = Uint8Array.from([0, 255, 10, 1]); + const path = await tempFile(bytes); + const resolver = new SourceResolver({ stdin: stdin() }); + + expect(await resolver.resolveBytes("payload", `file://${path}`)).toEqual(bytes); + }); + + test("preserves arbitrary stdin bytes", async () => { + const bytes = Uint8Array.from([9, 0, 254]); + const resolver = new SourceResolver({ stdin: stdin(bytes) }); + + expect(await resolver.resolveBytes("payload", "-")).toEqual(bytes); + }); + + test("rejects a second stdin consumer and identifies both options", async () => { + const resolver = new SourceResolver({ stdin: stdin(new TextEncoder().encode("value")) }); + + await resolver.resolveText("payload", "-"); + const resolution = resolver.resolveText("bearer-token", "-"); + + await expect(resolution).rejects.toBeInstanceOf(SourceResolutionError); + await expect(resolution).rejects.toThrow("'--bearer-token' conflicts with '--payload'"); + }); + + test("claims stdin before concurrent resolutions can race", async () => { + const input = new PassThrough() as unknown as NodeJS.ReadStream; + const resolver = new SourceResolver({ stdin: input }); + const first = resolver.resolveBytes("first", "-"); + const second = resolver.resolveBytes("second", "-"); + + await expect(second).rejects.toThrow("'--second' conflicts with '--first'"); + input.end("value"); + expect(new TextDecoder().decode(await first)).toBe("value"); + }); + + test("reports file failures with option and path context", async () => { + const missing = join(tmpdir(), "agentcore-source-missing", "secret-value"); + const resolver = new SourceResolver({ stdin: stdin() }); + + const resolution = resolver.resolveBytes("payload", `file://${missing}`); + await expect(resolution).rejects.toBeInstanceOf(SourceResolutionError); + await expect(resolution).rejects.toThrow(`could not read '--payload' from file '${missing}'`); + }); + + test("wraps non-abort stdin failures with option context", async () => { + const input = new PassThrough() as unknown as NodeJS.ReadStream; + const resolver = new SourceResolver({ stdin: input }); + const resolution = resolver.resolveBytes("payload", "-"); + input.destroy(new Error("stream failed")); + + await expect(resolution).rejects.toBeInstanceOf(SourceResolutionError); + await expect(resolution).rejects.toThrow("could not read '--payload' from stdin"); + }); +}); + +describe("SourceResolver text", () => { + test("passes undefined through", async () => { + const resolver = new SourceResolver({ stdin: stdin() }); + + expect(await resolver.resolveText("value", undefined)).toBeUndefined(); + }); + + test("resolves inline, file, and stdin text without transforming it", async () => { + const path = await tempFile("file\nvalue"); + + expect( + await new SourceResolver({ stdin: stdin() }).resolveText("inline", " inline value "), + ).toBe(" inline value "); + expect(await new SourceResolver({ stdin: stdin() }).resolveText("file", `file://${path}`)).toBe( + "file\nvalue", + ); + expect( + await new SourceResolver({ + stdin: stdin(new TextEncoder().encode("stdin\nvalue")), + }).resolveText("stdin", "-"), + ).toBe("stdin\nvalue"); + }); + + test("rejects malformed UTF-8 with option context", async () => { + const path = await tempFile(Uint8Array.from([0xff, 0x61])); + const resolver = new SourceResolver({ stdin: stdin() }); + const resolution = resolver.resolveText("api-key", `file://${path}`); + + await expect(resolution).rejects.toBeInstanceOf(SourceResolutionError); + await expect(resolution).rejects.toThrow("'--api-key' must contain valid UTF-8"); + }); +}); + +describe("SourceResolver cancellation", () => { + test("rejects an already-aborted inline resolution", async () => { + const controller = new AbortController(); + controller.abort(); + const resolver = new SourceResolver({ + stdin: stdin(), + signal: controller.signal, + }); + const resolution = resolver.resolveBytes("payload", "inline"); + + await expect(resolution).rejects.toMatchObject({ name: "AbortError" }); + await expect(resolution).rejects.not.toBeInstanceOf(SourceResolutionError); + }); + + test("preserves AbortError from file resolution", async () => { + const path = await tempFile("payload"); + const controller = new AbortController(); + controller.abort(); + const resolver = new SourceResolver({ + stdin: stdin(), + signal: controller.signal, + }); + const resolution = resolver.resolveBytes("payload", `file://${path}`); + + await expect(resolution).rejects.toMatchObject({ name: "AbortError" }); + await expect(resolution).rejects.not.toBeInstanceOf(SourceResolutionError); + }); + + test("aborts while waiting for stdin and preserves AbortError", async () => { + const input = new PassThrough() as unknown as NodeJS.ReadStream; + const controller = new AbortController(); + const resolver = new SourceResolver({ + stdin: input, + signal: controller.signal, + }); + const resolution = resolver.resolveBytes("payload", "-"); + + controller.abort(); + + try { + await expect( + Promise.race([ + resolution, + Bun.sleep(100).then(() => { + throw new Error("stdin read did not abort"); + }), + ]), + ).rejects.toMatchObject({ name: "AbortError" }); + await expect(resolution).rejects.not.toBeInstanceOf(SourceResolutionError); + } finally { + input.destroy(); + await resolution.catch(() => {}); + } + }); +}); diff --git a/src/io/source.ts b/src/io/source.ts new file mode 100644 index 000000000..2c3e8e7ee --- /dev/null +++ b/src/io/source.ts @@ -0,0 +1,89 @@ +import { readFile } from "node:fs/promises"; +import { addAbortSignal } from "node:stream"; +import { buffer } from "node:stream/consumers"; + +const FILE_PREFIX = "file://"; +const STDIN = "-"; + +export type SourceResolverConfig = { + stdin: NodeJS.ReadStream; + signal?: AbortSignal; +}; + +export class SourceResolutionError extends TypeError { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "SourceResolutionError"; + } +} + +export class SourceResolver { + private stdinClaimedBy?: string; + + constructor(private readonly config: SourceResolverConfig) {} + + async resolveBytes(name: string, source: string | undefined): Promise { + if (source === undefined) return undefined; + this.config.signal?.throwIfAborted(); + + if (source === STDIN) { + return this.readStdin(name); + } + + if (source.startsWith(FILE_PREFIX)) { + return this.readFile(name, source.slice(FILE_PREFIX.length)); + } + + return new TextEncoder().encode(source); + } + + async resolveText(name: string, source: string | undefined): Promise { + const bytes = await this.resolveBytes(name, source); + if (bytes === undefined) return undefined; + + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error) { + throw new SourceResolutionError(`'--${name}' must contain valid UTF-8`, { + cause: error, + }); + } + } + + private async readStdin(name: string): Promise { + if (this.stdinClaimedBy !== undefined) { + throw new SourceResolutionError( + `only one option may read from stdin; '--${name}' conflicts with ` + + `'--${this.stdinClaimedBy}'`, + ); + } + this.stdinClaimedBy = name; + + const stdin = this.config.signal + ? addAbortSignal(this.config.signal, this.config.stdin) + : this.config.stdin; + try { + return await buffer(stdin); + } catch (error) { + if (isAbortError(error)) throw error; + throw new SourceResolutionError(`could not read '--${name}' from stdin`, { + cause: error, + }); + } + } + + private async readFile(name: string, path: string): Promise { + try { + return await readFile(path, { signal: this.config.signal }); + } catch (error) { + if (isAbortError(error)) throw error; + throw new SourceResolutionError(`could not read '--${name}' from file '${path}'`, { + cause: error, + }); + } + } +} + +function isAbortError(error: unknown): boolean { + return (error as { name?: unknown })?.name === "AbortError"; +} diff --git a/src/io/types.ts b/src/io/types.ts new file mode 100644 index 000000000..c54c3ba6b --- /dev/null +++ b/src/io/types.ts @@ -0,0 +1,16 @@ +import type z from "zod"; + +// AppIO is injected at the application edge and threaded through handlers and +// the TUI so production uses process streams while tests use in-memory streams. +export interface AppIO { + stdin: NodeJS.ReadStream; + stdout: NodeJS.WriteStream; + stderr: NodeJS.WriteStream; +} + +export interface ReadWriteJson { + /** Reads and validates 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/middleware/withJsonRenderer.tsx b/src/middleware/withJsonRenderer.tsx index bece7a42d..6d2afe888 100644 --- a/src/middleware/withJsonRenderer.tsx +++ b/src/middleware/withJsonRenderer.tsx @@ -1,6 +1,6 @@ import { type Middleware } from "../router"; import { JsonRendererKey, renderJson } from "../tui"; -import type { AppIO } from "../handlers/types"; +import type { AppIO } from "../io"; // withJsonRenderer pins a JsonRenderer on the context, prewired to write to the // injected stdout. Handlers emit machine-readable output via diff --git a/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx b/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx index 405de2078..3b6735782 100644 --- a/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx +++ b/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx @@ -1,6 +1,7 @@ import { renderTui } from "../tui"; import { JsonKey } from "../handlers/keys"; -import type { AppIO, Core } from "../handlers/types"; +import type { AppIO } from "../io"; +import type { Core } from "../handlers/types"; import { type Middleware } from "../router"; // countPassedValues counts how many entries of an object hold a defined value. diff --git a/src/testing/testIO.tsx b/src/testing/testIO.tsx index a07ed36d1..48b799fee 100644 --- a/src/testing/testIO.tsx +++ b/src/testing/testIO.tsx @@ -1,5 +1,5 @@ import { PassThrough } from "node:stream"; -import type { AppIO } from "../handlers/types"; +import type { AppIO } from "../io"; // TestIO bundles an in-memory AppIO with accessors to read back what was written // to each stream. Pass `io` to createRootHandler({ io }) to capture a command's diff --git a/src/tui/index.tsx b/src/tui/index.tsx index 4978d1f9f..6fe429e8b 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -8,7 +8,8 @@ import { contextKey, type ContextKey, } from "../router"; -import type { AppIO, Core } from "../handlers/types"; +import type { AppIO } from "../io"; +import type { Core } from "../handlers/types"; import { JsonKey } from "../handlers/keys"; // renderJson pretty-prints a value as indented JSON. It is the output