From e5f2ddcdd30971c940d4b38ea0db727dac967960 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 19:31:15 +0000 Subject: [PATCH 1/2] feat: Add SEAM_CLI_TOKEN and SEAM_CLI_WORKSPACE_ID environment variables Credentials may now be given in the environment instead of being stored by "seam login" and "seam select workspace". Either variable, both, or neither may be set: whatever is set wins over what is stored. Resolve the token and workspace id in one place so every command sees the same credentials, report a clear error when either is missing, and warn when a command stores a credential the environment currently overrides. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014vMQhRm7UfP3WfSGBX6WUW --- README.md | 28 +++++ src/bin/cli.ts | 20 +++- src/lib/get-credentials.test.ts | 134 +++++++++++++++++++++++ src/lib/get-credentials.ts | 72 ++++++++++++ src/lib/get-current-workspace-id.ts | 8 +- src/lib/get-seam.ts | 54 +++++++-- src/lib/interact-for-login.ts | 6 + src/lib/interact-for-server-selection.ts | 6 + src/lib/interact-for-workspace-id.ts | 8 ++ test/cli.test.ts | 95 +++++++++++++++- 10 files changed, 409 insertions(+), 22 deletions(-) create mode 100644 src/lib/get-credentials.test.ts create mode 100644 src/lib/get-credentials.ts diff --git a/README.md b/README.md index a4f0f7b8..64a5b7cc 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,34 @@ 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 + +Credentials may be given in the environment instead of being stored by +`seam login` and `seam select workspace`: + +- `SEAM_CLI_TOKEN`: a Personal Access Token or API Key, +- `SEAM_CLI_WORKSPACE_ID`: the workspace requests are made against. + +Either one, both, or neither may be set. Whatever is set wins over what is +stored, 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 +``` + +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`. + +Commands that store credentials still store them while these are set, and +report that the environment overrides what was stored. + ## Help Pass `--help` to any command to see what it accepts. Without a command, it diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 8c2cf4a0..a6d338d5 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -13,6 +13,14 @@ import { } from 'lib/completion/index.js' import { getConfigStore } from 'lib/config/index.js' import { getApiBlueprint } from 'lib/get-api-blueprint.js' +import { + getToken, + getTokenFromEnv, + getWorkspaceIdFromEnv, + tokenEnvVar, + warnEnvVarOverride, + workspaceIdEnvVar, +} 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' @@ -108,15 +116,16 @@ async function cli(args: ParsedArgs) { config.set(`${getServer()}.pat`, `seam_apikey1_token`) output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) + warnEnvVarOverride(tokenEnvVar, getTokenFromEnv(), 'token') return } 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 } @@ -165,9 +174,15 @@ async function cli(args: ParsedArgs) { await validateToken(token, args['workspace_id']) config.set(`${getServer()}.pat`, token) config.delete('current_workspace_id') + warnEnvVarOverride(tokenEnvVar, getTokenFromEnv(), 'token') } if (args['workspace_id']) { config.set(`current_workspace_id`, args['workspace_id']) + warnEnvVarOverride( + workspaceIdEnvVar, + getWorkspaceIdFromEnv(), + 'workspace', + ) } if (args['token'] || args['workspace_id'] || args['server']) { return @@ -180,6 +195,7 @@ async function cli(args: ParsedArgs) { await interactForLogin() return } else if (isEqual(selectedCommand, ['logout'])) { + warnEnvVarOverride(tokenEnvVar, getTokenFromEnv(), 'token') config.delete('pat') output.info('Logged out!') return diff --git a/src/lib/get-credentials.test.ts b/src/lib/get-credentials.test.ts new file mode 100644 index 00000000..c7d408b7 --- /dev/null +++ b/src/lib/get-credentials.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +import { getConfigStore } from './config/index.js' +import { + getToken, + getWorkspaceId, + tokenEnvVar, + warnEnvVarOverride, + workspaceIdEnvVar, +} from './get-credentials.js' +import { createMemoryOutput } from './output/create-memory-output.js' +import { setOutput } from './output/get-output.js' + +const server = 'https://connect.example.com' + +const storedConfig: Record = {} + +vi.mock('./config/index.js', () => ({ + getConfigStore: vi.fn(() => ({ + get: (key: string) => storedConfig[key], + })), +})) + +vi.mock('./get-server.js', () => ({ + getServer: vi.fn(() => server), +})) + +beforeEach(() => { + for (const key of Object.keys(storedConfig)) { + delete storedConfig[key] + } + delete process.env[tokenEnvVar] + delete process.env[workspaceIdEnvVar] +}) + +afterEach(() => { + delete process.env[tokenEnvVar] + delete process.env[workspaceIdEnvVar] + vi.mocked(getConfigStore).mockClear() +}) + +test('getToken: reads the token stored for the current server', () => { + storedConfig[`${server}.pat`] = 'seam_apikey1_stored' + + expect(getToken()).toBe('seam_apikey1_stored') +}) + +test(`getToken: ${tokenEnvVar} wins over the stored token`, () => { + storedConfig[`${server}.pat`] = 'seam_apikey1_stored' + process.env[tokenEnvVar] = 'seam_apikey1_env' + + expect(getToken()).toBe('seam_apikey1_env') +}) + +test(`getToken: ${tokenEnvVar} is used without a stored token`, () => { + process.env[tokenEnvVar] = 'seam_apikey1_env' + + expect(getToken()).toBe('seam_apikey1_env') +}) + +test(`getToken: trims ${tokenEnvVar}`, () => { + process.env[tokenEnvVar] = ' seam_apikey1_env\n' + + expect(getToken()).toBe('seam_apikey1_env') +}) + +test(`getToken: ignores an empty ${tokenEnvVar}`, () => { + storedConfig[`${server}.pat`] = 'seam_apikey1_stored' + process.env[tokenEnvVar] = ' ' + + expect(getToken()).toBe('seam_apikey1_stored') +}) + +test('getToken: returns null when nothing is set', () => { + expect(getToken()).toBe(null) +}) + +test('getWorkspaceId: reads the stored workspace selection', () => { + storedConfig['current_workspace_id'] = 'workspace1' + + expect(getWorkspaceId()).toBe('workspace1') +}) + +test(`getWorkspaceId: ${workspaceIdEnvVar} wins over the stored selection`, () => { + storedConfig['current_workspace_id'] = 'workspace1' + process.env[workspaceIdEnvVar] = 'workspace2' + + expect(getWorkspaceId()).toBe('workspace2') +}) + +test(`getWorkspaceId: ${workspaceIdEnvVar} is used without a stored selection`, () => { + process.env[workspaceIdEnvVar] = 'workspace2' + + expect(getWorkspaceId()).toBe('workspace2') +}) + +test(`getWorkspaceId: ignores an empty ${workspaceIdEnvVar}`, () => { + storedConfig['current_workspace_id'] = 'workspace1' + process.env[workspaceIdEnvVar] = '' + + expect(getWorkspaceId()).toBe('workspace1') +}) + +test('getWorkspaceId: returns null when nothing is set', () => { + expect(getWorkspaceId()).toBe(null) +}) + +test('getToken and getWorkspaceId: either may be set on its own', () => { + storedConfig[`${server}.pat`] = 'seam_apikey1_stored' + storedConfig['current_workspace_id'] = 'workspace1' + process.env[workspaceIdEnvVar] = 'workspace2' + + expect(getToken()).toBe('seam_apikey1_stored') + expect(getWorkspaceId()).toBe('workspace2') +}) + +test('warnEnvVarOverride: warns on stderr when the environment variable is set', () => { + const memoryOutput = createMemoryOutput({ format: 'json' }) + setOutput(memoryOutput.output) + + warnEnvVarOverride(tokenEnvVar, 'seam_apikey1_env', 'token') + + expect(memoryOutput.stdout()).toBe('') + expect(memoryOutput.stderr()).toContain(tokenEnvVar) +}) + +test('warnEnvVarOverride: says nothing when the environment variable is unset', () => { + const memoryOutput = createMemoryOutput({ format: 'json' }) + setOutput(memoryOutput.output) + + warnEnvVarOverride(tokenEnvVar, null, 'token') + + expect(memoryOutput.stderr()).toBe('') +}) diff --git a/src/lib/get-credentials.ts b/src/lib/get-credentials.ts new file mode 100644 index 00000000..6e390496 --- /dev/null +++ b/src/lib/get-credentials.ts @@ -0,0 +1,72 @@ +import chalk from 'chalk' + +import { getConfigStore } from './config/index.js' +import { getServer } from './get-server.js' +import { getOutput } from './output/get-output.js' + +/** Overrides the stored token for the current server. */ +export const tokenEnvVar = 'SEAM_CLI_TOKEN' + +/** Overrides the stored workspace selection. */ +export const workspaceIdEnvVar = 'SEAM_CLI_WORKSPACE_ID' + +/** + * The token used to authenticate requests. + * + * {@link tokenEnvVar} wins over the token stored by `seam login`, + * so a token may be given per command or per shell without logging in. + */ +export const getToken = (): string | null => { + const token = getTokenFromEnv() + if (token != null) return token + + return readString(getConfigStore().get(`${getServer()}.pat`)) +} + +/** + * The workspace requests are made against. + * + * {@link workspaceIdEnvVar} wins over the workspace stored by + * `seam select workspace`. Returns `null` when neither is set: a token + * scoped to a single workspace does not need one. + */ +export const getWorkspaceId = (): string | null => { + const workspaceId = getWorkspaceIdFromEnv() + if (workspaceId != null) return workspaceId + + return readString(getConfigStore().get('current_workspace_id')) +} + +export const getTokenFromEnv = (): string | null => + readString(process.env[tokenEnvVar]) + +export const getWorkspaceIdFromEnv = (): string | null => + readString(process.env[workspaceIdEnvVar]) + +/** + * Warn when an environment variable shadows what is about to be stored. + * + * The value is still stored: it takes effect wherever the environment + * variable is not set. + */ +export const warnEnvVarOverride = ( + envVar: string, + envValue: string | null, + what: string, +): void => { + if (envValue == null) return + + getOutput().warn( + chalk.yellow( + `Warning: ${envVar} is set and overrides the stored ${what} while it remains set`, + ), + ) +} + +const readString = (value: unknown): string | null => { + if (typeof value !== 'string') return null + + const trimmedValue = value.trim() + + return trimmedValue === '' ? null : trimmedValue +} diff --git a/src/lib/get-current-workspace-id.ts b/src/lib/get-current-workspace-id.ts index ef6c6df4..1ec2113d 100644 --- a/src/lib/get-current-workspace-id.ts +++ b/src/lib/get-current-workspace-id.ts @@ -1,11 +1,9 @@ -import { getConfigStore } from './config/index.js' +import { getWorkspaceId } from './get-credentials.js' import { interactForWorkspaceId } from './interact-for-workspace-id.js' export const getCurrentWorkspaceId = async (): Promise => { - const configStore = getConfigStore() - - const currentWorkspaceId = configStore.get('current_workspace_id') - if (typeof currentWorkspaceId === 'string') return currentWorkspaceId + const currentWorkspaceId = getWorkspaceId() + if (currentWorkspaceId != null) return currentWorkspaceId return await interactForWorkspaceId() } diff --git a/src/lib/get-seam.ts b/src/lib/get-seam.ts index 3139f98d..47aafebd 100644 --- a/src/lib/get-seam.ts +++ b/src/lib/get-seam.ts @@ -5,24 +5,33 @@ import { SeamHttpWithoutWorkspace, } from '@seamapi/http/connect' -import { getConfigStore } from './config/index.js' +import { + getToken, + getWorkspaceId, + tokenEnvVar, + workspaceIdEnvVar, +} from './get-credentials.js' import { getServer } from './get-server.js' export const getSeam = async (): Promise => { - const config = getConfigStore() - - const token = config.get(`${getServer()}.pat`) as string - - const workspaceId = config.get('current_workspace_id') as string + const token = getRequiredToken() const options = { endpoint: getServer() } if (isPersonalAccessToken(token)) { - return SeamHttp.fromPersonalAccessToken(token, workspaceId, options) + return SeamHttp.fromPersonalAccessToken( + token, + getRequiredWorkspaceId(), + options, + ) } if (isConsoleSessionToken(token)) { - return SeamHttp.fromConsoleSessionToken(token, workspaceId, options) + return SeamHttp.fromConsoleSessionToken( + token, + getRequiredWorkspaceId(), + options, + ) } return SeamHttp.fromApiKey(token, options) @@ -31,13 +40,36 @@ export const getSeam = async (): Promise => { export const getSeamMultiWorkspace = async (): Promise< SeamHttpWithoutWorkspace | SeamHttp > => { - const config = getConfigStore() - const token = config.get(`${getServer()}.pat`) as string + const token = getRequiredToken() const options = { endpoint: getServer() } if (isPersonalAccessToken(token)) { return SeamHttpWithoutWorkspace.fromPersonalAccessToken(token, options) } - return getSeam() + return await getSeam() +} + +const getRequiredToken = (): string => { + const token = getToken() + + if (token == null) { + throw new Error( + `Not logged in: run "seam login" or set the ${tokenEnvVar} environment variable`, + ) + } + + return token +} + +const getRequiredWorkspaceId = (): string => { + const workspaceId = getWorkspaceId() + + if (workspaceId == null) { + throw new Error( + `No workspace selected: run "seam select workspace" or set the ${workspaceIdEnvVar} environment variable`, + ) + } + + return workspaceId } diff --git a/src/lib/interact-for-login.ts b/src/lib/interact-for-login.ts index 94cec49e..3a83aca8 100644 --- a/src/lib/interact-for-login.ts +++ b/src/lib/interact-for-login.ts @@ -2,6 +2,11 @@ import { isApiKey, isPersonalAccessToken } from '@seamapi/http/connect' import chalk from 'chalk' import { getConfigStore } from './config/index.js' +import { + getTokenFromEnv, + tokenEnvVar, + warnEnvVarOverride, +} from './get-credentials.js' import { getServer } from './get-server.js' import { interactForWorkspaceId } from './interact-for-workspace-id.js' import { getOutput } from './output/get-output.js' @@ -52,4 +57,5 @@ export const interactForLogin = async () => { config.set(`${getServer()}.pat`, token) output.info(`Token saved! You may begin using the CLI!`) + warnEnvVarOverride(tokenEnvVar, getTokenFromEnv(), 'token') } diff --git a/src/lib/interact-for-server-selection.ts b/src/lib/interact-for-server-selection.ts index 2958fc34..b9b17af5 100644 --- a/src/lib/interact-for-server-selection.ts +++ b/src/lib/interact-for-server-selection.ts @@ -1,6 +1,11 @@ import { randomBytes } from 'node:crypto' import { getConfigStore } from './config/index.js' +import { + getTokenFromEnv, + tokenEnvVar, + warnEnvVarOverride, +} from './get-credentials.js' import { getServer } from './get-server.js' import { getOutput } from './output/get-output.js' import { prompt } from './util/prompt.js' @@ -39,6 +44,7 @@ export async function interactForServerSelection() { config.set('server', `https://${userUrlSeed}.fakeseamconnect.seam.vc`) config.set(`${getServer()}.pat`, `seam_apikey1_token`) output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) + warnEnvVarOverride(tokenEnvVar, getTokenFromEnv(), 'token') } else { config.set('server', server) } diff --git a/src/lib/interact-for-workspace-id.ts b/src/lib/interact-for-workspace-id.ts index 04f58c02..439b1f44 100644 --- a/src/lib/interact-for-workspace-id.ts +++ b/src/lib/interact-for-workspace-id.ts @@ -1,6 +1,11 @@ import { SeamHttpWithoutWorkspace } from '@seamapi/http/connect' import { getConfigStore } from './config/index.js' +import { + getWorkspaceIdFromEnv, + warnEnvVarOverride, + workspaceIdEnvVar, +} from './get-credentials.js' import { getSeamMultiWorkspace } from './get-seam.js' import { getServer } from './get-server.js' import { prompt } from './util/prompt.js' @@ -8,6 +13,9 @@ import { withLoading } from './util/with-loading.js' export const interactForWorkspaceId = async (personalAccessToken?: string) => { const config = getConfigStore() + + warnEnvVarOverride(workspaceIdEnvVar, getWorkspaceIdFromEnv(), 'workspace') + const seam = personalAccessToken ? SeamHttpWithoutWorkspace.fromPersonalAccessToken(personalAccessToken, { endpoint: getServer(), diff --git a/test/cli.test.ts b/test/cli.test.ts index 923e174c..cd455697 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -24,7 +24,12 @@ const errorResponse = { let server: Server let stateHome: string let configHome: string -let requests: Array<{ path: string; body: unknown }> = [] +let loggedOutStateHome: string +let requests: Array<{ + path: string + body: unknown + headers: Record +}> = [] let failNextRequest = false beforeAll(async () => { @@ -32,7 +37,11 @@ beforeAll(async () => { let body = '' req.on('data', (chunk) => (body += chunk)) req.on('end', () => { - requests.push({ path: req.url ?? '', body: JSON.parse(body || '{}') }) + requests.push({ + path: req.url ?? '', + body: JSON.parse(body || '{}'), + headers: req.headers, + }) if (failNextRequest) { failNextRequest = false @@ -69,6 +78,10 @@ beforeAll(async () => { join(stateHome, 'seam', 'cli.json'), JSON.stringify({ [endpoint]: { pat: 'seam_apikey1_token' } }), ) + + // The same settings without a stored token, i.e., not logged in. + loggedOutStateHome = join(home, 'logged-out-state') + await mkdir(join(loggedOutStateHome, 'seam'), { recursive: true }) }) afterAll(async () => { @@ -83,7 +96,15 @@ interface CliResult { const runCli = async ( args: string[], - { input }: { input?: string } = {}, + { + input, + env, + stateHome: stateHomeOverride, + }: { + input?: string + env?: Record + stateHome?: string + } = {}, ): Promise => { const { stdout, stderr, exitCode } = await execa( 'node', @@ -92,8 +113,12 @@ const runCli = async ( cwd: projectRoot, env: { XDG_CONFIG_HOME: configHome, - XDG_STATE_HOME: stateHome, + XDG_STATE_HOME: stateHomeOverride ?? stateHome, FORCE_COLOR: '0', + // Never inherit credentials from the environment running the tests. + SEAM_CLI_TOKEN: undefined, + SEAM_CLI_WORKSPACE_ID: undefined, + ...env, }, input: input ?? '', reject: false, @@ -205,6 +230,68 @@ test('cli: pretty prints the response with --no-json', async () => { expect(stdout).not.toContain('"device_id"') }) +test('cli: SEAM_CLI_TOKEN wins over the stored token', async () => { + requests = [] + const { exitCode } = await runCli(['devices', 'list'], { + env: { SEAM_CLI_TOKEN: 'seam_apikey1_from_env' }, + }) + + expect(exitCode).toBe(0) + expect(requests[0]?.headers['authorization']).toBe( + 'Bearer seam_apikey1_from_env', + ) +}) + +test('cli: SEAM_CLI_TOKEN authenticates without logging in', async () => { + requests = [] + const { exitCode, stderr } = await runCli(['devices', 'list'], { + env: { SEAM_CLI_TOKEN: 'seam_apikey1_from_env' }, + stateHome: loggedOutStateHome, + }) + + expect(exitCode).toBe(0) + expect(stderr).not.toContain('Not logged in') + expect(requests[0]?.headers['authorization']).toBe( + 'Bearer seam_apikey1_from_env', + ) +}) + +test('cli: reports not being logged in without SEAM_CLI_TOKEN', async () => { + const { stdout, stderr, exitCode } = await runCli(['devices', 'list'], { + stateHome: loggedOutStateHome, + }) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toContain('Not logged in') + expect(stderr).toContain('SEAM_CLI_TOKEN') +}) + +test('cli: SEAM_CLI_WORKSPACE_ID sets the workspace for the request', async () => { + requests = [] + const { exitCode } = await runCli(['devices', 'list'], { + env: { + SEAM_CLI_TOKEN: 'seam_at1_from_env', + SEAM_CLI_WORKSPACE_ID: 'workspace_from_env', + }, + }) + + expect(exitCode).toBe(0) + expect(requests[0]?.headers['authorization']).toBe('Bearer seam_at1_from_env') + expect(requests[0]?.headers['seam-workspace']).toBe('workspace_from_env') +}) + +test('cli: reports no workspace for a personal access token without one', async () => { + const { stdout, stderr, exitCode } = await runCli(['devices', 'list'], { + env: { SEAM_CLI_TOKEN: 'seam_at1_from_env' }, + }) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toContain('No workspace selected') + expect(stderr).toContain('SEAM_CLI_WORKSPACE_ID') +}) + test('cli: reports a failed request on stdout and exits non-zero', async () => { failNextRequest = true const { stdout, stderr, exitCode } = await runCli(['devices', 'list']) From 4170845b7cb5f65243957bea61b4754b8ffbadd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 19:40:15 +0000 Subject: [PATCH 2/2] feat: Add SEAM_CLI_ENDPOINT and fail when the environment overrides a command Move the environment variables into lib/env.ts and add SEAM_CLI_ENDPOINT, which overrides the server stored by "seam select server" the same way SEAM_CLI_TOKEN and SEAM_CLI_WORKSPACE_ID override the stored token and workspace. Commands that would store an overridden value now fail instead of 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. Each reports what to unset, without a stack trace, and stores nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014vMQhRm7UfP3WfSGBX6WUW --- README.md | 28 +++++-- src/bin/cli.ts | 44 +++++++---- src/lib/env.test.ts | 75 ++++++++++++++++++ src/lib/env.ts | 59 +++++++++++++++ src/lib/get-credentials.test.ts | 47 +++--------- src/lib/get-credentials.ts | 40 +--------- src/lib/get-seam.ts | 8 +- src/lib/get-server.test.ts | 55 ++++++++++++++ src/lib/get-server.ts | 13 +++- src/lib/interact-for-login.ts | 9 +-- src/lib/interact-for-server-selection.ts | 10 ++- src/lib/interact-for-workspace-id.ts | 10 ++- test/cli.test.ts | 96 +++++++++++++++++++++++- 13 files changed, 377 insertions(+), 117 deletions(-) create mode 100644 src/lib/env.test.ts create mode 100644 src/lib/env.ts create mode 100644 src/lib/get-server.test.ts diff --git a/README.md b/README.md index 64a5b7cc..051be347 100644 --- a/README.md +++ b/README.md @@ -157,15 +157,16 @@ so it can be inspected from a pipe; anything else is written to stderr only. ### Environment variables -Credentials may be given in the environment instead of being stored by -`seam login` and `seam select workspace`: +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_WORKSPACE_ID`: the workspace requests are made against, +- `SEAM_CLI_ENDPOINT`: the Seam API server requests are made to. -Either one, both, or neither may be set. Whatever is set wins over what is -stored, which makes them useful for CI, for a single command, or for working -against another workspace in one shell. +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 @@ -174,14 +175,25 @@ 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`. -Commands that store credentials still store them while these are set, and -report that the environment overrides what was stored. +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 diff --git a/src/bin/cli.ts b/src/bin/cli.ts index a6d338d5..29ed877f 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -12,15 +12,18 @@ import { renderCompletion, } from 'lib/completion/index.js' import { getConfigStore } from 'lib/config/index.js' -import { getApiBlueprint } from 'lib/get-api-blueprint.js' import { - getToken, + assertEnvVarUnset, + endpointEnvVar, + EnvVarOverrideError, + getEndpointFromEnv, getTokenFromEnv, getWorkspaceIdFromEnv, tokenEnvVar, - warnEnvVarOverride, workspaceIdEnvVar, -} from 'lib/get-credentials.js' +} from 'lib/env.js' +import { getApiBlueprint } from 'lib/get-api-blueprint.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' @@ -108,6 +111,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` @@ -116,7 +122,6 @@ async function cli(args: ParsedArgs) { config.set(`${getServer()}.pat`, `seam_apikey1_token`) output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) - warnEnvVarOverride(tokenEnvVar, getTokenFromEnv(), 'token') return } @@ -165,6 +170,19 @@ async function cli(args: ParsedArgs) { const selectedCommand = await interactForCommandSelection(args._, 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') @@ -174,15 +192,9 @@ async function cli(args: ParsedArgs) { await validateToken(token, args['workspace_id']) config.set(`${getServer()}.pat`, token) config.delete('current_workspace_id') - warnEnvVarOverride(tokenEnvVar, getTokenFromEnv(), 'token') } if (args['workspace_id']) { config.set(`current_workspace_id`, args['workspace_id']) - warnEnvVarOverride( - workspaceIdEnvVar, - getWorkspaceIdFromEnv(), - 'workspace', - ) } if (args['token'] || args['workspace_id'] || args['server']) { return @@ -195,7 +207,7 @@ async function cli(args: ParsedArgs) { await interactForLogin() return } else if (isEqual(selectedCommand, ['logout'])) { - warnEnvVarOverride(tokenEnvVar, getTokenFromEnv(), 'token') + assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log out') config.delete('pat') output.info('Logged out!') return @@ -211,6 +223,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"', @@ -225,6 +242,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') @@ -353,7 +371,7 @@ run(process.argv.slice(2)).catch((e: unknown) => { const output = getOutput() process.exitCode = 1 - if (e instanceof NonInteractiveError) { + if (e instanceof NonInteractiveError || e instanceof EnvVarOverrideError) { output.error(chalk.red(e.message)) return } diff --git a/src/lib/env.test.ts b/src/lib/env.test.ts new file mode 100644 index 00000000..384b3f0e --- /dev/null +++ b/src/lib/env.test.ts @@ -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() +}) diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 00000000..f47cc493 --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,59 @@ +/** + * 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' + +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: string): string | null => { + const value = process.env[envVar] + + if (value == null) return null + + const trimmedValue = value.trim() + + return trimmedValue === '' ? null : trimmedValue +} diff --git a/src/lib/get-credentials.test.ts b/src/lib/get-credentials.test.ts index c7d408b7..9062fc03 100644 --- a/src/lib/get-credentials.test.ts +++ b/src/lib/get-credentials.test.ts @@ -1,15 +1,8 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest' import { getConfigStore } from './config/index.js' -import { - getToken, - getWorkspaceId, - tokenEnvVar, - warnEnvVarOverride, - workspaceIdEnvVar, -} from './get-credentials.js' -import { createMemoryOutput } from './output/create-memory-output.js' -import { setOutput } from './output/get-output.js' +import { tokenEnvVar, workspaceIdEnvVar } from './env.js' +import { getToken, getWorkspaceId } from './get-credentials.js' const server = 'https://connect.example.com' @@ -25,17 +18,20 @@ vi.mock('./get-server.js', () => ({ getServer: vi.fn(() => server), })) +const clearEnv = (): void => { + delete process.env[tokenEnvVar] + delete process.env[workspaceIdEnvVar] +} + beforeEach(() => { for (const key of Object.keys(storedConfig)) { delete storedConfig[key] } - delete process.env[tokenEnvVar] - delete process.env[workspaceIdEnvVar] + clearEnv() }) afterEach(() => { - delete process.env[tokenEnvVar] - delete process.env[workspaceIdEnvVar] + clearEnv() vi.mocked(getConfigStore).mockClear() }) @@ -58,12 +54,6 @@ test(`getToken: ${tokenEnvVar} is used without a stored token`, () => { expect(getToken()).toBe('seam_apikey1_env') }) -test(`getToken: trims ${tokenEnvVar}`, () => { - process.env[tokenEnvVar] = ' seam_apikey1_env\n' - - expect(getToken()).toBe('seam_apikey1_env') -}) - test(`getToken: ignores an empty ${tokenEnvVar}`, () => { storedConfig[`${server}.pat`] = 'seam_apikey1_stored' process.env[tokenEnvVar] = ' ' @@ -113,22 +103,3 @@ test('getToken and getWorkspaceId: either may be set on its own', () => { expect(getToken()).toBe('seam_apikey1_stored') expect(getWorkspaceId()).toBe('workspace2') }) - -test('warnEnvVarOverride: warns on stderr when the environment variable is set', () => { - const memoryOutput = createMemoryOutput({ format: 'json' }) - setOutput(memoryOutput.output) - - warnEnvVarOverride(tokenEnvVar, 'seam_apikey1_env', 'token') - - expect(memoryOutput.stdout()).toBe('') - expect(memoryOutput.stderr()).toContain(tokenEnvVar) -}) - -test('warnEnvVarOverride: says nothing when the environment variable is unset', () => { - const memoryOutput = createMemoryOutput({ format: 'json' }) - setOutput(memoryOutput.output) - - warnEnvVarOverride(tokenEnvVar, null, 'token') - - expect(memoryOutput.stderr()).toBe('') -}) diff --git a/src/lib/get-credentials.ts b/src/lib/get-credentials.ts index 6e390496..5e573654 100644 --- a/src/lib/get-credentials.ts +++ b/src/lib/get-credentials.ts @@ -1,19 +1,11 @@ -import chalk from 'chalk' - import { getConfigStore } from './config/index.js' +import { getTokenFromEnv, getWorkspaceIdFromEnv } from './env.js' import { getServer } from './get-server.js' -import { getOutput } from './output/get-output.js' - -/** Overrides the stored token for the current server. */ -export const tokenEnvVar = 'SEAM_CLI_TOKEN' - -/** Overrides the stored workspace selection. */ -export const workspaceIdEnvVar = 'SEAM_CLI_WORKSPACE_ID' /** * The token used to authenticate requests. * - * {@link tokenEnvVar} wins over the token stored by `seam login`, + * `SEAM_CLI_TOKEN` wins over the token stored by `seam login`, * so a token may be given per command or per shell without logging in. */ export const getToken = (): string | null => { @@ -26,7 +18,7 @@ export const getToken = (): string | null => { /** * The workspace requests are made against. * - * {@link workspaceIdEnvVar} wins over the workspace stored by + * `SEAM_CLI_WORKSPACE_ID` wins over the workspace stored by * `seam select workspace`. Returns `null` when neither is set: a token * scoped to a single workspace does not need one. */ @@ -37,32 +29,6 @@ export const getWorkspaceId = (): string | null => { return readString(getConfigStore().get('current_workspace_id')) } -export const getTokenFromEnv = (): string | null => - readString(process.env[tokenEnvVar]) - -export const getWorkspaceIdFromEnv = (): string | null => - readString(process.env[workspaceIdEnvVar]) - -/** - * Warn when an environment variable shadows what is about to be stored. - * - * The value is still stored: it takes effect wherever the environment - * variable is not set. - */ -export const warnEnvVarOverride = ( - envVar: string, - envValue: string | null, - what: string, -): void => { - if (envValue == null) return - - getOutput().warn( - chalk.yellow( - `Warning: ${envVar} is set and overrides the stored ${what} while it remains set`, - ), - ) -} - const readString = (value: unknown): string | null => { if (typeof value !== 'string') return null diff --git a/src/lib/get-seam.ts b/src/lib/get-seam.ts index 47aafebd..7d252433 100644 --- a/src/lib/get-seam.ts +++ b/src/lib/get-seam.ts @@ -5,12 +5,8 @@ import { SeamHttpWithoutWorkspace, } from '@seamapi/http/connect' -import { - getToken, - getWorkspaceId, - tokenEnvVar, - workspaceIdEnvVar, -} from './get-credentials.js' +import { tokenEnvVar, workspaceIdEnvVar } from './env.js' +import { getToken, getWorkspaceId } from './get-credentials.js' import { getServer } from './get-server.js' export const getSeam = async (): Promise => { diff --git a/src/lib/get-server.test.ts b/src/lib/get-server.test.ts new file mode 100644 index 00000000..f876bce3 --- /dev/null +++ b/src/lib/get-server.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +import { getConfigStore } from './config/index.js' +import { endpointEnvVar } from './env.js' +import { getServer } from './get-server.js' + +const storedConfig: Record = {} + +vi.mock('./config/index.js', () => ({ + getConfigStore: vi.fn(() => ({ + get: (key: string) => storedConfig[key], + })), +})) + +beforeEach(() => { + for (const key of Object.keys(storedConfig)) { + delete storedConfig[key] + } + delete process.env[endpointEnvVar] +}) + +afterEach(() => { + delete process.env[endpointEnvVar] + vi.mocked(getConfigStore).mockClear() +}) + +test('getServer: reads the stored server', () => { + storedConfig['server'] = 'https://connect.example.com' + + expect(getServer()).toBe('https://connect.example.com') +}) + +test('getServer: defaults to Seam', () => { + expect(getServer()).toBe('https://connect.getseam.com') +}) + +test(`getServer: ${endpointEnvVar} wins over the stored server`, () => { + storedConfig['server'] = 'https://connect.example.com' + process.env[endpointEnvVar] = 'http://localhost:3020' + + expect(getServer()).toBe('http://localhost:3020') +}) + +test(`getServer: ${endpointEnvVar} is used without a stored server`, () => { + process.env[endpointEnvVar] = 'http://localhost:3020' + + expect(getServer()).toBe('http://localhost:3020') +}) + +test(`getServer: ignores an empty ${endpointEnvVar}`, () => { + storedConfig['server'] = 'https://connect.example.com' + process.env[endpointEnvVar] = '' + + expect(getServer()).toBe('https://connect.example.com') +}) diff --git a/src/lib/get-server.ts b/src/lib/get-server.ts index df7b00e9..2c4521c7 100644 --- a/src/lib/get-server.ts +++ b/src/lib/get-server.ts @@ -1,9 +1,20 @@ import { getConfigStore } from './config/index.js' +import { getEndpointFromEnv } from './env.js' +const defaultServer = 'https://connect.getseam.com' + +/** + * The Seam API server requests are made against. + * + * `SEAM_CLI_ENDPOINT` wins over the server stored by `seam select server`. + */ export const getServer = (): string => { + const endpoint = getEndpointFromEnv() + if (endpoint != null) return endpoint + const config = getConfigStore() const server = config.get('server') - return typeof server === 'string' ? server : 'https://connect.getseam.com' + return typeof server === 'string' ? server : defaultServer } diff --git a/src/lib/interact-for-login.ts b/src/lib/interact-for-login.ts index 3a83aca8..4a9cf036 100644 --- a/src/lib/interact-for-login.ts +++ b/src/lib/interact-for-login.ts @@ -2,11 +2,7 @@ import { isApiKey, isPersonalAccessToken } from '@seamapi/http/connect' import chalk from 'chalk' import { getConfigStore } from './config/index.js' -import { - getTokenFromEnv, - tokenEnvVar, - warnEnvVarOverride, -} from './get-credentials.js' +import { assertEnvVarUnset, getTokenFromEnv, tokenEnvVar } from './env.js' import { getServer } from './get-server.js' import { interactForWorkspaceId } from './interact-for-workspace-id.js' import { getOutput } from './output/get-output.js' @@ -18,6 +14,8 @@ export const interactForLogin = async () => { const config = await getConfigStore() const output = getOutput() + assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log in') + if (getServer().includes('localhost')) { output.info( `You're using a local Seam Connect instance, you can enter the API Key to your local user, you can create a new user from:\n\n${getServer()}/admin/create_user_with_api_key`, @@ -57,5 +55,4 @@ export const interactForLogin = async () => { config.set(`${getServer()}.pat`, token) output.info(`Token saved! You may begin using the CLI!`) - warnEnvVarOverride(tokenEnvVar, getTokenFromEnv(), 'token') } diff --git a/src/lib/interact-for-server-selection.ts b/src/lib/interact-for-server-selection.ts index b9b17af5..32d8900b 100644 --- a/src/lib/interact-for-server-selection.ts +++ b/src/lib/interact-for-server-selection.ts @@ -2,15 +2,19 @@ import { randomBytes } from 'node:crypto' import { getConfigStore } from './config/index.js' import { + assertEnvVarUnset, + endpointEnvVar, + getEndpointFromEnv, getTokenFromEnv, tokenEnvVar, - warnEnvVarOverride, -} from './get-credentials.js' +} from './env.js' import { getServer } from './get-server.js' import { getOutput } from './output/get-output.js' import { prompt } from './util/prompt.js' export async function interactForServerSelection() { + assertEnvVarUnset(endpointEnvVar, getEndpointFromEnv(), 'select a server') + const servers = [ 'http://localhost:3020', 'https://connect.getseam.com', @@ -41,10 +45,10 @@ export async function interactForServerSelection() { if (userUrlSeed.trim().length === 0) { userUrlSeed = randomBytes(5).toString('hex') } + assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log in') config.set('server', `https://${userUrlSeed}.fakeseamconnect.seam.vc`) config.set(`${getServer()}.pat`, `seam_apikey1_token`) output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) - warnEnvVarOverride(tokenEnvVar, getTokenFromEnv(), 'token') } else { config.set('server', server) } diff --git a/src/lib/interact-for-workspace-id.ts b/src/lib/interact-for-workspace-id.ts index 439b1f44..357058df 100644 --- a/src/lib/interact-for-workspace-id.ts +++ b/src/lib/interact-for-workspace-id.ts @@ -2,10 +2,10 @@ import { SeamHttpWithoutWorkspace } from '@seamapi/http/connect' import { getConfigStore } from './config/index.js' import { + assertEnvVarUnset, getWorkspaceIdFromEnv, - warnEnvVarOverride, workspaceIdEnvVar, -} from './get-credentials.js' +} from './env.js' import { getSeamMultiWorkspace } from './get-seam.js' import { getServer } from './get-server.js' import { prompt } from './util/prompt.js' @@ -14,7 +14,11 @@ import { withLoading } from './util/with-loading.js' export const interactForWorkspaceId = async (personalAccessToken?: string) => { const config = getConfigStore() - warnEnvVarOverride(workspaceIdEnvVar, getWorkspaceIdFromEnv(), 'workspace') + assertEnvVarUnset( + workspaceIdEnvVar, + getWorkspaceIdFromEnv(), + 'select a workspace', + ) const seam = personalAccessToken ? SeamHttpWithoutWorkspace.fromPersonalAccessToken(personalAccessToken, { diff --git a/test/cli.test.ts b/test/cli.test.ts index cd455697..2a3301f3 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -22,9 +22,11 @@ const errorResponse = { } let server: Server +let endpoint: string let stateHome: string let configHome: string let loggedOutStateHome: string +let otherServerConfigHome: string let requests: Array<{ path: string body: unknown @@ -62,7 +64,7 @@ beforeAll(async () => { throw new Error('Could not determine the test server address') } // A host without dots: configstore reads nested keys by dot path. - const endpoint = `http://localhost:${address.port}` + endpoint = `http://localhost:${address.port}` // Settings live under the config dir, auth state under the state dir. const home = await mkdtemp(join(tmpdir(), 'seam-cli-test-')) @@ -82,6 +84,14 @@ beforeAll(async () => { // The same settings without a stored token, i.e., not logged in. loggedOutStateHome = join(home, 'logged-out-state') await mkdir(join(loggedOutStateHome, 'seam'), { recursive: true }) + + // Settings pointing at a server nothing is listening on. + otherServerConfigHome = join(home, 'other-server-config') + await mkdir(join(otherServerConfigHome, 'seam'), { recursive: true }) + await writeFile( + join(otherServerConfigHome, 'seam', 'cli.json'), + JSON.stringify({ server: 'http://localhost:1' }), + ) }) afterAll(async () => { @@ -99,10 +109,12 @@ const runCli = async ( { input, env, + configHome: configHomeOverride, stateHome: stateHomeOverride, }: { input?: string env?: Record + configHome?: string stateHome?: string } = {}, ): Promise => { @@ -112,12 +124,13 @@ const runCli = async ( { cwd: projectRoot, env: { - XDG_CONFIG_HOME: configHome, + XDG_CONFIG_HOME: configHomeOverride ?? configHome, XDG_STATE_HOME: stateHomeOverride ?? stateHome, FORCE_COLOR: '0', // Never inherit credentials from the environment running the tests. SEAM_CLI_TOKEN: undefined, SEAM_CLI_WORKSPACE_ID: undefined, + SEAM_CLI_ENDPOINT: undefined, ...env, }, input: input ?? '', @@ -281,6 +294,85 @@ test('cli: SEAM_CLI_WORKSPACE_ID sets the workspace for the request', async () = expect(requests[0]?.headers['seam-workspace']).toBe('workspace_from_env') }) +test('cli: SEAM_CLI_ENDPOINT wins over the stored server', async () => { + requests = [] + const { exitCode } = await runCli(['devices', 'list'], { + configHome: otherServerConfigHome, + env: { SEAM_CLI_ENDPOINT: endpoint }, + }) + + expect(exitCode).toBe(0) + expect(requests[0]?.path).toBe('/devices/list') +}) + +test('cli: uses the stored server without SEAM_CLI_ENDPOINT', async () => { + requests = [] + const { exitCode } = await runCli(['devices', 'list'], { + configHome: otherServerConfigHome, + }) + + expect(exitCode).toBe(1) + expect(requests).toHaveLength(0) +}) + +test('cli: refuses to log in while SEAM_CLI_TOKEN is set', async () => { + const { stdout, stderr, exitCode } = await runCli( + ['login', '--token', 'seam_apikey1_stored'], + { env: { SEAM_CLI_TOKEN: 'seam_apikey1_from_env' } }, + ) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toContain('Cannot log in while SEAM_CLI_TOKEN is set') + expect(stderr).not.toContain('CLI Error') +}) + +test('cli: refuses to select a workspace while SEAM_CLI_WORKSPACE_ID is set', async () => { + const { stdout, stderr, exitCode } = await runCli(['select', 'workspace'], { + env: { SEAM_CLI_WORKSPACE_ID: 'workspace_from_env' }, + }) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toContain( + 'Cannot select a workspace while SEAM_CLI_WORKSPACE_ID is set', + ) +}) + +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' } }, + ) + + expect(exitCode).toBe(1) + expect(stderr).toContain( + 'Cannot select a workspace while SEAM_CLI_WORKSPACE_ID is set', + ) +}) + +test('cli: refuses to select a server while SEAM_CLI_ENDPOINT is set', async () => { + const { stdout, stderr, exitCode } = await runCli( + ['select', 'server', '--server', 'https://connect.example.com'], + { env: { SEAM_CLI_ENDPOINT: endpoint } }, + ) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toContain( + 'Cannot select a server while SEAM_CLI_ENDPOINT is set', + ) +}) + +test('cli: refuses to log out while SEAM_CLI_TOKEN is set', async () => { + const { stderr, exitCode } = await runCli(['logout'], { + env: { SEAM_CLI_TOKEN: 'seam_apikey1_from_env' }, + }) + + expect(exitCode).toBe(1) + expect(stderr).toContain('Cannot log out while SEAM_CLI_TOKEN is set') +}) + test('cli: reports no workspace for a personal access token without one', async () => { const { stdout, stderr, exitCode } = await runCli(['devices', 'list'], { env: { SEAM_CLI_TOKEN: 'seam_at1_from_env' },