diff --git a/README.md b/README.md index 756dafc2..2e6a1908 100644 --- a/README.md +++ b/README.md @@ -183,12 +183,12 @@ 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` +Everything `seam login`, `seam select workspace`, and `seam select endpoint` 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. +- `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 @@ -213,7 +213,7 @@ Personal Access Token works across workspaces, so it needs one from either 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_WORKSPACE_ID` is set, and `seam select endpoint` while `SEAM_CLI_ENDPOINT` is set. Unset the variable to use those commands. ```bash @@ -278,7 +278,7 @@ the loaders for all three shells. Completions are generated from the cached Seam API definitions, so they may briefly lag a newly released API. Pass `--update` to refresh the cache first, e.g., `seam completion bash --update`. They do not reflect definitions served -by another Seam API server when `seam config use-remote-api-defs` is enabled. +by another Seam API endpoint when `seam config use-remote-api-defs` is enabled. If completions do not appear after installing them system wide: diff --git a/TESTING.md b/TESTING.md index 3e789769..be5651e6 100644 --- a/TESTING.md +++ b/TESTING.md @@ -74,7 +74,7 @@ request is the product. "The prompt offered these choices with these hints" is behavior: the choices are what the user sees (`blueprint-object.test.ts` asserting on the recorded `choices` is the good in-repo example). "`resolveAuth` called `getConfigStore`" is -implementation: the contract is _what server comes back_, not how it was +implementation: the contract is _what endpoint comes back_, not how it was looked up. Even at a real boundary, prefer **capture-then-assert** over diff --git a/src/bin/cli.ts b/src/bin/cli.ts index d1aebc35..4a156515 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -122,12 +122,12 @@ async function cli(args: ParsedArgs, argv: string[]) { const localCommand = findLocalCommand(args._) // Commands declared not to need a token bypass the login gate. A partial - // path keeps the historical rule: only login and select server may be + // path keeps the historical rule: only login and select endpoint may be // reached logged out. const requiresAuth = localCommand != null ? localCommand.requiresAuth - : !(args._[0] === 'login' || isEqual(args._, ['select', 'server'])) + : !(args._[0] === 'login' || isEqual(args._, ['select', 'endpoint'])) if (requiresAuth && resolveAuth(config).token == null) { output.error(`Not logged in. Please run "seam login" or set ${tokenEnvVar}`) diff --git a/src/lib/args/validate.test.ts b/src/lib/args/validate.test.ts index 21a11aa4..665773c9 100644 --- a/src/lib/args/validate.test.ts +++ b/src/lib/args/validate.test.ts @@ -53,11 +53,15 @@ test('assertKnownArgs: names an endpoint command by its path', () => { test('assertKnownArgs: names a CLI command by its words', () => { expect(() => { - assertKnownArgs({ serverr: 'https://example.com' }, ['select', 'server'], { - accepted: new Set(['server']), - isLocal: true, - }) - }).toThrow('Unknown parameter for select server: --serverr') + assertKnownArgs( + { endpointt: 'https://example.com' }, + ['select', 'endpoint'], + { + accepted: new Set(['endpoint']), + isLocal: true, + }, + ) + }).toThrow('Unknown parameter for select endpoint: --endpointt') }) test('assertKnownArgs: names every unknown argument at once, with a hint', () => { diff --git a/src/lib/auth/operations.ts b/src/lib/auth/operations.ts index 1b968fe2..9670c32c 100644 --- a/src/lib/auth/operations.ts +++ b/src/lib/auth/operations.ts @@ -12,7 +12,7 @@ import { import { validateToken } from './validate-token.js' /** A stored auth setting an environment variable may override. */ -export type AuthSetting = 'server' | 'token' | 'workspaceId' +export type AuthSetting = 'endpoint' | 'token' | 'workspaceId' /** * Refuse to store a setting the environment overrides. @@ -29,10 +29,10 @@ export const assertMutable = ( action: string, ): void => { const { envVar, source, value } = { - server: { + endpoint: { envVar: endpointEnvVar, - source: auth.serverSource, - value: auth.server, + source: auth.endpointSource, + value: auth.endpoint, }, token: { envVar: tokenEnvVar, source: auth.tokenSource, value: auth.token }, workspaceId: { @@ -47,7 +47,7 @@ export const assertMutable = ( } export interface LoginOptions { - server?: string | undefined + endpoint?: string | undefined token?: string | undefined workspaceId?: string | undefined } @@ -55,13 +55,13 @@ export interface LoginOptions { /** * Store the given credentials, validating the token first. * - * The token is stored under the server it will be used with, so a given - * server is stored and re-resolved before the token key is derived. + * 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. * * Validation reaches the network, so a test may inject its own `validate`. */ export const login = async ( - { server, token, workspaceId }: LoginOptions, + { endpoint, token, workspaceId }: LoginOptions, config: ConfigStore = getConfigStore(), validate: typeof validateToken = validateToken, ): Promise => { @@ -70,20 +70,19 @@ export const login = async ( // Nothing is stored while the environment overrides it, so refuse before // storing anything rather than part way through. assertMutable(auth, 'token', 'log in') - if (server != null) assertMutable(auth, 'server', 'select a server') + if (endpoint != null) assertMutable(auth, 'endpoint', 'select an endpoint') if (workspaceId != null) { assertMutable(auth, 'workspaceId', 'select a workspace') } - if (server != null) { - config.set('server', server) - config.delete('current_workspace_id') + if (endpoint != null) { + storeEndpoint(endpoint, config) auth = resolveAuth(config) } if (token != null) { await validate(token, workspaceId) - config.set(`${auth.server}.pat`, token) + config.set(`${auth.endpoint}.pat`, token) config.delete('current_workspace_id') } @@ -92,39 +91,38 @@ export const login = async ( } } -/** Store the token for the current server, e.g., one just prompted for. */ +/** Store the token for the current endpoint, e.g., one just prompted for. */ export const storeToken = ( token: string, config: ConfigStore = getConfigStore(), ): void => { const auth = resolveAuth(config) assertMutable(auth, 'token', 'log in') - config.set(`${auth.server}.pat`, token) + config.set(`${auth.endpoint}.pat`, token) } /** Remove the stored token and workspace selection. */ export const logout = (config: ConfigStore = getConfigStore()): void => { const auth = resolveAuth(config) assertMutable(auth, 'token', 'log out') - config.delete(`${auth.server}.pat`) - // Configs written before tokens were stored per server may still hold an + config.delete(`${auth.endpoint}.pat`) + // Configs written before tokens were stored per endpoint may still hold an // un-namespaced token, so drop that too. config.delete('pat') config.delete('current_workspace_id') } /** - * Store the server to make requests against. + * Store the endpoint to make requests against. * - * The workspace selection belongs to the previous server, so it is cleared. + * The workspace selection belongs to the previous endpoint, so it is cleared. */ -export const selectServer = ( - server: string, +export const selectEndpoint = ( + endpoint: string, config: ConfigStore = getConfigStore(), ): void => { - assertMutable(resolveAuth(config), 'server', 'select a server') - config.set('server', server) - config.delete('current_workspace_id') + assertMutable(resolveAuth(config), 'endpoint', 'select an endpoint') + storeEndpoint(endpoint, config) } /** Store the workspace requests are made against. */ @@ -137,33 +135,43 @@ export const selectWorkspace = ( } /** - * Point the CLI at a fake Seam Connect server and store the well-known - * token it accepts. Returns the generated server URL for reporting. + * Point the CLI at a fake Seam Connect endpoint and store the well-known + * token it accepts. Returns the generated endpoint URL for reporting. */ -export const selectFakeServer = ({ +export const selectFakeEndpoint = ({ urlSeed = randomBytes(5).toString('hex'), config = getConfigStore(), }: { urlSeed?: string config?: ConfigStore -} = {}): { server: string; token: string } => { +} = {}): { endpoint: string; token: string } => { const auth = resolveAuth(config) - assertMutable(auth, 'server', 'select a server') + assertMutable(auth, 'endpoint', 'select an endpoint') assertMutable(auth, 'token', 'log in') - const server = `https://${urlSeed}.fakeseamconnect.seam.vc` + const endpoint = `https://${urlSeed}.fakeseamconnect.seam.vc` const token = 'seam_apikey1_token' - config.set('server', server) - config.set(`${server}.pat`, token) - config.delete('current_workspace_id') + storeEndpoint(endpoint, config) + config.set(`${endpoint}.pat`, token) - return { server, token } + return { endpoint, token } } -/** Store whether API definitions come from the server instead of npm. */ +/** Store whether API definitions come from the endpoint instead of npm. */ export const setUseRemoteApiDefs = ( useRemoteApiDefs: boolean, config: ConfigStore = getConfigStore(), ): void => { config.set('use_remote_api_defs', useRemoteApiDefs) } + +/** + * Write the endpoint, dropping what belonged to the previous one: the + * workspace selection, and any value left under the legacy `server` key that + * {@link resolveAuth} would otherwise still fall back to. + */ +const storeEndpoint = (endpoint: string, config: ConfigStore): void => { + config.set('endpoint', endpoint) + config.delete('server') + config.delete('current_workspace_id') +} diff --git a/src/lib/auth/validate-token.ts b/src/lib/auth/validate-token.ts index 394af062..918f5354 100644 --- a/src/lib/auth/validate-token.ts +++ b/src/lib/auth/validate-token.ts @@ -8,7 +8,7 @@ import { import { resolveAuth } from 'lib/context.js' export const validateToken = async (token: string, workspaceId?: string) => { - const options = { endpoint: resolveAuth().server } + const options = { endpoint: resolveAuth().endpoint } if (isPersonalAccessToken(token)) { const seam = workspaceId diff --git a/src/lib/blueprint/index.ts b/src/lib/blueprint/index.ts index 7d050869..f60570ee 100644 --- a/src/lib/blueprint/index.ts +++ b/src/lib/blueprint/index.ts @@ -7,7 +7,7 @@ export type ApiBlueprint = Blueprint export interface GetApiBlueprintOptions { /** - * Build from the OpenAPI document the configured server is currently + * Build from the OpenAPI document the configured endpoint is currently * running, instead of the published npm types. */ useRemoteDefinitions?: boolean @@ -19,8 +19,8 @@ export const getApiBlueprint = async ({ useRemoteDefinitions = false, update = false, }: GetApiBlueprintOptions = {}): Promise => { - // Remote definitions describe whatever the server is currently running, so - // build them directly from the server's OpenAPI document. + // Remote definitions describe whatever the endpoint is currently running, so + // build them directly from that endpoint's OpenAPI document. if (useRemoteDefinitions) return await createRemoteBlueprint() return await getBlueprint({ update }) diff --git a/src/lib/blueprint/source-remote.ts b/src/lib/blueprint/source-remote.ts index 6989a1cf..e05d7581 100644 --- a/src/lib/blueprint/source-remote.ts +++ b/src/lib/blueprint/source-remote.ts @@ -3,15 +3,15 @@ import type { Blueprint } from '@seamapi/blueprint' import { resolveAuth } from 'lib/context.js' /** - * Build a blueprint from the OpenAPI document the current server is running, - * describing exactly what that server accepts rather than what is published. + * Build a blueprint from the OpenAPI document the current endpoint is running, + * describing exactly what that endpoint accepts rather than what is published. */ export const createRemoteBlueprint = async (): Promise => { const [{ createBlueprint }, { getOpenapiSchema }] = await Promise.all([ import('@seamapi/blueprint'), import('@seamapi/http/connect'), ]) - const openapi = await getOpenapiSchema(resolveAuth().server) + const openapi = await getOpenapiSchema(resolveAuth().endpoint) return await createBlueprint({ openapi }, { omitUndocumented: true }) } diff --git a/src/lib/commands/local/config-set-fake-server.ts b/src/lib/commands/local/config-set-fake-server.ts deleted file mode 100644 index d91cdc09..00000000 --- a/src/lib/commands/local/config-set-fake-server.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { selectFakeServer } from 'lib/auth/operations.js' -import type { Command } from 'lib/commands/registry.js' - -/** Hidden: a development shortcut, kept out of help and completion. */ -export const configSetFakeServerCommand: Command = { - definition: { - path: ['config', 'set', 'fake-server'], - kind: 'cli', - title: 'Point the CLI at a fake Seam Connect server.', - description: '', - flags: [], - }, - requiresAuth: false, - hidden: true, - execute: async (_invocation, ctx) => { - const { server } = selectFakeServer({ config: ctx.config }) - ctx.output.info(`Server URL set to ${server}`) - ctx.output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) - return { kind: 'done' } - }, -} diff --git a/src/lib/commands/local/login.ts b/src/lib/commands/local/login.ts index fda343f0..e1cb7bba 100644 --- a/src/lib/commands/local/login.ts +++ b/src/lib/commands/local/login.ts @@ -12,17 +12,17 @@ export const loginCommand: Command = { description: 'Prompts for a personal access token unless one is passed with --token.', flags: [ - stringFlag('server', 'Seam API server to log in to.'), + 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.'), ], }, requiresAuth: false, execute: async ({ args }, ctx) => { - if (args['token'] || args['workspace_id'] || args['server']) { + if (args['token'] || args['workspace_id'] || args['endpoint']) { await login( { - server: args['server'] ? args['server'] : undefined, + endpoint: args['endpoint'] ? args['endpoint'] : undefined, token: args['token'] ? String(args['token']).trim() : undefined, workspaceId: args['workspace_id'] ? args['workspace_id'] : undefined, }, diff --git a/src/lib/commands/local/select-endpoint.ts b/src/lib/commands/local/select-endpoint.ts new file mode 100644 index 00000000..8f2b38eb --- /dev/null +++ b/src/lib/commands/local/select-endpoint.ts @@ -0,0 +1,30 @@ +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' + +export const selectEndpointCommand: Command = { + definition: { + path: ['select', 'endpoint'], + kind: 'cli', + title: 'Select the Seam API endpoint.', + description: '', + flags: [stringFlag('endpoint', 'Seam API endpoint to select.')], + }, + requiresAuth: false, + execute: async ({ args }, ctx) => { + assertMutable(ctx.auth, 'endpoint', 'select an endpoint') + if (args['endpoint']) { + selectEndpoint(args['endpoint'], ctx.config) + return { kind: 'done' } + } + if (ctx.interactivity === 'non-interactive') { + throw new NonInteractiveError( + 'Missing required parameter for select endpoint: --endpoint', + ) + } + await interactForEndpointSelection() + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/local/select-server.ts b/src/lib/commands/local/select-server.ts deleted file mode 100644 index f98c9658..00000000 --- a/src/lib/commands/local/select-server.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { assertMutable, selectServer } 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 { interactForServerSelection } from 'lib/interactions/index.js' - -export const selectServerCommand: Command = { - definition: { - path: ['select', 'server'], - kind: 'cli', - title: 'Select the Seam API server.', - description: '', - flags: [stringFlag('server', 'Seam API server to select.')], - }, - requiresAuth: false, - execute: async ({ args }, ctx) => { - assertMutable(ctx.auth, 'server', 'select a server') - if (args['server']) { - selectServer(args['server'], ctx.config) - return { kind: 'done' } - } - if (ctx.interactivity === 'non-interactive') { - throw new NonInteractiveError( - 'Missing required parameter for select server: --server', - ) - } - await interactForServerSelection() - return { kind: 'done' } - }, -} diff --git a/src/lib/commands/registry.ts b/src/lib/commands/registry.ts index f40175cb..750eb01c 100644 --- a/src/lib/commands/registry.ts +++ b/src/lib/commands/registry.ts @@ -7,12 +7,11 @@ import type { CliContext } from 'lib/context.js' import { executeApiCommand } from './api-command.js' import { createCompletionCommands } from './local/completion.js' import { configRevealLocationCommand } from './local/config-reveal-location.js' -import { configSetFakeServerCommand } from './local/config-set-fake-server.js' import { configUseRemoteApiDefsCommand } from './local/config-use-remote-api-defs.js' import { healthCommand } from './local/health.js' import { loginCommand } from './local/login.js' import { logoutCommand } from './local/logout.js' -import { selectServerCommand } from './local/select-server.js' +import { selectEndpointCommand } from './local/select-endpoint.js' import { selectWorkspaceCommand } from './local/select-workspace.js' import { wizardCommand } from './local/wizard.js' import { @@ -72,12 +71,11 @@ const completionCommands = createCompletionCommands( export const localCommands: Command[] = [ ...completionCommands, configRevealLocationCommand, - configSetFakeServerCommand, configUseRemoteApiDefsCommand, healthCommand, loginCommand, logoutCommand, - selectServerCommand, + selectEndpointCommand, selectWorkspaceCommand, wizardCommand, ] diff --git a/src/lib/context.ts b/src/lib/context.ts index 5bef8eb6..94e25dae 100644 --- a/src/lib/context.ts +++ b/src/lib/context.ts @@ -9,21 +9,21 @@ import { import type { SeamApi } from './http/api.js' import type { Output } from './output/output.js' -export const defaultServer = 'https://connect.getseam.com' +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' /** - * The server, token, and workspace requests are made with. + * 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 server falls back to Seam. + * variable wins over the stored value, and the endpoint falls back to Seam. * The source tags say where each value came from. */ export interface AuthContext { - server: string - serverSource: ValueSource + endpoint: string + endpointSource: ValueSource token: string | null tokenSource: Exclude | null workspaceId: string | null @@ -33,21 +33,25 @@ export interface AuthContext { export const resolveAuth = ( config: ConfigStore = getConfigStore(), ): AuthContext => { - const envServer = getEndpointFromEnv() - const storedServer = config.get('server') - const server = - envServer ?? (typeof storedServer === 'string' ? storedServer : null) + 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) const envToken = getTokenFromEnv() - const storedToken = readString(config.get(`${server ?? defaultServer}.pat`)) + const storedToken = readString( + config.get(`${endpoint ?? defaultEndpoint}.pat`), + ) const envWorkspaceId = getWorkspaceIdFromEnv() const storedWorkspaceId = readString(config.get('current_workspace_id')) return { - server: server ?? defaultServer, - serverSource: - envServer != null ? 'env' : server != null ? 'config' : 'default', + endpoint: endpoint ?? defaultEndpoint, + endpointSource: + envEndpoint != null ? 'env' : endpoint != null ? 'config' : 'default', token: envToken ?? storedToken, tokenSource: envToken != null ? 'env' : storedToken != null ? 'config' : null, diff --git a/src/lib/env.ts b/src/lib/env.ts index a3e114aa..6bcee2cf 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,5 +1,5 @@ /** - * Credentials and the server may be given in the environment. + * Credentials and the endpoint 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 @@ -12,7 +12,7 @@ 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`. */ +/** Overrides the endpoint stored by `seam select endpoint`. */ export const endpointEnvVar = 'SEAM_CLI_ENDPOINT' /** Every variable read here is declared on `ProcessEnv` in `env.d.ts`. */ diff --git a/src/lib/http/client.ts b/src/lib/http/client.ts index 3e8d2efb..ca37ba02 100644 --- a/src/lib/http/client.ts +++ b/src/lib/http/client.ts @@ -13,7 +13,7 @@ export const getSeam = async ( ): Promise => { const token = getRequiredToken(auth) - const options = { endpoint: auth.server } + const options = { endpoint: auth.endpoint } if (isPersonalAccessToken(token)) { return SeamHttp.fromPersonalAccessToken( @@ -38,7 +38,7 @@ export const getSeamMultiWorkspace = async ( auth: AuthContext = resolveAuth(), ): Promise => { const token = getRequiredToken(auth) - const options = { endpoint: auth.server } + const options = { endpoint: auth.endpoint } if (isPersonalAccessToken(token)) { return SeamHttpWithoutWorkspace.fromPersonalAccessToken(token, options) diff --git a/src/lib/interactions/server-selection.ts b/src/lib/interactions/endpoint-selection.ts similarity index 55% rename from src/lib/interactions/server-selection.ts rename to src/lib/interactions/endpoint-selection.ts index 26aba76b..c17595c3 100644 --- a/src/lib/interactions/server-selection.ts +++ b/src/lib/interactions/endpoint-selection.ts @@ -2,44 +2,47 @@ import { randomBytes } from 'node:crypto' import { assertMutable, - selectFakeServer, - selectServer, + selectEndpoint, + selectFakeEndpoint, } from 'lib/auth/operations.js' import { getConfigStore } from 'lib/config/index.js' import { resolveAuth } from 'lib/context.js' import { getOutput } from 'lib/output/get-output.js' import { promptAutocomplete, promptText } from 'lib/prompt.js' -export async function interactForServerSelection() { +export async function interactForEndpointSelection() { const config = getConfigStore() - assertMutable(resolveAuth(config), 'server', 'select a server') + assertMutable(resolveAuth(config), 'endpoint', 'select an endpoint') - const servers = [ + const endpoints = [ 'http://localhost:3020', 'https://connect.getseam.com', 'https://fakeseamconnect.seam.vc', ] // Searchable, as selecting a device or a command is. - const server = await promptAutocomplete({ - message: 'Select a server:', - choices: servers.map((server) => ({ label: server, value: server })), + const endpoint = await promptAutocomplete({ + message: 'Select an endpoint:', + choices: endpoints.map((endpoint) => ({ + label: endpoint, + value: endpoint, + })), }) const output = getOutput() - if (server === servers[2]) { + if (endpoint === endpoints[2]) { let userUrlSeed = await promptText({ message: - 'You can input a custom server URL or leave this field empty to use a new fakeserver.', + 'You can input a custom endpoint URL or leave this field empty to use a new fakeserver.', }) if (userUrlSeed.trim().length === 0) { userUrlSeed = randomBytes(5).toString('hex') } - selectFakeServer({ urlSeed: userUrlSeed, config }) + selectFakeEndpoint({ urlSeed: userUrlSeed, config }) output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) } else { - selectServer(server, config) + selectEndpoint(endpoint, config) } - output.info(`Server set to ${server}`) + output.info(`Endpoint set to ${endpoint}`) } diff --git a/src/lib/interactions/index.ts b/src/lib/interactions/index.ts index 6fc0b67b..88753e39 100644 --- a/src/lib/interactions/index.ts +++ b/src/lib/interactions/index.ts @@ -10,9 +10,9 @@ export * from './command-selection.js' export * from './connected-account.js' export * from './custom-metadata.js' export * from './device.js' +export * from './endpoint-selection.js' export * from './login.js' export * from './resource.js' -export * from './server-selection.js' export * from './timestamp.js' export * from './use-remote-api-defs.js' export * from './user-identity.js' diff --git a/src/lib/interactions/login.ts b/src/lib/interactions/login.ts index 683b8533..066836b7 100644 --- a/src/lib/interactions/login.ts +++ b/src/lib/interactions/login.ts @@ -19,9 +19,9 @@ export const interactForLogin = async () => { // Refuse before prompting: nothing typed here could be stored. assertMutable(auth, 'token', 'log in') - if (auth.server.includes('localhost')) { + if (auth.endpoint.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${auth.server}/admin/create_user_with_api_key`, + `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${auth.endpoint}/admin/create_user_with_api_key`, ) } else { output.info( diff --git a/src/lib/interactions/workspace-id.ts b/src/lib/interactions/workspace-id.ts index 4380878c..45c886c7 100644 --- a/src/lib/interactions/workspace-id.ts +++ b/src/lib/interactions/workspace-id.ts @@ -15,7 +15,7 @@ export const interactForWorkspaceId = async (personalAccessToken?: string) => { const seam = personalAccessToken ? SeamHttpWithoutWorkspace.fromPersonalAccessToken(personalAccessToken, { - endpoint: resolveAuth(config).server, + endpoint: resolveAuth(config).endpoint, }) : await getSeamMultiWorkspace() diff --git a/src/lib/prompt.test.ts b/src/lib/prompt.test.ts index ce2715de..1349c6d0 100644 --- a/src/lib/prompt.test.ts +++ b/src/lib/prompt.test.ts @@ -28,13 +28,13 @@ test('searchChoices: matches every term against the label and hint', () => { }) test('searchChoices: matches any part of a name, not only its start', () => { - const servers = [ + const endpoints = [ { label: 'http://localhost:3020' }, { label: 'https://connect.getseam.com' }, { label: 'https://fakeseamconnect.seam.vc' }, ] - expect(search('fake', servers)).toEqual([servers[2]]) + expect(search('fake', endpoints)).toEqual([endpoints[2]]) }) test('searchChoices: offers every choice until something is typed', () => { diff --git a/test/auth/operations.test.ts b/test/auth/operations.test.ts index 20c35811..80f88bb0 100644 --- a/test/auth/operations.test.ts +++ b/test/auth/operations.test.ts @@ -3,15 +3,15 @@ import { afterEach, beforeEach, expect, test } from 'vitest' import { login, logout, - selectFakeServer, - selectServer, + selectEndpoint, + selectFakeEndpoint, selectWorkspace, storeToken, } from 'lib/auth/operations.js' import { createMemoryConfigStore } from 'lib/config/memory-config-store.js' import { endpointEnvVar, tokenEnvVar, workspaceIdEnvVar } from 'lib/env.js' -const server = 'https://connect.example.com' +const endpoint = 'https://connect.example.com' /** * Validation is a network call, so it is faked at that edge: a capture of @@ -40,8 +40,8 @@ const clearEnv = (): void => { beforeEach(clearEnv) afterEach(clearEnv) -test('login: stores a validated token under the current server', async () => { - const store = createMemoryConfigStore({ server }) +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) @@ -49,27 +49,27 @@ test('login: stores a validated token under the current server', async () => { expect(validated).toEqual([ { token: 'seam_apikey1_stored', workspaceId: undefined }, ]) - expect(store.get(`${server}.pat`)).toBe('seam_apikey1_stored') + expect(store.get(`${endpoint}.pat`)).toBe('seam_apikey1_stored') }) -test('login: stores the token under a server given alongside it', async () => { - const store = createMemoryConfigStore({ server }) +test('login: stores the token under an endpoint given alongside it', async () => { + const store = createMemoryConfigStore({ endpoint }) const { validate } = createValidate() await login( - { server: 'https://other.example.com', token: 'seam_apikey1_stored' }, + { endpoint: 'https://other.example.com', token: 'seam_apikey1_stored' }, store, validate, ) - expect(store.get('server')).toBe('https://other.example.com') + expect(store.get('endpoint')).toBe('https://other.example.com') expect(store.get('https://other.example.com.pat')).toBe('seam_apikey1_stored') - expect(store.has(`${server}.pat`)).toBe(false) + expect(store.has(`${endpoint}.pat`)).toBe(false) }) test('login: a new login clears the previous workspace selection', async () => { const store = createMemoryConfigStore({ - server, + endpoint, current_workspace_id: 'workspace1', }) const { validate } = createValidate() @@ -80,7 +80,7 @@ test('login: a new login clears the previous workspace selection', async () => { }) test('login: stores a workspace given with the token', async () => { - const store = createMemoryConfigStore({ server }) + const store = createMemoryConfigStore({ endpoint }) const { validate, validated } = createValidate() await login( @@ -97,29 +97,29 @@ test('login: stores a workspace given with the token', async () => { test(`login: refuses while ${tokenEnvVar} is set, before storing anything`, async () => { process.env[tokenEnvVar] = 'seam_apikey1_env' - const store = createMemoryConfigStore({ server }) + 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`) - expect(store.has(`${server}.pat`)).toBe(false) + expect(store.has(`${endpoint}.pat`)).toBe(false) expect(validated).toEqual([]) }) -test(`login: refuses a server while ${endpointEnvVar} is set`, async () => { - process.env[endpointEnvVar] = server +test(`login: refuses an endpoint while ${endpointEnvVar} is set`, async () => { + process.env[endpointEnvVar] = endpoint const store = createMemoryConfigStore() const { validate } = createValidate() await expect( - login({ server: 'https://other.example.com' }, store, validate), - ).rejects.toThrow(`Cannot select a server while ${endpointEnvVar} is set`) + 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' - const store = createMemoryConfigStore({ server }) + const store = createMemoryConfigStore({ endpoint }) const { validate } = createValidate() await expect( @@ -133,25 +133,25 @@ test(`login: refuses a workspace while ${workspaceIdEnvVar} is set`, async () => ) }) -test('storeToken: stores under the current server without validating', () => { - const store = createMemoryConfigStore({ server }) +test('storeToken: stores under the current endpoint without validating', () => { + const store = createMemoryConfigStore({ endpoint }) storeToken('seam_apikey1_stored', store) - expect(store.get(`${server}.pat`)).toBe('seam_apikey1_stored') + expect(store.get(`${endpoint}.pat`)).toBe('seam_apikey1_stored') }) test('logout: removes the stored token, legacy token, and workspace', () => { const store = createMemoryConfigStore({ - server, - [`${server}.pat`]: 'seam_apikey1_stored', + endpoint, + [`${endpoint}.pat`]: 'seam_apikey1_stored', pat: 'seam_apikey1_legacy', current_workspace_id: 'workspace1', }) logout(store) - expect(store.has(`${server}.pat`)).toBe(false) + expect(store.has(`${endpoint}.pat`)).toBe(false) expect(store.has('pat')).toBe(false) expect(store.has('current_workspace_id')).toBe(false) }) @@ -159,32 +159,41 @@ test('logout: removes the stored token, legacy token, and workspace', () => { test(`logout: refuses while ${tokenEnvVar} is set`, () => { process.env[tokenEnvVar] = 'seam_apikey1_env' const store = createMemoryConfigStore({ - server, - [`${server}.pat`]: 'seam_apikey1_stored', + endpoint, + [`${endpoint}.pat`]: 'seam_apikey1_stored', }) expect(() => { logout(store) }).toThrow(`Cannot log out while ${tokenEnvVar} is set`) - expect(store.get(`${server}.pat`)).toBe('seam_apikey1_stored') + expect(store.get(`${endpoint}.pat`)).toBe('seam_apikey1_stored') }) -test('selectServer: stores the server and clears the workspace', () => { +test('selectEndpoint: stores the endpoint and clears the workspace', () => { const store = createMemoryConfigStore({ current_workspace_id: 'workspace1' }) - selectServer(server, store) + selectEndpoint(endpoint, store) - expect(store.get('server')).toBe(server) + expect(store.get('endpoint')).toBe(endpoint) expect(store.has('current_workspace_id')).toBe(false) }) -test(`selectServer: refuses while ${endpointEnvVar} is set`, () => { +test('selectEndpoint: drops an endpoint left under the legacy key', () => { + const store = createMemoryConfigStore({ server: 'https://old.example.com' }) + + selectEndpoint(endpoint, store) + + expect(store.get('endpoint')).toBe(endpoint) + expect(store.has('server')).toBe(false) +}) + +test(`selectEndpoint: refuses while ${endpointEnvVar} is set`, () => { process.env[endpointEnvVar] = 'http://localhost:3020' const store = createMemoryConfigStore() expect(() => { - selectServer(server, store) - }).toThrow(`Cannot select a server while ${endpointEnvVar} is set`) + selectEndpoint(endpoint, store) + }).toThrow(`Cannot select an endpoint while ${endpointEnvVar} is set`) }) test('selectWorkspace: stores the workspace selection', () => { @@ -204,25 +213,25 @@ test(`selectWorkspace: refuses while ${workspaceIdEnvVar} is set`, () => { }).toThrow(`Cannot select a workspace while ${workspaceIdEnvVar} is set`) }) -test('selectFakeServer: stores the server and its well-known token', () => { +test('selectFakeEndpoint: stores the endpoint and its well-known token', () => { const store = createMemoryConfigStore({ current_workspace_id: 'workspace1' }) - const { server: fakeServer } = selectFakeServer({ + const { endpoint: fakeEndpoint } = selectFakeEndpoint({ urlSeed: 'abc123', config: store, }) - expect(fakeServer).toBe('https://abc123.fakeseamconnect.seam.vc') - expect(store.get('server')).toBe(fakeServer) - expect(store.get(`${fakeServer}.pat`)).toBe('seam_apikey1_token') + expect(fakeEndpoint).toBe('https://abc123.fakeseamconnect.seam.vc') + expect(store.get('endpoint')).toBe(fakeEndpoint) + expect(store.get(`${fakeEndpoint}.pat`)).toBe('seam_apikey1_token') expect(store.has('current_workspace_id')).toBe(false) }) -test(`selectFakeServer: refuses while ${endpointEnvVar} is set`, () => { - process.env[endpointEnvVar] = server +test(`selectFakeEndpoint: refuses while ${endpointEnvVar} is set`, () => { + process.env[endpointEnvVar] = endpoint const store = createMemoryConfigStore() - expect(() => selectFakeServer({ urlSeed: 'abc123', config: store })).toThrow( - `Cannot select a server while ${endpointEnvVar} is set`, - ) + expect(() => + selectFakeEndpoint({ urlSeed: 'abc123', config: store }), + ).toThrow(`Cannot select an endpoint while ${endpointEnvVar} is set`) }) diff --git a/test/cli.test.ts b/test/cli.test.ts index 5142338c..55df0042 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -29,7 +29,7 @@ let stateHome: string let configHome: string let cacheHome: string let loggedOutStateHome: string -let otherServerConfigHome: string +let otherEndpointConfigHome: string let requests: Array<{ path: string body: unknown @@ -77,7 +77,7 @@ beforeAll(async () => { await mkdir(join(stateHome, 'seam'), { recursive: true }) await writeFile( join(configHome, 'seam', 'cli.json'), - JSON.stringify({ server: endpoint }), + JSON.stringify({ endpoint }), ) await writeFile( join(stateHome, 'seam', 'cli.json'), @@ -88,12 +88,12 @@ beforeAll(async () => { 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 }) + // Settings pointing at an endpoint nothing is listening on. + otherEndpointConfigHome = join(home, 'other-endpoint-config') + await mkdir(join(otherEndpointConfigHome, 'seam'), { recursive: true }) await writeFile( - join(otherServerConfigHome, 'seam', 'cli.json'), - JSON.stringify({ server: 'http://localhost:1' }), + join(otherEndpointConfigHome, 'seam', 'cli.json'), + JSON.stringify({ endpoint: 'http://localhost:1' }), ) // A pre-seeded blueprint cache holding the fixture blueprint, so tests @@ -257,15 +257,15 @@ test('cli: reports an unknown argument rather than sending it', async () => { test('cli: reports an unknown argument to a command it handles itself', async () => { const { stdout, stderr, exitCode } = await runCli([ 'select', - 'server', - '--serverr', + 'endpoint', + '--endpointt', 'https://example.com', ]) expect(exitCode).toBe(1) expect(stdout).toBe('') - expect(stderr).toContain('Unknown parameter for select server: --serverr') - expect(stderr).toContain("Run 'seam select server --help'") + expect(stderr).toContain('Unknown parameter for select endpoint: --endpointt') + expect(stderr).toContain("Run 'seam select endpoint --help'") }) test('cli: reports an unknown argument to a command taking none', async () => { @@ -526,10 +526,10 @@ 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 () => { +test('cli: SEAM_CLI_ENDPOINT wins over the stored endpoint', async () => { requests = [] const { exitCode } = await runCli(['devices', 'list'], { - configHome: otherServerConfigHome, + configHome: otherEndpointConfigHome, env: { SEAM_CLI_ENDPOINT: endpoint }, }) @@ -537,10 +537,10 @@ test('cli: SEAM_CLI_ENDPOINT wins over the stored server', async () => { expect(requests[0]?.path).toBe('/devices/list') }) -test('cli: uses the stored server without SEAM_CLI_ENDPOINT', async () => { +test('cli: uses the stored endpoint without SEAM_CLI_ENDPOINT', async () => { requests = [] const { exitCode } = await runCli(['devices', 'list'], { - configHome: otherServerConfigHome, + configHome: otherEndpointConfigHome, }) expect(exitCode).toBe(1) @@ -583,16 +583,16 @@ test('cli: refuses to log in with a workspace while SEAM_CLI_WORKSPACE_ID is set ) }) -test('cli: refuses to select a server while SEAM_CLI_ENDPOINT is set', async () => { +test('cli: refuses to select an endpoint while SEAM_CLI_ENDPOINT is set', async () => { const { stdout, stderr, exitCode } = await runCli( - ['select', 'server', '--server', 'https://connect.example.com'], + ['select', 'endpoint', '--endpoint', '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', + 'Cannot select an endpoint while SEAM_CLI_ENDPOINT is set', ) }) @@ -606,7 +606,7 @@ test('cli: logout removes the stored token and workspace', async () => { stateFile, JSON.stringify({ [endpoint]: { pat: 'seam_apikey1_token' }, - // A token stored before tokens were kept per server. + // A token stored before tokens were kept per endpoint. pat: 'seam_apikey1_legacy', current_workspace_id: 'workspace1', }), @@ -703,7 +703,7 @@ test('cli: logout leaves the cli unauthenticated', async () => { expect(stderr).toContain('Not logged in') }) -test('cli: logout keeps a token stored for another server', async () => { +test('cli: logout keeps a token stored for another endpoint', async () => { const home = await createLoggedInStateHome() await writeFile( join(home, 'seam', 'cli.json'), diff --git a/test/commands/registry.test.ts b/test/commands/registry.test.ts index 2d5e84c4..5a72ca26 100644 --- a/test/commands/registry.test.ts +++ b/test/commands/registry.test.ts @@ -30,13 +30,7 @@ test('registry: every visible local command is in the spec', () => { } }) -test('registry: hidden commands are findable without being offered', () => { - const fakeServer = registry.find(['config', 'set', 'fake-server']) - expect(fakeServer?.hidden).toBe(true) - expect(fakeServer?.requiresAuth).toBe(false) -}) - -test('registry: only commands for logging in and selecting a server skip auth', () => { +test('registry: only commands for logging in and selecting an endpoint skip auth', () => { const noAuth = localCommands .filter(({ requiresAuth }) => !requiresAuth) .map(({ definition }) => definition.path.join(' ')) @@ -45,9 +39,8 @@ test('registry: only commands for logging in and selecting a server skip auth', 'completion bash', 'completion fish', 'completion zsh', - 'config set fake-server', 'login', - 'select server', + 'select endpoint', 'wizard', ]) }) @@ -68,6 +61,6 @@ test('acceptedParamsOf: names the parameters behind the flags', () => { expect(login).toBeDefined() if (login == null) return expect(acceptedParamsOf(login.definition)).toEqual( - new Set(['server', 'token', 'workspace_id']), + new Set(['endpoint', 'token', 'workspace_id']), ) }) diff --git a/test/commands/spec.test.ts b/test/commands/spec.test.ts index 06c141cc..c804ecc7 100644 --- a/test/commands/spec.test.ts +++ b/test/commands/spec.test.ts @@ -21,7 +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([ - 'server', + 'endpoint', 'token', 'workspace-id', ]) diff --git a/test/context.test.ts b/test/context.test.ts index 33a9e86a..b4246ead 100644 --- a/test/context.test.ts +++ b/test/context.test.ts @@ -4,7 +4,7 @@ import { createMemoryConfigStore } from 'lib/config/memory-config-store.js' import { resolveAuth } from 'lib/context.js' import { endpointEnvVar, tokenEnvVar, workspaceIdEnvVar } from 'lib/env.js' -const server = 'https://connect.example.com' +const endpoint = 'https://connect.example.com' const store = createMemoryConfigStore @@ -17,60 +17,78 @@ const clearEnv = (): void => { beforeEach(clearEnv) afterEach(clearEnv) -test('resolveAuth: reads the stored server', () => { - const auth = resolveAuth(store({ server })) +test('resolveAuth: reads the stored endpoint', () => { + const auth = resolveAuth(store({ endpoint })) - expect(auth.server).toBe(server) - expect(auth.serverSource).toBe('config') + expect(auth.endpoint).toBe(endpoint) + expect(auth.endpointSource).toBe('config') }) -test('resolveAuth: defaults the server to Seam', () => { +test('resolveAuth: defaults the endpoint to Seam', () => { const auth = resolveAuth(store()) - expect(auth.server).toBe('https://connect.getseam.com') - expect(auth.serverSource).toBe('default') + expect(auth.endpoint).toBe('https://connect.getseam.com') + expect(auth.endpointSource).toBe('default') }) -test(`resolveAuth: ${endpointEnvVar} wins over the stored server`, () => { +test('resolveAuth: reads an endpoint stored under the legacy key', () => { + const auth = resolveAuth(store({ server: endpoint })) + + expect(auth.endpoint).toBe(endpoint) + expect(auth.endpointSource).toBe('config') +}) + +test('resolveAuth: the stored endpoint wins over the legacy key', () => { + const auth = resolveAuth( + store({ endpoint, server: 'https://old.example.com' }), + ) + + expect(auth.endpoint).toBe(endpoint) +}) + +test(`resolveAuth: ${endpointEnvVar} wins over the stored endpoint`, () => { process.env[endpointEnvVar] = 'http://localhost:3020' - const auth = resolveAuth(store({ server })) + const auth = resolveAuth(store({ endpoint })) - expect(auth.server).toBe('http://localhost:3020') - expect(auth.serverSource).toBe('env') + expect(auth.endpoint).toBe('http://localhost:3020') + expect(auth.endpointSource).toBe('env') }) -test(`resolveAuth: ${endpointEnvVar} is used without a stored server`, () => { +test(`resolveAuth: ${endpointEnvVar} is used without a stored endpoint`, () => { process.env[endpointEnvVar] = 'http://localhost:3020' - expect(resolveAuth(store()).server).toBe('http://localhost:3020') + expect(resolveAuth(store()).endpoint).toBe('http://localhost:3020') }) test(`resolveAuth: ignores an empty ${endpointEnvVar}`, () => { process.env[endpointEnvVar] = '' - const auth = resolveAuth(store({ server })) + const auth = resolveAuth(store({ endpoint })) - expect(auth.server).toBe(server) - expect(auth.serverSource).toBe('config') + expect(auth.endpoint).toBe(endpoint) + expect(auth.endpointSource).toBe('config') }) -test('resolveAuth: reads the token stored for the current server', () => { +test('resolveAuth: reads the token stored for the current endpoint', () => { const auth = resolveAuth( - store({ server, [`${server}.pat`]: 'seam_apikey1_stored' }), + store({ + endpoint, + [`${endpoint}.pat`]: 'seam_apikey1_stored', + }), ) expect(auth.token).toBe('seam_apikey1_stored') expect(auth.tokenSource).toBe('config') }) -test(`resolveAuth: the token stored for ${endpointEnvVar} wins over the stored server's`, () => { +test(`resolveAuth: the token stored for ${endpointEnvVar} wins over the stored endpoint's`, () => { process.env[endpointEnvVar] = 'http://localhost:3020' const auth = resolveAuth( store({ - server, - [`${server}.pat`]: 'seam_apikey1_stored', + endpoint, + [`${endpoint}.pat`]: 'seam_apikey1_stored', 'http://localhost:3020.pat': 'seam_apikey1_local', }), ) @@ -82,7 +100,10 @@ test(`resolveAuth: ${tokenEnvVar} wins over the stored token`, () => { process.env[tokenEnvVar] = 'seam_apikey1_env' const auth = resolveAuth( - store({ server, [`${server}.pat`]: 'seam_apikey1_stored' }), + store({ + endpoint, + [`${endpoint}.pat`]: 'seam_apikey1_stored', + }), ) expect(auth.token).toBe('seam_apikey1_env') @@ -99,7 +120,10 @@ test(`resolveAuth: ignores an empty ${tokenEnvVar}`, () => { process.env[tokenEnvVar] = ' ' const auth = resolveAuth( - store({ server, [`${server}.pat`]: 'seam_apikey1_stored' }), + store({ + endpoint, + [`${endpoint}.pat`]: 'seam_apikey1_stored', + }), ) expect(auth.token).toBe('seam_apikey1_stored') @@ -154,8 +178,8 @@ test('resolveAuth: each value resolves on its own', () => { const auth = resolveAuth( store({ - server, - [`${server}.pat`]: 'seam_apikey1_stored', + endpoint, + [`${endpoint}.pat`]: 'seam_apikey1_stored', current_workspace_id: 'workspace1', }), )