diff --git a/package-lock.json b/package-lock.json index 7cad5948..b3280bd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "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", @@ -26,7 +26,7 @@ "seam": "bin/cli.js" }, "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", @@ -1459,9 +1459,9 @@ "license": "MIT" }, "node_modules/@seamapi/blueprint": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@seamapi/blueprint/-/blueprint-1.2.0.tgz", - "integrity": "sha512-+fomtVD/VGJavEpOtVZD+3ZxUYgTXAS4DreNqbsNl0R1qS0wiIdJ40AmE9dgLnY1cHCfPNBmf1z//FdvCqHhrw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@seamapi/blueprint/-/blueprint-1.5.0.tgz", + "integrity": "sha512-UhLlcfgxUbnxoi4GoL45Kwvz3d9kfu64enYmdGYHWDbya1lpvBF5E9NVP52OOR9OjSZpQFyIg9mUV1rjgjTyUA==", "license": "MIT", "dependencies": { "change-case": "^5.4.4", @@ -1488,9 +1488,9 @@ } }, "node_modules/@seamapi/types": { - "version": "1.985.0", - "resolved": "https://registry.npmjs.org/@seamapi/types/-/types-1.985.0.tgz", - "integrity": "sha512-3+aFZXav6zQ9mPsl6FLR0TuMBbu3u3kC16OoEjNHZbTASj1eOYTAjb2lUR8VjFAaR1iZi/rNcIPBRfX/xaWbfg==", + "version": "1.994.0", + "resolved": "https://registry.npmjs.org/@seamapi/types/-/types-1.994.0.tgz", + "integrity": "sha512-43QOfg63nIppl6wLZ3s05cAl1PeoAw5dutjX5DMAcxeFNapw/2QQpfc+OosZgbSxdEdZKtdZdJJAt/B/Ed5TwQ==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index fcc7325a..72e8c711 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/lib/args/coerce.test.ts b/src/lib/args/coerce.test.ts index 4c9fe0b9..49d53653 100644 --- a/src/lib/args/coerce.test.ts +++ b/src/lib/args/coerce.test.ts @@ -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], @@ -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) => { diff --git a/src/lib/args/coerce.ts b/src/lib/args/coerce.ts index 06e955eb..b7cf0fba 100644 --- a/src/lib/args/coerce.ts +++ b/src/lib/args/coerce.ts @@ -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. diff --git a/src/lib/commands/spec.ts b/src/lib/commands/spec.ts index 502a9215..0e67828f 100644 --- a/src/lib/commands/spec.ts +++ b/src/lib/commands/spec.ts @@ -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 } /** diff --git a/src/lib/interactions/blueprint-object.ts b/src/lib/interactions/blueprint-object.ts index 19fe4911..0f9185f2 100644 --- a/src/lib/interactions/blueprint-object.ts +++ b/src/lib/interactions/blueprint-object.ts @@ -42,6 +42,7 @@ export const interactForBlueprintObject = async ( command: string[] parameters: Parameter[] params: Record + hasRequiredParameters?: boolean isSubProperty?: boolean subPropertyPath?: string }, @@ -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 @@ -99,7 +109,9 @@ export const interactForBlueprintObject = async ( paramToEdit = await promptAutocomplete({ message: parameterSelectionMessage, choices: [ - ...(haveAllRequiredParams && !args.isSubProperty + ...(haveAllRequiredParams && + satisfiesRequestRequirement && + !args.isSubProperty ? [ { value: 'done', @@ -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) diff --git a/src/lib/interactions/command-params.ts b/src/lib/interactions/command-params.ts index ff978a62..0f186884 100644 --- a/src/lib/interactions/command-params.ts +++ b/src/lib/interactions/command-params.ts @@ -17,6 +17,7 @@ export const interactForCommandParams = async ( command: args.command, params: args.params, parameters: endpoint.request.parameters, + hasRequiredParameters: endpoint.request.hasRequiredParameters, }, ctx, ) diff --git a/test/commands/spec.test.ts b/test/commands/spec.test.ts index 35cb802f..06c141cc 100644 --- a/test/commands/spec.test.ts +++ b/test/commands/spec.test.ts @@ -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', diff --git a/test/interactions/blueprint-object.test.ts b/test/interactions/blueprint-object.test.ts index 4a3065af..3e10177f 100644 --- a/test/interactions/blueprint-object.test.ts +++ b/test/interactions/blueprint-object.test.ts @@ -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)(