Skip to content
Merged
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
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
},
"dependencies": {
"@clack/prompts": "^1.7.0",
"@seamapi/blueprint": "1.2.0",
"@seamapi/blueprint": "1.5.0",
"@seamapi/http": "2.2.0",
"@seamapi/wizard": "0.5.2",
"chalk": "^6.0.0",
Expand All @@ -106,7 +106,7 @@
"tar": "^7.5.22"
},
"devDependencies": {
"@seamapi/types": "1.985.0",
"@seamapi/types": "^1.994.0",
"@types/command-line-usage": "^5.0.4",
"@types/minimist": "^1.2.5",
"@types/node": "^24.10.9",
Expand Down
8 changes: 8 additions & 0 deletions src/lib/args/coerce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ const enumList = parameter({
itemEnumValues: [{ name: 'august_lock' }, { name: 'schlage_lock' }],
})
const object = parameter({ name: 'custom_metadata', format: 'object' })
const nullableString = parameter({
name: 'name',
format: 'string',
isNullable: true,
})

test.each([
[boolean, 'true', true],
Expand All @@ -55,6 +60,9 @@ test.each([
[numberList, '1,2', [1, 2]],
[enumList, 'august_lock,schlage_lock', ['august_lock', 'schlage_lock']],
[object, '{"floor":2}', { floor: 2 }],
[nullableString, 'null', null],
[nullableString, null, null],
[string, 'null', 'null'],
] as Array<[Parameter, unknown, unknown]>)(
'coerceParam: %o given %o becomes %o',
(param, given, value) => {
Expand Down
6 changes: 6 additions & 0 deletions src/lib/args/coerce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ export const coerceArgParams = (
}

export const coerceParam = (parameter: Parameter, given: unknown): Coerced => {
// `null` is an explicit JSON value for nullable parameters, not the string
// "null" (or a one-item list containing it).
if (parameter.isNullable && (given === null || given === 'null')) {
return { value: null }
}

if (parameter.format === 'list') return coerceList(parameter, given)

// A repeated argument parses as an array, which only a list accepts.
Expand Down
22 changes: 12 additions & 10 deletions src/lib/commands/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,19 +201,21 @@ const toCommandFlag = (parameter: Parameter): CommandFlag => ({
})

const toFlagValues = (parameter: Parameter): string[] => {
if (parameter.format === 'enum') {
return parameter.values.map(({ name }) => name).filter(isSafeToken)
}
let values: string[] = []

if (parameter.format === 'list' && parameter.itemFormat === 'enum') {
return parameter.itemEnumValues.map(({ name }) => name).filter(isSafeToken)
if (parameter.format === 'enum') {
values = parameter.values.map(({ name }) => name).filter(isSafeToken)
} else if (parameter.format === 'list' && parameter.itemFormat === 'enum') {
values = parameter.itemEnumValues
.map(({ name }) => name)
.filter(isSafeToken)
} else if (parameter.format === 'boolean') {
// Nothing marks parameters as boolean-only flags, so minimist reads the
// next argument as the value.
values = ['true', 'false']
}

// Nothing marks parameters as boolean-only flags, so minimist reads the next
// argument as the value.
if (parameter.format === 'boolean') return ['true', 'false']

return []
return parameter.isNullable ? [...values, 'null'] : values
}

/**
Expand Down
38 changes: 37 additions & 1 deletion src/lib/interactions/blueprint-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const interactForBlueprintObject = async (
command: string[]
parameters: Parameter[]
params: Record<string, any>
hasRequiredParameters?: boolean
isSubProperty?: boolean
subPropertyPath?: string
},
Expand All @@ -60,12 +61,21 @@ export const interactForBlueprintObject = async (
const isSupplied = (k: string): boolean => args.params[k] !== undefined

const haveAllRequiredParams = required.every(isSupplied)
// Some request schemas require one of several parameters without marking
// any individual parameter as required. The request-level signal tells us
// that an entirely empty request still needs interaction.
const hasAnyParams = Object.values(args.params).some(
(value) => value !== undefined,
)
const satisfiesRequestRequirement =
args.hasRequiredParameters !== true || hasAnyParams

const cmdPath = `/${args.command.join('/').replace(/-/g, '_')}`

const shouldAutoSubmit =
ctx.interactivity !== 'interactive' &&
haveAllRequiredParams &&
satisfiesRequestRequirement &&
!args.isSubProperty
if (shouldAutoSubmit) {
return args.params
Expand Down Expand Up @@ -99,7 +109,9 @@ export const interactForBlueprintObject = async (
paramToEdit = await promptAutocomplete({
message: parameterSelectionMessage,
choices: [
...(haveAllRequiredParams && !args.isSubProperty
...(haveAllRequiredParams &&
satisfiesRequestRequirement &&
!args.isSubProperty
? [
{
value: 'done',
Expand Down Expand Up @@ -171,6 +183,30 @@ export const interactForBlueprintObject = async (
// Dismissing any prompt below returns to the parameter menu with the
// parameter left as it was, rather than ending the whole command.
try {
if (prop != null && (prop.isNullable || isSupplied(paramToEdit))) {
const action = await promptSelect({
message: withBackHint(`${paramToEdit}:`),
choices: [
{ label: 'Enter a value', value: 'value' },
...(prop.isNullable
? [{ label: 'Set to null', value: 'null' as const }]
: []),
...(isSupplied(paramToEdit)
? [{ label: 'Unset', value: 'unset' as const }]
: []),
],
})

if (action === 'null') {
args.params[paramToEdit] = null
return interactForBlueprintObject(args, ctx)
}
if (action === 'unset') {
delete args.params[paramToEdit]
return interactForBlueprintObject(args, ctx)
}
}

if (paramToEdit === 'device_id') {
args.params[paramToEdit] = await interactForDevice()
return interactForBlueprintObject(args, ctx)
Expand Down
1 change: 1 addition & 0 deletions src/lib/interactions/command-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const interactForCommandParams = async (
command: args.command,
params: args.params,
parameters: endpoint.request.parameters,
hasRequiredParameters: endpoint.request.hasRequiredParameters,
},
ctx,
)
Expand Down
19 changes: 19 additions & 0 deletions test/commands/spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,25 @@ test('command spec: collects values for enum and boolean flags', () => {
expect(flags.find(({ long }) => long === 'limit')?.values).toEqual([])
})

test('command spec: includes null among the values for a nullable flag', () => {
const blueprint = structuredClone(testBlueprint)
const parameter = blueprint.routes
.flatMap(({ endpoints }) => endpoints)
.find(({ path }) => path === '/devices/list')
?.request.parameters.find(({ name }) => name === 'device_type')
if (parameter == null) throw new Error('Missing test parameter')
parameter.isNullable = true

const nullableSpec = getCommandSpec(blueprint, localCommandDefinitions)
const flags = findCommand(nullableSpec, ['devices', 'list'])?.flags ?? []

expect(flags.find(({ long }) => long === 'device-type')?.values).toEqual([
'august_lock',
'schlage_lock',
'null',
])
})

test('command spec: groups every incomplete command path', () => {
expect(findGroup(spec, [])?.subcommands.map(({ name }) => name)).toContain(
'devices',
Expand Down
61 changes: 61 additions & 0 deletions test/interactions/blueprint-object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,67 @@ test('interactForBlueprintObject: offers the submit choice when a required value
expect(choices.map(({ value }) => value)).toContain('done')
})

test('interactForBlueprintObject: prompts in auto mode when an empty request has required parameters', async () => {
scriptPrompt(['name', 'Front Door'])

await expect(
interactForBlueprintObject(
{
command: ['devices', 'update'],
parameters: [
{ name: 'name', isRequired: false, format: 'string' },
] as unknown as Parameter[],
params: {},
hasRequiredParameters: true,
},
ctx('auto'),
),
).resolves.toEqual({ name: 'Front Door' })
expect(memoryPrompt.questions.map(({ kind }) => kind)).toEqual([
'autocomplete',
'text',
])
})

test('interactForBlueprintObject: lets a nullable parameter be set to null', async () => {
const nullableParameters = [
{ name: 'name', isRequired: false, isNullable: true, format: 'string' },
] as unknown as Parameter[]
scriptPrompt(['name', 'null', 'done'])

await expect(
interactForBlueprintObject(
{
command: ['devices', 'update'],
parameters: nullableParameters,
params: {},
},
ctx('interactive'),
),
).resolves.toEqual({ name: null })

expect(memoryPrompt.questions[1]).toMatchObject({
kind: 'select',
choices: expect.arrayContaining([{ label: 'Set to null', value: 'null' }]),
})
})

test('interactForBlueprintObject: lets a supplied parameter be unset', async () => {
scriptPrompt(['name', 'unset', 'done'])

await expect(
interactForBlueprintObject(
args({ device_id: 'device1', name: 'Front Door' }),
ctx('interactive'),
),
).resolves.toEqual({ device_id: 'device1' })

expect(memoryPrompt.questions[1]).toMatchObject({
kind: 'select',
choices: expect.arrayContaining([{ label: 'Unset', value: 'unset' }]),
})
})

// `custom_metadata` and `custom_metadata_has` are both records, a format with
// no branch of its own, so each has to be routed by name.
test.for(['custom_metadata', 'custom_metadata_has'] as const)(
Expand Down
Loading