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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions packages/gunshi/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2000,3 +2000,56 @@ describe('nested sub-commands', () => {
)
})
})

describe('onResolveValue hook', () => {
test('required arg supplied only by onResolveValue clears validationError', async () => {
const mockFn = vi.fn()

await cli(
// no --token on the CLI
[],
{
args: {
token: { type: 'string', required: true }
},
run: mockFn
},
{
onResolveValue: sources => ({
...sources.values,
token: 'secret-from-env'
})
}
)

expect(mockFn).toHaveBeenCalledOnce()
const ctx = mockFn.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/)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
})
12 changes: 10 additions & 2 deletions packages/gunshi/src/cli/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -84,6 +85,13 @@ export async function cliCore<G extends GunshiParamsConstraint = DefaultGunshiPa
})
const omitted = resolved.omitted

const resolvedValues = await resolveValue(options.onResolveValue, values, explicit)
// Re-run validation against the hook-resolved values so that required args
// filled in by the hook (e.g. from config/env) no longer produce a false error.
const resolvedError = options.onResolveValue
? revalidateError(error, args, resolvedValues)
: error

// override subCommands with level-specific sub-commands for rendering
if (levelSubCommands) {
cliOptions.subCommands = levelSubCommands
Expand All @@ -96,7 +104,7 @@ export async function cliCore<G extends GunshiParamsConstraint = DefaultGunshiPa
const commandContext = await createCommandContext({
args,
explicit,
values,
values: resolvedValues,
positionals,
rest,
argv,
Expand All @@ -106,7 +114,7 @@ export async function cliCore<G extends GunshiParamsConstraint = DefaultGunshiPa
commandPath,
command: resolvedCommand,
extensions: getPluginExtensions(resolvedPlugins),
validationError: error,
validationError: resolvedError,
cliOptions: cliOptions
})

Expand Down
199 changes: 199 additions & 0 deletions packages/gunshi/src/resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/**
* @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' }
}

const values: ArgValues<TestArgs> = {
name: 'default-name',
port: 3000,
debug: false
}

const explicit: ArgExplicitlyProvided<TestArgs> = {
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<TestArgs> = { 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<TestArgs> = {
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<TestArgs> = { ...original }

const hook = vi.fn().mockImplementation((sources: { values: ArgValues<TestArgs> }) => {
// Attempt to mutate the snapshot passed to the hook
try {
;(sources.values as Record<string, unknown>)['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')
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test('should support synchronous hook', async () => {
const overridden: ArgValues<TestArgs> = { name: 'sync-result', port: 9000, debug: true }
const hook = vi.fn().mockReturnValue(overridden)

const result = await resolveValue(hook, values, explicit)

expect(result).toBe(overridden)
})
})
Comment thread
imjuni marked this conversation as resolved.

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)
})
})
72 changes: 72 additions & 0 deletions packages/gunshi/src/resolver.ts
Original file line number Diff line number Diff line change
@@ -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<A extends Args>(
hook: ((sources: ValueResolutionSources<A>) => Awaitable<ArgValues<A> | undefined>) | undefined,
values: ArgValues<A>,
explicit: ArgExplicitlyProvided<A>
): Promise<ArgValues<A>> {
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<A>
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<A extends Args>(
error: AggregateError | undefined,
args: A,
resolvedValues: ArgValues<A>
): AggregateError | undefined {
if (!error) return undefined

const values = resolvedValues as Record<string, unknown>

// 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<object, string>()
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
}
Loading