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
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,48 @@ seam access-codes create --code "1234" --name "My Code"
seam access-codes list --device-id $MY_DOOR
```

### Output

Only the response is written to stdout, so any command may be piped or
redirected. Prompts, progress, and other information are written to stderr.

The response is trimmed to the response key and pagination: no other top level
fields are reported.

```bash
# The response, and nothing else, ends up in the file
seam devices list > devices.json

# Prompts and progress still show up in the terminal
seam devices list | jq '.devices[].device_id'
```

### JSON

Pass `--json` to read request params from stdin as JSON and write the response
to stdout as JSON.

```bash
# Read params from a file
seam locks unlock-door --json < params.json

# Or from another program
echo '{"device_id": "'"$MY_DOOR"'"}' | seam locks unlock-door --json

# Params given as flags win over params read from stdin
seam devices list --json --limit 5 < params.json
```

The JSON format is enabled automatically whenever stdout is not a terminal, so
piping and redirecting produce JSON without passing `--json`. Pass `--no-json`
to opt out and get the pretty format instead.

Reading params from stdin also implies `-y`: there is nobody to prompt, so the
CLI reports the missing required params rather than waiting for an answer.

An error exits non-zero. A request that fails reports its `error` on stdout,
so it can be inspected from a pipe; anything else is written to stderr only.

## Development and Testing

### Quickstart
Expand Down
165 changes: 138 additions & 27 deletions src/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { isDeepStrictEqual as isEqual } from 'node:util'
import chalk from 'chalk'
import commandLineUsage from 'command-line-usage'
import parseArgs, { type ParsedArgs } from 'minimist'
import prompts from 'prompts'

import { getApiBlueprint } from 'lib/get-api-blueprint.js'
import { getConfigStore } from 'lib/get-config-store.js'
import { getResponseKey } from 'lib/get-response-key.js'
import { getServer } from 'lib/get-server.js'
import { interactForActionAttemptPoll } from 'lib/interact-for-action-attempt-poll.js'
import { interactForCommandParams } from 'lib/interact-for-command-params.js'
Expand All @@ -17,7 +17,11 @@ import { interactForLogin } from 'lib/interact-for-login.js'
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 { createOutput, type OutputFormat } from 'lib/output/create-output.js'
import { getOutput, setOutput } from 'lib/output/get-output.js'
import type { ContextHelpers } from 'lib/types.js'
import { canPrompt, prompt } from 'lib/util/prompt.js'
import { parseJsonParams, readStdinJson } from 'lib/util/read-stdin-json.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 @@ -37,6 +41,25 @@ const sections = [
alias: 'h',
type: Boolean,
},
{
name: 'json',
description:
'Read request params as JSON from stdin and write the response to stdout as JSON. Enabled automatically when stdout is not a terminal, disable with {bold --no-json}.',
type: Boolean,
},
{
name: 'y',
description:
'Do not prompt: use the given params and take the first suggestion.',
type: Boolean,
},
],
},
{
header: 'Output',
content: [
'Only the response is written to stdout, so it is safe to pipe. Prompts, progress, and other information are written to stderr.',
'The response is trimmed to the response key and pagination.',
],
},
{
Expand All @@ -62,22 +85,48 @@ const sections = [
name: 'seam access-codes list {bold --device-id} $MY_DOOR',
summary: 'List you access codes.',
},
{
name: 'seam devices list {bold --json} > devices.json',
summary: 'Write the response to a file as JSON.',
},
{
name: 'seam locks unlock-door {bold --json} < params.json',
summary: 'Read request params from a JSON file.',
},
{
name: 'cat params.json | seam locks unlock-door {bold --json}',
summary: 'Pipe request params in as JSON.',
},
],
},
]

/**
* Flags that configure the CLI itself and are never sent as request params.
*/
const cliFlags = new Set([
'_',
'h',
'help',
'json',
'remote-api-defs',
'remote_api_defs',
'version',
'y',
])

async function cli(args: ParsedArgs) {
const config = getConfigStore()
const output = getOutput()

if (args['help'] || args['h']) {
const usage = commandLineUsage(sections)
console.log(usage)
if (args['help'] === true || args['h'] === true) {
output.text(commandLineUsage(sections))
return
}

if (args['version']) {
console.log(seamapiCliVersion)
process.exit(0)
if (args['version'] === true) {
output.text(seamapiCliVersion)
return
}

if (
Expand All @@ -89,10 +138,10 @@ async function cli(args: ParsedArgs) {
const fakeApiUrl = `https://${randomstring}.fakeseamconnect.seam.vc`

config.set('server', fakeApiUrl)
console.log(`Server URL set to ${fakeApiUrl}`)
output.info(`Server URL set to ${fakeApiUrl}`)

config.set(`${getServer()}.pat`, `seam_apikey1_token`)
console.log(`PAT set to use fakeseamconnect with "seam_apikey1_token"`)
output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`)
return
}

Expand All @@ -101,8 +150,9 @@ async function cli(args: ParsedArgs) {
args._[0] !== 'login' &&
!isEqual(args._, ['select', 'server'])
) {
console.log(`Not logged in. Please run "seam login"`)
process.exit(1)
output.error(`Not logged in. Please run "seam login"`)
process.exitCode = 1
return
}

args._ = args._.map((arg) => arg.toLowerCase().replace(/_/g, '-'))
Expand All @@ -115,12 +165,17 @@ async function cli(args: ParsedArgs) {

const blueprint = await getApiBlueprint(use_remote_api_defs ?? false)

const commandParams: Record<string, any> = {}
const commandParams: Record<string, any> = {
...(await readParamsFromStdin(args)),
}

/**
* Whether or not to auto-select first option
* Whether or not to auto-select first option.
*
* Piped or redirected input holds request params, not answers,
* so there is nobody to ask.
*/
const is_interactive = args['y'] !== true
const is_interactive = args['y'] !== true && canPrompt()

const ctx: ContextHelpers = {
blueprint,
Expand All @@ -131,8 +186,11 @@ async function cli(args: ParsedArgs) {
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 (!cliFlags.has(k) && !cliFlags.has(key)) {
commandParams[key] = v
}
}

const selectedCommand = await interactForCommandSelection(args._, ctx)
Expand All @@ -157,10 +215,10 @@ async function cli(args: ParsedArgs) {
return
} else if (isEqual(selectedCommand, ['logout'])) {
config.delete('pat')
console.log('Logged out!')
output.info('Logged out!')
return
} else if (isEqual(selectedCommand, ['config', 'reveal-location'])) {
console.log(config.path)
output.text(config.path)
return
} else if (isEqual(selectedCommand, ['config', 'use-remote-api-defs'])) {
await interactForUseRemoteApiDefs()
Expand Down Expand Up @@ -229,22 +287,52 @@ async function cli(args: ParsedArgs) {
const response = await RequestSeamApi({
path: apiPath,
params,
responseKey: getResponseKey(selectedCommand, ctx),
})

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

if (response.data?.action_attempt) {
interactForActionAttemptPoll(response.data.action_attempt)
if (response.data?.action_attempt && is_interactive) {
await interactForActionAttemptPoll(response.data.action_attempt)
}
}

/**
* Request params piped or redirected into the CLI, e.g.,
* `seam devices list --json < params.json`.
*
* Params given as flags take precedence over params read here.
*/
const readParamsFromStdin = async (
args: ParsedArgs,
): Promise<Record<string, unknown>> => {
const json = getJsonFlag(args)
if (typeof json === 'string') {
return parseJsonParams(json, '--json') ?? {}
}

return (await readStdinJson()) ?? {}
}

/**
* The `--json` flag: true, false for `--no-json`, or params given inline
* as `--json '{"limit": 2}'`.
*/
const getJsonFlag = (args: ParsedArgs): boolean | string | undefined => {
const json: unknown = args['json']
if (json === 'true') return true
if (json === 'false') return false
if (typeof json === 'boolean' || typeof json === 'string') return json
return undefined
}

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

if (process.env['INSIDE_WEB_BROWSER'] !== '1') {
const { action } = await prompts({
if (process.env['INSIDE_WEB_BROWSER'] !== '1' && canPrompt()) {
const { action } = await prompt({
type: 'confirm',
name: 'action',
message: 'Would you like to open the webview in your browser?',
Expand All @@ -257,9 +345,32 @@ const handleConnectWebviewResponse = async (connect_webview: any) => {
}
}

cli(parseArgs(process.argv.slice(2), { string: ['code'] })).catch((e) => {
console.log(chalk.red(`CLI Error: ${e.toString()}\n${e.stack}`))
if (e.toString().includes('object Object')) {
console.log(e)
}
/**
* Whether to write machine readable output.
*
* Explicit `--json` or `--no-json` wins, otherwise the CLI writes JSON
* whenever stdout is piped or redirected, and pretty output at a terminal.
*/
const resolveOutputFormat = (args: ParsedArgs): OutputFormat => {
const json = getJsonFlag(args)
if (json === false) return 'text'
if (json !== undefined) return 'json'
return process.stdout.isTTY === true ? 'text' : 'json'
}

const args = parseArgs(process.argv.slice(2), { string: ['code'] })

setOutput(
createOutput({
format: resolveOutputFormat(args),
colors: process.stdout.isTTY === true,
}),
)

cli(args).catch((e: unknown) => {
const output = getOutput()
const error = e instanceof Error ? e : new Error(String(e))
output.error(chalk.red(`CLI Error: ${error.message}`))
if (error.stack != null) output.error(chalk.gray(error.stack))
process.exitCode = 1
})
25 changes: 25 additions & 0 deletions src/lib/get-response-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { getCommandBlueprintDef } from './get-command-blueprint-def.js'
import type { ContextHelpers } from './types.js'

/**
* The top level response key documented for a command,
* e.g., `devices` for `seam devices list`.
*
* Returns null when the command is not in the blueprint,
* or when it does not respond with a resource.
*/
export const getResponseKey = (
command: string[],
ctx: ContextHelpers,
): string | null => {
let endpoint
try {
endpoint = getCommandBlueprintDef(command, ctx)
} catch {
return null
}

if (endpoint.response.responseType === 'void') return null

return endpoint.response.responseKey
}
7 changes: 4 additions & 3 deletions src/lib/interact-for-action-attempt-poll.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { ActionAttemptsGetResponse } from '@seamapi/http/connect'
import prompts from 'prompts'

import { getSeam } from './get-seam.js'
import { getOutput } from './output/get-output.js'
import { prompt } from './util/prompt.js'
import { withLoading } from './util/with-loading.js'

export const interactForActionAttemptPoll = async (
action_attempt: ActionAttemptsGetResponse['action_attempt'],
) => {
if (action_attempt.status === 'pending') {
const { poll_for_action_attempt } = await prompts({
const { poll_for_action_attempt } = await prompt({
name: 'poll_for_action_attempt',
message: "Would you like to poll the action attempt until it's ready?",
type: 'toggle',
Expand All @@ -29,7 +30,7 @@ export const interactForActionAttemptPoll = async (
{ waitForActionAttempt: { pollingInterval: 240, timeout: 10_000 } },
),
)
console.dir(updated_action_attempt, { depth: null })
getOutput().data({ action_attempt: updated_action_attempt })
}
}
}
Loading
Loading