Skip to content
Closed
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
18 changes: 9 additions & 9 deletions cli/src/commands/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
buildConfigureShowData,
configureCredentialStatus,
type ConfigureShowData,
type ProfileInspect,
} from "@/features/profile/model.ts";
import type { ConfigureAuthPlane } from "@/lib/profile-status.ts";
import { configureVerify } from "@/lib/profile-status.ts";
Expand Down Expand Up @@ -42,6 +43,7 @@ import {
} from "@/features/profile/render.ts";
import { renderShellExportView } from "@/ui/shell/render.ts";
import {
assertProfileHasNoEnvCredentials,
createEmptyProfile,
deleteProfile,
getActiveProfileName,
Expand Down Expand Up @@ -186,7 +188,7 @@ async function fetchProfileIdentity(

const profileShowCommand = defineLocalCommand<
{ profileName: string; config: boolean },
ProfileShowResult & { config: boolean }
ProfileInspect
>({
id: "profile.show",
localConfig: true,
Expand All @@ -208,17 +210,13 @@ const profileShowCommand = defineLocalCommand<
config: Boolean(args.config),
};
},
async local(input, context) {
async local(input) {
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 };
return inspectProfile(input.profileName);
} finally {
setCliContext(previous);
refreshCliRuntimeContext(previous);
Expand All @@ -227,8 +225,8 @@ const profileShowCommand = defineLocalCommand<
present(result) {
return {
kind: "normalized",
data: profileShowToJson(result),
humanText: formatProfileShow(result, { config: result.config }),
data: { profile: result },
humanText: formatProfileInspect(result),
};
},
});
Expand All @@ -247,6 +245,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 +273,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
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
26 changes: 11 additions & 15 deletions cli/src/lib/profile-configure-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export type ConfigureOptions = {
dataPlaneUrl?: string;
controlPlaneUrl?: string;
profile?: string;
org?: string;
show?: boolean;
clear?: boolean;
allowInsecureHttp?: boolean;
Expand Down Expand Up @@ -112,7 +111,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 =
Expand Down Expand Up @@ -146,18 +144,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.",
);
Expand All @@ -173,7 +172,7 @@ export async function configureRunSet(
"Nothing to configure. Use --user/--password, --basic-token, or --api-key --env <name>.",
);
}
if (hasLakehouse && (!user || !password)) {
if (hasLakehouseCredentials && (!user || !password)) {
throw new CliError("--user and --password must be provided together.");
}
if (hasApiKey && !env) {
Expand All @@ -184,14 +183,11 @@ export async function configureRunSet(
configureClearManagementCredentials();
secretSet("api-key", apiKey, undefined, { fromArgv: apiKeyFromArgv });
configSet("api_key_env", env);
if (org) {
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 });
Expand All @@ -212,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}.`)]);
Expand Down
9 changes: 9 additions & 0 deletions cli/src/lib/profile-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([FROM_ENV_PSEUDOPROFILE_NAME]);

export const PROFILE_CONFIG_KEYS = [
"user",
"api_key_env",
Expand Down Expand Up @@ -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());
Expand Down
Loading
Loading