Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions cli/src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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" };
Expand Down Expand Up @@ -155,6 +166,7 @@ async function fetchLoginWhoami(oauthResponse: TokenResponse): Promise<WhoamiRes
}

async function runLogin(args: LoginArgs, sink: OutputSink): Promise<void> {
assertProfileHasNoEnvCredentials("altertable login");
assertInteractiveLogin();
applyControlPlaneOverride(args);

Expand Down
89 changes: 18 additions & 71 deletions cli/src/commands/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,14 @@ 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 {
configureArgs,
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,
Expand All @@ -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,
Expand Down Expand Up @@ -77,12 +66,12 @@ function profileNameArgOrActive(args: Record<string, unknown>): 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;
Expand Down Expand Up @@ -166,69 +155,28 @@ function profileShowTargetName(args: Record<string, unknown>): string {
return getCliContext().profile ?? getActiveProfileName();
}

async function fetchProfileIdentity(
context: OperationContext,
): Promise<WhoamiResponse | undefined> {
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),
};
},
});
Expand All @@ -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;
},
Expand Down Expand Up @@ -274,6 +223,7 @@ const profileSwitchCommand = defineLocalCommand<string | undefined, string>({
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.");
Expand Down Expand Up @@ -371,18 +321,15 @@ const profileStatusCommand = defineLocalCommand<string, ProfileStatusResult>({
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);
Expand Down
36 changes: 36 additions & 0 deletions cli/src/features/profile/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import {
assertSafeProfileName,
DEFAULT_PROFILE_NAME,
FROM_ENV_PSEUDOPROFILE_NAME,
type ProfileConfigKey,
ensureProfileExists,
ensureProfilesLayout,
Expand All @@ -21,6 +22,7 @@ import {
profileDir,
profileExists,
readProfileConfigRecord,
resolveProfileName,
setActiveProfile,
writeProfileConfig,
} from "@/lib/profile-store.ts";
Expand All @@ -30,6 +32,7 @@ export {
DEFAULT_PROFILE_NAME,
ensureProfileExists,
ensureProfilesLayout,
FROM_ENV_PSEUDOPROFILE_NAME,
getActiveProfileName,
profileConfigFile,
profileExists,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 0 additions & 10 deletions cli/src/features/profile/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@ import {
buildActiveContextSummaryView,
buildProfileInspectView,
buildProfileListView,
buildProfileShowView,
buildProfileStatusView,
configureAuthenticationRows,
type ProfileShowResult,
type ProfileShowViewOptions,
type ProfileStatusResult,
} from "@/features/profile/views.ts";
import {
Expand All @@ -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));
}
Expand Down
Loading
Loading