Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
61 changes: 61 additions & 0 deletions packages/gunshi/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
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
67 changes: 67 additions & 0 deletions packages/gunshi/src/resolver.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* @author kazuya kawaguchi (a.k.a. kazupon)
* @license MIT
*/

import { expectTypeOf, test } from 'vitest'
import type { Args, ArgExplicitlyProvided, ArgValues } from 'args-tokens'
import type { Awaitable, ValueResolutionSources } from './types.ts'

type StringArg = { type: 'string' }
type NumberArg = { type: 'number' }
type BooleanArg = { type: 'boolean' }

type MyArgs = Args & {
name: StringArg
port: NumberArg
debug: BooleanArg
}

test('ValueResolutionSources.values is typed as ArgValues<A>', () => {
type Sources = ValueResolutionSources<MyArgs>
expectTypeOf<Sources['values']>().toEqualTypeOf<ArgValues<MyArgs>>()
})

test('ValueResolutionSources.explicit is typed as ArgExplicitlyProvided<A>', () => {
type Sources = ValueResolutionSources<MyArgs>
expectTypeOf<Sources['explicit']>().toEqualTypeOf<ArgExplicitlyProvided<MyArgs>>()
})

test('sources.values.name resolves to string | undefined', () => {
type Sources = ValueResolutionSources<MyArgs>
expectTypeOf<Sources['values']['name']>().toEqualTypeOf<string | undefined>()
})

test('sources.values.port resolves to number | undefined', () => {
type Sources = ValueResolutionSources<MyArgs>
expectTypeOf<Sources['values']['port']>().toEqualTypeOf<number | undefined>()
})

test('sources.values.debug resolves to boolean | undefined', () => {
type Sources = ValueResolutionSources<MyArgs>
expectTypeOf<Sources['values']['debug']>().toEqualTypeOf<boolean | undefined>()
})

test('sources.explicit flags resolve to boolean', () => {
type Sources = ValueResolutionSources<MyArgs>
expectTypeOf<Sources['explicit']['name']>().toEqualTypeOf<boolean>()
expectTypeOf<Sources['explicit']['port']>().toEqualTypeOf<boolean>()
expectTypeOf<Sources['explicit']['debug']>().toEqualTypeOf<boolean>()
})

test('onResolveValue hook return type is Awaitable<ArgValues<A> | undefined>', () => {
type Hook = (sources: ValueResolutionSources<MyArgs>) => Awaitable<ArgValues<MyArgs> | undefined>
// Sync return: ArgValues<MyArgs>
expectTypeOf<ReturnType<Hook>>().toEqualTypeOf<Awaitable<ArgValues<MyArgs> | undefined>>()
})

test('onResolveValue hook accepts ValueResolutionSources<A> as parameter', () => {
type Hook = (sources: ValueResolutionSources<MyArgs>) => Awaitable<ArgValues<MyArgs> | undefined>
expectTypeOf<Parameters<Hook>[0]>().toEqualTypeOf<ValueResolutionSources<MyArgs>>()
Comment thread
imjuni marked this conversation as resolved.
})

test('ValueResolutionSources defaults A to Args when no type parameter given', () => {
type DefaultSources = ValueResolutionSources
expectTypeOf<DefaultSources['values']>().toEqualTypeOf<ArgValues<Args>>()
expectTypeOf<DefaultSources['explicit']>().toEqualTypeOf<ArgExplicitlyProvided<Args>>()
})
Loading