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
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ suggestions.

Pass `--interactive` (or `-i`) to always be prompted to review and edit
properties before the request is made. The prompt is prefilled with whatever
you passed as arguments, so this is the way to add optional properties, or to
check a request before making it.
you passed as arguments or piped in as JSON, and each property you open is
prefilled with the value it has, ready to edit rather than retype. This is the
way to add optional properties, or to check a request before making it.

For scripts and CI, pass `--non-interactive` (or `-y`) to never be prompted.
The command must then be complete: if the command itself is ambiguous, or any
Expand Down Expand Up @@ -137,8 +138,8 @@ one yourself. Run `seam <command> --help` to see whether a command paginates.

### JSON

Request params may be piped or redirected in as a JSON object. Params given as
arguments win over params read from stdin.
Request params may be piped or redirected in as a JSON object, or passed
inline with `--raw`. Params given as arguments win over raw or stdin params.

An argument the command does not accept is an error, so a typo is reported
rather than sent. Params read from stdin are passed through as given, so
Expand All @@ -151,6 +152,9 @@ seam locks unlock-door < params.json
# Or from another program
echo '{"device_id": "'"$MY_DOOR"'"}' | seam locks unlock-door

# Pass request params inline as JSON
seam devices list --raw '{"search":"bar"}'

# --device-id wins over any device_id in params.json
seam devices list --limit 5 < params.json
```
Expand Down
11 changes: 9 additions & 2 deletions src/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { createSeamApi, type SeamApi } from 'lib/http/api.js'
import { interactForCommandSelection } from 'lib/interactions/index.js'
import { getOutput, setOutput } from 'lib/output/get-output.js'
import { createOutput } from 'lib/output/output.js'
import { readStdinJson } from 'lib/output/read-stdin-json.js'
import { parseJsonParams, readStdinJson } from 'lib/output/read-stdin-json.js'
import { resolveOutputFormat } from 'lib/output/resolve-output-format.js'
import { setAuthOverrides } from 'lib/overrides.js'
import { canPrompt } from 'lib/prompt.js'
Expand Down Expand Up @@ -168,7 +168,14 @@ async function cli(args: ParsedArgs, argv: string[]) {

// Params piped or redirected in, e.g., `seam devices list < params.json`.
const pipedParams = await readStdinJson()
const stdinParams: Record<string, any> = { ...pipedParams }
const rawParams =
args['raw'] == null ? null : parseJsonParams(String(args['raw']), '--raw')
// Inline raw params take precedence over piped params, while ordinary
// command arguments still take precedence over both.
const stdinParams: Record<string, any> = {
...pipedParams,
...rawParams,
}

const auth = resolveAuth(config)
let seamApi: Promise<SeamApi> | null = null
Expand Down
1 change: 1 addition & 0 deletions src/lib/args/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const cliFlags: string[] = [
'h',
'help',
'json',
'raw',
'remote_schema',
'update',
'version',
Expand Down
8 changes: 8 additions & 0 deletions src/lib/commands/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ export const globalFlags: CommandFlag[] = [
takesValue: false,
isRequired: false,
},
{
long: 'raw',
short: null,
description: 'Pass request parameters as an inline JSON object.',
values: [],
takesValue: true,
isRequired: false,
},
{
long: 'non-interactive',
short: 'y',
Expand Down
18 changes: 11 additions & 7 deletions src/lib/interactions/access-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import { getSeam } from 'lib/http/client.js'
import { interactForDevice } from './device.js'
import { interactForResource } from './resource.js'

export const interactForAccessCode = async ({
// The key is a Seam API parameter name: callers pass the blueprint params
// bag through as-is, so only the binding may be camelCase.
device_id: deviceId,
}: {
device_id?: string
}) => {
export const interactForAccessCode = async (
{
// The key is a Seam API parameter name: callers pass the blueprint params
// bag through as-is, so only the binding may be camelCase.
device_id: deviceId,
}: {
device_id?: string
},
initialValue?: string,
) => {
const seam = await getSeam()

if (!deviceId) {
Expand All @@ -19,6 +22,7 @@ export const interactForAccessCode = async ({
return interactForResource({
resourceName: 'access_code',
fetchResources: () => seam.accessCodes.list({ device_id: deviceId }),
initialValue,
toChoice: (accessCode) => ({
title: accessCode.name ?? '<No Name>',
value: accessCode.access_code_id,
Expand Down
3 changes: 2 additions & 1 deletion src/lib/interactions/acs-entrance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { getSeam } from 'lib/http/client.js'

import { interactForResource } from './resource.js'

export const interactForAcsEntrance = async () => {
export const interactForAcsEntrance = async (initialValue?: string) => {
const seam = await getSeam()

return interactForResource({
resourceName: 'ACS entrance',
fetchResources: () => seam.acs.entrances.list(),
initialValue,
toChoice: (entrance) => ({
title: entrance.display_name ?? '<No Name>',
value: entrance.acs_entrance_id,
Expand Down
9 changes: 8 additions & 1 deletion src/lib/interactions/acs-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ import { getSeam } from 'lib/http/client.js'

import { interactForResource } from './resource.js'

export const interactForAcsSystem = async (message?: string) => {
export const interactForAcsSystem = async ({
message,
initialValue,
}: {
message?: string | undefined
initialValue?: string | undefined
} = {}) => {
const seam = await getSeam()

return interactForResource({
resourceName: 'ACS system',
fetchResources: () => seam.acs.systems.list(),
message,
initialValue,
toChoice: (system) => ({
title: `${system.name} ${system.external_type_display_name}`,
value: system.acs_system_id,
Expand Down
9 changes: 5 additions & 4 deletions src/lib/interactions/acs-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@ import { getSeam } from 'lib/http/client.js'
import { interactForAcsSystem } from './acs-system.js'
import { interactForResource } from './resource.js'

export const interactForAcsUser = async () => {
export const interactForAcsUser = async (initialValue?: string) => {
const seam = await getSeam()

const acsSystemId = await interactForAcsSystem(
'What acs_system does the acs_user belong to?',
)
const acsSystemId = await interactForAcsSystem({
message: 'What acs_system does the acs_user belong to?',
})

return interactForResource({
resourceName: 'ACS user',
fetchResources: () => seam.acs.users.list({ acs_system_id: acsSystemId }),
initialValue,
toChoice: (user) => ({
title: `${user.display_name} ${user.email_address}`,
value: user.acs_user_id,
Expand Down
75 changes: 62 additions & 13 deletions src/lib/interactions/blueprint-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,47 +207,62 @@ export const interactForBlueprintObject = async (
}
}

const current = args.params[paramToEdit]

if (paramToEdit === 'device_id') {
args.params[paramToEdit] = await interactForDevice()
args.params[paramToEdit] = await interactForDevice(toText(current))
return interactForBlueprintObject(args, ctx)
} else if (paramToEdit === 'access_code_id') {
args.params[paramToEdit] = await interactForAccessCode(args.params as any)
args.params[paramToEdit] = await interactForAccessCode(
args.params as any,
toText(current),
)
return interactForBlueprintObject(args, ctx)
} else if (paramToEdit === 'connected_account_id') {
const connectedAccountId = await interactForConnectedAccount()
const connectedAccountId = await interactForConnectedAccount(
toText(current),
)
args.params[paramToEdit] = connectedAccountId
return interactForBlueprintObject(args, ctx)
} else if (
paramToEdit === 'user_identity_id' ||
paramToEdit === 'user_identity_ids'
) {
const userIdentityId = await interactForUserIdentity()
const userIdentityId = await interactForUserIdentity(
paramToEdit === 'user_identity_ids'
? toTextList(current)[0]
: toText(current),
)
args.params[paramToEdit] =
paramToEdit === 'user_identity_ids' ? [userIdentityId] : userIdentityId
return interactForBlueprintObject(args, ctx)
} else if (paramToEdit.endsWith('acs_system_id')) {
args.params[paramToEdit] = await interactForAcsSystem()
args.params[paramToEdit] = await interactForAcsSystem({
initialValue: toText(current),
})
return interactForBlueprintObject(args, ctx)
} else if (paramToEdit.endsWith('acs_user_id')) {
args.params[paramToEdit] = await interactForAcsUser()
args.params[paramToEdit] = await interactForAcsUser(toText(current))
return interactForBlueprintObject(args, ctx)
} else if (paramToEdit.endsWith('acs_entrance_id')) {
args.params['acs_entrance_id'] = await interactForAcsEntrance()
args.params['acs_entrance_id'] = await interactForAcsEntrance(
toText(args.params['acs_entrance_id']),
)
return interactForBlueprintObject(args, ctx)
} else if (
paramToEdit.endsWith('_at') ||
paramToEdit === 'since' ||
paramToEdit.endsWith('_before') ||
paramToEdit.endsWith('_after')
) {
args.params[paramToEdit] = await interactForTimestamp()
args.params[paramToEdit] = await interactForTimestamp(toText(current))
return interactForBlueprintObject(args, ctx)
} else if (
paramToEdit === 'custom_metadata' ||
paramToEdit === 'custom_metadata_has'
) {
args.params[paramToEdit] = await interactForCustomMetadata(
args.params[paramToEdit] || {},
toRecord(current),
)
return interactForBlueprintObject(args, ctx)
}
Expand All @@ -256,10 +271,11 @@ export const interactForBlueprintObject = async (
if (['string', 'id', 'datetime'].includes(prop.format)) {
let value
if (prop.format === 'datetime') {
value = await interactForTimestamp()
value = await interactForTimestamp(toText(current))
} else {
value = await promptText({
message: withBackHint(`${paramToEdit}:`),
initialValue: toText(current),
})
}
args.params[paramToEdit] = value
Expand All @@ -271,13 +287,14 @@ export const interactForBlueprintObject = async (
label: v.name,
value: v.name,
})),
initialValue: toText(current),
})
args.params[paramToEdit] = value
return interactForBlueprintObject(args, ctx)
} else if (prop.format === 'boolean') {
const value = await promptConfirm({
message: withBackHint(`${paramToEdit}:`),
initialValue: true,
initialValue: toBoolean(current) ?? true,
active: 'true',
inactive: 'false',
})
Expand All @@ -292,20 +309,21 @@ export const interactForBlueprintObject = async (
label: v.name,
value: v.name,
})),
initialValues: toTextList(current),
})
args.params[paramToEdit] = value
return interactForBlueprintObject(args, ctx)
} else if (prop.format === 'list') {
args.params[paramToEdit] = await interactForArray(
args.params[paramToEdit] || [],
toTextList(current),
`Edit the list for ${paramToEdit}`,
)
return interactForBlueprintObject(args, ctx)
} else if (prop.format === 'object') {
args.params[paramToEdit] = await interactForBlueprintObject(
{
command: args.command,
params: {},
params: toRecord(current),
parameters: prop.parameters,
isSubProperty: true,
subPropertyPath: paramToEdit,
Expand All @@ -316,6 +334,7 @@ export const interactForBlueprintObject = async (
} else if (prop.format === 'number') {
const value = await promptNumber({
message: withBackHint(`${paramToEdit}:`),
initialValue: toNumber(current),
})

args.params[paramToEdit] = value
Expand All @@ -332,3 +351,33 @@ export const interactForBlueprintObject = async (
`Didn't know how to handle Blueprint parameter for property: "${paramToEdit}"`,
)
}

/*
* A parameter's current value in the shape its editor starts from, so that
* editing a parameter that already has a value continues from that value
* instead of from nothing — whether it came from an argument, from the JSON
* piped in, or from an earlier trip through this menu.
*
* Params are passed through as given, so a value need not match the format
* its parameter documents: the JSON piped in is arbitrary, and a schema can
* change under a script. A value the editor cannot start from is left out of
* it, which opens the editor as an unset parameter does, rather than
* rendering a value of the wrong type as text to edit.
*/

const toText = (value: unknown): string | undefined =>
typeof value === 'string' ? value : undefined

const toNumber = (value: unknown): number | undefined =>
typeof value === 'number' ? value : undefined

const toBoolean = (value: unknown): boolean | undefined =>
typeof value === 'boolean' ? value : undefined

const toTextList = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((item) => typeof item === 'string') : []

const toRecord = (value: unknown): Record<string, any> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, any>)
: {}
3 changes: 2 additions & 1 deletion src/lib/interactions/connected-account.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { getSeam } from 'lib/http/client.js'

import { interactForResource } from './resource.js'
export const interactForConnectedAccount = async () => {
export const interactForConnectedAccount = async (initialValue?: string) => {
const seam = await getSeam()

return interactForResource({
resourceName: 'connected_account',
fetchResources: () => seam.connectedAccounts.list(),
initialValue,
toChoice: (connectedAccount) => {
const identifiers = Object.values(
connectedAccount.user_identifier ?? {},
Expand Down
6 changes: 6 additions & 0 deletions src/lib/interactions/custom-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,16 @@ export const interactForCustomMetadata = async (
message: withBackHint('Enter a key to add or edit:'),
})

// Editing an existing key continues from the value it has, so a value
// is corrected rather than retyped.
const currentValue = updatedCustomMetadata[newKey]

let newValue: string | boolean = await promptText({
message: withBackHint(
'Enter the new value to add or edit (or null to delete):',
),
initialValue:
currentValue == null ? undefined : currentValue.toString(),
})
if (newKey) {
if (newValue === 'false' || newValue === 'true') {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/interactions/device.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { getSeam } from 'lib/http/client.js'

import { interactForResource } from './resource.js'
export const interactForDevice = async () => {
export const interactForDevice = async (initialValue?: string) => {
const seam = await getSeam()

return interactForResource({
resourceName: 'device',
fetchResources: () => seam.devices.list(),
initialValue,
toChoice: (device) => ({
title: device.properties.name ?? '<No Name>',
value: device.device_id,
Expand Down
Loading
Loading