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..64a9f82 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("XDG_DATA_HOME") ?? join(envHome(), ".local", "share"); } function xdgConfigHome(): string { - return process.env.XDG_CONFIG_HOME || 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 f31d6e6..88230a2 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("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.`, @@ -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("ALTERTABLE_ALLOW_INSECURE_HTTP", "1"); } assertAllowedApiBase(url, { allowInsecureHttp: Boolean(args["allow-insecure-http"]) }); - process.env.ALTERTABLE_MANAGEMENT_API_BASE = 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 26c470e..e891d68 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_NAMES, 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("ALTERTABLE_BASIC_AUTH_TOKEN")) { return "basic_token"; } - if (process.env.ALTERTABLE_LAKEHOUSE_USERNAME && process.env.ALTERTABLE_LAKEHOUSE_PASSWORD) { + if (readEnv("ALTERTABLE_LAKEHOUSE_USERNAME") && readEnv("ALTERTABLE_LAKEHOUSE_PASSWORD")) { 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("ALTERTABLE_ENV"), endpoints: { - data_plane: process.env.ALTERTABLE_API_BASE || undefined, - control_plane: process.env.ALTERTABLE_MANAGEMENT_API_BASE || undefined, + data_plane: readEnv("ALTERTABLE_API_BASE"), + control_plane: readEnv("ALTERTABLE_MANAGEMENT_API_BASE"), }, 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("ALTERTABLE_API_KEY")); } function hasStoredManagementCredentials(profileName: string): boolean { @@ -525,11 +526,11 @@ function hasOAuthLogin(profileName: string): boolean { } function hasEnvLakehouseCredentials(): boolean { - if (process.env.ALTERTABLE_BASIC_AUTH_TOKEN) { + if (readEnv("ALTERTABLE_BASIC_AUTH_TOKEN")) { return true; } return Boolean( - process.env.ALTERTABLE_LAKEHOUSE_USERNAME && process.env.ALTERTABLE_LAKEHOUSE_PASSWORD, + readEnv("ALTERTABLE_LAKEHOUSE_USERNAME") && readEnv("ALTERTABLE_LAKEHOUSE_PASSWORD"), ); } @@ -549,24 +550,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_NAMES.flatMap((name) => { + const value = readEnv(name); if (!value) { return []; } - return [{ name, display: secret ? "set (hidden)" : value }]; + return [{ name, display: isSecretEnv(name) ? "set (hidden)" : String(value) }]; }); } @@ -609,7 +600,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("ALTERTABLE_ENV") ?? configGet("api_key_env", profileName) ?? undefined, }; } if (hasOAuthLogin(profileName)) { @@ -653,15 +644,15 @@ function buildLakehouseCredential(profileName: string): ConfigureLakehouseCreden password: "set", }; } - if (process.env.ALTERTABLE_BASIC_AUTH_TOKEN) { + if (readEnv("ALTERTABLE_BASIC_AUTH_TOKEN")) { 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("ALTERTABLE_LAKEHOUSE_USERNAME"); + if (envUser && readEnv("ALTERTABLE_LAKEHOUSE_PASSWORD")) { return { configured: true, mechanism: "lakehouse_username_password", @@ -675,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 = process.env.ALTERTABLE_ENV; + const envOverride = readEnv("ALTERTABLE_ENV"); if (envOverride && envOverride !== storedApiKeyEnv) { overrides.environment = envOverride; overrides.stored_environment = storedApiKeyEnv || null; } - if (process.env.ALTERTABLE_API_KEY && hasStoredManagementCredentials(profileName)) { + if (readEnv("ALTERTABLE_API_KEY") && hasStoredManagementCredentials(profileName)) { overrides.api_key = true; } return overrides; @@ -722,11 +713,11 @@ export function managementPlaneStatusDetail(profileName: string): string | null } export function lakehousePlaneStatusDetail(profileName: string): string | null { - if (process.env.ALTERTABLE_BASIC_AUTH_TOKEN) { + if (readEnv("ALTERTABLE_BASIC_AUTH_TOKEN")) { return "via ALTERTABLE_BASIC_AUTH_TOKEN"; } - const envUser = process.env.ALTERTABLE_LAKEHOUSE_USERNAME; - if (envUser && process.env.ALTERTABLE_LAKEHOUSE_PASSWORD) { + const envUser = readEnv("ALTERTABLE_LAKEHOUSE_USERNAME"); + if (envUser && readEnv("ALTERTABLE_LAKEHOUSE_PASSWORD")) { return envUser; } if (!hasStoredLakehouseCredentials(profileName)) { @@ -753,7 +744,7 @@ export type ActiveContext = { }; function resolveEnvironment(showData: ConfigureShowData): string | undefined { - const envOverride = process.env.ALTERTABLE_ENV; + 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 c33f128..a220e48 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("ALTERTABLE_BASIC_AUTH_TOKEN"); if (envToken) { return basicAuthHeader(envToken); } - const envUser = process.env.ALTERTABLE_LAKEHOUSE_USERNAME; - const envPassword = process.env.ALTERTABLE_LAKEHOUSE_PASSWORD; + const envUser = readEnv("ALTERTABLE_LAKEHOUSE_USERNAME"); + const envPassword = readEnv("ALTERTABLE_LAKEHOUSE_PASSWORD"); 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("ALTERTABLE_API_KEY") ?? ""; 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("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 0ddc9c2..9f227cc 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("ALTERTABLE_CONFIG_HOME"); if (override) { return override; } - const xdg = process.env.XDG_CONFIG_HOME ?? join(process.env.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 50917dd..e6d930b 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("ALTERTABLE_ALLOW_INSECURE_HTTP") ?? false; } export function resolveApiBase(profileName: string): string { - let base = process.env.ALTERTABLE_API_BASE ?? ""; + 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 = process.env.ALTERTABLE_MANAGEMENT_API_BASE ?? ""; + 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 new file mode 100644 index 0000000..3eb6444 --- /dev/null +++ b/cli/src/lib/env.ts @@ -0,0 +1,199 @@ +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 = { + 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), + }, + ALTERTABLE_UPDATE_GITHUB_REPO: { parse: optional }, + ALTERTABLE_UPDATE_INSTALL_METHOD: { + parse: oneOf(["auto", "package-manager", "github-binary"] as const), + }, + 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; + +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(", ")}`, + ); + } +} + +assertSecretEnvMetadata(ENV_SCHEMA); + +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, + name: TName, +): EnvValue { + return ENV_SCHEMA[name].parse(source[name], name) as EnvValue; +} + +export function setEnv(name: TName, value: string): void { + ENV_SCHEMA[name].parse(value, name); + process.env[name] = value; +} + +export function unsetEnv(name: EnvName): void { + delete process.env[name]; +} + +export function copyProcessEnv(): NodeJS.ProcessEnv { + return { ...process.env }; +} + +export function validateEnvironment(source: EnvSource = process.env): void { + 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) { + 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 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 ea51a74..d031ccf 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("ALTERTABLE_MOCK_HTTP_FILE"); 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("ALTERTABLE_MOCK_HTTP_FILE"); 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("ALTERTABLE_HTTP_LOG"); 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("ALTERTABLE_MOCK_HTTP_FILE"); 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("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 65be398..1bea1d8 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("ALTERTABLE_OAUTH_CLIENT_ID") ?? "altertable_cli"; } export function oauthScope(): string { - return process.env.ALTERTABLE_OAUTH_SCOPE ?? "management"; + return readEnv("ALTERTABLE_OAUTH_SCOPE") ?? "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("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 27e5709..f489206 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("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/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..fb07bf9 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("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"), ); } @@ -145,7 +146,7 @@ export function resolveWorkingProfile(override?: string): string { ensureProfilesLayout(); - const explicit = override ?? process.env.ALTERTABLE_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 951f37c..aad494c 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("ALTERTABLE_SECRET_BACKEND") ?? ""; 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..3740125 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,33 +296,13 @@ 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("ALTERTABLE_UPDATE_SOURCE") ?? UpdaterConfig.defaults.source; } export function resolveUpdateInstallMethod(method?: UpdateInstallMethod): UpdateInstallMethod { return ( - method ?? - parseUpdateInstallMethod(process.env[UpdaterConfig.env.installMethod]) ?? - UpdaterConfig.defaults.installMethod + method ?? readEnv("ALTERTABLE_UPDATE_INSTALL_METHOD") ?? UpdaterConfig.defaults.installMethod ); } @@ -346,14 +323,14 @@ function repoSegments(repo: string): [string, string] { function npmRegistryUrl(): string { const registry = - process.env[UpdaterConfig.env.registryUrl] ?? UpdaterConfig.sources.npm.registryUrl; + 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 = process.env[UpdaterConfig.env.githubRepo] ?? 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(); @@ -363,7 +340,7 @@ function githubReleaseMetadataUrl(version?: string): string { if (!version) { return githubLatestReleaseUrl(); } - const repo = process.env[UpdaterConfig.env.githubRepo] ?? UpdaterConfig.githubRepo; + const repo = readEnv("ALTERTABLE_UPDATE_GITHUB_REPO") ?? UpdaterConfig.githubRepo; const url = new URL(UpdaterConfig.sources.github.apiBaseUrl); appendEncodedUrlPath( url, @@ -656,13 +633,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, "ALTERTABLE_UPDATE_INSTALLER"); + if (override) { return override; } - const userAgent = env.npm_config_user_agent ?? ""; + const userAgent = readEnvFrom(env, "npm_config_user_agent") ?? ""; if (userAgent.startsWith("bun/")) { return "bun"; } @@ -675,7 +652,7 @@ export function detectInstallManager(env: NodeJS.ProcessEnv = process.env): Inst if (userAgent.startsWith("npm/")) { return "npm"; } - if (env[UpdaterConfig.env.bunInstall]) { + if (readEnvFrom(env, "BUN_INSTALL")) { return "bun"; } return UpdaterConfig.defaults.installManager; @@ -1054,12 +1031,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("ALTERTABLE_NO_UPDATE_CHECK"); + if (noUpdate === true) { return true; } - const updateCheck = process.env[UpdaterConfig.env.updateCheck]; + const updateCheck = readEnv("ALTERTABLE_UPDATE_CHECK"); return updateCheck === "0" || updateCheck === "false" || updateCheck === "never"; } @@ -1104,11 +1081,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..93210c4 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..e9e37b7 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("NO_COLOR", "1"); noColorEnvSetByCli = true; return; } setTerminalColorMode(undefined); if (noColorEnvSetByCli) { - delete process.env.NO_COLOR; + unsetEnv("NO_COLOR"); noColorEnvSetByCli = false; } } export function ensurePromptColorAlignment(): void { if (!shouldUseTerminalColor()) { - process.env.NO_COLOR = "1"; + setEnv("NO_COLOR", "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("ALTERTABLE_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("NO_COLOR"); + const forceColor = readEnv("FORCE_COLOR"); + 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("OSC_HYPERLINK"); 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("TERM_PROGRAM"); + if (readEnv("SSH_CONNECTION") && 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("WT_SESSION") || readEnv("GHOSTTY_RESOURCES_DIR")) { return true; } return isInteractiveTerminal(); diff --git a/cli/tests/env.test.ts b/cli/tests/env.test.ts new file mode 100644 index 0000000..734439e --- /dev/null +++ b/cli/tests/env.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + assertSecretEnvMetadata, + CONFIG_ENV_NAMES, + isSecretEnv, + readEnvFrom, + validateEnvironment, +} from "@/lib/env.ts"; +import { ConfigurationError, EXIT_CONFIG } from "@/lib/errors.ts"; + +describe("environment schema", () => { + 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({}, "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" }, "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" }, "ALTERTABLE_UPDATE_INSTALLER"], + [{ ALTERTABLE_ALLOW_INSECURE_HTTP: "yes" }, "ALTERTABLE_ALLOW_INSECURE_HTTP"], + ] 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(key); + } + } + }); + + test("validates the OAuth redirect port exactly", () => { + 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" }, "ALTERTABLE_OAUTH_REDIRECT_PORT"), + ).toThrow(ConfigurationError); + expect(() => + readEnvFrom({ ALTERTABLE_OAUTH_REDIRECT_PORT: "65536" }, "ALTERTABLE_OAUTH_REDIRECT_PORT"), + ).toThrow(ConfigurationError); + }); + + test("classifies credential values for safe diagnostics", () => { + 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", () => { + 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); }); });