diff --git a/.changeset/config-middleware.md b/.changeset/config-middleware.md new file mode 100644 index 00000000..c418ebc0 --- /dev/null +++ b/.changeset/config-middleware.md @@ -0,0 +1,15 @@ +--- +'@kidd-cli/core': minor +--- + +Extract config loading from core runtime into an opt-in middleware (`@kidd-cli/core/config`) with support for layered resolution (global > project > local). Config is no longer baked into `CommandContext` — it is added via module augmentation when the middleware is imported, keeping builds lean for CLIs that don't need config. + +**Breaking:** `ctx.config` is no longer available by default. Use the config middleware: + +```ts +import { config } from '@kidd-cli/core/config' + +cli({ + middleware: [config({ schema: mySchema, layers: true })], +}) +``` diff --git a/docs/concepts/configuration.md b/docs/concepts/configuration.md index 7eaaa322..565ef8ee 100644 --- a/docs/concepts/configuration.md +++ b/docs/concepts/configuration.md @@ -35,34 +35,88 @@ export default defineConfig({ }) ``` -## CLI Config Options +## Config Middleware -The `config` option in `cli()` controls how runtime configuration is loaded and validated for your CLI's users. +Configuration is purely opt-in via the `config()` middleware from `@kidd-cli/core/config`. Register it in the middleware array to make `ctx.config` available in handlers. + +### Lazy loading (default) + +By default, config is loaded lazily -- nothing is read from disk until the handler calls `ctx.config.load()`: ```ts +import { cli } from '@kidd-cli/core' +import { config } from '@kidd-cli/core/config' +import { configSchema } from './config.js' + cli({ name: 'my-app', version: '1.0.0', - config: { - schema: MyConfigSchema, - name: 'myapp', - }, + middleware: [config({ schema: configSchema })], commands: { deploy }, }) ``` -| Field | Type | Default | Description | -| -------- | --------- | ------------------- | ------------------------------------------------------------------- | -| `schema` | `ZodType` | -- | Zod schema to validate the loaded config. Infers `ctx.config` type. | -| `name` | `string` | Derived from `name` | Override the config file name for file discovery | +### Eager loading + +Pass `eager: true` to load and validate config during the middleware pass, before the handler runs: + +```ts +config({ schema: configSchema, eager: true }) +``` + +### Middleware options + +| Field | Type | Default | Description | +| -------- | --------- | ------------------- | -------------------------------------------------------------------- | +| `schema` | `ZodType` | -- | Zod schema to validate the loaded config. Infers `ctx.config` type. | +| `eager` | `boolean` | `false` | Load config during middleware pass instead of on first `load()` call | +| `layers` | `boolean` | `false` | Enable layered resolution when eager loading. For lazy mode, pass `{ layers: true }` to `load()` instead. | +| `dirs` | `object` | From `ctx.meta` | Override layer directories: `{ global?: string, local?: string }`. Only applies when layered resolution is used. | +| `name` | `string` | Derived from `name` | Override the config file name for file discovery | + +## Using `ctx.config` + +The middleware decorates `ctx.config` as a `ConfigHandle` with a `load()` method. It returns the load result or `null` on error: + +```ts +export default command({ + async handler(ctx) { + const result = await ctx.config.load() + if (!result) return + result.config.apiUrl // string + result.config.org // string + }, +}) +``` + +Pass `{ exitOnError: true }` to call `ctx.fail()` on error, guaranteeing a non-null return: + +```ts +export default command({ + async handler(ctx) { + const { config } = await ctx.config.load({ exitOnError: true }) + config.apiUrl // string — guaranteed + }, +}) +``` + +### Loading with layers + +Pass `{ layers: true }` to `load()` to include layer metadata in the result: + +```ts +const result = await ctx.config.load({ layers: true, exitOnError: true }) +result.config.apiUrl // string +result.layers // ConfigLayer[] +``` ## Typing `ctx.config` -The Zod schema validates config at runtime, but TypeScript cannot automatically propagate the schema type to `ctx.config` in command handlers (commands are defined in separate files and dynamically imported). Use `ConfigType` with module augmentation to get compile-time safety: +The Zod schema validates config at runtime, but TypeScript cannot automatically propagate the schema type to `ctx.config` in command handlers (commands are defined in separate files and dynamically imported). Use `ConfigType` with module augmentation on `@kidd-cli/core/config` to get compile-time safety: ```ts // src/config.ts -import type { ConfigType } from '@kidd-cli/core' +import type { ConfigType } from '@kidd-cli/core/config' import { z } from 'zod' export const configSchema = z.object({ @@ -70,12 +124,12 @@ export const configSchema = z.object({ org: z.string().min(1), }) -declare module '@kidd-cli/core' { - interface CliConfig extends ConfigType {} +declare module '@kidd-cli/core/config' { + interface ConfigRegistry extends ConfigType {} } ``` -This keeps the schema as the single source of truth -- `CliConfig` is always derived from it, so they can never drift apart. Every command handler now sees `ctx.config.apiUrl` and `ctx.config.org` as fully typed properties. +This keeps the schema as the single source of truth -- `ConfigRegistry` is always derived from it, so they can never drift apart. Every command handler now sees typed properties on the `result.config` object returned by `ctx.config.load()`. You can scaffold this setup automatically: @@ -237,13 +291,17 @@ const setupCommand = command({ Use different config file names for different environments: ```ts +import { config } from '@kidd-cli/core/config' + cli({ name: 'my-app', version: '1.0.0', - config: { - schema: configSchema, - name: process.env['NODE_ENV'] === 'production' ? 'my-app-prod' : 'my-app', - }, + middleware: [ + config({ + schema: configSchema, + name: process.env['NODE_ENV'] === 'production' ? 'my-app-prod' : 'my-app', + }), + ], commands: { deploy }, }) ``` diff --git a/docs/concepts/context.md b/docs/concepts/context.md index 0cfabfcf..8f7fbb55 100644 --- a/docs/concepts/context.md +++ b/docs/concepts/context.md @@ -4,19 +4,19 @@ The central API surface threaded through every handler and middleware. Provides ## Properties -| Property | Type | Description | -| --------- | ----------------------------------------- | ---------------------------------------------------------------------------- | -| `args` | `DeepReadonly>` | Parsed and validated command args | -| `colors` | `Colors` | Color formatting utilities (picocolors) | -| `config` | `DeepReadonly>` | Validated runtime config | -| `format` | `Format` | Pure string formatters (no I/O) | -| `log` | `Log` | Logging methods (info, success, error, warn, etc.) | -| `prompts` | `Prompts` | Interactive prompts (confirm, text, select, etc.) | -| `spinner` | `Spinner` | Spinner for long-running operations (start, stop, message) | -| `store` | `Store` | Typed in-memory key-value store | -| `fail` | `(message, options?) => never` | Throw a user-facing error | -| `meta` | `Meta` | CLI metadata | -| `auth` | `AuthContext` | Auth credential and login (when `@kidd-cli/core/auth` middleware registered) | +| Property | Type | Description | +| --------- | -------------------------------------- | ---------------------------------------------------------------------------- | +| `args` | `DeepReadonly>` | Parsed and validated command args | +| `colors` | `Colors` | Color formatting utilities (picocolors) | +| `config` | `ConfigHandle` | Lazy config handle with `load()` method (when config middleware registered) | +| `format` | `Format` | Pure string formatters (no I/O) | +| `log` | `Log` | Logging methods (info, success, error, warn, etc.) | +| `prompts` | `Prompts` | Interactive prompts (confirm, text, select, etc.) | +| `spinner` | `Spinner` | Spinner for long-running operations (start, stop, message) | +| `store` | `Store` | Typed in-memory key-value store | +| `fail` | `(message, options?) => never` | Throw a user-facing error | +| `meta` | `Meta` | CLI metadata | +| `auth` | `AuthContext` | Auth credential and login (when `@kidd-cli/core/auth` middleware registered) | ## `ctx.args` @@ -35,13 +35,13 @@ const deploy = command({ ## `ctx.config` -Deeply readonly validated config loaded from the project's config file. The type is a merge of `CliConfig` (global augmentation) and the schema passed to `cli({ config: { schema } })`. +A `ConfigHandle` decorated by the `config()` middleware from `@kidd-cli/core/config`. Only present when the config middleware is registered. Config loads lazily by default -- call `ctx.config.load()` to read and validate the config file, which returns a `Result` tuple. -Use `ConfigType` with module augmentation to derive `CliConfig` from your Zod schema: +Use `ConfigType` with module augmentation on `@kidd-cli/core/config` to derive `ConfigRegistry` from your Zod schema: ```ts // src/config.ts -import type { ConfigType } from '@kidd-cli/core' +import type { ConfigType } from '@kidd-cli/core/config' import { z } from 'zod' export const configSchema = z.object({ @@ -49,36 +49,50 @@ export const configSchema = z.object({ org: z.string().min(1), }) -declare module '@kidd-cli/core' { - interface CliConfig extends ConfigType {} +declare module '@kidd-cli/core/config' { + interface ConfigRegistry extends ConfigType {} } ``` -Then pass the schema to `cli()`: +Then register the config middleware: ```ts import { cli } from '@kidd-cli/core' +import { config } from '@kidd-cli/core/config' import { configSchema } from './config.js' cli({ name: 'my-app', version: '1.0.0', - config: { schema: configSchema }, + middleware: [config({ schema: configSchema })], commands: import.meta.dirname + '/commands', }) ``` -Commands can now access typed config properties: +Commands load and access config via the handle: ```ts export default command({ async handler(ctx) { - ctx.config.apiUrl // string - ctx.config.org // string + const [error, result] = await ctx.config.load() + if (error) { + ctx.fail(error.message) + return + } + result.config.apiUrl // string + result.config.org // string }, }) ``` +Pass `{ layers: true }` to `load()` to include layer metadata: + +```ts +const [error, result] = await ctx.config.load({ layers: true }) +result.config.apiUrl // string +result.layers // ConfigLayer[] +``` + Run `kidd add config` to scaffold this setup in an existing project, or pass `--config` to `kidd init` when creating a new project. ## `ctx.log` @@ -258,7 +272,7 @@ See [Authentication](./authentication.md) for the full auth system reference. kidd exposes empty interfaces that consumers extend via TypeScript declaration merging. This adds project-wide type safety without threading generics through every handler. -For `CliConfig`, use the `ConfigType` utility to derive the type from your Zod schema (see [`ctx.config`](#ctxconfig) above). For other interfaces, extend them directly: +For `ConfigRegistry`, use the `ConfigType` utility to derive the type from your Zod schema (see [`ctx.config`](#ctxconfig) above). Note that config augmentation targets `@kidd-cli/core/config`, while other interfaces target `@kidd-cli/core`: ```ts declare module '@kidd-cli/core' { @@ -266,31 +280,33 @@ declare module '@kidd-cli/core' { verbose: boolean } - // Prefer ConfigType over manual properties -- see ctx.config docs - interface CliConfig extends ConfigType {} - interface KiddStore { token: string } } + +// Config augmentation uses a separate module +declare module '@kidd-cli/core/config' { + interface ConfigRegistry extends ConfigType {} +} ``` -| Interface | Affects | Description | -| ----------- | ------------ | ------------------------------------------------------------------------------------------------ | -| `KiddArgs` | `ctx.args` | Global args merged into every command's args | -| `CliConfig` | `ctx.config` | Global config merged into every command's config | -| `KiddStore` | `ctx.store` | Global store keys merged into the store type | -| `StoreMap` | `ctx.store` | The store's full key-value shape -- extend this to register typed keys (merges with `KiddStore`) | +| Interface | Module | Affects | Description | +| ---------------- | ----------------------- | ------------------- | ------------------------------------------------------------------------------------------------ | +| `KiddArgs` | `@kidd-cli/core` | `ctx.args` | Global args merged into every command's args | +| `ConfigRegistry` | `@kidd-cli/core/config` | `ctx.config.load()` | Typed config returned by `load()` result | +| `KiddStore` | `@kidd-cli/core` | `ctx.store` | Global store keys merged into the store type | +| `StoreMap` | `@kidd-cli/core` | `ctx.store` | The store's full key-value shape -- extend this to register typed keys (merges with `KiddStore`) | ## Context in screen commands Screen commands defined with `screen()` do not receive a `CommandContext` object. Instead, parsed args are passed directly as props to the React component, and runtime values are accessed via hooks: -| Hook | Returns | Context equivalent | -| ------------- | ------------------- | ------------------ | -| `useConfig()` | `Readonly` | `ctx.config` | -| `useMeta()` | `Readonly` | `ctx.meta` | -| `useStore()` | `Store` | `ctx.store` | +| Hook | Returns | Context equivalent | +| ------------- | ---------------- | ------------------ | +| `useConfig()` | `ConfigHandle` | `ctx.config` | +| `useMeta()` | `Readonly` | `ctx.meta` | +| `useStore()` | `Store` | `ctx.store` | See [Screens](./screens.md) for details. diff --git a/docs/guides/build-a-cli.md b/docs/guides/build-a-cli.md index b0f819f6..51249a95 100644 --- a/docs/guides/build-a-cli.md +++ b/docs/guides/build-a-cli.md @@ -43,7 +43,7 @@ const deploy = command({ ### 2. Bootstrap the CLI -`cli()` registers commands, parses arguments, loads config, runs middleware, and invokes the matched handler. +`cli()` registers commands, parses arguments, runs middleware, and invokes the matched handler. ```ts import { cli } from '@kidd-cli/core' @@ -54,7 +54,6 @@ cli({ description: 'My CLI tool', commands: { deploy, migrate }, middleware: [timing], - config: { schema: MyConfigSchema }, help: { header: 'my-app - deploy and migrate with ease' }, }) ``` @@ -200,11 +199,11 @@ kidd init --config **Manual setup:** -Create a config schema file with `ConfigType` to derive `CliConfig` from your Zod schema: +Create a config schema file with `ConfigType` to derive `ConfigRegistry` from your Zod schema: ```ts // src/config.ts -import type { ConfigType } from '@kidd-cli/core' +import type { ConfigType } from '@kidd-cli/core/config' import { z } from 'zod' export const configSchema = z.object({ @@ -212,36 +211,48 @@ export const configSchema = z.object({ region: z.string().default('us-east-1'), }) -declare module '@kidd-cli/core' { - interface CliConfig extends ConfigType {} +declare module '@kidd-cli/core/config' { + interface ConfigRegistry extends ConfigType {} } ``` -Pass the schema to `cli()`: +Register the config middleware in `cli()`: ```ts import { cli } from '@kidd-cli/core' +import { config } from '@kidd-cli/core/config' import { configSchema } from './config.js' cli({ name: 'my-app', version: '1.0.0', - config: { schema: configSchema }, + middleware: [config({ schema: configSchema })], commands: import.meta.dirname + '/commands', }) ``` -Commands now see fully typed `ctx.config`: +Commands load config lazily via the `ctx.config` handle: ```ts export default command({ async handler(ctx) { - ctx.config.apiUrl // string - ctx.config.region // string + const [error, result] = await ctx.config.load() + if (error) { + ctx.fail(error.message) + return + } + result.config.apiUrl // string + result.config.region // string }, }) ``` +To load config eagerly (during middleware pass), use `eager: true`: + +```ts +config({ schema: configSchema, eager: true }) +``` + **Standalone config client:** For loading config outside the `cli()` bootstrap, use `createConfigClient`: diff --git a/docs/guides/testing-your-cli.md b/docs/guides/testing-your-cli.md index a94071e2..163796f2 100644 --- a/docs/guides/testing-your-cli.md +++ b/docs/guides/testing-your-cli.md @@ -128,7 +128,6 @@ import { createTestContext } from '@kidd-cli/core/test' const { ctx, stdout } = createTestContext({ args: { name: 'Alice', verbose: true }, - config: { apiUrl: 'https://api.example.com' }, meta: { command: ['deploy'], name: 'my-cli', version: '1.0.0' }, }) @@ -213,25 +212,33 @@ describe('whoami', () => { ## Testing with Config -Pass config values via the `overrides` to test commands that read `ctx.config`: +Commands that use `ctx.config` expect a `ConfigHandle` with a `load()` method. Use `createTestContext` and `decorateContext` to attach a mock config handle: ```ts import { describe, expect, it } from 'vitest' -import { command } from '@kidd-cli/core' -import { runHandler } from '@kidd-cli/core/test' +import { command, decorateContext } from '@kidd-cli/core' +import { createTestContext } from '@kidd-cli/core/test' const status = command({ async handler(ctx) { - ctx.logger.print(`API: ${ctx.config.apiUrl}`) + const [error, result] = await ctx.config.load() + if (error) { + ctx.fail(error.message) + return + } + ctx.logger.print(`API: ${result.config.apiUrl}`) }, }) describe('status', () => { it('should display the configured API URL', async () => { - const { stdout } = await runHandler({ - cmd: status, - overrides: { config: { apiUrl: 'https://api.example.com' } }, + const { ctx, stdout } = createTestContext() + + decorateContext(ctx, 'config', { + load: async () => [null, { config: { apiUrl: 'https://api.example.com' } }], }) + + await status.handler(ctx) expect(stdout()).toContain('https://api.example.com') }) }) diff --git a/docs/quick-start.md b/docs/quick-start.md index d8add2a7..017e0a2f 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -81,27 +81,28 @@ Every command now logs its execution time automatically. ## Add configuration -kidd discovers and validates configuration files using Zod. Define a config schema and use module augmentation to make the types available on `ctx.config`: +kidd discovers and validates configuration files using Zod. Define a config schema and use module augmentation to type the config handle: ```ts // src/config.ts -import type { ConfigType } from '@kidd-cli/core' +import type { ConfigType } from '@kidd-cli/core/config' import { z } from 'zod' export const configSchema = z.object({ greeting: z.string().default('Hello'), }) -declare module '@kidd-cli/core' { - interface CliConfig extends ConfigType {} +declare module '@kidd-cli/core/config' { + interface ConfigRegistry extends ConfigType {} } ``` -Pass the schema to `cli()`: +Register the `config()` middleware in `cli()`: ```ts // src/index.ts import { cli } from '@kidd-cli/core' +import { config } from '@kidd-cli/core/config' import greet from './commands/greet.js' import { timing } from './middleware/timing.js' import { configSchema } from './config.js' @@ -109,13 +110,12 @@ import { configSchema } from './config.js' cli({ name: 'my-app', version: '0.1.0', - config: { schema: configSchema }, - middleware: [timing], + middleware: [config({ schema: configSchema }), timing], commands: { greet }, }) ``` -Now update the command to read from config: +Now update the command to load config lazily via the handle: ```ts // src/commands/greet.ts @@ -128,12 +128,17 @@ export default command({ name: z.string().describe('Who to greet'), }), async handler(ctx) { - ctx.logger.success(`${ctx.config.greeting}, ${ctx.args.name}!`) + const [error, result] = await ctx.config.load() + if (error) { + ctx.fail(error.message) + return + } + ctx.logger.success(`${result.config.greeting}, ${ctx.args.name}!`) }, }) ``` -kidd will look for `my-app.config.ts`, `my-app.config.js`, `.my-apprc.json`, and other common config file patterns -- all validated against your Zod schema at startup. +kidd will look for `.my-app.jsonc`, `.my-app.json`, and `.my-app.yaml` -- all validated against your Zod schema when `load()` is called. ## Build for production diff --git a/docs/reference/bootstrap.md b/docs/reference/bootstrap.md index 8fb6324f..26b6e875 100644 --- a/docs/reference/bootstrap.md +++ b/docs/reference/bootstrap.md @@ -13,7 +13,6 @@ cli({ description: 'My CLI tool', commands: { deploy, migrate }, middleware: [timing], - config: { schema: MyConfigSchema }, help: { header: 'my-app - deploy and migrate with ease', footer: 'Docs: https://my-app.dev', @@ -31,20 +30,12 @@ cli({ | `description` | `string` | -- | Human-readable description | | `commands` | `string \| CommandMap \| Promise \| CommandsConfig` | -- | Commands source | | `middleware` | `Middleware[]` | -- | Root middleware stack | -| `config` | `CliConfigOptions` | -- | Config schema and file name override | | `help` | `CliHelpOptions` | -- | Custom help header/footer | | `dirs` | `DirsConfig` | -- | Directory name overrides | | `log` | `Log` | -- | Custom log implementation | | `prompts` | `Prompts` | -- | Custom prompts implementation | | `spinner` | `Spinner` | -- | Custom spinner implementation | -## CliConfigOptions - -| Field | Type | Default | Description | -| -------- | --------- | ------------------- | ------------------------------------------------ | -| `schema` | `ZodType` | -- | Zod schema to validate the loaded config | -| `name` | `string` | Derived from `name` | Override the config file name for file discovery | - ## CliHelpOptions | Field | Type | Description | @@ -90,7 +81,7 @@ export default defineConfig({ Extend kidd's interfaces via TypeScript declaration merging for project-wide type safety: ```ts -import type { ConfigType } from '@kidd-cli/core' +import type { ConfigType } from '@kidd-cli/core/config' import { z } from 'zod' export const configSchema = z.object({ @@ -102,19 +93,22 @@ declare module '@kidd-cli/core' { interface KiddArgs { verbose: boolean } - interface CliConfig extends ConfigType {} interface KiddStore { token: string } } + +declare module '@kidd-cli/core/config' { + interface ConfigRegistry extends ConfigType {} +} ``` -| Interface | Affects | Description | -| ----------- | ------------ | ------------------------------------------------------------------------------------------- | -| `KiddArgs` | `ctx.args` | Global args merged into every command's args | -| `CliConfig` | `ctx.config` | Global config merged into every command's config | -| `KiddStore` | `ctx.store` | Global store keys merged into the store type | -| `StoreMap` | `ctx.store` | The store's full key-value shape -- extend to register typed keys (merges with `KiddStore`) | +| Interface | Module | Affects | Description | +| ---------------- | ----------------------- | ------------------- | ------------------------------------------------------------------------------------------- | +| `KiddArgs` | `@kidd-cli/core` | `ctx.args` | Global args merged into every command's args | +| `ConfigRegistry` | `@kidd-cli/core/config` | `ctx.config.load()` | Typed config returned by `load()` result | +| `KiddStore` | `@kidd-cli/core` | `ctx.store` | Global store keys merged into the store type | +| `StoreMap` | `@kidd-cli/core` | `ctx.store` | The store's full key-value shape -- extend to register typed keys (merges with `KiddStore`) | ## Sub-exports diff --git a/examples/advanced/README.md b/examples/advanced/README.md index 5a5c1b57..15526f55 100644 --- a/examples/advanced/README.md +++ b/examples/advanced/README.md @@ -60,7 +60,7 @@ const configSchema = z.object({ }) ``` -Config values are loaded from a configuration file and accessible via `ctx.config`. +Config values are loaded lazily via `ctx.config.load()` and accessible on the result object. ## Middleware stack @@ -72,7 +72,7 @@ Config values are loaded from a configuration file and accessible via `ctx.confi ### Standalone `http()` middleware -The `http()` middleware is decoupled from auth and uses a `headers` function to dynamically inject config values: +The `http()` middleware is decoupled from auth and uses a `headers` function to dynamically inject config values. Since config is loaded lazily, load it inside the headers callback: ```ts import { http } from '@kidd-cli/core/http' @@ -80,10 +80,13 @@ import { http } from '@kidd-cli/core/http' http({ baseUrl: 'https://api.acme.dev', namespace: 'api', - headers: (ctx) => ({ - 'X-Org': ctx.config.org, - 'X-Environment': ctx.config.defaultEnvironment, - }), + headers: async (ctx) => { + const [, result] = await ctx.config.load() + return { + 'X-Org': result?.config.org ?? '', + 'X-Environment': result?.config.defaultEnvironment ?? '', + } + }, }) ``` diff --git a/examples/advanced/acme.config.json b/examples/advanced/acme.config.json new file mode 100644 index 00000000..3411ae46 --- /dev/null +++ b/examples/advanced/acme.config.json @@ -0,0 +1,5 @@ +{ + "apiUrl": "https://api.acme.dev", + "defaultEnvironment": "staging", + "org": "acme" +} diff --git a/examples/advanced/src/commands/deploy/preview.ts b/examples/advanced/src/commands/deploy/preview.ts index efb8a7c2..a358ad0d 100644 --- a/examples/advanced/src/commands/deploy/preview.ts +++ b/examples/advanced/src/commands/deploy/preview.ts @@ -14,6 +14,8 @@ export default command({ positionals, description: 'Deploy a preview environment', handler: async (ctx) => { + const { config } = await ctx.config.load({ exitOnError: true }) + ctx.status.spinner.start(`Deploying preview from ${ctx.args.branch}`) if (ctx.args.clean) { @@ -23,7 +25,7 @@ export default command({ ctx.status.spinner.message('Uploading artifacts') ctx.status.spinner.message('Provisioning environment') - const deployUrl = `https://preview-${ctx.args.branch}.${ctx.config.org}.acme.dev` + const deployUrl = `https://preview-${ctx.args.branch}.${config.org}.acme.dev` ctx.status.spinner.stop('Preview deployed') @@ -31,7 +33,7 @@ export default command({ ctx.format.json({ branch: ctx.args.branch, environment: 'preview', - org: ctx.config.org, + org: config.org, url: deployUrl, }) ) diff --git a/examples/advanced/src/commands/deploy/production.ts b/examples/advanced/src/commands/deploy/production.ts index 31cabdcf..85dd06bd 100644 --- a/examples/advanced/src/commands/deploy/production.ts +++ b/examples/advanced/src/commands/deploy/production.ts @@ -10,9 +10,11 @@ export default command({ options, description: 'Deploy to production', handler: async (ctx) => { + const { config } = await ctx.config.load({ exitOnError: true }) + if (!ctx.args.force) { const confirmed = await ctx.prompts.confirm({ - message: `Deploy ${ctx.args.tag} to production for ${ctx.config.org}?`, + message: `Deploy ${ctx.args.tag} to production for ${config.org}?`, }) if (!confirmed) { @@ -29,9 +31,9 @@ export default command({ process.stdout.write( ctx.format.json({ environment: 'production', - org: ctx.config.org, + org: config.org, tag: ctx.args.tag, - url: `https://${ctx.config.org}.acme.dev`, + url: `https://${config.org}.acme.dev`, }) ) }, diff --git a/examples/advanced/src/commands/status.ts b/examples/advanced/src/commands/status.ts index 0d3dffc0..c07e6aab 100644 --- a/examples/advanced/src/commands/status.ts +++ b/examples/advanced/src/commands/status.ts @@ -2,16 +2,18 @@ import { command } from '@kidd-cli/core' export default command({ description: 'Show project status', - handler: (ctx) => { + handler: async (ctx) => { + const { config } = await ctx.config.load({ exitOnError: true }) + const status = { cli: { name: ctx.meta.name, version: ctx.meta.version, }, config: { - apiUrl: ctx.config.apiUrl, - environment: ctx.config.defaultEnvironment, - org: ctx.config.org, + apiUrl: config.apiUrl, + environment: config.defaultEnvironment, + org: config.org, }, } diff --git a/examples/advanced/src/index.ts b/examples/advanced/src/index.ts index 81a94be4..b8986cda 100644 --- a/examples/advanced/src/index.ts +++ b/examples/advanced/src/index.ts @@ -1,5 +1,7 @@ import { cli } from '@kidd-cli/core' -import type { ConfigType, DisplayConfig } from '@kidd-cli/core' +import type { DisplayConfig } from '@kidd-cli/core' +import { config } from '@kidd-cli/core/config' +import type { ConfigType } from '@kidd-cli/core/config' import { http } from '@kidd-cli/core/http' import type { HttpClient } from '@kidd-cli/core/http' import { z } from 'zod' @@ -17,8 +19,10 @@ declare module '@kidd-cli/core' { interface CommandContext { readonly api: HttpClient } +} - interface CliConfig extends ConfigType {} +declare module '@kidd-cli/core/config' { + interface ConfigRegistry extends ConfigType {} } const display: DisplayConfig = { @@ -51,9 +55,6 @@ const display: DisplayConfig = { cli({ commands: `${import.meta.dirname}/commands`, - config: { - schema: configSchema, - }, description: 'Acme platform CLI', display, help: { @@ -61,12 +62,19 @@ cli({ order: ['deploy', 'status', 'ping', 'whoami'], }, middleware: [ + config({ schema: configSchema, eager: true }), http({ baseUrl: 'https://api.acme.dev', - headers: (ctx) => ({ - 'X-Environment': String(ctx.config.defaultEnvironment), - 'X-Org': String(ctx.config.org), - }), + headers: async (ctx) => { + const result = await ctx.config.load() + if (!result) { + return {} + } + return { + 'X-Environment': String(result.config.defaultEnvironment), + 'X-Org': String(result.config.org), + } + }, namespace: 'api', }), timing, diff --git a/packages/core/package.json b/packages/core/package.json index 7f2ae815..5ce5c656 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,6 +30,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./config": { + "types": "./dist/middleware/config.d.ts", + "default": "./dist/middleware/config.js" + }, "./auth": { "types": "./dist/middleware/auth.d.ts", "default": "./dist/middleware/auth.js" diff --git a/packages/core/src/cli.test.ts b/packages/core/src/cli.test.ts index a91fa1a8..e810ef79 100644 --- a/packages/core/src/cli.test.ts +++ b/packages/core/src/cli.test.ts @@ -205,32 +205,11 @@ describe('context properties', () => { expect(handler).toHaveBeenCalledTimes(1) const ctx = handler.mock.calls[0]![0] as CommandContext expect(ctx).toHaveProperty('args') - expect(ctx).toHaveProperty('config') expect(ctx).toHaveProperty('format') expect(ctx).toHaveProperty('store') expect(ctx).toHaveProperty('fail') expect(ctx).toHaveProperty('meta') }) - - it('provides empty config when no config option is given', async () => { - const handler = vi.fn() - const commands: CommandMap = { - run: command({ - description: 'Run', - handler, - }), - } - - setArgv('run') - await runTestCli({ - commands, - name: 'test-cli', - version: '1.0.0', - }) - - const ctx = handler.mock.calls[0]![0] as CommandContext - expect(ctx.config).toEqual({}) - }) }) describe('version resolution', () => { diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index e25db6b7..e771bc02 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -32,9 +32,7 @@ import type { ErrorRef, ResolvedRef } from './runtime/index.js' * * @param options - CLI configuration including name, version, commands, and middleware. */ -export async function cli( - options: CliOptions -): Promise { +export async function cli(options: CliOptions): Promise { registerCrashHandlers(options.name) const [uncaughtError, result] = await attemptAsync(async () => { @@ -104,7 +102,6 @@ export async function cli( const [runtimeError, runtime] = await createRuntime({ argv: normalizedArgv, - config: options.config, dirs, display: options.display, log: options.log, diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index 00d82830..2126d4ed 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -45,10 +45,9 @@ export function isCommandsConfig(value: unknown): value is CommandsConfig { export function command< TOptionsDef extends ArgsDef = ArgsDef, TPositionalsDef extends ArgsDef = ArgsDef, - TConfig extends Record = Record, const TMiddleware extends readonly Middleware[] = readonly Middleware[], ->(def: CommandDef): CommandType { +>(def: CommandDef): CommandType { const resolved = { ...def, deprecated: resolveValue(def.deprecated), diff --git a/packages/core/src/context/create-context.test.ts b/packages/core/src/context/create-context.test.ts index 6c2ee846..e6fa3d4a 100644 --- a/packages/core/src/context/create-context.test.ts +++ b/packages/core/src/context/create-context.test.ts @@ -7,7 +7,6 @@ import { isContextError } from './error.js' function defaultOptions(): { args: { name: string; verbose: boolean } argv: readonly string[] - config: { debug: boolean } meta: { command: string[] dirs: { global: string; local: string } @@ -18,7 +17,6 @@ function defaultOptions(): { return { args: { name: 'test', verbose: true }, argv: ['my-cli', 'deploy', 'preview', '--verbose'], - config: { debug: false }, meta: { command: ['deploy', 'preview'], dirs: { global: '.my-cli', local: '.my-cli' }, @@ -30,7 +28,7 @@ function defaultOptions(): { describe('createContext()', () => { // --------------------------------------------------------------------------- - // Args, config + // Args // --------------------------------------------------------------------------- describe('args', () => { @@ -41,13 +39,6 @@ describe('createContext()', () => { }) }) - describe('config', () => { - it('contains the provided config', () => { - const ctx = createContext(defaultOptions()) - expect(ctx.config.debug).toBeFalsy() - }) - }) - // --------------------------------------------------------------------------- // Meta // --------------------------------------------------------------------------- diff --git a/packages/core/src/context/create-context.ts b/packages/core/src/context/create-context.ts index 54a68fed..61ce1a57 100644 --- a/packages/core/src/context/create-context.ts +++ b/packages/core/src/context/create-context.ts @@ -3,7 +3,7 @@ import type { Colors } from 'picocolors/types' import { createDotDirectory } from '@/lib/dotdir/index.js' import { createLog } from '@/lib/log.js' -import type { AnyRecord, KiddStore, Merge, ResolvedDirs } from '@/types/index.js' +import type { AnyRecord, KiddStore, Merge } from '@/types/index.js' import { createContextError } from './error.js' import { createContextFormat } from './format.js' @@ -25,20 +25,19 @@ import type { /** * Options for creating a {@link CommandContext} instance via {@link createContext}. * - * Carries the parsed args, validated config, and CLI metadata needed to - * assemble a fully-wired context. Optional overrides allow callers to inject - * custom {@link Log}, {@link Prompts}, and {@link Status} implementations; - * when omitted, default `@clack/prompts`-backed instances are used. + * Carries the parsed args and CLI metadata needed to assemble a fully-wired + * context. Optional overrides allow callers to inject custom {@link Log}, + * {@link Prompts}, and {@link Status} implementations; when omitted, default + * `@clack/prompts`-backed instances are used. */ -export interface CreateContextOptions { +export interface CreateContextOptions { readonly args: TArgs readonly argv: readonly string[] - readonly config: TConfig readonly meta: { readonly name: string readonly version: string readonly command: string[] - readonly dirs: ResolvedDirs + readonly dirs: { readonly local: string; readonly global: string } } readonly display?: DisplayConfig readonly log?: Log @@ -60,9 +59,9 @@ export interface CreateContextOptions( - options: CreateContextOptions -): CommandContext { +export function createContext( + options: CreateContextOptions +): CommandContext { const dc = options.display ?? {} const commonDefaults = resolveCommonDefaults(dc) @@ -93,9 +92,8 @@ export function createContext['args'], + args: options.args as CommandContext['args'], colors: Object.freeze({ ...pc }) as Colors, - config: options.config as CommandContext['config'], dotdir: ctxDotdir, fail(message: string, failOptions?: { code?: string; exitCode?: number }): never { // Accepted exception: ctx.fail() is typed `never` and caught by the CLI boundary. @@ -104,12 +102,12 @@ export function createContext['meta'], + meta: ctxMeta as CommandContext['meta'], prompts: ctxPrompts, raw: Object.freeze({ argv: Object.freeze([...options.argv]) }), status: ctxStatus, store: ctxStore, - } as CommandContext + } as CommandContext } // --------------------------------------------------------------------------- @@ -144,8 +142,8 @@ function resolveCommonDefaults(dc: DisplayConfig): { * @param commonDefaults - Common per-call defaults from display config. * @returns A Status instance. */ -function resolveStatus( - options: CreateContextOptions, +function resolveStatus( + options: CreateContextOptions, dc: DisplayConfig, commonDefaults: { readonly guide?: boolean; readonly output?: DisplayConfig['output'] } ): Status { diff --git a/packages/core/src/context/types.ts b/packages/core/src/context/types.ts index 404c7561..5cf66f3a 100644 --- a/packages/core/src/context/types.ts +++ b/packages/core/src/context/types.ts @@ -7,7 +7,6 @@ import type { AnyRecord, DeepReadonly, KiddArgs, - CliConfig, KiddStore, Merge, ResolvedDirs, @@ -717,39 +716,34 @@ export type ImperativeContextKeys = 'colors' | 'fail' | 'format' | 'prompts' * Context subset available inside `screen()` components via `useScreenContext()`. * * Retains `log` and `status` (swapped with React-backed implementations), - * data properties (`args`, `config`, `meta`, `store`), and any - * middleware-decorated properties (`auth`, `http`, `report`, etc.). + * data properties (`args`, `meta`, `store`), and any + * middleware-decorated properties (`auth`, `http`, `report`, `config`, etc.). * * Omits `colors`, `fail`, `format`, and `prompts` which have no * screen-safe equivalent. * * @typeParam TArgs - Parsed args type. - * @typeParam TConfig - Config type. */ -export type ScreenContext< - TArgs extends AnyRecord = AnyRecord, - TConfig extends AnyRecord = AnyRecord, -> = Omit, ImperativeContextKeys> +export type ScreenContext = Omit< + CommandContext, + ImperativeContextKeys +> /** * The context object threaded through every handler, middleware, and hook. * - * Contains framework-level primitives: parsed args, validated config, CLI - * metadata, a key-value store, formatting helpers, logging, prompts, - * status indicators, and a fail function. Additional capabilities (e.g. - * `report`, `auth`) are added by middleware via `decorateContext`. + * Contains framework-level primitives: parsed args, CLI metadata, a key-value + * store, formatting helpers, logging, prompts, status indicators, and a fail + * function. Additional capabilities (e.g. `config`, `report`, `auth`) are + * added by middleware via `decorateContext`. * - * All data properties (args, config, meta) are deeply readonly — attempting - * to mutate any nested property produces a compile-time error. Use `ctx.store` - * for mutable state that flows between middleware and handlers. + * All data properties (args, meta) are deeply readonly — attempting to mutate + * any nested property produces a compile-time error. Use `ctx.store` for + * mutable state that flows between middleware and handlers. * * @typeParam TArgs - Parsed args type (inferred from the command's zod/yargs args definition). - * @typeParam TConfig - Config type (inferred from the zod schema passed to `cli({ config: { schema } })`). */ -export interface CommandContext< - TArgs extends AnyRecord = AnyRecord, - TConfig extends AnyRecord = AnyRecord, -> { +export interface CommandContext { /** * Parsed and validated args for this command. Deeply immutable. */ @@ -761,11 +755,6 @@ export interface CommandContext< */ readonly colors: Colors - /** - * Runtime config validated against the zod schema. Deeply immutable. - */ - readonly config: DeepReadonly> - /** * Dot directory manager for reading/writing files in the CLI's * dot directories (e.g. `~/.myapp/`, `/.myapp/`). diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6f103d6d..41a164a6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,10 +8,8 @@ export { defineConfig } from '@kidd-cli/config' export { render, renderToString, screen, useScreenContext } from './screen/index.js' export type { ScreenDef, ScreenExit } from './screen/index.js' export type { - CliConfig, Command, CommandsConfig, - ConfigType, HelpOptions, MiddlewareEnv, Resolvable, diff --git a/packages/core/src/middleware/config/config.test.ts b/packages/core/src/middleware/config/config.test.ts new file mode 100644 index 00000000..70dfd601 --- /dev/null +++ b/packages/core/src/middleware/config/config.test.ts @@ -0,0 +1,348 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' + +import { createContext } from '@/context/index.js' + +import { config } from './config.js' +import type { ConfigHandle } from './types.js' + +const mockSpinnerInstance = vi.hoisted(() => ({ + message: vi.fn(), + start: vi.fn(), + stop: vi.fn(), +})) + +vi.mock(import('@clack/prompts'), async (importOriginal) => ({ + ...(await importOriginal()), + cancel: vi.fn(), + isCancel: vi.fn(() => false), + log: { + error: vi.fn(), + info: vi.fn(), + message: vi.fn(), + step: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + }, + spinner: vi.fn(() => mockSpinnerInstance), +})) + +const schema = z.object({ + name: z.string(), + port: z.number().default(3000), +}) + +type TestConfig = z.infer + +const validConfig: TestConfig = { name: 'test-app', port: 8080 } + +function createTmpDir(): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'kidd-config-mw-'))) + mkdirSync(join(dir, '.git'), { recursive: true }) + return dir +} + +function createTestContext(): ReturnType { + return createContext({ + args: {}, + argv: ['my-cli', 'test'], + meta: { + command: ['test'], + dirs: { global: '.my-cli', local: '.my-cli' }, + name: 'my-cli', + version: '1.0.0', + }, + }) +} + +function writeConfig(dir: string, data: Record): void { + writeFileSync(join(dir, 'my-cli.config.json'), JSON.stringify(data, null, 2)) +} + +function getHandle(ctx: ReturnType): ConfigHandle { + return (ctx as unknown as Record).config as ConfigHandle +} + +describe('config middleware', () => { + const originalCwd = process.cwd() + + afterEach(() => { + process.chdir(originalCwd) + }) + + describe('lazy mode (default)', () => { + it('should decorate ctx.config as a handle with load()', async () => { + const tmpDir = createTmpDir() + writeConfig(tmpDir, validConfig) + process.chdir(tmpDir) + + const ctx = createTestContext() + const mw = config({ schema }) + const next = vi.fn(() => Promise.resolve()) + + await mw.handler(ctx, next) + + const handle = getHandle(ctx) + expect(handle).toBeDefined() + expect(typeof handle.load).toBe('function') + expect(next).toHaveBeenCalledOnce() + + rmSync(tmpDir, { force: true, recursive: true }) + }) + + it('should load config from disk when load() is called', async () => { + const tmpDir = createTmpDir() + writeConfig(tmpDir, validConfig) + process.chdir(tmpDir) + + const ctx = createTestContext() + const mw = config({ schema }) + await mw.handler( + ctx, + vi.fn(() => Promise.resolve()) + ) + + const result = await getHandle(ctx).load() + + expect(result).not.toBeNull() + expect(result!.config).toMatchObject({ name: 'test-app', port: 8080 }) + + rmSync(tmpDir, { force: true, recursive: true }) + }) + + it('should return null when config validation fails', async () => { + const tmpDir = createTmpDir() + writeConfig(tmpDir, { invalid: true }) + process.chdir(tmpDir) + + const ctx = createTestContext() + const mw = config({ schema }) + await mw.handler( + ctx, + vi.fn(() => Promise.resolve()) + ) + + const result = await getHandle(ctx).load() + + expect(result).toBeNull() + + rmSync(tmpDir, { force: true, recursive: true }) + }) + }) + + describe('exitOnError', () => { + it('should return config directly with exitOnError', async () => { + const tmpDir = createTmpDir() + writeConfig(tmpDir, validConfig) + process.chdir(tmpDir) + + const ctx = createTestContext() + const mw = config({ schema }) + await mw.handler( + ctx, + vi.fn(() => Promise.resolve()) + ) + + const result = await getHandle(ctx).load({ exitOnError: true }) + + expect(result.config).toMatchObject({ name: 'test-app', port: 8080 }) + + rmSync(tmpDir, { force: true, recursive: true }) + }) + + it('should call ctx.fail() when exitOnError and load fails', async () => { + const tmpDir = createTmpDir() + writeConfig(tmpDir, { invalid: true }) + process.chdir(tmpDir) + + const ctx = createTestContext() + const mw = config({ schema }) + await mw.handler( + ctx, + vi.fn(() => Promise.resolve()) + ) + + await expect(getHandle(ctx).load({ exitOnError: true })).rejects.toThrow( + 'Failed to load config' + ) + + rmSync(tmpDir, { force: true, recursive: true }) + }) + }) + + describe('caching', () => { + it('should return cached result on subsequent load() calls', async () => { + const tmpDir = createTmpDir() + writeConfig(tmpDir, validConfig) + process.chdir(tmpDir) + + const ctx = createTestContext() + const mw = config({ schema }) + await mw.handler( + ctx, + vi.fn(() => Promise.resolve()) + ) + + const handle = getHandle(ctx) + const first = await handle.load() + const second = await handle.load() + + expect(first).toBe(second) + + rmSync(tmpDir, { force: true, recursive: true }) + }) + + it('should not cache null results', async () => { + const tmpDir = createTmpDir() + writeConfig(tmpDir, { invalid: true }) + process.chdir(tmpDir) + + const ctx = createTestContext() + const mw = config({ schema }) + await mw.handler( + ctx, + vi.fn(() => Promise.resolve()) + ) + + const handle = getHandle(ctx) + const first = await handle.load() + expect(first).toBeNull() + + const second = await handle.load() + expect(second).toBeNull() + + rmSync(tmpDir, { force: true, recursive: true }) + }) + }) + + describe('eager mode', () => { + it('should pre-load config during middleware pass', async () => { + const tmpDir = createTmpDir() + writeConfig(tmpDir, validConfig) + process.chdir(tmpDir) + + const ctx = createTestContext() + const mw = config({ eager: true, schema }) + await mw.handler( + ctx, + vi.fn(() => Promise.resolve()) + ) + + const result = await getHandle(ctx).load() + + expect(result).not.toBeNull() + expect(result!.config).toMatchObject({ name: 'test-app', port: 8080 }) + + rmSync(tmpDir, { force: true, recursive: true }) + }) + + it('should call ctx.fail() when eager load fails validation', async () => { + const tmpDir = createTmpDir() + writeConfig(tmpDir, { invalid: true }) + process.chdir(tmpDir) + + const ctx = createTestContext() + const mw = config({ eager: true, schema }) + + await expect( + mw.handler( + ctx, + vi.fn(() => Promise.resolve()) + ) + ).rejects.toThrow('Failed to load config') + + rmSync(tmpDir, { force: true, recursive: true }) + }) + }) + + describe('layered load', () => { + it('should merge layers and return layer metadata', async () => { + const tmpDir = createTmpDir() + writeConfig(tmpDir, validConfig) + process.chdir(tmpDir) + + const ctx = createTestContext() + const mw = config({ schema }) + await mw.handler( + ctx, + vi.fn(() => Promise.resolve()) + ) + + const result = await getHandle(ctx).load({ layers: true }) + + expect(result).not.toBeNull() + expect(result!.layers).toBeDefined() + expect(result!.layers).toHaveLength(3) + expect(result!.layers!.map((l) => l.name)).toEqual(['global', 'project', 'local']) + + rmSync(tmpDir, { force: true, recursive: true }) + }) + }) + + describe('single-layer load', () => { + it('should load config from the project layer', async () => { + const tmpDir = createTmpDir() + writeConfig(tmpDir, validConfig) + process.chdir(tmpDir) + + const ctx = createTestContext() + const mw = config({ schema }) + await mw.handler( + ctx, + vi.fn(() => Promise.resolve()) + ) + + const result = await getHandle(ctx).load({ layer: 'project' }) + + expect(result).not.toBeNull() + expect(result!.config).toMatchObject({ name: 'test-app', port: 8080 }) + + rmSync(tmpDir, { force: true, recursive: true }) + }) + }) + + describe('empty config validation', () => { + it('should return null when empty config fails schema validation', async () => { + const tmpDir = createTmpDir() + process.chdir(tmpDir) + + const ctx = createTestContext() + const strictSchema = z.object({ name: z.string() }) + const mw = config({ schema: strictSchema }) + await mw.handler( + ctx, + vi.fn(() => Promise.resolve()) + ) + + const result = await getHandle(ctx).load() + + expect(result).toBeNull() + + rmSync(tmpDir, { force: true, recursive: true }) + }) + + it('should apply schema defaults when no file exists', async () => { + const tmpDir = createTmpDir() + process.chdir(tmpDir) + + const ctx = createTestContext() + const defaultSchema = z.object({ port: z.number().default(3000) }) + const mw = config({ schema: defaultSchema }) + await mw.handler( + ctx, + vi.fn(() => Promise.resolve()) + ) + + const result = await getHandle<{ port: number }>(ctx).load() + + expect(result).not.toBeNull() + expect(result!.config.port).toBe(3000) + + rmSync(tmpDir, { force: true, recursive: true }) + }) + }) +}) diff --git a/packages/core/src/middleware/config/config.ts b/packages/core/src/middleware/config/config.ts new file mode 100644 index 00000000..b3190b1b --- /dev/null +++ b/packages/core/src/middleware/config/config.ts @@ -0,0 +1,346 @@ +import { join } from 'node:path' + +import { P, err, isPlainObject, match, merge, ok } from '@kidd-cli/utils/fp' +import type { Result } from '@kidd-cli/utils/fp' +import { validate } from '@kidd-cli/utils/validate' +import type { ZodTypeAny } from 'zod' +import { z } from 'zod' + +import { decorateContext } from '@/context/decorate.js' +import type { CommandContext } from '@/context/types.js' +import { createConfigClient } from '@/lib/config/client.js' +import type { ConfigLoadResult } from '@/lib/config/types.js' +import { resolveGlobalPath } from '@/lib/project/paths.js' +import { middleware } from '@/middleware.js' +import type { Middleware } from '@/types/index.js' + +import type { + ConfigHandle, + ConfigLayer, + ConfigLayerName, + ConfigLoadCallOptions, + ConfigLoadCallResult, + ConfigMiddlewareOptions, +} from './types.js' + +/** + * Permissive schema used when loading raw layer data without per-layer validation. + * Layers are validated only after merging. + * + * @private + */ +const RAW_SCHEMA = z.object({}).passthrough() + +/** + * Create a config middleware that decorates `ctx.config` with a lazy config handle. + * + * By default, config is loaded on-demand when `ctx.config.load()` is called. + * With `eager: true`, config is loaded during the middleware pass and cached + * so that subsequent `load()` calls return instantly. + * + * @param options - Config middleware options including schema, eager flag, and optional layers config. + * @returns A Middleware that decorates ctx.config with a {@link ConfigHandle}. + */ +export function config( + options: ConfigMiddlewareOptions +): Middleware { + return middleware(async (ctx, next) => { + const configName = options.name ?? ctx.meta.name + const handle = createConfigHandle({ configName, ctx, options }) + + decorateContext(ctx, 'config', handle) + + if (options.eager === true) { + const loadOptions = match(options.layers) + .with(true, (): ConfigLoadCallOptions => ({ exitOnError: true, layers: true })) + .otherwise((): ConfigLoadCallOptions => ({ exitOnError: true })) + await handle.load(loadOptions) + } + + return next() + }) +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +/** + * Parameters for the config handle factory. + * + * @private + */ +interface ConfigHandleParams { + readonly configName: string + readonly ctx: CommandContext + readonly options: ConfigMiddlewareOptions +} + +/** + * Create a closure-based config handle with lazy loading and caching. + * + * The handle reads config from disk on the first `load()` call and caches + * the result. Subsequent calls return the cached value without re-reading. + * Errors are not cached — a failed load can be retried. + * + * @private + * @param params - The config name, context, and middleware options. + * @returns A {@link ConfigHandle} instance. + */ +function createConfigHandle( + params: ConfigHandleParams +): ConfigHandle { + const { configName, ctx, options } = params + const { schema } = options + + /* eslint-disable -- closure-scoped mutable cache is intentional */ + let cached: ConfigLoadCallResult | null = null + /* eslint-enable */ + + /** + * Load config based on the provided options. + * + * Returns the config result or null on error. When `exitOnError` is true, + * calls `ctx.fail()` on error instead of returning null. + * + * @private + * @param callOptions - Resolution mode and error handling options. + * @returns The load result, or null on error. + */ + async function load( + callOptions?: ConfigLoadCallOptions + ): Promise | null> { + if (cached !== null) { + return cached + } + + const [resultError, result] = await match(callOptions) + .with({ layer: P.union('global', 'project', 'local') }, (opts) => + loadNamedLayer(configName, schema, opts.layer, ctx, options) + ) + .with({ layers: true }, () => loadLayered(configName, schema, ctx, options)) + .otherwise(() => loadSingle(configName, schema)) + + if (resultError) { + if (callOptions?.exitOnError === true) { + ctx.fail(`Failed to load config: ${resultError.message}`) + } + return null + } + + cached = result + return result + } + + return { load } as ConfigHandle +} + +/** + * Load config from a single directory (cwd) and validate. + * + * When no config file is found, validates an empty object against the schema + * to apply defaults and catch missing required fields. + * + * @private + * @param configName - The config file base name. + * @param schema - Zod schema for validation. + * @returns A Result tuple with the load result. + */ +async function loadSingle( + configName: string, + schema: ZodTypeAny +): Promise>> { + const client = createConfigClient({ name: configName, schema }) + const [loadError, result] = await client.load() + + if (loadError) { + return err(loadError) + } + + if (!result) { + return validateEmpty({}, schema) + } + + return ok({ config: result.config as Record }) +} + +/** + * Load config from all three layer directories, merge, validate, and return. + * + * Layers are loaded without per-layer schema validation so that partial configs + * (valid only after composition) can participate in the merge. The merged result + * is validated once against the full schema. + * + * Per-layer load errors are captured in layer metadata rather than halting + * the entire operation. + * + * @private + * @param configName - The config file base name. + * @param schema - Zod schema for validation. + * @param ctx - The command context. + * @param options - Middleware options with optional dir overrides. + * @returns A Result tuple with the merged config and layer metadata. + */ +async function loadLayered( + configName: string, + schema: TSchema, + ctx: CommandContext, + options: ConfigMiddlewareOptions +): Promise>> { + const globalDirName = options.dirs?.global ?? ctx.meta.dirs.global + const localDirName = options.dirs?.local ?? ctx.meta.dirs.local + + const layerDirs: readonly { readonly name: ConfigLayerName; readonly dir: string }[] = [ + { dir: resolveGlobalPath({ dirName: globalDirName }), name: 'global' }, + { dir: process.cwd(), name: 'project' }, + { dir: join(process.cwd(), localDirName), name: 'local' }, + ] + + const rawClient = createConfigClient({ name: configName, schema: RAW_SCHEMA }) + const layerResults = await Promise.all( + layerDirs.map((entry) => resolveLayer(rawClient.load, entry)) + ) + + const foundConfigs = layerResults + .filter((layer) => layer.config !== null) + .map((layer) => layer.config as Record) + + if (foundConfigs.length === 0) { + const [validationError, validated] = validate({ + createError: ({ message }) => new Error(`Invalid merged config:\n${message}`), + params: {}, + schema, + }) + + if (validationError) { + return err(validationError) + } + + return ok({ + config: validated as Record, + layers: layerResults, + }) + } + + const merged = foundConfigs.reduce( + (acc, layerConfig) => merge(acc, layerConfig), + {} as Record + ) + + const [validationError, validated] = validate({ + createError: ({ message }) => new Error(`Invalid merged config:\n${message}`), + params: merged, + schema, + }) + + if (validationError) { + return err(validationError) + } + + return ok({ + config: validated as Record, + layers: layerResults, + }) +} + +/** + * Load config from a specific named layer directory and validate. + * + * @private + * @param configName - The config file base name. + * @param schema - Zod schema for validation. + * @param layerName - The layer to load. + * @param ctx - The command context. + * @param options - Middleware options with optional dir overrides. + * @returns A Result tuple with the validated config from the named layer. + */ +async function loadNamedLayer( + configName: string, + schema: TSchema, + layerName: ConfigLayerName, + ctx: CommandContext, + options: ConfigMiddlewareOptions +): Promise>> { + const globalDirName = options.dirs?.global ?? ctx.meta.dirs.global + const localDirName = options.dirs?.local ?? ctx.meta.dirs.local + + const dir = match(layerName) + .with('global', () => resolveGlobalPath({ dirName: globalDirName })) + .with('project', () => process.cwd()) + .with('local', () => join(process.cwd(), localDirName)) + .exhaustive() + + const client = createConfigClient({ name: configName, schema }) + const [loadError, result] = await client.load(dir) + + if (loadError) { + return err(loadError) + } + + if (!result) { + return validateEmpty({}, schema) + } + + return ok({ config: result.config as Record }) +} + +/** + * Validate an empty object against a schema to apply defaults and surface missing required fields. + * + * Used when no config file is found. + * + * @private + * @param data - The raw data to validate. + * @param schema - Zod schema for validation. + * @returns A Result tuple with the validated config. + */ +function validateEmpty( + data: Record, + schema: ZodTypeAny +): Result> { + const [validationError, validated] = validate({ + createError: ({ message }) => new Error(`Invalid config:\n${message}`), + params: data, + schema, + }) + + if (validationError) { + return err(validationError) + } + + return ok({ config: validated as Record }) +} + +/** + * Load config from a single layer directory for use in layered resolution. + * + * Per-layer load errors are captured in the layer metadata rather than + * halting the entire middleware — a broken layer still participates in + * the merge with a null config. + * + * @private + * @param load - The config client's load function. + * @param entry - The layer name and directory. + * @returns A ConfigLayer with the loaded data or nulls. + */ +async function resolveLayer( + load: (cwd?: string) => Promise | null]>, + entry: { readonly name: ConfigLayerName; readonly dir: string } +): Promise { + const [loadError, result] = await load(entry.dir) + + if (loadError || !result) { + return { config: null, filePath: null, format: null, name: entry.name } + } + + const rawConfig = match(isPlainObject(result.config)) + .with(true, () => result.config as Record) + .otherwise(() => null) + + return { + config: rawConfig, + filePath: result.filePath, + format: result.format, + name: entry.name, + } +} diff --git a/packages/core/src/middleware/config/index.ts b/packages/core/src/middleware/config/index.ts new file mode 100644 index 00000000..c517a8b6 --- /dev/null +++ b/packages/core/src/middleware/config/index.ts @@ -0,0 +1,12 @@ +export { config } from './config.js' +export type { + ConfigHandle, + ConfigLayer, + ConfigLayerName, + ConfigLoadCallOptions, + ConfigLoadCallResult, + ConfigMiddlewareOptions, + ConfigRegistry, + ConfigType, + ResolvedConfig, +} from './types.js' diff --git a/packages/core/src/middleware/config/types.ts b/packages/core/src/middleware/config/types.ts new file mode 100644 index 00000000..13840003 --- /dev/null +++ b/packages/core/src/middleware/config/types.ts @@ -0,0 +1,186 @@ +import type { ZodType, ZodTypeAny, infer as ZodInfer } from 'zod' + +import type { ConfigFormat } from '@/lib/config/types.js' +import type { DeepReadonly } from '@/types/index.js' + +// --------------------------------------------------------------------------- +// Config layer types +// --------------------------------------------------------------------------- + +/** + * Names for configuration resolution layers. + */ +export type ConfigLayerName = 'global' | 'project' | 'local' + +/** + * Metadata for a single resolved configuration layer. + */ +export interface ConfigLayer { + /** Which layer this config came from. */ + readonly name: ConfigLayerName + /** Absolute path to the resolved config file, or null if not found. */ + readonly filePath: string | null + /** The format of the resolved config file, or null if not found. */ + readonly format: ConfigFormat | null + /** The raw config data loaded from this layer (pre-merge, pre-validation). */ + readonly config: Readonly> | null +} + +// --------------------------------------------------------------------------- +// Config load types +// --------------------------------------------------------------------------- + +/** + * Options for `ctx.config.load()`. + * + * Controls how config is resolved and how errors are handled. + */ +export interface ConfigLoadCallOptions { + /** + * Enable layered resolution (global > project > local merge). + * When true, returns layer metadata alongside the merged config. + */ + readonly layers?: boolean + /** + * Load a specific named layer only. Validates against the full schema. + * Mutually exclusive with `layers`. + */ + readonly layer?: ConfigLayerName + /** + * When true, calls `ctx.fail()` on load/validation errors instead of + * returning null. Guarantees a non-null return value. + */ + readonly exitOnError?: boolean +} + +/** + * Result returned by `ctx.config.load()`. + * + * @typeParam TConfig - The validated config type. + */ +export interface ConfigLoadCallResult { + /** Validated config data. Deeply readonly. */ + readonly config: TConfig + /** Per-layer metadata. Only present when `{ layers: true }` was passed. */ + readonly layers?: readonly ConfigLayer[] +} + +/** + * Config handle decorated onto `ctx.config` by the config middleware. + * + * Provides lazy, on-demand config loading with automatic caching. + * The first successful `load()` call reads from disk; subsequent calls + * return the cached result. + * + * @typeParam TConfig - The validated config type. + */ +export interface ConfigHandle { + /** + * Load and validate config from disk, or return the cached result. + * + * With `exitOnError: true`, calls `ctx.fail()` on error and guarantees + * a non-null return. Without it, returns `null` on error. + * + * @param options - Controls resolution mode and error handling. + * @returns The load result, or null on error (unless `exitOnError` is set). + */ + readonly load: { + (options: ConfigLoadCallOptions & { readonly exitOnError: true }): Promise< + ConfigLoadCallResult + > + (options?: ConfigLoadCallOptions): Promise | null> + } +} + +// --------------------------------------------------------------------------- +// Middleware options +// --------------------------------------------------------------------------- + +/** + * Options for the config middleware factory. + * + * @typeParam TSchema - Zod schema type used to validate the loaded config. + */ +export interface ConfigMiddlewareOptions { + /** + * Zod schema to validate the loaded config. Infers `ctx.config` type. + */ + readonly schema: TSchema + /** + * Override the config file base name. Default: derived from `ctx.meta.name`. + */ + readonly name?: string + /** + * Load config eagerly during the middleware pass (before the handler runs). + * When true, the result is cached so subsequent `load()` calls are instant. + * Default: false (lazy — config loaded on first `load()` call). + */ + readonly eager?: boolean + /** + * Enable layered config resolution with global > project > local merging. + * When true, config files are discovered at three locations and deep-merged + * with local-wins precedence. Only applies when `eager` is true; for lazy + * mode, pass `{ layers: true }` to `load()`. Default: false. + */ + readonly layers?: boolean + /** + * Override layer directories. Only applies when layered resolution is used. + */ + readonly dirs?: { + /** Override the global directory name. Default: `ctx.meta.dirs.global`. */ + readonly global?: string + /** Override the local directory name. Default: `ctx.meta.dirs.local`. */ + readonly local?: string + } +} + +// --------------------------------------------------------------------------- +// Utility types +// --------------------------------------------------------------------------- + +/** + * Derive the config type from a Zod schema for use in module augmentation. + * + * Use this in a `declare module` block to type `ctx.config` via the registry: + * + * ```ts + * import type { ConfigType } from '@kidd-cli/core/config' + * + * declare module '@kidd-cli/core/config' { + * interface ConfigRegistry extends ConfigType {} + * } + * ``` + */ +export type ConfigType = DeepReadonly> + +/** + * Registry interface for typed config. Consumers augment this to narrow `ctx.config`. + * + * When empty (no augmentation), `ctx.config` defaults to `DeepReadonly>`. + * When augmented, `ctx.config` resolves to the augmented type. + */ +export interface ConfigRegistry {} + +/** + * Resolved config type. Falls back to a generic record when no augmentation is provided. + */ +export type ResolvedConfig = keyof ConfigRegistry extends never + ? DeepReadonly> + : ConfigRegistry + +// --------------------------------------------------------------------------- +// Module augmentation +// --------------------------------------------------------------------------- + +declare module '@kidd-cli/core' { + interface CommandContext { + /** + * Config handle for lazy, on-demand config loading. + * Added by the config middleware (`@kidd-cli/core/config`). + * + * Call `ctx.config.load()` to read and validate config from disk. + * Results are cached after the first successful load. + */ + readonly config: ConfigHandle + } +} diff --git a/packages/core/src/middleware/http/http.test.ts b/packages/core/src/middleware/http/http.test.ts index 351aeb8c..08433840 100644 --- a/packages/core/src/middleware/http/http.test.ts +++ b/packages/core/src/middleware/http/http.test.ts @@ -155,6 +155,34 @@ describe('http()', () => { expect(next).toHaveBeenCalled() }) + it('should resolve headers from an async function', async () => { + const ctx = createMockCtx() + const asyncHeadersFn = vi.fn(() => Promise.resolve({ 'X-Async': 'async-value' })) + const mw = http({ + baseUrl: 'https://api.example.com', + headers: asyncHeadersFn, + namespace: 'api', + }) + const next = vi.fn() + + await mw.handler(ctx as never, next) + + expect(asyncHeadersFn).toHaveBeenCalledWith(ctx) + + const client = (ctx as Record)['api'] as { + get: (path: string) => Promise + } + await client.get('/test') + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.example.com/test', + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Async': 'async-value' }), + }) + ) + expect(next).toHaveBeenCalled() + }) + it('should call next after decorating context', async () => { const ctx = createMockCtx() const mw = http({ baseUrl: 'https://api.example.com', namespace: 'api' }) diff --git a/packages/core/src/middleware/http/http.ts b/packages/core/src/middleware/http/http.ts index 487b5b38..286420f3 100644 --- a/packages/core/src/middleware/http/http.ts +++ b/packages/core/src/middleware/http/http.ts @@ -24,8 +24,8 @@ import type { HttpOptions } from './types.js' export function http(options: HttpOptions): Middleware { const { namespace, baseUrl, headers } = options - return middleware((ctx, next) => { - const resolvedHeaders = resolveHeaders(ctx, headers) + return middleware(async (ctx, next) => { + const resolvedHeaders = await resolveHeaders(ctx, headers) const client = createHttpClient({ baseUrl, @@ -45,18 +45,19 @@ export function http(options: HttpOptions): Middleware { /** * Resolve headers from the options value. * - * Calls the function form with ctx when provided, returns static headers - * directly, or returns undefined when no headers are configured. + * Calls the function form with ctx when provided (awaiting if it returns a + * Promise), returns static headers directly, or returns undefined when no + * headers are configured. * * @private * @param ctx - The context object. - * @param headers - The headers option (static, function, or undefined). + * @param headers - The headers option (static, sync/async function, or undefined). * @returns The resolved headers record or undefined. */ -function resolveHeaders( +async function resolveHeaders( ctx: CommandContext, headers: HttpOptions['headers'] -): Readonly> | undefined { +): Promise> | undefined> { if (headers === undefined) { return undefined } diff --git a/packages/core/src/middleware/http/types.ts b/packages/core/src/middleware/http/types.ts index 8e455cd7..502b6d78 100644 --- a/packages/core/src/middleware/http/types.ts +++ b/packages/core/src/middleware/http/types.ts @@ -83,5 +83,7 @@ export interface HttpOptions { readonly baseUrl: string readonly headers?: | Readonly> - | ((ctx: CommandContext) => Readonly>) + | (( + ctx: CommandContext + ) => Readonly> | Promise>>) } diff --git a/packages/core/src/runtime/runtime.test.ts b/packages/core/src/runtime/runtime.test.ts index 205bbdc8..b2a979a0 100644 --- a/packages/core/src/runtime/runtime.test.ts +++ b/packages/core/src/runtime/runtime.test.ts @@ -8,10 +8,6 @@ vi.mock(import('@/context/index.js'), () => ({ createContext: vi.fn(() => ({ mock: 'context' })), })) -vi.mock(import('@/lib/config/index.js'), () => ({ - createConfigClient: vi.fn(), -})) - vi.mock(import('./args/index.js'), () => ({ createArgsParser: vi.fn(), })) @@ -21,12 +17,10 @@ vi.mock(import('./runner.js'), () => ({ })) const { createContext } = await import('@/context/index.js') -const { createConfigClient } = await import('@/lib/config/index.js') const { createArgsParser } = await import('./args/index.js') const { createMiddlewareExecutor } = await import('./runner.js') const mockedCreateContext = vi.mocked(createContext) -const mockedCreateConfigClient = vi.mocked(createConfigClient) const mockedCreateArgsParser = vi.mocked(createArgsParser) const mockedCreateRunner = vi.mocked(createMiddlewareExecutor) @@ -72,88 +66,6 @@ describe('createRuntime()', () => { expect(runtime).toHaveProperty('execute') }) - it('should use empty config when no config options provided', async () => { - setupDefaults() - - const { createRuntime } = await import('./runtime.js') - const [, runtime] = await createRuntime({ - dirs: { global: '.my-cli', local: '.my-cli' }, - name: 'my-cli', - version: '1.0.0', - }) - - const execution = makeExecution() - await runtime!.execute(execution) - - expect(mockedCreateContext).toHaveBeenCalledWith(expect.objectContaining({ config: {} })) - }) - - it('should use empty config when config schema is undefined', async () => { - setupDefaults() - - const { createRuntime } = await import('./runtime.js') - const [, runtime] = await createRuntime({ - config: {} as never, - dirs: { global: '.my-cli', local: '.my-cli' }, - name: 'my-cli', - version: '1.0.0', - }) - - const execution = makeExecution() - await runtime!.execute(execution) - - expect(mockedCreateConfigClient).not.toHaveBeenCalled() - expect(mockedCreateContext).toHaveBeenCalledWith(expect.objectContaining({ config: {} })) - }) - - it('should use empty config when config client load returns error', async () => { - setupDefaults() - const mockLoad = vi.fn().mockResolvedValue([new Error('config not found'), null]) - mockedCreateConfigClient.mockReturnValue({ load: mockLoad } as never) - - const { z } = await import('zod') - const schema = z.object({ debug: z.boolean() }) - - const { createRuntime } = await import('./runtime.js') - const [, runtime] = await createRuntime({ - config: { schema }, - dirs: { global: '.my-cli', local: '.my-cli' }, - name: 'my-cli', - version: '1.0.0', - }) - - const execution = makeExecution() - await runtime!.execute(execution) - - expect(mockedCreateConfigClient).toHaveBeenCalled() - expect(mockedCreateContext).toHaveBeenCalledWith(expect.objectContaining({ config: {} })) - }) - - it('should use loaded config when config client load succeeds', async () => { - setupDefaults() - const loadedConfig = { debug: true } - const mockLoad = vi.fn().mockResolvedValue([null, { config: loadedConfig }]) - mockedCreateConfigClient.mockReturnValue({ load: mockLoad } as never) - - const { z } = await import('zod') - const schema = z.object({ debug: z.boolean() }) - - const { createRuntime } = await import('./runtime.js') - const [, runtime] = await createRuntime({ - config: { schema }, - dirs: { global: '.my-cli', local: '.my-cli' }, - name: 'my-cli', - version: '1.0.0', - }) - - const execution = makeExecution() - await runtime!.execute(execution) - - expect(mockedCreateContext).toHaveBeenCalledWith( - expect.objectContaining({ config: loadedConfig }) - ) - }) - it('should return err when arg parsing fails', async () => { setupDefaults() mockedCreateArgsParser.mockReturnValue({ @@ -241,7 +153,6 @@ describe('createRuntime()', () => { expect(mockedCreateContext).toHaveBeenCalledWith({ args: validatedArgs, - config: {}, meta: { command: ['build', 'all'], dirs: { global: '.my-cli', local: '.my-cli' }, diff --git a/packages/core/src/runtime/runtime.ts b/packages/core/src/runtime/runtime.ts index cc9f26cb..a86b2379 100644 --- a/packages/core/src/runtime/runtime.ts +++ b/packages/core/src/runtime/runtime.ts @@ -1,30 +1,25 @@ import { attemptAsync, err, ok } from '@kidd-cli/utils/fp' import type { ResultAsync } from '@kidd-cli/utils/fp' -import type { z } from 'zod' import { createContext } from '@/context/index.js' import type { CommandContext } from '@/context/types.js' -import type { CliConfigOptions, Middleware } from '@/types/index.js' +import type { Middleware } from '@/types/index.js' import { createArgsParser } from './args/index.js' import { createMiddlewareExecutor } from './runner.js' import type { ResolvedExecution, Runtime, RuntimeOptions } from './types.js' /** - * Create a runtime that orchestrates config loading and middleware execution. + * Create a runtime that orchestrates middleware execution. * - * Loads config up front, then captures it in a closure alongside a runner. + * Captures middleware in a closure alongside a runner. * The returned `runtime.execute` method handles arg parsing, context creation, * and middleware chain execution for each command invocation. * - * @param options - Runtime configuration including name, version, config, and middleware. - * @returns An ResultAsync containing the runtime or an error. + * @param options - Runtime configuration including name, version, and middleware. + * @returns A ResultAsync containing the runtime or an error. */ -export async function createRuntime( - options: RuntimeOptions -): ResultAsync { - const config = await resolveConfig(options.config, options.name) - +export async function createRuntime(options: RuntimeOptions): ResultAsync { const middleware: Middleware[] = options.middleware ?? [] const runner = createMiddlewareExecutor(middleware) @@ -42,7 +37,6 @@ export async function createRuntime( const ctx = createContext({ args: validatedArgs, argv: options.argv, - config, display: options.display, log: options.log, meta: { @@ -76,39 +70,3 @@ export async function createRuntime( return ok(runtime) } - -// --------------------------------------------------------------------------- -// Private -// --------------------------------------------------------------------------- - -/** - * Load and validate a config file via the config client. - * - * Returns the validated config record or an empty object when no config - * options are provided or when loading fails. - * - * @private - * @param configOptions - Config loading options with schema and optional name override. - * @param defaultName - Fallback config file name derived from the CLI name. - * @returns The loaded config record or an empty object. - */ -async function resolveConfig( - configOptions: CliConfigOptions | undefined, - defaultName: string -): Promise> { - if (!configOptions || !configOptions.schema) { - return {} - } - const { createConfigClient } = await import('@/lib/config/index.js') - const client = createConfigClient({ - name: configOptions.name ?? defaultName, - schema: configOptions.schema, - }) - const [configError, configResult] = await client.load() - if (configError || !configResult) { - return {} - } - // Accepted exception: configResult.config is generic TOutput from zod schema. - // The cast bridges the generic boundary to the internal Record type. - return configResult.config as Record -} diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index 075dbe92..d3f7b185 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -1,24 +1,16 @@ import type { ResultAsync, Result } from '@kidd-cli/utils/fp' -import type { z } from 'zod' import type { CommandContext, DisplayConfig, Log, Prompts, Status } from '@/context/types.js' -import type { - ArgsDef, - CliConfigOptions, - Middleware, - ResolvedDirs, - ScreenRenderFn, -} from '@/types/index.js' +import type { ArgsDef, Middleware, ResolvedDirs, ScreenRenderFn } from '@/types/index.js' /** * Options for creating a runtime via `createRuntime`. */ -export interface RuntimeOptions { +export interface RuntimeOptions { readonly name: string readonly version: string readonly argv: readonly string[] readonly dirs: ResolvedDirs - readonly config?: CliConfigOptions readonly middleware?: Middleware[] readonly display?: DisplayConfig readonly log?: Log diff --git a/packages/core/src/stories/importer.ts b/packages/core/src/stories/importer.ts index 612fa3c5..3aeac3a8 100644 --- a/packages/core/src/stories/importer.ts +++ b/packages/core/src/stories/importer.ts @@ -1,9 +1,8 @@ import Module from 'node:module' -import type { createJiti } from 'jiti' - import { toError } from '@kidd-cli/utils/fp' import { hasTag } from '@kidd-cli/utils/tag' +import type { createJiti } from 'jiti' import type { StoryEntry } from './types.js' @@ -11,7 +10,9 @@ import type { StoryEntry } from './types.js' * A story importer that can load `.stories.{tsx,ts,jsx,js}` files. */ export interface StoryImporter { - readonly importStory: (filePath: string) => Promise + readonly importStory: ( + filePath: string + ) => Promise } /** @@ -52,7 +53,10 @@ export function createStoryImporter(): readonly [Error, null] | readonly [null, const entry = (mod.default ?? mod) as unknown if (!isStoryEntry(entry)) { - return [new Error(`File ${filePath} does not export a valid Story or StoryGroup`), null] + return [ + new Error(`File ${filePath} does not export a valid Story or StoryGroup`), + null, + ] } return [null, entry] diff --git a/packages/core/src/stories/viewer/stories-screen.tsx b/packages/core/src/stories/viewer/stories-screen.tsx index edeac80b..bb6c88ce 100644 --- a/packages/core/src/stories/viewer/stories-screen.tsx +++ b/packages/core/src/stories/viewer/stories-screen.tsx @@ -145,7 +145,8 @@ function StoriesViewer({ include }: { readonly include?: string }): ReactElement {errors.length} file(s) failed to import: {errors.map((e) => ( - {' '}{e.filePath}: {e.message} + {' '} + {e.filePath}: {e.message} ))} diff --git a/packages/core/src/test/command.ts b/packages/core/src/test/command.ts index e5a03d5e..9118e6b2 100644 --- a/packages/core/src/test/command.ts +++ b/packages/core/src/test/command.ts @@ -40,7 +40,6 @@ export async function runCommand(options: RunCommandOptions): Promise { expect(ctx.args.name).toBe('Alice') }) - it('should accept config overrides', () => { - const { ctx } = createTestContext({ config: { debug: true } }) - expect(ctx.config.debug).toBeTruthy() - }) - it('should accept meta overrides', () => { const { ctx } = createTestContext({ meta: { command: ['deploy'], name: 'my-cli', version: '2.0.0' }, diff --git a/packages/core/src/test/context.ts b/packages/core/src/test/context.ts index 0d912139..31cec723 100644 --- a/packages/core/src/test/context.ts +++ b/packages/core/src/test/context.ts @@ -27,21 +27,19 @@ import type { PromptResponses, TestContextOptions, TestContextResult } from './t * @param overrides - Optional overrides for args, config, meta, log, prompts, or status. * @returns A TestContextResult with the context and a stdout accessor. */ -export function createTestContext< - TArgs extends AnyRecord = AnyRecord, - TConfig extends AnyRecord = AnyRecord, ->(overrides?: TestContextOptions): TestContextResult { - const opts = overrides ?? ({} as TestContextOptions) +export function createTestContext( + overrides?: TestContextOptions +): TestContextResult { + const opts = overrides ?? ({} as TestContextOptions) const { output, stream } = createWritableCapture() const log = resolveLog(opts, stream) const prompts = resolvePrompts(opts) const status = resolveStatus(opts) const meta = resolveMeta(opts) - const ctx = createContext({ + const ctx = createContext({ args: (opts.args ?? {}) as TArgs, argv: [meta.name, ...meta.command], - config: (opts.config ?? {}) as TConfig, log, meta, prompts, diff --git a/packages/core/src/test/handler.ts b/packages/core/src/test/handler.ts index f5e9f4fc..e504456a 100644 --- a/packages/core/src/test/handler.ts +++ b/packages/core/src/test/handler.ts @@ -16,11 +16,11 @@ import type { HandlerResult, RunHandlerOptions } from './types.js' * @param options - The command and optional test context overrides. * @returns A HandlerResult with the context, captured stdout, and any error. */ -export async function runHandler< - TArgs extends AnyRecord = AnyRecord, - TConfig extends AnyRecord = AnyRecord, ->({ cmd, overrides }: RunHandlerOptions): Promise> { - const { ctx, stdout } = createTestContext(overrides) +export async function runHandler({ + cmd, + overrides, +}: RunHandlerOptions): Promise> { + const { ctx, stdout } = createTestContext(overrides) if (!cmd.handler) { return { ctx, error: undefined, stdout } diff --git a/packages/core/src/test/middleware.ts b/packages/core/src/test/middleware.ts index 17bf1429..2bc8cfd6 100644 --- a/packages/core/src/test/middleware.ts +++ b/packages/core/src/test/middleware.ts @@ -12,14 +12,11 @@ import type { MiddlewareResult, RunMiddlewareOptions } from './types.js' * @param options - The middlewares and optional test context overrides. * @returns A MiddlewareResult with the context and captured stdout. */ -export async function runMiddleware< - TArgs extends AnyRecord = AnyRecord, - TConfig extends AnyRecord = AnyRecord, ->({ +export async function runMiddleware({ middlewares, overrides, -}: RunMiddlewareOptions): Promise> { - const { ctx, stdout } = createTestContext(overrides) +}: RunMiddlewareOptions): Promise> { + const { ctx, stdout } = createTestContext(overrides) await executeChain(middlewares, 0, ctx, async () => {}) diff --git a/packages/core/src/test/types.ts b/packages/core/src/test/types.ts index 349b75e7..858ebbaf 100644 --- a/packages/core/src/test/types.ts +++ b/packages/core/src/test/types.ts @@ -1,14 +1,7 @@ import type { vi } from 'vitest' import type { CommandContext, Log, Prompts, Status } from '@/context/types.js' -import type { - AnyRecord, - CliConfigOptions, - Command, - CommandMap, - Middleware, - ResolvedDirs, -} from '@/types/index.js' +import type { AnyRecord, Command, CommandMap, Middleware, ResolvedDirs } from '@/types/index.js' /** * Overrides for constructing a test context via {@link createTestContext}. @@ -16,14 +9,9 @@ import type { * All fields are optional — sensible defaults are provided for each. * * @typeParam TArgs - Parsed args type for the context. - * @typeParam TConfig - Config type for the context. */ -export interface TestContextOptions< - TArgs extends AnyRecord = AnyRecord, - TConfig extends AnyRecord = AnyRecord, -> { +export interface TestContextOptions { readonly args?: TArgs - readonly config?: TConfig readonly meta?: { readonly name?: string readonly version?: string @@ -39,13 +27,9 @@ export interface TestContextOptions< * Result of {@link createTestContext}. * * @typeParam TArgs - Parsed args type for the context. - * @typeParam TConfig - Config type for the context. */ -export interface TestContextResult< - TArgs extends AnyRecord = AnyRecord, - TConfig extends AnyRecord = AnyRecord, -> { - readonly ctx: CommandContext +export interface TestContextResult { + readonly ctx: CommandContext readonly stdout: () => string } @@ -53,13 +37,9 @@ export interface TestContextResult< * Result of {@link runHandler}. * * @typeParam TArgs - Parsed args type for the context. - * @typeParam TConfig - Config type for the context. */ -export interface HandlerResult< - TArgs extends AnyRecord = AnyRecord, - TConfig extends AnyRecord = AnyRecord, -> { - readonly ctx: CommandContext +export interface HandlerResult { + readonly ctx: CommandContext readonly stdout: () => string readonly error: Error | undefined } @@ -68,13 +48,9 @@ export interface HandlerResult< * Result of {@link runMiddleware}. * * @typeParam TArgs - Parsed args type for the context. - * @typeParam TConfig - Config type for the context. */ -export interface MiddlewareResult< - TArgs extends AnyRecord = AnyRecord, - TConfig extends AnyRecord = AnyRecord, -> { - readonly ctx: CommandContext +export interface MiddlewareResult { + readonly ctx: CommandContext readonly stdout: () => string } @@ -100,28 +76,20 @@ export interface PromptResponses { * Options for {@link runHandler}. * * @typeParam TArgs - Parsed args type for the context. - * @typeParam TConfig - Config type for the context. */ -export interface RunHandlerOptions< - TArgs extends AnyRecord = AnyRecord, - TConfig extends AnyRecord = AnyRecord, -> { +export interface RunHandlerOptions { readonly cmd: Command - readonly overrides?: TestContextOptions + readonly overrides?: TestContextOptions } /** * Options for {@link runMiddleware}. * * @typeParam TArgs - Parsed args type for the context. - * @typeParam TConfig - Config type for the context. */ -export interface RunMiddlewareOptions< - TArgs extends AnyRecord = AnyRecord, - TConfig extends AnyRecord = AnyRecord, -> { +export interface RunMiddlewareOptions { readonly middlewares: readonly Middleware[] - readonly overrides?: TestContextOptions + readonly overrides?: TestContextOptions } /** @@ -133,7 +101,6 @@ export interface RunCommandOptions { readonly name?: string readonly version?: string readonly middleware?: Middleware[] - readonly config?: CliConfigOptions } /** diff --git a/packages/core/src/types/cli.ts b/packages/core/src/types/cli.ts index bc8e80d5..ea50fbc3 100644 --- a/packages/core/src/types/cli.ts +++ b/packages/core/src/types/cli.ts @@ -1,5 +1,3 @@ -import type { z } from 'zod' - import type { DisplayConfig, Log, Prompts, Status } from '@/context/types.js' import type { CommandMap, CommandsConfig } from './command.js' @@ -14,20 +12,6 @@ import type { Middleware } from './middleware.js' */ export interface KiddArgs {} -/** - * Global config merged into every ctx.config. - * - * Extend via module augmentation with {@link ConfigType} to derive the - * shape from your Zod schema: - * - * ```ts - * declare module '@kidd-cli/core' { - * interface CliConfig extends ConfigType {} - * } - * ``` - */ -export interface CliConfig {} - /** * Global store keys merged into every ctx.store. */ @@ -61,20 +45,6 @@ export interface ResolvedDirs { readonly global: string } -/** - * Config loading options nested inside {@link CliOptions}. - */ -export interface CliConfigOptions { - /** - * Zod schema to validate the loaded config. Infers `ctx.config` type. - */ - readonly schema?: TSchema - /** - * Override the config file name. Default: derived from `name` in CliOptions. - */ - readonly name?: string -} - /** * Help output customization options. * @@ -103,7 +73,7 @@ export interface HelpOptions { /** * Options passed to `cli()`. */ -export interface CliOptions { +export interface CliOptions { /** * CLI name. Used for help text and config file discovery. */ @@ -120,10 +90,6 @@ export interface CliOptions { * Human-readable description shown in help text. */ readonly description?: string - /** - * Runtime config options (schema, config file name override). - */ - readonly config?: CliConfigOptions /** * Middleware stack. Executed in order before each command handler. */ @@ -179,6 +145,4 @@ export interface CliOptions { /** * Signature of the `cli()` entry point function. */ -export type CliFn = ( - options: CliOptions -) => Promise +export type CliFn = (options: CliOptions) => Promise diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index 366ca479..ed3f367b 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -107,14 +107,12 @@ type InferSingleArgsDef = * Handler function for a command. Receives the fully typed context. * * @typeParam TArgs - Parsed args type. - * @typeParam TConfig - Config type. * @typeParam TVars - Context variables contributed by typed middleware. */ export type HandlerFn< TArgs extends AnyRecord = AnyRecord, - TConfig extends AnyRecord = AnyRecord, TVars = {}, // eslint-disable-line @typescript-eslint/ban-types -- empty intersection identity -> = (ctx: CommandContext & Readonly) => Promise | void +> = (ctx: CommandContext & Readonly) => Promise | void /** * Internal render function signature used by `screen()` commands. @@ -151,13 +149,11 @@ export interface CommandsConfig { * * @typeParam TOptionsDef - Option (flag) definitions type. * @typeParam TPositionalsDef - Positional argument definitions type. - * @typeParam TConfig - Config type. * @typeParam TMiddleware - Tuple of typed middleware, preserving per-element `TEnv`. */ export interface CommandDef< TOptionsDef extends ArgsDef = ArgsDef, TPositionalsDef extends ArgsDef = ArgsDef, - TConfig extends AnyRecord = AnyRecord, TMiddleware extends readonly Middleware[] = readonly Middleware[], > { /** @@ -238,7 +234,6 @@ export interface CommandDef< */ readonly handler?: HandlerFn< InferArgsMerged, - TConfig, InferVariables > } @@ -249,7 +244,6 @@ export interface CommandDef< export type Command< TOptionsDef extends ArgsDef = ArgsDef, TPositionalsDef extends ArgsDef = ArgsDef, - TConfig extends AnyRecord = AnyRecord, TMiddleware extends readonly Middleware[] = readonly Middleware[], > = Tagged< { @@ -267,7 +261,6 @@ export type Command< readonly help?: HelpOptions readonly handler?: HandlerFn< InferArgsMerged, - TConfig, InferVariables > }, @@ -297,9 +290,8 @@ export interface AutoloadOptions { export type CommandFn = < TOptionsDef extends ArgsDef = ArgsDef, TPositionalsDef extends ArgsDef = ArgsDef, - TConfig extends AnyRecord = AnyRecord, const TMiddleware extends readonly Middleware[] = readonly Middleware[], >( - def: CommandDef + def: CommandDef ) => Command diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index d5c12cdd..76ca5c4b 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -1,6 +1,5 @@ export type { AnyRecord, - ConfigType, DeepReadonly, InferSchema, IsAny, @@ -37,8 +36,6 @@ export type { } from './command.js' export type { - CliConfig, - CliConfigOptions, CliFn, CliOptions, DirsConfig, diff --git a/packages/core/src/types/utility.ts b/packages/core/src/types/utility.ts index 3256c532..ce9eaef2 100644 --- a/packages/core/src/types/utility.ts +++ b/packages/core/src/types/utility.ts @@ -1,4 +1,4 @@ -import type { z } from 'zod' +import type { ZodType } from 'zod' // --------------------------------------------------------------------------- // Generic type utilities @@ -58,20 +58,4 @@ export type UnionToIntersection = (U extends unknown ? (x: U) => void : never /** * Extract the inferred output type from a zod schema, or fall back to a plain object. */ -export type InferSchema = TSchema extends z.ZodType ? TOutput : AnyRecord - -/** - * Derive the config type from a Zod schema for use in module augmentation. - * - * Use this in a `declare module` block to keep `CliConfig` in sync with - * your Zod config schema, eliminating manual type duplication: - * - * ```ts - * import type { ConfigType } from '@kidd-cli/core' - * - * declare module '@kidd-cli/core' { - * interface CliConfig extends ConfigType {} - * } - * ``` - */ -export type ConfigType = z.infer +export type InferSchema = TSchema extends ZodType ? TOutput : AnyRecord diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 5fa540f3..4239c879 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ 'lib/format': 'src/lib/format/index.ts', 'lib/project': 'src/lib/project/index.ts', 'lib/store': 'src/lib/store/index.ts', + 'middleware/config': 'src/middleware/config/index.ts', 'middleware/auth': 'src/middleware/auth/index.ts', 'middleware/http': 'src/middleware/http/index.ts', 'middleware/figures': 'src/middleware/figures/index.ts',