diff --git a/README.md b/README.md index cd9831a2..c04d8622 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 62a3e300..84bd4962 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -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' @@ -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` @@ -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 } @@ -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') @@ -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 @@ -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"', @@ -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') @@ -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 } diff --git a/src/lib/env.d.ts b/src/lib/env.d.ts new file mode 100644 index 00000000..95a0589a --- /dev/null +++ b/src/lib/env.d.ts @@ -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 {} 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..a109ec1b --- /dev/null +++ b/src/lib/env.ts @@ -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 +} diff --git a/src/lib/get-credentials.test.ts b/src/lib/get-credentials.test.ts new file mode 100644 index 00000000..9062fc03 --- /dev/null +++ b/src/lib/get-credentials.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +import { getConfigStore } from './config/index.js' +import { tokenEnvVar, workspaceIdEnvVar } from './env.js' +import { getToken, getWorkspaceId } from './get-credentials.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), +})) + +const clearEnv = (): void => { + delete process.env[tokenEnvVar] + delete process.env[workspaceIdEnvVar] +} + +beforeEach(() => { + for (const key of Object.keys(storedConfig)) { + delete storedConfig[key] + } + clearEnv() +}) + +afterEach(() => { + clearEnv() + 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: 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') +}) diff --git a/src/lib/get-credentials.ts b/src/lib/get-credentials.ts new file mode 100644 index 00000000..5e573654 --- /dev/null +++ b/src/lib/get-credentials.ts @@ -0,0 +1,38 @@ +import { getConfigStore } from './config/index.js' +import { getTokenFromEnv, getWorkspaceIdFromEnv } from './env.js' +import { getServer } from './get-server.js' + +/** + * The token used to authenticate requests. + * + * `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 => { + const token = getTokenFromEnv() + if (token != null) return token + + return readString(getConfigStore().get(`${getServer()}.pat`)) +} + +/** + * The workspace requests are made against. + * + * `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. + */ +export const getWorkspaceId = (): string | null => { + const workspaceId = getWorkspaceIdFromEnv() + if (workspaceId != null) return workspaceId + + return readString(getConfigStore().get('current_workspace_id')) +} + +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..7d252433 100644 --- a/src/lib/get-seam.ts +++ b/src/lib/get-seam.ts @@ -5,24 +5,29 @@ import { SeamHttpWithoutWorkspace, } from '@seamapi/http/connect' -import { getConfigStore } from './config/index.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 => { - 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 +36,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/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 94cec49e..4a9cf036 100644 --- a/src/lib/interact-for-login.ts +++ b/src/lib/interact-for-login.ts @@ -2,6 +2,7 @@ import { isApiKey, isPersonalAccessToken } from '@seamapi/http/connect' import chalk from 'chalk' import { getConfigStore } from './config/index.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' @@ -13,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`, diff --git a/src/lib/interact-for-server-selection.ts b/src/lib/interact-for-server-selection.ts index 2958fc34..32d8900b 100644 --- a/src/lib/interact-for-server-selection.ts +++ b/src/lib/interact-for-server-selection.ts @@ -1,11 +1,20 @@ import { randomBytes } from 'node:crypto' import { getConfigStore } from './config/index.js' +import { + assertEnvVarUnset, + endpointEnvVar, + getEndpointFromEnv, + getTokenFromEnv, + tokenEnvVar, +} 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', @@ -36,6 +45,7 @@ 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"`) diff --git a/src/lib/interact-for-workspace-id.ts b/src/lib/interact-for-workspace-id.ts index 04f58c02..357058df 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 { + assertEnvVarUnset, + getWorkspaceIdFromEnv, + workspaceIdEnvVar, +} from './env.js' import { getSeamMultiWorkspace } from './get-seam.js' import { getServer } from './get-server.js' import { prompt } from './util/prompt.js' @@ -8,6 +13,13 @@ import { withLoading } from './util/with-loading.js' export const interactForWorkspaceId = async (personalAccessToken?: string) => { const config = getConfigStore() + + assertEnvVarUnset( + workspaceIdEnvVar, + getWorkspaceIdFromEnv(), + 'select a workspace', + ) + const seam = personalAccessToken ? SeamHttpWithoutWorkspace.fromPersonalAccessToken(personalAccessToken, { endpoint: getServer(), diff --git a/test/cli.test.ts b/test/cli.test.ts index b785675e..07b5d707 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -22,9 +22,16 @@ const errorResponse = { } let server: Server +let endpoint: string let stateHome: string let configHome: string -let requests: Array<{ path: string; body: unknown }> = [] +let loggedOutStateHome: string +let otherServerConfigHome: string +let requests: Array<{ + path: string + body: unknown + headers: Record +}> = [] let failNextRequest = false beforeAll(async () => { @@ -32,7 +39,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 @@ -53,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-')) @@ -69,6 +80,18 @@ 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 }) + + // 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 () => { @@ -83,7 +106,17 @@ interface CliResult { const runCli = async ( args: string[], - { input }: { input?: string } = {}, + { + input, + env, + configHome: configHomeOverride, + stateHome: stateHomeOverride, + }: { + input?: string + env?: Record + configHome?: string + stateHome?: string + } = {}, ): Promise => { const { stdout, stderr, exitCode } = await execa( 'node', @@ -91,9 +124,14 @@ const runCli = async ( { cwd: projectRoot, env: { - XDG_CONFIG_HOME: configHome, - XDG_STATE_HOME: stateHome, + 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 ?? '', reject: false, @@ -315,6 +353,147 @@ 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: 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' }, + }) + + 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'])