diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts index a9e090a..ae4f65d 100644 --- a/cli/src/commands/login.ts +++ b/cli/src/commands/login.ts @@ -10,9 +10,11 @@ import type { WhoamiResponse } from "@/features/management/model.ts"; import { formatWhoamiPrincipalLine } from "@/features/management/render.ts"; import { configureRunClear } from "@/lib/profile-configure-core.ts"; import { + assertProfileHasNoEnvCredentials, createEmptyProfile, deriveProfileName, profileExists, + profileHasAnyAuthConfigured, resolveProfileName, setActiveProfile, updateProfile, @@ -59,6 +61,15 @@ function selectLoginProfile( return { profileName: currentProfile, profileAction: "replaced" }; } + // Sign into the current profile while it has no credentials of its own yet — a + // fresh `default` or a just-created empty profile. Only branch to a new profile + // once the current one is already authenticated, so a second login doesn't + // clobber an existing session. (Env credentials never reach here — login is + // refused up front while they are set.) + if (!profileHasAnyAuthConfigured(currentProfile)) { + return { profileName: currentProfile, profileAction: "unchanged" }; + } + const targetProfile = loginProfileName(whoami, environment, currentProfile); if (targetProfile === currentProfile) { return { profileName: targetProfile, profileAction: "unchanged" }; @@ -155,6 +166,7 @@ async function fetchLoginWhoami(oauthResponse: TokenResponse): Promise { + assertProfileHasNoEnvCredentials("altertable login"); assertInteractiveLogin(); applyControlPlaneOverride(args); diff --git a/cli/src/commands/profile.ts b/cli/src/commands/profile.ts index 3a50712..6214eab 100644 --- a/cli/src/commands/profile.ts +++ b/cli/src/commands/profile.ts @@ -2,11 +2,7 @@ import { asCliArgString } from "@/lib/cli-args.ts"; import { getCliContext, isJsonOutput, setCliContext } from "@/context.ts"; import { CliError, ConfigurationError } from "@/lib/errors.ts"; import { withConfigureProfileContext } from "@/lib/profile-configure-core.ts"; -import { - buildConfigureShowData, - configureCredentialStatus, - type ConfigureShowData, -} from "@/features/profile/model.ts"; +import type { ProfileInspect } from "@/features/profile/model.ts"; import type { ConfigureAuthPlane } from "@/lib/profile-status.ts"; import { configureVerify } from "@/lib/profile-status.ts"; import { @@ -14,11 +10,6 @@ import { runProfileConfigure, type ConfigureCommandArgs, } from "@/lib/profile-configure.ts"; -import { buildActiveContext } from "@/features/profile/model.ts"; -import { managementWhoamiOperation } from "@/lib/management-operations.ts"; -import type { OperationContext } from "@/lib/operation-command.ts"; -import { runOperationPlan } from "@/lib/operation-effect.ts"; -import type { WhoamiResponse } from "@/features/management/model.ts"; import { findFirstPositionalToken, valueFlagsFor } from "@/lib/command-delegation.ts"; import { defaultConfigurePrompts, @@ -28,20 +19,18 @@ import { defineLocalCommand, defineValueCommand } from "@/lib/operation-command- import { buildProfileDirenvView, buildProfileShellExportView, - profileShowToJson, profileStatusToJson, profileSwitchOption, - type ProfileShowResult, type ProfileStatusResult, } from "@/features/profile/views.ts"; import { formatProfileInspect, formatProfileList, - formatProfileShow, formatProfileStatus, } from "@/features/profile/render.ts"; import { renderShellExportView } from "@/ui/shell/render.ts"; import { + assertProfileHasNoEnvCredentials, createEmptyProfile, deleteProfile, getActiveProfileName, @@ -77,12 +66,12 @@ function profileNameArgOrActive(args: Record): string { return args.name ? requireProfileName(args.name) : getActiveProfileName(); } -function configuredVerificationPlanes(configuration: ConfigureShowData): ConfigureAuthPlane[] { +function configuredVerificationPlanes(profile: ProfileInspect): ConfigureAuthPlane[] { const planes: ConfigureAuthPlane[] = []; - if (configuration.credentials.management.configured) { + if (profile.auth.management !== "none") { planes.push("management"); } - if (configuration.credentials.lakehouse.configured) { + if (profile.auth.lakehouse !== "none") { planes.push("lakehouse"); } return planes; @@ -166,69 +155,28 @@ function profileShowTargetName(args: Record): string { return getCliContext().profile ?? getActiveProfileName(); } -async function fetchProfileIdentity( - context: OperationContext, -): Promise { - try { - const enriched = await runOperationPlan( - managementWhoamiOperation.plan(buildActiveContext(), context), - context, - ); - if (enriched.principal === undefined && enriched.organization === undefined) { - return undefined; - } - return { principal: enriched.principal ?? {}, organization: enriched.organization ?? {} }; - } catch { - // Best-effort: fall back to stored/blank identity when whoami is unreachable. - return undefined; - } -} - -const profileShowCommand = defineLocalCommand< - { profileName: string; config: boolean }, - ProfileShowResult & { config: boolean } ->({ +const profileShowCommand = defineLocalCommand<{ profileName: string }, ProfileInspect>({ id: "profile.show", localConfig: true, output: "normalized", meta: { name: "show", - description: "Show active profile identity and credential details", + description: "Show a profile's stored identity, auth, and endpoints", }, args: { name: { type: "string", description: "Profile name (default: active profile)" }, - config: { - type: "boolean", - description: "Include CLI config paths (config dir, profile config file, secret store)", - }, }, parse({ args }) { - return { - profileName: existingProfileName(profileShowTargetName(args)), - config: Boolean(args.config), - }; + return { profileName: existingProfileName(profileShowTargetName(args)) }; }, - async local(input, context) { - const previous = getCliContext(); - try { - const next = { ...previous, profile: input.profileName }; - setCliContext(next); - refreshCliRuntimeContext(next); - const configuration = buildConfigureShowData(); - const identity = configureCredentialStatus().hasManagement - ? await fetchProfileIdentity(context) - : undefined; - return { configuration, identity, config: input.config }; - } finally { - setCliContext(previous); - refreshCliRuntimeContext(previous); - } + local(input) { + return inspectProfile(input.profileName); }, present(result) { return { kind: "normalized", - data: profileShowToJson(result), - humanText: formatProfileShow(result, { config: result.config }), + data: { profile: result }, + humanText: formatProfileInspect(result), }; }, }); @@ -247,6 +195,7 @@ function createProfileUseCommand(id: string, name: string, hidden = false) { return requireProfileName(args.name); }, local(profileName) { + assertProfileHasNoEnvCredentials("Switching profiles"); setActiveProfile(profileName); return profileName; }, @@ -274,6 +223,7 @@ const profileSwitchCommand = defineLocalCommand({ return args.name ? requireProfileName(args.name) : undefined; }, async local(profileName) { + assertProfileHasNoEnvCredentials("Switching profiles"); if (!profileName) { if (isJsonOutput(getCliContext()) || getCliContext().agent || process.stdin.isTTY !== true) { throw new CliError("Interactive profile switch requires a TTY. Pass a profile name."); @@ -371,18 +321,15 @@ const profileStatusCommand = defineLocalCommand({ parse({ args }) { return existingProfileName(profileShowTargetName(args)); }, - async local(profileName, context) { + async local(profileName) { const previous = getCliContext(); try { const next = { ...previous, profile: profileName }; setCliContext(next); refreshCliRuntimeContext(next); - const configuration = buildConfigureShowData(); - const identity = configureCredentialStatus().hasManagement - ? await fetchProfileIdentity(context) - : undefined; - const verification = await configureVerify(configuredVerificationPlanes(configuration)); - return { configuration, identity, verification }; + const profile = inspectProfile(profileName); + const verification = await configureVerify(configuredVerificationPlanes(profile)); + return { profile, verification }; } finally { setCliContext(previous); refreshCliRuntimeContext(previous); diff --git a/cli/src/features/profile/model.ts b/cli/src/features/profile/model.ts index eb9e1fd..52d67ec 100644 --- a/cli/src/features/profile/model.ts +++ b/cli/src/features/profile/model.ts @@ -12,6 +12,7 @@ import { import { assertSafeProfileName, DEFAULT_PROFILE_NAME, + FROM_ENV_PSEUDOPROFILE_NAME, type ProfileConfigKey, ensureProfileExists, ensureProfilesLayout, @@ -21,6 +22,7 @@ import { profileDir, profileExists, readProfileConfigRecord, + resolveProfileName, setActiveProfile, writeProfileConfig, } from "@/lib/profile-store.ts"; @@ -30,6 +32,7 @@ export { DEFAULT_PROFILE_NAME, ensureProfileExists, ensureProfilesLayout, + FROM_ENV_PSEUDOPROFILE_NAME, getActiveProfileName, profileConfigFile, profileExists, @@ -196,6 +199,11 @@ function profileAuth(name: string): ProfileAuth { }; } +export function profileHasAnyAuthConfigured(name: string): boolean { + const auth = profileAuth(name); + return auth.management !== "none" || auth.lakehouse !== "none"; +} + function readProfileSnapshot(name: string): ProfileSnapshot { return { config: readProfileConfigRecord(name), @@ -486,6 +494,34 @@ function hasStoredLakehouseCredentials(): boolean { return secretExists("lakehouse/basic-token") || secretExists("lakehouse/password"); } +export function hasCredentialsThroughEnv(): boolean { + return hasEnvManagementCredentials() || hasEnvLakehouseCredentials(); +} + +/** + * The profile identity currently in effect. Normally the active stored profile, + * but environment credentials override any stored profile for the whole process, + * so they surface as the reserved `_from_env` pseudo-profile. + */ +export function resolveActiveProfileName(): string { + return hasCredentialsThroughEnv() + ? FROM_ENV_PSEUDOPROFILE_NAME + : resolveProfileName(getCliContext().profile); +} + +/** + * Guard for commands that mutate stored-profile state (login, switching). While + * credentials come from the environment there is no stored profile to act on — + * the env vars pin the identity and would override any change — so refuse. + */ +export function assertProfileHasNoEnvCredentials(action: string): void { + if (resolveActiveProfileName() === FROM_ENV_PSEUDOPROFILE_NAME) { + throw new ConfigurationError( + `${action} is disabled while credentials come from the environment. Unset ALTERTABLE_API_KEY / ALTERTABLE_BASIC_AUTH_TOKEN / ALTERTABLE_LAKEHOUSE_USERNAME / ALTERTABLE_LAKEHOUSE_PASSWORD to manage stored profiles.`, + ); + } +} + export function configureCredentialStatus(): ConfigureCredentialStatus { return { hasManagement: diff --git a/cli/src/features/profile/render.ts b/cli/src/features/profile/render.ts index e1d6935..1461c75 100644 --- a/cli/src/features/profile/render.ts +++ b/cli/src/features/profile/render.ts @@ -3,11 +3,8 @@ import { buildActiveContextSummaryView, buildProfileInspectView, buildProfileListView, - buildProfileShowView, buildProfileStatusView, configureAuthenticationRows, - type ProfileShowResult, - type ProfileShowViewOptions, type ProfileStatusResult, } from "@/features/profile/views.ts"; import { @@ -33,13 +30,6 @@ export function formatProfileInspect(profile: ProfileInspect): string { return renderDocumentText(buildProfileInspectView(profile)); } -export function formatProfileShow( - result: ProfileShowResult, - options: ProfileShowViewOptions = {}, -): string { - return renderDocumentText(buildProfileShowView(result, options)); -} - export function formatProfileStatus(result: ProfileStatusResult): string { return renderDocumentText(buildProfileStatusView(result)); } diff --git a/cli/src/features/profile/views.ts b/cli/src/features/profile/views.ts index 03b878e..26b450f 100644 --- a/cli/src/features/profile/views.ts +++ b/cli/src/features/profile/views.ts @@ -10,7 +10,6 @@ import { section, table, text, - type DisplayBlock, type DisplayDocument, type DisplayRow, } from "@/ui/document.ts"; @@ -21,9 +20,7 @@ import { terminalDescription, terminalHighlightCommands, terminalNotConfiguredStatus, - terminalStrong, } from "@/ui/terminal/styles.ts"; -import type { WhoamiResponse } from "@/features/management/model.ts"; import type { ActiveContext, ConfigureCredentialStatus, @@ -173,132 +170,8 @@ export function buildProfileDirenvView(profileName: string): ShellExportView { }; } -export type ProfileShowResult = { - configuration: ConfigureShowData; - identity?: WhoamiResponse; -}; - -function whoamiPrincipalValue( - identity: WhoamiResponse | undefined, - configuration: ConfigureShowData, -): string { - const principal = identity?.principal; - if (principal?.type === "ServiceAccount") { - return `${principal.name ?? ""} (${principal.slug ?? ""})`; - } - if (principal?.email) { - return principal.name ? `${principal.name} <${principal.email}>` : principal.email; - } - if (principal?.name) { - return principal.name; - } - return lakehouseUserValue(configuration) ?? terminalNotConfiguredStatus(); -} - -function whoamiOrganizationValue( - identity: WhoamiResponse | undefined, - configuration: ConfigureShowData, -): string { - const organization = identity?.organization; - if (organization?.name && organization?.slug) { - return `${organization.name} (${organization.slug})`; - } - if (organization?.name || organization?.slug) { - return organization.name ?? organization.slug ?? ""; - } - const configured = configuration.organization; - if (configured.name && configured.slug) { - return `${configured.name} (${configured.slug})`; - } - return configured.name ?? configured.slug ?? terminalNotConfiguredStatus(); -} - -function managementEnvironmentValue(configuration: ConfigureShowData): string | undefined { - const credential = configuration.credentials.management; - return credential.configured ? credential.environment : undefined; -} - -function lakehouseUserValue(configuration: ConfigureShowData): string | undefined { - const credential = configuration.credentials.lakehouse; - return credential.configured && credential.mechanism === "lakehouse_username_password" - ? credential.user - : undefined; -} - -function profileEnvironmentValue(configuration: ConfigureShowData): string { - return ( - managementEnvironmentValue(configuration) ?? - process.env.ALTERTABLE_ENV ?? - terminalNotConfiguredStatus() - ); -} - -function cliConfigRows(configuration: ConfigureShowData): DisplayRow[] { - return [ - { label: "Config dir:", value: configuration.config_dir }, - { label: "Profile config file:", value: configuration.config_file }, - { label: "Secret store:", value: configuration.secret_store }, - ]; -} - -function principalLabel(identity: WhoamiResponse | undefined): string { - return identity?.principal?.type === "ServiceAccount" ? "Service account:" : "User:"; -} - -function profileIdentityRows(result: ProfileShowResult): DisplayRow[] { - return [ - { label: "Profile:", value: terminalStrong(result.configuration.profile) }, - { label: "Environment:", value: profileEnvironmentValue(result.configuration) }, - { - label: principalLabel(result.identity), - value: whoamiPrincipalValue(result.identity, result.configuration), - }, - { - label: "Organization:", - value: whoamiOrganizationValue(result.identity, result.configuration), - }, - ]; -} - -function profileDetailBlocks(configuration: ConfigureShowData): DisplayBlock[] { - const blocks: DisplayBlock[] = [ - rows([ - { label: "Data plane:", value: configuration.data_plane, linkifyUrls: true }, - { label: "Control plane:", value: configuration.control_plane, linkifyUrls: true }, - ...configureAuthenticationRows(configuration, undefined), - ]), - ]; - const hints = configureSetupHintLines({ - hasManagement: configuration.credentials.management.configured, - hasLakehouse: configuration.credentials.lakehouse.configured, - }); - if (hints.length > 0) { - blocks.push(text(hints)); - } - const overrides = configureOverrideRows(configuration.overrides); - if (overrides.length > 0) { - blocks.push(rows(overrides)); - } - return blocks; -} - -export type ProfileShowViewOptions = { - /** Include the CLI config paths section (config dir, profile config file, secret store). */ - config?: boolean; -}; - -export function buildProfileShowView( - result: ProfileShowResult, - options: ProfileShowViewOptions = {}, -): DisplayDocument { - return document( - ...(options.config ? [section(rows(cliConfigRows(result.configuration)))] : []), - section(rows(profileIdentityRows(result))), - section(...profileDetailBlocks(result.configuration)), - ); -} - -export type ProfileStatusResult = ProfileShowResult & { +export type ProfileStatusResult = { + profile: ProfileInspect; verification: ConfigureVerifyResult; }; @@ -332,7 +205,7 @@ export function buildProfileStatusView(result: ProfileStatusResult): DisplayDocu ` ${error.plane}: ${error.message}\n ${formatConfigureVerifyRemediation(error.plane)}`, ); return document( - ...buildProfileShowView(result).sections, + ...buildProfileInspectView(result.profile).sections, section( rows(verificationRows(result.verification)), ...(errors.length > 0 ? [text(errors)] : []), @@ -340,33 +213,9 @@ export function buildProfileStatusView(result: ProfileStatusResult): DisplayDocu ); } -export function profileShowToJson(result: ProfileShowResult): Record { - const environment = - managementEnvironmentValue(result.configuration) ?? process.env.ALTERTABLE_ENV ?? null; - return { - cli_config: { - config_dir: result.configuration.config_dir, - config_file: result.configuration.config_file, - secret_store: result.configuration.secret_store, - }, - profile: { - name: result.configuration.profile, - environment, - user: result.identity?.principal ?? null, - organization: result.identity?.organization ?? null, - }, - details: { - data_plane: result.configuration.data_plane, - control_plane: result.configuration.control_plane, - credentials: result.configuration.credentials, - overrides: result.configuration.overrides, - }, - }; -} - export function profileStatusToJson(result: ProfileStatusResult): Record { return { - ...profileShowToJson(result), + profile: result.profile, verification: { configured: result.verification.configured, verified: result.verification.verified, diff --git a/cli/src/lib/management-operations.ts b/cli/src/lib/management-operations.ts index 7038203..1d806b9 100644 --- a/cli/src/lib/management-operations.ts +++ b/cli/src/lib/management-operations.ts @@ -1,8 +1,5 @@ import { defineHttpOperation } from "@/lib/http-operation.ts"; import { buildCatalogRowsFromResponses } from "@/lib/catalog-rows.ts"; -import { parseApiJson } from "@/lib/parse-api-json.ts"; -import { type WhoamiResponse } from "@/features/management/model.ts"; -import { type ActiveContext, withAuthenticatedIdentity } from "@/features/profile/model.ts"; import type { CatalogRow } from "@/features/management/model.ts"; import type { OperationContext } from "@/lib/operation-command.ts"; import { runOperationEffect } from "@/lib/operation-effect.ts"; @@ -19,17 +16,6 @@ export type ManagementCatalogCreateResult = { fallbackName: string; }; -export const managementWhoamiOperation = defineHttpOperation({ - id: "management.whoami", - request: () => ({ - plane: "management", - method: "GET", - endpoint: "/whoami", - }), - decode: (response, _context, activeContext) => - withAuthenticatedIdentity(activeContext, parseApiJson(response) as WhoamiResponse), -}); - export const managementCatalogCreateOperation = defineHttpOperation< ManagementCatalogCreateInput, ManagementCatalogCreateResult diff --git a/cli/src/lib/profile-configure-core.ts b/cli/src/lib/profile-configure-core.ts index 8451395..f211c27 100644 --- a/cli/src/lib/profile-configure-core.ts +++ b/cli/src/lib/profile-configure-core.ts @@ -23,7 +23,6 @@ export type ConfigureOptions = { dataPlaneUrl?: string; controlPlaneUrl?: string; profile?: string; - org?: string; show?: boolean; clear?: boolean; allowInsecureHttp?: boolean; @@ -41,9 +40,8 @@ function markCurrentProfileUpdated(): void { configSet("updated_at", timestamp); } -function resolveConfigureProfile(options: ConfigureOptions): string | undefined { +function resolveConfigureProfile(options: ConfigureOptions, org: string): string | undefined { const explicitProfile = options.profile; - const org = options.org ?? ""; const env = options.env ?? ""; if (explicitProfile && explicitProfile !== AUTO_PROFILE_NAME) { @@ -100,8 +98,9 @@ export function configureClearAll(): void { export async function configureRunSet( options: ConfigureOptions, sink: OutputSink = getOutputSink(), + org = "", ): Promise { - return withConfigureProfileContext(resolveConfigureProfile(options), async () => { + return withConfigureProfileContext(resolveConfigureProfile(options, org), async () => { let user = options.user ?? ""; let password = options.password ?? ""; let apiKey = options.apiKey ?? ""; @@ -109,7 +108,6 @@ export async function configureRunSet( const env = options.env ?? ""; const dataPlaneUrl = options.dataPlaneUrl ?? ""; const controlPlaneUrl = options.controlPlaneUrl ?? ""; - const org = options.org ?? ""; const allowInsecureHttp = options.allowInsecureHttp ?? false; const passwordFromArgv = @@ -143,18 +141,19 @@ export async function configureRunSet( apiKey = (await Bun.stdin.text()).replace(/\n$/, ""); } - const hasLakehouse = Boolean(user || password); - const hasToken = Boolean(basicToken); + const hasLakehouseCredentials = Boolean(user || password); + const hasLakehouseBasicToken = Boolean(basicToken); const hasApiKey = Boolean(apiKey); - const lakehouseMechanismCount = Number(hasLakehouse) + Number(hasToken); - const hasAnyCredential = hasLakehouse || hasToken || hasApiKey; + const lakehouseMechanismCount = + Number(hasLakehouseCredentials) + Number(hasLakehouseBasicToken); + const hasAnyCredential = hasLakehouseCredentials || hasLakehouseBasicToken || hasApiKey; if (lakehouseMechanismCount > 1) { throw new CliError( "Choose a single lakehouse authentication mechanism: --user/--password or --basic-token.", ); } - if (hasApiKey && (hasLakehouse || hasToken)) { + if (hasApiKey && (hasLakehouseCredentials || hasLakehouseBasicToken)) { throw new CliError( "Choose a single authentication mechanism per configure invocation: lakehouse credentials or --api-key.", ); @@ -170,7 +169,7 @@ export async function configureRunSet( "Nothing to configure. Use --user/--password, --basic-token, or --api-key --env .", ); } - if (hasLakehouse && (!user || !password)) { + if (hasLakehouseCredentials && (!user || !password)) { throw new CliError("--user and --password must be provided together."); } if (hasApiKey && !env) { @@ -185,10 +184,10 @@ export async function configureRunSet( configSet("organization_slug", org); } } - if (hasToken) { + if (hasLakehouseBasicToken) { configureClearLakehouseCredentials(); secretSet("lakehouse/basic-token", basicToken); - } else if (hasLakehouse) { + } else if (hasLakehouseCredentials) { configureClearLakehouseCredentials(); configSet("user", user); secretSet("lakehouse/password", password, undefined, { fromArgv: passwordFromArgv }); @@ -209,9 +208,9 @@ export async function configureRunSet( if (hasApiKey) { sink.writeMetadata([terminalMetadata(`Saved management API key for environment ${env}.`)]); - } else if (hasToken) { + } else if (hasLakehouseBasicToken) { sink.writeMetadata([terminalMetadata("Saved lakehouse Basic token.")]); - } else if (hasLakehouse) { + } else if (hasLakehouseCredentials) { sink.writeMetadata([terminalMetadata(`Saved lakehouse credentials for user ${user}.`)]); } else if (dataPlaneUrl) { sink.writeMetadata([terminalMetadata(`Saved data plane URL ${dataPlaneUrl}.`)]); diff --git a/cli/src/lib/profile-configure-interactive.ts b/cli/src/lib/profile-configure-interactive.ts index 0ef3f70..d5e171a 100644 --- a/cli/src/lib/profile-configure-interactive.ts +++ b/cli/src/lib/profile-configure-interactive.ts @@ -357,7 +357,7 @@ async function readSecretWithOptionalReuse(args: ReadSecretWithOptionalReuseArgs export async function collectManagementCredentials( prompts: ConfigurePrompts, options: ConfigureWizardOptions, -): Promise { +): Promise<{ options: ConfigureOptions; org: string }> { prompts.writePrompt(`\n${terminalAccent("Management")}\n`); const currentEnv = configGet("api_key_env"); @@ -393,7 +393,6 @@ export async function collectManagementCredentials( const configureOptions: ConfigureOptions = { apiKey, env, - org, profile: options.profile, allowInsecureHttp: options.allowInsecureHttp, }; @@ -405,7 +404,7 @@ export async function collectManagementCredentials( configureOptions.controlPlaneUrl = controlPlaneUrl || DEFAULT_CONTROL_PLANE_URL; } - return configureOptions; + return { options: configureOptions, org }; } export async function collectLakehouseCredentials( diff --git a/cli/src/lib/profile-configure.ts b/cli/src/lib/profile-configure.ts index 5013189..cc37e71 100644 --- a/cli/src/lib/profile-configure.ts +++ b/cli/src/lib/profile-configure.ts @@ -36,8 +36,9 @@ const AUTO_PROFILE_NAME = "auto"; async function saveCredentials( configureOptions: ConfigureOptions, sink: OutputSink, + org = "", ): Promise { - await configureRunSet({ ...configureOptions, interactive: true }, sink); + await configureRunSet({ ...configureOptions, interactive: true }, sink, org); } function formatNextCommandsLine(commands: string[]): string { @@ -109,13 +110,13 @@ async function runConfigureWizardInCurrentProfile( } if (planes.includes("management")) { - const managementOptions = await collectManagementCredentials(prompts, { + const { options: managementOptions, org } = await collectManagementCredentials(prompts, { ...options, profile: activeProfile, }); - await saveCredentials(managementOptions, sink); - if (activeProfile === AUTO_PROFILE_NAME && managementOptions.org && managementOptions.env) { - activeProfile = deriveProfileName(managementOptions.org, managementOptions.env); + await saveCredentials(managementOptions, sink, org); + if (activeProfile === AUTO_PROFILE_NAME && org && managementOptions.env) { + activeProfile = deriveProfileName(org, managementOptions.env); } configuredPlanes.push("management"); } diff --git a/cli/src/lib/profile-store.ts b/cli/src/lib/profile-store.ts index 1f0f378..a813730 100644 --- a/cli/src/lib/profile-store.ts +++ b/cli/src/lib/profile-store.ts @@ -5,6 +5,12 @@ import { ConfigurationError } from "@/lib/errors.ts"; export const DEFAULT_PROFILE_NAME = "default"; +// Reserved pseudo-profile: the identity in effect when credentials come from +// environment variables rather than a stored profile. Never a real directory. +export const FROM_ENV_PSEUDOPROFILE_NAME = "_from_env"; + +const RESERVED_PROFILE_NAMES = new Set([FROM_ENV_PSEUDOPROFILE_NAME]); + export const PROFILE_CONFIG_KEYS = [ "user", "api_key_env", @@ -52,6 +58,9 @@ export function assertSafeProfileName(name: string): void { `Invalid profile name: ${name}. Use only letters, digits, dots, underscores, and hyphens.`, ); } + if (RESERVED_PROFILE_NAMES.has(name)) { + throw new ConfigurationError(`Profile name is reserved: ${name}`); + } const resolvedProfileDir = resolve(join(profilesDir(), name)); const resolvedProfilesRoot = resolve(profilesDir()); diff --git a/cli/tests/oauth.test.ts b/cli/tests/oauth.test.ts index b44ce5b..8b87a01 100644 --- a/cli/tests/oauth.test.ts +++ b/cli/tests/oauth.test.ts @@ -7,7 +7,9 @@ import { configGet } from "@/lib/config.ts"; import { setCliContext } from "@/context.ts"; import { parseCallback, buildAuthorizeUrl, startLoopbackServer } from "@/lib/oauth-flow.ts"; import { storeOAuthTokens, getStoredAccessToken, clearOAuthTokens } from "@/lib/oauth-profile.ts"; -import { getActiveProfileName, profileExists } from "@/lib/profile-store.ts"; +import { getActiveProfileName, profileExists, setActiveProfile } from "@/lib/profile-store.ts"; +import { createEmptyProfile } from "@/features/profile/model.ts"; +import { secretSet } from "@/lib/secrets.ts"; let testHome = ""; const TEST_PRINCIPAL = { @@ -29,6 +31,10 @@ afterEach(() => { delete process.env.ALTERTABLE_SECRET_BACKEND; delete process.env.ALTERTABLE_MANAGEMENT_API_BASE; delete process.env.ALTERTABLE_ALLOW_INSECURE_HTTP; + delete process.env.ALTERTABLE_API_KEY; + delete process.env.ALTERTABLE_BASIC_AUTH_TOKEN; + delete process.env.ALTERTABLE_LAKEHOUSE_USERNAME; + delete process.env.ALTERTABLE_LAKEHOUSE_PASSWORD; }); describe("resolveOAuthBase", () => { @@ -151,6 +157,10 @@ import { applyControlPlaneOverride, storeLoginProfileMetadata, } from "@/commands/login.ts"; +import { + assertProfileHasNoEnvCredentials, + resolveActiveProfileName, +} from "@/features/profile/model.ts"; import { ConfigurationError } from "@/lib/errors.ts"; import { configureRunClear } from "@/lib/profile-configure-core.ts"; import { getOutputSink } from "@/lib/runtime.ts"; @@ -173,26 +183,26 @@ describe("resolveWhoamiEnvironment", () => { }); describe("login profile metadata", () => { - test("stores environment and organization from whoami", () => { - const metadata = storeLoginProfileMetadata( - { - principal: TEST_PRINCIPAL, - organization: { name: "Altertable", slug: "altertable" }, - authentication_scope: "environment", - environment_slug: "production", - }, - {}, - ); + const WHOAMI = { + principal: TEST_PRINCIPAL, + organization: { name: "Altertable", slug: "altertable" }, + authentication_scope: "environment", + environment_slug: "production", + } as const; + + // `altertable login` on a fresh install signs into the unauthenticated `default` + // profile and stores the whoami identity there, rather than deriving a new one. + test("lands in the current default profile when it is unauthenticated", () => { + const metadata = storeLoginProfileMetadata(WHOAMI, {}); storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }); expect(metadata).toEqual({ environment: "production", - profileName: "altertable_production", - profileAction: "created", + profileName: "default", + profileAction: "unchanged", }); - expect(profileExists("default")).toBe(true); - expect(profileExists("altertable_production")).toBe(true); - expect(getActiveProfileName()).toBe("altertable_production"); + expect(getActiveProfileName()).toBe("default"); + expect(profileExists("altertable_production")).toBe(false); expect(configGet("api_key_env")).toBe("production"); expect(configGet("organization_slug")).toBe("altertable"); expect(configGet("organization_name")).toBe("Altertable"); @@ -201,6 +211,87 @@ describe("login profile metadata", () => { expect(getStoredAccessToken()).toBe("acc"); }); + // A second `altertable login` must not clobber the now-authenticated `default`; + // it derives and switches to a fresh profile instead. + test("creates a separate profile when the current one is already authenticated", () => { + storeLoginProfileMetadata(WHOAMI, {}); + storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }); + + const metadata = storeLoginProfileMetadata(WHOAMI, {}); + + expect(metadata).toEqual({ + environment: "production", + profileName: "altertable_production", + profileAction: "created", + }); + expect(profileExists("default")).toBe(true); + expect(profileExists("altertable_production")).toBe(true); + expect(getActiveProfileName()).toBe("altertable_production"); + }); + + // `altertable profile create new` (which switches to `new`) followed by + // `altertable login` signs into `new`, since it has not been configured yet. + test("lands in a freshly created, unconfigured profile", () => { + createEmptyProfile("new"); + setActiveProfile("new"); + + const metadata = storeLoginProfileMetadata(WHOAMI, {}); + + expect(metadata).toEqual({ + environment: "production", + profileName: "new", + profileAction: "unchanged", + }); + expect(getActiveProfileName()).toBe("new"); + expect(profileExists("altertable_production")).toBe(false); + }); + + // Lakehouse-only credentials still count as "authenticated" — a login must not + // overwrite them, so it branches to a new profile. + test("treats a profile with only lakehouse credentials as authenticated", () => { + secretSet("lakehouse/password", "s_lake"); + + const metadata = storeLoginProfileMetadata(WHOAMI, {}); + + expect(metadata).toEqual({ + environment: "production", + profileName: "altertable_production", + profileAction: "created", + }); + expect(getActiveProfileName()).toBe("altertable_production"); + }); + + // Credentials supplied via environment variables aren't a stored profile; the + // active identity is the reserved `_from_env` pseudo-profile. + test("resolves the active identity to reserved _from_env when a credential env var is set", () => { + expect(resolveActiveProfileName()).toBe("default"); + + process.env.ALTERTABLE_API_KEY = "atm_env"; + expect(resolveActiveProfileName()).toBe("_from_env"); + delete process.env.ALTERTABLE_API_KEY; + + process.env.ALTERTABLE_BASIC_AUTH_TOKEN = "dG9rZW4="; + expect(resolveActiveProfileName()).toBe("_from_env"); + delete process.env.ALTERTABLE_BASIC_AUTH_TOKEN; + + process.env.ALTERTABLE_LAKEHOUSE_USERNAME = "u"; + process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = "p"; + expect(resolveActiveProfileName()).toBe("_from_env"); + }); + + // Environment credentials pin the identity, so stored-profile controls (login, + // switching) are locked while they are set. + test("locks stored-profile controls while credentials come from the environment", () => { + expect(() => assertProfileHasNoEnvCredentials("altertable login")).not.toThrow(); + + process.env.ALTERTABLE_API_KEY = "atm_env"; + expect(() => assertProfileHasNoEnvCredentials("altertable login")).toThrow(ConfigurationError); + }); + + test("_from_env is a reserved profile name that cannot be created", () => { + expect(() => createEmptyProfile("_from_env")).toThrow(); + }); + test("can replace the current profile login session", () => { const metadata = storeLoginProfileMetadata( { diff --git a/cli/tests/profile.test.ts b/cli/tests/profile.test.ts index 6767726..3a559c5 100644 --- a/cli/tests/profile.test.ts +++ b/cli/tests/profile.test.ts @@ -33,13 +33,10 @@ import { buildProfileInspectView, buildProfileListView, buildProfileShellExportView, - profileShowToJson, profileStatusToJson, - type ProfileShowResult, type ProfileStatusResult, } from "@/features/profile/views.ts"; -import { formatProfileShow, formatProfileStatus } from "@/features/profile/render.ts"; -import { buildConfigureShowData } from "@/features/profile/model.ts"; +import { formatProfileInspect, formatProfileStatus } from "@/features/profile/render.ts"; import { runProfileConfigure } from "@/lib/profile-configure.ts"; import { setCliContext, getCliContext } from "@/context.ts"; import { createCliTestRuntime, runCommandWithTestRuntime } from "@tests/cli-test-runtime.ts"; @@ -184,7 +181,7 @@ describe("profile storage", () => { }); test("profile list view describes table columns declaratively", async () => { - await configureRunSet({ profile: "acme_prod", org: "acme", apiKey: "atm_prod", env: "prod" }); + await configureRunSet({ profile: "acme_prod", apiKey: "atm_prod", env: "prod" }); const view = buildProfileListView(listProfiles()); const [section] = view.sections; const [block] = section?.blocks ?? []; @@ -480,97 +477,52 @@ describe("profile --configure dispatch", () => { }); describe("profile show view", () => { - function showResult(identity?: ProfileShowResult["identity"]): ProfileShowResult { - return { configuration: buildConfigureShowData(), identity }; - } - - test("renders profile identity and credentials without config paths by default", async () => { + test("renders the stored profile's auth, environment, and config file", async () => { await configureRunSet({ apiKey: "atm_show", env: "production" }); - const output = formatProfileShow( - showResult({ - principal: { type: "User", name: "Ada Lovelace", email: "ada@example.com" }, - organization: { name: "Acme", slug: "acme" }, - }), - ); + const output = formatProfileInspect(inspectProfile(getActiveProfileName())); - expect(output).toContain("Ada Lovelace "); - expect(output).toContain("Acme (acme)"); - expect(output).toContain("Data plane:"); - expect(output).toContain("management API key"); + expect(output).toContain("Management auth"); + expect(output).toContain("api_key"); + expect(output).toContain("Environment"); expect(output).toContain("production"); + expect(output).toContain("Config file"); expect(output).not.toContain("atm_show"); - expect(output).not.toContain("Config dir:"); - expect(output).not.toContain("Secret store:"); - }); - - test("--config includes CLI config paths", async () => { - await configureRunSet({ apiKey: "atm_show", env: "production" }); - - const output = formatProfileShow(showResult(), { config: true }); - - expect(output).toContain("Config dir:"); - expect(output).toContain("Profile config file:"); - expect(output).toContain("Secret store:"); - }); - - test("falls back to 'not set' identity when whoami is unavailable", async () => { - await configureRunSet({ apiKey: "atm_show", env: "production" }); - - const output = formatProfileShow(showResult()); - - expect(output).toContain("User:"); - expect(output).toContain("not set"); }); - test("json envelope groups cli_config, profile, and details", async () => { - await configureRunSet({ apiKey: "atm_show", env: "production" }); - - const json = profileShowToJson( - showResult({ principal: { name: "Ada" }, organization: { slug: "acme" } }), - ); + test("marks an unconfigured profile as empty with no auth", () => { + const output = formatProfileInspect(inspectProfile(getActiveProfileName())); - expect(json.cli_config).toBeDefined(); - expect((json.profile as { name: string }).name).toBe(getActiveProfileName()); - expect((json.profile as { environment: string }).environment).toBe("production"); - expect( - (json.details as { credentials: { management: { configured: boolean } } }).credentials - .management.configured, - ).toBe(true); + expect(output).toContain("Status"); + expect(output).toContain("empty"); + expect(output).toContain("none"); }); }); describe("profile status view", () => { - function statusResult( - verification: ProfileStatusResult["verification"], - identity?: ProfileStatusResult["identity"], - ): ProfileStatusResult { - return { configuration: buildConfigureShowData(), identity, verification }; + function statusResult(verification: ProfileStatusResult["verification"]): ProfileStatusResult { + return { profile: inspectProfile(getActiveProfileName()), verification }; } - test("renders profile show plus a verification section", async () => { + test("renders the inspect view plus a verification section", async () => { await configureRunSet({ apiKey: "atm_status", env: "production" }); const output = formatProfileStatus( - statusResult( - { - profile: getActiveProfileName(), - configured: ["management"], - verified: { management: true, lakehouse: false }, - errors: [], - }, - { principal: { name: "Ada" }, organization: { slug: "acme" } }, - ), + statusResult({ + profile: getActiveProfileName(), + configured: ["management"], + verified: { management: true, lakehouse: false }, + errors: [], + }), ); - expect(output).toContain("management API key"); + expect(output).toContain("Management auth"); expect(output).toContain("Verification:"); expect(output).toContain("Management:"); expect(output).toContain("verified"); - expect(output).not.toContain("Config dir:"); }); - test("json envelope includes the verification result", async () => { + test("json envelope includes the profile and verification result", async () => { await configureRunSet({ apiKey: "atm_status", env: "production" }); const json = profileStatusToJson( @@ -583,6 +535,6 @@ describe("profile status view", () => { ); expect((json.verification as { configured: string[] }).configured).toEqual(["management"]); - expect(json.cli_config).toBeDefined(); + expect((json.profile as { name: string }).name).toBe(getActiveProfileName()); }); }); diff --git a/tests/configure.test.ts b/tests/configure.test.ts index 3ca3ab0..dafc096 100644 --- a/tests/configure.test.ts +++ b/tests/configure.test.ts @@ -67,20 +67,19 @@ describe("altertable profile --configure", () => { expect(await workspace.readFile(workspace.credentialsFile)).toContain("profile/default/api-key=atm_fromstdin\n"); }); - test("--show masks secrets, reports the store, and non-TTY configure requires flags", async () => { + test("profile show reports auth without leaking secrets, and non-TTY configure requires flags", async () => { await workspace.resetConfig(); expect((await workspace.runCommand("altertable profile --configure --user u_blabla --password s_llll")).exitCode).toBe(0); - let result = await workspace.runCommand("altertable profile show --config"); + const result = await workspace.runCommand("altertable profile show"); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("u_blabla"); - expect(result.stdout).toMatch(/password:\s*set/i); + expect(result.stdout).toContain("Lakehouse auth"); + expect(result.stdout).toContain("username_password"); expect(result.stdout).not.toContain("s_llll"); - expect(result.stdout).toContain(workspace.credentialsFile); await workspace.resetConfig(); - result = await workspace.runCommand("altertable profile --configure"); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Interactive configure requires a TTY"); + const configure = await workspace.runCommand("altertable profile --configure"); + expect(configure.exitCode).not.toBe(0); + expect(configure.stderr).toContain("Interactive configure requires a TTY"); }); test("--verify checks management credentials after save", async () => { @@ -145,7 +144,9 @@ describe("altertable profile --configure", () => { expect(await workspace.fileExists(workspace.configFile)).toBe(false); expect(await workspace.fileExists(workspace.credentialsFile)).toBe(false); - expect((await workspace.runCommand("altertable profile show --config")).stdout).toContain("No credentials configured"); + const show = await workspace.runCommand("altertable profile show"); + expect(show.exitCode).toBe(0); + expect(show.stdout).toContain("empty"); }); test("stores endpoint overrides and applies endpoint precedence", async () => { @@ -177,7 +178,7 @@ describe("altertable profile --configure", () => { expect((await workspace.runCommand("altertable profile --configure --api-key atm_x --env prod --control-plane-url http://localhost:13000")).exitCode).toBe(0); await workspace.setupHttpLog(); await workspace.setupMockHttp(whoamiMock()); - expect((await workspace.runCommand("altertable profile show")).exitCode).toBe(0); + expect((await workspace.runCommand("altertable api GET /whoami")).exitCode).toBe(0); expect(await workspace.httpLogValue("URL")).toBe("http://localhost:13000/rest/v1/whoami"); await workspace.resetConfig(); @@ -209,11 +210,11 @@ describe("altertable profile --configure", () => { ).exitCode, ).toBe(0); - const result = await workspace.runCommand("altertable profile show --config"); + const result = await workspace.runCommand("altertable profile show"); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Data plane:"); - expect(result.stdout).toContain("Control plane:"); + expect(result.stdout).toContain("Data plane"); + expect(result.stdout).toContain("Control plane"); expect(result.stdout).toContain("http://localhost:15000"); - expect(result.stdout).toContain("http://localhost:13000/rest/v1"); + expect(result.stdout).toContain("http://localhost:13000"); }); }); diff --git a/tests/context.test.ts b/tests/context.test.ts index 7f85d81..268ac19 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -1,59 +1,56 @@ -import { beforeAll, describe, expect, test } from "bun:test"; +import { beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { createTestWorkspace, type TestWorkspace } from "./helpers.ts"; -import { whoamiMock } from "./mock-http.ts"; describe("altertable profile show", () => { let workspace: TestWorkspace; beforeAll(async () => { workspace = await createTestWorkspace({ - ALTERTABLE_API_KEY: "atm_test", + ALTERTABLE_API_KEY: undefined, ALTERTABLE_ENV: undefined, ALTERTABLE_MANAGEMENT_API_BASE: undefined, }); }); - test("formats a User principal", async () => { - await workspace.setupMockHttp(whoamiMock({ type: "User", name: "Jane Doe", email: "jane@x.io" })); + beforeEach(async () => { + await workspace.resetConfig(); + expect( + (await workspace.runCommand("altertable profile --configure --api-key atm_test --env production")).exitCode, + ).toBe(0); + }); + + test("shows the stored profile's auth and environment", async () => { const result = await workspace.runCommand("altertable profile show"); expect(result.exitCode).toBe(0); expect(result.stderr).toBe(""); - expect(result.stdout).toContain("Profile:"); - expect(result.stdout).toContain("User:"); - expect(result.stdout).toContain("Jane Doe "); - expect(result.stdout).toContain("Organization:"); - expect(result.stdout).toContain("Acme (acme)"); + expect(result.stdout).toContain("Profile"); + expect(result.stdout).toContain("default"); + expect(result.stdout).toContain("Management auth"); + expect(result.stdout).toContain("api_key"); + expect(result.stdout).toContain("Environment"); + expect(result.stdout).toContain("production"); + expect(result.stdout).not.toContain("atm_test"); }); test("--no-color emits plain text without ANSI styling", async () => { - await workspace.setupMockHttp(whoamiMock({ type: "User", name: "Jane Doe", email: "jane@x.io" })); const result = await workspace.runCommand("altertable --no-color profile show"); expect(result.exitCode).toBe(0); expect(result.stdout).not.toMatch(/\x1B/); - expect(result.stdout).toContain("Jane Doe "); + expect(result.stdout).toContain("production"); }); - test("--agent returns structured session JSON", async () => { - await workspace.setupMockHttp(whoamiMock({ type: "User", name: "Jane Doe", email: "jane@x.io" })); + test("--agent returns structured profile JSON", async () => { const result = await workspace.runCommand("altertable --agent profile show"); expect(result.exitCode).toBe(0); expect(JSON.parse(result.stdout)).toMatchObject({ profile: { name: "default", - user: { name: "Jane Doe" }, + environment: "production", + auth: { management: "api_key" }, }, }); }); - - test("formats a ServiceAccount principal", async () => { - await workspace.setupMockHttp(whoamiMock({ type: "ServiceAccount", name: "ci-bot", slug: "ci-bot" })); - const result = await workspace.runCommand("altertable profile show"); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Service account:"); - expect(result.stdout).toContain("ci-bot (ci-bot)"); - }); }); diff --git a/tests/helpers.ts b/tests/helpers.ts index 893ee5f..0ad4149 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -218,6 +218,9 @@ function buildEnv(overrides: TestEnv = {}): Record { } } env.PATH = `${join(repoRoot, "bin")}:${env.PATH ?? ""}`; + // Keep CLI output deterministic regardless of the developer's terminal: citty + // colorizes arg-validation errors unless NO_COLOR/CI is set (CI has it, local shells don't). + env.NO_COLOR = "1"; for (const [key, value] of Object.entries(overrides)) { if (value === undefined) { delete env[key]; diff --git a/tests/integration.e2e.ts b/tests/integration.e2e.ts index 57f4b20..7164e1e 100644 --- a/tests/integration.e2e.ts +++ b/tests/integration.e2e.ts @@ -1,6 +1,5 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { createTestWorkspace, type TestWorkspace } from "./helpers.ts"; -import { whoamiMock } from "./mock-http.ts"; const API_BASE = "http://0.0.0.0:15000"; @@ -54,11 +53,9 @@ describe("lakehouse integration flows", () => { result = await workspace.runCommand('altertable --agent query "SELECT 1" --layout table'); expect(result.exitCode).not.toBe(0); - await workspace.setupMockHttp(whoamiMock({ type: "User", name: "Agent User", email: "agent@x.io" })); result = await workspace.runCommand("altertable --agent profile show", { env: { ALTERTABLE_API_KEY: "atm_test" } }); expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout).profile.user.email).toBe("agent@x.io"); - workspace.clearMockHttp(); + expect(JSON.parse(result.stdout).profile.name).toBe("default"); const queryId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; const sessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; diff --git a/tests/management.test.ts b/tests/management.test.ts index 4cdce0b..0e122f2 100644 --- a/tests/management.test.ts +++ b/tests/management.test.ts @@ -18,7 +18,7 @@ describe("management API user flows", () => { await workspace.setupHttpLog(); await workspace.setupMockHttp(whoamiMock()); - const result = await workspace.runCommand("altertable profile show"); + const result = await workspace.runCommand("altertable api GET /whoami"); expect(result.exitCode).toBe(0); expect(await workspace.httpLogValue("AUTH")).toBe("Authorization: [REDACTED]"); @@ -31,7 +31,7 @@ describe("management API user flows", () => { await workspace.setupHttpLog(); await workspace.setupMockHttp(whoamiMock()); - const result = await workspace.runCommand("altertable profile show", { + const result = await workspace.runCommand("altertable api GET /whoami", { env: { ALTERTABLE_API_KEY: "atm_env", ALTERTABLE_MANAGEMENT_API_BASE: "http://localhost:9" }, }); @@ -48,14 +48,14 @@ describe("management API user flows", () => { await workspace.appendFile(workspace.defaultProfileConfig, "management_api_base=http://localhost:7\n"); await workspace.setupHttpLog(); await workspace.setupMockHttp(whoamiMock()); - expect((await workspace.runCommand("altertable profile show", { env: { ALTERTABLE_API_KEY: "atm_env" } })).exitCode).toBe(0); + expect((await workspace.runCommand("altertable api GET /whoami", { env: { ALTERTABLE_API_KEY: "atm_env" } })).exitCode).toBe(0); expect(await workspace.httpLogValue("URL")).toBe("http://localhost:7/rest/v1/whoami"); await workspace.setupHttpLog(); await workspace.setupMockHttp(whoamiMock()); expect( ( - await workspace.runCommand("altertable profile show", { + await workspace.runCommand("altertable api GET /whoami", { env: { ALTERTABLE_API_KEY: "atm_env", ALTERTABLE_MANAGEMENT_API_BASE: "http://localhost:8/" }, }) ).exitCode, @@ -86,8 +86,9 @@ describe("management API user flows", () => { test("context works locally without credentials, but raw API requires them", async () => { const context = await workspace.runCommand("altertable profile show"); expect(context.exitCode).toBe(0); - expect(context.stdout).toContain("Profile:"); - expect(context.stdout).toContain("No credentials configured"); + expect(context.stdout).toContain("Profile"); + expect(context.stdout).toContain("Status"); + expect(context.stdout).toContain("empty"); const api = await workspace.runCommand("altertable api GET /whoami"); expect(api.exitCode).toBe(10); diff --git a/tests/profile.test.ts b/tests/profile.test.ts index 35b2c7d..f71b16e 100644 --- a/tests/profile.test.ts +++ b/tests/profile.test.ts @@ -1,6 +1,5 @@ import { beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { createTestWorkspace, type TestWorkspace } from "./helpers.ts"; -import { whoamiMock } from "./mock-http.ts"; describe("profile switching", () => { let workspace: TestWorkspace; @@ -32,15 +31,15 @@ describe("profile switching", () => { test("active profile and --profile flag select different identities", async () => { expect((await workspace.runCommand("altertable profile use acme_staging")).exitCode).toBe(0); - await workspace.setupMockHttp(whoamiMock({ type: "User", name: "Staging", email: "s@x.io" })); let result = await workspace.runCommand("altertable profile show"); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Staging"); + expect(result.stdout).toContain("acme_staging"); + expect(result.stdout).toContain("staging"); - await workspace.setupMockHttp(whoamiMock({ type: "User", name: "Production", email: "p@x.io" })); result = await workspace.runCommand("altertable --profile acme_production profile show"); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Production"); + expect(result.stdout).toContain("acme_production"); + expect(result.stdout).toContain("production"); }); test("rename carries the active profile", async () => { @@ -73,6 +72,28 @@ describe("profile switching", () => { result = await workspace.runCommand("altertable profile list"); expect(result.stdout).not.toContain("globex_dev"); }); + + // Environment credentials pin the identity to `_from_env`, so login and profile + // switching are disabled — they would only mutate stored state the env overrides. + test("environment credentials disable login and profile switching", async () => { + const login = await workspace.runCommand("altertable login", { + env: { ALTERTABLE_API_KEY: "atm_env" }, + }); + expect(login.exitCode).not.toBe(0); + expect(login.stderr).toContain("disabled while credentials come from the environment"); + + const use = await workspace.runCommand("altertable profile use acme_staging", { + env: { ALTERTABLE_API_KEY: "atm_env" }, + }); + expect(use.exitCode).not.toBe(0); + expect(use.stderr).toContain("disabled while credentials come from the environment"); + + const switched = await workspace.runCommand("altertable profile switch acme_staging", { + env: { ALTERTABLE_LAKEHOUSE_USERNAME: "u", ALTERTABLE_LAKEHOUSE_PASSWORD: "p" }, + }); + expect(switched.exitCode).not.toBe(0); + expect(switched.stderr).toContain("disabled while credentials come from the environment"); + }); }); async function seedAcmeProfiles(workspace: TestWorkspace): Promise { diff --git a/tests/scripting.test.ts b/tests/scripting.test.ts index 56ae18b..d19cf8a 100644 --- a/tests/scripting.test.ts +++ b/tests/scripting.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { createTestWorkspace, type TestWorkspace } from "./helpers.ts"; -import { jsonMock, whoamiMock } from "./mock-http.ts"; +import { jsonMock } from "./mock-http.ts"; const statusMocks = { auth: [jsonMock("GET", "/whoami", { error: "invalid key" }, 401)], @@ -23,12 +23,11 @@ describe("scriptable exit codes and JSON errors", () => { }); test("--json profile show exits 0 and prints structured success JSON", async () => { - await workspace.setupMockHttp(whoamiMock()); const result = await workspace.runCommand("altertable --json profile show"); expect(result.exitCode).toBe(0); expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout).profile.user.name).toBe("Jane"); + expect(JSON.parse(result.stdout).profile.name).toBe("default"); }); test.each([