Skip to content
Draft
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
31 changes: 25 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ A command line interface (CLI) for interacting with the Seam API.

## Description

Every command is interactive: the CLI prompts for any missing required
parameter with suggestions pulled from your workspace. Pass `-y` to take the
first suggestion instead of being asked.
Commands run as soon as every required parameter is given. Anything missing is
prompted for, with suggestions pulled from your workspace. Pass
`--non-interactive` (or `-y`) to never be prompted: the command fails instead.

## Installation

Expand All @@ -32,9 +32,19 @@ $ paru -S seam-bin

## Usage

Every `seam` command is interactive and will prompt you for any missing
required properties with helpful suggestions. To avoid automatic behavior,
pass `-y`
Every `seam` command makes its request as soon as every required property is
given. When something is missing, the CLI prompts you for it with helpful
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.

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
required property is missing, the CLI exits with an error naming what is
missing instead of asking for it.

```bash
# Login to Seam
Expand All @@ -52,6 +62,15 @@ seam connect-webviews create
# List devices in your workspace
seam devices list

# Review and edit filters before listing devices
seam devices list --interactive

# List devices, failing instead of prompting
seam devices list --non-interactive

# Fails with: Missing required parameter for /locks/unlock_door: --device-id
seam locks unlock-door --non-interactive

MY_DOOR=$(seam devices get --name "Front Door" --id-only)

# Unlock a lock
Expand Down
92 changes: 77 additions & 15 deletions src/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { isDeepStrictEqual as isEqual } from 'node:util'

import chalk from 'chalk'
import commandLineUsage from 'command-line-usage'
import parseArgs, { type ParsedArgs } from 'minimist'
import type { ParsedArgs } from 'minimist'
import prompts from 'prompts'

import { getApiBlueprint } from 'lib/get-api-blueprint.js'
Expand All @@ -18,6 +18,13 @@ import { interactForServerSelection } from 'lib/interact-for-server-selection.js
import { interactForUseRemoteApiDefs } from 'lib/interact-for-use-remote-api-defs.js'
import { interactForWorkspaceId } from 'lib/interact-for-workspace-id.js'
import type { ContextHelpers } from 'lib/types.js'
import {
getInteractivity,
type Interactivity,
interactivityFlags,
NonInteractiveError,
parseCliArgs,
} from 'lib/util/cli-args.js'
import { RequestSeamApi } from 'lib/util/request-seam-api.js'
import { validateToken } from 'lib/validate-token.js'
import seamapiCliVersion from 'lib/version.js'
Expand All @@ -26,7 +33,7 @@ const sections = [
{
header: 'Seam CLI',
content:
'Every seam command is interactive and will prompt you for any missing required properties with helpful suggestions. To avoid automatic behavior, pass -y ',
'Every seam command runs as soon as every required property is given, and otherwise prompts you for what is missing with helpful suggestions. Pass -i to always review properties first, or -y to never be prompted. ',
},
{
header: 'Options',
Expand All @@ -37,6 +44,20 @@ const sections = [
alias: 'h',
type: Boolean,
},
{
name: 'interactive',
description:
'Always prompt to review and edit properties, prefilled with the given arguments.',
alias: 'i',
type: Boolean,
},
{
name: 'non-interactive',
description:
'Never prompt: exit with an error if the command or any required property is missing.',
alias: 'y',
type: Boolean,
},
],
},
{
Expand All @@ -50,6 +71,14 @@ const sections = [
summary: 'Create a connect webview to connect devices.',
},
{ name: 'seam devices list', summary: 'List devices in your workspace.' },
{
name: 'seam devices list {bold --interactive}',
summary: 'Review and edit filters before listing devices.',
},
{
name: 'seam devices list {bold --non-interactive}',
summary: 'List devices, failing instead of prompting.',
},
{
name: 'seam locks unlock-door {bold --device-id} $MY_DOOR',
summary: 'Unlock a lock.',
Expand Down Expand Up @@ -117,22 +146,21 @@ async function cli(args: ParsedArgs) {

const commandParams: Record<string, any> = {}

/**
* Whether or not to auto-select first option
*/
const is_interactive = args['y'] !== true

const ctx: ContextHelpers = {
blueprint,
is_interactive,
interactivity: getInteractivity(args),
}

const isNonInteractive = ctx.interactivity === 'non-interactive'

for (const k in args) {
if (k === '_') continue
const v = args[k]
delete args[k]
args[k.replace(/-/g, '_')] = v
commandParams[k.replace(/-/g, '_')] = v
const key = k.replace(/-/g, '_')
args[key] = v
if (interactivityFlags.includes(key)) continue
commandParams[key] = v
}

const selectedCommand = await interactForCommandSelection(args._, ctx)
Expand All @@ -153,6 +181,11 @@ async function cli(args: ParsedArgs) {
if (args['token'] || args['workspace_id'] || args['server']) {
return
}
if (isNonInteractive) {
throw new NonInteractiveError(
'Missing required parameter for login: --token',
)
}
await interactForLogin()
return
} else if (isEqual(selectedCommand, ['logout'])) {
Expand All @@ -163,9 +196,19 @@ async function cli(args: ParsedArgs) {
console.log(config.path)
return
} else if (isEqual(selectedCommand, ['config', 'use-remote-api-defs'])) {
if (isNonInteractive) {
throw new NonInteractiveError(
'Cannot select whether to use remote API definitions in non-interactive mode',
)
}
await interactForUseRemoteApiDefs()
return
} else if (isEqual(selectedCommand, ['select', 'workspace'])) {
if (isNonInteractive) {
throw new NonInteractiveError(
'Cannot select a workspace in non-interactive mode: pass --workspace-id to "seam login"',
)
}
await interactForWorkspaceId()
return
} else if (isEqual(selectedCommand, ['events', 'list'])) {
Expand All @@ -180,6 +223,11 @@ async function cli(args: ParsedArgs) {
config.delete('current_workspace_id')
return
}
if (isNonInteractive) {
throw new NonInteractiveError(
'Missing required parameter for select server: --server',
)
}
await interactForServerSelection()
return
} else if (isEqual(selectedCommand, ['health', 'get-health'])) {
Expand Down Expand Up @@ -232,18 +280,27 @@ async function cli(args: ParsedArgs) {
})

if (response.data?.connect_webview) {
await handleConnectWebviewResponse(response.data.connect_webview)
await handleConnectWebviewResponse(
response.data.connect_webview,
ctx.interactivity,
)
}

if (response.data?.action_attempt) {
if (response.data?.action_attempt && !isNonInteractive) {
interactForActionAttemptPoll(response.data.action_attempt)
}
}

const handleConnectWebviewResponse = async (connect_webview: any) => {
const handleConnectWebviewResponse = async (
connect_webview: any,
interactivity: Interactivity,
) => {
const url = connect_webview.url

if (process.env['INSIDE_WEB_BROWSER'] !== '1') {
if (
interactivity !== 'non-interactive' &&
process.env['INSIDE_WEB_BROWSER'] !== '1'
) {
const { action } = await prompts({
type: 'confirm',
name: 'action',
Expand All @@ -257,7 +314,12 @@ const handleConnectWebviewResponse = async (connect_webview: any) => {
}
}

cli(parseArgs(process.argv.slice(2), { string: ['code'] })).catch((e) => {
cli(parseCliArgs(process.argv.slice(2))).catch((e) => {
if (e instanceof NonInteractiveError) {
console.log(chalk.red(e.message))
process.exit(1)
}

console.log(chalk.red(`CLI Error: ${e.toString()}\n${e.stack}`))
if (e.toString().includes('object Object')) {
console.log(e)
Expand Down
81 changes: 81 additions & 0 deletions src/lib/interact-for-blueprint-object.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { Parameter } from '@seamapi/blueprint'
import prompts from 'prompts'
import { beforeEach, expect, test, vi } from 'vitest'

import { interactForBlueprintObject } from './interact-for-blueprint-object.js'
import type { ContextHelpers } from './types.js'

vi.mock('prompts', () => ({
default: vi.fn(async () => ({ paramToEdit: 'done' })),
}))

beforeEach(() => {
vi.mocked(prompts).mockClear()
})

const parameters = [
{ name: 'device_id', isRequired: true, format: 'id' },
{ name: 'name', isRequired: false, format: 'string' },
] as unknown as Parameter[]

const ctx = (interactivity: ContextHelpers['interactivity']): ContextHelpers =>
({ interactivity, blueprint: {} }) as unknown as ContextHelpers

const args = (params: Record<string, any>) => ({
command: ['devices', 'get'],
parameters,
params,
})

test('interactForBlueprintObject: submits without prompting once every required parameter is given', async () => {
await expect(
interactForBlueprintObject(args({ device_id: 'device1' }), ctx('auto')),
).resolves.toEqual({ device_id: 'device1' })
expect(prompts).not.toHaveBeenCalled()
})

test('interactForBlueprintObject: prompts to review given parameters when interactive', async () => {
await expect(
interactForBlueprintObject(
args({ device_id: 'device1' }),
ctx('interactive'),
),
).resolves.toEqual({ device_id: 'device1' })
expect(prompts).toHaveBeenCalledTimes(1)
})

test('interactForBlueprintObject: prefills the prompt with the given parameters', async () => {
await interactForBlueprintObject(
args({ device_id: 'device1' }),
ctx('interactive'),
)

const { choices } = vi.mocked(prompts).mock.calls[0]?.[0] as {
choices: Array<{ value: string; description?: string }>
}
expect(choices.find(({ value }) => value === 'device_id')).toMatchObject({
description: '[device1]',
})
})

test('interactForBlueprintObject: submits without prompting when non-interactive', async () => {
await expect(
interactForBlueprintObject(
args({ device_id: 'device1' }),
ctx('non-interactive'),
),
).resolves.toEqual({ device_id: 'device1' })
expect(prompts).not.toHaveBeenCalled()
})

test('interactForBlueprintObject: rejects missing required parameters when non-interactive', async () => {
await expect(
interactForBlueprintObject(
args({ name: 'Front Door' }),
ctx('non-interactive'),
),
).rejects.toThrowError(
'Missing required parameter for /devices/get: --device-id',
)
expect(prompts).not.toHaveBeenCalled()
})
20 changes: 18 additions & 2 deletions src/lib/interact-for-blueprint-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { interactForDevice } from './interact-for-device.js'
import { interactForTimestamp } from './interact-for-timestamp.js'
import { interactForUserIdentity } from './interact-for-user-identity.js'
import type { ContextHelpers } from './types.js'
import { NonInteractiveError, toArgName } from './util/cli-args.js'
import { ellipsis } from './util/ellipsis.js'

const ergonomicPropOrder = [
Expand Down Expand Up @@ -47,12 +48,28 @@ export const interactForBlueprintObject = async (

const haveAllRequiredParams = required.every((k) => args.params[k])

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

const should_auto_submit =
!ctx.is_interactive && haveAllRequiredParams && !args.isSubProperty
ctx.interactivity !== 'interactive' &&
haveAllRequiredParams &&
!args.isSubProperty
if (should_auto_submit) {
return args.params
}

if (ctx.interactivity === 'non-interactive') {
const missing = required.filter((k) => !args.params[k])
const target = args.isSubProperty ? `"${args.subPropertyPath}"` : cmdPath
throw new NonInteractiveError(
missing.length > 0
? `Missing required ${
missing.length === 1 ? 'parameter' : 'parameters'
} for ${target}: ${missing.map(toArgName).join(' ')}`
: `Cannot prompt for ${target} in non-interactive mode`,
)
}

const propSortScore = (prop: string) => {
if (required.includes(prop)) return 100 - ergonomicPropOrder.indexOf(prop)
if (args.params[prop] !== undefined) {
Expand All @@ -61,7 +78,6 @@ export const interactForBlueprintObject = async (
return ergonomicPropOrder.indexOf(prop)
}

const cmdPath = `/${args.command.join('/').replace(/-/g, '_')}`
const parameterSelectionMessage = args.isSubProperty
? `Editing "${args.subPropertyPath}"`
: `[${cmdPath}] Parameters`
Expand Down
Loading