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
43 changes: 39 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,40 @@ 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.

### Selecting an endpoint and a workspace

Two settings say where commands go, and one command each stores them:

```bash
# Every later command runs against this endpoint
seam select endpoint https://connect.getseam.com

# ...and this workspace
seam select workspace $MY_WORKSPACE
```

Run either without a value to pick one interactively.

To send a single command somewhere else, pass `--endpoint` or
`--workspace-id` to that command. They override what is selected for that one
invocation and store nothing:

```bash
# List devices in another workspace, without switching to it
seam devices list --workspace-id $OTHER_WORKSPACE

# Run one command against a local Seam Connect instance
seam devices list --endpoint http://localhost:3020

# Log in to another endpoint: the token is stored for that endpoint,
# and the selected one is left alone
seam login --endpoint http://localhost:3020 --token $LOCAL_KEY
```

Because the two flags never store anything, they are refused on the commands
that do: `seam select endpoint --endpoint <url>` is an error, and the value
belongs after the command instead.

### Environment variables

Everything `seam login`, `seam select workspace`, and `seam select endpoint`
Expand All @@ -191,8 +225,9 @@ store may be given in the environment instead:
- `SEAM_CLI_ENDPOINT`: the Seam API endpoint requests are made to.

Any of them, all of them, or none of them may be set. Each one wins over the
corresponding stored value, which makes them useful for CI, for a single
command, or for working against another workspace in one shell.
corresponding stored value and is in turn overridden by `--endpoint` or
`--workspace-id`, which makes them useful for CI or for working against
another workspace for a whole shell.

```bash
# One command against another workspace
Expand All @@ -207,8 +242,8 @@ SEAM_CLI_ENDPOINT=http://localhost:3020 seam devices list
```

An API Key is scoped to a single workspace, so it needs no workspace id. A
Personal Access Token works across workspaces, so it needs one from either
`SEAM_CLI_WORKSPACE_ID` or `seam select workspace`.
Personal Access Token works across workspaces, so it needs one from
`--workspace-id`, `SEAM_CLI_WORKSPACE_ID`, or `seam select workspace`.

The command that would store an overridden value fails rather than storing
something the environment ignores: `seam login` and `seam logout` while
Expand Down
37 changes: 31 additions & 6 deletions src/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@ import {
cliFlags,
getInteractivity,
parseCliArgs,
toAuthOverrides,
toParameterName,
} from 'lib/args/parse.js'
import { assertKnownArgs } from 'lib/args/validate.js'
import { assertKnownArgs, assertNoAuthOverrides } from 'lib/args/validate.js'
import { getApiBlueprint } from 'lib/blueprint/index.js'
import { printCompletion } from 'lib/commands/local/completion.js'
import { runWizard } from 'lib/commands/local/wizard.js'
import {
acceptedParamsOf,
buildRegistry,
findLocalCommand,
findLocalCommandTakingPositional,
} from 'lib/commands/registry.js'
import { getConfigStore } from 'lib/config/index.js'
import { type CliContext, resolveAuth } from 'lib/context.js'
Expand All @@ -29,6 +31,7 @@ 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 { resolveOutputFormat } from 'lib/output/resolve-output-format.js'
import { setAuthOverrides } from 'lib/overrides.js'
import { canPrompt } from 'lib/prompt.js'
import {
completionShells,
Expand All @@ -41,6 +44,21 @@ async function cli(args: ParsedArgs, argv: string[]) {
const config = getConfigStore()
const output = getOutput()

// Scoped to this one command, and read wherever auth resolves, so they are
// in place before anything asks what the endpoint or the workspace is.
const authOverrides = toAuthOverrides(args)
setAuthOverrides(authOverrides)

// A command may take one value after its path, e.g., the URL in 'seam
// select endpoint <url>'. Split it off before the path is normalized, or
// lowercasing the path would rewrite the value along with it.
const commandWords = args._.map(toCommandWord)
const commandTakingPositional = findLocalCommandTakingPositional(commandWords)
const positional =
commandTakingPositional == null ? undefined : String(args._.at(-1))
args._ =
commandTakingPositional == null ? commandWords : commandWords.slice(0, -1)

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

const helpFlag = args['help'] ?? args['h']
Expand Down Expand Up @@ -75,8 +93,6 @@ async function cli(args: ParsedArgs, argv: string[]) {
return
}

args._ = args._.map(toCommandWord)

// Argument keys name parameters however they are written, so normalize each
// one to the name the API gives it. Replace the key rather than adding the
// normalized form alongside it, or an argument would be sent twice: once as
Expand Down Expand Up @@ -121,6 +137,12 @@ async function cli(args: ParsedArgs, argv: string[]) {

const localCommand = findLocalCommand(args._)

// Before the login gate, so a command that selects reports the flag it
// cannot take rather than whatever the flag pointed it at.
if (localCommand != null) {
assertNoAuthOverrides(localCommand.definition, authOverrides)
}

// Commands declared not to need a token bypass the login gate. A partial
// path keeps the historical rule: only login and select endpoint may be
// reached logged out.
Expand Down Expand Up @@ -185,13 +207,14 @@ async function cli(args: ParsedArgs, argv: string[]) {

// Check the arguments before the command acts on any of them, so a
// mistake is reported rather than half applied.
assertNoAuthOverrides(command.definition, authOverrides)
assertKnownArgs(argParams, selectedCommand, {
accepted: acceptedParamsOf(command.definition),
isLocal: findLocalCommand(selectedCommand) != null,
})

const result = await command.execute(
{ path: selectedCommand, argParams, stdinParams, args, argv },
{ path: selectedCommand, positional, argParams, stdinParams, args, argv },
ctx,
)

Expand All @@ -204,8 +227,10 @@ async function cli(args: ParsedArgs, argv: string[]) {
}
}

const toCommandWord = (arg: string): string =>
arg.toLowerCase().replace(/_/g, '-')
// minimist reads a numeric word as a number, so a command path is only a
// path once every word is one.
const toCommandWord = (arg: string | number): string =>
String(arg).toLowerCase().replace(/_/g, '-')

const run = async (argv: string[]) => {
if (argv[0] === 'wizard') {
Expand Down
49 changes: 47 additions & 2 deletions src/lib/args/parse.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import parseArgs, { type ParsedArgs } from 'minimist'

import type { AuthOverrides } from 'lib/overrides.js'

/**
* How the CLI should behave when properties are not given as arguments.
*
Expand Down Expand Up @@ -29,12 +31,14 @@ export const interactivityFlags: string[] = [
*/
export const cliFlags: string[] = [
...interactivityFlags,
'endpoint',
'h',
'help',
'json',
'remote_api_defs',
'update',
'version',
'workspace_id',
]

export interface ParseCliArgsOptions {
Expand All @@ -53,8 +57,18 @@ export const parseCliArgs = (
): ParsedArgs =>
parseArgs(argv, {
// A page cursor and a code are opaque even before the endpoint's own
// parameter types are known, so always keep them exactly as given.
string: ['code', 'page-cursor', 'page_cursor', ...stringKeys],
// parameter types are known, so always keep them exactly as given. The
// overrides are read as given for the same reason: a URL or an id is
// never a number, however it happens to be spelled.
string: [
'code',
'endpoint',
'page-cursor',
'page_cursor',
'workspace-id',
'workspace_id',
...stringKeys,
],
boolean: ['non-interactive', 'interactive', 'json'],
// Deliberately not aliased to -n, which is reserved for a future
// --dry-run flag.
Expand All @@ -76,6 +90,37 @@ export const toArgParams = (args: ParsedArgs): Record<string, unknown> => {
return argParams
}

/**
* The auth overrides among the parsed arguments.
*
* Both scope a single command: they change what it resolves to and are never
* stored, so they read like the environment variables they take precedence
* over, down to treating a blank value as though it were not given.
*/
export const toAuthOverrides = (args: ParsedArgs): AuthOverrides => {
// Read by the name each key names, as every other argument is, so the
// overrides may be written `--workspace-id`, `--workspace_id`, or in caps,
// and so they resolve whenever they are read.
const byName: Record<string, unknown> = {}
for (const [key, value] of Object.entries(args)) {
if (key === '_') continue
byName[toParameterName(key)] = value
}

return {
endpoint: readOverride(byName['endpoint']),
workspaceId: readOverride(byName['workspace_id']),
}
}

const readOverride = (value: unknown): string | null => {
if (typeof value !== 'string') return null

const trimmedValue = value.trim()

return trimmedValue === '' ? null : trimmedValue
}

export interface GetInteractivityOptions {
/**
* Whether there is a terminal to prompt on.
Expand Down
37 changes: 37 additions & 0 deletions src/lib/args/validate.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Parameter } from '@seamapi/blueprint'

import type { CommandDefinition } from 'lib/commands/spec.js'
import { NonInteractiveError, UsageError } from 'lib/errors.js'
import type { AuthOverrides } from 'lib/overrides.js'

import { toArgName, toGivenArgName } from './parse.js'

Expand Down Expand Up @@ -40,6 +42,41 @@ export const assertRequiredParams = (
* Only arguments are checked. Params read from stdin are passed through as
* given, so a caller may send whatever the API itself accepts.
*/
/**
* Refuse the auth overrides on a command that selects what they override.
*
* `--endpoint` and `--workspace-id` scope one command and are never stored,
* so on `seam select ...` they would read as the value to store and quietly
* do nothing of the kind. The positional is what stores.
*/
export const assertNoAuthOverrides = (
{ path, positional }: CommandDefinition,
overrides: AuthOverrides,
): void => {
if (path[0] !== 'select') return

const given = [
overrides.endpoint == null ? null : '--endpoint',
overrides.workspaceId == null ? null : '--workspace-id',
].filter((flag) => flag != null)

if (given.length === 0) return

const command = `seam ${path.join(' ')}`

throw new UsageError(
`${given.join(' and ')} cannot be used with ${command}: ${
given.length === 1 ? 'it overrides' : 'they override'
} a single command rather than changing what is selected.`,
{
hint:
positional == null
? `Run '${command}' to change what is selected.`
: `Run '${command} <${positional.name}>' to change what is selected.`,
},
)
}

export const assertKnownArgs = (
argParams: Record<string, unknown>,
command: string[],
Expand Down
42 changes: 13 additions & 29 deletions src/lib/auth/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,49 +44,33 @@ export const assertMutable = (
assertEnvVarUnset(envVar, value, action)
}

export interface LoginOptions {
endpoint?: string | undefined
token?: string | undefined
workspaceId?: string | undefined
}

/**
* Store the given credentials, validating the token first.
* Store a token, validating it first.
*
* The token is stored under the endpoint it will be used with, so a given
* endpoint is stored and re-resolved before the token key is derived.
* The token is stored under the endpoint it will be used with, which
* `--endpoint` or the environment may have pointed elsewhere for this one
* command: logging in to another endpoint stores a token for it without
* selecting it. The workspace is only what the token is validated against,
* as a Personal Access Token is meaningless without one.
*
* Validation reaches the network, so a test may inject its own `validate`.
*/
export const login = async (
{ endpoint, token, workspaceId }: LoginOptions,
token: string,
config: ConfigStore = getConfigStore(),
validate: typeof validateToken = validateToken,
): Promise<void> => {
let auth = resolveAuth(config)
const auth = resolveAuth(config)

// Nothing is stored while the environment overrides it, so refuse before
// storing anything rather than part way through.
// validating rather than after reaching the network.
assertMutable(auth, 'token', 'log in')
if (endpoint != null) assertMutable(auth, 'endpoint', 'select an endpoint')
if (workspaceId != null) {
assertMutable(auth, 'workspaceId', 'select a workspace')
}

if (endpoint != null) {
storeEndpoint(endpoint, config)
auth = resolveAuth(config)
}
await validate(token, auth.workspaceId ?? undefined)

if (token != null) {
await validate(token, workspaceId)
config.set(`${auth.endpoint}.pat`, token)
config.delete('current_workspace_id')
}

if (workspaceId != null) {
config.set('current_workspace_id', workspaceId)
}
config.set(`${auth.endpoint}.pat`, token)
// The selection belongs to whoever was logged in before.
config.delete('current_workspace_id')
}

/** Store the token for the current endpoint, e.g., one just prompted for. */
Expand Down
19 changes: 4 additions & 15 deletions src/lib/commands/local/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,13 @@ export const loginCommand: Command = {
kind: 'cli',
title: 'Log in to Seam.',
description:
'Prompts for a personal access token unless one is passed with --token.',
flags: [
stringFlag('endpoint', 'Seam API endpoint to log in to.'),
stringFlag('token', 'Personal access token to log in with.'),
stringFlag('workspace-id', 'Workspace to select after logging in.'),
],
'Prompts for a personal access token unless one is passed with --token. The token is stored for the selected endpoint, or for the one --endpoint names.',
flags: [stringFlag('token', 'Personal access token to log in with.')],
},
requiresAuth: false,
execute: async ({ args }, ctx) => {
if (args['token'] || args['workspace_id'] || args['endpoint']) {
await login(
{
endpoint: args['endpoint'] ? args['endpoint'] : undefined,
token: args['token'] ? String(args['token']).trim() : undefined,
workspaceId: args['workspace_id'] ? args['workspace_id'] : undefined,
},
ctx.config,
)
if (args['token']) {
await login(String(args['token']).trim(), ctx.config)
return { kind: 'done' }
}
assertMutable(ctx.auth, 'token', 'log in')
Expand Down
Loading
Loading