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
4 changes: 2 additions & 2 deletions cli/src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ 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,
assertNoEnvConfigMode,
createEmptyProfile,
deriveProfileName,
profileExists,
Expand Down Expand Up @@ -189,7 +189,7 @@ export function sameWhoamiContext(a: WhoamiResponse, b: WhoamiResponse): boolean
}

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

Expand Down
32 changes: 26 additions & 6 deletions cli/src/commands/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ import {
} from "@/features/profile/render.ts";
import { renderShellExportView } from "@/ui/shell/render.ts";
import {
assertProfileHasNoEnvCredentials,
assertNoEnvConfigMode,
createEmptyProfile,
deleteProfile,
envConfigMode,
getActiveProfileName,
inspectProfile,
isFromEnvProfile,
listProfiles,
profileExists,
renameProfile,
Expand All @@ -55,12 +57,26 @@ function optionalArg(value: unknown): string | undefined {
}

function existingProfileName(name: string): string {
if (!profileExists(name)) {
// `_from_env` is a valid read target (rendered from env vars) only while env
// config is actually in effect; it has no stored directory, so otherwise the
// name resolves to nothing.
const resolvable = isFromEnvProfile(name) ? envConfigMode() : profileExists(name);
if (!resolvable) {
throw new ConfigurationError(`Profile not found: ${name}`);
}
return name;
}

// env/direnv export a selectable profile name; `_from_env` isn't one.
function requireStoredProfileForExport(name: string): string {
if (isFromEnvProfile(name)) {
throw new CliError(
"The active identity is configured through environment variables; there is no stored profile to export. Pass a profile name.",
);
}
return existingProfileName(name);
}

function profileNameArgOrActive(args: Record<string, unknown>): string {
return args.name ? requireProfileName(args.name) : getActiveProfileName();
}
Expand Down Expand Up @@ -130,6 +146,7 @@ const profileCreateCommand = defineLocalCommand({
};
},
async local(input, context) {
assertNoEnvConfigMode();
createEmptyProfile(input.name);
await runProfileConfigure(input.configure, context.sink, input.name);
setActiveProfile(input.name);
Expand Down Expand Up @@ -192,7 +209,7 @@ function createProfileUseCommand(id: string, name: string, hidden = false) {
return requireProfileName(args.name);
},
local(profileName) {
assertProfileHasNoEnvCredentials("Switching profiles");
assertNoEnvConfigMode();
setActiveProfile(profileName);
return profileName;
},
Expand Down Expand Up @@ -220,7 +237,7 @@ const profileSwitchCommand = defineLocalCommand<string | undefined, string>({
return args.name ? requireProfileName(args.name) : undefined;
},
async local(profileName) {
assertProfileHasNoEnvCredentials("Switching profiles");
assertNoEnvConfigMode();
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 @@ -265,7 +282,7 @@ const profileEnvCommand = defineValueCommand({
name: { type: "positional", description: "Profile name (default: active profile)" },
},
parse({ args }) {
return existingProfileName(profileNameArgOrActive(args));
return requireStoredProfileForExport(profileNameArgOrActive(args));
},
value(profileName) {
return profileName;
Expand All @@ -289,7 +306,7 @@ const profileDirenvCommand = defineValueCommand({
name: { type: "positional", description: "Profile name (default: active profile)" },
},
parse({ args }) {
return existingProfileName(profileNameArgOrActive(args));
return requireStoredProfileForExport(profileNameArgOrActive(args));
},
value(profileName) {
return profileName;
Expand Down Expand Up @@ -358,6 +375,7 @@ const profileRenameCommand = defineLocalCommand({
};
},
local(input) {
assertNoEnvConfigMode();
renameProfile(input.from, input.to);
return input;
},
Expand Down Expand Up @@ -387,6 +405,7 @@ const profileDeleteCommand = defineLocalCommand({
return requireProfileName(args.name);
},
local(profileName) {
assertNoEnvConfigMode();
deleteProfile(profileName);
return profileName;
},
Expand Down Expand Up @@ -463,6 +482,7 @@ export const profileCommand = defineLocalCommand<Record<string, unknown>, void>(
},
async local(args, context) {
if (args.configure) {
assertNoEnvConfigMode();
await runProfileConfigure(args as ConfigureCommandArgs, context.sink);
return;
}
Expand Down
97 changes: 80 additions & 17 deletions cli/src/features/profile/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import {
import {
assertSafeProfileName,
DEFAULT_PROFILE_NAME,
envConfigMode,
FROM_ENV_PSEUDOPROFILE_NAME,
isFromEnvProfile,
type ProfileConfigKey,
ensureProfileExists,
ensureProfilesLayout,
Expand All @@ -37,8 +39,10 @@ export {
DEFAULT_PROFILE_NAME,
ensureProfileExists,
ensureProfilesLayout,
envConfigMode,
FROM_ENV_PSEUDOPROFILE_NAME,
getActiveProfileName,
isFromEnvProfile,
profileConfigFile,
profileExists,
profilesDir,
Expand Down Expand Up @@ -279,7 +283,42 @@ function parseTimestampMs(raw: string | undefined): string | undefined {
return new Date(timestamp).toISOString();
}

function envLakehouseAuthKind(): ProfileLakehouseAuth {
if (process.env.ALTERTABLE_BASIC_AUTH_TOKEN) {
return "basic_token";
}
if (process.env.ALTERTABLE_LAKEHOUSE_USERNAME && process.env.ALTERTABLE_LAKEHOUSE_PASSWORD) {
return "username_password";
}
return "none";
}

// The `_from_env` identity has no stored directory, so build its inspection view
// from the environment variables in effect rather than reading disk.
function inspectFromEnvProfile(): ProfileInspect {
const management: ProfileManagementAuth = hasEnvManagementCredentials() ? "api_key" : "none";
const lakehouse = envLakehouseAuthKind();
return {
name: FROM_ENV_PSEUDOPROFILE_NAME,
active: true,
config_file: "environment variables",
organization: {},
principal: {},
environment: process.env.ALTERTABLE_ENV || undefined,
endpoints: {
data_plane: process.env.ALTERTABLE_API_BASE || undefined,
control_plane: process.env.ALTERTABLE_MANAGEMENT_API_BASE || undefined,
},
auth: { management, lakehouse },
status: management !== "none" || lakehouse !== "none" ? "configured" : "empty",
timestamps: {},
};
}

export function inspectProfile(name: string): ProfileInspect {
if (isFromEnvProfile(name)) {
return inspectFromEnvProfile();
}
assertSafeProfileName(name);
ensureProfilesLayout();
if (!profileExists(name)) {
Expand Down Expand Up @@ -501,32 +540,56 @@ function hasStoredLakehouseCredentials(profileName: string): boolean {
);
}

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.
* but env configuration isolates the identity to the reserved `_from_env`
* pseudo-profile (see `envConfigMode`).
*/
export function resolveActiveProfileName(): string {
return hasCredentialsThroughEnv()
? FROM_ENV_PSEUDOPROFILE_NAME
: resolveWorkingProfile(getCliContext().profile);
return resolveWorkingProfile(getCliContext().profile);
}

const ENV_CONFIG_VARS: ReadonlyArray<{ name: string; secret: boolean }> = [
{ name: "ALTERTABLE_API_KEY", secret: true },
{ name: "ALTERTABLE_BASIC_AUTH_TOKEN", secret: true },
{ name: "ALTERTABLE_LAKEHOUSE_USERNAME", secret: false },
{ name: "ALTERTABLE_LAKEHOUSE_PASSWORD", secret: true },
{ name: "ALTERTABLE_ENV", secret: false },
{ name: "ALTERTABLE_API_BASE", secret: false },
{ name: "ALTERTABLE_MANAGEMENT_API_BASE", secret: false },
];
Comment on lines +552 to +560

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll send a follow up refactor to better manage our env variables.


/** The profile-configuring env vars currently set, with secrets masked. */
export function configuredEnvConfig(): Array<{ name: string; display: string }> {
return ENV_CONFIG_VARS.flatMap(({ name, secret }) => {
const value = process.env[name];
if (!value) {
return [];
}
return [{ name, display: secret ? "set (hidden)" : value }];
});
}

/**
* 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.
* Guard for commands that mutate stored-profile state (login, switching,
* create/delete/rename, configure). While the CLI is configured through
* environment variables the active identity is the synthetic `_from_env`
* profile — there is no stored profile to act on and the env vars would override
* any change — so refuse and show what's configured.
*/
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 assertNoEnvConfigMode(): void {
if (!envConfigMode()) {
return;
}
const lines = configuredEnvConfig().map(({ name, display }) => ` ${name.padEnd(32)} ${display}`);
throw new ConfigurationError(
[
"Profile management commands aren't available when configuring through environment variables.",
"",
"Currently configured:",
...lines,
].join("\n"),
);
}

export function configureCredentialStatus(profileName: string): ConfigureCredentialStatus {
Expand Down
20 changes: 18 additions & 2 deletions cli/src/features/profile/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,24 @@ import type {
ProfileInspect,
ProfileSummary,
} from "@/features/profile/model.ts";
import { isFromEnvProfile } from "@/features/profile/model.ts";
import type { ShellExportView } from "@/ui/shell/model.ts";

const ACTIVE_PROFILE_MARK = "✓";
const INACTIVE_PROFILE_MARK = " ";
const PROFILE_NAME_HEADER = `${INACTIVE_PROFILE_MARK} NAME`;

function profileInspectRows(profile: ProfileInspect): DisplayRow[] {
// The env pseudo-profile has no stored name/status; a heading line replaces
// them (see buildProfileInspectView).
const identityRows: DisplayRow[] = isFromEnvProfile(profile.name)
? []
: [
{ label: "Profile", value: `${profile.name}${profile.active ? " (active)" : ""}` },
{ label: "Status", value: profile.status },
];
return [
{ label: "Profile", value: `${profile.name}${profile.active ? " (active)" : ""}` },
{ label: "Status", value: profile.status },
...identityRows,
{ label: "Principal", value: formatProfilePrincipal(profile) },
{ label: "Organization", value: profile.organization.slug ?? "not set" },
{ label: "Environment", value: profile.environment ?? "not set" },
Expand All @@ -66,6 +74,14 @@ function profileInspectRows(profile: ProfileInspect): DisplayRow[] {
}

export function buildProfileInspectView(profile: ProfileInspect): DisplayDocument {
if (isFromEnvProfile(profile.name)) {
return document(
section(
text([`${TERMINAL_INDENT}Configuration set from environment variables`]),
rows(profileInspectRows(profile)),
),
);
}
return document(section(rows(profileInspectRows(profile))));
}

Expand Down
Loading
Loading