From f8d0372d97071aa59923c07f3781298320e64275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 13 Jul 2026 15:54:50 +0200 Subject: [PATCH 1/2] feat(cli): route runtime env access through typed helpers --- cli/src/cli.ts | 2 + cli/src/commands/completion.ts | 10 +- cli/src/commands/login.ts | 7 +- cli/src/features/profile/model.ts | 55 ++++----- cli/src/lib/auth.ts | 11 +- cli/src/lib/config-files.ts | 5 +- cli/src/lib/config.ts | 8 +- cli/src/lib/env.ts | 193 ++++++++++++++++++++++++++++++ cli/src/lib/http.ts | 11 +- cli/src/lib/oauth-flow.ts | 9 +- cli/src/lib/oauth-profile.ts | 3 +- cli/src/lib/pager.ts | 3 +- cli/src/lib/profile-store.ts | 15 +-- cli/src/lib/secrets.ts | 3 +- cli/src/lib/updater-config.ts | 13 -- cli/src/lib/updater.ts | 60 +++------- cli/src/lib/usage.ts | 3 +- cli/src/ui/terminal/styles.ts | 38 +++--- cli/tests/env.test.ts | 88 ++++++++++++++ cli/tests/updater.test.ts | 9 +- 20 files changed, 394 insertions(+), 152 deletions(-) create mode 100644 cli/src/lib/env.ts create mode 100644 cli/tests/env.test.ts diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 697746a..30345fd 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -41,6 +41,7 @@ import { findFirstPositionalToken, valueFlagsFor } from "@/lib/command-delegatio import { findEarlyBootstrapExit } from "@/lib/early-bootstrap.ts"; import { terminalError, applyTerminalColorFromContext } from "@/ui/terminal/styles.ts"; import { maybeShowUpdateNotice } from "@/lib/updater.ts"; +import { validateEnvironment } from "@/lib/env.ts"; function buildCliContextFromArgs(args: Record): CliContext { return parseGlobalFlagsFromArgs(args); @@ -161,6 +162,7 @@ async function bootstrap(): Promise { setCliContext(earlyContext); try { + validateEnvironment(); const earlyExit = findEarlyBootstrapExit(rawArgs); if (earlyExit?.id === "help") { const [command, parent] = await resolveSubCommandForUsage(main, rawArgs); diff --git a/cli/src/commands/completion.ts b/cli/src/commands/completion.ts index 85afc7a..7a9c302 100644 --- a/cli/src/commands/completion.ts +++ b/cli/src/commands/completion.ts @@ -23,6 +23,7 @@ import { terminalSuccess, } from "@/ui/terminal/styles.ts"; import { buildCompletionSpec } from "@/lib/completion-spec.ts"; +import { readEnv } from "@/lib/env.ts"; import { formatBashCompletion, formatFishCompletion, @@ -107,7 +108,8 @@ function resolveShell(shell: unknown): SupportedShell { throw new CliError(`Unsupported shell: ${shell}. Use ${formatSupportedShells()}.`); } - const detectedShell = process.env.SHELL ? getShellName(process.env.SHELL) : ""; + const envShell = readEnv("shell"); + const detectedShell = envShell ? getShellName(envShell) : ""; if (isSupportedShell(detectedShell)) { return detectedShell; } @@ -118,15 +120,15 @@ function resolveShell(shell: unknown): SupportedShell { } function envHome(): string { - return process.env.HOME || homedir(); + return readEnv("home") ?? homedir(); } function xdgDataHome(): string { - return process.env.XDG_DATA_HOME || join(envHome(), ".local", "share"); + return readEnv("xdgDataHome") ?? join(envHome(), ".local", "share"); } function xdgConfigHome(): string { - return process.env.XDG_CONFIG_HOME || join(envHome(), ".config"); + return readEnv("xdgConfigHome") ?? join(envHome(), ".config"); } function shellQuote(value: string): string { diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts index f31d6e6..760108a 100644 --- a/cli/src/commands/login.ts +++ b/cli/src/commands/login.ts @@ -23,6 +23,7 @@ import { updateProfile, } from "@/features/profile/model.ts"; import { terminalSuccess } from "@/ui/terminal/styles.ts"; +import { readEnv, setEnv } from "@/lib/env.ts"; function isInteractiveTerminal(): boolean { return process.stdin.isTTY; @@ -143,7 +144,7 @@ export function applyControlPlaneOverride(args: LoginArgs): void { if (!url) { return; } - const envOverride = process.env.ALTERTABLE_MANAGEMENT_API_BASE; + const envOverride = readEnv("managementApiBase"); if (envOverride && envOverride !== url) { throw new ConfigurationError( `ALTERTABLE_MANAGEMENT_API_BASE=${envOverride} overrides --control-plane-url=${url}. Unset the environment variable or make them match.`, @@ -153,10 +154,10 @@ export function applyControlPlaneOverride(args: LoginArgs): void { // resolveManagementApiRoot re-validates the URL on every read using this env // var, so set it too — otherwise an http:// root would fail the very next // read (whoami, and later commands in this process). - process.env.ALTERTABLE_ALLOW_INSECURE_HTTP = "1"; + setEnv("allowInsecureHttp", "1"); } assertAllowedApiBase(url, { allowInsecureHttp: Boolean(args["allow-insecure-http"]) }); - process.env.ALTERTABLE_MANAGEMENT_API_BASE = url; + setEnv("managementApiBase", url); } // Profile-free on purpose: the minted token — not the current profile's stored diff --git a/cli/src/features/profile/model.ts b/cli/src/features/profile/model.ts index 26c470e..b3c5c63 100644 --- a/cli/src/features/profile/model.ts +++ b/cli/src/features/profile/model.ts @@ -8,6 +8,7 @@ import { } from "@/lib/config.ts"; import { getCliContext } from "@/context.ts"; import { ConfigurationError } from "@/lib/errors.ts"; +import { CONFIG_ENV_KEYS, Env, isSecretEnv, readEnv } from "@/lib/env.ts"; import type { WhoamiResponse } from "@/features/management/model.ts"; import { moveProfileSecrets, @@ -284,10 +285,10 @@ function parseTimestampMs(raw: string | undefined): string | undefined { } function envLakehouseAuthKind(): ProfileLakehouseAuth { - if (process.env.ALTERTABLE_BASIC_AUTH_TOKEN) { + if (readEnv("basicAuthToken")) { return "basic_token"; } - if (process.env.ALTERTABLE_LAKEHOUSE_USERNAME && process.env.ALTERTABLE_LAKEHOUSE_PASSWORD) { + if (readEnv("lakehouseUsername") && readEnv("lakehousePassword")) { return "username_password"; } return "none"; @@ -304,10 +305,10 @@ function inspectFromEnvProfile(): ProfileInspect { config_file: "environment variables", organization: {}, principal: {}, - environment: process.env.ALTERTABLE_ENV || undefined, + environment: readEnv("environment"), endpoints: { - data_plane: process.env.ALTERTABLE_API_BASE || undefined, - control_plane: process.env.ALTERTABLE_MANAGEMENT_API_BASE || undefined, + data_plane: readEnv("apiBase"), + control_plane: readEnv("managementApiBase"), }, auth: { management, lakehouse }, status: management !== "none" || lakehouse !== "none" ? "configured" : "empty", @@ -513,7 +514,7 @@ export type ConfigureShowData = { }; function hasEnvManagementCredentials(): boolean { - return Boolean(process.env.ALTERTABLE_API_KEY); + return Boolean(readEnv("apiKey")); } function hasStoredManagementCredentials(profileName: string): boolean { @@ -525,12 +526,10 @@ function hasOAuthLogin(profileName: string): boolean { } function hasEnvLakehouseCredentials(): boolean { - if (process.env.ALTERTABLE_BASIC_AUTH_TOKEN) { + if (readEnv("basicAuthToken")) { return true; } - return Boolean( - process.env.ALTERTABLE_LAKEHOUSE_USERNAME && process.env.ALTERTABLE_LAKEHOUSE_PASSWORD, - ); + return Boolean(readEnv("lakehouseUsername") && readEnv("lakehousePassword")); } function hasStoredLakehouseCredentials(profileName: string): boolean { @@ -549,24 +548,14 @@ export function resolveActiveProfileName(): string { 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 }, -]; - /** 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]; + return CONFIG_ENV_KEYS.flatMap((key) => { + const value = readEnv(key); if (!value) { return []; } - return [{ name, display: secret ? "set (hidden)" : value }]; + return [{ name: Env[key], display: isSecretEnv(key) ? "set (hidden)" : String(value) }]; }); } @@ -609,7 +598,7 @@ function buildManagementCredential(profileName: string): ConfigureManagementCred configured: true, mechanism: "management_api_key", source: "environment", - environment: process.env.ALTERTABLE_ENV ?? configGet("api_key_env", profileName) ?? undefined, + environment: readEnv("environment") ?? configGet("api_key_env", profileName) ?? undefined, }; } if (hasOAuthLogin(profileName)) { @@ -653,15 +642,15 @@ function buildLakehouseCredential(profileName: string): ConfigureLakehouseCreden password: "set", }; } - if (process.env.ALTERTABLE_BASIC_AUTH_TOKEN) { + if (readEnv("basicAuthToken")) { return { configured: true, mechanism: "lakehouse_basic_token", source: "environment", }; } - const envUser = process.env.ALTERTABLE_LAKEHOUSE_USERNAME; - if (envUser && process.env.ALTERTABLE_LAKEHOUSE_PASSWORD) { + const envUser = readEnv("lakehouseUsername"); + if (envUser && readEnv("lakehousePassword")) { return { configured: true, mechanism: "lakehouse_username_password", @@ -675,13 +664,13 @@ function buildLakehouseCredential(profileName: string): ConfigureLakehouseCreden function buildConfigureShowOverrides(profileName: string): ConfigureShowOverrides { const overrides: ConfigureShowOverrides = {}; const storedApiKeyEnv = configGet("api_key_env", profileName); - const envOverride = process.env.ALTERTABLE_ENV; + const envOverride = readEnv("environment"); if (envOverride && envOverride !== storedApiKeyEnv) { overrides.environment = envOverride; overrides.stored_environment = storedApiKeyEnv || null; } - if (process.env.ALTERTABLE_API_KEY && hasStoredManagementCredentials(profileName)) { + if (readEnv("apiKey") && hasStoredManagementCredentials(profileName)) { overrides.api_key = true; } return overrides; @@ -722,11 +711,11 @@ export function managementPlaneStatusDetail(profileName: string): string | null } export function lakehousePlaneStatusDetail(profileName: string): string | null { - if (process.env.ALTERTABLE_BASIC_AUTH_TOKEN) { + if (readEnv("basicAuthToken")) { return "via ALTERTABLE_BASIC_AUTH_TOKEN"; } - const envUser = process.env.ALTERTABLE_LAKEHOUSE_USERNAME; - if (envUser && process.env.ALTERTABLE_LAKEHOUSE_PASSWORD) { + const envUser = readEnv("lakehouseUsername"); + if (envUser && readEnv("lakehousePassword")) { return envUser; } if (!hasStoredLakehouseCredentials(profileName)) { @@ -753,7 +742,7 @@ export type ActiveContext = { }; function resolveEnvironment(showData: ConfigureShowData): string | undefined { - const envOverride = process.env.ALTERTABLE_ENV; + const envOverride = readEnv("environment"); if (envOverride && envOverride.length > 0) { return envOverride; } diff --git a/cli/src/lib/auth.ts b/cli/src/lib/auth.ts index c33f128..0e2cf1b 100644 --- a/cli/src/lib/auth.ts +++ b/cli/src/lib/auth.ts @@ -1,6 +1,7 @@ import { configGet } from "@/lib/config.ts"; import { CliError, ConfigurationError, EXIT_CONFIG } from "@/lib/errors.ts"; import { secretGet } from "@/lib/secrets.ts"; +import { readEnv } from "@/lib/env.ts"; export function basicAuthToken(user: string, password: string): string { return Buffer.from(`${user}:${password}`).toString("base64"); @@ -28,13 +29,13 @@ function storedLakehouseCredentialsExpired(profileName: string): boolean { } function lakehouseEnvAuthHeader(): string | undefined { - const envToken = process.env.ALTERTABLE_BASIC_AUTH_TOKEN; + const envToken = readEnv("basicAuthToken"); if (envToken) { return basicAuthHeader(envToken); } - const envUser = process.env.ALTERTABLE_LAKEHOUSE_USERNAME; - const envPassword = process.env.ALTERTABLE_LAKEHOUSE_PASSWORD; + const envUser = readEnv("lakehouseUsername"); + const envPassword = readEnv("lakehousePassword"); if (envUser && envPassword) { return basicAuthHeader(basicAuthToken(envUser, envPassword)); } @@ -92,7 +93,7 @@ export function getLoginLakehouseCredentials( } export function getManagementAuthHeader(profileName: string): string { - const envKey = process.env.ALTERTABLE_API_KEY ?? ""; + const envKey = readEnv("apiKey") ?? ""; if (envKey) { return `Authorization: Bearer ${envKey}`; } @@ -112,7 +113,7 @@ export function getManagementAuthHeader(profileName: string): string { } function managementEnv(profileName: string): string { - return process.env.ALTERTABLE_ENV ?? configGet("api_key_env", profileName); + return readEnv("environment") ?? configGet("api_key_env", profileName); } export function requireManagementEnv(profileName: string): string { diff --git a/cli/src/lib/config-files.ts b/cli/src/lib/config-files.ts index 0ddc9c2..36c604e 100644 --- a/cli/src/lib/config-files.ts +++ b/cli/src/lib/config-files.ts @@ -1,6 +1,7 @@ import { randomBytes } from "node:crypto"; import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { readEnv } from "@/lib/env.ts"; function trim(value: string): string { return value.trim(); @@ -11,11 +12,11 @@ function tempSiblingPath(filePath: string): string { } export function configDir(): string { - const override = process.env.ALTERTABLE_CONFIG_HOME; + const override = readEnv("configHome"); if (override) { return override; } - const xdg = process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? "", ".config"); + const xdg = readEnv("xdgConfigHome") ?? join(readEnv("home") ?? "", ".config"); return join(xdg, "altertable"); } diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index 50917dd..4f17c34 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -9,6 +9,7 @@ import { kvUnset, } from "@/lib/config-files.ts"; import { assertAllowedApiBase } from "@/lib/url-policy.ts"; +import { readEnv } from "@/lib/env.ts"; import { isQueryLayout, type QueryLayout } from "@/ui/layouts/query.ts"; export { configDir, configFile, credentialsFile, kvGet, kvSet, kvUnset }; @@ -24,12 +25,11 @@ export function configSetGlobal(key: string, value: string): void { } function resolveAllowInsecureHttp(): boolean { - const envFlag = process.env.ALTERTABLE_ALLOW_INSECURE_HTTP ?? ""; - return envFlag === "true" || envFlag === "1"; + return readEnv("allowInsecureHttp") ?? false; } export function resolveApiBase(profileName: string): string { - let base = process.env.ALTERTABLE_API_BASE ?? ""; + let base = readEnv("apiBase") ?? ""; if (!base) { base = configGet("api_base", profileName); } @@ -42,7 +42,7 @@ export function resolveApiBase(profileName: string): string { } function resolveManagementApiRoot(profileName: string): string { - let root = process.env.ALTERTABLE_MANAGEMENT_API_BASE ?? ""; + let root = readEnv("managementApiBase") ?? ""; if (!root) { root = configGet("management_api_base", profileName); } diff --git a/cli/src/lib/env.ts b/cli/src/lib/env.ts new file mode 100644 index 0000000..7007a03 --- /dev/null +++ b/cli/src/lib/env.ts @@ -0,0 +1,193 @@ +import { ConfigurationError } from "@/lib/errors.ts"; + +type EnvSource = Readonly>; + +function optional(value: string | undefined): string | undefined { + return value === undefined || value.length === 0 ? undefined : value; +} + +function oneOf( + values: readonly TValue[], +): (value: string | undefined, name: string) => TValue | undefined { + return (value, name) => { + const present = optional(value); + if (present === undefined) { + return undefined; + } + if (values.includes(present as TValue)) { + return present as TValue; + } + throw invalidValue(name, present, `one of: ${values.join(", ")}`); + }; +} + +function booleanValue(value: string | undefined, name: string): boolean | undefined { + const present = optional(value)?.toLowerCase(); + if (present === undefined) { + return undefined; + } + if (present === "1" || present === "true") { + return true; + } + if (present === "0" || present === "false") { + return false; + } + throw invalidValue(name, value ?? "", "true, false, 1, or 0"); +} + +function port(value: string | undefined, name: string): number | undefined { + const present = optional(value); + if (present === undefined) { + return undefined; + } + if (!/^\d+$/.test(present)) { + throw invalidValue(name, present, "an integer from 0 to 65535"); + } + const parsed = Number(present); + if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 65_535) { + throw invalidValue(name, present, "an integer from 0 to 65535"); + } + return parsed; +} + +function invalidValue(name: string, value: string, expectation: string): ConfigurationError { + return new ConfigurationError( + `Invalid ${name} value ${JSON.stringify(value)}; expected ${expectation}.`, + ); +} + +const ENV_SCHEMA = { + allowInsecureHttp: { name: "ALTERTABLE_ALLOW_INSECURE_HTTP", parse: booleanValue }, + apiBase: { name: "ALTERTABLE_API_BASE", parse: optional }, + apiKey: { name: "ALTERTABLE_API_KEY", parse: optional, secret: true }, + basicAuthToken: { name: "ALTERTABLE_BASIC_AUTH_TOKEN", parse: optional, secret: true }, + color: { name: "ALTERTABLE_COLOR", parse: oneOf(["auto", "always", "never"] as const) }, + configHome: { name: "ALTERTABLE_CONFIG_HOME", parse: optional }, + dockerPassword: { name: "ALTERTABLE_DOCKER_PASSWORD", parse: optional, secret: true }, + dockerUser: { name: "ALTERTABLE_DOCKER_USER", parse: optional }, + environment: { name: "ALTERTABLE_ENV", parse: optional }, + httpLog: { name: "ALTERTABLE_HTTP_LOG", parse: optional }, + lakehousePassword: { + name: "ALTERTABLE_LAKEHOUSE_PASSWORD", + parse: optional, + secret: true, + }, + lakehouseUsername: { name: "ALTERTABLE_LAKEHOUSE_USERNAME", parse: optional }, + managementApiBase: { name: "ALTERTABLE_MANAGEMENT_API_BASE", parse: optional }, + mockHttpFile: { name: "ALTERTABLE_MOCK_HTTP_FILE", parse: optional }, + mockUsers: { name: "ALTERTABLE_MOCK_USERS", parse: optional }, + noUpdateCheck: { name: "ALTERTABLE_NO_UPDATE_CHECK", parse: booleanValue }, + oauthClientId: { name: "ALTERTABLE_OAUTH_CLIENT_ID", parse: optional }, + oauthRedirectPort: { name: "ALTERTABLE_OAUTH_REDIRECT_PORT", parse: port }, + oauthScope: { name: "ALTERTABLE_OAUTH_SCOPE", parse: optional }, + profile: { name: "ALTERTABLE_PROFILE", parse: optional }, + secretBackend: { + name: "ALTERTABLE_SECRET_BACKEND", + parse: oneOf(["file", "keychain"] as const), + }, + updateCheck: { + name: "ALTERTABLE_UPDATE_CHECK", + parse: oneOf(["0", "1", "false", "true", "never"] as const), + }, + updateGithubRepo: { name: "ALTERTABLE_UPDATE_GITHUB_REPO", parse: optional }, + updateInstallMethod: { + name: "ALTERTABLE_UPDATE_INSTALL_METHOD", + parse: oneOf(["auto", "package-manager", "github-binary"] as const), + }, + updateInstaller: { + name: "ALTERTABLE_UPDATE_INSTALLER", + parse: oneOf(["bun", "npm", "pnpm", "yarn"] as const), + }, + updateRegistryUrl: { name: "ALTERTABLE_UPDATE_REGISTRY_URL", parse: optional }, + updateSource: { name: "ALTERTABLE_UPDATE_SOURCE", parse: oneOf(["npm", "github"] as const) }, + bunInstall: { name: "BUN_INSTALL", parse: optional }, + ci: { name: "CI", parse: optional }, + columns: { name: "COLUMNS", parse: optional }, + forceColor: { name: "FORCE_COLOR", parse: optional }, + home: { name: "HOME", parse: optional }, + noColor: { name: "NO_COLOR", parse: optional }, + npmUserAgent: { name: "npm_config_user_agent", parse: optional }, + oscHyperlink: { name: "OSC_HYPERLINK", parse: optional }, + shell: { name: "SHELL", parse: optional }, + term: { name: "TERM", parse: optional }, + termProgram: { name: "TERM_PROGRAM", parse: optional }, + ghosttyResourcesDir: { name: "GHOSTTY_RESOURCES_DIR", parse: optional }, + sshConnection: { name: "SSH_CONNECTION", parse: optional }, + test: { name: "TEST", parse: optional }, + xdgConfigHome: { name: "XDG_CONFIG_HOME", parse: optional }, + xdgDataHome: { name: "XDG_DATA_HOME", parse: optional }, + wtSession: { name: "WT_SESSION", parse: optional }, +} as const; + +export type EnvKey = keyof typeof ENV_SCHEMA; +export type EnvValue = ReturnType<(typeof ENV_SCHEMA)[TKey]["parse"]>; + +export const Env = Object.freeze( + Object.fromEntries(Object.entries(ENV_SCHEMA).map(([key, definition]) => [key, definition.name])), +) as { readonly [TKey in EnvKey]: (typeof ENV_SCHEMA)[TKey]["name"] }; + +export function readEnv(key: TKey): EnvValue { + return readEnvFrom(process.env, key); +} + +export function readEnvFrom(source: EnvSource, key: TKey): EnvValue { + const definition = ENV_SCHEMA[key]; + return definition.parse(source[definition.name], definition.name) as EnvValue; +} + +export function setEnv(key: TKey, value: string): void { + const definition = ENV_SCHEMA[key]; + definition.parse(value, definition.name); + process.env[definition.name] = value; +} + +export function unsetEnv(key: EnvKey): void { + delete process.env[ENV_SCHEMA[key].name]; +} + +export function copyProcessEnv(): NodeJS.ProcessEnv { + return { ...process.env }; +} + +export function validateEnvironment(source: EnvSource = process.env): void { + const knownNames = new Set(Object.values(Env)); + const unknownNames = Object.keys(source) + .filter((name) => name.startsWith("ALTERTABLE_") && !knownNames.has(name)) + .sort(); + if (unknownNames.length === 0) { + for (const definition of Object.values(ENV_SCHEMA)) { + if (definition.name.startsWith("ALTERTABLE_")) { + definition.parse(source[definition.name], definition.name); + } + } + return; + } + + const noun = unknownNames.length === 1 ? "variable" : "variables"; + throw new ConfigurationError( + `Unknown Altertable environment ${noun}: ${unknownNames.join(", ")}. Check the spelling against the documented ALTERTABLE_* variables.`, + ); +} + +export type ConfigEnvKey = + | "apiKey" + | "basicAuthToken" + | "lakehouseUsername" + | "lakehousePassword" + | "environment" + | "apiBase" + | "managementApiBase"; + +export const CONFIG_ENV_KEYS = [ + "apiKey", + "basicAuthToken", + "lakehouseUsername", + "lakehousePassword", + "environment", + "apiBase", + "managementApiBase", +] as const satisfies readonly ConfigEnvKey[]; + +export function isSecretEnv(key: ConfigEnvKey): boolean { + return "secret" in ENV_SCHEMA[key] && ENV_SCHEMA[key].secret === true; +} diff --git a/cli/src/lib/http.ts b/cli/src/lib/http.ts index ea51a74..43ffeff 100644 --- a/cli/src/lib/http.ts +++ b/cli/src/lib/http.ts @@ -16,6 +16,7 @@ import { shouldRetryHttpRequest, } from "@/lib/http-retry.ts"; import { DEFAULT_READ_TIMEOUT_MS, STREAM_READ_TIMEOUT_MS } from "@/lib/transport-defaults.ts"; +import { readEnv } from "@/lib/env.ts"; export { computeRetryDelayMs, isRetryableMethod, parseRetryAfterMs } from "@/lib/http-retry.ts"; export { redactResponseBodyForDebug } from "@/lib/redact.ts"; @@ -200,7 +201,7 @@ function createChunkedMockStream(body: string): ReadableStream { } async function executeMockRequest(options: HttpSendOptions, attemptIndex: number): Promise { - const mockFile = process.env.ALTERTABLE_MOCK_HTTP_FILE; + const mockFile = readEnv("mockHttpFile"); if (!mockFile) { throw new CliError("Mock HTTP file is not configured."); } @@ -232,7 +233,7 @@ async function executeMockStream( options: HttpStreamOptions, attemptIndex: number, ): Promise> { - const mockFile = process.env.ALTERTABLE_MOCK_HTTP_FILE; + const mockFile = readEnv("mockHttpFile"); if (!mockFile) { throw new CliError("Mock HTTP file is not configured."); } @@ -288,7 +289,7 @@ export function redactAuthHeader(authHeader: string): string { } function logHttpRequest(options: HttpSendOptions): void { - const logPath = process.env.ALTERTABLE_HTTP_LOG; + const logPath = readEnv("httpLog"); if (!logPath) { return; } @@ -500,7 +501,7 @@ async function executeLiveStream(options: HttpStreamOptions): Promise { const maxAttempts = options.maxAttempts ?? MAX_RETRY_ATTEMPTS; - const mockFile = process.env.ALTERTABLE_MOCK_HTTP_FILE; + const mockFile = readEnv("mockHttpFile"); for (let attemptIndex = 0; attemptIndex < maxAttempts; attemptIndex += 1) { try { @@ -527,7 +528,7 @@ export async function httpSendStream( options: HttpStreamOptions, ): Promise> { const maxAttempts = options.maxAttempts ?? MAX_RETRY_ATTEMPTS; - const mockFile = process.env.ALTERTABLE_MOCK_HTTP_FILE; + const mockFile = readEnv("mockHttpFile"); for (let attemptIndex = 0; attemptIndex < maxAttempts; attemptIndex += 1) { try { diff --git a/cli/src/lib/oauth-flow.ts b/cli/src/lib/oauth-flow.ts index 65be398..ea719a1 100644 --- a/cli/src/lib/oauth-flow.ts +++ b/cli/src/lib/oauth-flow.ts @@ -5,6 +5,7 @@ import { httpSend } from "@/lib/http.ts"; import { ConfigurationError } from "@/lib/errors.ts"; import { terminalMetadata } from "@/ui/terminal/styles.ts"; import type { OutputSink } from "@/lib/runtime.ts"; +import { readEnv } from "@/lib/env.ts"; const LOGIN_TIMEOUT_MS = 180_000; @@ -16,11 +17,11 @@ export type TokenResponse = { }; export function oauthClientId(): string { - return process.env.ALTERTABLE_OAUTH_CLIENT_ID || "altertable_cli"; + return readEnv("oauthClientId") ?? "altertable_cli"; } export function oauthScope(): string { - return process.env.ALTERTABLE_OAUTH_SCOPE ?? "management"; + return readEnv("oauthScope") ?? "management"; } export type CallbackOutcome = { ok: true; code: string } | { ok: false; message: string }; @@ -174,9 +175,7 @@ export async function refreshAccessToken( } function redirectPort(): number { - const raw = process.env.ALTERTABLE_OAUTH_REDIRECT_PORT; - const parsed = raw ? Number.parseInt(raw, 10) : 0; - return Number.isNaN(parsed) ? 0 : parsed; + return readEnv("oauthRedirectPort") ?? 0; } export type LoopbackServer = { diff --git a/cli/src/lib/oauth-profile.ts b/cli/src/lib/oauth-profile.ts index 27e5709..d242d1b 100644 --- a/cli/src/lib/oauth-profile.ts +++ b/cli/src/lib/oauth-profile.ts @@ -2,6 +2,7 @@ import { configGet, configSet, resolveOAuthBase } from "@/lib/config.ts"; import { secretDelete, secretGet, secretSet } from "@/lib/secrets.ts"; import { ConfigurationError, HttpError } from "@/lib/errors.ts"; import { refreshAccessToken, type TokenResponse } from "@/lib/oauth-flow.ts"; +import { readEnv } from "@/lib/env.ts"; const ACCESS_TOKEN_ACCOUNT = "oauth/access-token"; const REFRESH_TOKEN_ACCOUNT = "oauth/refresh-token"; @@ -32,7 +33,7 @@ export function hasOAuthSession(profileName: string): boolean { } export async function ensureFreshAccessToken(profileName: string): Promise { - if (process.env.ALTERTABLE_API_KEY) { + if (readEnv("apiKey")) { return; // env API key takes precedence; don't refresh or clear the OAuth session } const expiryRaw = configGet(OAUTH_EXPIRY_KEY, profileName); diff --git a/cli/src/lib/pager.ts b/cli/src/lib/pager.ts index f71688b..178ff4a 100644 --- a/cli/src/lib/pager.ts +++ b/cli/src/lib/pager.ts @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import { getQueryDefaultPager } from "@/lib/config.ts"; import { getOutputSink, type OutputSink } from "@/lib/runtime.ts"; import { getVisibleTextWidth } from "@/ui/terminal/styles.ts"; +import { copyProcessEnv } from "@/lib/env.ts"; export type PagerMode = "auto" | "always" | "never"; @@ -35,7 +36,7 @@ export function shouldUsePager(text: string, options: PagerOptions): boolean { return lineCount > rows || hasWideLine; } -export function buildPagerEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { +export function buildPagerEnv(env: NodeJS.ProcessEnv = copyProcessEnv()): NodeJS.ProcessEnv { return { ...env, LESS: "FRX", LESSCHARSET: env.LESSCHARSET ?? "utf-8" }; } diff --git a/cli/src/lib/profile-store.ts b/cli/src/lib/profile-store.ts index 1147b17..e7a78b2 100644 --- a/cli/src/lib/profile-store.ts +++ b/cli/src/lib/profile-store.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync } from "node:fs"; import { join, resolve, sep } from "node:path"; import { configDir, configFile, kvGet, kvSet } from "@/lib/config-files.ts"; import { ConfigurationError } from "@/lib/errors.ts"; +import { readEnv } from "@/lib/env.ts"; export const DEFAULT_PROFILE_NAME = "default"; @@ -22,12 +23,12 @@ export function isFromEnvProfile(name: string): boolean { // secret backend, OAuth, mock/log) are deliberately excluded. export function envConfigMode(): boolean { return Boolean( - process.env.ALTERTABLE_API_KEY || - process.env.ALTERTABLE_BASIC_AUTH_TOKEN || - (process.env.ALTERTABLE_LAKEHOUSE_USERNAME && process.env.ALTERTABLE_LAKEHOUSE_PASSWORD) || - process.env.ALTERTABLE_ENV || - process.env.ALTERTABLE_API_BASE || - process.env.ALTERTABLE_MANAGEMENT_API_BASE, + readEnv("apiKey") || + readEnv("basicAuthToken") || + (readEnv("lakehouseUsername") && readEnv("lakehousePassword")) || + readEnv("environment") || + readEnv("apiBase") || + readEnv("managementApiBase"), ); } @@ -145,7 +146,7 @@ export function resolveWorkingProfile(override?: string): string { ensureProfilesLayout(); - const explicit = override ?? process.env.ALTERTABLE_PROFILE ?? ""; + const explicit = override ?? readEnv("profile") ?? ""; if (explicit.length > 0) { assertSafeProfileName(explicit); diff --git a/cli/src/lib/secrets.ts b/cli/src/lib/secrets.ts index 951f37c..8a24655 100644 --- a/cli/src/lib/secrets.ts +++ b/cli/src/lib/secrets.ts @@ -5,6 +5,7 @@ import { credentialsFile, kvGet, kvSet, kvUnset } from "@/lib/config.ts"; import { CliError } from "@/lib/errors.ts"; import { logWarn } from "@/lib/log.ts"; import { assertSafeProfileName, isFromEnvProfile } from "@/lib/profile-store.ts"; +import { readEnv } from "@/lib/env.ts"; type SecretBackend = "macos" | "file"; @@ -55,7 +56,7 @@ function hasSecurityCommand(): boolean { } function secretBackend(): SecretBackend { - const override = process.env.ALTERTABLE_SECRET_BACKEND ?? ""; + const override = readEnv("secretBackend") ?? ""; if (override === "file") { return "file"; } diff --git a/cli/src/lib/updater-config.ts b/cli/src/lib/updater-config.ts index f23bd15..0b32a63 100644 --- a/cli/src/lib/updater-config.ts +++ b/cli/src/lib/updater-config.ts @@ -75,18 +75,6 @@ export const UpdaterConfig = { checkInterval: "update_check_interval", }, defaults: UpdaterDefault, - env: { - source: "ALTERTABLE_UPDATE_SOURCE", - registryUrl: "ALTERTABLE_UPDATE_REGISTRY_URL", - githubRepo: "ALTERTABLE_UPDATE_GITHUB_REPO", - installer: "ALTERTABLE_UPDATE_INSTALLER", - installMethod: "ALTERTABLE_UPDATE_INSTALL_METHOD", - noUpdateCheck: "ALTERTABLE_NO_UPDATE_CHECK", - updateCheck: "ALTERTABLE_UPDATE_CHECK", - bunInstall: "BUN_INSTALL", - ci: "CI", - test: "TEST", - }, sources: UpdaterSourceConfig, timeoutsMs: { automatic: 900, @@ -125,5 +113,4 @@ export type InstallationKind = export const UpdaterSources = objectKeys(UpdaterConfig.sources); export const UpdaterCheckIntervals = objectKeys(UpdaterConfig.intervalsMs); -export const UpdaterInstallManagers = objectKeys(UpdaterConfig.installCommands); export const UpdaterInstallMethods = objectKeys(UpdaterConfig.installMethods); diff --git a/cli/src/lib/updater.ts b/cli/src/lib/updater.ts index bf5643e..76cb828 100644 --- a/cli/src/lib/updater.ts +++ b/cli/src/lib/updater.ts @@ -15,13 +15,11 @@ import { getOutputSink } from "@/lib/runtime.ts"; import { terminalAccent, terminalMetadata, terminalSuccess } from "@/ui/terminal/styles.ts"; import { document, rows, section, type DisplayRow } from "@/ui/document.ts"; import { renderDocumentText } from "@/ui/renderers/terminal.ts"; +import { copyProcessEnv, readEnv, readEnvFrom } from "@/lib/env.ts"; import { - UpdaterInstallManagers, UpdaterInstallationKind, UpdaterCheckIntervals, UpdaterInstallMethod, - UpdaterInstallMethods, - UpdaterSources, UpdaterConfig, type InstallationKind, type InstallManager, @@ -33,7 +31,6 @@ import { } from "@/lib/updater-config.ts"; export { - UpdaterInstallManagers, UpdaterInstallationKind, UpdaterCheckIntervals, UpdaterInstallMethod, @@ -299,34 +296,12 @@ export function setUpdateCheckInterval(interval: UpdateCheckInterval): void { configSetGlobal(UpdaterConfig.configKeys.checkInterval, interval); } -function parseUpdateSource(value: string | undefined): UpdateSource | undefined { - if (isAllowedValue(UpdaterSources, value)) { - return value; - } - return undefined; -} - -function parseUpdateInstallMethod(value: string | undefined): UpdateInstallMethod | undefined { - if (isAllowedValue(UpdaterInstallMethods, value)) { - return value; - } - return undefined; -} - export function resolveUpdateSource(source?: UpdateSource): UpdateSource { - return ( - source ?? - parseUpdateSource(process.env[UpdaterConfig.env.source]) ?? - UpdaterConfig.defaults.source - ); + return source ?? readEnv("updateSource") ?? UpdaterConfig.defaults.source; } export function resolveUpdateInstallMethod(method?: UpdateInstallMethod): UpdateInstallMethod { - return ( - method ?? - parseUpdateInstallMethod(process.env[UpdaterConfig.env.installMethod]) ?? - UpdaterConfig.defaults.installMethod - ); + return method ?? readEnv("updateInstallMethod") ?? UpdaterConfig.defaults.installMethod; } function isAllowedValue( @@ -345,15 +320,14 @@ function repoSegments(repo: string): [string, string] { } function npmRegistryUrl(): string { - const registry = - process.env[UpdaterConfig.env.registryUrl] ?? UpdaterConfig.sources.npm.registryUrl; + const registry = readEnv("updateRegistryUrl") ?? UpdaterConfig.sources.npm.registryUrl; const url = new URL(registry); appendEncodedUrlPath(url, UpdaterConfig.packageName, "latest"); return url.toString(); } function githubLatestReleaseUrl(): string { - const repo = process.env[UpdaterConfig.env.githubRepo] ?? UpdaterConfig.githubRepo; + const repo = readEnv("updateGithubRepo") ?? UpdaterConfig.githubRepo; const url = new URL(UpdaterConfig.sources.github.apiBaseUrl); appendEncodedUrlPath(url, ...repoSegments(repo), "releases", "latest"); return url.toString(); @@ -363,7 +337,7 @@ function githubReleaseMetadataUrl(version?: string): string { if (!version) { return githubLatestReleaseUrl(); } - const repo = process.env[UpdaterConfig.env.githubRepo] ?? UpdaterConfig.githubRepo; + const repo = readEnv("updateGithubRepo") ?? UpdaterConfig.githubRepo; const url = new URL(UpdaterConfig.sources.github.apiBaseUrl); appendEncodedUrlPath( url, @@ -656,13 +630,13 @@ export async function fetchLatestRelease(options: FetchLatestOptions = {}): Prom }; } -export function detectInstallManager(env: NodeJS.ProcessEnv = process.env): InstallManager { - const override = env[UpdaterConfig.env.installer]; - if (isAllowedValue(UpdaterInstallManagers, override)) { +export function detectInstallManager(env: NodeJS.ProcessEnv = copyProcessEnv()): InstallManager { + const override = readEnvFrom(env, "updateInstaller"); + if (override) { return override; } - const userAgent = env.npm_config_user_agent ?? ""; + const userAgent = readEnvFrom(env, "npmUserAgent") ?? ""; if (userAgent.startsWith("bun/")) { return "bun"; } @@ -675,7 +649,7 @@ export function detectInstallManager(env: NodeJS.ProcessEnv = process.env): Inst if (userAgent.startsWith("npm/")) { return "npm"; } - if (env[UpdaterConfig.env.bunInstall]) { + if (readEnvFrom(env, "bunInstall")) { return "bun"; } return UpdaterConfig.defaults.installManager; @@ -1054,12 +1028,12 @@ export async function checkForUpdate(options: FetchLatestOptions = {}): Promise< } function envDisablesAutomaticChecks(): boolean { - const noUpdate = process.env[UpdaterConfig.env.noUpdateCheck]; - if (noUpdate === "1" || noUpdate === "true") { + const noUpdate = readEnv("noUpdateCheck"); + if (noUpdate === true) { return true; } - const updateCheck = process.env[UpdaterConfig.env.updateCheck]; + const updateCheck = readEnv("updateCheck"); return updateCheck === "0" || updateCheck === "false" || updateCheck === "never"; } @@ -1104,11 +1078,7 @@ function shouldShowAutomaticUpdateNotice(options: { if (stderrIsTTY !== true) { return false; } - if ( - process.env[UpdaterConfig.env.ci] || - process.env[UpdaterConfig.env.test] || - envDisablesAutomaticChecks() - ) { + if (readEnv("ci") || readEnv("test") || envDisablesAutomaticChecks()) { return false; } diff --git a/cli/src/lib/usage.ts b/cli/src/lib/usage.ts index 975dcfc..a24e06a 100644 --- a/cli/src/lib/usage.ts +++ b/cli/src/lib/usage.ts @@ -9,6 +9,7 @@ import { terminalStrong, } from "@/ui/terminal/styles.ts"; import { HELP_FLAGS, VERSION_FLAGS } from "@/lib/early-bootstrap.ts"; +import { readEnv } from "@/lib/env.ts"; const HELP_INDENT = " "; const HELP_COLUMN_GAP = " "; @@ -205,7 +206,7 @@ function getHelpTerminalWidth(): number { return columns; } - const environmentColumns = Number.parseInt(globalThis.process?.env?.COLUMNS ?? "", 10); + const environmentColumns = Number.parseInt(readEnv("columns") ?? "", 10); return Number.isFinite(environmentColumns) && environmentColumns > 0 ? environmentColumns : HELP_FALLBACK_TERMINAL_WIDTH; diff --git a/cli/src/ui/terminal/styles.ts b/cli/src/ui/terminal/styles.ts index 33070d6..fb0bdd1 100644 --- a/cli/src/ui/terminal/styles.ts +++ b/cli/src/ui/terminal/styles.ts @@ -1,3 +1,5 @@ +import { readEnv, setEnv, unsetEnv } from "@/lib/env.ts"; + const COMMAND_PATTERN = /altertable(?:\s+[^\s'",]+)*/g; const MARKDOWN_LINK_PATTERN = /\[([^\]]+)]\((https?:\/\/[^)\s]+)\)/g; const URL_PATTERN = /https?:\/\/[^\s)>\]]+/g; @@ -56,30 +58,31 @@ export function setTerminalColorMode(mode: TerminalColorMode | undefined): void export function applyTerminalColorFromContext(context: { noColor?: boolean }): void { if (context.noColor) { setTerminalColorMode("never"); - process.env.NO_COLOR = "1"; + setEnv("noColor", "1"); noColorEnvSetByCli = true; return; } setTerminalColorMode(undefined); if (noColorEnvSetByCli) { - delete process.env.NO_COLOR; + unsetEnv("noColor"); noColorEnvSetByCli = false; } } export function ensurePromptColorAlignment(): void { if (!shouldUseTerminalColor()) { - process.env.NO_COLOR = "1"; + setEnv("noColor", "1"); } } function readEnvColorMode(): TerminalColorMode | undefined { - const env = globalThis.process?.env ?? {}; - const value = env.ALTERTABLE_COLOR?.trim().toLowerCase(); - if (value === "always" || value === "never" || value === "auto") { - return value; + try { + return readEnv("color"); + } catch { + // Error rendering must remain available while startup validation reports + // an invalid ALTERTABLE_COLOR value. + return undefined; } - return undefined; } function resolveTerminalColorMode(): TerminalColorMode { @@ -90,11 +93,12 @@ function resolveTerminalColorMode(): TerminalColorMode { if (envMode !== undefined) { return envMode; } - const env = globalThis.process?.env ?? {}; - if (env.NO_COLOR === "1" || env.FORCE_COLOR === "0") { + const noColor = readEnv("noColor"); + const forceColor = readEnv("forceColor"); + if (noColor === "1" || forceColor === "0") { return "never"; } - if (env.FORCE_COLOR === "1" || env.FORCE_COLOR === "2" || env.FORCE_COLOR === "3") { + if (forceColor === "1" || forceColor === "2" || forceColor === "3") { return "always"; } return "auto"; @@ -112,8 +116,7 @@ export function shouldUseTerminalColor(): boolean { if (mode === "always") { return true; } - const env = globalThis.process?.env ?? {}; - if (env.TERM === "dumb" || Boolean(env.TEST) || Boolean(env.CI)) { + if (readEnv("term") === "dumb" || Boolean(readEnv("test")) || Boolean(readEnv("ci"))) { return false; } return isInteractiveTerminal(); @@ -123,18 +126,17 @@ export function shouldUseTerminalHyperlinks(): boolean { if (!shouldUseTerminalColor()) { return false; } - const env = globalThis.process?.env ?? {}; - const override = env.OSC_HYPERLINK?.trim().toLowerCase(); + const override = readEnv("oscHyperlink"); if (override === "0" || override === "false") { return false; } if (override === "1" || override === "true") { return true; } - if (env.SSH_CONNECTION && env.TERM_PROGRAM !== "Apple_Terminal") { + const termProgram = readEnv("termProgram"); + if (readEnv("sshConnection") && termProgram !== "Apple_Terminal") { return false; } - const termProgram = env.TERM_PROGRAM; if ( termProgram === "iTerm.app" || termProgram === "Apple_Terminal" || @@ -144,7 +146,7 @@ export function shouldUseTerminalHyperlinks(): boolean { ) { return true; } - if (env.WT_SESSION || env.GHOSTTY_RESOURCES_DIR) { + if (readEnv("wtSession") || readEnv("ghosttyResourcesDir")) { return true; } return isInteractiveTerminal(); diff --git a/cli/tests/env.test.ts b/cli/tests/env.test.ts new file mode 100644 index 0000000..2732de2 --- /dev/null +++ b/cli/tests/env.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { CONFIG_ENV_KEYS, Env, isSecretEnv, readEnvFrom, validateEnvironment } from "@/lib/env.ts"; +import { ConfigurationError, EXIT_CONFIG } from "@/lib/errors.ts"; + +describe("environment schema", () => { + test("maps typed keys to their canonical environment names", () => { + expect(Env.apiKey).toBe("ALTERTABLE_API_KEY"); + expect(Env.managementApiBase).toBe("ALTERTABLE_MANAGEMENT_API_BASE"); + expect(new Set(CONFIG_ENV_KEYS).size).toBe(CONFIG_ENV_KEYS.length); + }); + + test("returns undefined for absent and empty optional values", () => { + expect(readEnvFrom({}, "apiKey")).toBeUndefined(); + expect(readEnvFrom({ ALTERTABLE_API_KEY: "" }, "apiKey")).toBeUndefined(); + }); + + test("parses booleans without truthy-string ambiguity", () => { + expect(readEnvFrom({ ALTERTABLE_ALLOW_INSECURE_HTTP: "true" }, "allowInsecureHttp")).toBe(true); + expect(readEnvFrom({ ALTERTABLE_ALLOW_INSECURE_HTTP: "0" }, "allowInsecureHttp")).toBe(false); + }); + + test("rejects invalid enum and boolean values as configuration errors", () => { + for (const [source, key] of [ + [{ ALTERTABLE_UPDATE_INSTALLER: "nmp" }, "updateInstaller"], + [{ ALTERTABLE_ALLOW_INSECURE_HTTP: "yes" }, "allowInsecureHttp"], + ] as const) { + try { + readEnvFrom(source, key); + throw new Error("expected environment validation to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).exitCode).toBe(EXIT_CONFIG); + expect((error as Error).message).toContain(Env[key]); + } + } + }); + + test("validates the OAuth redirect port exactly", () => { + expect(readEnvFrom({ ALTERTABLE_OAUTH_REDIRECT_PORT: "0" }, "oauthRedirectPort")).toBe(0); + expect(readEnvFrom({ ALTERTABLE_OAUTH_REDIRECT_PORT: "65535" }, "oauthRedirectPort")).toBe( + 65_535, + ); + expect(() => + readEnvFrom({ ALTERTABLE_OAUTH_REDIRECT_PORT: "12px" }, "oauthRedirectPort"), + ).toThrow(ConfigurationError); + expect(() => + readEnvFrom({ ALTERTABLE_OAUTH_REDIRECT_PORT: "65536" }, "oauthRedirectPort"), + ).toThrow(ConfigurationError); + }); + + test("classifies credential values for safe diagnostics", () => { + expect(isSecretEnv("apiKey")).toBe(true); + expect(isSecretEnv("lakehousePassword")).toBe(true); + expect(isSecretEnv("lakehouseUsername")).toBe(false); + }); + + test("rejects misspelled Altertable variable names", () => { + expect(() => + validateEnvironment({ ALTERTABLE_API_KEI: "secret", ALTERTABLE_API_KEY: "valid" }), + ).toThrow("Unknown Altertable environment variable: ALTERTABLE_API_KEI"); + }); + + test("validates every configured Altertable value at startup", () => { + expect(() => validateEnvironment({ ALTERTABLE_UPDATE_SOURCE: "gitlab" })).toThrow( + ConfigurationError, + ); + }); +}); + +test("production modules access the environment only through env.ts", async () => { + const sourceRoot = join(import.meta.dir, "..", "src"); + const directAccessPattern = /(?:process|globalThis\.process\?)\.env/; + const violations: string[] = []; + + for await (const relativePath of new Bun.Glob("**/*.ts").scan(sourceRoot)) { + if (relativePath === "lib/env.ts" || relativePath.startsWith("generated/")) { + continue; + } + const source = await readFile(join(sourceRoot, relativePath), "utf8"); + if (directAccessPattern.test(source)) { + violations.push(relativePath); + } + } + + expect(violations).toEqual([]); +}); diff --git a/cli/tests/updater.test.ts b/cli/tests/updater.test.ts index 924be84..a9fde20 100644 --- a/cli/tests/updater.test.ts +++ b/cli/tests/updater.test.ts @@ -8,6 +8,7 @@ import { createHash } from "node:crypto"; import { buildMainCommand, resolveTopLevelCommandName } from "@/cli.ts"; import { CLI_PACKAGE_METADATA } from "@/package-metadata.ts"; import { VERSION } from "@/version.ts"; +import { ConfigurationError } from "@/lib/errors.ts"; import { checkForUpdate, compareVersions, @@ -563,8 +564,8 @@ describe("automatic update checks", () => { expect(detectInstallManager({ npm_config_user_agent: "pnpm/9.0.0 npm/? node/?" })).toBe("pnpm"); expect(detectInstallManager({ BUN_INSTALL: "/tmp/bun" })).toBe("bun"); expect(detectInstallManager({ ALTERTABLE_UPDATE_INSTALLER: "yarn" })).toBe("yarn"); - expect(detectInstallManager({ ALTERTABLE_UPDATE_INSTALLER: "invalid" })).toBe( - UpdaterConfig.defaults.installManager, + expect(() => detectInstallManager({ ALTERTABLE_UPDATE_INSTALLER: "invalid" })).toThrow( + ConfigurationError, ); }); @@ -753,9 +754,9 @@ describe("update command", () => { expect(UpdaterInstallMethods).toContain("github-binary"); }); - test("falls back to configured source default for invalid environment override", () => { + test("rejects an invalid source environment override", () => { process.env.ALTERTABLE_UPDATE_SOURCE = "invalid"; - expect(resolveUpdateSource()).toBe(UpdaterConfig.defaults.source); + expect(() => resolveUpdateSource()).toThrow(ConfigurationError); }); }); From 37be6a5e9aa35ff1c095ac69d6c5f365b8a15c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 13 Jul 2026 16:02:50 +0200 Subject: [PATCH 2/2] refactor(env): read Altertable settings from canonical env vars --- cli/src/commands/completion.ts | 8 +- cli/src/commands/login.ts | 6 +- cli/src/features/profile/model.ts | 46 ++++--- cli/src/lib/auth.ts | 10 +- cli/src/lib/config-files.ts | 4 +- cli/src/lib/config.ts | 6 +- cli/src/lib/env.ts | 216 +++++++++++++++--------------- cli/src/lib/http.ts | 10 +- cli/src/lib/oauth-flow.ts | 6 +- cli/src/lib/oauth-profile.ts | 2 +- cli/src/lib/profile-store.ts | 14 +- cli/src/lib/secrets.ts | 2 +- cli/src/lib/updater.ts | 25 ++-- cli/src/lib/usage.ts | 2 +- cli/src/ui/terminal/styles.ts | 22 +-- cli/tests/env.test.ts | 63 ++++++--- 16 files changed, 237 insertions(+), 205 deletions(-) diff --git a/cli/src/commands/completion.ts b/cli/src/commands/completion.ts index 7a9c302..64a9f82 100644 --- a/cli/src/commands/completion.ts +++ b/cli/src/commands/completion.ts @@ -108,7 +108,7 @@ function resolveShell(shell: unknown): SupportedShell { throw new CliError(`Unsupported shell: ${shell}. Use ${formatSupportedShells()}.`); } - const envShell = readEnv("shell"); + const envShell = readEnv("SHELL"); const detectedShell = envShell ? getShellName(envShell) : ""; if (isSupportedShell(detectedShell)) { return detectedShell; @@ -120,15 +120,15 @@ function resolveShell(shell: unknown): SupportedShell { } function envHome(): string { - return readEnv("home") ?? homedir(); + return readEnv("HOME") ?? homedir(); } function xdgDataHome(): string { - return readEnv("xdgDataHome") ?? join(envHome(), ".local", "share"); + return readEnv("XDG_DATA_HOME") ?? join(envHome(), ".local", "share"); } function xdgConfigHome(): string { - return readEnv("xdgConfigHome") ?? join(envHome(), ".config"); + return readEnv("XDG_CONFIG_HOME") ?? join(envHome(), ".config"); } function shellQuote(value: string): string { diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts index 760108a..88230a2 100644 --- a/cli/src/commands/login.ts +++ b/cli/src/commands/login.ts @@ -144,7 +144,7 @@ export function applyControlPlaneOverride(args: LoginArgs): void { if (!url) { return; } - const envOverride = readEnv("managementApiBase"); + const envOverride = readEnv("ALTERTABLE_MANAGEMENT_API_BASE"); if (envOverride && envOverride !== url) { throw new ConfigurationError( `ALTERTABLE_MANAGEMENT_API_BASE=${envOverride} overrides --control-plane-url=${url}. Unset the environment variable or make them match.`, @@ -154,10 +154,10 @@ export function applyControlPlaneOverride(args: LoginArgs): void { // resolveManagementApiRoot re-validates the URL on every read using this env // var, so set it too — otherwise an http:// root would fail the very next // read (whoami, and later commands in this process). - setEnv("allowInsecureHttp", "1"); + setEnv("ALTERTABLE_ALLOW_INSECURE_HTTP", "1"); } assertAllowedApiBase(url, { allowInsecureHttp: Boolean(args["allow-insecure-http"]) }); - setEnv("managementApiBase", url); + setEnv("ALTERTABLE_MANAGEMENT_API_BASE", url); } // Profile-free on purpose: the minted token — not the current profile's stored diff --git a/cli/src/features/profile/model.ts b/cli/src/features/profile/model.ts index b3c5c63..e891d68 100644 --- a/cli/src/features/profile/model.ts +++ b/cli/src/features/profile/model.ts @@ -8,7 +8,7 @@ import { } from "@/lib/config.ts"; import { getCliContext } from "@/context.ts"; import { ConfigurationError } from "@/lib/errors.ts"; -import { CONFIG_ENV_KEYS, Env, isSecretEnv, readEnv } from "@/lib/env.ts"; +import { CONFIG_ENV_NAMES, isSecretEnv, readEnv } from "@/lib/env.ts"; import type { WhoamiResponse } from "@/features/management/model.ts"; import { moveProfileSecrets, @@ -285,10 +285,10 @@ function parseTimestampMs(raw: string | undefined): string | undefined { } function envLakehouseAuthKind(): ProfileLakehouseAuth { - if (readEnv("basicAuthToken")) { + if (readEnv("ALTERTABLE_BASIC_AUTH_TOKEN")) { return "basic_token"; } - if (readEnv("lakehouseUsername") && readEnv("lakehousePassword")) { + if (readEnv("ALTERTABLE_LAKEHOUSE_USERNAME") && readEnv("ALTERTABLE_LAKEHOUSE_PASSWORD")) { return "username_password"; } return "none"; @@ -305,10 +305,10 @@ function inspectFromEnvProfile(): ProfileInspect { config_file: "environment variables", organization: {}, principal: {}, - environment: readEnv("environment"), + environment: readEnv("ALTERTABLE_ENV"), endpoints: { - data_plane: readEnv("apiBase"), - control_plane: readEnv("managementApiBase"), + data_plane: readEnv("ALTERTABLE_API_BASE"), + control_plane: readEnv("ALTERTABLE_MANAGEMENT_API_BASE"), }, auth: { management, lakehouse }, status: management !== "none" || lakehouse !== "none" ? "configured" : "empty", @@ -514,7 +514,7 @@ export type ConfigureShowData = { }; function hasEnvManagementCredentials(): boolean { - return Boolean(readEnv("apiKey")); + return Boolean(readEnv("ALTERTABLE_API_KEY")); } function hasStoredManagementCredentials(profileName: string): boolean { @@ -526,10 +526,12 @@ function hasOAuthLogin(profileName: string): boolean { } function hasEnvLakehouseCredentials(): boolean { - if (readEnv("basicAuthToken")) { + if (readEnv("ALTERTABLE_BASIC_AUTH_TOKEN")) { return true; } - return Boolean(readEnv("lakehouseUsername") && readEnv("lakehousePassword")); + return Boolean( + readEnv("ALTERTABLE_LAKEHOUSE_USERNAME") && readEnv("ALTERTABLE_LAKEHOUSE_PASSWORD"), + ); } function hasStoredLakehouseCredentials(profileName: string): boolean { @@ -550,12 +552,12 @@ export function resolveActiveProfileName(): string { /** The profile-configuring env vars currently set, with secrets masked. */ export function configuredEnvConfig(): Array<{ name: string; display: string }> { - return CONFIG_ENV_KEYS.flatMap((key) => { - const value = readEnv(key); + return CONFIG_ENV_NAMES.flatMap((name) => { + const value = readEnv(name); if (!value) { return []; } - return [{ name: Env[key], display: isSecretEnv(key) ? "set (hidden)" : String(value) }]; + return [{ name, display: isSecretEnv(name) ? "set (hidden)" : String(value) }]; }); } @@ -598,7 +600,7 @@ function buildManagementCredential(profileName: string): ConfigureManagementCred configured: true, mechanism: "management_api_key", source: "environment", - environment: readEnv("environment") ?? configGet("api_key_env", profileName) ?? undefined, + environment: readEnv("ALTERTABLE_ENV") ?? configGet("api_key_env", profileName) ?? undefined, }; } if (hasOAuthLogin(profileName)) { @@ -642,15 +644,15 @@ function buildLakehouseCredential(profileName: string): ConfigureLakehouseCreden password: "set", }; } - if (readEnv("basicAuthToken")) { + if (readEnv("ALTERTABLE_BASIC_AUTH_TOKEN")) { return { configured: true, mechanism: "lakehouse_basic_token", source: "environment", }; } - const envUser = readEnv("lakehouseUsername"); - if (envUser && readEnv("lakehousePassword")) { + const envUser = readEnv("ALTERTABLE_LAKEHOUSE_USERNAME"); + if (envUser && readEnv("ALTERTABLE_LAKEHOUSE_PASSWORD")) { return { configured: true, mechanism: "lakehouse_username_password", @@ -664,13 +666,13 @@ function buildLakehouseCredential(profileName: string): ConfigureLakehouseCreden function buildConfigureShowOverrides(profileName: string): ConfigureShowOverrides { const overrides: ConfigureShowOverrides = {}; const storedApiKeyEnv = configGet("api_key_env", profileName); - const envOverride = readEnv("environment"); + const envOverride = readEnv("ALTERTABLE_ENV"); if (envOverride && envOverride !== storedApiKeyEnv) { overrides.environment = envOverride; overrides.stored_environment = storedApiKeyEnv || null; } - if (readEnv("apiKey") && hasStoredManagementCredentials(profileName)) { + if (readEnv("ALTERTABLE_API_KEY") && hasStoredManagementCredentials(profileName)) { overrides.api_key = true; } return overrides; @@ -711,11 +713,11 @@ export function managementPlaneStatusDetail(profileName: string): string | null } export function lakehousePlaneStatusDetail(profileName: string): string | null { - if (readEnv("basicAuthToken")) { + if (readEnv("ALTERTABLE_BASIC_AUTH_TOKEN")) { return "via ALTERTABLE_BASIC_AUTH_TOKEN"; } - const envUser = readEnv("lakehouseUsername"); - if (envUser && readEnv("lakehousePassword")) { + const envUser = readEnv("ALTERTABLE_LAKEHOUSE_USERNAME"); + if (envUser && readEnv("ALTERTABLE_LAKEHOUSE_PASSWORD")) { return envUser; } if (!hasStoredLakehouseCredentials(profileName)) { @@ -742,7 +744,7 @@ export type ActiveContext = { }; function resolveEnvironment(showData: ConfigureShowData): string | undefined { - const envOverride = readEnv("environment"); + const envOverride = readEnv("ALTERTABLE_ENV"); if (envOverride && envOverride.length > 0) { return envOverride; } diff --git a/cli/src/lib/auth.ts b/cli/src/lib/auth.ts index 0e2cf1b..a220e48 100644 --- a/cli/src/lib/auth.ts +++ b/cli/src/lib/auth.ts @@ -29,13 +29,13 @@ function storedLakehouseCredentialsExpired(profileName: string): boolean { } function lakehouseEnvAuthHeader(): string | undefined { - const envToken = readEnv("basicAuthToken"); + const envToken = readEnv("ALTERTABLE_BASIC_AUTH_TOKEN"); if (envToken) { return basicAuthHeader(envToken); } - const envUser = readEnv("lakehouseUsername"); - const envPassword = readEnv("lakehousePassword"); + const envUser = readEnv("ALTERTABLE_LAKEHOUSE_USERNAME"); + const envPassword = readEnv("ALTERTABLE_LAKEHOUSE_PASSWORD"); if (envUser && envPassword) { return basicAuthHeader(basicAuthToken(envUser, envPassword)); } @@ -93,7 +93,7 @@ export function getLoginLakehouseCredentials( } export function getManagementAuthHeader(profileName: string): string { - const envKey = readEnv("apiKey") ?? ""; + const envKey = readEnv("ALTERTABLE_API_KEY") ?? ""; if (envKey) { return `Authorization: Bearer ${envKey}`; } @@ -113,7 +113,7 @@ export function getManagementAuthHeader(profileName: string): string { } function managementEnv(profileName: string): string { - return readEnv("environment") ?? configGet("api_key_env", profileName); + return readEnv("ALTERTABLE_ENV") ?? configGet("api_key_env", profileName); } export function requireManagementEnv(profileName: string): string { diff --git a/cli/src/lib/config-files.ts b/cli/src/lib/config-files.ts index 36c604e..9f227cc 100644 --- a/cli/src/lib/config-files.ts +++ b/cli/src/lib/config-files.ts @@ -12,11 +12,11 @@ function tempSiblingPath(filePath: string): string { } export function configDir(): string { - const override = readEnv("configHome"); + const override = readEnv("ALTERTABLE_CONFIG_HOME"); if (override) { return override; } - const xdg = readEnv("xdgConfigHome") ?? join(readEnv("home") ?? "", ".config"); + const xdg = readEnv("XDG_CONFIG_HOME") ?? join(readEnv("HOME") ?? "", ".config"); return join(xdg, "altertable"); } diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index 4f17c34..e6d930b 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -25,11 +25,11 @@ export function configSetGlobal(key: string, value: string): void { } function resolveAllowInsecureHttp(): boolean { - return readEnv("allowInsecureHttp") ?? false; + return readEnv("ALTERTABLE_ALLOW_INSECURE_HTTP") ?? false; } export function resolveApiBase(profileName: string): string { - let base = readEnv("apiBase") ?? ""; + let base = readEnv("ALTERTABLE_API_BASE") ?? ""; if (!base) { base = configGet("api_base", profileName); } @@ -42,7 +42,7 @@ export function resolveApiBase(profileName: string): string { } function resolveManagementApiRoot(profileName: string): string { - let root = readEnv("managementApiBase") ?? ""; + let root = readEnv("ALTERTABLE_MANAGEMENT_API_BASE") ?? ""; if (!root) { root = configGet("management_api_base", profileName); } diff --git a/cli/src/lib/env.ts b/cli/src/lib/env.ts index 7007a03..3eb6444 100644 --- a/cli/src/lib/env.ts +++ b/cli/src/lib/env.ts @@ -57,92 +57,99 @@ function invalidValue(name: string, value: string, expectation: string): Configu } const ENV_SCHEMA = { - allowInsecureHttp: { name: "ALTERTABLE_ALLOW_INSECURE_HTTP", parse: booleanValue }, - apiBase: { name: "ALTERTABLE_API_BASE", parse: optional }, - apiKey: { name: "ALTERTABLE_API_KEY", parse: optional, secret: true }, - basicAuthToken: { name: "ALTERTABLE_BASIC_AUTH_TOKEN", parse: optional, secret: true }, - color: { name: "ALTERTABLE_COLOR", parse: oneOf(["auto", "always", "never"] as const) }, - configHome: { name: "ALTERTABLE_CONFIG_HOME", parse: optional }, - dockerPassword: { name: "ALTERTABLE_DOCKER_PASSWORD", parse: optional, secret: true }, - dockerUser: { name: "ALTERTABLE_DOCKER_USER", parse: optional }, - environment: { name: "ALTERTABLE_ENV", parse: optional }, - httpLog: { name: "ALTERTABLE_HTTP_LOG", parse: optional }, - lakehousePassword: { - name: "ALTERTABLE_LAKEHOUSE_PASSWORD", - parse: optional, - secret: true, - }, - lakehouseUsername: { name: "ALTERTABLE_LAKEHOUSE_USERNAME", parse: optional }, - managementApiBase: { name: "ALTERTABLE_MANAGEMENT_API_BASE", parse: optional }, - mockHttpFile: { name: "ALTERTABLE_MOCK_HTTP_FILE", parse: optional }, - mockUsers: { name: "ALTERTABLE_MOCK_USERS", parse: optional }, - noUpdateCheck: { name: "ALTERTABLE_NO_UPDATE_CHECK", parse: booleanValue }, - oauthClientId: { name: "ALTERTABLE_OAUTH_CLIENT_ID", parse: optional }, - oauthRedirectPort: { name: "ALTERTABLE_OAUTH_REDIRECT_PORT", parse: port }, - oauthScope: { name: "ALTERTABLE_OAUTH_SCOPE", parse: optional }, - profile: { name: "ALTERTABLE_PROFILE", parse: optional }, - secretBackend: { - name: "ALTERTABLE_SECRET_BACKEND", - parse: oneOf(["file", "keychain"] as const), - }, - updateCheck: { - name: "ALTERTABLE_UPDATE_CHECK", + ALTERTABLE_ALLOW_INSECURE_HTTP: { parse: booleanValue }, + ALTERTABLE_API_BASE: { parse: optional }, + ALTERTABLE_API_KEY: { parse: optional, secret: true }, + ALTERTABLE_BASIC_AUTH_TOKEN: { parse: optional, secret: true }, + ALTERTABLE_COLOR: { parse: oneOf(["auto", "always", "never"] as const) }, + ALTERTABLE_CONFIG_HOME: { parse: optional }, + ALTERTABLE_DOCKER_PASSWORD: { parse: optional, secret: true }, + ALTERTABLE_DOCKER_USER: { parse: optional }, + ALTERTABLE_ENV: { parse: optional }, + ALTERTABLE_HTTP_LOG: { parse: optional }, + ALTERTABLE_LAKEHOUSE_PASSWORD: { parse: optional, secret: true }, + ALTERTABLE_LAKEHOUSE_USERNAME: { parse: optional }, + ALTERTABLE_MANAGEMENT_API_BASE: { parse: optional }, + ALTERTABLE_MOCK_HTTP_FILE: { parse: optional }, + ALTERTABLE_MOCK_USERS: { parse: optional }, + ALTERTABLE_NO_UPDATE_CHECK: { parse: booleanValue }, + ALTERTABLE_OAUTH_CLIENT_ID: { parse: optional }, + ALTERTABLE_OAUTH_REDIRECT_PORT: { parse: port }, + ALTERTABLE_OAUTH_SCOPE: { parse: optional }, + ALTERTABLE_PROFILE: { parse: optional }, + ALTERTABLE_SECRET_BACKEND: { parse: oneOf(["file", "keychain"] as const) }, + ALTERTABLE_UPDATE_CHECK: { parse: oneOf(["0", "1", "false", "true", "never"] as const), }, - updateGithubRepo: { name: "ALTERTABLE_UPDATE_GITHUB_REPO", parse: optional }, - updateInstallMethod: { - name: "ALTERTABLE_UPDATE_INSTALL_METHOD", + ALTERTABLE_UPDATE_GITHUB_REPO: { parse: optional }, + ALTERTABLE_UPDATE_INSTALL_METHOD: { parse: oneOf(["auto", "package-manager", "github-binary"] as const), }, - updateInstaller: { - name: "ALTERTABLE_UPDATE_INSTALLER", - parse: oneOf(["bun", "npm", "pnpm", "yarn"] as const), - }, - updateRegistryUrl: { name: "ALTERTABLE_UPDATE_REGISTRY_URL", parse: optional }, - updateSource: { name: "ALTERTABLE_UPDATE_SOURCE", parse: oneOf(["npm", "github"] as const) }, - bunInstall: { name: "BUN_INSTALL", parse: optional }, - ci: { name: "CI", parse: optional }, - columns: { name: "COLUMNS", parse: optional }, - forceColor: { name: "FORCE_COLOR", parse: optional }, - home: { name: "HOME", parse: optional }, - noColor: { name: "NO_COLOR", parse: optional }, - npmUserAgent: { name: "npm_config_user_agent", parse: optional }, - oscHyperlink: { name: "OSC_HYPERLINK", parse: optional }, - shell: { name: "SHELL", parse: optional }, - term: { name: "TERM", parse: optional }, - termProgram: { name: "TERM_PROGRAM", parse: optional }, - ghosttyResourcesDir: { name: "GHOSTTY_RESOURCES_DIR", parse: optional }, - sshConnection: { name: "SSH_CONNECTION", parse: optional }, - test: { name: "TEST", parse: optional }, - xdgConfigHome: { name: "XDG_CONFIG_HOME", parse: optional }, - xdgDataHome: { name: "XDG_DATA_HOME", parse: optional }, - wtSession: { name: "WT_SESSION", parse: optional }, + ALTERTABLE_UPDATE_INSTALLER: { parse: oneOf(["bun", "npm", "pnpm", "yarn"] as const) }, + ALTERTABLE_UPDATE_REGISTRY_URL: { parse: optional }, + ALTERTABLE_UPDATE_SOURCE: { parse: oneOf(["npm", "github"] as const) }, + BUN_INSTALL: { parse: optional }, + CI: { parse: optional }, + COLUMNS: { parse: optional }, + FORCE_COLOR: { parse: optional }, + GHOSTTY_RESOURCES_DIR: { parse: optional }, + HOME: { parse: optional }, + NO_COLOR: { parse: optional }, + OSC_HYPERLINK: { parse: optional }, + SHELL: { parse: optional }, + SSH_CONNECTION: { parse: optional }, + TERM: { parse: optional }, + TERM_PROGRAM: { parse: optional }, + TEST: { parse: optional }, + WT_SESSION: { parse: optional }, + XDG_CONFIG_HOME: { parse: optional }, + XDG_DATA_HOME: { parse: optional }, + npm_config_user_agent: { parse: optional }, } as const; -export type EnvKey = keyof typeof ENV_SCHEMA; -export type EnvValue = ReturnType<(typeof ENV_SCHEMA)[TKey]["parse"]>; +const SECRET_NAME_PATTERN = /_(?:KEY|PASSWORD|SECRET|TOKEN)$/; + +export function assertSecretEnvMetadata>>( + schema: TSchema, +): void { + const missing = Object.entries(schema) + .filter( + ([name, definition]) => + SECRET_NAME_PATTERN.test(name) && + (definition as { readonly secret?: boolean }).secret !== true, + ) + .map(([name]) => name) + .sort(); + if (missing.length > 0) { + throw new Error( + `Secret environment variables must declare secret: true: ${missing.join(", ")}`, + ); + } +} -export const Env = Object.freeze( - Object.fromEntries(Object.entries(ENV_SCHEMA).map(([key, definition]) => [key, definition.name])), -) as { readonly [TKey in EnvKey]: (typeof ENV_SCHEMA)[TKey]["name"] }; +assertSecretEnvMetadata(ENV_SCHEMA); -export function readEnv(key: TKey): EnvValue { - return readEnvFrom(process.env, key); +export type EnvName = keyof typeof ENV_SCHEMA; +export type EnvValue = ReturnType<(typeof ENV_SCHEMA)[TName]["parse"]>; + +export function readEnv(name: TName): EnvValue { + return readEnvFrom(process.env, name); } -export function readEnvFrom(source: EnvSource, key: TKey): EnvValue { - const definition = ENV_SCHEMA[key]; - return definition.parse(source[definition.name], definition.name) as EnvValue; +export function readEnvFrom( + source: EnvSource, + name: TName, +): EnvValue { + return ENV_SCHEMA[name].parse(source[name], name) as EnvValue; } -export function setEnv(key: TKey, value: string): void { - const definition = ENV_SCHEMA[key]; - definition.parse(value, definition.name); - process.env[definition.name] = value; +export function setEnv(name: TName, value: string): void { + ENV_SCHEMA[name].parse(value, name); + process.env[name] = value; } -export function unsetEnv(key: EnvKey): void { - delete process.env[ENV_SCHEMA[key].name]; +export function unsetEnv(name: EnvName): void { + delete process.env[name]; } export function copyProcessEnv(): NodeJS.ProcessEnv { @@ -150,44 +157,43 @@ export function copyProcessEnv(): NodeJS.ProcessEnv { } export function validateEnvironment(source: EnvSource = process.env): void { - const knownNames = new Set(Object.values(Env)); + const knownNames = new Set(Object.keys(ENV_SCHEMA)); const unknownNames = Object.keys(source) .filter((name) => name.startsWith("ALTERTABLE_") && !knownNames.has(name)) .sort(); - if (unknownNames.length === 0) { - for (const definition of Object.values(ENV_SCHEMA)) { - if (definition.name.startsWith("ALTERTABLE_")) { - definition.parse(source[definition.name], definition.name); - } - } - return; + if (unknownNames.length > 0) { + const noun = unknownNames.length === 1 ? "variable" : "variables"; + throw new ConfigurationError( + `Unknown Altertable environment ${noun}: ${unknownNames.join(", ")}. Check the spelling against the documented ALTERTABLE_* variables.`, + ); } - const noun = unknownNames.length === 1 ? "variable" : "variables"; - throw new ConfigurationError( - `Unknown Altertable environment ${noun}: ${unknownNames.join(", ")}. Check the spelling against the documented ALTERTABLE_* variables.`, - ); + for (const name of Object.keys(ENV_SCHEMA) as EnvName[]) { + if (name.startsWith("ALTERTABLE_")) { + readEnvFrom(source, name); + } + } } -export type ConfigEnvKey = - | "apiKey" - | "basicAuthToken" - | "lakehouseUsername" - | "lakehousePassword" - | "environment" - | "apiBase" - | "managementApiBase"; - -export const CONFIG_ENV_KEYS = [ - "apiKey", - "basicAuthToken", - "lakehouseUsername", - "lakehousePassword", - "environment", - "apiBase", - "managementApiBase", -] as const satisfies readonly ConfigEnvKey[]; - -export function isSecretEnv(key: ConfigEnvKey): boolean { - return "secret" in ENV_SCHEMA[key] && ENV_SCHEMA[key].secret === true; +export type ConfigEnvName = + | "ALTERTABLE_API_KEY" + | "ALTERTABLE_BASIC_AUTH_TOKEN" + | "ALTERTABLE_LAKEHOUSE_USERNAME" + | "ALTERTABLE_LAKEHOUSE_PASSWORD" + | "ALTERTABLE_ENV" + | "ALTERTABLE_API_BASE" + | "ALTERTABLE_MANAGEMENT_API_BASE"; + +export const CONFIG_ENV_NAMES = [ + "ALTERTABLE_API_KEY", + "ALTERTABLE_BASIC_AUTH_TOKEN", + "ALTERTABLE_LAKEHOUSE_USERNAME", + "ALTERTABLE_LAKEHOUSE_PASSWORD", + "ALTERTABLE_ENV", + "ALTERTABLE_API_BASE", + "ALTERTABLE_MANAGEMENT_API_BASE", +] as const satisfies readonly ConfigEnvName[]; + +export function isSecretEnv(name: EnvName): boolean { + return "secret" in ENV_SCHEMA[name] && ENV_SCHEMA[name].secret === true; } diff --git a/cli/src/lib/http.ts b/cli/src/lib/http.ts index 43ffeff..d031ccf 100644 --- a/cli/src/lib/http.ts +++ b/cli/src/lib/http.ts @@ -201,7 +201,7 @@ function createChunkedMockStream(body: string): ReadableStream { } async function executeMockRequest(options: HttpSendOptions, attemptIndex: number): Promise { - const mockFile = readEnv("mockHttpFile"); + const mockFile = readEnv("ALTERTABLE_MOCK_HTTP_FILE"); if (!mockFile) { throw new CliError("Mock HTTP file is not configured."); } @@ -233,7 +233,7 @@ async function executeMockStream( options: HttpStreamOptions, attemptIndex: number, ): Promise> { - const mockFile = readEnv("mockHttpFile"); + const mockFile = readEnv("ALTERTABLE_MOCK_HTTP_FILE"); if (!mockFile) { throw new CliError("Mock HTTP file is not configured."); } @@ -289,7 +289,7 @@ export function redactAuthHeader(authHeader: string): string { } function logHttpRequest(options: HttpSendOptions): void { - const logPath = readEnv("httpLog"); + const logPath = readEnv("ALTERTABLE_HTTP_LOG"); if (!logPath) { return; } @@ -501,7 +501,7 @@ async function executeLiveStream(options: HttpStreamOptions): Promise { const maxAttempts = options.maxAttempts ?? MAX_RETRY_ATTEMPTS; - const mockFile = readEnv("mockHttpFile"); + const mockFile = readEnv("ALTERTABLE_MOCK_HTTP_FILE"); for (let attemptIndex = 0; attemptIndex < maxAttempts; attemptIndex += 1) { try { @@ -528,7 +528,7 @@ export async function httpSendStream( options: HttpStreamOptions, ): Promise> { const maxAttempts = options.maxAttempts ?? MAX_RETRY_ATTEMPTS; - const mockFile = readEnv("mockHttpFile"); + const mockFile = readEnv("ALTERTABLE_MOCK_HTTP_FILE"); for (let attemptIndex = 0; attemptIndex < maxAttempts; attemptIndex += 1) { try { diff --git a/cli/src/lib/oauth-flow.ts b/cli/src/lib/oauth-flow.ts index ea719a1..1bea1d8 100644 --- a/cli/src/lib/oauth-flow.ts +++ b/cli/src/lib/oauth-flow.ts @@ -17,11 +17,11 @@ export type TokenResponse = { }; export function oauthClientId(): string { - return readEnv("oauthClientId") ?? "altertable_cli"; + return readEnv("ALTERTABLE_OAUTH_CLIENT_ID") ?? "altertable_cli"; } export function oauthScope(): string { - return readEnv("oauthScope") ?? "management"; + return readEnv("ALTERTABLE_OAUTH_SCOPE") ?? "management"; } export type CallbackOutcome = { ok: true; code: string } | { ok: false; message: string }; @@ -175,7 +175,7 @@ export async function refreshAccessToken( } function redirectPort(): number { - return readEnv("oauthRedirectPort") ?? 0; + return readEnv("ALTERTABLE_OAUTH_REDIRECT_PORT") ?? 0; } export type LoopbackServer = { diff --git a/cli/src/lib/oauth-profile.ts b/cli/src/lib/oauth-profile.ts index d242d1b..f489206 100644 --- a/cli/src/lib/oauth-profile.ts +++ b/cli/src/lib/oauth-profile.ts @@ -33,7 +33,7 @@ export function hasOAuthSession(profileName: string): boolean { } export async function ensureFreshAccessToken(profileName: string): Promise { - if (readEnv("apiKey")) { + if (readEnv("ALTERTABLE_API_KEY")) { return; // env API key takes precedence; don't refresh or clear the OAuth session } const expiryRaw = configGet(OAUTH_EXPIRY_KEY, profileName); diff --git a/cli/src/lib/profile-store.ts b/cli/src/lib/profile-store.ts index e7a78b2..fb07bf9 100644 --- a/cli/src/lib/profile-store.ts +++ b/cli/src/lib/profile-store.ts @@ -23,12 +23,12 @@ export function isFromEnvProfile(name: string): boolean { // secret backend, OAuth, mock/log) are deliberately excluded. export function envConfigMode(): boolean { return Boolean( - readEnv("apiKey") || - readEnv("basicAuthToken") || - (readEnv("lakehouseUsername") && readEnv("lakehousePassword")) || - readEnv("environment") || - readEnv("apiBase") || - readEnv("managementApiBase"), + readEnv("ALTERTABLE_API_KEY") || + readEnv("ALTERTABLE_BASIC_AUTH_TOKEN") || + (readEnv("ALTERTABLE_LAKEHOUSE_USERNAME") && readEnv("ALTERTABLE_LAKEHOUSE_PASSWORD")) || + readEnv("ALTERTABLE_ENV") || + readEnv("ALTERTABLE_API_BASE") || + readEnv("ALTERTABLE_MANAGEMENT_API_BASE"), ); } @@ -146,7 +146,7 @@ export function resolveWorkingProfile(override?: string): string { ensureProfilesLayout(); - const explicit = override ?? readEnv("profile") ?? ""; + const explicit = override ?? readEnv("ALTERTABLE_PROFILE") ?? ""; if (explicit.length > 0) { assertSafeProfileName(explicit); diff --git a/cli/src/lib/secrets.ts b/cli/src/lib/secrets.ts index 8a24655..aad494c 100644 --- a/cli/src/lib/secrets.ts +++ b/cli/src/lib/secrets.ts @@ -56,7 +56,7 @@ function hasSecurityCommand(): boolean { } function secretBackend(): SecretBackend { - const override = readEnv("secretBackend") ?? ""; + const override = readEnv("ALTERTABLE_SECRET_BACKEND") ?? ""; if (override === "file") { return "file"; } diff --git a/cli/src/lib/updater.ts b/cli/src/lib/updater.ts index 76cb828..3740125 100644 --- a/cli/src/lib/updater.ts +++ b/cli/src/lib/updater.ts @@ -297,11 +297,13 @@ export function setUpdateCheckInterval(interval: UpdateCheckInterval): void { } export function resolveUpdateSource(source?: UpdateSource): UpdateSource { - return source ?? readEnv("updateSource") ?? UpdaterConfig.defaults.source; + return source ?? readEnv("ALTERTABLE_UPDATE_SOURCE") ?? UpdaterConfig.defaults.source; } export function resolveUpdateInstallMethod(method?: UpdateInstallMethod): UpdateInstallMethod { - return method ?? readEnv("updateInstallMethod") ?? UpdaterConfig.defaults.installMethod; + return ( + method ?? readEnv("ALTERTABLE_UPDATE_INSTALL_METHOD") ?? UpdaterConfig.defaults.installMethod + ); } function isAllowedValue( @@ -320,14 +322,15 @@ function repoSegments(repo: string): [string, string] { } function npmRegistryUrl(): string { - const registry = readEnv("updateRegistryUrl") ?? UpdaterConfig.sources.npm.registryUrl; + const registry = + readEnv("ALTERTABLE_UPDATE_REGISTRY_URL") ?? UpdaterConfig.sources.npm.registryUrl; const url = new URL(registry); appendEncodedUrlPath(url, UpdaterConfig.packageName, "latest"); return url.toString(); } function githubLatestReleaseUrl(): string { - const repo = readEnv("updateGithubRepo") ?? UpdaterConfig.githubRepo; + const repo = readEnv("ALTERTABLE_UPDATE_GITHUB_REPO") ?? UpdaterConfig.githubRepo; const url = new URL(UpdaterConfig.sources.github.apiBaseUrl); appendEncodedUrlPath(url, ...repoSegments(repo), "releases", "latest"); return url.toString(); @@ -337,7 +340,7 @@ function githubReleaseMetadataUrl(version?: string): string { if (!version) { return githubLatestReleaseUrl(); } - const repo = readEnv("updateGithubRepo") ?? UpdaterConfig.githubRepo; + const repo = readEnv("ALTERTABLE_UPDATE_GITHUB_REPO") ?? UpdaterConfig.githubRepo; const url = new URL(UpdaterConfig.sources.github.apiBaseUrl); appendEncodedUrlPath( url, @@ -631,12 +634,12 @@ export async function fetchLatestRelease(options: FetchLatestOptions = {}): Prom } export function detectInstallManager(env: NodeJS.ProcessEnv = copyProcessEnv()): InstallManager { - const override = readEnvFrom(env, "updateInstaller"); + const override = readEnvFrom(env, "ALTERTABLE_UPDATE_INSTALLER"); if (override) { return override; } - const userAgent = readEnvFrom(env, "npmUserAgent") ?? ""; + const userAgent = readEnvFrom(env, "npm_config_user_agent") ?? ""; if (userAgent.startsWith("bun/")) { return "bun"; } @@ -649,7 +652,7 @@ export function detectInstallManager(env: NodeJS.ProcessEnv = copyProcessEnv()): if (userAgent.startsWith("npm/")) { return "npm"; } - if (readEnvFrom(env, "bunInstall")) { + if (readEnvFrom(env, "BUN_INSTALL")) { return "bun"; } return UpdaterConfig.defaults.installManager; @@ -1028,12 +1031,12 @@ export async function checkForUpdate(options: FetchLatestOptions = {}): Promise< } function envDisablesAutomaticChecks(): boolean { - const noUpdate = readEnv("noUpdateCheck"); + const noUpdate = readEnv("ALTERTABLE_NO_UPDATE_CHECK"); if (noUpdate === true) { return true; } - const updateCheck = readEnv("updateCheck"); + const updateCheck = readEnv("ALTERTABLE_UPDATE_CHECK"); return updateCheck === "0" || updateCheck === "false" || updateCheck === "never"; } @@ -1078,7 +1081,7 @@ function shouldShowAutomaticUpdateNotice(options: { if (stderrIsTTY !== true) { return false; } - if (readEnv("ci") || readEnv("test") || envDisablesAutomaticChecks()) { + if (readEnv("CI") || readEnv("TEST") || envDisablesAutomaticChecks()) { return false; } diff --git a/cli/src/lib/usage.ts b/cli/src/lib/usage.ts index a24e06a..93210c4 100644 --- a/cli/src/lib/usage.ts +++ b/cli/src/lib/usage.ts @@ -206,7 +206,7 @@ function getHelpTerminalWidth(): number { return columns; } - const environmentColumns = Number.parseInt(readEnv("columns") ?? "", 10); + const environmentColumns = Number.parseInt(readEnv("COLUMNS") ?? "", 10); return Number.isFinite(environmentColumns) && environmentColumns > 0 ? environmentColumns : HELP_FALLBACK_TERMINAL_WIDTH; diff --git a/cli/src/ui/terminal/styles.ts b/cli/src/ui/terminal/styles.ts index fb0bdd1..e9e37b7 100644 --- a/cli/src/ui/terminal/styles.ts +++ b/cli/src/ui/terminal/styles.ts @@ -58,26 +58,26 @@ export function setTerminalColorMode(mode: TerminalColorMode | undefined): void export function applyTerminalColorFromContext(context: { noColor?: boolean }): void { if (context.noColor) { setTerminalColorMode("never"); - setEnv("noColor", "1"); + setEnv("NO_COLOR", "1"); noColorEnvSetByCli = true; return; } setTerminalColorMode(undefined); if (noColorEnvSetByCli) { - unsetEnv("noColor"); + unsetEnv("NO_COLOR"); noColorEnvSetByCli = false; } } export function ensurePromptColorAlignment(): void { if (!shouldUseTerminalColor()) { - setEnv("noColor", "1"); + setEnv("NO_COLOR", "1"); } } function readEnvColorMode(): TerminalColorMode | undefined { try { - return readEnv("color"); + return readEnv("ALTERTABLE_COLOR"); } catch { // Error rendering must remain available while startup validation reports // an invalid ALTERTABLE_COLOR value. @@ -93,8 +93,8 @@ function resolveTerminalColorMode(): TerminalColorMode { if (envMode !== undefined) { return envMode; } - const noColor = readEnv("noColor"); - const forceColor = readEnv("forceColor"); + const noColor = readEnv("NO_COLOR"); + const forceColor = readEnv("FORCE_COLOR"); if (noColor === "1" || forceColor === "0") { return "never"; } @@ -116,7 +116,7 @@ export function shouldUseTerminalColor(): boolean { if (mode === "always") { return true; } - if (readEnv("term") === "dumb" || Boolean(readEnv("test")) || Boolean(readEnv("ci"))) { + if (readEnv("TERM") === "dumb" || Boolean(readEnv("TEST")) || Boolean(readEnv("CI"))) { return false; } return isInteractiveTerminal(); @@ -126,15 +126,15 @@ export function shouldUseTerminalHyperlinks(): boolean { if (!shouldUseTerminalColor()) { return false; } - const override = readEnv("oscHyperlink"); + const override = readEnv("OSC_HYPERLINK"); if (override === "0" || override === "false") { return false; } if (override === "1" || override === "true") { return true; } - const termProgram = readEnv("termProgram"); - if (readEnv("sshConnection") && termProgram !== "Apple_Terminal") { + const termProgram = readEnv("TERM_PROGRAM"); + if (readEnv("SSH_CONNECTION") && termProgram !== "Apple_Terminal") { return false; } if ( @@ -146,7 +146,7 @@ export function shouldUseTerminalHyperlinks(): boolean { ) { return true; } - if (readEnv("wtSession") || readEnv("ghosttyResourcesDir")) { + if (readEnv("WT_SESSION") || readEnv("GHOSTTY_RESOURCES_DIR")) { return true; } return isInteractiveTerminal(); diff --git a/cli/tests/env.test.ts b/cli/tests/env.test.ts index 2732de2..734439e 100644 --- a/cli/tests/env.test.ts +++ b/cli/tests/env.test.ts @@ -1,30 +1,40 @@ import { describe, expect, test } from "bun:test"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import { CONFIG_ENV_KEYS, Env, isSecretEnv, readEnvFrom, validateEnvironment } from "@/lib/env.ts"; +import { + assertSecretEnvMetadata, + CONFIG_ENV_NAMES, + isSecretEnv, + readEnvFrom, + validateEnvironment, +} from "@/lib/env.ts"; import { ConfigurationError, EXIT_CONFIG } from "@/lib/errors.ts"; describe("environment schema", () => { - test("maps typed keys to their canonical environment names", () => { - expect(Env.apiKey).toBe("ALTERTABLE_API_KEY"); - expect(Env.managementApiBase).toBe("ALTERTABLE_MANAGEMENT_API_BASE"); - expect(new Set(CONFIG_ENV_KEYS).size).toBe(CONFIG_ENV_KEYS.length); + test("uses canonical environment names as typed keys", () => { + expect(CONFIG_ENV_NAMES).toContain("ALTERTABLE_API_KEY"); + expect(CONFIG_ENV_NAMES).toContain("ALTERTABLE_MANAGEMENT_API_BASE"); + expect(new Set(CONFIG_ENV_NAMES).size).toBe(CONFIG_ENV_NAMES.length); }); test("returns undefined for absent and empty optional values", () => { - expect(readEnvFrom({}, "apiKey")).toBeUndefined(); - expect(readEnvFrom({ ALTERTABLE_API_KEY: "" }, "apiKey")).toBeUndefined(); + expect(readEnvFrom({}, "ALTERTABLE_API_KEY")).toBeUndefined(); + expect(readEnvFrom({ ALTERTABLE_API_KEY: "" }, "ALTERTABLE_API_KEY")).toBeUndefined(); }); test("parses booleans without truthy-string ambiguity", () => { - expect(readEnvFrom({ ALTERTABLE_ALLOW_INSECURE_HTTP: "true" }, "allowInsecureHttp")).toBe(true); - expect(readEnvFrom({ ALTERTABLE_ALLOW_INSECURE_HTTP: "0" }, "allowInsecureHttp")).toBe(false); + expect( + readEnvFrom({ ALTERTABLE_ALLOW_INSECURE_HTTP: "true" }, "ALTERTABLE_ALLOW_INSECURE_HTTP"), + ).toBe(true); + expect( + readEnvFrom({ ALTERTABLE_ALLOW_INSECURE_HTTP: "0" }, "ALTERTABLE_ALLOW_INSECURE_HTTP"), + ).toBe(false); }); test("rejects invalid enum and boolean values as configuration errors", () => { for (const [source, key] of [ - [{ ALTERTABLE_UPDATE_INSTALLER: "nmp" }, "updateInstaller"], - [{ ALTERTABLE_ALLOW_INSECURE_HTTP: "yes" }, "allowInsecureHttp"], + [{ ALTERTABLE_UPDATE_INSTALLER: "nmp" }, "ALTERTABLE_UPDATE_INSTALLER"], + [{ ALTERTABLE_ALLOW_INSECURE_HTTP: "yes" }, "ALTERTABLE_ALLOW_INSECURE_HTTP"], ] as const) { try { readEnvFrom(source, key); @@ -32,28 +42,39 @@ describe("environment schema", () => { } catch (error) { expect(error).toBeInstanceOf(ConfigurationError); expect((error as ConfigurationError).exitCode).toBe(EXIT_CONFIG); - expect((error as Error).message).toContain(Env[key]); + expect((error as Error).message).toContain(key); } } }); test("validates the OAuth redirect port exactly", () => { - expect(readEnvFrom({ ALTERTABLE_OAUTH_REDIRECT_PORT: "0" }, "oauthRedirectPort")).toBe(0); - expect(readEnvFrom({ ALTERTABLE_OAUTH_REDIRECT_PORT: "65535" }, "oauthRedirectPort")).toBe( - 65_535, - ); + expect( + readEnvFrom({ ALTERTABLE_OAUTH_REDIRECT_PORT: "0" }, "ALTERTABLE_OAUTH_REDIRECT_PORT"), + ).toBe(0); + expect( + readEnvFrom({ ALTERTABLE_OAUTH_REDIRECT_PORT: "65535" }, "ALTERTABLE_OAUTH_REDIRECT_PORT"), + ).toBe(65_535); expect(() => - readEnvFrom({ ALTERTABLE_OAUTH_REDIRECT_PORT: "12px" }, "oauthRedirectPort"), + readEnvFrom({ ALTERTABLE_OAUTH_REDIRECT_PORT: "12px" }, "ALTERTABLE_OAUTH_REDIRECT_PORT"), ).toThrow(ConfigurationError); expect(() => - readEnvFrom({ ALTERTABLE_OAUTH_REDIRECT_PORT: "65536" }, "oauthRedirectPort"), + readEnvFrom({ ALTERTABLE_OAUTH_REDIRECT_PORT: "65536" }, "ALTERTABLE_OAUTH_REDIRECT_PORT"), ).toThrow(ConfigurationError); }); test("classifies credential values for safe diagnostics", () => { - expect(isSecretEnv("apiKey")).toBe(true); - expect(isSecretEnv("lakehousePassword")).toBe(true); - expect(isSecretEnv("lakehouseUsername")).toBe(false); + expect(isSecretEnv("ALTERTABLE_API_KEY")).toBe(true); + expect(isSecretEnv("ALTERTABLE_LAKEHOUSE_PASSWORD")).toBe(true); + expect(isSecretEnv("ALTERTABLE_LAKEHOUSE_USERNAME")).toBe(false); + }); + + test("rejects credential-looking names without secret metadata", () => { + expect(() => + assertSecretEnvMetadata({ + ALTERTABLE_NEW_TOKEN: { secret: false }, + ALTERTABLE_SAFE_VALUE: {}, + }), + ).toThrow("Secret environment variables must declare secret: true: ALTERTABLE_NEW_TOKEN"); }); test("rejects misspelled Altertable variable names", () => {