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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
# Build directories
package

# Shell completions generated on prepack
completions

# Environment versions file
.versions

Expand Down
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,60 @@ seam access-codes create --code "1234" --name "My Code"
seam access-codes list --device-id $MY_DOOR
```

## Help

Pass `--help` to any command to see what it accepts. Without a command, it
lists every top level command; with an incomplete command, it lists the
subcommands under it; with a full command, it documents that command's
options, marking the required ones.

```bash
# Every top level command
seam --help

# The commands under seam devices
seam devices --help

# The options accepted by seam devices list
seam devices list --help
```

## Shell completion

The CLI can print a completion script for bash, fish, and zsh that completes
commands, flags, and flag values such as device types.

Load completions into the current shell with

```bash
# bash
source <(seam completion bash)

# zsh
source <(seam completion zsh)
```

Install them for every shell with

```bash
# bash
seam completion bash > /usr/share/bash-completion/completions/seam

# fish
seam completion fish > ~/.config/fish/completions/seam.fish

# zsh
seam completion zsh > "${fpath[1]}/_seam"
```

The same scripts are packaged under `completions/` in the published package,
so a system package may install them without running the CLI.

Completions are generated from the API definitions bundled with the CLI. They
do not reflect definitions served by a Seam API server, so they may be out of
date when `seam config use-remote-api-defs` is enabled, and when running the
CLI from a source checkout with `npm run seam`.

## Development and Testing

### Quickstart
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"index.js.map",
"index.d.ts",
"bin",
"completions",
"lib",
"src",
"!test",
Expand Down
38 changes: 33 additions & 5 deletions prepack.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,58 @@
import { readFile, writeFile } from 'node:fs/promises'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'

import type { Blueprint } from '@seamapi/blueprint'
import { $ } from 'execa'

import getBlueprint from './src/lib/blueprint.js'
import {
completionFileNames,
completionShells,
renderCompletion,
} from './src/lib/completion/index.js'

const versionFile = './src/lib/version.ts'
const blueprintFile = './src/lib/blueprint.ts'
const completionsDirectory = './completions'

const main = async (): Promise<void> => {
const version = await injectVersion(resolveFile(versionFile))
// eslint-disable-next-line no-console
console.log(`✓ Version ${version} injected into ${versionFile}`)

await injectBlueprint(
resolveFile(blueprintFile),
await getBlueprint({ regenerate: true }),
)
const blueprint = await getBlueprint({ regenerate: true })

await injectBlueprint(resolveFile(blueprintFile), blueprint)
// eslint-disable-next-line no-console
console.log(`✓ Blueprint injected into ${blueprintFile}`)

await writeCompletions(resolveFile(completionsDirectory), blueprint)
// eslint-disable-next-line no-console
console.log(`✓ Shell completions written to ${completionsDirectory}`)

const { command } = await $`tsc --project tsconfig.prepack.json`
// eslint-disable-next-line no-console
console.log(`✓ Rebuilt with '${command}'`)
}

const writeCompletions = async (
path: string,
blueprint: Blueprint,
): Promise<void> => {
await mkdir(path, { recursive: true })

await Promise.all(
completionShells.map(async (shell) => {
await writeFile(
join(path, completionFileNames[shell]),
renderCompletion(shell, blueprint),
'utf8',
)
}),
)
}

const injectVersion = async (path: string): Promise<string> => {
const { version } = await readPackageJson()

Expand Down
97 changes: 48 additions & 49 deletions src/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ import { randomBytes } from 'node:crypto'
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 { getCommandSpec } from 'lib/command-spec.js'
import {
completionShells,
isCompletionShell,
renderCompletion,
} from 'lib/completion/index.js'
import { getApiBlueprint } from 'lib/get-api-blueprint.js'
import { getConfigStore } from 'lib/get-config-store.js'
import { getServer } from 'lib/get-server.js'
Expand All @@ -17,61 +22,37 @@ 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 { renderHelp } from 'lib/render-help.js'
import type { ContextHelpers } from 'lib/types.js'
import { RequestSeamApi } from 'lib/util/request-seam-api.js'
import { validateToken } from 'lib/validate-token.js'
import seamapiCliVersion from 'lib/version.js'

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 ',
},
{
header: 'Options',
optionList: [
{
name: 'help',
description: 'Display this help guide.',
alias: 'h',
type: Boolean,
},
],
},
{
header: 'Command List Examples',
content: [
{ name: 'seam', summary: 'Interactively select commands to execute.' },
{ name: 'seam login', summary: 'Login to Seam.' },
{ name: 'seam select workspace', summary: 'Select your workspace.' },
{
name: 'seam connect-webviews create',
summary: 'Create a connect webview to connect devices.',
},
{ name: 'seam devices list', summary: 'List devices in your workspace.' },
{
name: 'seam locks unlock-door {bold --device-id} $MY_DOOR',
summary: 'Unlock a lock.',
},
{
name: "seam access-codes create {bold --code} '1234' {bold --name} 'My Code'",
summary: 'Create an access code.',
},
{
name: 'seam access-codes list {bold --device-id} $MY_DOOR',
summary: 'List you access codes.',
},
],
},
]

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

if (args['help'] || args['h']) {
const usage = commandLineUsage(sections)
console.log(usage)
const helpFlag = args['help'] ?? args['h']
if (helpFlag != null) {
// Help comes from the bundled API definitions so that it works offline and
// without logging in.
const spec = getCommandSpec(await getApiBlueprint(false))

// minimist reads the word after --help as its value, so 'seam --help
// devices' asks about devices just as 'seam devices --help' does.
const commandPath = [
...args._,
...(typeof helpFlag === 'string' ? [helpFlag] : []),
].map(toCommandWord)

const help = renderHelp(commandPath, spec)

if (help == null) {
console.log(chalk.red(`Unknown command: seam ${commandPath.join(' ')}`))
console.log(`Run 'seam --help' to see the available commands.`)
process.exit(1)
}

console.log(help)
return
}

Expand All @@ -80,6 +61,21 @@ async function cli(args: ParsedArgs) {
process.exit(0)
}

if (args._[0] === 'completion') {
const shell = args._[1]

if (!isCompletionShell(shell)) {
console.log(`Usage: seam completion <${completionShells.join('|')}>`)
process.exit(1)
}

// Completions always come from the bundled API definitions so that they
// can be generated offline and without logging in. They may lag the
// definitions served by Seam when config use-remote-api-defs is enabled.
console.log(renderCompletion(shell, await getApiBlueprint(false)))
return
}

if (
args._[0] === 'config' &&
args._[1] === 'set' &&
Expand All @@ -105,7 +101,7 @@ async function cli(args: ParsedArgs) {
process.exit(1)
}

args._ = args._.map((arg) => arg.toLowerCase().replace(/_/g, '-'))
args._ = args._.map(toCommandWord)
for (const k in args) {
args[k.toLowerCase().replace(/-/g, '_')] = args[k]
}
Expand Down Expand Up @@ -240,6 +236,9 @@ async function cli(args: ParsedArgs) {
}
}

const toCommandWord = (arg: string): string =>
arg.toLowerCase().replace(/_/g, '-')

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

Expand Down
111 changes: 111 additions & 0 deletions src/lib/command-spec.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { expect, test } from 'vitest'

import { testBlueprint } from '../../test/fixtures/blueprint.js'
import {
findCommand,
findGroup,
firstSentence,
getCommandSpec,
toPlainText,
} from './command-spec.js'

const spec = getCommandSpec(testBlueprint)

test('command spec: derives commands from endpoint paths', () => {
expect(findCommand(spec, ['devices', 'list'])?.title).toBe('List Devices')
expect(findCommand(spec, ['devices', 'list'])?.description).toBe(
'Returns a list of all devices. Results are paginated.',
)
})

test('command spec: falls back to the first sentence for an untitled endpoint', () => {
expect(findCommand(spec, ['devices', 'unmanaged', 'get'])?.title).toBe(
'Gets an unmanaged device.',
)
})

test('command spec: includes commands handled by the CLI itself', () => {
expect(findCommand(spec, ['login'])?.flags.map(({ long }) => long)).toEqual([
'server',
'token',
'workspace-id',
])
expect(findCommand(spec, ['select', 'workspace'])).toBeDefined()
expect(findCommand(spec, ['completion', 'zsh'])).toBeDefined()
})

test('command spec: turns parameters into kebab-case flags', () => {
expect(
findCommand(spec, ['devices', 'list'])?.flags.map(({ long }) => long),
).toEqual(['device-type', 'is-managed', 'limit'])
})

test('command spec: carries whether a flag is required', () => {
expect(
findCommand(spec, ['devices', 'unmanaged', 'get'])?.flags,
).toMatchObject([{ long: 'device-id', isRequired: true }])
expect(
findCommand(spec, ['devices', 'list'])?.flags.map(
({ isRequired }) => isRequired,
),
).toEqual([false, false, false])
})

test('command spec: collects values for enum and boolean flags', () => {
const flags = findCommand(spec, ['devices', 'list'])?.flags ?? []
expect(flags.find(({ long }) => long === 'device-type')?.values).toEqual([
'august_lock',
'schlage_lock',
])
expect(flags.find(({ long }) => long === 'is-managed')?.values).toEqual([
'true',
'false',
])
expect(flags.find(({ long }) => long === 'limit')?.values).toEqual([])
})

test('command spec: groups every incomplete command path', () => {
expect(findGroup(spec, [])?.subcommands.map(({ name }) => name)).toContain(
'devices',
)
expect(
findGroup(spec, ['devices'])?.subcommands.map(({ name }) => name),
).toEqual(['list', 'unmanaged'])
expect(findGroup(spec, ['devices', 'unmanaged'])?.subcommands).toEqual([
{ name: 'get', description: 'Gets an unmanaged device.' },
])
})

test('command spec: names the commands a group holds', () => {
const subcommands = findGroup(spec, [])?.subcommands ?? []
expect(subcommands.find(({ name }) => name === 'devices')?.description).toBe(
'list, unmanaged',
)
expect(
subcommands.find(({ name }) => name === 'completion')?.description,
).toBe('bash, fish, zsh')
})

test('command spec: a command path is either a command or a group', () => {
expect(findGroup(spec, ['devices', 'list'])).toBeUndefined()
expect(findCommand(spec, ['devices'])).toBeUndefined()
expect(findCommand(spec, ['nope'])).toBeUndefined()
expect(findGroup(spec, ['nope'])).toBeUndefined()
})

test('toPlainText: reduces markdown to one line', () => {
expect(toPlainText('Returns all [devices](https://docs.seam.co).')).toBe(
'Returns all devices.',
)
expect(toPlainText('Uses `code`\nand **bold**.')).toBe('Uses code and bold.')
expect(toPlainText("Keeps the device's colon: intact.")).toBe(
"Keeps the device's colon: intact.",
)
})

test('firstSentence: stops at the first sentence break', () => {
expect(firstSentence('First sentence. Second sentence.')).toBe(
'First sentence.',
)
expect(firstSentence('No break here')).toBe('No break here')
})
Loading