diff --git a/packages/gunshi/src/cli.test.ts b/packages/gunshi/src/cli.test.ts index 0e99d208..74123da6 100644 --- a/packages/gunshi/src/cli.test.ts +++ b/packages/gunshi/src/cli.test.ts @@ -2000,3 +2000,64 @@ describe('nested sub-commands', () => { ) }) }) + +describe('onResolveValue hook', () => { + test('required arg supplied only by onResolveValue clears validationError', async () => { + const runSpy = vi.fn() + const hookSpy = vi.fn(sources => ({ + ...sources.values, + token: 'secret-from-env' + })) + + await cli( + // no --token on the CLI + [], + { + args: { + token: { type: 'string', required: true } + }, + run: runSpy + }, + { + onResolveValue: hookSpy + } + ) + + // hook must be called exactly once with a well-formed sources payload + expect(hookSpy).toHaveBeenCalledOnce() + const [sources] = hookSpy.mock.calls[0] + expect(sources).toHaveProperty('values') + expect(sources).toHaveProperty('explicit') + expect(typeof sources.explicit.token).toBe('boolean') + + expect(runSpy).toHaveBeenCalledOnce() + const ctx = runSpy.mock.calls[0][0] + expect(ctx.values.token).toBe('secret-from-env') + // validation error must be cleared because the hook satisfied the requirement + expect(ctx.validationError).toBeUndefined() + }) + + test('validationError remains when onResolveValue does not satisfy required arg', async () => { + const utils = await import('./utils.ts') + const log = defineMockLog(utils) + const runSpy = vi.fn() + + await cli( + [], + { + args: { + token: { type: 'string', required: true } + }, + run: runSpy + }, + { + // hook returns undefined — falls back to original (still missing token) + onResolveValue: () => undefined + } + ) + + // command runner must not be invoked; error is rendered instead + expect(runSpy).not.toHaveBeenCalled() + expect(log()).toMatch(/token/) + }) +}) diff --git a/packages/gunshi/src/cli/core.ts b/packages/gunshi/src/cli/core.ts index e057c9b1..c4ff9844 100644 --- a/packages/gunshi/src/cli/core.ts +++ b/packages/gunshi/src/cli/core.ts @@ -9,6 +9,7 @@ import { createCommandContext } from '../context.ts' import { createDecorators } from '../decorators.ts' import { createPluginContext } from '../plugin/context.ts' import { resolveDependencies } from '../plugin/dependency.ts' +import { revalidateError, resolveValue } from '../resolver.ts' import { create, getCommandSubCommands, isLazyCommand, resolveLazyCommand } from '../utils.ts' import type { Decorators } from '../decorators.ts' @@ -84,6 +85,13 @@ export async function cliCore', () => { + type Sources = ValueResolutionSources + expectTypeOf().toEqualTypeOf>() +}) + +test('ValueResolutionSources.explicit is typed as ArgExplicitlyProvided', () => { + type Sources = ValueResolutionSources + expectTypeOf().toEqualTypeOf>() +}) + +test('sources.values.name resolves to string | undefined', () => { + type Sources = ValueResolutionSources + expectTypeOf().toEqualTypeOf() +}) + +test('sources.values.port resolves to number | undefined', () => { + type Sources = ValueResolutionSources + expectTypeOf().toEqualTypeOf() +}) + +test('sources.values.debug resolves to boolean | undefined', () => { + type Sources = ValueResolutionSources + expectTypeOf().toEqualTypeOf() +}) + +test('sources.explicit flags resolve to boolean', () => { + type Sources = ValueResolutionSources + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() +}) + +test('onResolveValue hook return type is Awaitable | undefined>', () => { + type Hook = (sources: ValueResolutionSources) => Awaitable | undefined> + // Sync return: ArgValues + expectTypeOf>().toEqualTypeOf | undefined>>() +}) + +test('onResolveValue hook accepts ValueResolutionSources as parameter', () => { + type Hook = (sources: ValueResolutionSources) => Awaitable | undefined> + expectTypeOf[0]>().toEqualTypeOf>() +}) + +test('ValueResolutionSources defaults A to Args when no type parameter given', () => { + type DefaultSources = ValueResolutionSources + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf>() +}) diff --git a/packages/gunshi/src/resolver.test.ts b/packages/gunshi/src/resolver.test.ts new file mode 100644 index 00000000..cb815d5c --- /dev/null +++ b/packages/gunshi/src/resolver.test.ts @@ -0,0 +1,248 @@ +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + * @license MIT + */ + +import { ArgResolveError } from 'args-tokens' +import { describe, expect, test, vi } from 'vitest' +import { revalidateError, resolveValue } from './resolver.ts' +import type { Args, ArgExplicitlyProvided, ArgValues } from 'args-tokens' + +type TestArgs = Args & { + name: { type: 'string' } + port: { type: 'number' } + debug: { type: 'boolean' } +} + +type NestedArgs = Args & { + name: { type: 'string' } +} + +const values: ArgValues = { + name: 'default-name', + port: 3000, + debug: false +} + +const explicit: ArgExplicitlyProvided = { + name: false, + port: false, + debug: false +} + +describe('resolveValue', () => { + test('should return original values when hook is undefined', async () => { + const result = await resolveValue(undefined, values, explicit) + expect(result).toBe(values) + }) + + test('should return hook result when hook returns a value', async () => { + const overridden: ArgValues = { name: 'from-config', port: 8080, debug: true } + const hook = vi.fn().mockResolvedValue(overridden) + + const result = await resolveValue(hook, values, explicit) + + expect(result).toBe(overridden) + expect(hook).toHaveBeenCalledOnce() + // hook receives a frozen snapshot (not the original reference), but with equal values + const [calledSources] = hook.mock.calls[0] + expect(calledSources.values).toEqual(values) + expect(calledSources.values).not.toBe(values) + expect(Object.isFrozen(calledSources.values)).toBe(true) + expect(calledSources.explicit).toBe(explicit) + }) + + test('should fall back to original values when hook returns undefined', async () => { + const hook = vi.fn().mockResolvedValue(undefined) + + const result = await resolveValue(hook, values, explicit) + + expect(result).toBe(values) + expect(hook).toHaveBeenCalledOnce() + }) + + test('should pass correct sources to hook', async () => { + const explicitWithPort: ArgExplicitlyProvided = { + name: false, + port: true, + debug: false + } + const hook = vi.fn().mockResolvedValue(undefined) + + await resolveValue(hook, values, explicitWithPort) + + const [calledSources] = hook.mock.calls[0] + expect(calledSources.values).toEqual(values) + expect(calledSources.explicit).toBe(explicitWithPort) + }) + + test('should return original values when hook mutates snapshot and returns undefined', async () => { + const original = { name: 'original', port: 3000, debug: false } + const inputValues: ArgValues = { ...original } + + const hook = vi.fn().mockImplementation((sources: { values: ArgValues }) => { + // Attempt to mutate the snapshot passed to the hook + try { + ;(sources.values as Record)['name'] = 'mutated' + } catch { + // Silently ignore TypeError from frozen object in strict mode + } + return undefined + }) + + const result = await resolveValue(hook, inputValues, explicit) + + // Fallback must be the unmodified original + expect(result).toBe(inputValues) + expect(result.name).toBe('original') + }) + + test('should preserve original values when hook mutates a nested object on the snapshot and returns undefined', async () => { + // Object.freeze only freezes the top-level properties, so nested objects/arrays + // are still mutable. The fallback must still return the unmodified original reference. + const nested = { extra: 'original' } + const inputValues = { name: 'original' } as ArgValues + // Attach a nested object outside the type so we can observe mutation attempts + ;(inputValues as Record)['meta'] = nested + + const nestedExplicit: ArgExplicitlyProvided = { name: false } + + const hook = vi + .fn() + .mockImplementation( + (sources: { values: ArgValues & Record }) => { + // Top-level freeze blocks this; nested objects are not frozen + try { + ;(sources.values as Record)['name'] = 'mutated-top' + } catch { + // Silently ignore TypeError from frozen top-level in strict mode + } + // Mutate nested object — shallow freeze does NOT protect this + const meta = sources.values['meta'] as Record | undefined + if (meta) { + meta['extra'] = 'mutated-nested' + } + return undefined + } + ) + + const result = await resolveValue( + hook as unknown as Parameters[0], + inputValues, + nestedExplicit as unknown as ArgExplicitlyProvided + ) + + // Fallback reference must be the unmodified original + expect(result).toBe(inputValues) + // Top-level string was protected by Object.freeze + expect(result.name).toBe('original') + // Nested object was mutated through the shallow-frozen snapshot — + // this documents the known limitation of Object.freeze and ensures the + // suite catches any regression if the implementation moves to deep-freeze. + expect(nested.extra).toBe('mutated-nested') + }) + + test('should support synchronous hook', async () => { + const overridden: ArgValues = { name: 'sync-result', port: 9000, debug: true } + const hook = vi.fn().mockReturnValue(overridden) + + const result = await resolveValue(hook, values, explicit) + + expect(result).toBe(overridden) + }) +}) + +describe('revalidateError', () => { + const requiredArgs = { + name: { type: 'string' as const, required: true as const }, + port: { type: 'number' as const } + } + + test('should return undefined when original error is undefined', () => { + const result = revalidateError(undefined, requiredArgs, { name: 'foo', port: 3000 }) + expect(result).toBeUndefined() + }) + + test('should clear required error when resolved value is now present', () => { + const schema = requiredArgs.name + const requiredError = new ArgResolveError( + "Optional argument '--name' is required", + 'name', + 'required', + schema + ) + const error = new AggregateError([requiredError]) + + // hook filled in the required arg + const result = revalidateError(error, requiredArgs, { name: 'from-config', port: 3000 }) + + expect(result).toBeUndefined() + }) + + test('should keep required error when resolved value is still missing', () => { + const schema = requiredArgs.name + const requiredError = new ArgResolveError( + "Optional argument '--name' is required", + 'name', + 'required', + schema + ) + const error = new AggregateError([requiredError]) + + // hook did not fill in the required arg + const result = revalidateError(error, requiredArgs, { port: 3000 } as ArgValues< + typeof requiredArgs + >) + + expect(result).toBeInstanceOf(AggregateError) + expect(result!.errors).toHaveLength(1) + expect(result!.errors[0]).toBe(requiredError) + }) + + test('should keep non-required errors (type, conflict) unchanged', () => { + const schema = requiredArgs.port + const typeError = new ArgResolveError( + "Optional argument '--port' should be 'number'", + 'port', + 'type', + schema + ) + const error = new AggregateError([typeError]) + + // values look resolved but the type error should still remain + const result = revalidateError(error, requiredArgs, { name: 'foo', port: 3000 }) + + expect(result).toBeInstanceOf(AggregateError) + expect(result!.errors).toHaveLength(1) + expect(result!.errors[0]).toBe(typeError) + }) + + test('should partially clear errors when only some required args are resolved', () => { + const mixedArgs = { + name: { type: 'string' as const, required: true as const }, + config: { type: 'string' as const, required: true as const } + } + const nameError = new ArgResolveError( + "Optional argument '--name' is required", + 'name', + 'required', + mixedArgs.name + ) + const configError = new ArgResolveError( + "Optional argument '--config' is required", + 'config', + 'required', + mixedArgs.config + ) + const error = new AggregateError([nameError, configError]) + + // hook filled in 'name' but not 'config' + const result = revalidateError(error, mixedArgs, { name: 'filled' } as ArgValues< + typeof mixedArgs + >) + + expect(result).toBeInstanceOf(AggregateError) + expect(result!.errors).toHaveLength(1) + expect(result!.errors[0]).toBe(configError) + }) +}) diff --git a/packages/gunshi/src/resolver.ts b/packages/gunshi/src/resolver.ts new file mode 100644 index 00000000..9af2f8df --- /dev/null +++ b/packages/gunshi/src/resolver.ts @@ -0,0 +1,72 @@ +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + * @license MIT + */ + +import { ArgResolveError } from 'args-tokens' +import type { Args, ArgExplicitlyProvided, ArgValues } from 'args-tokens' +import type { Awaitable, ValueResolutionSources } from './types.ts' + +/** + * Apply the onResolveValue hook if provided, falling back to the original values. + * + * @typeParam A - The Args type from command definition + * + * @param hook - The onResolveValue hook from CLI options, if registered + * @param values - Parsed argument values with schema defaults filled in + * @param explicit - Map of which keys were explicitly provided via CLI + * @returns The resolved values from the hook, or the original values if no hook or hook returns undefined + */ +export async function resolveValue( + hook: ((sources: ValueResolutionSources) => Awaitable | undefined>) | undefined, + values: ArgValues, + explicit: ArgExplicitlyProvided +): Promise> { + if (!hook) { + return values + } + // Pass a frozen shallow copy so hook cannot mutate the original values; + // the original is preserved as the fallback when the hook returns undefined. + const snapshot = Object.freeze({ ...values }) as ArgValues + return (await hook({ values: snapshot, explicit })) ?? values +} + +/** + * Recompute the validation error after the onResolveValue hook has run. + * + * Required-argument errors are dropped for any key whose value is now + * non-nullish in `resolvedValues`; all other errors (type, conflict, etc.) + * are kept unchanged. + * + * @typeParam A - The Args type from command definition + * + * @param error - The AggregateError produced by resolveArgs before the hook ran + * @param args - The Args schema used during parsing (used to map schema references back to raw keys) + * @param resolvedValues - The final values after the hook has been applied + * @returns A new AggregateError containing only the still-failing errors, or undefined if all errors are resolved + */ +export function revalidateError( + error: AggregateError | undefined, + args: A, + resolvedValues: ArgValues +): AggregateError | undefined { + if (!error) return undefined + + const values = resolvedValues as Record + + // Build a reverse map from schema object reference → rawArg key so that we + // can look up keys without having to re-apply kebab-case conversion logic. + const schemaToKey = new Map() + for (const [rawArg, schema] of Object.entries(args)) { + schemaToKey.set(schema, rawArg) + } + + const remaining = (error.errors as Error[]).filter(err => { + if (!(err instanceof ArgResolveError) || err.type !== 'required') return true + const rawArg = schemaToKey.get(err.schema) + if (rawArg == null) return true // Cannot match schema — keep the error conservatively + return values[rawArg] == null + }) + + return remaining.length > 0 ? new AggregateError(remaining) : undefined +} diff --git a/packages/gunshi/src/types.ts b/packages/gunshi/src/types.ts index cc6b8b1f..3385fd6a 100644 --- a/packages/gunshi/src/types.ts +++ b/packages/gunshi/src/types.ts @@ -238,6 +238,22 @@ export interface CommandEnvironment>, error: Error) => Awaitable) | undefined } +/** + * Value resolution sources for the onResolveValue hook. + * + * @typeParam A - The Args type from command definition + */ +export interface ValueResolutionSources { + /** + * Parsed argument values, with defaults from schema filled in for unset keys + */ + values: ArgValues + /** + * Map of which keys were explicitly provided via command line arguments + */ + explicit: ArgExplicitlyProvided +} + /** * CLI options of {@linkcode cli} function. * @@ -335,6 +351,19 @@ export interface CliOptions>, error: Error) => Awaitable + /** + * Hook that runs once after argument parsing and before command execution. + * + * Receives parsed argv values (with schema defaults filled in) and explicit CLI keys. + * The hook is responsible for merging values from any external sources (e.g. config files, env vars). + * The returned record becomes the final resolved values for the command context. + * + * @param sources - Parsed argv values and explicit CLI keys + * @returns The final resolved values, or undefined to use parsed argv values as-is + */ + onResolveValue?: ( + sources: ValueResolutionSources> + ) => Awaitable> | undefined> } /**