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

### Environment variables

Everything `seam login`, `seam select workspace`, and `seam select server`
store may be given in the environment instead:

- `SEAM_CLI_TOKEN`: a Personal Access Token or API Key,
- `SEAM_CLI_WORKSPACE_ID`: the workspace requests are made against,
- `SEAM_CLI_ENDPOINT`: the Seam API server 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.

```bash
# One command against another workspace
SEAM_CLI_WORKSPACE_ID=$OTHER_WORKSPACE seam devices list

# No login needed: authenticate from the environment
export SEAM_CLI_TOKEN=$SEAM_API_KEY
seam devices list

# Work against a local Seam Connect instance
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`.

The command that would store an overridden value fails rather than storing
something the environment ignores: `seam login` and `seam logout` while
`SEAM_CLI_TOKEN` is set, `seam select workspace` while
`SEAM_CLI_WORKSPACE_ID` is set, and `seam select server` while
`SEAM_CLI_ENDPOINT` is set. Unset the variable to use those commands.

```bash
$ SEAM_CLI_TOKEN=$SEAM_API_KEY seam login
Cannot log in while SEAM_CLI_TOKEN is set: it overrides what would be stored. Unset SEAM_CLI_TOKEN to log in.
```

## Help

Pass `--help` to any command to see what it accepts. Without a command, it
Expand Down
40 changes: 37 additions & 3 deletions src/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,19 @@ import {
renderCompletion,
} from 'lib/completion/index.js'
import { getConfigStore } from 'lib/config/index.js'
import {
assertEnvVarUnset,
endpointEnvVar,
EnvVarOverrideError,
getEndpointFromEnv,
getTokenFromEnv,
getWorkspaceIdFromEnv,
tokenEnvVar,
workspaceIdEnvVar,
} from 'lib/env.js'
import { getApiBlueprint } from 'lib/get-api-blueprint.js'
import { getCommandBlueprintDef } from 'lib/get-command-blueprint-def.js'
import { getToken } from 'lib/get-credentials.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'
Expand Down Expand Up @@ -129,6 +140,9 @@ async function cli(args: ParsedArgs) {
args._[1] === 'set' &&
args._[2] === 'fake-server'
) {
assertEnvVarUnset(endpointEnvVar, getEndpointFromEnv(), 'select a server')
assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log in')

const randomstring = randomBytes(5).toString('hex')
const fakeApiUrl = `https://${randomstring}.fakeseamconnect.seam.vc`

Expand All @@ -141,11 +155,11 @@ async function cli(args: ParsedArgs) {
}

if (
!config.get(`${getServer()}.pat`) &&
getToken() == null &&
args._[0] !== 'login' &&
!isEqual(args._, ['select', 'server'])
) {
output.error(`Not logged in. Please run "seam login"`)
output.error(`Not logged in. Please run "seam login" or set ${tokenEnvVar}`)
process.exitCode = 1
return
}
Expand Down Expand Up @@ -185,6 +199,19 @@ async function cli(args: ParsedArgs) {
assertKnownArgs(argParams, selectedCommand, ctx)

if (isEqual(selectedCommand, ['login'])) {
// Nothing is stored while the environment overrides it, so refuse before
// storing anything rather than part way through.
assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log in')
if (args['server']) {
assertEnvVarUnset(endpointEnvVar, getEndpointFromEnv(), 'select a server')
}
if (args['workspace_id']) {
assertEnvVarUnset(
workspaceIdEnvVar,
getWorkspaceIdFromEnv(),
'select a workspace',
)
}
if (args['server']) {
config.set('server', args['server'])
config.delete('current_workspace_id')
Expand All @@ -209,6 +236,7 @@ async function cli(args: ParsedArgs) {
await interactForLogin()
return
} else if (isEqual(selectedCommand, ['logout'])) {
assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log out')
config.delete('pat')
output.info('Logged out!')
return
Expand All @@ -224,6 +252,11 @@ async function cli(args: ParsedArgs) {
await interactForUseRemoteApiDefs()
return
} else if (isEqual(selectedCommand, ['select', 'workspace'])) {
assertEnvVarUnset(
workspaceIdEnvVar,
getWorkspaceIdFromEnv(),
'select a workspace',
)
if (isNonInteractive) {
throw new NonInteractiveError(
'Cannot select a workspace in non-interactive mode: pass --workspace-id to "seam login"',
Expand All @@ -238,6 +271,7 @@ async function cli(args: ParsedArgs) {
commandParams['since'] = date.toISOString()
}
} else if (isEqual(selectedCommand, ['select', 'server'])) {
assertEnvVarUnset(endpointEnvVar, getEndpointFromEnv(), 'select a server')
if (args['server']) {
config.set('server', args['server'])
config.delete('current_workspace_id')
Expand Down Expand Up @@ -419,7 +453,7 @@ run(process.argv.slice(2)).catch((e: unknown) => {
return
}

if (e instanceof NonInteractiveError) {
if (e instanceof NonInteractiveError || e instanceof EnvVarOverrideError) {
output.error(chalk.red(e.message))
return
}
Expand Down
12 changes: 12 additions & 0 deletions src/lib/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
INSIDE_WEB_BROWSER?: string
SEAM_CLI_ENDPOINT?: string
SEAM_CLI_TOKEN?: string
SEAM_CLI_WORKSPACE_ID?: string
}
}
}

export {}
75 changes: 75 additions & 0 deletions src/lib/env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, expect, test } from 'vitest'

import {
assertEnvVarUnset,
endpointEnvVar,
EnvVarOverrideError,
getEndpointFromEnv,
getTokenFromEnv,
getWorkspaceIdFromEnv,
tokenEnvVar,
workspaceIdEnvVar,
} from './env.js'

const envVars = [tokenEnvVar, workspaceIdEnvVar, endpointEnvVar]

const clearEnv = (): void => {
for (const envVar of envVars) {
delete process.env[envVar]
}
}

beforeEach(clearEnv)
afterEach(clearEnv)

test('env: reads each variable', () => {
process.env[tokenEnvVar] = 'seam_apikey1_env'
process.env[workspaceIdEnvVar] = 'workspace1'
process.env[endpointEnvVar] = 'https://connect.example.com'

expect(getTokenFromEnv()).toBe('seam_apikey1_env')
expect(getWorkspaceIdFromEnv()).toBe('workspace1')
expect(getEndpointFromEnv()).toBe('https://connect.example.com')
})

test('env: reads null when unset', () => {
expect(getTokenFromEnv()).toBe(null)
expect(getWorkspaceIdFromEnv()).toBe(null)
expect(getEndpointFromEnv()).toBe(null)
})

test('env: trims values', () => {
process.env[tokenEnvVar] = ' seam_apikey1_env\n'

expect(getTokenFromEnv()).toBe('seam_apikey1_env')
})

test('env: reads an empty value as unset', () => {
process.env[tokenEnvVar] = ''
process.env[workspaceIdEnvVar] = ' '

expect(getTokenFromEnv()).toBe(null)
expect(getWorkspaceIdFromEnv()).toBe(null)
})

test('assertEnvVarUnset: throws when the variable is set', () => {
expect(() => {
assertEnvVarUnset(tokenEnvVar, 'seam_apikey1_env', 'log in')
}).toThrow(EnvVarOverrideError)

expect(() => {
assertEnvVarUnset(tokenEnvVar, 'seam_apikey1_env', 'log in')
}).toThrow(/Cannot log in while SEAM_CLI_TOKEN is set/)
})

test('assertEnvVarUnset: says how to proceed', () => {
expect(() => {
assertEnvVarUnset(workspaceIdEnvVar, 'workspace1', 'select a workspace')
}).toThrow(/Unset SEAM_CLI_WORKSPACE_ID to select a workspace/)
})

test('assertEnvVarUnset: passes when the variable is unset', () => {
expect(() => {
assertEnvVarUnset(tokenEnvVar, null, 'log in')
}).not.toThrow()
})
63 changes: 63 additions & 0 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Credentials and the server may be given in the environment.
*
* Each variable overrides the corresponding stored value for as long as it
* is set, so any of them may be used per command or per shell. Commands that
* would store an overridden value fail instead: see {@link assertEnvVarUnset}.
*/

/** Overrides the token stored by `seam login`. */
export const tokenEnvVar = 'SEAM_CLI_TOKEN'

/** Overrides the workspace stored by `seam select workspace`. */
export const workspaceIdEnvVar = 'SEAM_CLI_WORKSPACE_ID'

/** Overrides the server stored by `seam select server`. */
export const endpointEnvVar = 'SEAM_CLI_ENDPOINT'

/** Every variable read here is declared on `ProcessEnv` in `env.d.ts`. */
type SeamCliEnvVar =
typeof endpointEnvVar | typeof tokenEnvVar | typeof workspaceIdEnvVar

export const getTokenFromEnv = (): string | null => readEnvVar(tokenEnvVar)

export const getWorkspaceIdFromEnv = (): string | null =>
readEnvVar(workspaceIdEnvVar)

export const getEndpointFromEnv = (): string | null =>
readEnvVar(endpointEnvVar)

/** Reported without a stack trace: the environment is at fault, not the CLI. */
export class EnvVarOverrideError extends Error {
override name = 'EnvVarOverrideError'
}

/**
* Refuse to store a value the environment overrides.
*
* Storing it would have no effect while the variable is set, so a command
* that appears to succeed would leave the CLI using something else.
*
* @param action What the command does, e.g., `log in`.
*/
export const assertEnvVarUnset = (
envVar: string,
envValue: string | null,
action: string,
): void => {
if (envValue == null) return

throw new EnvVarOverrideError(
`Cannot ${action} while ${envVar} is set: it overrides what would be stored. Unset ${envVar} to ${action}.`,
)
}

const readEnvVar = (envVar: SeamCliEnvVar): string | null => {
const value = process.env[envVar]

if (value == null) return null

const trimmedValue = value.trim()

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