diff --git a/.ai/plans/dependency-injection/implementation-plan.md b/.ai/plans/dependency-injection/implementation-plan.md new file mode 100644 index 000000000..614267150 --- /dev/null +++ b/.ai/plans/dependency-injection/implementation-plan.md @@ -0,0 +1,1411 @@ +# Dependency Injection Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add lightweight DI via `.provide()` + `getCommandContext()` with AsyncLocalStorage-based context propagation to cli-forge. + +**Architecture:** AsyncLocalStorage scopes execution context per `forge()`/`sdk()` call. The CLI instance is a type-level witness for `getCommandContext()` — ALS handles runtime lookup. Browser fallback uses a simple last-set-wins store. Provider execution is deferred to handler phase. + +**Tech Stack:** TypeScript, AsyncLocalStorage (`node:async_hooks`), tsdown browser aliasing + +--- + +### Task 1: AnyCLI / AnyInternalCLI Type Aliases + +**Goal:** Replace 45 scattered `` casts with named type aliases. This is a pure refactor prerequisite before adding the 5th generic. + +**Files:** +- Modify: `packages/cli-forge/src/lib/public-api.ts` (add `AnyCLI`, deprecate `UnknownCLI`) +- Modify: `packages/cli-forge/src/lib/internal-cli.ts` (add `AnyInternalCLI`, replace ~17 occurrences) +- Modify: `packages/cli-forge/src/lib/resolve-completions.ts` (replace ~11 occurrences) +- Modify: `packages/cli-forge/src/lib/documentation.ts` (replace 1 occurrence) +- Modify: `packages/cli-forge/src/lib/format-help.ts` (replace 1 occurrence, plus parameter type) +- Modify: `packages/cli-forge/src/lib/composable-builder.ts` (update `ExtractChildren`, `ExtractArgs`) +- Modify: `packages/cli-forge/src/lib/cli-option-groups.ts` (replace 2 occurrences) +- Modify: `packages/cli-forge/src/lib/interactive-shell.ts` (replace 1 occurrence) +- Modify: `packages/cli-forge/src/browser/interactive-shell.ts` (replace 1 occurrence) +- Modify: `packages/cli-forge/src/lib/test-harness.ts` (replace `InternalCLI` bare usage in `mockHandler`) +- Modify: `packages/cli-forge/src/index.ts` (export `AnyCLI`) + +**Step 1: Add type aliases** + +In `public-api.ts`, near the existing `UnknownCLI` at line 1107: + +```typescript +/** Type alias for a CLI instance with any type parameters. */ +export type AnyCLI = CLI; + +/** @deprecated Use AnyCLI instead */ +export type UnknownCLI = AnyCLI; +``` + +In `internal-cli.ts`, after imports: + +```typescript +export type AnyInternalCLI = InternalCLI; +``` + +**Step 2: Replace all occurrences** + +In each file, replace `InternalCLI` with `AnyInternalCLI` and `CLI` with `AnyCLI`. For single-`any` usages like `InternalCLI`, replace with `AnyInternalCLI` as well since they serve the same purpose. + +Key replacements in `internal-cli.ts`: +- Line 90: `obj is AnyInternalCLI` +- Line 101: `Record` +- Line 112: `private _parent?: AnyInternalCLI` +- Line 206, 377, 629, 710, 711, 771, 831, 871, 873, 874, 1062, 1114, 1206: all `AnyInternalCLI` +- Line 743: `CLI` → `AnyCLI` + +In `resolve-completions.ts`: Lines 7, 16, 21, 93, 94, 98, 103, 120, 121, 191, 197 → `AnyInternalCLI` + +In `documentation.ts`: Line 150 → `AnyInternalCLI` + +In `format-help.ts`: Lines 10, 44 → `AnyInternalCLI` + +In `composable-builder.ts`: Lines 7, 14 → use `AnyCLI` in the extends clauses + +In `cli-option-groups.ts`: Lines 4, 22 → `AnyInternalCLI` + +In `interactive-shell.ts` (both node and browser): constructor param → `AnyInternalCLI` + +In `test-harness.ts`: Line 60 `mockHandler` param → `AnyInternalCLI` + +**Step 3: Export AnyCLI from index.ts** + +Add to exports in `packages/cli-forge/src/index.ts`: + +```typescript +export type { AnyCLI } from './lib/public-api'; +``` + +**Step 4: Build and test** + +Run: `nx build cli-forge && nx test cli-forge && nx test type-tests` +Expected: All pass, no behavior change. + +**Step 5: Commit** + +``` +refactor(cli-forge): replace scattered any casts with AnyCLI/AnyInternalCLI aliases +``` + +--- + +### Task 2: Add TProviders Generic to CLI Interface + +**Goal:** Add the 5th generic parameter `TProviders = {}` to CLI and InternalCLI. All existing methods pass it through unchanged. No new methods yet. + +**Files:** +- Modify: `packages/cli-forge/src/lib/public-api.ts` (CLI interface, all method return types, cli() factory, SDK types) +- Modify: `packages/cli-forge/src/lib/internal-cli.ts` (InternalCLI class declaration, clone()) +- Modify: `packages/cli-forge/src/lib/composable-builder.ts` (ComposableBuilder type) +- Modify: `packages/cli-forge/src/lib/test-harness.ts` (TestHarness generic) +- Create: `type-tests/fixtures/providers-basic.ts` (type test for TProviders passthrough) + +**Step 1: Write the type test** + +Create `type-tests/fixtures/providers-basic.ts`: + +```typescript +import { cli } from 'cli-forge'; +import { IsTrue, AssertEqual } from '../assertions/helpers'; + +// TProviders defaults to {} and passes through option/command chains +const app = cli('test') + .option('name', { type: 'string' }) + .command('sub', { + builder: (cmd) => cmd.option('port', { type: 'number' }), + handler: () => {}, + }); + +// Verify the CLI type still works - args are inferred +type AppArgs = Parameters[0]>[0]; +type _test1 = IsTrue>; +``` + +**Step 2: Update CLI interface declaration** + +In `public-api.ts` line 91-97, add `TProviders = {}`: + +```typescript +export interface CLI< + TArgs extends ParsedArgs = ParsedArgs, + THandlerReturn = void, + TChildren = {}, + TParent = undefined, + TProviders = {} +> +``` + +Update every method that returns `CLI` to include `TProviders` as the 5th parameter. This includes: + +- `command()` overloads (lines 98-151) — return type adds `, TProviders` +- `commands()` overloads (lines 161-401) — return type adds `, TProviders` +- `option()` overloads (lines 467-584) — return type adds `, TProviders` +- `positional()` overloads (lines 596-694) — return type adds `, TProviders` +- `middleware()` (line 823-830) — return type adds `, TProviders` +- `handler()` (lines 849-854) — return type adds `, TProviders` +- All simple passthrough methods (`config`, `updateConfig`, `enableInteractiveShell`, `errorHandler`, `withPromptProvider`, `conflicts`, `implies`, `demandCommand`, `strict`, `usage`, `examples`, `version`, `group`, `env`, `localize`, `init`, `completion`) + +Update `AnyCLI`: + +```typescript +export type AnyCLI = CLI; +``` + +Update `UnknownCLI`, `CLIHandlerContext`, `SDKCommand`, `SDKInvokable`, `SDKChildren` and any other types that reference `CLI<...>` to include the 5th parameter. + +Update the `cli()` factory function (line 1180) to pass through `TProviders`: + +```typescript +export function cli< + TArgs extends ParsedArgs, + THandlerReturn = void, + TChildren = {}, + TName extends string = string +>( + name: TName, + ... +) { + return new InternalCLI(name, rootCommandConfiguration as any) as any as CLI< + TArgs, + THandlerReturn, + TChildren, + undefined, + {} // TProviders starts empty + >; +} +``` + +**Step 3: Update InternalCLI class** + +In `internal-cli.ts` line 70-76: + +```typescript +export class InternalCLI< + TArgs extends ParsedArgs = ParsedArgs, + THandlerReturn = void, + TChildren = {}, + TParent = undefined, + TProviders = {} +> implements CLI +``` + +Update `AnyInternalCLI`: + +```typescript +export type AnyInternalCLI = InternalCLI; +``` + +Update `clone()` (line 1278): + +```typescript +clone() { + const clone = new InternalCLI( + this.name + ); + ... +} +``` + +**Step 4: Update ComposableBuilder** + +In `composable-builder.ts`: + +```typescript +export type ComposableBuilder< + TArgs2 extends ParsedArgs, + TAddedChildren = {} +> = ( + init: CLI +) => CLI, THandlerReturn, TChildren & TAddedChildren, TParent, TProviders> +``` + +**Step 5: Update TestHarness** + +The TestHarness doesn't need `TProviders` in its generic since it only uses `TArgs`. Just ensure it accepts `CLI`. + +**Step 6: Build and test** + +Run: `nx build cli-forge && nx test cli-forge && nx test type-tests` +Expected: All pass. The new generic defaults to `{}` so nothing breaks. + +**Step 7: Commit** + +``` +refactor(cli-forge): add TProviders generic parameter to CLI interface +``` + +--- + +### Task 3: AsyncLocalStorage Infrastructure + +**Goal:** Create the ALS wrapper module with browser fallback stub, wired into the build system. + +**Files:** +- Create: `packages/cli-forge/src/lib/async-context.ts` (Node ALS wrapper) +- Create: `packages/cli-forge/src/browser/async-context.ts` (browser fallback) +- Modify: `packages/cli-forge/tsdown.config.mjs` (add browser alias) + +**Step 1: Create the Node ALS module** + +Create `packages/cli-forge/src/lib/async-context.ts`: + +```typescript +import { AsyncLocalStorage } from 'node:async_hooks'; + +export interface ForgeContextData { + args: Record; + commandChain: string[]; + providers: Map; + providerFactories: Map; + globalCache: Map; + /** Whether inject() is currently allowed (only during handler phase) */ + handlerPhase: boolean; +} + +export const contextStorage = new AsyncLocalStorage(); +``` + +**Step 2: Create the browser fallback** + +Create `packages/cli-forge/src/browser/async-context.ts`: + +```typescript +/** + * Browser-safe fallback for AsyncLocalStorage. + * + * Uses a simple last-set-wins store. This is correct for browser usage + * where CLI execution is single-threaded/sequential. + * + * Swapped in via browserAlias in tsdown.config.mjs. + */ + +export interface ForgeContextData { + args: Record; + commandChain: string[]; + providers: Map; + providerFactories: Map; + globalCache: Map; + handlerPhase: boolean; +} + +let currentStore: ForgeContextData | undefined; + +export const contextStorage = { + run(store: ForgeContextData, fn: () => T): T { + const prev = currentStore; + currentStore = store; + try { + return fn(); + } finally { + currentStore = prev; + } + }, + getStore(): ForgeContextData | undefined { + return currentStore; + }, +}; +``` + +**Step 3: Add browser alias** + +In `packages/cli-forge/tsdown.config.mjs`, add to `browserAlias`: + +```javascript +browserAlias: { + 'node-shell-deps': 'src/browser/shell-deps.ts', + 'interactive-shell': 'src/browser/interactive-shell.ts', + 'async-context': 'src/browser/async-context.ts', +}, +``` + +**Step 4: Build** + +Run: `nx build cli-forge` +Expected: Build succeeds. Both node and browser bundles produce correctly. + +**Step 5: Commit** + +``` +chore(cli-forge): add AsyncLocalStorage infrastructure with browser fallback +``` + +--- + +### Task 4: Implement `.provide()` on CLI + +**Goal:** Add the `.provide()` method to the CLI interface and InternalCLI implementation. Stores provider registrations on the CLI instance for later resolution. + +**Files:** +- Modify: `packages/cli-forge/src/lib/public-api.ts` (add `.provide()` to CLI interface, add `ProviderConfig`/`GlobalProviderConfig` types) +- Modify: `packages/cli-forge/src/lib/internal-cli.ts` (implement `.provide()`, add provider storage) +- Modify: `packages/cli-forge/src/index.ts` (export new types) +- Create: `type-tests/fixtures/providers-provide.ts` (type test) + +**Step 1: Write the type test** + +Create `type-tests/fixtures/providers-provide.ts`: + +```typescript +import { cli } from 'cli-forge'; +import { IsTrue, AssertEqual, AssertProperty } from '../assertions/helpers'; + +// Eager value provider +const app1 = cli('test') + .option('name', { type: 'string' }) + .provide('logger', { log: console.log }); + +// Factory provider (executionScope) +const app2 = cli('test') + .option('logLevel', { type: 'string', default: 'info' }) + .provide('logger', { + factory: (args) => ({ level: args.logLevel }), + }); + +// Global factory (no args) +const app3 = cli('test') + .provide('pool', { + factory: () => ({ connections: 10 }), + lifetime: 'global' as const, + }); + +// Duplicate key at same level is forbidden +const app4 = cli('test') + .provide('logger', { log: console.log }) + // @ts-expect-error — duplicate key at same level + .provide('logger', { log: console.log }); + +// Multiple providers accumulate +const app5 = cli('test') + .provide('a', 1) + .provide('b', 'hello') + .provide('c', true); +``` + +**Step 2: Add types to public-api.ts** + +Add near the top of `public-api.ts`: + +```typescript +export interface ProviderConfig { + factory: (args: TArgs) => T; + lifetime?: 'executionScope'; +} + +export interface GlobalProviderConfig { + factory: () => T; + lifetime: 'global'; +} +``` + +Add `.provide()` to the CLI interface (after `handler()` around line 854): + +```typescript + /** + * Register a provider value or factory. Providers are accessible via + * `getCommandContext(cli).inject(key)` during handler execution. + * + * Eager value: + * ```ts + * cli('app').provide('logger', new Logger()) + * ``` + * + * Factory (executionScope, receives args): + * ```ts + * cli('app').provide('logger', { factory: (args) => new Logger(args.logLevel) }) + * ``` + * + * Factory (global, no args): + * ```ts + * cli('app').provide('pool', { factory: () => new Pool(), lifetime: 'global' }) + * ``` + */ + // Global factory (no args) + provide( + key: TName & (TName extends keyof TProviders ? never : TName), + config: GlobalProviderConfig, + ): CLI; + + // ExecutionScope factory (receives args) + provide( + key: TName & (TName extends keyof TProviders ? never : TName), + config: ProviderConfig, + ): CLI; + + // Eager value + provide( + key: TName & (TName extends keyof TProviders ? never : TName), + value: T, + ): CLI; +``` + +Note the overload ordering: global factory first (most specific — has `lifetime: 'global'`), then executionScope factory (has `factory` prop), then eager value (fallback). + +**Step 3: Implement in InternalCLI** + +Add provider storage fields to `InternalCLI` (after the existing fields around line 118): + +```typescript + /** + * Registered providers for this command level. + * Maps key -> { type: 'eager', value } | { type: 'factory', factory, lifetime } + */ + registeredProviders: Map = new Map(); +``` + +Add the `ProviderRegistration` type (in `internal-cli.ts` near the top): + +```typescript +type ProviderRegistration = + | { type: 'eager'; value: unknown } + | { type: 'factory'; factory: Function; lifetime: 'global' | 'executionScope' }; +``` + +Implement the `.provide()` method: + +```typescript + provide(key: string, valueOrConfig: unknown): any { + if (this.registeredProviders.has(key)) { + throw new Error(`Provider '${key}' is already registered on this command.`); + } + if ( + valueOrConfig !== null && + typeof valueOrConfig === 'object' && + 'factory' in valueOrConfig && + typeof (valueOrConfig as any).factory === 'function' + ) { + const config = valueOrConfig as { factory: Function; lifetime?: string }; + this.registeredProviders.set(key, { + type: 'factory', + factory: config.factory, + lifetime: (config.lifetime as 'global' | 'executionScope') ?? 'executionScope', + }); + } else { + this.registeredProviders.set(key, { type: 'eager', value: valueOrConfig }); + } + return this; + } +``` + +Update `clone()` to copy providers: + +```typescript + clone() { + const clone = new InternalCLI( + this.name + ); + // ... existing clone logic ... + clone.registeredProviders = new Map(this.registeredProviders); + return clone; + } +``` + +**Step 4: Export new types from index.ts** + +```typescript +export type { ProviderConfig, GlobalProviderConfig } from './lib/public-api'; +``` + +**Step 5: Build and test** + +Run: `nx build cli-forge && nx test cli-forge && nx test type-tests` +Expected: All pass. `.provide()` is callable, types accumulate, duplicates error at type level. + +**Step 6: Commit** + +``` +feat(cli-forge): add .provide() method for registering DI providers +``` + +--- + +### Task 5: Context Module — `getCommandContext` and `inject` + +**Goal:** Create the `cli-forge/context` entrypoint with `getCommandContext()`, `CommandContext`, and `inject()` implementation. + +**Files:** +- Create: `packages/cli-forge/src/lib/context.ts` (main context module) +- Modify: `packages/cli-forge/package.json` (add `./context` export) +- Modify: `packages/cli-forge/src/index.ts` (re-export context types) + +**Step 1: Create the context module** + +Create `packages/cli-forge/src/lib/context.ts`: + +```typescript +import { ParsedArgs } from '@cli-forge/parser'; +import type { CLI } from './public-api'; +import { contextStorage, ForgeContextData } from './async-context'; + +// ---- Public Types ---- + +export type AnyCLI = CLI; + +/** Recursively gather all providers from a CLI and its ancestors */ +export type ProvidersOf = T extends CLI + ? (Parent extends AnyCLI ? ProvidersOf & Providers : Providers) + : {}; + +/** Infer the full CommandContext type from a CLI type */ +export type InferContextOfCommand = T extends CLI + ? CommandContext, C> + : never; + +export interface CommandContext { + /** Current parsed + middleware-transformed args */ + readonly args: TArgs; + + /** Resolved command path, e.g. ['deploy', 'staging'] */ + readonly commandChain: readonly string[]; + + /** + * Inject a provided value. Constrained to keys registered via .provide(). + * Throws if key was not provided. + */ + inject(key: K): TProviders[K]; + + /** + * Inject with fallback. Returns defaultValue if key not provided. + */ + inject( + key: K, + defaultValue: TProviders[K], + ): TProviders[K]; + + /** + * Navigate to a child command's typed context. + * Throws if that child is not in the active command chain. + */ + getChildContext( + command: K, + ): TChildren[K] extends CLI + ? CommandContext & P, GC> + : never; +} + +// ---- Module-level global provider cache ---- + +const globalProviderCache = new Map(); + +// ---- Implementation ---- + +/** + * Get the active execution context. The CLI parameter is a type-level + * witness — it drives TypeScript inference but is not used at runtime. + * + * @example + * // Typed via CLI instance (T inferred) + * const ctx = getCommandContext(app); + * + * // Typed via explicit generic + * const ctx = getCommandContext(); + */ +export function getCommandContext(cli?: T): InferContextOfCommand { + const store = contextStorage.getStore(); + if (!store) { + throw new Error( + 'No active execution context. Ensure this code runs during a forge()/sdk() handler.' + ); + } + if (!store.handlerPhase) { + throw new Error( + 'inject() is not available during middleware/init. Providers are resolved during handler execution when args are finalized.' + ); + } + return createCommandContext(store) as InferContextOfCommand; +} + +function createCommandContext(store: ForgeContextData): CommandContext { + return { + get args() { + return store.args; + }, + get commandChain() { + return store.commandChain; + }, + inject(key: string, ...rest: unknown[]) { + const hasDefault = rest.length > 0; + const defaultValue = rest[0]; + + // Check if already resolved in this execution + if (store.providers.has(key)) { + return store.providers.get(key); + } + + // Check factory registrations + const registration = store.providerFactories.get(key); + if (!registration) { + if (hasDefault) return defaultValue; + const registered = [...store.providerFactories.keys(), ...store.providers.keys()]; + throw new Error( + `Provider '${key}' not found. Registered: ${registered.join(', ') || '(none)'}` + ); + } + + // Resolve factory + let value: unknown; + if (registration.lifetime === 'global') { + if (globalProviderCache.has(key)) { + value = globalProviderCache.get(key); + } else { + value = registration.factory(); + globalProviderCache.set(key, value); + } + } else { + // executionScope — factory receives args + value = registration.factory(store.args); + } + + // Cache in execution scope + store.providers.set(key, value); + return value; + }, + getChildContext(command: string) { + if (!store.commandChain.includes(command)) { + throw new Error( + `getChildContext('${command}') called but '${command}' is not in the active command chain [${store.commandChain.map(c => `'${c}'`).join(', ')}].` + ); + } + // Child context shares the same store — providers are inherited + return createCommandContext(store); + }, + }; +} + +/** @internal — exported for TestHarness.mockContext() */ +export { globalProviderCache }; +``` + +**Step 2: Add package.json export** + +In `packages/cli-forge/package.json`, add to `"exports"`: + +```json +"./context": { + "browser": "./dist/browser/lib/context.mjs", + "types": "./dist/lib/context.d.mts", + "import": "./dist/lib/context.mjs", + "require": "./dist/lib/context.cjs", + "default": "./dist/lib/context.cjs" +} +``` + +**Step 3: Re-export types from index.ts** + +Add to `packages/cli-forge/src/index.ts`: + +```typescript +export type { + CommandContext, + InferContextOfCommand, + ProvidersOf, +} from './lib/context'; +export { getCommandContext } from './lib/context'; +``` + +**Step 4: Build** + +Run: `nx build cli-forge` +Expected: Build succeeds with the new `./context` entrypoint. + +**Step 5: Commit** + +``` +feat(cli-forge): add getCommandContext and CommandContext for DI access +``` + +--- + +### Task 6: Wire ALS into `forge()` and `sdk()` + +**Goal:** Wrap handler execution in `contextStorage.run()` so `getCommandContext()` works during handlers. + +**Files:** +- Modify: `packages/cli-forge/src/lib/internal-cli.ts` (wrap runCommand in ALS, collect providers from command chain) +- Create: `packages/cli-forge/src/lib/context.spec.ts` (integration tests) + +**Step 1: Write the integration test** + +Create `packages/cli-forge/src/lib/context.spec.ts`: + +```typescript +import { cli } from './public-api'; +import { getCommandContext } from './context'; + +describe('getCommandContext', () => { + it('should provide args during handler execution', async () => { + let capturedArgs: any; + + const app = cli('test') + .option('name', { type: 'string', default: 'world' }) + .handler((args) => { + const ctx = getCommandContext(app); + capturedArgs = ctx.args; + }); + + await app.forge(['--name', 'hello']); + expect(capturedArgs.name).toBe('hello'); + }); + + it('should inject eager providers', async () => { + let injected: any; + const logger = { log: jest.fn() }; + + const app = cli('test') + .provide('logger', logger) + .handler(() => { + const ctx = getCommandContext(app); + injected = ctx.inject('logger'); + }); + + await app.forge([]); + expect(injected).toBe(logger); + }); + + it('should inject executionScope factory providers with args', async () => { + let injected: any; + + const app = cli('test') + .option('level', { type: 'string', default: 'info' }) + .provide('logger', { + factory: (args: any) => ({ level: args.level }), + }) + .handler(() => { + const ctx = getCommandContext(app); + injected = ctx.inject('logger'); + }); + + await app.forge(['--level', 'debug']); + expect(injected).toEqual({ level: 'debug' }); + }); + + it('should inject global factory providers without args', async () => { + let callCount = 0; + + const app = cli('test') + .provide('pool', { + factory: () => { callCount++; return { id: 1 }; }, + lifetime: 'global' as const, + }) + .handler(() => { + const ctx = getCommandContext(app); + ctx.inject('pool'); + ctx.inject('pool'); // second call should use cache + }); + + await app.forge([]); + expect(callCount).toBe(1); + }); + + it('should throw when called outside handler', () => { + expect(() => getCommandContext(cli('test'))).toThrow( + 'No active execution context' + ); + }); + + it('should throw for unregistered provider without default', async () => { + const app = cli('test') + .handler(() => { + const ctx = getCommandContext(app) as any; + ctx.inject('missing'); + }); + + await expect(app.forge([])).rejects.toThrow("Provider 'missing' not found"); + }); + + it('should return default for unregistered provider with default', async () => { + let result: any; + + const app = cli('test') + .handler(() => { + const ctx = getCommandContext(app) as any; + result = ctx.inject('missing', 'fallback'); + }); + + await app.forge([]); + expect(result).toBe('fallback'); + }); + + it('should work with SDK mode', async () => { + let injected: any; + + const app = cli('test') + .provide('svc', { value: 42 }) + .handler(() => { + const ctx = getCommandContext(app); + injected = ctx.inject('svc'); + }); + + const sdk = app.sdk(); + await sdk(); + expect(injected).toEqual({ value: 42 }); + }); + + it('should provide commandChain for subcommands', async () => { + let chain: any; + + const app = cli('test') + .command('deploy', { + builder: (cmd) => cmd, + handler: () => { + const ctx = getCommandContext(app); + chain = ctx.commandChain; + }, + }); + + await app.forge(['deploy']); + expect(chain).toEqual(['deploy']); + }); +}); +``` + +**Step 2: Wire ALS into forge()** + +In `internal-cli.ts`, import the context storage: + +```typescript +import { contextStorage, ForgeContextData } from './async-context'; +``` + +In the `forge()` method, after the final strict parse (around line 1266) and before calling `runCommand()`, wrap the execution in `contextStorage.run()`: + +Replace line 1266: +```typescript +const finalArgV = await this.runCommand(argv, args, executedMiddleware); +``` + +With: +```typescript +// Collect all providers from the command chain +const allProviders = this.collectProviders(); +const contextData: ForgeContextData = { + args: argv, + commandChain: [...this.commandChain], + providers: new Map(), + providerFactories: new Map(), + globalCache: new Map(), // unused — global cache is at module level + handlerPhase: false, +}; + +// Register providers into context +for (const [key, reg] of allProviders) { + if (reg.type === 'eager') { + contextData.providers.set(key, reg.value); + } else { + contextData.providerFactories.set(key, { + factory: reg.factory, + lifetime: reg.lifetime, + }); + } +} + +const finalArgV = await contextStorage.run(contextData, async () => { + contextData.handlerPhase = true; + contextData.args = argv; // ensure final args visible + return this.runCommand(argv, args, executedMiddleware); +}); +``` + +Add the `collectProviders()` method to `InternalCLI`: + +```typescript + /** + * Collect all registered providers from the command chain + * (root -> subcommand -> nested subcommand). + * Child providers override parent providers with the same key. + */ + private collectProviders(): Map { + const result = new Map(); + // Start from root (this) and walk down the command chain + let cmd: AnyInternalCLI = this; + for (const [key, reg] of cmd.registeredProviders) { + result.set(key, reg); + } + for (const name of this.commandChain) { + cmd = cmd.registeredCommands[name]; + if (cmd) { + for (const [key, reg] of cmd.registeredProviders) { + result.set(key, reg); // child overrides parent + } + } + } + return result; + } +``` + +**Step 3: Wire ALS into SDK** + +In `buildSDKProxy()`, wrap the handler invocation (around line 829-833): + +Replace: +```typescript +// Execute handler +const context: CLIHandlerContext = { + command: cmd as unknown as CLI, +}; +const result = await handler(parsedArgs, context); +``` + +With: +```typescript +// Collect providers from the cloned command +const allProviders = new Map(); +// Walk from root to target, collecting providers +let walkCmd: AnyInternalCLI = cmd; +for (const [key, reg] of walkCmd.registeredProviders) { + allProviders.set(key, reg); +} + +const contextData: ForgeContextData = { + args: parsedArgs, + commandChain: [], + providers: new Map(), + providerFactories: new Map(), + globalCache: new Map(), + handlerPhase: true, +}; + +for (const [key, reg] of allProviders) { + if (reg.type === 'eager') { + contextData.providers.set(key, reg.value); + } else { + contextData.providerFactories.set(key, { + factory: reg.factory, + lifetime: reg.lifetime, + }); + } +} + +const context: CLIHandlerContext = { + command: cmd as unknown as AnyCLI, +}; +const result = await contextStorage.run(contextData, async () => { + return handler(parsedArgs, context); +}); +``` + +**Step 4: Update runCommand to update ALS context args after middleware** + +In `runCommand()`, after middleware transforms args (around line 650), update the ALS store: + +```typescript +// After middleware loop, update ALS context if active +const store = contextStorage.getStore(); +if (store) { + store.args = args; +} +``` + +**Step 5: Run tests** + +Run: `nx test cli-forge` +Expected: All existing tests pass, new context tests pass. + +**Step 6: Commit** + +``` +feat(cli-forge): wire AsyncLocalStorage into forge() and sdk() execution +``` + +--- + +### Task 7: TestHarness `mockContext` + +**Goal:** Add `mockContext()` and `clearMockedContexts()` static methods to TestHarness. + +**Files:** +- Modify: `packages/cli-forge/src/lib/test-harness.ts` +- Create: `packages/cli-forge/src/lib/test-harness-context.spec.ts` + +**Step 1: Write the test** + +Create `packages/cli-forge/src/lib/test-harness-context.spec.ts`: + +```typescript +import { cli } from './public-api'; +import { TestHarness } from './test-harness'; +import { getCommandContext } from './context'; + +describe('TestHarness.mockContext', () => { + afterEach(() => { + TestHarness.clearMockedContexts(); + }); + + it('should provide mocked args via getCommandContext', () => { + const app = cli('test') + .option('name', { type: 'string' }); + + const cleanup = TestHarness.mockContext(app, { + args: { name: 'mocked' }, + }); + + const ctx = getCommandContext(app); + expect(ctx.args.name).toBe('mocked'); + cleanup(); + }); + + it('should provide mocked providers via inject', () => { + const mockLogger = { log: jest.fn() }; + + const app = cli('test') + .provide('logger', { log: () => {} }); + + const cleanup = TestHarness.mockContext(app, { + args: {}, + providers: { logger: mockLogger }, + }); + + const ctx = getCommandContext(app); + expect(ctx.inject('logger')).toBe(mockLogger); + cleanup(); + }); + + it('cleanup should remove the mocked context', () => { + const app = cli('test'); + + const cleanup = TestHarness.mockContext(app, { args: {} }); + cleanup(); + + expect(() => getCommandContext(app)).toThrow('No active execution context'); + }); + + it('clearMockedContexts should remove all mocked contexts', () => { + const app = cli('test'); + + TestHarness.mockContext(app, { args: {} }); + TestHarness.clearMockedContexts(); + + expect(() => getCommandContext(app)).toThrow('No active execution context'); + }); +}); +``` + +**Step 2: Implement mockContext** + +In `test-harness.ts`, add the static methods and import `contextStorage`: + +```typescript +import { contextStorage, ForgeContextData } from './async-context'; + +// Track active mocked ALS contexts for cleanup +const mockedDisposers: Array<() => void> = []; + +export class TestHarness { + // ... existing code ... + + /** + * Set up a mocked execution context for testing. + * getCommandContext() will return a context with the given args and providers. + * Returns a cleanup function that removes the mocked context. + */ + static mockContext( + _cli: CLI, + options: { + args?: Partial; + providers?: Partial; + commandChain?: string[]; + }, + ): () => void { + const contextData: ForgeContextData = { + args: (options.args ?? {}) as Record, + commandChain: options.commandChain ?? [], + providers: new Map( + Object.entries(options.providers ?? {}) + ), + providerFactories: new Map(), + globalCache: new Map(), + handlerPhase: true, // Always true in test mocks so inject() works + }; + + // Enter the ALS context — this sets the store for the current + // synchronous execution and any async continuations from here. + contextStorage.enterWith(contextData); + + const dispose = () => { + // Clear the store by entering with undefined + contextStorage.enterWith(undefined as any); + const idx = mockedDisposers.indexOf(dispose); + if (idx !== -1) mockedDisposers.splice(idx, 1); + }; + + mockedDisposers.push(dispose); + return dispose; + } + + /** Clear all mocked contexts. Call in afterEach(). */ + static clearMockedContexts(): void { + for (const dispose of [...mockedDisposers]) { + dispose(); + } + } +} +``` + +Note: `enterWith()` is not available on Cloudflare Workers, but that's fine — `mockContext` is a testing utility that only runs in Node/Bun test environments. The browser fallback stub should also add `enterWith` support: + +Update `packages/cli-forge/src/browser/async-context.ts` to add: + +```typescript +export const contextStorage = { + run(store: ForgeContextData, fn: () => T): T { /* ... existing ... */ }, + getStore(): ForgeContextData | undefined { return currentStore; }, + enterWith(store: ForgeContextData): void { currentStore = store; }, +}; +``` + +**Step 3: Run tests** + +Run: `nx test cli-forge` +Expected: All pass including new mockContext tests. + +**Step 4: Commit** + +``` +feat(cli-forge): add TestHarness.mockContext for testing DI providers +``` + +--- + +### Task 8: Type Tests + +**Goal:** Comprehensive type-level tests for TProviders accumulation, inject key constraints, duplicate rejection, child context inheritance, and InferContextOfCommand. + +**Files:** +- Create: `type-tests/fixtures/providers-inject.ts` +- Create: `type-tests/fixtures/providers-child-context.ts` +- Create: `type-tests/fixtures/providers-infer-context.ts` + +**Step 1: Create inject constraint test** + +Create `type-tests/fixtures/providers-inject.ts`: + +```typescript +import { cli } from 'cli-forge'; +import { getCommandContext, CommandContext } from 'cli-forge/context'; + +// Setup: app with providers +const app = cli('test') + .option('verbose', { type: 'boolean' }) + .provide('logger', { info: (msg: string) => console.log(msg) }) + .provide('api', { get: (url: string) => fetch(url) }); + +// In a handler context: +function testInject() { + const ctx = getCommandContext(app); + + // Valid injects + const logger = ctx.inject('logger'); + logger.info('test'); // should be typed + + const api = ctx.inject('api'); + api.get('/test'); // should be typed + + // Invalid inject — uncomment to verify type error + // @ts-expect-error — 'missing' not in providers + ctx.inject('missing'); +} +``` + +**Step 2: Create child context test** + +Create `type-tests/fixtures/providers-child-context.ts`: + +```typescript +import { cli } from 'cli-forge'; +import { getCommandContext } from 'cli-forge/context'; + +const app = cli('test') + .provide('rootSvc', { root: true }) + .command('deploy', { + builder: (cmd) => cmd + .option('target', { type: 'string', required: true }) + .provide('deployer', { deploy: (t: string) => {} }), + handler: () => {}, + }); + +function testChildContext() { + const rootCtx = getCommandContext(app); + + const deployCtx = rootCtx.getChildContext('deploy'); + + // Child has its own args + deployCtx.args.target; // string + + // Child inherits parent providers + deployCtx.inject('rootSvc'); // { root: boolean } + + // Child has its own providers + deployCtx.inject('deployer'); // { deploy: (t: string) => void } + + // Invalid child + // @ts-expect-error — 'nonexistent' not in children + rootCtx.getChildContext('nonexistent'); +} +``` + +**Step 3: Create InferContextOfCommand test** + +Create `type-tests/fixtures/providers-infer-context.ts`: + +```typescript +import { cli } from 'cli-forge'; +import { getCommandContext, InferContextOfCommand } from 'cli-forge/context'; + +const app = cli('test') + .option('name', { type: 'string' }) + .provide('svc', { hello: 'world' }); + +// Overload 1: inferred from instance +function test1() { + const ctx = getCommandContext(app); + ctx.args.name; // string | undefined + ctx.inject('svc'); // { hello: string } +} + +// Overload 2: explicit generic +function test2() { + const ctx = getCommandContext(); + ctx.args.name; // string | undefined + ctx.inject('svc'); // { hello: string } +} + +// InferContextOfCommand utility type +type Ctx = InferContextOfCommand; +// Should have args with name, providers with svc +``` + +**Step 4: Run type tests** + +Run: `nx test type-tests` +Expected: All pass, @ts-expect-error lines correctly catch type errors. + +**Step 5: Commit** + +``` +chore(cli-forge): add type tests for provider injection and context inference +``` + +--- + +### Task 9: Example and Documentation + +**Goal:** Add a multi-file example demonstrating the providers feature. + +**Files:** +- Create: `examples/providers/meta.yml` +- Create: `examples/providers/cli.ts` +- Create: `examples/providers/deploy.ts` + +**Step 1: Create the example** + +Create `examples/providers/meta.yml`: + +```yaml +id: providers +title: Dependency Injection with Providers +description: | + Demonstrates using .provide() and getCommandContext() to register + and inject services without threading them through function calls. +entryPoint: ./cli.ts +fileMap: + './cli.ts': 'cli.ts' + './deploy.ts': 'deploy.ts' +commands: + - command: '{filename} deploy --target production --apiUrl http://example.com' + assertions: + - contains: 'Deploying to production' +``` + +Create `examples/providers/cli.ts`: + +```typescript +import { cli } from 'cli-forge'; +import { runDeploy } from './deploy'; + +export const app = cli('deploy-tool') + .option('logLevel', { + type: 'string', + default: 'info', + description: 'Logging level', + }) + .option('apiUrl', { + type: 'string', + required: true, + description: 'API base URL', + }) + .provide('logger', { + factory: (args) => ({ + info: (msg: string) => console.log(`[${args.logLevel}] ${msg}`), + }), + }) + .command('deploy', { + description: 'Deploy to a target environment', + builder: (cmd) => + cmd.option('target', { + type: 'string', + required: true, + description: 'Deployment target', + }), + handler: async () => { + await runDeploy(); + }, + }); + +app.forge(); +``` + +Create `examples/providers/deploy.ts`: + +```typescript +import { getCommandContext } from 'cli-forge/context'; +import { app } from './cli'; + +export async function runDeploy() { + const ctx = getCommandContext(app); + const deployCtx = ctx.getChildContext('deploy'); + + const logger = ctx.inject('logger'); + const target = deployCtx.args.target; + + logger.info(`Deploying to ${target}`); +} +``` + +**Step 2: Run the example** + +Run: `npx tsx examples/providers/cli.ts deploy --target production --apiUrl http://example.com` +Expected: Output contains `[info] Deploying to production` + +**Step 3: Run e2e** + +Run: `nx run e2e:e2e:examples` +Expected: New example passes assertions. + +**Step 4: Commit** + +``` +docs(cli-forge): add providers example demonstrating DI pattern +``` + +--- + +## Task Dependency Graph + +``` +Task 1 (AnyCLI aliases) + └─> Task 2 (TProviders generic) + ├─> Task 4 (.provide() method) + │ └─> Task 6 (Wire ALS into forge/sdk) + │ ├─> Task 7 (TestHarness.mockContext) + │ └─> Task 8 (Type tests) + │ └─> Task 9 (Example) + └─> Task 3 (ALS infrastructure) ─────────┘ + └─> Task 5 (Context module) ────┘ +``` + +Tasks 3 and 4 can be done in parallel after Task 2. +Tasks 5 depends on Task 3. +Task 6 depends on Tasks 4 and 5. +Tasks 7 and 8 depend on Task 6. +Task 9 depends on Task 8. diff --git a/.ai/plans/dependency-injection/issues.md b/.ai/plans/dependency-injection/issues.md new file mode 100644 index 000000000..ce8036bef --- /dev/null +++ b/.ai/plans/dependency-injection/issues.md @@ -0,0 +1,23 @@ +# Plan Review — Resolved Issues + +All issues from the initial review have been addressed in the updated plan. + +## Resolved + +| # | Severity | Issue | Resolution | +|---|----------|-------|------------| +| 1 | High | Module-level `Map` breaks under async/concurrent SDK execution | AsyncLocalStorage with browser fallback stub. ALS scopes context per async execution chain. | +| 2 | High | `global` factories taking `factory(args)` are unsound | Global factories use `() => T` (no args). Args-dependent providers must use `executionScope`. | +| 3 | High | Lazy factory resolution during discovery sees partial args | Provider execution deferred to handler phase. `inject()` throws during middleware/init. | +| 4 | Medium | Type merge `&` produces intersection, not override semantics | Same-level duplicates forbidden (type error). Child-shadows-parent uses `Omit` for override semantics. | +| 5 | Medium | SDK clones don't match identity-based lookup | ALS handles execution scoping. CLI parameter is a type-level witness only. | +| 6 | Medium | Testing section inconsistent with current test harness API | Fixed to `new TestHarness(cli)`. Setup/teardown pattern hides ALS as implementation detail. | +| 7 | Medium | Change surface underestimated for 5th generic | AnyCLI pass as prerequisite. Full file list updated including `composable-builder.ts`. | + +## Plan Inconsistencies (also fixed) + +| Issue | Fix | +|-------|-----| +| `export const app = ... .forge()` invalid — `forge()` returns `Promise` | Example now shows `await app.forge()` as a standalone call | +| Test example missing child context for `target` | Mock now includes `target` in args and `commandChain: ['deploy']` | +| `updateConfig` provenance note | Removed — routes to existing parser config behavior, no new mechanism needed | diff --git a/.ai/plans/dependency-injection/plan.md b/.ai/plans/dependency-injection/plan.md new file mode 100644 index 000000000..2ddb431b2 --- /dev/null +++ b/.ai/plans/dependency-injection/plan.md @@ -0,0 +1,763 @@ +# Feature Spec: Runtime Context & Providers for CLI Forge + +## Context + +Originated from a conversation about built-in logging. The real pain points: + +1. **Accessing normalized args anywhere** without threading through every function +2. **Accessing config read/write** without coupling to the CLI instance +3. **Initializing services from parsed args** and accessing them without threading + +## Evaluation + +| Idea | Verdict | Rationale | +|------|---------|-----------| +| Built-in logging | **Nay** | Application-specific. The real problem is context/service access. | +| Full DI (Angular/Effect modules) | **Nay** | Module system + decorators + lifecycle tiers is too much paradigm. | +| Lightweight DI via `.provide()` + `getCommandContext()` | **Yay** | Small API, type-safe, doesn't require controlling the runtime. | + +Lightweight DI works here because: + +- Linear lifecycle: parse -> middleware -> handler -> done +- Only two lifetimes: global singleton and per-execution +- No circular dependencies - providers are independent values/factories +- Middleware is the natural initialization point (has access to parsed args) + +--- + +## Design + +### Prerequisite: `AnyCLI` Type Alias + +Before adding a 5th generic, clean up 30+ scattered `CLI` occurrences. + +```typescript +// public-api.ts - exported for consumers +export type AnyCLI = CLI; + +// Deprecate UnknownCLI (line 1107) in favor of AnyCLI +/** @deprecated Use AnyCLI instead */ +export type UnknownCLI = AnyCLI; + +// internal-cli.ts - internal use only +type AnyInternalCLI = InternalCLI; +``` + +Occurrences to replace: + +- `internal-cli.ts`: ~17 `InternalCLI` -> `AnyInternalCLI` +- `resolve-completions.ts`: ~11 +- `documentation.ts`: ~1 +- `format-help.ts`: ~1 +- `composable-builder.ts`: ~1 + +--- + +### Context Propagation: AsyncLocalStorage + +Context is propagated via `AsyncLocalStorage` from `node:async_hooks`. This works across all target runtimes: + +| Runtime | Support | +|---------|---------| +| Node.js 16.4+ | Stable, V8-native in 22+ | +| Bun | Fully supported | +| Deno | Works for direct `await` chains (sufficient for CLI execution flow) | +| Cloudflare Workers | Supported with `nodejs_compat` flag | +| Browsers | Fallback stub (see below) | + +**Browser fallback**: Uses the existing `browserAlias` pattern in `tsdown.config.mjs`. The browser build swaps in a simple last-set-wins store. Since browser CLI execution is inherently single-threaded/sequential, this is correct for that environment. + +```typescript +// src/lib/async-context.ts (Node/Bun/Deno) +import { AsyncLocalStorage } from 'node:async_hooks'; +export const contextStorage = new AsyncLocalStorage(); + +// src/browser/async-context.ts (Browser fallback) +// Simple last-set-wins store — correct for single-execution browser usage +export const contextStorage = { + run(store: ForgeContextData, fn: () => T): T { + currentStore = store; + try { return fn(); } + finally { currentStore = undefined; } + }, + getStore(): ForgeContextData | undefined { + return currentStore; + }, +}; +let currentStore: ForgeContextData | undefined; +``` + +**Why ALS works here**: `forge()` and `sdk()` are both async flows built on direct `await` chains. ALS propagates correctly through `await`. The Deno caveat (context lost in `setTimeout`/`queueMicrotask`) doesn't apply since cli-forge's execution flow doesn't use timer-based propagation. + +**Why not a module-level Map**: SDK mode clones the command tree per invocation (`targetCmd.clone()`). Identity-based lookup on the original CLI instance can't find the clone's context, and concurrent SDK calls would overwrite each other in a shared map. ALS scopes context to the execution's async chain, solving both problems. + +--- + +### No Tokens - String Literal Keys + +Plain string literal keys instead of token objects. Since `TProviders` accumulates the type map on the CLI generic (`{ logger: Logger, api: ApiClient }`), `inject()` just needs `keyof TProviders` - the type safety comes from the context, not from a separate token object. + +```typescript +app.provide('logger', new Logger()); +ctx.inject('logger'); // returns Logger, compile-time checked +``` + +--- + +### `TProviders` Generic on CLI + +Add a 5th generic that accumulates a `{ [key]: type }` map: + +```typescript +export interface CLI< + TArgs extends ParsedArgs = ParsedArgs, + THandlerReturn = void, + TChildren = {}, + TParent = undefined, + TProviders = {} // NEW +> { +``` + +Every method that returns `CLI<...>` passes `TProviders` through unchanged, except `.provide()` which extends it. + +#### Recursive Provider Resolution + +To support parent provider inheritance, utility types walk the `TParent` chain: + +```typescript +/** Recursively gather all providers from a CLI and its ancestors */ +type ProvidersOf = T extends CLI + ? (Parent extends AnyCLI ? ProvidersOf & Providers : Providers) + : {}; + +/** Get providers from the parent chain only (excludes own providers) */ +type ParentProviders = T extends CLI + ? (Parent extends AnyCLI ? ProvidersOf : {}) + : {}; +``` + +These are used by `getCommandContext` so that calling it on any CLI (including a subcommand) automatically includes ancestor providers in the context type. And by `getChildContext` to merge parent + child providers. + +#### Duplicate Key Handling + +**Same-level duplicates are forbidden.** Calling `.provide('logger', ...)` twice on the same CLI is a type error: + +```typescript +app + .provide('logger', new Logger()) + .provide('logger', new OtherLogger()); // TYPE ERROR — 'logger' already in TProviders +``` + +Enforced via conditional type on `TName`: + +```typescript +provide( + key: TName & (TName extends keyof TProviders ? never : TName), + ... +): ... +``` + +**Child-shadows-parent is allowed.** A child command can provide a key that a parent already provides, overriding it for that subtree: + +```typescript +app + .provide('logger', new Logger({ level: 'info' })) + .command('debug', { + builder: (cmd) => cmd + .provide('logger', new Logger({ level: 'debug' })), // OK — child scope override + }); +``` + +This matches standard DI scoping semantics. + +--- + +### `.provide()` on CLI + +```typescript +interface CLI { + // Eager value + provide( + key: TName, + value: T, + ): CLI; + + // Factory with lifetime + provide( + key: TName, + config: ProviderConfig, + ): CLI; +} + +interface ProviderConfig { + factory: ((args: TArgs) => T) | (() => T); + /** Default: 'executionScope' */ + lifetime?: 'global' | 'executionScope'; +} +``` + +**TArgs-aware factories**: Because `.provide()` is in the fluent chain, `executionScope` factories receive the current TArgs: + +```typescript +cli('app') + .option('logLevel', { type: 'string', default: 'info' }) + + // TArgs = { logLevel: string } + .provide('logger', { + factory: (args) => new Logger(args.logLevel), // args.logLevel typed! + // lifetime defaults to 'executionScope' + }) + + .option('apiUrl', { type: 'string', required: true }) + + // TArgs = { logLevel: string, apiUrl: string } + .provide('api', { + factory: (args) => new ApiClient(args.apiUrl), // args.apiUrl typed! + }); +``` + +**Global factories do NOT receive args**: Since global factories run once and cache permanently, binding them to a specific execution's args is unsound. Global factories must be arg-independent: + +```typescript +.provide('pool', { + factory: () => new ConnectionPool(), // no args parameter + lifetime: 'global', +}) +``` + +**Overload resolution**: TypeScript needs to distinguish `provide('key', value)` from `provide('key', { factory })`. The factory overload matches when the second arg has a `factory` property. The eager overload matches everything else. Order: factory overload first (more specific), then eager value fallback. + +#### Lifetime Semantics + +| Lifetime | Scope | Factory Signature | Use Case | +|----------|-------|-------------------|----------| +| `global` | Module-level singleton | `() => T` | Expensive shared resources (DB pools, SDK clients) | +| `executionScope` | Per `forge()` / `sdk()` call | `(args: TArgs) => T` | Request-scoped services (loggers, per-run clients) | + +- **Eager values** (no factory): Stored in the ALS context. Same object reference reused if CLI definition is reused, but accessibility is scoped to the execution. +- **`global` factory**: Called once on first `inject()` ever. Cached at module level permanently. No args — avoids binding to a single execution's arg values. +- **`executionScope` factory**: Called once per `forge()`/`sdk()` on first `inject()`. Cached in the ALS context store. Cleaned up when execution completes. + +**Lazy by default**: Factories run on first `inject()`, not at `forge()` start. Avoids creating expensive resources for commands that don't use them. + +**Provider execution is deferred to handler phase**: `inject()` is only available during and after handler execution. Calling `inject()` during middleware or init hooks throws: + +``` +Error: inject() is not available during middleware/init. Providers +are resolved during handler execution when args are finalized. +``` + +This prevents factories from caching values based on partially-parsed args during the discovery loop. + +--- + +### `getCommandContext()` - Context Access + +```typescript +import { getCommandContext } from 'cli-forge/context'; + +// Overload 1: Typed via CLI instance (T inferred) +const ctx = getCommandContext(app); + +// Overload 2: Typed via explicit generic (no instance needed) +const ctx = getCommandContext(); + +ctx.args; // typed as app's TArgs +ctx.inject('logger'); // typed as Logger, compile-time checked +ctx.getChildContext('deploy'); // typed as deploy's context +``` + +#### How It Works — AsyncLocalStorage + +The CLI instance parameter is a **type-level witness** — it provides type information but is not used for runtime lookup: + +```typescript +// Overload signatures +function getCommandContext(cli: T): InferContextOfCommand; +function getCommandContext(): InferContextOfCommand; + +// Runtime: ignores cli argument, reads from ALS +function getCommandContext(cli?: AnyCLI): CommandContext { + const store = contextStorage.getStore(); + if (!store) { + throw new Error( + 'No active execution context. Ensure this code runs during forge()/sdk() handler execution.' + ); + } + return store.context; +} +``` + +1. `forge(argv)` / `sdk()` wraps handler execution in `contextStorage.run(contextData, () => ...)` +2. Inside any code called during that execution, `getCommandContext()` reads from `contextStorage.getStore()` +3. ALS automatically scopes context to the async execution chain — concurrent SDK calls are isolated +4. Return type inferred from the passed CLI's generics (or explicit generic parameter) + +**Why the CLI parameter is not validated at runtime**: The parameter exists solely to drive TypeScript's type inference. Validating it would require storing the CLI identity in the ALS context and comparing, but this adds runtime cost for no benefit — if you pass the wrong CLI type, you get wrong types, which the compiler would have caught if the code is well-typed. + +#### `InferContextOfCommand` Type + +```typescript +type InferContextOfCommand = T extends CLI + ? CommandContext, C> + : never; +``` + +#### CommandContext Interface + +```typescript +interface CommandContext { + /** Current parsed + middleware-transformed args (this command level's TArgs) */ + readonly args: TArgs; + + /** Resolved command path, e.g. ['deploy', 'staging'] */ + readonly commandChain: readonly string[]; + + /** + * Inject a provided value. Compile-time constrained to string keys + * registered via .provide() on this CLI or any ancestor CLI. + * Throws at runtime if key was not provided or factory fails. + */ + inject(key: K): TProviders[K]; + + /** Inject with fallback. Returns defaultValue if key not provided (no throw). */ + inject( + key: K, + defaultValue: TProviders[K], + ): TProviders[K]; + + /** Update configuration files (routes to existing parser config behavior) */ + updateConfig(values: Partial): Promise; + updateConfig(updater: ConfigUpdater): Promise; + + /** + * Navigate to a child command's typed context. + * Throws if that child is not in the active command chain. + * Merges parent providers with child providers for type-safe inheritance. + */ + getChildContext( + command: K, + ): TChildren[K] extends CLI + ? CommandContext & P, GC> + : never; +} +``` + +Note: `getChildContext` uses `Omit & P` for child provider types, giving proper override semantics when a child shadows a parent provider. + +#### Strict `inject()` - Compile-Time & Runtime Checking + +**Compile-time**: `TProviders` accumulates as `{ logger: Logger, api: ApiClient }` and `inject()` constrains `K extends keyof TProviders`: + +```typescript +const ctx = getCommandContext(app); +ctx.inject('logger'); // OK - 'logger' is in TProviders, returns Logger +ctx.inject('api'); // OK - 'api' is in TProviders, returns ApiClient +ctx.inject('random'); // TYPE ERROR - 'random' not in TProviders +``` + +**Runtime**: Even if type checking is bypassed (e.g. `as any`), `inject()` validates at runtime: + +```typescript +ctx.inject('unknown'); // THROWS: "Provider 'unknown' not found. Registered: logger, api" +ctx.inject('unknown', fallback); // OK - returns fallback, no throw +``` + +The overload without `defaultValue` always throws for unregistered keys. The overload with `defaultValue` returns it silently. + +#### `getChildContext()` - Why It Exists + +With `ProvidersOf` walking the parent chain, `getCommandContext(deploySubcommand)` **does** include ancestor providers in its type — if you have a reference to the child CLI instance and its `TParent` generic is correctly set. + +However, `getChildContext` is still the primary navigation pattern because: + +1. **You typically only have the root CLI reference** — child CLI instances are created inside builders and aren't exported +2. **It provides the child's `TArgs`** — the root context only has root args; `getChildContext` gives you the child command's args +3. **It validates the active command chain** at runtime — throws if `'deploy'` isn't the active command + +```typescript +const app = cli('app') + .option('verbose', { type: 'boolean' }) + .provide('logger', { + factory: (a) => new Logger(a.verbose), + }) + .command('deploy', { + builder: (cmd) => + cmd + .option('target', { type: 'string', required: true }) + .provide('deployClient', { + factory: (a) => new DeployClient(a.target), + }), + handler: async () => { + await runDeploy(); + }, + }); + +// In a deeply nested helper: +function runDeploy() { + const rootCtx = getCommandContext(app); + rootCtx.args.verbose; // OK - root arg + + const deployCtx = rootCtx.getChildContext('deploy'); + deployCtx.args.target; // OK - DeployArgs includes target + deployCtx.inject('deployClient'); // OK - deploy registered this + deployCtx.inject('logger'); // OK - inherited from parent + rootCtx.getChildContext('nonexistent'); // TYPE ERROR +} +``` + +**Runtime behavior**: `getChildContext('deploy')` **throws** if `'deploy'` is not in the active `commandChain`: + +``` +Error: getChildContext('deploy') called but 'deploy' is not in the active +command chain ['build']. The active command is 'build'. +``` + +#### Recommended Pattern: Context Helper Functions + +To avoid repeating `getCommandContext(app).getChildContext('deploy')` everywhere, define thin helpers: + +```typescript +// context-helpers.ts +import { getCommandContext } from 'cli-forge/context'; +import { app } from './cli'; + +export const getDeployContext = () => getCommandContext(app).getChildContext('deploy'); +export const getBuildContext = () => getCommandContext(app).getChildContext('build'); +``` + +```typescript +// deploy.ts - clean usage +import { getDeployContext } from './context-helpers'; + +export async function runDeploy() { + const ctx = getDeployContext(); + const logger = ctx.inject('logger'); // inherited from parent + const client = ctx.inject('deployClient'); + // ... +} +``` + +--- + +## Lifecycle + +### Context Through the Execution Flow + +``` +forge(argv) +| +| +- DISCOVERY LOOP ----------------------------------------+ +| | Non-strict parse -> mergedArgs | +| | Middleware runs | +| | Init hooks (inject() NOT available here) | +| | Discover next subcommand -> loop or break | +| +---------------------------------------------------------+ +| +| Prompt resolution +| Final strict parse -> argv +| +| contextStorage.run(contextData, () => { <- ALS scope begins +| context.args = finalArgs +| runCommand(argv) +| Remaining middleware +| context.args = final result +| Handler executes <- inject() available here +| }) <- ALS scope ends, cleanup +``` + +### When Factories Run + +- **`executionScope`**: Factory called on first `inject()` within the handler phase of this `forge()`/`sdk()` call. Receives finalized `context.args`. Cached in ALS context store for the duration of the execution. +- **`global`**: Factory called on first `inject()` ever. No args parameter. Cached at module level permanently. +- **Eager values**: Stored in ALS context store at handler phase start. Always available during handler execution. + +--- + +## Edge Cases + +| Scenario | Behavior | +|----------|----------| +| Interactive shell | Child process via `spawnSync()` -> fresh process -> fresh ALS context. No special handling. | +| SDK mode | Each `sdk()` proxy call clones the command tree and runs handler in its own `contextStorage.run()`. Concurrent calls are isolated by ALS. | +| TestHarness | Uses `mockContext()` to set up ALS context for testing (see Testing section). | +| Browser | Fallback stub provides `run()`/`getStore()` with last-set-wins semantics. Correct for single-execution browser usage. | +| Outside handler | `getCommandContext()` throws: `"No active execution context. Ensure this code runs during forge()/sdk() handler execution."` | +| Multiple CLIs | Each `forge()`/`sdk()` call creates its own ALS context. No collision. | +| Nested `forge()` same CLI | Each `contextStorage.run()` creates a new ALS scope. Inner execution sees its own context; outer resumes when inner completes. | + +--- + +## Testing + +### `TestHarness.mockContext()` - Context Mocking + +`TestHarness.mockContext()` sets up an ALS context so `getCommandContext(cli)` returns the mocked context during tests. The ALS implementation detail is hidden from users — they see a simple setup/teardown pattern: + +```typescript +import { TestHarness } from 'cli-forge'; +import { app } from './cli'; + +// Setup — installs a mock ALS context internally +const cleanup = TestHarness.mockContext(app, { + args: { logLevel: 'debug', apiUrl: 'http://test' }, + providers: { + logger: mockLogger, + api: mockApi, + }, +}); + +// Now any code that calls getCommandContext(app) gets the mocked context +await runDeploy(); +expect(mockApi.post).toHaveBeenCalledWith('/deploy/production'); + +// Teardown +cleanup(); + +// or bulk cleanup in afterEach(): +TestHarness.clearMockedContexts(); +``` + +**How it works**: `mockContext()` enters an ALS context with the provided args and pre-resolved providers. `getCommandContext()` finds this context normally via `contextStorage.getStore()`. Returns a cleanup function that exits the ALS context. + +### Integration testing with full execution + +For tests that need the full parse -> middleware -> handler flow, `new TestHarness(cli)` works as before — `forge()` sets up the real ALS context: + +```typescript +const harness = new TestHarness(app); +const result = await harness.parse(['deploy', '--target', 'production', '--apiUrl', 'http://test']); +// Handler ran with real provider factories, real ALS context +``` + +### API + +```typescript +class TestHarness { + // Existing API... + + /** + * Set up a mocked execution context. getCommandContext() will return + * a context with the given args and providers. + * Returns a cleanup function that removes the mocked context. + */ + static mockContext( + cli: CLI, + options: { + args?: Partial; + providers?: Partial; + commandChain?: string[]; + }, + ): () => void; + + /** Clear all mocked contexts. Call in afterEach(). */ + static clearMockedContexts(): void; +} +``` + +--- + +## Full Example + +```typescript +// cli.ts +import { cli } from 'cli-forge'; + +export const app = cli('deploy-tool') + .option('logLevel', { type: 'string', default: 'info' }) + .option('apiUrl', { type: 'string', required: true }) + .provide('logger', { + factory: (args) => createLogger(args.logLevel), + }) + .provide('api', { + factory: (args) => new ApiClient(args.apiUrl), + }) + .command('deploy', { + builder: (cmd) => cmd.option('target', { type: 'string', required: true }), + handler: async () => { + await runDeploy(); + }, + }); + +await app.forge(); +``` + +```typescript +// deploy.ts +import { getCommandContext } from 'cli-forge/context'; +import { app } from './cli'; + +export async function runDeploy() { + const rootCtx = getCommandContext(app); + const deployCtx = rootCtx.getChildContext('deploy'); + + const logger = rootCtx.inject('logger'); + const api = rootCtx.inject('api'); + const target = deployCtx.args.target; + + logger.info(`Deploying to ${target}...`); + const result = await api.post(`/deploy/${target}`); + + if (result.ok) { + logger.info('Deploy succeeded'); + await rootCtx.updateConfig({ lastDeploy: new Date().toISOString() }); + } else { + logger.error(`Deploy failed: ${result.statusText}`); + } +} +``` + +```typescript +// deploy.spec.ts +import { TestHarness } from 'cli-forge'; +import { app } from './cli'; +import { runDeploy } from './deploy'; + +it('deploys to production', async () => { + const mockLogger = { info: jest.fn(), error: jest.fn() }; + const mockApi = { post: jest.fn().mockResolvedValue({ ok: true }) }; + + const cleanup = TestHarness.mockContext(app, { + args: { logLevel: 'info', apiUrl: 'http://test', target: 'production' }, + providers: { logger: mockLogger, api: mockApi }, + commandChain: ['deploy'], + }); + + await runDeploy(); + expect(mockApi.post).toHaveBeenCalledWith('/deploy/production'); + + cleanup(); +}); +``` + +--- + +## Full API Surface + +```typescript +// -- cli-forge (main entrypoint additions) -- + +interface ProviderConfig { + factory: ((args: TArgs) => T) | (() => T); + /** Default: 'executionScope' */ + lifetime?: 'global' | 'executionScope'; +} + +// Global lifetime enforces no-args factory +interface GlobalProviderConfig { + factory: () => T; + lifetime: 'global'; +} + +export type AnyCLI = CLI; + +// CLI.provide() additions +interface CLI { + // Eager value (rejects duplicate keys at same level) + provide( + key: TName & (TName extends keyof TProviders ? never : TName), + value: T, + ): CLI; + + // Factory — executionScope (receives args) + provide( + key: TName & (TName extends keyof TProviders ? never : TName), + config: ProviderConfig, + ): CLI; + + // Factory — global (no args) + provide( + key: TName & (TName extends keyof TProviders ? never : TName), + config: GlobalProviderConfig, + ): CLI; +} + +// -- cli-forge/context -- + +/** Infer the CommandContext type from a CLI type */ +type InferContextOfCommand = T extends CLI + ? CommandContext, C> + : never; + +/** Recursively gather all providers from a CLI and its ancestors */ +type ProvidersOf = T extends CLI + ? (Parent extends AnyCLI ? ProvidersOf & Providers : Providers) + : {}; + +// Overload 1: Typed via CLI instance (T inferred from argument) +function getCommandContext(cli: T): InferContextOfCommand; +// Overload 2: Typed via explicit generic (no instance needed) +function getCommandContext(): InferContextOfCommand; + +interface CommandContext { + readonly args: TArgs; + readonly commandChain: readonly string[]; + + inject(key: K): TProviders[K]; + inject(key: K, defaultValue: TProviders[K]): TProviders[K]; + + updateConfig(values: Partial): Promise; + updateConfig(updater: ConfigUpdater): Promise; + + getChildContext( + command: K, + ): TChildren[K] extends CLI + ? CommandContext & P, GC> + : never; +} + +// -- TestHarness additions (on existing class) -- +class TestHarness { + static mockContext( + cli: CLI, + options: { + args?: Partial; + providers?: Partial; + commandChain?: string[]; + }, + ): () => void; + + static clearMockedContexts(): void; +} +``` + +--- + +## Files Changed + +| File | Change | +|------|--------| +| `packages/cli-forge/src/lib/public-api.ts` | Add `TProviders` to CLI, `.provide()`, `AnyCLI`, `ProviderConfig`, `GlobalProviderConfig`, deprecate `UnknownCLI` | +| `packages/cli-forge/src/lib/internal-cli.ts` | Add `TProviders` to InternalCLI, implement `.provide()`, wrap handler execution in `contextStorage.run()`, `AnyInternalCLI`, replace ~17 any casts | +| `packages/cli-forge/src/lib/async-context.ts` | **New**: Node/Bun/Deno AsyncLocalStorage wrapper, `ForgeContextData` type, `contextStorage` export | +| `packages/cli-forge/src/browser/async-context.ts` | **New**: Browser fallback stub with last-set-wins semantics | +| `packages/cli-forge/src/lib/context.ts` | **New**: `getCommandContext`, `CommandContext`, `InferContextOfCommand`, `ProvidersOf` | +| `packages/cli-forge/src/lib/test-harness.ts` | Add `mockContext`, `clearMockedContexts` static methods | +| `packages/cli-forge/src/lib/resolve-completions.ts` | Replace ~11 `InternalCLI` with `AnyInternalCLI` | +| `packages/cli-forge/src/lib/documentation.ts` | Replace `InternalCLI` with `AnyInternalCLI` | +| `packages/cli-forge/src/lib/format-help.ts` | Replace `InternalCLI` with `AnyInternalCLI` | +| `packages/cli-forge/src/lib/composable-builder.ts` | Update generic parameters | +| `packages/cli-forge/tsdown.config.mjs` | Add `'async-context'` to `browserAlias` | +| `packages/cli-forge/package.json` | Add `"./context"` export entry | +| `examples/` | New multi-file example | +| `type-tests/` | TProviders accumulation, inject key constraints, duplicate key rejection, getChildContext, `InferContextOfCommand` | + +--- + +## Resolved Decisions + +- **Context propagation**: AsyncLocalStorage with browser fallback stub +- **`getChildContext` runtime**: Throws if child not in active command chain. +- **Tokens vs string keys**: String literal keys. No tokens. +- **Naming**: `getCommandContext()`, `CommandContext`, `InferContextOfCommand`. +- **Parent provider inheritance**: Recursive `ProvidersOf` type walks `TParent` chain. +- **Duplicate keys**: Forbidden at same level (type error). Child-shadows-parent allowed with `Omit` override semantics. +- **Global factories**: No args parameter. Prevents unsound binding to first execution's args. +- **Provider execution timing**: Deferred to handler phase. `inject()` not available during middleware/init hooks. +- **`getCommandContext` overloads**: Both `(cli: T)` and `()` forms. CLI parameter is a type-level witness, not used at runtime. +- **Testing API**: Setup/teardown pattern with `mockContext()` returning cleanup function. ALS is hidden as implementation detail. +- **AnyCLI prerequisite**: Replace scattered `` before adding 5th generic. + +## Open Questions (v2 candidates) + +1. **Provider disposal**: `executionScope` providers with `dispose?: (value: T) => void` for cleanup? diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 967551c4b..c67c3353d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -42,7 +42,8 @@ "Bash(my-app:*)", "Bash(mise install:*)", "Bash(mise exec:*)", - "Skill(docs-style-check)" + "Skill(docs-style-check)", + "Skill(write-example)" ], "deny": [], "ask": [] diff --git a/docs-site/docs/guides/dependency-injection.md b/docs-site/docs/guides/dependency-injection.md new file mode 100644 index 000000000..b11a13145 --- /dev/null +++ b/docs-site/docs/guides/dependency-injection.md @@ -0,0 +1,251 @@ +--- +title: Dependency Injection +description: Register providers with .provide() and inject them into handlers via getCommandContext() +nav: + order: 9 +--- + +# Dependency injection + +CLI Forge ships a small dependency-injection layer so command handlers can access shared services — loggers, database clients, API clients, config objects — without threading them through function calls or reaching for module-level singletons. + +The full working example lives in [`examples/di-logger`](/examples/di-logger). This guide explains the underlying model and the subtleties you should know about before using it on a real CLI. + +## The model + +Two public APIs: + +- **`.provide(key, configOrValue)`** — registers a provider on a CLI or subcommand. Values are injected by key. +- **`getCommandContext(cli)`** — reads the active command context from inside a handler. Returns `{ args, commandChain, inject, getChildContext }`. + +Providers come in three flavors: + +| Form | Signature | When the factory runs | Good for | +|---|---|---|---| +| **Eager value** | `.provide('db', dbInstance)` | Never — the value is stored as-is | Pre-built objects, test doubles | +| **Execution scope** | `.provide('logger', { factory: (args) => ... })` | Once per `forge()` or `sdk()` call, lazily on first inject | Services that depend on parsed args | +| **Global** | `.provide('pool', { factory: () => ..., lifetime: 'global' })` | Once per process, cached permanently | Expensive singletons with no args dependency | + +Factories are invoked lazily when `inject()` is called inside a handler, so they always see the final, fully-parsed args — not the half-parsed state you'd get during middleware or init hooks. + +## Handlers must live in their own module + +To get typed `inject()` calls, `getCommandContext` needs the CLI instance as a type witness: + +```typescript +const ctx = getCommandContext(app); +const logger = ctx.inject('logger'); // typed as Logger, not unknown +``` + +If you try to use `getCommandContext(app)` inside a handler defined _inline_ in the `.command()` call, TypeScript can't resolve `typeof app` — the inference is circular because `app` is still being constructed. The handler's `inject` calls then fall back to `any`. + +The fix is to put the handler in its own file: + +```typescript +// cli.ts +import { cli } from 'cli-forge'; +import { runBuild } from './build'; + +export const app = cli('builder') + .option('logLevel', { type: 'string', default: 'info' }) + .provide('logger', { factory: (args) => makeLogger(args.logLevel) }) + .command('build', { handler: runBuild }); +``` + +```typescript +// build.ts +import { getCommandContext } from 'cli-forge/context'; +import { app } from './cli'; + +export function runBuild() { + const log = getCommandContext(app).inject('logger'); // typed + log.info('building'); +} +``` + +By the time `build.ts` is type-checked, `typeof app` is fully resolved and the provider types flow through cleanly. + +## Child commands can override parent providers + +Re-providing a key on a subcommand shadows the parent's registration — both at runtime and in the types: + +```typescript +const app = cli('app') + .provide('db', prodDb) + .command('test', { + builder: (cmd) => cmd.provide('db', testDb), // shadows parent's `db` + handler: runTest, // sees `testDb` when it inject()s `'db'` + }); +``` + +Providers added on ancestors are still visible from the child's context — only the conflicting key is overridden. + +## Reaching a subcommand's typed context + +You have two ways to get a `CommandContext` typed to a subcommand's args and providers. Pick based on where the subcommand's providers live. + +### Recommended — pass the subcommand CLI directly + +If the subcommand **doesn't need to inherit providers from the root** — i.e. it defines its own, or only uses providers declared on itself — declare it as a standalone CLI, import that reference from the handler's module, and pass it straight to `getCommandContext`: + +```typescript +// build.ts +import { cli } from 'cli-forge'; +import { getCommandContext } from 'cli-forge/context'; + +export const build = cli('build') + .option('target', { type: 'string', required: true }) + .provide('logger', { factory: () => makeLogger() }) + .handler(() => { + const ctx = getCommandContext(build); + ctx.inject('logger').info(`Target: ${ctx.args.target}`); + }); +``` + +```typescript +// cli.ts +import { cli } from 'cli-forge'; +import { build } from './build'; + +export const app = cli('app').command(build); +``` + +This is the most ergonomic form: + +- No `getChildContext` hop and no `app.getChildren().build` lookup. +- The handler module owns the subcommand reference, so there's no circular `typeof app` inference to work around. +- `ctx.args` and `ctx.inject()` are fully typed by `build`'s own declarations. +- Runtime validation still fires because `build.commandId` is on the active `commandIdChain` whenever `build` is the running command. + +Reach for this pattern by default when you're designing a subcommand that manages its own services. + +### When the subcommand inherits providers from the root + +The standalone form has one type-level limitation: a standalone `cli('build', ...)` has `TParent = undefined`, so its tracked type doesn't include providers declared on whichever parent eventually composes it. If `build`'s handler needs to `inject()` a provider that lives on `app`, the standalone reference won't carry that type. + +Two ways to handle that case: + +**(a)** Access the root's providers via the root context and narrow to the child with `getChildContext`: + +```typescript +// build.ts +import { getCommandContext } from 'cli-forge/context'; +import { app } from './cli'; + +export function runBuild() { + const rootCtx = getCommandContext(app); + const buildCtx = rootCtx.getChildContext('build'); + rootCtx.inject('logger').info(`Target: ${buildCtx.args.target}`); +} +``` + +`getChildContext('build')` returns a `CommandContext` so `buildCtx.args.target` is typed. `rootCtx.inject('logger')` goes through the root's declared providers. + +**(b)** Declare the subcommand inline inside `.command('name', { builder, handler })` and fetch the tracked instance via `app.getChildren()`. The inline form threads the parent's `TProviders` into the builder's `cmd` parameter, so the tracked child type *does* carry inherited providers: + +```typescript +// cli.ts +export const app = cli('app') + .provide('logger', makeLogger) + .command('build', { + builder: (cmd) => cmd.option('target', { type: 'string', required: true }), + handler: runBuild, + }); +``` + +```typescript +// build.ts +import { getCommandContext } from 'cli-forge/context'; +import { app } from './cli'; + +export function runBuild() { + const build = app.getChildren().build; // tracked instance carries parent providers + const ctx = getCommandContext(build); + ctx.inject('logger').info(`Target: ${ctx.args.target}`); +} +``` + +Both (a) and (b) are runtime-safe — the `commandIdChain` accepts the root, any ancestor, and the running command. The choice is purely about which type surface is more ergonomic for your handler. + +### Quick decision guide + +| Subcommand shape | Preferred handler pattern | +|---|---| +| Defines its own providers, doesn't need root's | Standalone `cli('sub')`, `getCommandContext(sub)` — **most ergonomic** | +| Needs root-inherited providers | `getCommandContext(app).getChildContext('sub')` or inline `.command('sub', { ... })` + `app.getChildren().sub` | + +### Runtime validation + +Both forms use the CLI instance as a **runtime** check, not just a type witness. Every `InternalCLI` is stamped with a process-unique `commandId` at construction. When `forge()` builds the execution context, it records the id of every command from the root down to the currently-running one. `getCommandContext(cli)` throws if `cli.commandId` isn't on that chain — so importing the wrong CLI or passing an unrelated app produces a descriptive error instead of silently returning the wrong providers. `getChildContext(name)` does the equivalent check against the command-name chain. + +Any command on the active chain is a valid reference: the root app, the running subcommand itself, and any ancestor in between. A sibling that wasn't reached, or a CLI from a different app entirely, will throw. + +### The root context's `args` type is a subset + +The root `CommandContext.args` type reflects only options defined at the root level. When reached via a subcommand, the underlying args object at runtime also contains the subcommand's options, but they're not visible through `rootCtx.args`. Use either pattern above to read subcommand-level options with the correct types — `rootCtx.getChildContext('sub').args` or `getCommandContext(subCli).args`. + +### The no-instance overload is not runtime-safe + +`getCommandContext()` with an explicit type parameter but no instance is supported as an escape hatch for cases where you can't get a live CLI reference from the handler's module. **It has no runtime validation** — `T` is accepted as-is, so a mismatched type parameter will silently produce a context typed for a CLI that isn't actually running. Prefer passing the CLI instance whenever feasible. + +## Testing providers + +The `TestHarness` exposes two APIs for DI tests. Pick `runWithMockedContext` for anything that crosses an `await`: + +```typescript +import { TestHarness } from 'cli-forge'; +import { getCommandContext } from 'cli-forge/context'; +import { afterEach, it, expect } from 'vitest'; +import { app } from './cli'; + +afterEach(() => TestHarness.clearMockedContexts()); + +it('uses the mocked logger', async () => { + const calls: string[] = []; + const mockLogger = { info: (msg: string) => calls.push(msg) }; + + await TestHarness.runWithMockedContext( + app, + { + args: { logLevel: 'info' }, + providers: { logger: mockLogger }, + }, + async () => { + await runBuild(); + } + ); + + expect(calls).toContain('Building target: web'); +}); +``` + +`runWithMockedContext` scopes the context via `AsyncLocalStorage.run()`, so the mock survives `await` points inside `fn` and is torn down when `fn` resolves (or throws). + +`mockContext` is a simpler setup/teardown variant for synchronous tests: + +```typescript +it('resolves provider', () => { + const cleanup = TestHarness.mockContext(app, { + providers: { db: mockDb }, + }); + + expect(getCommandContext(app).inject('db')).toBe(mockDb); + cleanup(); +}); +``` + +Both variants accept eager values _or_ `{ factory }` configs — mocked factories run lazily and receive the mocked `args`, mirroring how real `.provide()` calls work. + +### Global providers and test isolation + +Providers registered with `lifetime: 'global'` are cached permanently at the module level so they only run once per process. That's the right behavior in production, but it leaks between tests. `TestHarness.clearMockedContexts()` automatically calls `resetGlobalProviders()` for you, so the standard `afterEach(() => TestHarness.clearMockedContexts())` pattern is enough. + +If you need to reset the global cache mid-test, both `TestHarness.resetGlobalProviders()` and the raw `resetGlobalProviders` export (from `cli-forge` or `cli-forge/context`) are available. + +## Dependency injection and the browser runtime + +The Node build uses `AsyncLocalStorage` to scope each `forge()` / `sdk()` execution to its own context — concurrent invocations don't leak providers between each other. + +The browser build can't use `AsyncLocalStorage`; it falls back to a simple last-set-wins global store. That's correct for the common case (one CLI running at a time in a playground) but it can't isolate overlapping executions. If two `forge()` calls are in flight at once in the same browser tab — for example, an interactive shell that triggers another invocation before the first resolves — one's providers may be visible inside the other. + +The browser build emits a one-shot `console.warn` the first time it detects concurrent `run()` calls, so misuse fails loudly. If you need true isolation in a browser context, sequence your invocations explicitly (`await` the first before starting the second). diff --git a/examples/composable-options.ts b/examples/composable-options.ts index 8cb7aa282..433508541 100644 --- a/examples/composable-options.ts +++ b/examples/composable-options.ts @@ -33,7 +33,7 @@ import { UnknownCLI, chain, cli, makeComposableBuilder } from 'cli-forge'; // -- Reusable option definitions -- -// Manual generic approach +// Manual generic approach (uses UnknownCLI as the constraint) function withName(argv: T) { return argv.option('name', { type: 'string', @@ -42,7 +42,6 @@ function withName(argv: T) { }); } -// Using the helper (types inferred) const withGreeting = makeComposableBuilder((args) => args.option('greeting', { type: 'string', diff --git a/examples/di-logger/build.ts b/examples/di-logger/build.ts new file mode 100644 index 000000000..2379351e8 --- /dev/null +++ b/examples/di-logger/build.ts @@ -0,0 +1,23 @@ +import { getCommandContext } from 'cli-forge/context'; +import { app } from './cli'; + +// The handler lives in its own file so that `typeof app` is fully resolved +// by the time this module is type-checked. `getCommandContext(app)` uses +// the imported CLI as both a type witness (for inject/args typing) and a +// runtime identity check — at runtime the stored commandIdChain is walked +// to confirm `app` is part of the active execution. +export function runBuild() { + const ctx = getCommandContext(app); + const build = ctx.getChildContext('build'); + + const log = ctx.inject('logger'); + + log.debug('Resolving toolchain'); + log.debug('Loading config'); + log.info(`Building target: ${build.args.target}`); + log.warn('No cache configured'); + + // Surface how many messages the logger filtered out so tests can verify + // that log level filtering was applied. + console.log(`DONE (suppressed=${log.suppressed()})`); +} diff --git a/examples/di-logger/cli.ts b/examples/di-logger/cli.ts new file mode 100644 index 000000000..117122ee6 --- /dev/null +++ b/examples/di-logger/cli.ts @@ -0,0 +1,34 @@ +import { cli } from 'cli-forge'; +import { LEVELS, Level, makeLogger } from './logger'; +import { runBuild } from './build'; + +// A CLI that registers a logger as a DI provider. The factory reads +// `args.logLevel` when the provider is first injected, so the logger +// automatically obeys whatever level the user passed on the command line — +// no middleware, no prop drilling, no module-level singletons. +export const app = cli('builder') + .option('logLevel', { + type: 'string', + choices: LEVELS, + default: 'info' as Level, + description: 'Minimum log level to emit', + }) + .provide('logger', { + // Factory receives the finalized args, so logLevel is the one the user + // actually passed (after defaults, env vars, and config files resolve). + factory: (args) => makeLogger(args.logLevel as Level), + }) + .command('build', { + description: 'Build a target', + builder: (cmd) => + cmd.option('target', { + type: 'string', + required: true, + description: 'Build target (e.g. web, node)', + }), + handler: runBuild, + }); + +if (require.main === module) { + app.forge(); +} diff --git a/examples/di-logger/content.md b/examples/di-logger/content.md new file mode 100644 index 000000000..f57a1282d --- /dev/null +++ b/examples/di-logger/content.md @@ -0,0 +1,53 @@ +## The Logger + +A standalone module that defines the logger and its level-filtering logic. +Keeping this out of the CLI module makes it trivial to unit test and reuse. + +<%= file('logger.ts') %> + +## Registering the Provider + +`.provide('logger', { factory })` registers a factory that receives the +finalized, fully-parsed args. Because the factory is only called the first +time `inject('logger')` runs — during the handler phase, after all parsing, +middleware, and validation have completed — `args.logLevel` already reflects +whatever the user passed on the command line (or via environment variables, +config files, defaults, etc.). + +<%= file('cli.ts') %> + +## Using the Logger in a Handler + +The handler lives in its own file and imports the CLI instance. This matters: +`getCommandContext(app)` uses `app` as a **type witness** — TypeScript infers +providers and args types from `typeof app`. If the handler were defined inline +inside the `.command()` call, `typeof app` would be circular and TypeScript +would fall back to `unknown`. Splitting the handler sidesteps that. + +The live context is read from `AsyncLocalStorage` set up by `forge()`, but +the `app` argument is not purely compile-time. It's also used as a +**runtime identity check**: every `InternalCLI` is stamped with a unique +`commandId`, and `getCommandContext(cli)` throws if `cli.commandId` isn't on +the active chain (root → running command). Passing the wrong CLI as the +witness fails loudly instead of silently returning the wrong providers. + +<%= file('build.ts') %> + +## Why This Pattern + +Without DI, a logger typically becomes a module-level singleton that's +imported everywhere: + +```ts +// logger.ts +export const logger = makeLogger(process.env.LOG_LEVEL ?? 'info'); +``` + +That works until you want to: + +- Override the level per-command invocation (tests, SDK usage, REPL) +- Make the level depend on parsed CLI args (which don't exist at import time) +- Mock the logger in unit tests without module-level surgery + +With `.provide()`, the logger is scoped to each execution, reads args from +the actual parse, and can be swapped via `TestHarness.mockContext()` in tests. diff --git a/examples/di-logger/logger.ts b/examples/di-logger/logger.ts new file mode 100644 index 000000000..f0501d77f --- /dev/null +++ b/examples/di-logger/logger.ts @@ -0,0 +1,30 @@ +export const LEVELS = ['debug', 'info', 'warn', 'error'] as const; +export type Level = (typeof LEVELS)[number]; + +export interface Logger { + debug(msg: string): void; + info(msg: string): void; + warn(msg: string): void; + error(msg: string): void; + /** How many messages have been suppressed below the configured level. */ + suppressed(): number; +} + +export function makeLogger(level: Level): Logger { + const threshold = LEVELS.indexOf(level); + let suppressed = 0; + const emit = (lvl: Level, msg: string) => { + if (LEVELS.indexOf(lvl) < threshold) { + suppressed++; + return; + } + console.log(`[${lvl.toUpperCase()}] ${msg}`); + }; + return { + debug: (m) => emit('debug', m), + info: (m) => emit('info', m), + warn: (m) => emit('warn', m), + error: (m) => emit('error', m), + suppressed: () => suppressed, + }; +} diff --git a/examples/di-logger/meta.yml b/examples/di-logger/meta.yml new file mode 100644 index 000000000..e9102ebee --- /dev/null +++ b/examples/di-logger/meta.yml @@ -0,0 +1,31 @@ +id: di-logger +title: Dependency Injection - Logger with Log Level +description: | + Registers a `logger` provider whose factory inspects `args.logLevel`. + Any command handler can `inject('logger')` via `getCommandContext()` — no + prop drilling, and the logger's level filtering automatically follows + whatever the user passed on the command line. + + The handler lives in its own module and imports the CLI instance. That + way `typeof app` is fully resolved where `getCommandContext(app)` is + called, so `inject('logger')` is properly typed as `Logger` instead of + `unknown`. +test: + - name: 'default info level suppresses debug messages' + options: + command: 'npx tsx --no-cache --tsconfig ../tsconfig.json cli.ts build --target web' + assertions: + stdout: + matches: '\[INFO\] Building target: web.*\[WARN\] No cache configured.*DONE \(suppressed=2\)' + - name: 'debug level emits every message' + options: + command: 'npx tsx --no-cache --tsconfig ../tsconfig.json cli.ts --log-level debug build --target web' + assertions: + stdout: + matches: '\[DEBUG\] Resolving toolchain.*\[DEBUG\] Loading config.*\[INFO\] Building target: web.*\[WARN\] No cache configured.*DONE \(suppressed=0\)' + - name: 'warn level only shows warnings' + options: + command: 'npx tsx --no-cache --tsconfig ../tsconfig.json cli.ts --log-level warn build --target web' + assertions: + stdout: + matches: '\[WARN\] No cache configured.*DONE \(suppressed=3\)' diff --git a/examples/providers/cli.ts b/examples/providers/cli.ts new file mode 100644 index 000000000..28732f3f2 --- /dev/null +++ b/examples/providers/cli.ts @@ -0,0 +1,37 @@ +import { cli } from 'cli-forge'; +import { runDeploy } from './deploy'; + +export const app = cli('deploy-tool') + .option('logLevel', { + type: 'string', + default: 'info', + description: 'Logging level', + }) + .option('apiUrl', { + type: 'string', + required: true, + description: 'API base URL', + }) + // Register a logger provider whose factory receives the parsed args. + // Any command handler can inject it without threading it through calls. + .provide('logger', { + factory: (args) => ({ + info: (msg: string) => console.log(`[${args.logLevel}] ${msg}`), + }), + }) + .command('deploy', { + description: 'Deploy to a target environment', + builder: (cmd) => + cmd.option('target', { + type: 'string', + required: true, + description: 'Deployment target', + }), + handler: async () => { + await runDeploy(); + }, + }); + +if (require.main === module) { + app.forge(); +} diff --git a/examples/providers/deploy.ts b/examples/providers/deploy.ts new file mode 100644 index 000000000..cebebe83f --- /dev/null +++ b/examples/providers/deploy.ts @@ -0,0 +1,15 @@ +import { getCommandContext } from 'cli-forge/context'; +import { app } from './cli'; + +export async function runDeploy() { + // Passing `app` as both the type witness and the runtime identity check. + // The stored commandIdChain walks root -> deploy, so both the root and + // any ancestor are valid references. + const ctx = getCommandContext(app); + const deployCtx = ctx.getChildContext('deploy'); + + const logger = ctx.inject('logger'); + const target = deployCtx.args.target; + + logger.info(`Deploying to ${target}`); +} diff --git a/examples/providers/meta.yml b/examples/providers/meta.yml new file mode 100644 index 000000000..11bee67e4 --- /dev/null +++ b/examples/providers/meta.yml @@ -0,0 +1,16 @@ +id: providers +title: Dependency Injection with Providers +description: | + Demonstrates using `.provide()` and `getCommandContext()` to register + and inject services without threading them through function calls. + + The logger provider is registered once on the CLI with a factory that + receives parsed args, then any command handler can access it via + `getCommandContext(app).inject('logger')` — no prop drilling needed. +test: + - name: 'Logs deployment target using injected logger' + options: + command: 'npx tsx --no-cache --tsconfig ../tsconfig.json cli.ts deploy --target production --apiUrl http://example.com' + assertions: + stdout: + contains: 'Deploying to production' diff --git a/packages/cli-forge/package.json b/packages/cli-forge/package.json index 7c6c2f1b2..fd3f37899 100644 --- a/packages/cli-forge/package.json +++ b/packages/cli-forge/package.json @@ -72,6 +72,13 @@ "import": "./dist/prompt-providers/clack.mjs", "require": "./dist/prompt-providers/clack.cjs" }, + "./context": { + "browser": "./dist/browser/lib/context.mjs", + "types": "./dist/lib/context.d.mts", + "import": "./dist/lib/context.mjs", + "require": "./dist/lib/context.cjs", + "default": "./dist/lib/context.cjs" + }, "./package.json": "./package.json" }, "publishConfig": { diff --git a/packages/cli-forge/src/browser/async-context.ts b/packages/cli-forge/src/browser/async-context.ts new file mode 100644 index 000000000..532eda019 --- /dev/null +++ b/packages/cli-forge/src/browser/async-context.ts @@ -0,0 +1,69 @@ +/** + * Browser-safe fallback for AsyncLocalStorage. + * + * Uses a simple last-set-wins store. This is correct for browser usage + * where CLI execution is single-threaded/sequential. + * + * Swapped in via browserAlias in tsdown.config.mjs. + */ + +export interface ForgeContextData { + args: Record; + commandChain: string[]; + commandIdChain: string[]; + providers: Map; + providerFactories: Map; + handlerPhase: boolean; + /** Keys whose factories are currently being resolved — used for cycle detection */ + resolving: Set; +} + +let currentStore: ForgeContextData | undefined; +let activeRuns = 0; +let warnedAboutConcurrency = false; + +/** + * Emit a one-shot warning when a second `forge()`/`sdk()` call starts while + * another is still on the stack. Real async-boundary isolation needs + * `AsyncLocalStorage`, which browsers don't provide — this store is just a + * last-set-wins global. The warning exists so misuse fails loudly instead of + * silently leaking providers between concurrent executions. + */ +function warnOnceAboutConcurrency(): void { + if (warnedAboutConcurrency) return; + warnedAboutConcurrency = true; + // eslint-disable-next-line no-console + console.warn( + '[cli-forge] Concurrent forge()/sdk() execution detected in the browser. ' + + 'The browser build uses a last-set-wins context store because ' + + 'AsyncLocalStorage is unavailable, so DI providers may leak between ' + + 'overlapping executions. See docs: "DI and the browser runtime".' + ); +} + +export const contextStorage = { + run( + store: ForgeContextData, + fn: (...args: unknown[]) => T, + ...args: unknown[] + ): T { + if (activeRuns > 0) { + warnOnceAboutConcurrency(); + } + const prev = currentStore; + currentStore = store; + activeRuns++; + try { + return fn(...args); + } finally { + currentStore = prev; + activeRuns--; + } + }, + getStore(): ForgeContextData | undefined { + return currentStore; + }, + enterWith(store: ForgeContextData | undefined): void { + currentStore = store; + }, +}; diff --git a/packages/cli-forge/src/browser/interactive-shell.ts b/packages/cli-forge/src/browser/interactive-shell.ts index ce1c62805..37adfb768 100644 --- a/packages/cli-forge/src/browser/interactive-shell.ts +++ b/packages/cli-forge/src/browser/interactive-shell.ts @@ -1,4 +1,4 @@ -import type { InternalCLI } from '../lib/internal-cli'; +import type { AnyInternalCLI } from '../lib/internal-cli'; export interface InteractiveShellOptions { prompt?: string; @@ -14,7 +14,7 @@ export interface InteractiveShellOptions { export let INTERACTIVE_SHELL: InteractiveShell | undefined; export class InteractiveShell { - constructor(_cli: InternalCLI, _opts?: InteractiveShellOptions) { + constructor(_cli: AnyInternalCLI, _opts?: InteractiveShellOptions) { throw new Error('Interactive shell is only available in Node.js runtimes'); } } diff --git a/packages/cli-forge/src/index.ts b/packages/cli-forge/src/index.ts index a684c081b..35601afae 100644 --- a/packages/cli-forge/src/index.ts +++ b/packages/cli-forge/src/index.ts @@ -18,3 +18,11 @@ export type { export { completionHelpers } from './lib/completion-types'; export { ConfigurationProviders } from './lib/configuration-providers'; export type { LocalizationDictionary, LocalizationFunction } from '@cli-forge/parser'; +export type { ProviderConfig, GlobalProviderConfig } from './lib/public-api'; +export type { + CommandContext, + InferContextOfCommand, + ProvidersOf, + ProvidersFromChain, +} from './lib/context'; +export { getCommandContext, resetGlobalProviders } from './lib/context'; diff --git a/packages/cli-forge/src/lib/async-context.ts b/packages/cli-forge/src/lib/async-context.ts new file mode 100644 index 000000000..87d57ab6d --- /dev/null +++ b/packages/cli-forge/src/lib/async-context.ts @@ -0,0 +1,27 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +export interface ForgeContextData { + args: Record; + commandChain: string[]; + /** + * Process-unique identifiers of every CLI instance from the root down to + * the currently-running command. Used by `getCommandContext(cli)` to + * validate the CLI reference passed as a type witness is any command on + * the active chain — the root app, an ancestor, or the running command + * itself. Accepting ancestors matters because standalone-composed + * subcommands don't track their parent in the type system, so users + * reliably have a reference to the root `app` but not always to the + * specific running subcommand. + */ + commandIdChain: string[]; + /** Pre-resolved eager providers and cached factory results */ + providers: Map; + /** Factory registrations for lazy resolution */ + providerFactories: Map; + /** Whether inject() is currently allowed (only during handler phase) */ + handlerPhase: boolean; + /** Keys whose factories are currently being resolved — used for cycle detection */ + resolving: Set; +} + +export const contextStorage = new AsyncLocalStorage(); diff --git a/packages/cli-forge/src/lib/cli-option-groups.ts b/packages/cli-forge/src/lib/cli-option-groups.ts index b0c8b4c52..d7531126a 100644 --- a/packages/cli-forge/src/lib/cli-option-groups.ts +++ b/packages/cli-forge/src/lib/cli-option-groups.ts @@ -1,8 +1,8 @@ import { InternalOptionConfig } from '@cli-forge/parser'; -import { InternalCLI } from './internal-cli'; +import { AnyInternalCLI } from './internal-cli'; -export function readOptionGroupsForCLI(parentCLI: InternalCLI) { - function registerGroupsFromCLI(cli: InternalCLI) { +export function readOptionGroupsForCLI(parentCLI: AnyInternalCLI) { + function registerGroupsFromCLI(cli: AnyInternalCLI) { for (const { label, keys, sortOrder } of cli.registeredOptionGroups) { groups[label] ??= { keys: new Set(), @@ -19,7 +19,7 @@ export function readOptionGroupsForCLI(parentCLI: InternalCLI) { const groups: Record; sortOrder: number }> = {}; - let command: InternalCLI = parentCLI; + let command: AnyInternalCLI = parentCLI; registerGroupsFromCLI(command); for (const subcommand of parentCLI.commandChain) { command = command?.registeredCommands[subcommand]; diff --git a/packages/cli-forge/src/lib/composable-builder.ts b/packages/cli-forge/src/lib/composable-builder.ts index 3db46420e..e15e9d83e 100644 --- a/packages/cli-forge/src/lib/composable-builder.ts +++ b/packages/cli-forge/src/lib/composable-builder.ts @@ -4,14 +4,14 @@ import { CLI } from './public-api'; /** * Extracts the TChildren type parameter from a CLI type. */ -export type ExtractChildren = T extends CLI +export type ExtractChildren = T extends CLI ? C : never; /** * Extracts the TArgs type parameter from a CLI type. */ -export type ExtractArgs = T extends CLI ? A : never; +export type ExtractArgs = T extends CLI ? A : never; /** * Type for a composable builder function that transforms a CLI. @@ -19,11 +19,11 @@ export type ExtractArgs = T extends CLI ? A : never; */ export type ComposableBuilder< TArgs2 extends ParsedArgs, - - TAddedChildren = {} -> = ( - init: CLI -) => CLI, THandlerReturn, TChildren & TAddedChildren, TParent>; + TAddedChildren = {}, + TAddedProviders = {} +> = ( + init: CLI +) => CLI, THandlerReturn, TChildren & TAddedChildren, TParent, TProviders & TAddedProviders>; /** * Creates a composable builder function that can be used with `chain`. @@ -36,16 +36,16 @@ export type ComposableBuilder< * * @typeParam TArgs2 - The args type after the builder runs * @typeParam TChildren2 - The children type added by the builder + * @typeParam TProviders2 - The providers type added by the builder */ export function makeComposableBuilder< TArgs2 extends ParsedArgs, - - TChildren2 = {} + TChildren2 = {}, + TProviders2 = {} >( fn: ( - - init: CLI - ) => CLI + init: CLI + ) => CLI ) { // Run builder once against a recording proxy to capture operations. // Replaying these ensures inline closures (e.g. middleware) keep stable @@ -61,8 +61,8 @@ export function makeComposableBuilder< }); fn(proxy); - return ( - init: CLI + return ( + init: CLI ) => { let current: any = init; for (const op of operations) { @@ -72,7 +72,8 @@ export function makeComposableBuilder< Expand, THandlerReturn, TChildren & TChildren2, - TParent + TParent, + TProviders & TProviders2 >; }; } diff --git a/packages/cli-forge/src/lib/context.spec.ts b/packages/cli-forge/src/lib/context.spec.ts new file mode 100644 index 000000000..45d8a25b1 --- /dev/null +++ b/packages/cli-forge/src/lib/context.spec.ts @@ -0,0 +1,492 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { ParsedArgs } from '@cli-forge/parser'; +import { cli, CLI } from './public-api'; +import { getCommandContext, resetGlobalProviders } from './context'; + +afterEach(() => { + // Reset exit code set by handlers that error + process.exitCode = undefined; + // Wipe any global-lifetime provider state between specs + resetGlobalProviders(); +}); + +describe('getCommandContext() via forge()', () => { + it('provides args during handler execution', async () => { + let capturedArgs: any; + + await cli('test', { + builder: (cmd) => cmd.option('name', { type: 'string', default: 'world' }), + handler: () => { + const ctx = getCommandContext(); + capturedArgs = ctx.args; + }, + }).forge(['--name', 'alice']); + + expect(capturedArgs.name).toBe('alice'); + }); + + it('provides commandChain for root command', async () => { + let capturedChain: string[] | undefined; + + await cli('my-app', { + handler: () => { + const ctx = getCommandContext(); + capturedChain = ctx.commandChain; + }, + }).forge([]); + + expect(capturedChain).toEqual([]); + }); + + it('provides commandChain for subcommand', async () => { + let capturedChain: string[] | undefined; + + await cli('my-app') + .command('serve', { + handler: () => { + const ctx = getCommandContext(); + capturedChain = ctx.commandChain; + }, + }) + .forge(['serve']); + + expect(capturedChain).toEqual(['serve']); + }); + + it('resolves eager providers via inject()', async () => { + let injectedValue: unknown; + + await cli('test') + .provide('greeting', 'hello from eager') + .command('run', { + handler: () => { + const ctx = getCommandContext(); + injectedValue = ctx.inject('greeting'); + }, + }) + .forge(['run']); + + expect(injectedValue).toBe('hello from eager'); + }); + + it('resolves executionScope factory providers with finalized args', async () => { + let resolvedValue: unknown; + + await cli('test', { + builder: (cmd) => cmd.option('prefix', { type: 'string', default: 'hi' }), + }) + .provide('greeting', { + factory: (args: Record) => `${args['prefix']} world`, + lifetime: 'executionScope', + }) + .command('run', { + handler: () => { + const ctx = getCommandContext(); + resolvedValue = ctx.inject('greeting'); + }, + }) + .forge(['--prefix', 'hey', 'run']); + + expect(resolvedValue).toBe('hey world'); + }); + + it('resolves global factory providers once and caches permanently', async () => { + let callCount = 0; + let first: unknown; + let second: unknown; + + const app = cli('test') + .provide('counter', { + factory: () => { + callCount++; + return callCount; + }, + lifetime: 'global', + }) + .command('run', { + handler: () => { + const ctx = getCommandContext(); + first = ctx.inject('counter'); + second = ctx.inject('counter'); + }, + }); + + await app.forge(['run']); + + // Factory called once per global lifetime (may be cached from previous runs + // in the same process — that's the expected global behavior) + expect(first).toBe(second); + expect(typeof first).toBe('number'); + }); + + it('isolates global providers across unrelated CLIs that share a key', async () => { + // Two independent apps both register `lifetime: 'global'` under the + // same provider key but with different factories. The global cache is + // keyed by factory function identity, so each app must see the value + // produced by its own factory — not the first one to run. + let resolvedA: unknown; + let resolvedB: unknown; + + const appA = cli('app-a') + .provide('svc', { + factory: () => ({ from: 'A' }), + lifetime: 'global', + }) + .handler(() => { + resolvedA = getCommandContext(appA).inject('svc'); + }); + + const appB = cli('app-b') + .provide('svc', { + factory: () => ({ from: 'B' }), + lifetime: 'global', + }) + .handler(() => { + resolvedB = getCommandContext(appB).inject('svc'); + }); + + await appA.forge([]); + await appB.forge([]); + + expect(resolvedA).toEqual({ from: 'A' }); + expect(resolvedB).toEqual({ from: 'B' }); + }); + + it('throws for unregistered key without default', async () => { + let thrownError: unknown; + + await cli('test', { + handler: () => { + const ctx = getCommandContext(); + try { + ctx.inject('missing' as never); + } catch (e) { + thrownError = e; + } + }, + }).forge([]); + + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toContain('missing'); + }); + + it('returns default for unregistered key when default is provided', async () => { + let result: unknown; + + // The no-arg generic overload lets us use a type witness for a CLI + // that claims a `missing` provider — even though the actual running + // CLI doesn't register it. `inject('missing', fallback)` is typed + // via the phantom witness, and the runtime falls through to the + // default because `missing` isn't in the active providerFactories. + // This is the intended purpose of the default-value overload: + // a graceful fallback when the type witness and the real CLI drift. + type PhantomCli = CLI; + + await cli('test') + .provide('db', { factory: () => 'real-db', lifetime: 'executionScope' }) + .command('run', { + handler: () => { + const ctx = getCommandContext(); + result = ctx.inject('missing', 'fallback'); + }, + }) + .forge(['run']); + + expect(result).toBe('fallback'); + }); + + it('throws when called outside a handler', () => { + expect(() => getCommandContext()).toThrow( + /No CLI context found/ + ); + }); + + it('providers from parent commands are available in child command handlers', async () => { + let injectedFromParent: unknown; + + await cli('app') + .provide('logger', 'parent-logger') + .command('build', { + handler: () => { + const ctx = getCommandContext(); + injectedFromParent = ctx.inject('logger'); + }, + }) + .forge(['build']); + + expect(injectedFromParent).toBe('parent-logger'); + }); + + it('child provider overrides parent provider', async () => { + let injected: unknown; + + const app = cli('app').provide('db', 'parent-db'); + app.command('build', { + builder: (cmd) => cmd.provide('db', 'child-db'), + handler: () => { + const ctx = getCommandContext(); + injected = ctx.inject('db'); + }, + }); + + await app.forge(['build']); + + expect(injected).toBe('child-db'); + }); + + it('throws a cycle error when two factories inject each other', async () => { + let caught: unknown; + + await cli('test') + .provide('a', { + factory: () => getCommandContext().inject('b' as never), + }) + .provide('b', { + factory: () => getCommandContext().inject('a' as never), + }) + .handler(() => { + try { + getCommandContext().inject('a' as never); + } catch (e) { + caught = e; + } + }) + .forge([]); + + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toMatch(/Circular provider dependency/); + expect((caught as Error).message).toContain('a -> b -> a'); + }); + + it('throws when getCommandContext is passed a CLI not in the active chain', async () => { + const unrelated = cli('unrelated'); + let caught: unknown; + + await cli('app', { + handler: () => { + try { + getCommandContext(unrelated); + } catch (e) { + caught = e; + } + }, + }).forge([]); + + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toMatch(/not part of the active command chain/); + }); + + it('accepts the root CLI from within a subcommand handler', async () => { + let didRun = false; + + const app = cli('app') + .provide('svc', 'root-svc') + .command('build', { + handler: () => { + // Passing the root from inside a subcommand handler is valid — + // the chain includes every command from root down to the running + // command. + const ctx = getCommandContext(app); + expect(ctx.inject('svc')).toBe('root-svc'); + didRun = true; + }, + }); + + await app.forge(['build']); + + expect(didRun).toBe(true); + }); + + it('accepts the currently-running subcommand when it is reachable by reference', async () => { + let didRun = false; + + // Build the subcommand inline so its handler has access to `this` + // through TChildren, then fetch the tracked instance via + // `app.getChildren()` to pass it as the witness. + const app = cli('app').command('build', { + builder: (cmd) => cmd.option('target', { type: 'string', required: true }), + handler: () => { + const build = app.getChildren().build; + const ctx = getCommandContext(build); + expect(ctx.args.target).toBe('web'); + didRun = true; + }, + }); + + await app.forge(['build', '--target', 'web']); + + expect(didRun).toBe(true); + }); + + it('resetGlobalProviders() re-invokes global factories on next execution', async () => { + let callCount = 0; + + const app = cli('test') + .provide('counter', { + factory: () => ++callCount, + lifetime: 'global', + }) + .handler(() => { + void getCommandContext().inject('counter'); + }); + + await app.forge([]); + expect(callCount).toBe(1); + + await app.forge([]); + expect(callCount).toBe(1); // still cached + + resetGlobalProviders(); + await app.forge([]); + expect(callCount).toBe(2); // re-invoked after reset + }); +}); + +describe('cli.getContext() instance method', () => { + it('returns the same args as getCommandContext(cli)', async () => { + let viaMethod: unknown; + let viaFunction: unknown; + + const app = cli('test', { + builder: (cmd) => cmd.option('name', { type: 'string', default: 'world' }), + handler: () => { + viaMethod = app.getContext().args; + viaFunction = getCommandContext(app).args; + }, + }); + + await app.forge(['--name', 'alice']); + + expect(viaMethod).toEqual(viaFunction); + expect((viaMethod as { name: string }).name).toBe('alice'); + }); + + it('resolves providers via inject() on the returned context', async () => { + let injected: unknown; + + const app = cli('test') + .provide('greeting', 'hello from method') + .command('run', { + handler: () => { + injected = app.getContext().inject('greeting'); + }, + }); + + await app.forge(['run']); + + expect(injected).toBe('hello from method'); + }); + + it('walks the provider chain from a subcommand reference', async () => { + let injected: unknown; + + const app = cli('app') + .provide('logger', 'parent-logger') + .command('build', { + handler: () => { + // The root app is reachable, so calling getContext() on it from + // inside a subcommand handler should succeed and expose the + // parent provider. + injected = app.getContext().inject('logger'); + }, + }); + + await app.forge(['build']); + + expect(injected).toBe('parent-logger'); + }); + + it('throws when called from a CLI that is not in the active command chain', async () => { + const unrelated = cli('unrelated'); + let caught: unknown; + + await cli('app', { + handler: () => { + try { + unrelated.getContext(); + } catch (e) { + caught = e; + } + }, + }).forge([]); + + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toMatch(/not part of the active command chain/); + }); + + it('throws when called outside a handler', () => { + const app = cli('test'); + expect(() => app.getContext()).toThrow(/No CLI context found/); + }); + + it('exposes commandChain via the returned context', async () => { + let chain: string[] | undefined; + + const app = cli('my-app').command('serve', { + handler: () => { + chain = app.getContext().commandChain; + }, + }); + + await app.forge(['serve']); + + expect(chain).toEqual(['serve']); + }); +}); + +describe('getCommandContext() via sdk()', () => { + it('provides args during handler execution in SDK mode', async () => { + let capturedArgs: any; + + const app = cli('test', { + builder: (cmd) => cmd.option('name', { type: 'string', default: 'sdk-user' }), + handler: () => { + const ctx = getCommandContext(); + capturedArgs = ctx.args; + }, + }); + + await app.sdk()({ name: 'alice' }); + + expect(capturedArgs.name).toBe('alice'); + }); + + it('resolves eager providers in SDK mode', async () => { + let injectedValue: unknown; + + const app = cli('test') + .provide('greeting', 'sdk-hello') + .command('run', { + handler: () => { + const ctx = getCommandContext(); + injectedValue = ctx.inject('greeting'); + }, + }); + + await app.sdk().run({}); + + expect(injectedValue).toBe('sdk-hello'); + }); + + it('resolves executionScope factory providers in SDK mode', async () => { + let resolvedValue: unknown; + + const app = cli('test', { + builder: (cmd) => cmd.option('prefix', { type: 'string', default: 'hey' }), + }) + .provide('msg', { + factory: (args: Record) => `${args['prefix']} sdk`, + lifetime: 'executionScope', + }) + .command('run', { + handler: () => { + const ctx = getCommandContext(); + resolvedValue = ctx.inject('msg'); + }, + }); + + await app.sdk().run({ prefix: 'greetings from' }); + + expect(resolvedValue).toBe('greetings from sdk'); + }); +}); diff --git a/packages/cli-forge/src/lib/context.ts b/packages/cli-forge/src/lib/context.ts new file mode 100644 index 000000000..dddcd8806 --- /dev/null +++ b/packages/cli-forge/src/lib/context.ts @@ -0,0 +1,297 @@ +import type { CLI, AnyCLI, ProviderConfig, GlobalProviderConfig } from './public-api'; +import { contextStorage, ForgeContextData } from './async-context'; + +// ─── Type Helpers ───────────────────────────────────────────────────────────── + +/** + * Recursively gathers all providers from a CLI and its ancestors. + * + * When a child command re-provides a key that its parent also provides, the + * child's type shadows the parent's — matching the runtime override semantics + * in `collectProviders()`. This is why the parent's providers are wrapped in + * `Omit<..., keyof TProviders>` before being intersected with the child's. + */ +export type ProvidersOf = T extends CLI + ? TProviders & + (TParent extends AnyCLI + ? Omit, keyof TProviders> + : Record) + : Record; + +/** + * Same chain-walking semantics as {@link ProvidersOf}, but takes + * `TProviders` and `TParent` directly instead of a `CLI<...>` instance type. + * + * This exists so the `CLI` interface can declare + * `getContext(): CommandContext, TChildren>` + * without re-wrapping its own type parameters in a `CLI<...>`. Writing + * `ProvidersOf>` inside `CLI` would + * reference `CLI` while the interface is still being constructed, which + * has caused TypeScript cycle / constraint-solver headaches in the past. + * Passing the raw parameters through a non-CLI helper keeps the + * recursion purely in conditional-type space. + */ +export type ProvidersFromChain = + TParent extends CLI + ? TProviders & + Omit, keyof TProviders> + : TProviders; + +/** + * Infers the full CommandContext type from a CLI type. + */ +export type InferContextOfCommand = T extends CLI< + infer TArgs, + any, + infer TChildren, + any, + any +> + ? CommandContext, TChildren> + : never; + +/** + * The context object available inside a command handler via {@link getCommandContext}. + */ +export interface CommandContext { + /** The parsed arguments for the current command. */ + readonly args: TArgs; + + /** + * The chain of command names from root to the currently executing command. + * e.g. `['my-app', 'serve']` + */ + readonly commandChain: string[]; + + /** + * Resolves a provider by its key. + * + * @param key The provider key (must be a key of TProviders) + * @param defaultValue Optional fallback if the provider has no registered factory + */ + inject(key: K): TProviders[K]; + inject(key: K, defaultValue: TProviders[K]): TProviders[K]; + + /** + * Returns a CommandContext scoped to a child command that was already executed. + * The child command must appear in the current commandChain. + */ + getChildContext( + command: K & string + ): TChildren[K] extends AnyCLI ? InferContextOfCommand : never; +} + +// ─── Module-level state ─────────────────────────────────────────────────────── + +/** + * Permanent cache for global-lifetime providers. Survives across executions. + * + * Keyed by the factory function identity rather than the provider name, so + * two unrelated CLI apps (or two registrations of the same name with + * different factories) cannot collide on a shared cached value. Each + * distinct factory function gets its own slot; calling `.provide()` twice + * with the same factory reference (e.g. across clones) correctly shares. + */ +const globalProviderCache = new Map(); + +/** + * Clears the module-level cache for `lifetime: 'global'` providers. + * + * Global provider factories normally run once per Node process and their + * result is memoized forever. That's the desired behavior in production — + * it's also what makes them leak between tests. Call this from test setup + * (or automatically via `TestHarness.clearMockedContexts()`) to get a clean + * slate between specs that exercise global-lifetime providers. + */ +export function resetGlobalProviders(): void { + globalProviderCache.clear(); +} + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +function readStore(): ForgeContextData { + const store = contextStorage.getStore(); + if (!store) { + throw new Error( + 'No CLI context found. getCommandContext() must be called from within a command handler.' + ); + } + if (!store.handlerPhase) { + throw new Error( + 'inject() can only be called during the handler phase. ' + + 'Do not call getCommandContext() during option builders or middleware.' + ); + } + return store; +} + +const NOT_FOUND = Symbol('NOT_FOUND'); + +function resolveProvider(store: ForgeContextData, key: string): unknown | typeof NOT_FOUND { + // 1. Check pre-cached/pre-resolved providers (use .has() to handle undefined values) + if (store.providers.has(key)) { + return store.providers.get(key); + } + + // 2. Lazy-resolve from factories + const registration = store.providerFactories.get(key); + if (!registration) { + return NOT_FOUND; + } + + // 3. Cycle detection — a factory that inject()s a provider whose own + // factory inject()s back into this key would otherwise blow the stack. + if (store.resolving.has(key)) { + const cycle = [...store.resolving, key].join(' -> '); + throw new Error( + `Circular provider dependency detected while resolving "${key}": ${cycle}` + ); + } + + const { factory, lifetime } = registration; + store.resolving.add(key); + try { + if (lifetime === 'global') { + // Global: permanent module-level cache keyed by factory identity + // (not by key name) so unrelated registrations can't collide. + const globalFactory = factory as GlobalProviderConfig['factory']; + if (!globalProviderCache.has(globalFactory)) { + globalProviderCache.set(globalFactory, globalFactory()); + } + const value = globalProviderCache.get(globalFactory); + // Also cache in the store so subsequent inject() calls skip factory lookup + store.providers.set(key, value); + return value; + } else { + // executionScope: scoped to this execution, keyed by args identity + const value = (factory as ProviderConfig['factory'])(store.args); + store.providers.set(key, value); + return value; + } + } finally { + store.resolving.delete(key); + } +} + +function createCommandContext(store: ForgeContextData): CommandContext { + return { + get args() { + return store.args; + }, + + get commandChain() { + return store.commandChain; + }, + + inject(key: string, defaultValue?: unknown): unknown { + const resolved = resolveProvider(store, key); + if (resolved === NOT_FOUND) { + if (arguments.length >= 2) { + return defaultValue; + } + throw new Error( + `No provider registered for key "${key}". ` + + `Register it with .provide("${key}", { factory: ... }) on the CLI.` + ); + } + return resolved; + }, + + getChildContext(command: string): any { + if (!store.commandChain.includes(command)) { + throw new Error( + `Command "${command}" is not in the current command chain: [${store.commandChain.join(', ')}]. ` + + `getChildContext() can only be used with commands that have already executed.` + ); + } + return createCommandContext(store); + }, + }; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Returns the {@link CommandContext} for the currently executing command. + * + * Must be called from within a command handler (not during builders or middleware). + * + * ## Runtime safety + * + * When called with a CLI instance, `getCommandContext` validates at runtime + * that the instance is part of the active command chain — the root app, + * any ancestor on the chain, or the currently-running subcommand. If you + * pass a CLI that isn't in the chain (a sibling command, an unrelated app, + * a descendant that didn't run), it throws with a descriptive error. This + * catches the common bug where the wrong instance is passed as a type + * witness and `inject()` silently returns the wrong providers. + * + * ## Two ways to reach subcommand-typed access + * + * **Option 1** — from the root, walk by name: + * ```ts + * const ctx = getCommandContext(app); + * const buildCtx = ctx.getChildContext('build'); + * console.log(buildCtx.args.target); + * ``` + * + * **Option 2** — pass a composed subcommand reference directly, skipping + * the `getChildContext` hop: + * ```ts + * import { build } from './build'; // standalone CLI composed into app + * const ctx = getCommandContext(build); + * console.log(ctx.args.target); + * ``` + * + * Both forms are runtime-safe. Option 2 is only typed if the subcommand + * reference carries the full `TProviders` — i.e. the subcommand is declared + * inside the parent's `.command('name', { builder, handler })` call, where + * TypeScript can thread parent providers into the builder's `cmd`. A + * standalone `cli('build', ...)` reference doesn't see inherited providers + * in its type; use Option 1 for that shape. + * + * ## Type witness (no instance) + * + * The parameterless overload returns a context typed by an explicit generic + * — useful when you can't get a live reference to the CLI from where the + * handler is written. **This form has no runtime safety**: the `T` type + * parameter is trusted as-is, so a mismatched generic silently returns a + * context typed for a CLI that isn't running. Prefer passing the CLI + * instance whenever feasible. + * + * @param cli CLI instance used as both a type witness for inference and + * a runtime identity check. Required for runtime-safe usage. + * + * @example + * ```ts + * import { getCommandContext } from 'cli-forge/context'; + * import { app } from './cli'; + * + * const ctx = getCommandContext(app); + * const db = ctx.inject('db'); + * ``` + */ +export function getCommandContext(cli: T): InferContextOfCommand; +/** + * Type-only overload: trusts `T` without runtime validation. Prefer the + * instance-based overload whenever you can import the CLI that owns the + * handler — this form exists as an escape hatch for cases where no live + * CLI reference is reachable from the handler's module. + */ +export function getCommandContext(): InferContextOfCommand; +export function getCommandContext(cli?: T): InferContextOfCommand { + const store = readStore(); + if (cli && typeof (cli as { commandId?: unknown }).commandId === 'string') { + const { commandId } = cli as unknown as { commandId: string; name?: string }; + if (!store.commandIdChain.includes(commandId)) { + const name = (cli as unknown as { name?: string }).name ?? commandId; + throw new Error( + `getCommandContext() was called with a CLI instance ("${name}", id=${commandId}) ` + + `that is not part of the active command chain. ` + + `Active chain ids: [${store.commandIdChain.join(', ')}]. ` + + `Pass the root app, an ancestor on the chain, or the currently-running subcommand.` + ); + } + } + return createCommandContext(store) as InferContextOfCommand; +} diff --git a/packages/cli-forge/src/lib/documentation.ts b/packages/cli-forge/src/lib/documentation.ts index ad4f851e2..c471a8381 100644 --- a/packages/cli-forge/src/lib/documentation.ts +++ b/packages/cli-forge/src/lib/documentation.ts @@ -6,7 +6,7 @@ import { ConfigurationFiles, getEnvKey, } from '@cli-forge/parser'; -import { InternalCLI } from './internal-cli'; +import { AnyInternalCLI } from './internal-cli'; import { CLI } from './public-api'; export type Documentation = { @@ -147,7 +147,7 @@ export function generateDocumentation( } // Also include command names - track unique commands by instance to avoid duplicates - const seenCommands = new Set>(); + const seenCommands = new Set(); for (const cmdKey in cli.getSubcommands()) { const cmdInstance = cli.getSubcommands()[cmdKey]; if (!seenCommands.has(cmdInstance)) { diff --git a/packages/cli-forge/src/lib/format-help.ts b/packages/cli-forge/src/lib/format-help.ts index 5e5a63d78..a57ec2b50 100644 --- a/packages/cli-forge/src/lib/format-help.ts +++ b/packages/cli-forge/src/lib/format-help.ts @@ -5,9 +5,9 @@ import { isOneOfOptionConfig, isObjectOptionConfig, } from '@cli-forge/parser'; -import { InternalCLI } from './internal-cli'; +import { AnyInternalCLI } from './internal-cli'; -export function formatHelp(parentCLI: InternalCLI): string { +export function formatHelp(parentCLI: AnyInternalCLI): string { const help: string[] = []; let command = parentCLI; let epilogue = parentCLI.configuration?.epilogue; @@ -41,7 +41,7 @@ export function formatHelp(parentCLI: InternalCLI): string { help.push('Commands:'); } // Track displayed commands by their actual CLI instance to avoid duplicates - const displayedCommands = new Set>(); + const displayedCommands = new Set(); for (const key in command.registeredCommands) { const subcommand = command.registeredCommands[key]; // Skip if we've already displayed this command instance diff --git a/packages/cli-forge/src/lib/interactive-shell.ts b/packages/cli-forge/src/lib/interactive-shell.ts index 38249f6a8..36730e5b2 100644 --- a/packages/cli-forge/src/lib/interactive-shell.ts +++ b/packages/cli-forge/src/lib/interactive-shell.ts @@ -1,7 +1,7 @@ import { readline, execSync, spawnSync } from './node-shell-deps'; import type { Interface as ReadlineInterface } from 'readline'; import { stringToArgs } from './utils'; -import { InternalCLI } from './internal-cli'; +import { AnyInternalCLI } from './internal-cli'; import { getBin } from '@cli-forge/parser'; export interface InteractiveShellOptions { @@ -12,7 +12,7 @@ export interface InteractiveShellOptions { type NormalizedInteractiveShellOptions = Required; function normalizeShellOptions( - cli: InternalCLI, + cli: AnyInternalCLI, options?: InteractiveShellOptions ): NormalizedInteractiveShellOptions { return { @@ -37,7 +37,7 @@ export class InteractiveShell { private readonly rl: ReadlineInterface; private listeners: any[] = []; - constructor(cli: InternalCLI, opts?: InteractiveShellOptions) { + constructor(cli: AnyInternalCLI, opts?: InteractiveShellOptions) { if (INTERACTIVE_SHELL) { throw new Error( 'Only one interactive shell can be created at a time. Make sure the other instance is closed.' diff --git a/packages/cli-forge/src/lib/internal-cli.ts b/packages/cli-forge/src/lib/internal-cli.ts index 208c0b38e..f77504e08 100644 --- a/packages/cli-forge/src/lib/internal-cli.ts +++ b/packages/cli-forge/src/lib/internal-cli.ts @@ -12,6 +12,9 @@ import { type ConfigurationFiles, } from '@cli-forge/parser'; import { readOptionGroupsForCLI } from './cli-option-groups'; +import { contextStorage, ForgeContextData } from './async-context'; +import { getCommandContext } from './context'; +import type { CommandContext, ProvidersFromChain } from './context'; import { formatHelp } from './format-help'; // Lazy-imported to avoid pulling Node-only modules (readline, child_process) // into the module graph when bundled for the browser. @@ -24,6 +27,7 @@ async function getInteractiveShellModule(): Promise { return _shellModule; } import { + AnyCLI, CLI, CLICommandOptions, CLIHandlerContext, @@ -39,6 +43,24 @@ import type { PromptProvider, PromptOptionConfig } from './prompt-types'; import { resolvePrompts } from './resolve-prompts'; import { getCallingFile, getParentPackageJson } from './utils'; +/** Type alias for an InternalCLI instance with any type parameters. */ +export type AnyInternalCLI = InternalCLI; + +type ProviderRegistration = + | { type: 'eager'; value: unknown } + | { type: 'factory'; factory: Function; lifetime: 'global' | 'executionScope' }; + +/** + * Monotonic counter used to stamp every `InternalCLI` instance with a + * process-unique `commandId` at construction time. Used by + * {@link getCommandContext} to verify at runtime that a CLI instance passed + * as a type witness actually belongs to the currently-executing command + * chain — which catches the class of bug where the user passes the wrong + * CLI (a sibling, an unrelated app) as the type witness and silently gets + * incorrect `inject()` types. + */ +let _internalCliIdCounter = 0; + /** * The base class for a CLI application. This class is used to define the structure of the CLI. * @@ -70,10 +92,11 @@ const CLI_FORGE_BRAND = Symbol.for('cli-forge:InternalCLI'); export class InternalCLI< TArgs extends ParsedArgs = ParsedArgs, THandlerReturn = void, - + TChildren = {}, - TParent = undefined -> implements CLI + TParent = undefined, + TProviders = {} +> implements CLI { /** * Cross-realm brand for identifying InternalCLI instances across @@ -87,7 +110,7 @@ export class InternalCLI< */ static isInternalCLI( obj: unknown - ): obj is InternalCLI { + ): obj is AnyInternalCLI { return ( obj != null && typeof obj === 'object' && @@ -98,7 +121,24 @@ export class InternalCLI< /** * For internal use only. Stick to properties available on {@link CLI}. */ - registeredCommands: Record> = {}; + registeredCommands: Record = {}; + + /** + * Process-unique identifier stamped at construction time. Used to + * validate at runtime that a CLI instance passed to `getCommandContext` + * actually belongs to the active command chain. Never mutated once set — + * builders, handlers, and middleware see the same value for the lifetime + * of the instance, even across clones. + * + * For internal use only. + */ + readonly commandId: string; + + /** + * Registered DI providers keyed by name. + * For internal use only. + */ + registeredProviders: Map = new Map(); /** * For internal use only. Stick to properties available on {@link CLI}. @@ -109,7 +149,7 @@ export class InternalCLI< * Reference to the parent CLI instance, if this command was registered as a subcommand. * For internal use only. Use `getParent()` instead. */ - private _parent?: InternalCLI; + private _parent?: AnyInternalCLI; private requiresCommand: 'IMPLICIT' | 'EXPLICIT' | false = 'IMPLICIT'; @@ -203,7 +243,7 @@ export class InternalCLI< parser = new ArgvParser({ unmatchedParser: (arg) => { // eslint-disable-next-line @typescript-eslint/no-this-alias - let currentCommand: InternalCLI = this; + let currentCommand: AnyInternalCLI = this; for (const command of this.commandChain) { currentCommand = currentCommand.registeredCommands[command]; } @@ -240,6 +280,11 @@ export class InternalCLI< TChildren > ) { + // Stamp a stable, immutable identifier so `getCommandContext(cli)` can + // verify at runtime that this instance is part of the active command + // chain. The format is `${name}#${counter}` for debuggability — the + // counter guarantees uniqueness even when two commands share a name. + this.commandId = `${name}#${++_internalCliIdCounter}`; if (rootCommandConfiguration) { this.withRootCommandConfiguration(rootCommandConfiguration as any); } else { @@ -249,38 +294,43 @@ export class InternalCLI< withRootCommandConfiguration( configuration: CLICommandOptions - ): InternalCLI { + ): InternalCLI { this.configuration = configuration; this.requiresCommand = configuration.handler ? false : 'IMPLICIT'; return this; } command< - TCommandArgs extends TArgs, - TCmdName extends string, - TChildHandlerReturn = void + TCommand extends Command >( - cmd: Command + cmd: TCommand ): CLI< TArgs, THandlerReturn, - TChildren & { - [key in TCmdName]: CLI< - TCommandArgs, - TChildHandlerReturn, - {}, - CLI - >; - }, - TParent + TChildren & import('./public-api').CommandToChildEntry< + TCommand, + CLI + >, + TParent, + TProviders >; command< TCommandArgs extends TArgs, TChildHandlerReturn = void, - TCommandName extends string = string + TCommandName extends string = string, + TChildChildren = {}, + TChildProviders = {} >( key: TCommandName, - options: CLICommandOptions + options: CLICommandOptions< + TArgs, + TCommandArgs, + TChildHandlerReturn, + TChildren, + CLI, + TChildChildren, + TChildProviders + > ): CLI< TArgs, THandlerReturn, @@ -288,49 +338,18 @@ export class InternalCLI< [key in TCommandName]: CLI< TCommandArgs, TChildHandlerReturn, - {}, - CLI + TChildChildren, + CLI, + TChildProviders >; }, - TParent + TParent, + TProviders >; - command< - TCommandArgs extends TArgs, - TChildHandlerReturn = void, - TCommandName extends string = string - >( - keyOrCommand: TCommandName | Command, - options?: CLICommandOptions - ): CLI< - TArgs, - THandlerReturn, - TChildren & - (typeof keyOrCommand extends string - ? { - [key in typeof keyOrCommand]: CLI< - TCommandArgs, - TChildHandlerReturn, - {}, - CLI - >; - } - : typeof keyOrCommand extends Command< - TArgs, - infer TCmdArgs, - infer TCmdName - > - ? { - [key in TCmdName]: CLI< - TCmdArgs, - void, - {}, - CLI - >; - } - : - {}), - TParent - > { + command( + keyOrCommand: string | Command, + options?: CLICommandOptions + ): any { if (typeof keyOrCommand === 'string') { const key = keyOrCommand; if (!options) { @@ -374,7 +393,7 @@ export class InternalCLI< } } } else if (InternalCLI.isInternalCLI(keyOrCommand)) { - const cmd = keyOrCommand as InternalCLI; + const cmd = keyOrCommand as AnyInternalCLI; if (cmd.name === '$0') { this.withRootCommandConfiguration(cmd.configuration as any); // Copy any commands registered on the $0 instance (e.g. subcommands @@ -455,43 +474,43 @@ export class InternalCLI< conflicts( ...args: [string, string, ...string[]] - ): CLI { + ): CLI { this.parser.conflicts(...args); - return this as unknown as CLI; + return this as unknown as CLI; } implies( option: string, ...impliedOptions: string[] - ): CLI { + ): CLI { this.parser.implies(option, ...impliedOptions); - return this as unknown as CLI; + return this as unknown as CLI; } env( a0: string | EnvOptionConfig | undefined = fromCamelOrDashedCaseToConstCase( this.name ) - ): CLI { + ): CLI { if (typeof a0 === 'string') { this.parser.env(a0); } else { a0.prefix ??= fromCamelOrDashedCaseToConstCase(this.name); this.parser.env(a0); } - return this as unknown as CLI; + return this as unknown as CLI; } localize( dictionaryOrFn: LocalizationDictionary | LocalizationFunction, locale?: string - ): CLI { + ): CLI { if (typeof dictionaryOrFn === 'function') { this.parser.localize(dictionaryOrFn); } else { this.parser.localize(dictionaryOrFn, locale); } - return this as unknown as CLI; + return this as unknown as CLI; } /** @@ -503,34 +522,34 @@ export class InternalCLI< return this.parser.getDisplayKey(key); } - demandCommand(): CLI { + demandCommand(): CLI { this.requiresCommand = 'EXPLICIT'; - return this as unknown as CLI; + return this as unknown as CLI; } - strict(enable = true): CLI { + strict(enable = true): CLI { this.parser.options.strict = enable; - return this as unknown as CLI; + return this as unknown as CLI; } - usage(usageText: string): CLI { + usage(usageText: string): CLI { this.configuration ??= {}; this.configuration.usage = usageText; - return this as unknown as CLI; + return this as unknown as CLI; } examples( ...examples: string[] - ): CLI { + ): CLI { this.configuration ??= {}; this.configuration.examples ??= []; this.configuration.examples.push(...examples); - return this as unknown as CLI; + return this as unknown as CLI; } - version(version?: string): CLI { + version(version?: string): CLI { this._versionOverride = version; - return this as unknown as CLI; + return this as unknown as CLI; } /** @@ -554,7 +573,8 @@ export class InternalCLI< TArgs2 extends void ? TArgs : TArgs & TArgs2, THandlerReturn, TChildren, - TParent + TParent, + TProviders > { this.registeredMiddleware.add(callback); // If middleware returns void, TArgs doesn't change... @@ -565,7 +585,7 @@ export class InternalCLI< handler( fn: (args: TArgs, context: any) => R - ): CLI { + ): CLI { if (!this._configuration) { this._configuration = {}; } @@ -574,19 +594,41 @@ export class InternalCLI< return this as any; } + provide(key: string, valueOrConfig: unknown): any { + if (this.registeredProviders.has(key)) { + throw new Error(`Provider '${key}' is already registered on this command.`); + } + if ( + valueOrConfig !== null && + typeof valueOrConfig === 'object' && + 'factory' in valueOrConfig && + typeof (valueOrConfig as any).factory === 'function' + ) { + const config = valueOrConfig as { factory: Function; lifetime?: string }; + this.registeredProviders.set(key, { + type: 'factory', + factory: config.factory, + lifetime: (config.lifetime as 'global' | 'executionScope') ?? 'executionScope', + }); + } else { + this.registeredProviders.set(key, { type: 'eager', value: valueOrConfig }); + } + return this; + } + init( callback: ( - cli: CLI, + cli: CLI, args: TArgs ) => Promise | void - ): CLI { + ): CLI { this.registeredInitHooks.push(callback); - return this as unknown as CLI; + return this as unknown as CLI; } completion( callback?: CompletionCallback - ): CLI { + ): CLI { this._completionEnabled = true; if (callback) { this.completionCallback = callback; @@ -611,7 +653,7 @@ export class InternalCLI< }); } - return this as unknown as CLI; + return this as unknown as CLI; } /** @@ -626,7 +668,7 @@ export class InternalCLI< ): Promise { const middlewares = new Set<(args: any) => void>(this.registeredMiddleware); // eslint-disable-next-line @typescript-eslint/no-this-alias - let cmd: InternalCLI = this; + let cmd: AnyInternalCLI = this; for (const command of this.commandChain) { cmd = cmd.registeredCommands[command]; for (const mw of cmd.registeredMiddleware) { @@ -650,6 +692,11 @@ export class InternalCLI< args = middlewareResult as T; } } + // Keep ALS context args in sync after middleware transformations + const store = contextStorage.getStore(); + if (store) { + store.args = args as Record; + } await cmd.configuration.handler(args, { command: cmd as any, }); @@ -673,7 +720,7 @@ export class InternalCLI< const shellMod = await getInteractiveShellModule(); if (!shellMod.INTERACTIVE_SHELL) { const tui = new shellMod.InteractiveShell( - this as unknown as InternalCLI, + this as unknown as AnyInternalCLI, { prependArgs: originalArgV, } @@ -707,8 +754,8 @@ export class InternalCLI< getChildren(): TChildren { // Return a copy of registered commands, excluding aliases (same command registered under different keys) - const children: Record> = {}; - const seen = new Set>(); + const children: Record = {}; + const seen = new Set(); for (const [key, cmd] of Object.entries(this.registeredCommands)) { if (!seen.has(cmd)) { seen.add(cmd); @@ -722,32 +769,50 @@ export class InternalCLI< return this._parent as TParent; } + getContext(): CommandContext< + TArgs, + ProvidersFromChain, + TChildren + > { + // Delegate to getCommandContext(this) so the runtime chain validation + // and error messaging stay in a single place. + return getCommandContext( + this as unknown as CLI + ) as CommandContext< + TArgs, + ProvidersFromChain, + TChildren + >; + } + getBuilder(): | (< TInit extends ParsedArgs, TInitHandlerReturn, TInitChildren, - TInitParent + TInitParent, + TInitProviders >( - parser: CLI + parser: CLI ) => CLI< TInit & TArgs, TInitHandlerReturn, TInitChildren & TChildren, - TInitParent + TInitParent, + TInitProviders >) | undefined { const builder = this.configuration?.builder; if (!builder) return undefined; // Return a composable builder that preserves input types - return ((parser: CLI) => builder(parser)) as any; + return ((parser: AnyCLI) => builder(parser)) as any; } getHandler(): | ((args: Omit) => THandlerReturn) | undefined { const context: CLIHandlerContext = { - command: this as unknown as CLI, + command: this as unknown as CLI, }; const handler = this._configuration?.handler; if (!handler) { @@ -768,7 +833,7 @@ export class InternalCLI< >; } - private buildSDKProxy(targetCmd: InternalCLI): unknown { + private buildSDKProxy(targetCmd: AnyInternalCLI): unknown { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; @@ -826,11 +891,45 @@ export class InternalCLI< } } - // Execute handler + // Build context data for ALS — collect providers from root down to cmd. + // Use targetCmd (not the clone) because clone() does not copy _parent. + const providerChain: AnyInternalCLI[] = []; + let providerWalk: AnyInternalCLI | undefined = targetCmd; + while (providerWalk) { + providerChain.unshift(providerWalk); + providerWalk = providerWalk._parent; + } + const sdkContextData: ForgeContextData = { + args: parsedArgs as Record, + commandChain: [], + // providerChain walks root -> ...ancestors -> targetCmd already, + // so we reuse it for the id chain that getCommandContext validates. + commandIdChain: providerChain.map((c) => c.commandId), + providers: new Map(), + providerFactories: new Map(), + handlerPhase: true, + resolving: new Set(), + }; + for (const providerNode of providerChain) { + for (const [key, reg] of providerNode.registeredProviders) { + if (reg.type === 'eager') { + sdkContextData.providers.set(key, reg.value); + } else { + sdkContextData.providerFactories.set(key, { + factory: reg.factory, + lifetime: reg.lifetime, + }); + } + } + } + + // Execute handler inside ALS context const context: CLIHandlerContext = { - command: cmd as unknown as CLI, + command: cmd as unknown as AnyCLI, }; - const result = await handler(parsedArgs, context); + const result = await contextStorage.run(sdkContextData, async () => { + return handler(parsedArgs, context); + }); // Try to attach $args to the result (fails silently for primitives) if (result !== null && typeof result === 'object') { @@ -868,10 +967,10 @@ export class InternalCLI< } private collectMiddlewareChain( - cmd: InternalCLI + cmd: AnyInternalCLI ): Array<(args: any) => unknown | Promise> { - const chain: InternalCLI[] = []; - let current: InternalCLI | undefined = cmd; + const chain: AnyInternalCLI[] = []; + let current: AnyInternalCLI | undefined = cmd; while (current) { chain.unshift(current); current = current._parent; @@ -885,7 +984,7 @@ export class InternalCLI< return [...seen]; } - enableInteractiveShell(): CLI { + enableInteractiveShell(): CLI { if (this.requiresCommand === 'EXPLICIT') { throw new Error( 'Interactive shell is not supported for commands that require a command.' @@ -893,7 +992,7 @@ export class InternalCLI< } else if (typeof process !== 'undefined' && process.stdout?.isTTY) { this.requiresCommand = false; } - return this as unknown as CLI; + return this as unknown as CLI; } private versionHandler() { @@ -938,21 +1037,21 @@ export class InternalCLI< errorHandler( handler: ErrorHandler - ): CLI { + ): CLI { this.registeredErrorHandlers.unshift(handler); - return this as unknown as CLI; + return this as unknown as CLI; } withPromptProvider( provider: PromptProvider - ): CLI { + ): CLI { if (!provider.prompt && !provider.promptBatch) { throw new Error( "Prompt provider must implement at least one of 'prompt' or 'promptBatch'" ); } this.registeredPromptProviders.push(provider); - return this as unknown as CLI; + return this as unknown as CLI; } group( @@ -960,7 +1059,7 @@ export class InternalCLI< | string | { label: string; keys: (keyof TArgs)[]; sortOrder?: number }, keys?: (keyof TArgs)[] - ): CLI { + ): CLI { const config = typeof labelOrConfigObject === 'object' ? labelOrConfigObject @@ -978,16 +1077,16 @@ export class InternalCLI< sortOrder: config.sortOrder ?? Object.keys(this.registeredOptionGroups).length, }); - return this as unknown as CLI; + return this as unknown as CLI; } config( provider: ConfigurationFiles.AnyConfigProvider - ): CLI { + ): CLI { this.parser.config( provider as ConfigurationFiles.AnyConfigProvider ); - return this as unknown as CLI; + return this as unknown as CLI; } async updateConfig(values: Partial): Promise; @@ -1059,7 +1158,7 @@ export class InternalCLI< // → filter down. Builders stay lazy. let currentArgs = [...args]; // eslint-disable-next-line @typescript-eslint/no-this-alias - let currentCmd: InternalCLI = this; + let currentCmd: AnyInternalCLI = this; const mergedArgs: any = {}; const executedMiddleware = new Set<(args: any) => void>(); @@ -1111,7 +1210,7 @@ export class InternalCLI< // Build the next command if one was discovered during parsing // (i.e., subcommand token intercepted before positional matching) - let nextCmd: InternalCLI | null = null; + let nextCmd: AnyInternalCLI | null = null; if (discoveredCommand) { const cmd = currentCmd.registeredCommands[discoveredCommand]; cmd.parser = this.parser; @@ -1203,7 +1302,7 @@ export class InternalCLI< const allPromptConfigs = new Map(this.promptConfigs); { // eslint-disable-next-line @typescript-eslint/no-this-alias - let walkCmd: InternalCLI = this; + let walkCmd: AnyInternalCLI = this; for (const command of this.commandChain) { walkCmd = walkCmd.registeredCommands[command]; for (const p of walkCmd.registeredPromptProviders) { @@ -1263,7 +1362,34 @@ export class InternalCLI< throw validationFailedError; } - const finalArgV = await this.runCommand(argv, args, executedMiddleware); + // Collect all providers from the command chain (root → subcommands) + const allProviders = this.collectProviders(); + const contextData: ForgeContextData = { + args: argv as Record, + commandChain: [...this.commandChain], + commandIdChain: this.collectCommandIdChain(), + providers: new Map(), + providerFactories: new Map(), + handlerPhase: false, + resolving: new Set(), + }; + + // Register providers into context + for (const [key, reg] of allProviders) { + if (reg.type === 'eager') { + contextData.providers.set(key, reg.value); + } else { + contextData.providerFactories.set(key, { + factory: reg.factory, + lifetime: reg.lifetime, + }); + } + } + + const finalArgV = await contextStorage.run(contextData, async () => { + contextData.handlerPhase = true; + return this.runCommand(argv, args, executedMiddleware); + }); return finalArgV as TArgs; }); @@ -1272,13 +1398,57 @@ export class InternalCLI< } getSubcommands() { - return this.registeredCommands as Readonly>; + return this.registeredCommands as Readonly>; + } + + private collectProviders(): Map { + const result = new Map(); + // Start from root (this) and walk down the command chain + // eslint-disable-next-line @typescript-eslint/no-this-alias + let cmd: AnyInternalCLI = this; + for (const [key, reg] of cmd.registeredProviders) { + result.set(key, reg); + } + for (const name of this.commandChain) { + cmd = cmd.registeredCommands[name]; + if (cmd) { + for (const [key, reg] of cmd.registeredProviders) { + result.set(key, reg); // child overrides parent + } + } + } + return result; + } + + /** + * Walks the command chain from root (this) down to the running command + * and collects each instance's `commandId`. Populates + * `ForgeContextData.commandIdChain` so `getCommandContext(cli)` can + * validate at runtime that the CLI passed as a type witness is any + * command on the active chain. + */ + private collectCommandIdChain(): string[] { + const ids: string[] = [this.commandId]; + // eslint-disable-next-line @typescript-eslint/no-this-alias + let cmd: AnyInternalCLI = this; + for (const name of this.commandChain) { + const next = cmd.registeredCommands[name]; + if (!next) break; + cmd = next; + ids.push(cmd.commandId); + } + return ids; } clone() { - const clone = new InternalCLI( + const clone = new InternalCLI( this.name ); + // Propagate the commandId so the clone still validates against any + // original CLI reference the user passes to `getCommandContext()`. + // A clone conceptually represents the same command — just a private + // mutable copy — so it keeps the original's identity. + (clone as { commandId: string }).commandId = this.commandId; clone.parser = this.parser.clone(clone.parser.options) as any; if (this.configuration) { clone.withRootCommandConfiguration(this.configuration); @@ -1287,6 +1457,7 @@ export class InternalCLI< for (const command in this.registeredCommands ?? {}) { clone.command(this.registeredCommands[command].clone() as any); } + clone.registeredProviders = new Map(this.registeredProviders); clone.commandChain = [...this.commandChain]; clone.requiresCommand = this.requiresCommand; clone.registeredPromptProviders = [...this.registeredPromptProviders]; diff --git a/packages/cli-forge/src/lib/public-api.ts b/packages/cli-forge/src/lib/public-api.ts index 7c2300b63..19a6b4259 100644 --- a/packages/cli-forge/src/lib/public-api.ts +++ b/packages/cli-forge/src/lib/public-api.ts @@ -22,13 +22,24 @@ import { import { InternalCLI } from './internal-cli'; import type { CompletionCallback, OptionCompletionCallback } from './completion-types'; import type { PromptOptionConfig, PromptProvider } from './prompt-types'; +import type { CommandContext, ProvidersFromChain } from './context'; + +export interface ProviderConfig { + factory: (args: TArgs) => T; + lifetime?: 'executionScope'; +} + +export interface GlobalProviderConfig { + factory: () => T; + lifetime: 'global'; +} /** * Extracts the command name from a Command type. * Works with both CLI instances and command config objects. */ -export type ExtractCommandName = T extends CLI - ? T extends InternalCLI +export type ExtractCommandName = T extends CLI + ? T extends InternalCLI ? string : string : T extends { name: infer N } @@ -41,7 +52,7 @@ export type ExtractCommandName = T extends CLI * Extracts the args type from a Command. * Works with both CLI instances and command config objects. */ -export type ExtractCommandArgs = T extends CLI +export type ExtractCommandArgs = T extends CLI ? A : T extends CLICommandOptions ? A @@ -50,12 +61,23 @@ export type ExtractCommandArgs = T extends CLI /** * Extracts the handler return type from a Command. */ -export type ExtractCommandHandlerReturn = T extends CLI +export type ExtractCommandHandlerReturn = T extends CLI ? R : T extends CLICommandOptions ? R : void; +/** + * Extracts the registered providers from a Command. + * Works with both CLI instances (uses the 5th generic directly) and command + * config objects (infers the builder's return type's provider map). + */ +export type ExtractCommandProviders = T extends CLI + ? P + : T extends CLICommandOptions + ? P + : {}; + /** * Converts a Command to its child CLI entry for TChildren tracking. * TParentCLI is the parent CLI type that will be set as the child's TParent. @@ -65,7 +87,8 @@ export type CommandToChildEntry = { ExtractCommandArgs, ExtractCommandHandlerReturn, {}, - TParentCLI + TParentCLI, + ExtractCommandProviders >; }; @@ -91,16 +114,20 @@ export type CommandToChildEntry = { export interface CLI< TArgs extends ParsedArgs = ParsedArgs, THandlerReturn = void, - + TChildren = {}, - TParent = undefined + TParent = undefined, + TProviders = {} > { command< - TCommandArgs extends TArgs, - TCmdName extends string, - TChildHandlerReturn = void + TCommand extends Command, + TCommandArgs extends TArgs = ExtractCommandArgs extends TArgs + ? ExtractCommandArgs + : TArgs, + TCmdName extends string = ExtractCommandName, + TChildHandlerReturn = ExtractCommandHandlerReturn >( - cmd: Command + cmd: TCommand ): CLI< TArgs, THandlerReturn, @@ -109,10 +136,12 @@ export interface CLI< TCommandArgs, TChildHandlerReturn, {}, - CLI + CLI, + ExtractCommandProviders >; }, - TParent + TParent, + TProviders >; /** @@ -125,8 +154,9 @@ export interface CLI< TCommandArgs extends TArgs, TChildHandlerReturn, TKey extends string, - - TChildChildren = {} + + TChildChildren = {}, + TChildProviders = {} >( key: TKey, options: CLICommandOptions< @@ -134,8 +164,9 @@ export interface CLI< TCommandArgs, TChildHandlerReturn, TChildren, - CLI, - TChildChildren + CLI, + TChildChildren, + TChildProviders > ): CLI< TArgs, @@ -145,10 +176,12 @@ export interface CLI< TCommandArgs, TChildHandlerReturn, TChildChildren, - CLI + CLI, + TChildProviders >; }, - TParent + TParent, + TProviders >; /** @@ -164,8 +197,9 @@ export interface CLI< TArgs, THandlerReturn, TChildren & - CommandToChildEntry>, - TParent + CommandToChildEntry>, + TParent, + TProviders >; commands( c1: C1, @@ -174,9 +208,10 @@ export interface CLI< TArgs, THandlerReturn, TChildren & - CommandToChildEntry> & - CommandToChildEntry>, - TParent + CommandToChildEntry> & + CommandToChildEntry>, + TParent, + TProviders >; commands( c1: C1, @@ -186,10 +221,11 @@ export interface CLI< TArgs, THandlerReturn, TChildren & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry>, - TParent + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry>, + TParent, + TProviders >; commands< C1 extends Command, @@ -205,11 +241,12 @@ export interface CLI< TArgs, THandlerReturn, TChildren & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry>, - TParent + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry>, + TParent, + TProviders >; commands< C1 extends Command, @@ -227,12 +264,13 @@ export interface CLI< TArgs, THandlerReturn, TChildren & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry>, - TParent + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry>, + TParent, + TProviders >; commands< C1 extends Command, @@ -252,13 +290,14 @@ export interface CLI< TArgs, THandlerReturn, TChildren & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry>, - TParent + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry>, + TParent, + TProviders >; commands< C1 extends Command, @@ -280,14 +319,15 @@ export interface CLI< TArgs, THandlerReturn, TChildren & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry>, - TParent + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry>, + TParent, + TProviders >; commands< C1 extends Command, @@ -311,15 +351,16 @@ export interface CLI< TArgs, THandlerReturn, TChildren & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry>, - TParent + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry>, + TParent, + TProviders >; commands< C1 extends Command, @@ -345,16 +386,17 @@ export interface CLI< TArgs, THandlerReturn, TChildren & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry>, - TParent + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry>, + TParent, + TProviders >; commands< C1 extends Command, @@ -382,23 +424,24 @@ export interface CLI< TArgs, THandlerReturn, TChildren & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry> & - CommandToChildEntry>, - TParent + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry> & + CommandToChildEntry>, + TParent, + TProviders >; // Fallback for arrays or more than 10 commands (loses individual type tracking) - commands(commands: Command[]): CLI; + commands(commands: Command[]): CLI; commands( ...commands: Command[] - ): CLI; + ): CLI; /** * Register's a configuration provider for the CLI. See {@link ConfigurationProviders} for built-in providers. @@ -407,7 +450,7 @@ export interface CLI< */ config( provider: ConfigurationFiles.AnyConfigProvider - ): CLI; + ): CLI; /** * Updates configuration by routing each key to its owning provider. @@ -431,7 +474,7 @@ export interface CLI< * This presents as a small shell that only knows the current command and its subcommands. * Any flags already consumed by the command will be passed to every subcommand invocation. */ - enableInteractiveShell(): CLI; + enableInteractiveShell(): CLI; /** * Registers a custom global error handler for the CLI. This handler will be called when an error is thrown @@ -443,7 +486,7 @@ export interface CLI< */ errorHandler( handler: ErrorHandler - ): CLI; + ): CLI; /** * Registers a prompt provider for interactive option fulfillment. @@ -454,7 +497,7 @@ export interface CLI< */ withPromptProvider( provider: PromptProvider - ): CLI; + ): CLI; /** * Registers a new option for the CLI command. This option will be accessible @@ -484,7 +527,8 @@ export interface CLI< }>>, THandlerReturn, TChildren, - TParent + TParent, + TProviders >; // String option overload option< @@ -500,7 +544,8 @@ export interface CLI< }>>, THandlerReturn, TChildren, - TParent + TParent, + TProviders >; // Number option overload option< @@ -516,7 +561,8 @@ export interface CLI< }>>, THandlerReturn, TChildren, - TParent + TParent, + TProviders >; // Boolean option overload option< @@ -532,7 +578,8 @@ export interface CLI< }>>, THandlerReturn, TChildren, - TParent + TParent, + TProviders >; // Array option overload option< @@ -548,7 +595,8 @@ export interface CLI< }>>, THandlerReturn, TChildren, - TParent + TParent, + TProviders >; // OneOf option overload option< @@ -564,7 +612,8 @@ export interface CLI< }>>, THandlerReturn, TChildren, - TParent + TParent, + TProviders >; // Generic fallback overload option< @@ -580,7 +629,8 @@ export interface CLI< }>>, THandlerReturn, TChildren, - TParent + TParent, + TProviders >; /** @@ -610,7 +660,8 @@ export interface CLI< }>>, THandlerReturn, TChildren, - TParent + TParent, + TProviders >; // String option overload positional< @@ -626,7 +677,8 @@ export interface CLI< }>>, THandlerReturn, TChildren, - TParent + TParent, + TProviders >; // Number option overload positional< @@ -642,7 +694,8 @@ export interface CLI< }>>, THandlerReturn, TChildren, - TParent + TParent, + TProviders >; // Boolean option overload positional< @@ -658,7 +711,8 @@ export interface CLI< }>>, THandlerReturn, TChildren, - TParent + TParent, + TProviders >; // Array option overload positional< @@ -674,7 +728,8 @@ export interface CLI< }>>, THandlerReturn, TChildren, - TParent + TParent, + TProviders >; // Generic fallback overload positional< @@ -690,16 +745,17 @@ export interface CLI< }>>, THandlerReturn, TChildren, - TParent + TParent, + TProviders >; /** * Adds support for reading CLI options from environment variables. * @param prefix The prefix to use when looking up environment variables. Defaults to the command name. */ - env(prefix?: string): CLI; + env(prefix?: string): CLI; - env(options: EnvOptionConfig): CLI; + env(options: EnvOptionConfig): CLI; /** * Sets up localization for option keys and other text. @@ -724,7 +780,7 @@ export interface CLI< localize( dictionary: LocalizationDictionary, locale?: string - ): CLI; + ): CLI; /** * Sets up localization using a custom function for translating keys. * This allows integration with existing localization libraries like i18next. @@ -744,7 +800,7 @@ export interface CLI< */ localize( fn: LocalizationFunction - ): CLI; + ): CLI; /** * Sets a group of options as mutually exclusive. If more than one option is provided, there will be a validation error. @@ -752,7 +808,7 @@ export interface CLI< */ conflicts( ...options: [string, string, ...string[]] - ): CLI; + ): CLI; /** * Sets a group of options as mutually inclusive. If one option is provided, all other options must also be provided. @@ -762,14 +818,14 @@ export interface CLI< implies( option: string, ...impliedOptions: string[] - ): CLI; + ): CLI; /** * Requires a command to be provided when executing the CLI. Useful if your parent command * cannot be executed on its own. * @returns Updated CLI instance. */ - demandCommand(): CLI; + demandCommand(): CLI; /** * Enables or disables strict mode. When strict mode is enabled, the parser throws a validation error @@ -778,13 +834,13 @@ export interface CLI< * @param enable Whether to enable strict mode. Defaults to true. * @returns Updated CLI instance. */ - strict(enable?: boolean): CLI; + strict(enable?: boolean): CLI; /** * Sets the usage text for the CLI. This text will be displayed in place of the default usage text * @param usageText Text displayed in place of the default usage text for `--help` and in generated docs. */ - usage(usageText: string): CLI; + usage(usageText: string): CLI; /** * Sets the description for the CLI. This text will be displayed in the help text and generated docs. @@ -792,14 +848,14 @@ export interface CLI< */ examples( ...examples: string[] - ): CLI; + ): CLI; /** * Allows overriding the version displayed when passing `--version`. Defaults to crawling * the file system to get the package.json of the currently executing command. * @param override */ - version(override?: string): CLI; + version(override?: string): CLI; /** * Prints help text to stdout. @@ -814,11 +870,11 @@ export interface CLI< label: string; keys: (keyof TArgs)[]; sortOrder?: number; - }): CLI; + }): CLI; group( label: string, keys: (keyof TArgs)[] - ): CLI; + ): CLI; middleware( callback: MiddlewareFunction @@ -826,7 +882,8 @@ export interface CLI< TArgs2 extends void ? TArgs : Expand, THandlerReturn, TChildren, - TParent + TParent, + TProviders >; /** @@ -851,7 +908,36 @@ export interface CLI< args: TArgs, context: CLIHandlerContext ) => R - ): CLI; + ): CLI; + + /** + * Registers a dependency injection provider under a unique key. + * The value (or factory result) is accessible via `inject()` within command handlers. + * + * Three forms are supported: + * - **Eager value**: `provide('key', value)` — stored as-is. + * - **ExecutionScope factory**: `provide('key', { factory: (args) => value })` — called per invocation with parsed args. + * - **Global factory**: `provide('key', { factory: () => value, lifetime: 'global' })` — called once and cached. + * + * Duplicate keys on the same command instance throw an error. + */ + // Global factory (no args, must specify lifetime: 'global') + provide( + key: TName & (TName extends keyof TProviders ? never : TName), + config: GlobalProviderConfig, + ): CLI; + + // ExecutionScope factory (receives args) + provide( + key: TName & (TName extends keyof TProviders ? never : TName), + config: ProviderConfig, + ): CLI; + + // Eager value + provide( + key: TName & (TName extends keyof TProviders ? never : TName), + value: T, + ): CLI; /** * Registers an init hook that runs before command resolution. @@ -863,10 +949,10 @@ export interface CLI< */ init( callback: ( - cli: CLI, + cli: CLI, args: TArgs ) => Promise | void - ): CLI; + ): CLI; /** * Enables shell completion for this CLI. @@ -880,7 +966,7 @@ export interface CLI< */ completion( callback?: CompletionCallback - ): CLI; + ): CLI; /** * Parses argv and executes the CLI @@ -923,6 +1009,44 @@ export interface CLI< */ getParent(): TParent; + /** + * Returns the {@link CommandContext} for the currently executing command, + * inferred from this CLI instance. + * + * This is a shorthand for `getCommandContext(this)` — it avoids the + * separate `import { getCommandContext } from 'cli-forge'` when you + * already have a reference to the CLI. Both forms are equivalent in + * type inference and runtime safety: the CLI instance is validated + * against the active command chain, so passing a CLI that isn't + * running (a sibling, an unrelated app) throws. + * + * Must be called from within a command handler — not during builders + * or middleware. + * + * @example + * ```ts + * const app = cli('app') + * .provide('db', { factory: () => connectDb() }) + * .command('migrate', { + * handler: () => { + * const db = app.getContext().inject('db'); + * return db.runMigrations(); + * }, + * }); + * ``` + */ + // Uses `ProvidersFromChain` rather than + // `ProvidersOf>` so this signature + // doesn't re-wrap `CLI`'s own type parameters in a `CLI<...>` while + // the `CLI` interface is still being constructed. The chain-walk + // recursion then stays in conditional-type space, where TypeScript + // evaluates it lazily at call sites — no self-reference to trip. + getContext(): CommandContext< + TArgs, + ProvidersFromChain, + TChildren + >; + /** * Returns a programmatic SDK for invoking this CLI and its subcommands. * The SDK provides typed function calls instead of argv parsing. @@ -972,14 +1096,16 @@ export interface CLI< TInit extends ParsedArgs, TInitHandlerReturn, TInitChildren, - TInitParent + TInitParent, + TInitProviders >( - parser: CLI + parser: CLI ) => CLI< TInit & TArgs, TInitHandlerReturn, TInitChildren & TChildren, - TInitParent + TInitParent, + TInitProviders >) | undefined; getHandler(): @@ -988,13 +1114,13 @@ export interface CLI< } export interface CLIHandlerContext { - command: CLI; + command: CLI; } /** * Extracts the TChildren type parameter from a CLI type. */ -export type ExtractCLIChildren = T extends CLI +export type ExtractCLIChildren = T extends CLI ? C : {}; @@ -1014,14 +1140,20 @@ export interface CLICommandOptions< /** * The children commands that exist before the builder runs. */ - + TInitialChildren = {}, TParent = any, /** * The children commands after the builder runs (includes TInitialChildren plus any added by builder). */ - - TChildren = {} + + TChildren = {}, + /** + * The providers registered inside the builder via `.provide()`. Inferred + * from the builder's return type so that child-overrides-parent semantics + * are visible in `getCommandContext(child).inject()`. + */ + TChildProviders = {} > { /** * If set the command will be registered under the provided name and any aliases. @@ -1042,8 +1174,8 @@ export interface CLICommandOptions< // Note: Builder uses 'any' for THandlerReturn to avoid inference conflicts with the handler. // The handler's return type is inferred independently from the handler function itself. builder?: ( - parser: CLI - ) => CLI; + parser: CLI + ) => CLI; /** * The command handler. This function is called when the command is executed. @@ -1104,7 +1236,22 @@ export type ErrorHandler = ( } ) => void; -export type UnknownCLI = CLI; +/** Type alias for a CLI instance with any type parameters. Use in value positions where you need to accept any CLI. */ +export type AnyCLI = CLI; + +/** + * Base CLI constraint for generic functions. Uses `ParsedArgs` instead of `any` + * for `TArgs` so that method return types (like `.option()`) compute correctly + * through `chain()`. Use this as the bound in ``. + * + * @example + * ```ts + * function withVerbose(cli: T) { + * return cli.option('verbose', { type: 'boolean' }); + * } + * ``` + */ +export type UnknownCLI = CLI; export type MiddlewareFunction = ( args: TArgs @@ -1154,8 +1301,9 @@ export type SDKChildren = { infer A, infer R, infer C, - - infer _P + + infer _P, + infer _Providers > ? SDKCommand : never; @@ -1197,7 +1345,9 @@ export function cli< return new InternalCLI(name, rootCommandConfiguration as any) as any as CLI< TArgs, THandlerReturn, - TChildren + TChildren, + undefined, + {} >; } diff --git a/packages/cli-forge/src/lib/resolve-completions.ts b/packages/cli-forge/src/lib/resolve-completions.ts index ee8b75a51..72b355af1 100644 --- a/packages/cli-forge/src/lib/resolve-completions.ts +++ b/packages/cli-forge/src/lib/resolve-completions.ts @@ -1,10 +1,10 @@ import type { InternalOptionConfig } from '@cli-forge/parser'; -import type { InternalCLI } from './internal-cli'; +import type { AnyInternalCLI } from './internal-cli'; import type { CompletionContext, OptionCompletionCallback } from './completion-types'; export interface ResolveCompletionsOptions { /** The root CLI instance */ - rootCLI: InternalCLI; + rootCLI: AnyInternalCLI; /** The raw argv (without --get-completions) */ argv: string[]; } @@ -13,12 +13,12 @@ export interface ResolveCompletionsOptions { * Compute default completions from a command's registered subcommands and options. */ function getDefaultCompletions( - cmd: InternalCLI + cmd: AnyInternalCLI ): string[] { const completions: string[] = []; // Subcommand names (deduplicated, excluding hidden) - const seenCommands = new Set>(); + const seenCommands = new Set(); for (const [key, subcmd] of Object.entries(cmd.registeredCommands)) { if (seenCommands.has(subcmd)) continue; seenCommands.add(subcmd); @@ -90,17 +90,17 @@ function getOptionExpectingValue( * Search for an option completion callback in the command chain (deepest first). */ function findOptionCompletionCallback( - cmd: InternalCLI, - rootCLI: InternalCLI, + cmd: AnyInternalCLI, + rootCLI: AnyInternalCLI, optionKey: string ): OptionCompletionCallback | undefined { // Walk from the deepest command upward - let current: InternalCLI | undefined = cmd; + let current: AnyInternalCLI | undefined = cmd; while (current) { const cb = current.completionConfigs.get(optionKey); if (cb) return cb; current = current.getParent() as - | InternalCLI + | AnyInternalCLI | undefined; } // Also check root explicitly (may not be reachable via getParent chain) @@ -117,8 +117,8 @@ export async function resolveCompletions( // Walk argv to find the deepest resolved subcommand. // Track built commands to avoid re-running builders on live objects. - let currentCmd: InternalCLI = rootCLI; - const builtCommands = new Set>(); + let currentCmd: AnyInternalCLI = rootCLI; + const builtCommands = new Set(); for (const token of argv) { if (token.startsWith('-')) continue; @@ -188,13 +188,13 @@ export async function resolveCompletions( } // Command-level completion callback (walk up to find one) - let cmdForCallback: InternalCLI | undefined = currentCmd; + let cmdForCallback: AnyInternalCLI | undefined = currentCmd; while (cmdForCallback) { if (cmdForCallback.completionCallback) { return cmdForCallback.completionCallback(context); } cmdForCallback = cmdForCallback.getParent() as - | InternalCLI + | AnyInternalCLI | undefined; } diff --git a/packages/cli-forge/src/lib/test-harness-context.spec.ts b/packages/cli-forge/src/lib/test-harness-context.spec.ts new file mode 100644 index 000000000..606a27ace --- /dev/null +++ b/packages/cli-forge/src/lib/test-harness-context.spec.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { cli } from './public-api'; +import { getCommandContext } from './context'; +import { TestHarness } from './test-harness'; +import { contextStorage } from './async-context'; + +beforeEach(() => { + // ALS state can leak between tests in the same async chain (enterWith + // persists through awaits). Force a clean slate at the start of every + // test so assertions about "no context active" are meaningful. + contextStorage.enterWith(undefined); +}); + +afterEach(() => { + TestHarness.clearMockedContexts(); +}); + +describe('TestHarness.mockContext()', () => { + it('provides mocked args via getCommandContext', () => { + const app = cli('test').option('name', { type: 'string' }); + + TestHarness.mockContext(app, { args: { name: 'mocked-name' } }); + + const ctx = getCommandContext(app); + expect(ctx.args).toMatchObject({ name: 'mocked-name' }); + }); + + it('provides mocked providers via inject', () => { + const app = cli('test').provide('db', 'real-db'); + + TestHarness.mockContext(app, { providers: { db: 'mock-db' } }); + + const ctx = getCommandContext(app); + expect(ctx.inject('db')).toBe('mock-db'); + }); + + it('sets commandChain when provided', () => { + const app = cli('test'); + + TestHarness.mockContext(app, { commandChain: ['test', 'serve'] }); + + const ctx = getCommandContext(app); + expect(ctx.commandChain).toEqual(['test', 'serve']); + }); + + it('cleanup function removes the mocked context', () => { + const app = cli('test'); + + const cleanup = TestHarness.mockContext(app, { args: {} }); + + // Verify context is set + expect(() => getCommandContext(app)).not.toThrow(); + + // Run cleanup + cleanup(); + + // Context should be gone now + expect(() => getCommandContext(app)).toThrow(/No CLI context found/); + }); + + it('clearMockedContexts removes all mocked contexts', () => { + const app1 = cli('test1'); + const app2 = cli('test2'); + + TestHarness.mockContext(app1, { args: {} }); + TestHarness.mockContext(app2, { args: {} }); + + // Last one wins (enterWith is last-write-wins in the same execution context) + expect(() => getCommandContext(app2)).not.toThrow(); + + TestHarness.clearMockedContexts(); + + expect(() => getCommandContext(app2)).toThrow(/No CLI context found/); + }); + + it('uses empty defaults when no options provided', () => { + const app = cli('test'); + + TestHarness.mockContext(app, {}); + + const ctx = getCommandContext(app); + expect(ctx.args).toEqual({}); + expect(ctx.commandChain).toEqual([]); + }); + + it('resolves factory providers passed to mockContext', () => { + const app = cli('test') + .option('tag', { type: 'string' }) + .provide('label', { + factory: (args) => `real-${args.tag}`, + }); + + TestHarness.mockContext(app, { + args: { tag: 'mocked' }, + providers: { + label: { + factory: (args: any) => `mock-${args.tag}`, + } as any, + }, + }); + + const ctx = getCommandContext(app); + expect(ctx.inject('label')).toBe('mock-mocked'); + }); + + it('resolves global-lifetime factory providers passed to mockContext', () => { + const app = cli('test').provide('svc', { + factory: () => ({ real: true }), + lifetime: 'global', + }); + + TestHarness.mockContext(app, { + providers: { + svc: { + factory: () => ({ mock: true }), + lifetime: 'global', + } as any, + }, + }); + + const ctx = getCommandContext(app); + expect(ctx.inject('svc')).toEqual({ mock: true }); + }); +}); + +describe('TestHarness.runWithMockedContext()', () => { + it('scopes the mocked context across async boundaries', async () => { + const app = cli('test').provide('db', 'real-db'); + + const result = await TestHarness.runWithMockedContext( + app, + { providers: { db: 'mock-db' } }, + async () => { + // Force an async gap to prove the context survives awaits. + await new Promise((r) => setTimeout(r, 5)); + return getCommandContext(app).inject('db'); + } + ); + + expect(result).toBe('mock-db'); + }); + + it('tears down the mocked context when fn resolves', async () => { + const app = cli('test').provide('db', 'real-db'); + + await TestHarness.runWithMockedContext( + app, + { providers: { db: 'mock-db' } }, + () => { + expect(getCommandContext(app).inject('db')).toBe('mock-db'); + } + ); + + // Outside the run() callback, the mock should be gone. + expect(() => getCommandContext(app)).toThrow(/No CLI context found/); + }); + + it('tears down the mocked context when fn throws', async () => { + const app = cli('test'); + const error = new Error('boom'); + + await expect( + TestHarness.runWithMockedContext(app, { args: {} }, () => { + throw error; + }) + ).rejects.toBe(error); + + expect(() => getCommandContext(app)).toThrow(/No CLI context found/); + }); +}); + +describe('TestHarness.clearMockedContexts()', () => { + it('resets the global provider cache', async () => { + let callCount = 0; + const app = cli('test') + .provide('counter', { + factory: () => ++callCount, + lifetime: 'global', + }) + .handler(() => { + void getCommandContext(app).inject('counter'); + }); + + await app.forge([]); + expect(callCount).toBe(1); + + TestHarness.clearMockedContexts(); + + await app.forge([]); + expect(callCount).toBe(2); + }); +}); diff --git a/packages/cli-forge/src/lib/test-harness.ts b/packages/cli-forge/src/lib/test-harness.ts index 13a360f0f..78bb984bd 100644 --- a/packages/cli-forge/src/lib/test-harness.ts +++ b/packages/cli-forge/src/lib/test-harness.ts @@ -1,6 +1,10 @@ import { ParsedArgs } from '@cli-forge/parser'; -import { InternalCLI } from './internal-cli'; -import { CLI } from './public-api'; +import { contextStorage, ForgeContextData } from './async-context'; +import { resetGlobalProviders } from './context'; +import { AnyInternalCLI, InternalCLI } from './internal-cli'; +import { CLI, GlobalProviderConfig, ProviderConfig } from './public-api'; + +const mockedDisposers: Array<() => void> = []; export type TestHarnessParseResult = { /** @@ -29,6 +33,77 @@ export type TestHarnessParseResult = { commandChain: string[]; }; +/** + * Options accepted by {@link TestHarness.mockContext} and + * {@link TestHarness.runWithMockedContext}. + * + * `providers` mirrors the shape of `.provide()`: each entry is either an + * eager value, an `executionScope` factory config, or a `global` factory + * config. The runtime detection matches `InternalCLI.provide()` — any + * object with a `factory` function is treated as a factory registration. + */ +export type MockContextProviders = { + [K in keyof TProviders]?: + | TProviders[K] + | ProviderConfig + | GlobalProviderConfig; +}; + +export interface MockContextOptions { + args?: Partial; + providers?: MockContextProviders; + commandChain?: string[]; +} + +function buildMockContextData( + cli: CLI | undefined, + options: MockContextOptions +): ForgeContextData { + // Walk from the mocked CLI up through its parents to build an id chain, + // so `getCommandContext(root)` / `getCommandContext(running)` / any + // ancestor on the chain all validate successfully inside the mock. + const commandIdChain: string[] = []; + if (cli && InternalCLI.isInternalCLI(cli)) { + let walk: AnyInternalCLI | undefined = cli; + while (walk) { + commandIdChain.unshift(walk.commandId); + walk = walk.getParent() as AnyInternalCLI | undefined; + } + } + + const contextData: ForgeContextData = { + args: (options.args ?? {}) as Record, + commandChain: options.commandChain ?? [], + commandIdChain, + providers: new Map(), + providerFactories: new Map(), + handlerPhase: true, + resolving: new Set(), + }; + + for (const [key, value] of Object.entries(options.providers ?? {})) { + if ( + value !== null && + typeof value === 'object' && + 'factory' in (value as object) && + typeof (value as { factory: unknown }).factory === 'function' + ) { + const config = value as { + factory: Function; + lifetime?: 'global' | 'executionScope'; + }; + contextData.providerFactories.set(key, { + factory: config.factory, + lifetime: config.lifetime ?? 'executionScope', + }); + } else { + contextData.providers.set(key, value); + } + } + + return contextData; +} + /** * Utility for testing CLI instances. Can check argument parsing and validation, including * command chain resolution. @@ -36,7 +111,7 @@ export type TestHarnessParseResult = { export class TestHarness { private cli: InternalCLI; - constructor(cli: CLI) { + constructor(cli: CLI) { if (InternalCLI.isInternalCLI(cli)) { this.cli = cli; mockHandler(cli); @@ -47,6 +122,127 @@ export class TestHarness { } } + /** + * Mocks the CLI context for testing DI providers and command context outside + * of a real `forge()` execution. Returns a cleanup function that removes the + * mocked context when called. + * + * `options.providers` accepts both eager values and the same + * `{ factory, lifetime }` configs that `.provide()` takes — factory mocks + * run lazily via `inject()` and receive the mocked `args`. + * + * This helper is best suited for simple synchronous tests. It does not + * maintain a stack of prior contexts: calling `mockContext()` while + * another mock is active overwrites the current store, and the returned + * cleanup function blanks the store rather than restoring whatever was + * there before. + * + * For tests that use `await` across the mocked block or need nested + * mocked contexts that unwind properly, prefer {@link runWithMockedContext}, + * which uses `AsyncLocalStorage.run()` for proper scoping. + * + * @example + * ```ts + * afterEach(() => TestHarness.clearMockedContexts()); + * + * it('resolves provider', () => { + * const cleanup = TestHarness.mockContext(myApp, { + * providers: { + * db: mockDb, + * logger: { factory: (args) => makeLogger(args.logLevel) }, + * }, + * }); + * const ctx = getCommandContext(myApp); + * expect(ctx.inject('db')).toBe(mockDb); + * cleanup(); + * }); + * ``` + */ + static mockContext( + _cli: CLI, + options: MockContextOptions + ): () => void { + const contextData = buildMockContextData(_cli, options); + contextStorage.enterWith(contextData); + + const dispose = () => { + // Unconditionally blank the current store on dispose. Capturing the + // previous store and restoring it looks cleaner for nested mocks, but + // it misbehaves when `clearMockedContexts()` batch-disposes out of + // order. Callers that need proper nesting should use + // `runWithMockedContext`, which scopes via `AsyncLocalStorage.run()`. + contextStorage.enterWith(undefined); + const idx = mockedDisposers.indexOf(dispose); + if (idx !== -1) mockedDisposers.splice(idx, 1); + }; + + mockedDisposers.push(dispose); + return dispose; + } + + /** + * Runs `fn` inside a mocked DI context using `AsyncLocalStorage.run()`. + * + * Unlike {@link mockContext}, this variant scopes the context properly + * across async boundaries — any `await` inside `fn` keeps seeing the + * mocked providers, and the context is automatically torn down when `fn` + * resolves (or throws). Prefer this for anything that isn't a trivial + * synchronous assertion. + * + * @example + * ```ts + * const result = await TestHarness.runWithMockedContext( + * myApp, + * { providers: { db: mockDb } }, + * async () => { + * const ctx = getCommandContext(myApp); + * return ctx.inject('db').query('select 1'); + * } + * ); + * ``` + */ + static async runWithMockedContext( + _cli: CLI, + options: MockContextOptions, + fn: () => R | Promise + ): Promise { + const contextData = buildMockContextData(_cli, options); + // Wrap in an async function so synchronous throws from fn() are + // converted to rejected promises, and await inside fn sees the scoped + // context (AsyncLocalStorage.run propagates the store through awaits). + return await contextStorage.run(contextData, async () => fn()); + } + + /** + * Removes all mocked contexts registered via {@link mockContext} and + * clears the `lifetime: 'global'` provider cache so the next test starts + * with a fresh slate. + * + * Typically called from `afterEach`: + * ```ts + * afterEach(() => TestHarness.clearMockedContexts()); + * ``` + */ + static clearMockedContexts(): void { + for (const dispose of [...mockedDisposers]) { + dispose(); + } + // Belt-and-braces: even after all disposers ran, blank the store so any + // lingering reference from a test that forgot to capture its cleanup + // doesn't leak into the next test. + contextStorage.enterWith(undefined); + resetGlobalProviders(); + } + + /** + * Clears only the `lifetime: 'global'` provider cache without touching + * any active mocked contexts. Useful when a specific test needs to + * re-initialize global providers without discarding the surrounding mock. + */ + static resetGlobalProviders(): void { + resetGlobalProviders(); + } + async parse(args: string[]): Promise> { const argv = await this.cli.forge(args); @@ -57,7 +253,7 @@ export class TestHarness { } } -function mockHandler(cli: InternalCLI) { +function mockHandler(cli: AnyInternalCLI) { if (cli.configuration?.handler) { cli.configuration.handler = () => { // Mocked, should do nothing. diff --git a/packages/cli-forge/tsdown.config.mjs b/packages/cli-forge/tsdown.config.mjs index 6a9965d9e..a326c2ad7 100644 --- a/packages/cli-forge/tsdown.config.mjs +++ b/packages/cli-forge/tsdown.config.mjs @@ -8,6 +8,7 @@ export default defineConfig( browserAlias: { 'node-shell-deps': 'src/browser/shell-deps.ts', 'interactive-shell': 'src/browser/interactive-shell.ts', + 'async-context': 'src/browser/async-context.ts', }, }) ); diff --git a/type-tests/fixtures/providers-basic.ts b/type-tests/fixtures/providers-basic.ts new file mode 100644 index 000000000..8c3d88e0f --- /dev/null +++ b/type-tests/fixtures/providers-basic.ts @@ -0,0 +1,21 @@ +/** + * Tests that TProviders defaults to {} and passes through option/command chains + * without breaking existing type inference. + */ +import { cli } from 'cli-forge'; + +// TProviders defaults to {} and passes through option chains +const app = cli('test') + .option('name', { type: 'string' }) + .command('sub', { + builder: (cmd) => cmd.option('port', { type: 'number' }), + handler: () => {}, + }); + +// Verify the CLI type still accepts a handler with correct arg types +app.handler((args) => { + const name: string | undefined = args.name; + void name; +}); + +export default app; diff --git a/type-tests/fixtures/providers-child-context.ts b/type-tests/fixtures/providers-child-context.ts new file mode 100644 index 000000000..080b5b463 --- /dev/null +++ b/type-tests/fixtures/providers-child-context.ts @@ -0,0 +1,24 @@ +/** + * Tests child context and provider inheritance: + * - getChildContext constrains key to registered child command names + * - Invalid child command name produces a type error + */ +import { cli } from 'cli-forge'; +import { getCommandContext } from 'cli-forge/context'; + +const app = cli('test') + .provide('rootSvc', { root: true }) + .command('deploy', { + builder: (cmd) => cmd.option('target', { type: 'string', required: true }), + handler: () => {}, + }); + +app.handler(() => { + const rootCtx = getCommandContext(app); + + // Child context for a registered command is allowed + const _deployCtx = rootCtx.getChildContext('deploy'); + + // @ts-expect-error — 'nonexistent' is not a registered child command + rootCtx.getChildContext('nonexistent'); +}); diff --git a/type-tests/fixtures/providers-infer-context.ts b/type-tests/fixtures/providers-infer-context.ts new file mode 100644 index 000000000..ba17ec406 --- /dev/null +++ b/type-tests/fixtures/providers-infer-context.ts @@ -0,0 +1,34 @@ +/** + * Tests both overloads of getCommandContext: + * - Overload 1: instance passed as type witness + * - Overload 2: explicit generic parameter + */ +import { cli } from 'cli-forge'; +import { getCommandContext } from 'cli-forge/context'; + +const app = cli('test') + .option('name', { type: 'string' }) + .provide('svc', { hello: 'world' }); + +// Overload 1: inferred from instance — app is passed as a type witness +function test1() { + const ctx = getCommandContext(app); + const name: string | undefined = ctx.args.name; + const svc = ctx.inject('svc'); + const hello: string = svc.hello; + void name; + void hello; +} + +// Overload 2: explicit generic — no instance passed +function test2() { + const ctx = getCommandContext(); + const name: string | undefined = ctx.args.name; + const svc = ctx.inject('svc'); + const hello: string = svc.hello; + void name; + void hello; +} + +void test1; +void test2; diff --git a/type-tests/fixtures/providers-inject.ts b/type-tests/fixtures/providers-inject.ts new file mode 100644 index 000000000..42bce90ce --- /dev/null +++ b/type-tests/fixtures/providers-inject.ts @@ -0,0 +1,27 @@ +/** + * Tests inject key constraints: + * - Valid keys resolve to the correct type + * - Invalid keys produce a type error + */ +import { cli } from 'cli-forge'; +import { getCommandContext } from 'cli-forge/context'; + +const app = cli('test') + .provide('logger', { info: (msg: string) => console.log(msg) }) + .provide('api', { get: (url: string) => url }); + +// getCommandContext(app) infers providers from the CLI instance +app.handler(() => { + const ctx = getCommandContext(app); + + // Valid inject — logger is typed as { info: (msg: string) => void } + const logger = ctx.inject('logger'); + logger.info('test'); + + // Valid inject — api is typed as { get: (url: string) => Promise } + const api = ctx.inject('api'); + api.get('/test'); + + // @ts-expect-error — 'missing' is not a registered provider key + ctx.inject('missing'); +}); diff --git a/type-tests/fixtures/providers-override.ts b/type-tests/fixtures/providers-override.ts new file mode 100644 index 000000000..5884e214c --- /dev/null +++ b/type-tests/fixtures/providers-override.ts @@ -0,0 +1,34 @@ +/** + * Tests child-shadows-parent provider override semantics: + * - When a child command re-provides a key that exists on its parent, the + * child's type must shadow the parent's (not intersect with it). + * - Parent-only providers must still be visible from child contexts. + */ +import { cli } from 'cli-forge'; +import { getCommandContext } from 'cli-forge/context'; + +export const app = cli('app') + .provide('db', { kind: 'parent' as const, parentOnly: 1 }) + .provide('rootOnly', { root: true as const }) + .command('build', { + builder: (cmd) => + cmd.provide('db', { kind: 'child' as const, childOnly: 'x' }), + handler: () => handleBuild(), + }); + +function handleBuild() { + const ctx = getCommandContext(app); + const buildCtx = ctx.getChildContext('build'); + + const db = buildCtx.inject('db'); + + // Child override must win — this assignment proves `db` is the child shape, + // not the parent's `{ kind: 'parent'; parentOnly: number }`. + const _childShape: { kind: 'child'; childOnly: string } = db; + void _childShape; + + // The parent-only provider must still be reachable from the child context. + const rootOnly = buildCtx.inject('rootOnly'); + const _rootShape: { root: true } = rootOnly; + void _rootShape; +} diff --git a/type-tests/fixtures/providers-provide.ts b/type-tests/fixtures/providers-provide.ts new file mode 100644 index 000000000..a267b43d6 --- /dev/null +++ b/type-tests/fixtures/providers-provide.ts @@ -0,0 +1,39 @@ +/** + * Tests .provide() type accumulation: + * - Eager value providers + * - ExecutionScope factory providers + * - Global factory providers + * - Duplicate key rejection + * - Multiple provider accumulation + */ +import { cli } from 'cli-forge'; + +// Eager value provider accumulates type +const _app1 = cli('test') + .provide('logger', { log: (msg: string) => console.log(msg) }) + .provide('api', { get: (url: string) => url }); + +// Factory provider (executionScope) — factory receives args +const _app2 = cli('test') + .option('logLevel', { type: 'string', default: 'info' }) + .provide('logger', { + factory: (args) => ({ level: args.logLevel }), + }); + +// Global factory (no args, must specify lifetime: 'global') +const _app3 = cli('test').provide('pool', { + factory: () => ({ connections: 10 }), + lifetime: 'global' as const, +}); + +// Duplicate key at same level should be a type error +const _app4 = cli('test') + .provide('logger', { log: console.log }) + // @ts-expect-error — duplicate key 'logger' is not allowed at same level + .provide('logger', { log: console.log }); + +// Multiple providers accumulate into TProviders +const _app5 = cli('test') + .provide('a', 1) + .provide('b', 'hello') + .provide('c', true); diff --git a/type-tests/src/lib/composable-builders.spec.ts b/type-tests/src/lib/composable-builders.spec.ts index d12103c78..bf898b83e 100644 --- a/type-tests/src/lib/composable-builders.spec.ts +++ b/type-tests/src/lib/composable-builders.spec.ts @@ -229,4 +229,91 @@ describe('Composable Builder Type Inference', () => { }); }); + describe('chain inside command builders', () => { + it('should infer handler args when chain is used inside a command builder', () => { + const code = ` + import { cli, chain, makeComposableBuilder } from 'cli-forge'; + + const withName = makeComposableBuilder((args) => + args.option('name', { type: 'string', required: true }) + ); + + const withGreeting = makeComposableBuilder((args) => + args.option('greeting', { type: 'string', default: 'Hello' }) + ); + + cli('test').command('greet', { + builder: (args) => chain(args, withName, withGreeting), + handler: (args) => { + console.log(args.name, args.greeting); + }, + }); + `; + + const result = findHandlerParamType(code); + expect(result).not.toBeNull(); + expect(typeHasProperty(result!.type, 'name')).toBe(true); + expect(typeHasProperty(result!.type, 'greeting')).toBe(true); + }); + + it('should infer handler args when composable builder wraps a command with chain', () => { + const code = ` + import { cli, chain, makeComposableBuilder } from 'cli-forge'; + + const withName = makeComposableBuilder((args) => + args.option('name', { type: 'string', required: true }) + ); + + const withGreeting = makeComposableBuilder((args) => + args.option('greeting', { type: 'string', default: 'Hello' }) + ); + + const withGreetCommand = makeComposableBuilder((args) => + args.command('greet', { + builder: (args) => chain(args, withName, withGreeting), + handler: (args) => { + console.log(args.name, args.greeting); + }, + }) + ); + + cli('test', { + builder: (args) => chain(args, withGreetCommand), + }); + `; + + const result = findHandlerParamType(code); + expect(result).not.toBeNull(); + expect(typeHasProperty(result!.type, 'name')).toBe(true); + expect(typeHasProperty(result!.type, 'greeting')).toBe(true); + }); + + it('should infer handler args when using manual in chain', () => { + const code = ` + import { cli, chain, makeComposableBuilder, UnknownCLI } from 'cli-forge'; + + // Manual generic approach - works because UnknownCLI uses ParsedArgs (not any) + function withName(argv: T) { + return argv.option('name', { type: 'string', required: true }); + } + + const withGreeting = makeComposableBuilder((args) => + args.option('greeting', { type: 'string', default: 'Hello' }) + ); + + cli('test').command('greet', { + builder: (args) => chain(args, withName, withGreeting), + handler: (args) => { + console.log(args.name, args.greeting); + }, + }); + `; + + const result = findHandlerParamType(code); + expect(result).not.toBeNull(); + expect(typeHasProperty(result!.type, 'name')).toBe(true); + expect(typeHasProperty(result!.type, 'greeting')).toBe(true); + }); + }); + }); diff --git a/type-tests/src/lib/get-context-method.spec.ts b/type-tests/src/lib/get-context-method.spec.ts new file mode 100644 index 000000000..85919f6d1 --- /dev/null +++ b/type-tests/src/lib/get-context-method.spec.ts @@ -0,0 +1,165 @@ +/** + * Tests for `cli.getContext()` instance method type inference. + * + * `getContext()` is a shorthand for `getCommandContext(cli)` that lives + * on the CLI instance itself. The risk this file guards against is the + * method's return type collapsing to `any` or losing its provider / + * args information — which would make `ctx.args.foo` and + * `ctx.inject('bar')` silently return `any`. + */ +import { describe, it, expect } from 'vitest'; +import { createTestProgram } from './compiler.js'; +import * as ts from 'typescript'; + +/** + * Locate a variable declaration by name and return its resolved type. + */ +function findVariableType( + code: string, + variableName: string +): { type: ts.Type; typeString: string } | null { + const { typeChecker, sourceFile } = createTestProgram(code); + + function visit(node: ts.Node): ts.Type | null { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === variableName + ) { + return typeChecker.getTypeAtLocation(node); + } + + let result: ts.Type | null = null; + ts.forEachChild(node, (child) => { + if (!result) { + result = visit(child); + } + }); + return result; + } + + const type = visit(sourceFile); + if (!type) return null; + + return { + type, + typeString: typeChecker.typeToString(type), + }; +} + +/** + * Check whether a type has a property by name. + */ +function typeHasProperty(type: ts.Type, propName: string): boolean { + return type.getProperties().some((p) => p.name === propName); +} + +/** + * Check whether a type is (or contains at the top level) `any`. + * The TypeChecker exposes a TypeFlags.Any bit on the top-level type. + */ +function isAnyType(type: ts.Type): boolean { + return (type.flags & ts.TypeFlags.Any) !== 0; +} + +describe('cli.getContext() instance method type inference', () => { + // Note: the test code calls `app.getContext()` at module top level + // rather than inside a handler. At runtime `getContext()` throws when + // called outside a handler — but we're only type-checking via tsc, so + // the call site never executes. Calling from inside the handler body + // triggers a chicken-and-egg where `app` is referenced inside its own + // initializer, which causes TypeScript to widen `app` to `any` and + // defeats the whole point of the test. + + it('exposes args with typed options (not `any`) — mirrors docblock example', () => { + // This mirrors the `.command('migrate', ...)` example attached to the + // getContext() docblock in public-api.ts. `db` is a non-trivial + // provider shape so an `any` collapse is easy to spot, and `verbose` + // exercises that args from a parent builder are preserved. + const code = ` + import { cli } from 'cli-forge'; + + interface Db { + runMigrations(): { migrated: number }; + } + + const app = cli('app') + .option('verbose', { type: 'boolean' }) + .provide('db', { factory: (): Db => ({ runMigrations: () => ({ migrated: 0 }) }) }); + + const ctx = app.getContext(); + const args = ctx.args; + `; + + const argsResult = findVariableType(code, 'args'); + expect(argsResult).not.toBeNull(); + + // args must NOT collapse to `any` + expect(isAnyType(argsResult!.type)).toBe(false); + // args must carry the `verbose` option + expect(typeHasProperty(argsResult!.type, 'verbose')).toBe(true); + }); + + it('inject() returns the provider shape, not `any`', () => { + const code = ` + import { cli } from 'cli-forge'; + + interface Db { + runMigrations(): { migrated: number }; + } + + const app = cli('app') + .provide('db', { factory: (): Db => ({ runMigrations: () => ({ migrated: 0 }) }) }); + + const ctx = app.getContext(); + const db = ctx.inject('db'); + `; + + const dbResult = findVariableType(code, 'db'); + expect(dbResult).not.toBeNull(); + + // The injected Db must not be `any` + expect(isAnyType(dbResult!.type)).toBe(false); + // It must carry the `runMigrations` method defined on the Db interface + expect(typeHasProperty(dbResult!.type, 'runMigrations')).toBe(true); + }); + + it('commandChain is typed as string[] on the returned context', () => { + const code = ` + import { cli } from 'cli-forge'; + + const app = cli('app'); + const ctx = app.getContext(); + const commandChain = ctx.commandChain; + `; + + const chainResult = findVariableType(code, 'commandChain'); + expect(chainResult).not.toBeNull(); + expect(isAnyType(chainResult!.type)).toBe(false); + // typeToString normalizes `string[]` for readable assertions + expect(chainResult!.typeString).toBe('string[]'); + }); + + it('the full context object is not `any`', () => { + const code = ` + import { cli } from 'cli-forge'; + + const app = cli('app') + .option('verbose', { type: 'boolean' }) + .provide('svc', { hello: 'world' }); + + const ctx = app.getContext(); + `; + + const ctxResult = findVariableType(code, 'ctx'); + expect(ctxResult).not.toBeNull(); + + // The whole context must not collapse to `any` + expect(isAnyType(ctxResult!.type)).toBe(false); + // Its expected members must be present + expect(typeHasProperty(ctxResult!.type, 'args')).toBe(true); + expect(typeHasProperty(ctxResult!.type, 'inject')).toBe(true); + expect(typeHasProperty(ctxResult!.type, 'commandChain')).toBe(true); + expect(typeHasProperty(ctxResult!.type, 'getChildContext')).toBe(true); + }); +});