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
7 changes: 5 additions & 2 deletions .github/workflows/_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,12 @@ jobs:
fi
bun build src/bin/cli.ts --compile --minify --target="bun-${platform}" --outfile="$binary"
done
- name: Generate standalone binary checksums
- name: Add shell completion loaders
# Generated by prepack during npm pack.
run: cp completions/seam.bash completions/seam.fish completions/seam.zsh release/
- name: Generate checksums
working-directory: release
run: sha256sum seam-* > checksums.txt
run: sha256sum seam* > checksums.txt
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
Expand Down
13 changes: 10 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
prerelease: ${{ contains(github.ref_name, '-') }}
files: |
*.tgz
release/seam-*
release/seam*
release/checksums.txt
body_path: ${{ github.workspace }}/${{ steps.changelog.outputs.outfile }}
npm:
Expand Down Expand Up @@ -108,18 +108,25 @@ jobs:
url="${url}"
license=('MIT')
depends=('glibc' 'gcc-libs')
optdepends=('bash-completion: completions for bash')
provides=('seam')
conflicts=('seam')
options=('!strip' '!debug')
source=("\${url}/raw/v\${pkgver}/LICENSE.txt")
source=("\${url}/raw/v\${pkgver}/LICENSE.txt"
"seam-\${pkgver}.bash::\${url}/releases/download/v\${pkgver}/seam.bash"
"seam-\${pkgver}.fish::\${url}/releases/download/v\${pkgver}/seam.fish"
"seam-\${pkgver}.zsh::\${url}/releases/download/v\${pkgver}/seam.zsh")
source_x86_64=("\${pkgname}-\${pkgver}-x86_64::\${url}/releases/download/v\${pkgver}/seam-v\${pkgver}-linux-x64")
source_aarch64=("\${pkgname}-\${pkgver}-aarch64::\${url}/releases/download/v\${pkgver}/seam-v\${pkgver}-linux-arm64")
sha256sums=('SKIP')
sha256sums=('SKIP' 'SKIP' 'SKIP' 'SKIP')
sha256sums_x86_64=('SKIP')
sha256sums_aarch64=('SKIP')

package() {
install -Dm755 "\${pkgname}-\${pkgver}-\${CARCH}" "\${pkgdir}/usr/bin/seam"
install -Dm644 "seam-\${pkgver}.bash" "\${pkgdir}/usr/share/bash-completion/completions/seam"
install -Dm644 "seam-\${pkgver}.fish" "\${pkgdir}/usr/share/fish/vendor_completions.d/seam.fish"
install -Dm644 "seam-\${pkgver}.zsh" "\${pkgdir}/usr/share/zsh/site-functions/_seam"
install -Dm644 LICENSE.txt "\${pkgdir}/usr/share/licenses/\${pkgname}/LICENSE"
}
PKGBUILD
Expand Down
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
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,66 @@ Missing required parameter for /locks/unlock_door: --device-id
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.

## 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"
```

System packages install completion loaders instead: small scripts packaged
under `completions/` in the published package and attached to each
[GitHub release]. A loader runs `seam completion` the first time the shell
completes a seam command, so installed completions always match the CLI's
current Seam API definitions and never go stale between package updates. The
`seam-bin` AUR package installs the loaders for all three shells.

Completions are generated from the cached Seam API definitions, so they may
briefly lag a newly released API. Pass `--update` to refresh the cache first,
e.g., `seam completion bash --update`. They do not reflect definitions served
by another Seam API server when `seam config use-remote-api-defs` is enabled.

[GitHub release]: https://github.com/seamapi/cli/releases/latest

## 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
28 changes: 27 additions & 1 deletion prepack.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
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 { $ } from 'execa'

import {
completionFileNames,
completionShells,
renderCompletionStub,
} from './src/lib/completion/index.js'

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

const main = async (): Promise<void> => {
const version = await injectVersion(resolveFile(versionFile))
Expand All @@ -18,11 +26,29 @@ const main = async (): Promise<void> => {
`✓ Blueprint version ${blueprintVersion} injected into ${versionFile}`,
)

await writeCompletions(resolveFile(completionsDirectory))
// eslint-disable-next-line no-console
console.log(`✓ Shell completion loaders 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): Promise<void> => {
await mkdir(path, { recursive: true })

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

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

Expand Down
158 changes: 54 additions & 104 deletions src/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ import { randomBytes } from 'node:crypto'
import { isDeepStrictEqual as isEqual } from 'node:util'

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

import { getCommandSpec } from 'lib/command-spec.js'
import {
completionShells,
isCompletionShell,
renderCompletion,
} from 'lib/completion/index.js'
import { getConfigStore } from 'lib/config/index.js'
import { getApiBlueprint } from 'lib/get-api-blueprint.js'
import { getResponseKey } from 'lib/get-response-key.js'
Expand All @@ -20,6 +25,7 @@ import { interactForWorkspaceId } from 'lib/interact-for-workspace-id.js'
import { createOutput } from 'lib/output/create-output.js'
import { getOutput, setOutput } from 'lib/output/get-output.js'
import { resolveOutputFormat } from 'lib/output/resolve-output-format.js'
import { renderHelp } from 'lib/render-help.js'
import type { ContextHelpers } from 'lib/types.js'
import {
cliFlags,
Expand All @@ -34,109 +40,35 @@ 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 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',
optionList: [
{
name: 'help',
description: 'Display this help guide.',
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,
},
{
name: 'json',
description:
'Write the response to stdout as JSON. Enabled automatically when stdout is not a terminal, disable with {bold --no-json}.',
type: Boolean,
},
{
name: 'update',
description: 'Force an update of the cached Seam API definitions.',
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.',
'Request params may be piped or redirected in as a JSON object. Params given as arguments win over params read from stdin.',
],
},
{
header: 'Command List Examples',
content: [
{ name: 'seam', summary: 'Interactively select commands to execute.' },
{ name: 'seam login', summary: 'Login to Seam.' },
{
name: 'seam wizard',
summary: 'Set up Seam in the current project.',
},
{ 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 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.',
},
{
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.',
},
{
name: 'seam devices list > devices.json',
summary: 'Write the response to a file as JSON.',
},
{
name: 'cat params.json | seam locks unlock-door',
summary: 'Pipe request params in as JSON.',
},
],
},
]

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

if (args['help'] || args['h']) {
output.text(commandLineUsage(sections))
const update = args['update'] === true

const helpFlag = args['help'] ?? args['h']
if (helpFlag != null) {
// Help comes from the cached API definitions so that it works without
// logging in, and offline once the cache is warm.
const spec = getCommandSpec(await getApiBlueprint(false, { update }))

// 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) {
output.error(chalk.red(`Unknown command: seam ${commandPath.join(' ')}`))
output.error(`Run 'seam --help' to see the available commands.`)
process.exitCode = 1
return
}

output.text(help)
return
}

Expand All @@ -145,6 +77,24 @@ async function cli(args: ParsedArgs) {
return
}

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

if (!isCompletionShell(shell)) {
output.error(`Usage: seam completion <${completionShells.join('|')}>`)
process.exitCode = 1
return
}

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

if (
args._[0] === 'config' &&
args._[1] === 'set' &&
Expand All @@ -171,17 +121,14 @@ async function cli(args: ParsedArgs) {
return
}

args._ = args._.map((arg) => arg.toLowerCase().replace(/_/g, '-'))
args._ = args._.map(toCommandWord)
for (const k in args) {
args[k.toLowerCase().replace(/-/g, '_')] = args[k]
}

const use_remote_api_defs =
args['remote_api_defs'] ?? config.get('use_remote_api_defs')

const update = args['update'] === true
delete args['update']

const blueprint = await getApiBlueprint(use_remote_api_defs ?? false, {
update,
})
Expand Down Expand Up @@ -336,6 +283,9 @@ async function cli(args: ParsedArgs) {
}
}

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

const handleConnectWebviewResponse = async (
connect_webview: any,
interactivity: Interactivity,
Expand Down
Loading
Loading