diff --git a/README.md b/README.md index 2e6a1908..456e815a 100644 --- a/README.md +++ b/README.md @@ -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 ` is an error, and the value +belongs after the command instead. + ### Environment variables Everything `seam login`, `seam select workspace`, and `seam select endpoint` @@ -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 @@ -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 diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 4a156515..f19dd77c 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -8,9 +8,10 @@ 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' @@ -18,6 +19,7 @@ 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' @@ -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, @@ -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 '. 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'] @@ -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 @@ -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. @@ -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, ) @@ -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') { diff --git a/src/lib/args/parse.ts b/src/lib/args/parse.ts index 5fc85dfa..eb818acc 100644 --- a/src/lib/args/parse.ts +++ b/src/lib/args/parse.ts @@ -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. * @@ -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 { @@ -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. @@ -76,6 +90,37 @@ export const toArgParams = (args: ParsedArgs): Record => { 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 = {} + 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. diff --git a/src/lib/args/validate.ts b/src/lib/args/validate.ts index 9221d670..0210cc68 100644 --- a/src/lib/args/validate.ts +++ b/src/lib/args/validate.ts @@ -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' @@ -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, command: string[], diff --git a/src/lib/auth/operations.ts b/src/lib/auth/operations.ts index 795dd6ba..85e28dfd 100644 --- a/src/lib/auth/operations.ts +++ b/src/lib/auth/operations.ts @@ -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 => { - 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. */ diff --git a/src/lib/commands/local/login.ts b/src/lib/commands/local/login.ts index e1cb7bba..027a9d96 100644 --- a/src/lib/commands/local/login.ts +++ b/src/lib/commands/local/login.ts @@ -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') diff --git a/src/lib/commands/local/select-endpoint.ts b/src/lib/commands/local/select-endpoint.ts index 8f2b38eb..7a65015a 100644 --- a/src/lib/commands/local/select-endpoint.ts +++ b/src/lib/commands/local/select-endpoint.ts @@ -1,6 +1,5 @@ import { assertMutable, selectEndpoint } from 'lib/auth/operations.js' import type { Command } from 'lib/commands/registry.js' -import { stringFlag } from 'lib/commands/spec.js' import { NonInteractiveError } from 'lib/errors.js' import { interactForEndpointSelection } from 'lib/interactions/index.js' @@ -9,19 +8,24 @@ export const selectEndpointCommand: Command = { path: ['select', 'endpoint'], kind: 'cli', title: 'Select the Seam API endpoint.', - description: '', - flags: [stringFlag('endpoint', 'Seam API endpoint to select.')], + description: + 'Stores the endpoint every later command runs against. To use one for a single command instead, pass --endpoint to that command.', + flags: [], + positional: { + name: 'url', + description: 'Seam API endpoint to select.', + }, }, requiresAuth: false, - execute: async ({ args }, ctx) => { + execute: async ({ positional }, ctx) => { assertMutable(ctx.auth, 'endpoint', 'select an endpoint') - if (args['endpoint']) { - selectEndpoint(args['endpoint'], ctx.config) + if (positional != null) { + selectEndpoint(positional, ctx.config) return { kind: 'done' } } if (ctx.interactivity === 'non-interactive') { throw new NonInteractiveError( - 'Missing required parameter for select endpoint: --endpoint', + 'Missing required argument for select endpoint: ', ) } await interactForEndpointSelection() diff --git a/src/lib/commands/local/select-workspace.ts b/src/lib/commands/local/select-workspace.ts index 237ceb64..a7570b28 100644 --- a/src/lib/commands/local/select-workspace.ts +++ b/src/lib/commands/local/select-workspace.ts @@ -1,4 +1,4 @@ -import { assertMutable } from 'lib/auth/operations.js' +import { assertMutable, selectWorkspace } from 'lib/auth/operations.js' import type { Command } from 'lib/commands/registry.js' import { NonInteractiveError } from 'lib/errors.js' import { interactForWorkspaceId } from 'lib/interactions/index.js' @@ -8,15 +8,24 @@ export const selectWorkspaceCommand: Command = { path: ['select', 'workspace'], kind: 'cli', title: 'Select the current workspace.', - description: '', + description: + 'Stores the workspace every later command runs against. To use one for a single command instead, pass --workspace-id to that command.', flags: [], + positional: { + name: 'workspace-id', + description: 'Workspace to select.', + }, }, requiresAuth: true, - execute: async (_invocation, ctx) => { + execute: async ({ positional }, ctx) => { assertMutable(ctx.auth, 'workspaceId', 'select a workspace') + if (positional != null) { + selectWorkspace(positional, ctx.config) + return { kind: 'done' } + } if (ctx.interactivity === 'non-interactive') { throw new NonInteractiveError( - 'Cannot select a workspace in non-interactive mode: pass --workspace-id to "seam login"', + 'Missing required argument for select workspace: ', ) } await interactForWorkspaceId() diff --git a/src/lib/commands/registry.ts b/src/lib/commands/registry.ts index 750eb01c..30899396 100644 --- a/src/lib/commands/registry.ts +++ b/src/lib/commands/registry.ts @@ -38,6 +38,8 @@ export interface Command { /** Everything a single run of a command was given. */ export interface Invocation { path: string[] + /** The value written after the command path, when the command takes one. */ + positional?: string | undefined /** Params given as arguments, held to what the command accepts. */ argParams: Record /** Params piped in as JSON, passed through as given. */ @@ -93,6 +95,24 @@ export const localCommandDefinitions: CommandDefinition[] = localCommands export const findLocalCommand = (path: string[]): Command | undefined => localCommands.find((command) => isSamePath(command.definition.path, path)) +/** + * The local command the given words invoke with a value after its path, e.g., + * `select endpoint` for `select endpoint https://connect.getseam.com`, or + * `undefined` when the words are not a command taking one. + * + * Only commands declaring a positional match, so a stray word after any other + * command stays what it has always been: no command at all. + */ +export const findLocalCommandTakingPositional = ( + words: string[], +): Command | undefined => + localCommands.find( + ({ definition }) => + definition.positional != null && + words.length === definition.path.length + 1 && + isSamePath(definition.path, words.slice(0, -1)), + ) + /** Parameter names a command accepts as arguments. */ export const acceptedParamsOf = (definition: CommandDefinition): Set => new Set( diff --git a/src/lib/commands/spec.ts b/src/lib/commands/spec.ts index 0e67828f..2aa0f949 100644 --- a/src/lib/commands/spec.ts +++ b/src/lib/commands/spec.ts @@ -23,6 +23,17 @@ export interface CommandFlag { */ export type CommandKind = 'cli' | 'api' +/** + * A value written after the command rather than behind a flag, e.g., the URL + * in `seam select endpoint `. At most one, always required: it is the + * one thing the command is about. + */ +export interface CommandPositional { + /** Name shown in the usage line, without the angle brackets. */ + name: string + description: string +} + export interface CommandDefinition { path: string[] kind: CommandKind @@ -31,6 +42,8 @@ export interface CommandDefinition { /** Longer prose about the command, empty when there is none to add. */ description: string flags: CommandFlag[] + /** The value the command takes after its path, when it takes one. */ + positional?: CommandPositional } export interface Subcommand { @@ -56,6 +69,15 @@ export interface CommandSpec { } export const globalFlags: CommandFlag[] = [ + { + long: 'endpoint', + short: null, + description: + 'Seam API endpoint to run this one command against, instead of the selected one.', + values: [], + takesValue: true, + isRequired: false, + }, { long: 'help', short: 'h', @@ -115,6 +137,15 @@ export const globalFlags: CommandFlag[] = [ takesValue: false, isRequired: false, }, + { + long: 'workspace-id', + short: null, + description: + 'Workspace to run this one command against, instead of the selected one.', + values: [], + takesValue: true, + isRequired: false, + }, ] export const flagTokens = (flag: CommandFlag): string[] => { diff --git a/src/lib/context.ts b/src/lib/context.ts index 94e25dae..4c959291 100644 --- a/src/lib/context.ts +++ b/src/lib/context.ts @@ -8,18 +8,20 @@ import { } from './env.js' import type { SeamApi } from './http/api.js' import type { Output } from './output/output.js' +import { getAuthOverrides } from './overrides.js' export const defaultEndpoint = 'https://connect.getseam.com' /** Where a resolved value came from, e.g., to refuse writes the env shadows. */ -export type ValueSource = 'env' | 'config' | 'default' +export type ValueSource = 'flag' | 'env' | 'config' | 'default' /** * The endpoint, token, and workspace requests are made with. * - * Resolved in one place so the precedence rule exists once: an environment - * variable wins over the stored value, and the endpoint falls back to Seam. - * The source tags say where each value came from. + * Resolved in one place so the precedence rule exists once: a flag given for + * the one command wins over an environment variable, which wins over the + * stored value, and the endpoint falls back to Seam. The source tags say + * where each value came from. */ export interface AuthContext { endpoint: string @@ -33,35 +35,51 @@ export interface AuthContext { export const resolveAuth = ( config: ConfigStore = getConfigStore(), ): AuthContext => { + const { endpoint: flagEndpoint, workspaceId: flagWorkspaceId } = + getAuthOverrides() + const envEndpoint = getEndpointFromEnv() // Configs written before the endpoint was called one still hold it under // `server`, so fall back to that key rather than silently resetting them. const storedEndpoint = config.get('endpoint') ?? config.get('server') const endpoint = - envEndpoint ?? (typeof storedEndpoint === 'string' ? storedEndpoint : null) + flagEndpoint ?? + envEndpoint ?? + (typeof storedEndpoint === 'string' ? storedEndpoint : null) const envToken = getTokenFromEnv() + // The token is stored per endpoint, so an overridden endpoint is read with + // the token belonging to it rather than the one it replaced. const storedToken = readString( config.get(`${endpoint ?? defaultEndpoint}.pat`), ) const envWorkspaceId = getWorkspaceIdFromEnv() const storedWorkspaceId = readString(config.get('current_workspace_id')) + const workspaceId = flagWorkspaceId ?? envWorkspaceId ?? storedWorkspaceId return { endpoint: endpoint ?? defaultEndpoint, endpointSource: - envEndpoint != null ? 'env' : endpoint != null ? 'config' : 'default', + flagEndpoint != null + ? 'flag' + : envEndpoint != null + ? 'env' + : endpoint != null + ? 'config' + : 'default', token: envToken ?? storedToken, tokenSource: envToken != null ? 'env' : storedToken != null ? 'config' : null, - workspaceId: envWorkspaceId ?? storedWorkspaceId, + workspaceId, workspaceIdSource: - envWorkspaceId != null - ? 'env' - : storedWorkspaceId != null - ? 'config' - : null, + flagWorkspaceId != null + ? 'flag' + : envWorkspaceId != null + ? 'env' + : storedWorkspaceId != null + ? 'config' + : null, } } diff --git a/src/lib/overrides.ts b/src/lib/overrides.ts new file mode 100644 index 00000000..3903d7de --- /dev/null +++ b/src/lib/overrides.ts @@ -0,0 +1,32 @@ +/** + * The endpoint and workspace may be overridden for a single command. + * + * `--endpoint` and `--workspace-id` change what one invocation resolves to + * and are never stored: only `seam select endpoint` and `seam select + * workspace` write those settings. They are held here rather than threaded + * through every call because auth resolves ambiently, exactly as the + * environment variables they shadow do (see `env.ts`). + * + * Set once from the parsed arguments before anything resolves auth, and + * reset between tests. + */ + +export interface AuthOverrides { + endpoint: string | null + workspaceId: string | null +} + +const noOverrides: AuthOverrides = { endpoint: null, workspaceId: null } + +let overrides: AuthOverrides = noOverrides + +export const getAuthOverrides = (): AuthOverrides => overrides + +export const setAuthOverrides = (next: AuthOverrides): void => { + overrides = next +} + +/** Drop the overrides, e.g., between tests sharing a process. */ +export const resetAuthOverrides = (): void => { + overrides = noOverrides +} diff --git a/src/lib/render/help.ts b/src/lib/render/help.ts index 7228f58d..a93910ac 100644 --- a/src/lib/render/help.ts +++ b/src/lib/render/help.ts @@ -131,6 +131,7 @@ const commandSections = ( ): Section[] => { const name = ['seam', ...command.path].join(' ') const hasFlags = command.flags.length > 0 + const { positional } = command return [ { @@ -139,7 +140,26 @@ const commandSections = ( (line) => line !== '', ), }, - { header: 'Usage', content: `${name} [options]` }, + { + header: 'Usage', + content: + positional == null + ? `${name} [options]` + : `${name} <${positional.name}> [options]`, + }, + ...(positional == null + ? [] + : [ + { + header: 'Arguments', + content: [ + { + name: `{underline <${positional.name}>}`, + summary: positional.description, + }, + ], + }, + ]), // The command's own parameters are what the request is made of, so keep // them apart from the options every seam command takes. ...(hasFlags ? [optionSection(command.flags, 'Parameters')] : []), diff --git a/test/auth/operations.test.ts b/test/auth/operations.test.ts index adebde84..f04b2de9 100644 --- a/test/auth/operations.test.ts +++ b/test/auth/operations.test.ts @@ -9,6 +9,7 @@ import { } from 'lib/auth/operations.js' import { createMemoryConfigStore } from 'lib/config/memory-config-store.js' import { endpointEnvVar, tokenEnvVar, workspaceIdEnvVar } from 'lib/env.js' +import { resetAuthOverrides, setAuthOverrides } from 'lib/overrides.js' const endpoint = 'https://connect.example.com' @@ -34,6 +35,7 @@ const clearEnv = (): void => { delete process.env[endpointEnvVar] delete process.env[tokenEnvVar] delete process.env[workspaceIdEnvVar] + resetAuthOverrides() } beforeEach(clearEnv) @@ -43,7 +45,7 @@ test('login: stores a validated token under the current endpoint', async () => { const store = createMemoryConfigStore({ endpoint }) const { validate, validated } = createValidate() - await login({ token: 'seam_apikey1_stored' }, store, validate) + await login('seam_apikey1_stored', store, validate) expect(validated).toEqual([ { token: 'seam_apikey1_stored', workspaceId: undefined }, @@ -51,18 +53,16 @@ test('login: stores a validated token under the current endpoint', async () => { expect(store.get(`${endpoint}.pat`)).toBe('seam_apikey1_stored') }) -test('login: stores the token under an endpoint given alongside it', async () => { +test('login: stores the token under an overridden endpoint without selecting it', async () => { + setAuthOverrides({ endpoint: 'https://other.example.com', workspaceId: null }) const store = createMemoryConfigStore({ endpoint }) const { validate } = createValidate() - await login( - { endpoint: 'https://other.example.com', token: 'seam_apikey1_stored' }, - store, - validate, - ) + await login('seam_apikey1_stored', store, validate) - expect(store.get('endpoint')).toBe('https://other.example.com') expect(store.get('https://other.example.com.pat')).toBe('seam_apikey1_stored') + // The override scopes the command: the selection is left as it was. + expect(store.get('endpoint')).toBe(endpoint) expect(store.has(`${endpoint}.pat`)).toBe(false) }) @@ -73,25 +73,22 @@ test('login: a new login clears the previous workspace selection', async () => { }) const { validate } = createValidate() - await login({ token: 'seam_apikey1_stored' }, store, validate) + await login('seam_apikey1_stored', store, validate) expect(store.has('current_workspace_id')).toBe(false) }) -test('login: stores a workspace given with the token', async () => { +test('login: validates against the workspace in effect without storing it', async () => { + setAuthOverrides({ endpoint: null, workspaceId: 'workspace1' }) const store = createMemoryConfigStore({ endpoint }) const { validate, validated } = createValidate() - await login( - { token: 'seam_at1_stored', workspaceId: 'workspace1' }, - store, - validate, - ) + await login('seam_at1_stored', store, validate) expect(validated).toEqual([ { token: 'seam_at1_stored', workspaceId: 'workspace1' }, ]) - expect(store.get('current_workspace_id')).toBe('workspace1') + expect(store.has('current_workspace_id')).toBe(false) }) test(`login: refuses while ${tokenEnvVar} is set, before storing anything`, async () => { @@ -99,37 +96,22 @@ test(`login: refuses while ${tokenEnvVar} is set, before storing anything`, asyn const store = createMemoryConfigStore({ endpoint }) const { validate, validated } = createValidate() - await expect( - login({ token: 'seam_apikey1_stored' }, store, validate), - ).rejects.toThrow(`Cannot log in while ${tokenEnvVar} is set`) + await expect(login('seam_apikey1_stored', store, validate)).rejects.toThrow( + `Cannot log in while ${tokenEnvVar} is set`, + ) expect(store.has(`${endpoint}.pat`)).toBe(false) expect(validated).toEqual([]) }) -test(`login: refuses an endpoint while ${endpointEnvVar} is set`, async () => { - process.env[endpointEnvVar] = endpoint - const store = createMemoryConfigStore() - const { validate } = createValidate() - - await expect( - login({ endpoint: 'https://other.example.com' }, store, validate), - ).rejects.toThrow(`Cannot select an endpoint while ${endpointEnvVar} is set`) -}) - -test(`login: refuses a workspace while ${workspaceIdEnvVar} is set`, async () => { - process.env[workspaceIdEnvVar] = 'workspace_env' +test(`login: stores under the endpoint ${endpointEnvVar} names`, async () => { + process.env[endpointEnvVar] = 'https://other.example.com' const store = createMemoryConfigStore({ endpoint }) const { validate } = createValidate() - await expect( - login( - { token: 'seam_at1_stored', workspaceId: 'workspace1' }, - store, - validate, - ), - ).rejects.toThrow( - `Cannot select a workspace while ${workspaceIdEnvVar} is set`, - ) + await login('seam_apikey1_stored', store, validate) + + expect(store.get('https://other.example.com.pat')).toBe('seam_apikey1_stored') + expect(store.get('endpoint')).toBe(endpoint) }) test('storeToken: stores under the current endpoint without validating', () => { diff --git a/test/cli.test.ts b/test/cli.test.ts index 55df0042..d6bc24da 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -547,6 +547,82 @@ test('cli: uses the stored endpoint without SEAM_CLI_ENDPOINT', async () => { expect(requests).toHaveLength(0) }) +test('cli: --endpoint scopes one command without storing it', async () => { + requests = [] + const settingsFile = join(otherEndpointConfigHome, 'seam', 'cli.json') + const before = await readFile(settingsFile, 'utf8') + + const { exitCode } = await runCli( + ['devices', 'list', '--endpoint', endpoint], + { configHome: otherEndpointConfigHome }, + ) + + expect(exitCode).toBe(0) + expect(requests[0]?.path).toBe('/devices/list') + expect(await readFile(settingsFile, 'utf8')).toBe(before) +}) + +test('cli: --endpoint wins over SEAM_CLI_ENDPOINT', async () => { + requests = [] + const { exitCode } = await runCli( + ['devices', 'list', '--endpoint', endpoint], + { + configHome: otherEndpointConfigHome, + // Nothing is listening here: reaching the fake proves the flag won. + env: { SEAM_CLI_ENDPOINT: 'http://localhost:1' }, + }, + ) + + expect(exitCode).toBe(0) + expect(requests[0]?.path).toBe('/devices/list') +}) + +test('cli: --workspace-id scopes one command without storing it', async () => { + requests = [] + // A Personal Access Token is what carries a workspace on the wire. + const { exitCode } = await runCli( + ['devices', 'list', '--workspace-id', 'workspace_from_flag'], + { env: { SEAM_CLI_TOKEN: 'seam_at1_from_env' } }, + ) + + expect(exitCode).toBe(0) + expect(requests[0]?.headers['seam-workspace']).toBe('workspace_from_flag') + expect(await readStoredState(stateHome)).not.toHaveProperty( + 'current_workspace_id', + ) +}) + +test('cli: --workspace-id wins over SEAM_CLI_WORKSPACE_ID', async () => { + requests = [] + const { exitCode } = await runCli( + ['devices', 'list', '--workspace-id', 'workspace_from_flag'], + { + env: { + SEAM_CLI_TOKEN: 'seam_at1_from_env', + SEAM_CLI_WORKSPACE_ID: 'workspace_from_env', + }, + }, + ) + + expect(exitCode).toBe(0) + expect(requests[0]?.headers['seam-workspace']).toBe('workspace_from_flag') +}) + +test('cli: --endpoint is not sent to the API as a parameter', async () => { + requests = [] + const { exitCode } = await runCli([ + 'devices', + 'list', + '--endpoint', + endpoint, + '--limit', + '3', + ]) + + expect(exitCode).toBe(0) + expect(requests[0]?.body).toEqual({ limit: 3 }) +}) + test('cli: refuses to log in while SEAM_CLI_TOKEN is set', async () => { const { stdout, stderr, exitCode } = await runCli( ['login', '--token', 'seam_apikey1_stored'], @@ -571,29 +647,81 @@ test('cli: refuses to select a workspace while SEAM_CLI_WORKSPACE_ID is set', as ) }) -test('cli: refuses to log in with a workspace while SEAM_CLI_WORKSPACE_ID is set', async () => { - const { stderr, exitCode } = await runCli( - ['login', '--token', 'seam_apikey1_stored', '--workspace-id', 'workspace1'], - { env: { SEAM_CLI_WORKSPACE_ID: 'workspace_from_env' } }, +test('cli: refuses to select an endpoint while SEAM_CLI_ENDPOINT is set', async () => { + const { stdout, stderr, exitCode } = await runCli( + ['select', 'endpoint', 'https://connect.example.com'], + { env: { SEAM_CLI_ENDPOINT: endpoint } }, ) expect(exitCode).toBe(1) + expect(stdout).toBe('') expect(stderr).toContain( - 'Cannot select a workspace while SEAM_CLI_WORKSPACE_ID is set', + 'Cannot select an endpoint while SEAM_CLI_ENDPOINT is set', ) }) -test('cli: refuses to select an endpoint while SEAM_CLI_ENDPOINT is set', async () => { - const { stdout, stderr, exitCode } = await runCli( - ['select', 'endpoint', '--endpoint', 'https://connect.example.com'], - { env: { SEAM_CLI_ENDPOINT: endpoint } }, +test('cli: select endpoint stores the url given after it', async () => { + // A dedicated config home: the endpoint is shared by every other test. + const home = await mkdtemp(join(tmpdir(), 'seam-cli-select-')) + await mkdir(join(home, 'seam'), { recursive: true }) + + const { exitCode } = await runCli( + ['select', 'endpoint', 'https://Connect.Example.com'], + { configHome: home }, + ) + + expect(exitCode).toBe(0) + // Stored exactly as given: the command path is normalized, the value is not. + expect( + JSON.parse(await readFile(join(home, 'seam', 'cli.json'), 'utf8')), + ).toEqual({ endpoint: 'https://Connect.Example.com' }) +}) + +test('cli: select workspace stores the id given after it', async () => { + const home = await mkdtemp(join(tmpdir(), 'seam-cli-select-')) + await mkdir(join(home, 'seam'), { recursive: true }) + await writeFile( + join(home, 'seam', 'cli.json'), + JSON.stringify({ [endpoint]: { pat: 'seam_apikey1_token' } }), + ) + + const { exitCode } = await runCli(['select', 'workspace', 'workspace_1'], { + stateHome: home, + }) + + expect(exitCode).toBe(0) + expect(await readStoredState(home)).toMatchObject({ + current_workspace_id: 'workspace_1', + }) +}) + +test('cli: select endpoint without a url fails when it cannot prompt', async () => { + const { stderr, exitCode } = await runCli([ + 'select', + 'endpoint', + '--non-interactive', + ]) + + expect(exitCode).toBe(1) + expect(stderr).toContain( + 'Missing required argument for select endpoint: ', ) +}) + +test('cli: refuses the overrides on the commands that select', async () => { + const { stderr, exitCode } = await runCli([ + 'select', + 'endpoint', + 'https://connect.example.com', + '--endpoint', + 'https://other.example.com', + ]) expect(exitCode).toBe(1) - expect(stdout).toBe('') expect(stderr).toContain( - 'Cannot select an endpoint while SEAM_CLI_ENDPOINT is set', + '--endpoint cannot be used with seam select endpoint', ) + expect(stderr).toContain("Run 'seam select endpoint '") }) test('cli: logout removes the stored token and workspace', async () => { diff --git a/test/commands/registry.test.ts b/test/commands/registry.test.ts index 5a72ca26..07d6c5d6 100644 --- a/test/commands/registry.test.ts +++ b/test/commands/registry.test.ts @@ -4,6 +4,7 @@ import { acceptedParamsOf, buildRegistry, findLocalCommand, + findLocalCommandTakingPositional, localCommands, } from 'lib/commands/registry.js' import { testBlueprint } from 'test/fixtures/blueprint.js' @@ -60,7 +61,26 @@ test('acceptedParamsOf: names the parameters behind the flags', () => { const login = findLocalCommand(['login']) expect(login).toBeDefined() if (login == null) return - expect(acceptedParamsOf(login.definition)).toEqual( - new Set(['endpoint', 'token', 'workspace_id']), - ) + expect(acceptedParamsOf(login.definition)).toEqual(new Set(['token'])) +}) + +test('registry: the select commands take a value after their path', () => { + expect( + findLocalCommandTakingPositional(['select', 'endpoint', 'a-url']), + ).toBeDefined() + expect( + findLocalCommandTakingPositional(['select', 'workspace', 'workspace1']) + ?.definition.path, + ).toEqual(['select', 'workspace']) +}) + +test('registry: a stray word after any other command is not a positional', () => { + expect(findLocalCommandTakingPositional(['login', 'a-token'])).toBeUndefined() + expect( + findLocalCommandTakingPositional(['devices', 'list', 'extra']), + ).toBeUndefined() + // The command alone takes nothing: there is no word after its path. + expect( + findLocalCommandTakingPositional(['select', 'endpoint']), + ).toBeUndefined() }) diff --git a/test/commands/spec.test.ts b/test/commands/spec.test.ts index c804ecc7..7dcef017 100644 --- a/test/commands/spec.test.ts +++ b/test/commands/spec.test.ts @@ -21,9 +21,7 @@ test('command spec: falls back to the first sentence for an untitled endpoint', test('command spec: includes commands handled by the CLI itself', () => { expect(findCommand(spec, ['login'])?.flags.map(({ long }) => long)).toEqual([ - 'endpoint', 'token', - 'workspace-id', ]) expect(findCommand(spec, ['select', 'workspace'])).toBeDefined() expect(findCommand(spec, ['completion', 'zsh'])).toBeDefined() diff --git a/test/context.test.ts b/test/context.test.ts index b4246ead..ff9acccf 100644 --- a/test/context.test.ts +++ b/test/context.test.ts @@ -3,15 +3,28 @@ import { afterEach, beforeEach, expect, test } from 'vitest' import { createMemoryConfigStore } from 'lib/config/memory-config-store.js' import { resolveAuth } from 'lib/context.js' import { endpointEnvVar, tokenEnvVar, workspaceIdEnvVar } from 'lib/env.js' +import { resetAuthOverrides, setAuthOverrides } from 'lib/overrides.js' const endpoint = 'https://connect.example.com' const store = createMemoryConfigStore +/** The flags for one command, as `bin/cli.ts` sets them from the arguments. */ +const overrideWith = (overrides: { + endpoint?: string + workspaceId?: string +}): void => { + setAuthOverrides({ + endpoint: overrides.endpoint ?? null, + workspaceId: overrides.workspaceId ?? null, + }) +} + const clearEnv = (): void => { delete process.env[endpointEnvVar] delete process.env[tokenEnvVar] delete process.env[workspaceIdEnvVar] + resetAuthOverrides() } beforeEach(clearEnv) @@ -46,6 +59,58 @@ test('resolveAuth: the stored endpoint wins over the legacy key', () => { expect(auth.endpoint).toBe(endpoint) }) +test('resolveAuth: --endpoint wins over the stored endpoint', () => { + overrideWith({ endpoint: 'http://localhost:3020' }) + + const auth = resolveAuth(store({ endpoint })) + + expect(auth.endpoint).toBe('http://localhost:3020') + expect(auth.endpointSource).toBe('flag') +}) + +test(`resolveAuth: --endpoint wins over ${endpointEnvVar}`, () => { + process.env[endpointEnvVar] = 'http://localhost:3020' + overrideWith({ endpoint: 'http://localhost:9999' }) + + const auth = resolveAuth(store({ endpoint })) + + expect(auth.endpoint).toBe('http://localhost:9999') + expect(auth.endpointSource).toBe('flag') +}) + +test('resolveAuth: reads the token stored for an overridden endpoint', () => { + overrideWith({ endpoint: 'http://localhost:3020' }) + + const auth = resolveAuth( + store({ + endpoint, + [`${endpoint}.pat`]: 'seam_apikey1_stored', + 'http://localhost:3020.pat': 'seam_apikey1_local', + }), + ) + + expect(auth.token).toBe('seam_apikey1_local') +}) + +test('resolveAuth: --workspace-id wins over the stored selection', () => { + overrideWith({ workspaceId: 'workspace2' }) + + const auth = resolveAuth(store({ current_workspace_id: 'workspace1' })) + + expect(auth.workspaceId).toBe('workspace2') + expect(auth.workspaceIdSource).toBe('flag') +}) + +test(`resolveAuth: --workspace-id wins over ${workspaceIdEnvVar}`, () => { + process.env[workspaceIdEnvVar] = 'workspace2' + overrideWith({ workspaceId: 'workspace3' }) + + const auth = resolveAuth(store({ current_workspace_id: 'workspace1' })) + + expect(auth.workspaceId).toBe('workspace3') + expect(auth.workspaceIdSource).toBe('flag') +}) + test(`resolveAuth: ${endpointEnvVar} wins over the stored endpoint`, () => { process.env[endpointEnvVar] = 'http://localhost:3020'