From da1dd89bf6677311864c369e31709e33b84917ad Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Thu, 4 Jun 2026 10:22:50 +0200 Subject: [PATCH 1/2] feat: add env file imports --- README.md | 1 + docs/product/command-spec.md | 19 +- packages/cli/README.md | 1 + packages/cli/package.json | 1 + packages/cli/src/commands/env.ts | 16 +- packages/cli/src/controllers/app-env-api.ts | 116 ++++++ packages/cli/src/controllers/app-env-file.ts | 305 ++++++++++++++++ packages/cli/src/controllers/app-env.ts | 241 ++++++------- packages/cli/src/lib/app/env-file.ts | 168 +++++++++ packages/cli/src/presenters/app-env.ts | 42 +++ packages/cli/src/shell/command-meta.ts | 4 + packages/cli/src/types/app-env.ts | 19 +- packages/cli/tests/app-env-vars.test.ts | 160 +++++++++ packages/cli/tests/app-env.test.ts | 356 +++++++++++++++++++ pnpm-lock.yaml | 14 +- 15 files changed, 1322 insertions(+), 141 deletions(-) create mode 100644 packages/cli/src/controllers/app-env-api.ts create mode 100644 packages/cli/src/controllers/app-env-file.ts create mode 100644 packages/cli/src/lib/app/env-file.ts diff --git a/README.md b/README.md index 49c39f6..0266c9d 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Example workflow: ```bash pnpm prisma-cli auth login pnpm prisma-cli app deploy --env DATABASE_URL=postgresql://example +pnpm prisma-cli project env add --file .env --role preview pnpm prisma-cli project env list --role preview ``` diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index fc1410a..a547a66 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -687,7 +687,7 @@ Every write targets exactly one scope: the active local Git branch when one exists; outside a Git branch it shows a production/preview project-level overview. -### `prisma-cli project env add KEY=VALUE (--role | --branch )` +### `prisma-cli project env add (KEY=VALUE | --file ) (--role | --branch )` Purpose: @@ -700,6 +700,12 @@ Behavior: - KEY=VALUE is parsed from a single positional; KEY must match `[A-Z_][A-Z0-9_]*` - KEY without `=VALUE` reads the value from the current process environment +- `--file ` reads KEY=VALUE assignments from a dotenv file relative to + the current directory; `--file` is mutually exclusive with the positional + assignment +- file imports validate the whole file before writing; duplicate keys, invalid + keys, empty values, or existing target variables fail before any variables are + created - if a variable with the same key already exists in the scope, the command fails with a clear error directing to `env update` - branch-only variables are allowed; the CLI warns when the key does @@ -711,11 +717,13 @@ Examples: ```bash prisma-cli project env add STRIPE_KEY=sk_test_xxx --role production prisma-cli project env add STRIPE_KEY=sk_test_xxx --role preview +prisma-cli project env add --file .env --role preview prisma-cli project env add DATABASE_URL=postgresql://branch --branch feature/foo +prisma-cli project env add --file .env.local --branch feature/foo API_URL=https://api.example prisma-cli project env add API_URL --project proj_123 --role preview ``` -### `prisma-cli project env update KEY=VALUE (--role | --branch )` +### `prisma-cli project env update (KEY=VALUE | --file ) (--role | --branch )` Purpose: @@ -728,6 +736,12 @@ Behavior: - KEY=VALUE is parsed from a single positional; KEY must match `[A-Z_][A-Z0-9_]*` - KEY without `=VALUE` reads the value from the current process environment +- `--file ` reads KEY=VALUE assignments from a dotenv file relative to + the current directory; `--file` is mutually exclusive with the positional + assignment +- file imports validate the whole file before writing; duplicate keys, invalid + keys, empty values, or missing target variables fail before any variables are + updated - if no variable with the key exists in the scope, the command fails with a clear error directing to `env add` - the response carries metadata only — the value is never echoed back @@ -737,6 +751,7 @@ Examples: ```bash prisma-cli project env update STRIPE_KEY=sk_new_xxx --role production prisma-cli project env update STRIPE_KEY=sk_new_xxx --role preview +prisma-cli project env update --file .env --role production prisma-cli project env update DATABASE_URL=postgresql://branch --branch feature/foo ``` diff --git a/packages/cli/README.md b/packages/cli/README.md index 2567465..fce4bf6 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -57,6 +57,7 @@ Useful next commands: npx prisma-cli app logs npx prisma-cli app open npx prisma-cli project env add DATABASE_URL=postgresql://example --role preview +npx prisma-cli project env add --file .env --role preview npx prisma-cli project env list npx prisma-cli project env list --role preview ``` diff --git a/packages/cli/package.json b/packages/cli/package.json index 02faf0d..49eefdc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -47,6 +47,7 @@ "c12": "4.0.0-beta.5", "colorette": "^2.0.20", "commander": "^14.0.3", + "dotenv": "^17.4.2", "magicast": "^0.5.3", "open": "^11.0.0", "string-width": "^8.2.1", diff --git a/packages/cli/src/commands/env.ts b/packages/cli/src/commands/env.ts index e8af0c7..950a4b1 100644 --- a/packages/cli/src/commands/env.ts +++ b/packages/cli/src/commands/env.ts @@ -44,7 +44,8 @@ function createEnvAddCommand(runtime: CliRuntime): Command { ); command - .argument("", "Variable assignment as KEY=VALUE or KEY from the current environment") + .argument("[assignment]", "Variable assignment as KEY=VALUE or KEY from the current environment") + .addOption(new Option("--file ", "Read KEY=VALUE assignments from a dotenv file")) .addOption( new Option( "--role ", @@ -55,16 +56,17 @@ function createEnvAddCommand(runtime: CliRuntime): Command { .addOption(new Option("--project ", "Project id or name")); addGlobalFlags(command); - command.action(async (assignment: string, options) => { + command.action(async (assignment: string | undefined, options) => { const roleName = (options as { role?: string }).role; const branchName = (options as { branch?: string }).branch; const projectRef = (options as { project?: string }).project; + const filePath = (options as { file?: string }).file; await runCommand( runtime, "project.env.add", options as Record, - (context) => runEnvAdd(context, assignment, { roleName, branchName, projectRef }), + (context) => runEnvAdd(context, assignment, { roleName, branchName, projectRef, filePath }), { renderHuman: (context, descriptor, result) => renderEnvAdd(context, descriptor, result), renderJson: (result) => serializeEnvAdd(result), @@ -82,7 +84,8 @@ function createEnvUpdateCommand(runtime: CliRuntime): Command { ); command - .argument("", "Variable assignment as KEY=VALUE or KEY from the current environment") + .argument("[assignment]", "Variable assignment as KEY=VALUE or KEY from the current environment") + .addOption(new Option("--file ", "Read KEY=VALUE assignments from a dotenv file")) .addOption( new Option( "--role ", @@ -93,16 +96,17 @@ function createEnvUpdateCommand(runtime: CliRuntime): Command { .addOption(new Option("--project ", "Project id or name")); addGlobalFlags(command); - command.action(async (assignment: string, options) => { + command.action(async (assignment: string | undefined, options) => { const roleName = (options as { role?: string }).role; const branchName = (options as { branch?: string }).branch; const projectRef = (options as { project?: string }).project; + const filePath = (options as { file?: string }).file; await runCommand( runtime, "project.env.update", options as Record, - (context) => runEnvUpdate(context, assignment, { roleName, branchName, projectRef }), + (context) => runEnvUpdate(context, assignment, { roleName, branchName, projectRef, filePath }), { renderHuman: (context, descriptor, result) => renderEnvUpdate(context, descriptor, result), renderJson: (result) => serializeEnvUpdate(result), diff --git a/packages/cli/src/controllers/app-env-api.ts b/packages/cli/src/controllers/app-env-api.ts new file mode 100644 index 0000000..0172656 --- /dev/null +++ b/packages/cli/src/controllers/app-env-api.ts @@ -0,0 +1,116 @@ +import type { ManagementApiClient } from "@prisma/management-api-sdk"; + +import type { EnvVarRole } from "../lib/app/env-config"; +import { authRequiredError, CliError } from "../shell/errors"; +import type { EnvScopeDescriptor, EnvVariableMetadata } from "../types/app-env"; + +export interface ResolvedEnvApiScope { + descriptor: EnvScopeDescriptor; + apiTarget: { class: EnvVarRole; branchId: string | null }; +} + +export interface RawEnvironmentVariable { + id: string; + key: string; + branchId: string | null; + class: "production" | "preview"; + isManagedBySystem: boolean; + updatedAt: string; +} + +interface ApiErrorBody { + error?: { + code?: string; + message?: string; + hint?: string; + }; +} + +export async function findVariableByNaturalKey( + client: ManagementApiClient, + projectId: string, + key: string, + resolved: ResolvedEnvApiScope, + signal: AbortSignal, +): Promise { + const { data, error, response } = await client.GET("/v1/environment-variables", { + params: { + query: { + projectId, + class: resolved.apiTarget.class, + key, + }, + }, + signal, + }); + if (error || !data) { + throw apiCallError(`Failed to look up ${key}`, response, error); + } + + const matches = (data.data as RawEnvironmentVariable[]).filter((row) => + rowMatchesExactScope(row, resolved), + ); + return matches[0] ?? null; +} + +export function toMetadata( + row: RawEnvironmentVariable, + requestedScope: EnvScopeDescriptor, +): EnvVariableMetadata { + const rowScope = + row.branchId === null + ? ({ kind: "role", role: row.class } satisfies EnvScopeDescriptor) + : requestedScope; + + return { + id: row.id, + key: row.key, + scope: rowScope, + source: formatDescriptorLabel(rowScope), + isManagedBySystem: row.isManagedBySystem, + updatedAt: row.updatedAt, + }; +} + +export function rowMatchesExactScope( + row: RawEnvironmentVariable, + resolved: ResolvedEnvApiScope, +): boolean { + return row.class === resolved.apiTarget.class && + row.branchId === resolved.apiTarget.branchId; +} + +export function apiCallError( + summary: string, + response: Response | undefined, + error: ApiErrorBody | undefined, +): CliError { + const status = response?.status ?? 0; + const apiCode = error?.error?.code; + const apiMessage = error?.error?.message; + const apiHint = error?.error?.hint; + + if (status === 401 || status === 403) { + return authRequiredError(["prisma auth login"]); + } + + return new CliError({ + code: apiCode ?? "ENV_API_ERROR", + domain: "app", + summary, + why: apiMessage ?? `The Management API returned status ${status || "unknown"}.`, + fix: apiHint ?? "Re-run with --trace for the underlying API response details.", + exitCode: 1, + nextSteps: [], + }); +} + +function formatDescriptorLabel(scope: EnvScopeDescriptor): string { + if (scope.kind === "role") { + return scope.role ?? "unknown"; + } + if (scope.kind === "overview") { + return "overview"; + } + return `branch:${scope.branchName ?? scope.branchId ?? "unknown"}`; +} diff --git a/packages/cli/src/controllers/app-env-file.ts b/packages/cli/src/controllers/app-env-file.ts new file mode 100644 index 0000000..1090a53 --- /dev/null +++ b/packages/cli/src/controllers/app-env-file.ts @@ -0,0 +1,305 @@ +import type { ManagementApiClient } from "@prisma/management-api-sdk"; + +import { formatScopeLabel, type EnvScope } from "../lib/app/env-config"; +import type { EnvFileAssignment } from "../lib/app/env-file"; +import { CliError } from "../shell/errors"; +import type { CommandSuccess } from "../shell/output"; +import type { CommandContext } from "../shell/runtime"; +import type { + EnvAddResult, + EnvUpdateResult, + EnvVariableMetadata, +} from "../types/app-env"; +import { + apiCallError, + findVariableByNaturalKey, + type RawEnvironmentVariable, + type ResolvedEnvApiScope, + toMetadata, +} from "./app-env-api"; + +export interface ResolvedEnvFileScope extends ResolvedEnvApiScope { + scope: EnvScope; +} + +export async function runEnvAddFile( + context: CommandContext, + client: ManagementApiClient, + projectId: string, + resolved: ResolvedEnvFileScope, + filePath: string, + assignments: EnvFileAssignment[], +): Promise> { + const existing = await findVariablesByNaturalKey( + client, + projectId, + assignments.map((assignment) => assignment.key), + resolved, + context.runtime.signal, + ); + const existingKeys = assignments + .map((assignment) => assignment.key) + .filter((key) => existing.has(key)); + + if (existingKeys.length > 0) { + throw new CliError({ + code: "ENV_VARIABLE_ALREADY_EXISTS", + domain: "app", + summary: `${existingKeys.length} environment variable(s) already exist in ${formatScopeLabel(resolved.scope)}`, + why: `Existing keys: ${formatKeyList(existingKeys)}.`, + fix: "Use `prisma-cli project env update --file` to change existing values.", + exitCode: 1, + nextSteps: [ + `prisma-cli project env update --file ${filePath} ${formatScopeFlag(resolved.scope)}`, + ], + meta: { keys: existingKeys }, + }); + } + + const warnings = await missingPreviewDefaultWarnings( + client, + projectId, + resolved.scope, + assignments.map((assignment) => assignment.key), + context.runtime.signal, + ); + + const variables: EnvVariableMetadata[] = []; + for (const assignment of assignments) { + try { + const { data, error, response } = await client.POST( + "/v1/environment-variables", + { + body: { + projectId, + class: resolved.apiTarget.class, + ...(resolved.apiTarget.branchId !== null + ? { branchId: resolved.apiTarget.branchId } + : {}), + key: assignment.key, + value: assignment.value, + }, + signal: context.runtime.signal, + }, + ); + if (error || !data) { + throw apiCallError(`Failed to add ${assignment.key}`, response, error); + } + variables.push(toMetadata(data.data as RawEnvironmentVariable, resolved.descriptor)); + } catch (error) { + throw envFileApplyFailedError("add", filePath, resolved.scope, assignment.key, variables, error); + } + } + + return { + command: "project.env.add", + result: { + projectId, + scope: resolved.descriptor, + variables, + file: { + path: filePath, + count: variables.length, + }, + }, + warnings, + nextSteps: [], + }; +} + +export async function runEnvUpdateFile( + context: CommandContext, + client: ManagementApiClient, + projectId: string, + resolved: ResolvedEnvFileScope, + filePath: string, + assignments: EnvFileAssignment[], +): Promise> { + const existing = await findVariablesByNaturalKey( + client, + projectId, + assignments.map((assignment) => assignment.key), + resolved, + context.runtime.signal, + ); + const missingKeys = assignments + .map((assignment) => assignment.key) + .filter((key) => !existing.has(key)); + + if (missingKeys.length > 0) { + throw new CliError({ + code: "ENV_VARIABLE_NOT_FOUND", + domain: "app", + summary: `${missingKeys.length} environment variable(s) not found in ${formatScopeLabel(resolved.scope)}`, + why: `Missing keys: ${formatKeyList(missingKeys)}.`, + fix: "Use `prisma-cli project env add --file` to create new variables first.", + exitCode: 1, + nextSteps: [ + `prisma-cli project env add --file ${filePath} ${formatScopeFlag(resolved.scope)}`, + ], + meta: { keys: missingKeys }, + }); + } + + const variables: EnvVariableMetadata[] = []; + for (const assignment of assignments) { + const existingVariable = existing.get(assignment.key); + if (!existingVariable) { + continue; + } + + try { + const { data, error, response } = await client.PATCH( + "/v1/environment-variables/{envVarId}", + { + params: { path: { envVarId: existingVariable.id } }, + body: { value: assignment.value }, + signal: context.runtime.signal, + }, + ); + if (error || !data) { + throw apiCallError(`Failed to update value for ${assignment.key}`, response, error); + } + variables.push(toMetadata(data.data as RawEnvironmentVariable, resolved.descriptor)); + } catch (error) { + throw envFileApplyFailedError("update", filePath, resolved.scope, assignment.key, variables, error); + } + } + + return { + command: "project.env.update", + result: { + projectId, + scope: resolved.descriptor, + variables, + file: { + path: filePath, + count: variables.length, + }, + }, + warnings: [], + nextSteps: [], + }; +} + +async function findVariablesByNaturalKey( + client: ManagementApiClient, + projectId: string, + keys: string[], + resolved: ResolvedEnvFileScope, + signal: AbortSignal, +): Promise> { + const found = new Map(); + + for (const key of keys) { + const row = await findVariableByNaturalKey(client, projectId, key, resolved, signal); + if (row) { + found.set(key, row); + } + } + + return found; +} + +async function missingPreviewDefaultWarnings( + client: ManagementApiClient, + projectId: string, + scope: EnvScope, + keys: string[], + signal: AbortSignal, +): Promise { + if (scope.kind !== "branch") { + return []; + } + + const previewScope: ResolvedEnvFileScope = { + scope: { kind: "role", role: "preview" }, + descriptor: { kind: "role", role: "preview" }, + apiTarget: { class: "preview", branchId: null }, + }; + const missing: string[] = []; + + for (const key of keys) { + if (!(await findVariableByNaturalKey(client, projectId, key, previewScope, signal))) { + missing.push(key); + } + } + + if (missing.length === 0) { + return []; + } + + if (missing.length === 1) { + return [ + `Variable "${missing[0]}" does not exist in preview. It will only exist on ${formatScopeLabel(scope)}.`, + ]; + } + + return [ + `Variables ${formatKeyList(missing)} do not exist in preview. They will only exist on ${formatScopeLabel(scope)}.`, + ]; +} + +function envFileApplyFailedError( + command: "add" | "update", + filePath: string, + scope: EnvScope, + failedKey: string, + writtenVariables: EnvVariableMetadata[], + error: unknown, +): CliError { + const writtenKeys = writtenVariables.map((variable) => variable.key); + const cause = error instanceof CliError + ? error.summary + : error instanceof Error + ? error.message + : "Unknown error."; + + return new CliError({ + code: "ENV_FILE_APPLY_FAILED", + domain: "app", + summary: `Failed to ${command} "${failedKey}" from "${filePath}"`, + why: writtenKeys.length === 0 + ? `No variables were written before ${failedKey} failed. Cause: ${cause}` + : `Written keys before failure: ${formatKeyList(writtenKeys)}. Cause: ${cause}`, + fix: "Inspect the target scope, then retry the remaining keys once the API issue is resolved.", + exitCode: 1, + nextSteps: [ + `prisma-cli project env list ${formatScopeFlag(scope)}`, + retryStepForApplyFailure(command, filePath, scope, writtenKeys), + ], + meta: { + file: filePath, + failedKey, + writtenKeys, + }, + }); +} + +function retryStepForApplyFailure( + command: "add" | "update", + filePath: string, + scope: EnvScope, + writtenKeys: string[], +): string { + if (command === "update") { + return `prisma-cli project env update --file ${filePath} ${formatScopeFlag(scope)}`; + } + + if (writtenKeys.length === 0) { + return `prisma-cli project env add --file ${filePath} ${formatScopeFlag(scope)}`; + } + + return `prisma-cli project env add --file ${formatScopeFlag(scope)}`; +} + +function formatKeyList(keys: string[]): string { + return keys.map((key) => `"${key}"`).join(", "); +} + +function formatScopeFlag(scope: EnvScope): string { + if (scope.kind === "role") { + return `--role ${scope.role}`; + } + return `--branch ${scope.branchName}`; +} diff --git a/packages/cli/src/controllers/app-env.ts b/packages/cli/src/controllers/app-env.ts index 665fa5a..4383e66 100644 --- a/packages/cli/src/controllers/app-env.ts +++ b/packages/cli/src/controllers/app-env.ts @@ -7,6 +7,7 @@ import { type EnvScope, type EnvVarRole, } from "../lib/app/env-config"; +import { readEnvFileAssignments, type EnvFileAssignment } from "../lib/app/env-file"; import { requireComputeAuth } from "../lib/auth/guard"; import { readLocalGitBranch } from "../lib/git/local-branch"; import { authRequiredError, CliError, usageError, workspaceRequiredError } from "../shell/errors"; @@ -20,15 +21,20 @@ import type { EnvRmResult, EnvScopeDescriptor, EnvUpdateResult, - EnvVariableMetadata, } from "../types/app-env"; +import { + apiCallError, + findVariableByNaturalKey, + type RawEnvironmentVariable, + type ResolvedEnvApiScope, + toMetadata, +} from "./app-env-api"; +import { runEnvAddFile, runEnvUpdateFile } from "./app-env-file"; import { requireAuthenticatedAuthState } from "./auth"; import { listRealWorkspaceProjects } from "./project"; -interface ResolvedScope { +interface ResolvedScope extends ResolvedEnvApiScope { scope: EnvScope; - descriptor: EnvScopeDescriptor; - apiTarget: { class: EnvVarRole; branchId: string | null }; } type ResolvedListScope = @@ -50,16 +56,16 @@ interface EnvCommandFlags { roleName?: string; branchName?: string; projectRef?: string; + filePath?: string; } -interface RawEnvironmentVariable { - id: string; - key: string; - branchId: string | null; - class: "production" | "preview"; - isManagedBySystem: boolean; - updatedAt: string; -} +type EnvWriteSource = + | { kind: "single"; rawAssignment: string | undefined } + | { kind: "file"; filePath: string }; + +type ResolvedEnvWriteInput = + | { kind: "single"; key: string; value: string } + | { kind: "file"; filePath: string; assignments: EnvFileAssignment[] }; interface RawBranchRecord { id: string; @@ -73,49 +79,54 @@ export async function runEnvAdd( rawAssignment: string | undefined, flags: EnvCommandFlags, ): Promise> { - const { key, value } = parseKeyValuePositional(rawAssignment, "add", context.runtime.env); + const source = resolveEnvWriteSource(rawAssignment, flags.filePath, "add"); const scope = resolveEnvScope(flags, { requireExplicit: true, command: "add" }); if (!scope) { throw usageError( `prisma-cli project env add requires --role or --branch`, "Writing without an explicit scope is rejected.", "Pass --role production, --role preview, or --branch .", - [`prisma-cli project env add ${key}=${value} --role production`], + ["prisma-cli project env add KEY=value --role production"], "app", ); } + const input = await resolveEnvWriteInput(context, source, "add"); const { client, projectId } = await requireClientAndProject(context, flags.projectRef, "project env add"); const resolved = await resolveScopeToApi(client, projectId, scope, { createBranchIfMissing: true, signal: context.runtime.signal, }); - const existing = await findVariableByNaturalKey(client, projectId, key, resolved, context.runtime.signal); + if (input.kind === "file") { + return runEnvAddFile(context, client, projectId, resolved, input.filePath, input.assignments); + } + + const existing = await findVariableByNaturalKey(client, projectId, input.key, resolved, context.runtime.signal); if (existing) { throw new CliError({ code: "ENV_VARIABLE_ALREADY_EXISTS", domain: "app", - summary: `Variable "${key}" already exists in ${formatScopeLabel(scope)}`, + summary: `Variable "${input.key}" already exists in ${formatScopeLabel(scope)}`, why: "A variable with this key already exists in the targeted scope.", fix: "Use `prisma-cli project env update` to change an existing variable's value.", exitCode: 1, nextSteps: [ - `prisma-cli project env update ${key}= ${formatScopeFlag(scope)}`, + `prisma-cli project env update ${input.key}= ${formatScopeFlag(scope)}`, ], }); } const warnings = scope.kind === "branch" && - !(await findVariableByNaturalKey(client, projectId, key, { + !(await findVariableByNaturalKey(client, projectId, input.key, { scope: { kind: "role", role: "preview" }, descriptor: { kind: "role", role: "preview" }, apiTarget: { class: "preview", branchId: null }, }, context.runtime.signal)) ? [ - `Variable "${key}" does not exist in preview. It will only exist on ${formatScopeLabel(scope)}.`, + `Variable "${input.key}" does not exist in preview. It will only exist on ${formatScopeLabel(scope)}.`, ] : []; @@ -126,16 +137,16 @@ export async function runEnvAdd( projectId, class: resolved.apiTarget.class, ...(resolved.apiTarget.branchId !== null - ? { branchId: resolved.apiTarget.branchId } - : {}), - key, - value, + ? { branchId: resolved.apiTarget.branchId } + : {}), + key: input.key, + value: input.value, }, signal: context.runtime.signal, }, ); if (error || !data) { - throw apiCallError(`Failed to add ${key}`, response, error); + throw apiCallError(`Failed to add ${input.key}`, response, error); } return { @@ -155,36 +166,41 @@ export async function runEnvUpdate( rawAssignment: string | undefined, flags: EnvCommandFlags, ): Promise> { - const { key, value } = parseKeyValuePositional(rawAssignment, "update", context.runtime.env); + const source = resolveEnvWriteSource(rawAssignment, flags.filePath, "update"); const scope = resolveEnvScope(flags, { requireExplicit: true, command: "update" }); if (!scope) { throw usageError( `prisma-cli project env update requires --role or --branch`, "Writing without an explicit scope is rejected.", "Pass --role production, --role preview, or --branch .", - [`prisma-cli project env update ${key}=${value} --role production`], + ["prisma-cli project env update KEY=value --role production"], "app", ); } + const input = await resolveEnvWriteInput(context, source, "update"); const { client, projectId } = await requireClientAndProject(context, flags.projectRef, "project env update"); const resolved = await resolveScopeToApi(client, projectId, scope, { createBranchIfMissing: false, signal: context.runtime.signal, }); - const existing = await findVariableByNaturalKey(client, projectId, key, resolved, context.runtime.signal); + if (input.kind === "file") { + return runEnvUpdateFile(context, client, projectId, resolved, input.filePath, input.assignments); + } + + const existing = await findVariableByNaturalKey(client, projectId, input.key, resolved, context.runtime.signal); if (!existing) { throw new CliError({ code: "ENV_VARIABLE_NOT_FOUND", domain: "app", - summary: `Variable "${key}" not found in ${formatScopeLabel(scope)}`, + summary: `Variable "${input.key}" not found in ${formatScopeLabel(scope)}`, why: "No variable with this key exists in the targeted scope.", fix: "Use `prisma-cli project env add` to create a new variable.", exitCode: 1, nextSteps: [ - `prisma-cli project env add ${key}= ${formatScopeFlag(scope)}`, + `prisma-cli project env add ${input.key}= ${formatScopeFlag(scope)}`, ], }); } @@ -193,12 +209,12 @@ export async function runEnvUpdate( "/v1/environment-variables/{envVarId}", { params: { path: { envVarId: existing.id } }, - body: { value }, + body: { value: input.value }, signal: context.runtime.signal, }, ); if (error || !data) { - throw apiCallError(`Failed to update value for ${key}`, response, error); + throw apiCallError(`Failed to update value for ${input.key}`, response, error); } return { @@ -213,6 +229,72 @@ export async function runEnvUpdate( }; } +function resolveEnvWriteSource( + rawAssignment: string | undefined, + filePath: string | undefined, + command: "add" | "update", +): EnvWriteSource { + if (filePath !== undefined && rawAssignment !== undefined) { + throw usageError( + `prisma-cli project env ${command} accepts either KEY=VALUE or --file`, + "The command received both a positional assignment and a dotenv file path.", + "Pass one input source.", + [ + `prisma-cli project env ${command} KEY=value --role preview`, + `prisma-cli project env ${command} --file .env --role preview`, + ], + "app", + ); + } + + if (filePath !== undefined) { + if (filePath.length === 0) { + throw usageError( + `prisma-cli project env ${command} --file requires a path`, + "The --file flag was passed without a file path.", + "Pass a readable dotenv file path.", + [`prisma-cli project env ${command} --file .env --role preview`], + "app", + ); + } + return { kind: "file", filePath }; + } + + if (rawAssignment === undefined) { + throw usageError( + `prisma-cli project env ${command} requires KEY=VALUE or --file`, + "No environment variable input was supplied.", + "Pass a single KEY=VALUE assignment or a dotenv file path.", + [ + `prisma-cli project env ${command} KEY=value --role preview`, + `prisma-cli project env ${command} --file .env --role preview`, + ], + "app", + ); + } + + return { kind: "single", rawAssignment }; +} + +async function resolveEnvWriteInput( + context: CommandContext, + source: EnvWriteSource, + command: "add" | "update", +): Promise { + if (source.kind === "file") { + return { + kind: "file", + filePath: source.filePath, + assignments: await readEnvFileAssignments(context.runtime.cwd, source.filePath, command), + }; + } + + return { + kind: "single", + ...parseKeyValuePositional(source.rawAssignment, command, context.runtime.env), + }; +} + export async function runEnvList( context: CommandContext, flags: EnvCommandFlags, @@ -628,33 +710,6 @@ async function projectHasDefaultBranch( } } -async function findVariableByNaturalKey( - client: ManagementApiClient, - projectId: string, - key: string, - resolved: ResolvedScope, - signal: AbortSignal, -): Promise { - const { data, error, response } = await client.GET("/v1/environment-variables", { - params: { - query: { - projectId, - class: resolved.apiTarget.class, - key, - }, - }, - signal, - }); - if (error || !data) { - throw apiCallError(`Failed to look up ${key}`, response, error); - } - - const matches = (data.data as RawEnvironmentVariable[]).filter((row) => - rowMatchesExactScope(row, resolved), - ); - return matches[0] ?? null; -} - async function listVariables( client: ManagementApiClient, projectId: string, @@ -750,14 +805,6 @@ function rowMatchesScope( return row.branchId === null || row.branchId === resolved.apiTarget.branchId; } -function rowMatchesExactScope( - row: RawEnvironmentVariable, - resolved: ResolvedScope, -): boolean { - return row.class === resolved.apiTarget.class && - row.branchId === resolved.apiTarget.branchId; -} - function materializeEffectiveRows( rows: RawEnvironmentVariable[], resolved: ResolvedScope, @@ -780,65 +827,3 @@ function materializeEffectiveRows( return [...byKey.values()].sort((left, right) => left.key.localeCompare(right.key)); } - -function toMetadata( - row: RawEnvironmentVariable, - requestedScope: EnvScopeDescriptor, -): EnvVariableMetadata { - const rowScope = - row.branchId === null - ? ({ kind: "role", role: row.class } satisfies EnvScopeDescriptor) - : requestedScope; - - return { - id: row.id, - key: row.key, - scope: rowScope, - source: formatDescriptorLabel(rowScope), - isManagedBySystem: row.isManagedBySystem, - updatedAt: row.updatedAt, - }; -} - -function formatDescriptorLabel(scope: EnvScopeDescriptor): string { - if (scope.kind === "role") { - return scope.role ?? "unknown"; - } - if (scope.kind === "overview") { - return "overview"; - } - return `branch:${scope.branchName ?? scope.branchId ?? "unknown"}`; -} - -interface ApiErrorBody { - error?: { - code?: string; - message?: string; - hint?: string; - }; -} - -function apiCallError( - summary: string, - response: Response | undefined, - error: ApiErrorBody | undefined, -): CliError { - const status = response?.status ?? 0; - const apiCode = error?.error?.code; - const apiMessage = error?.error?.message; - const apiHint = error?.error?.hint; - - if (status === 401 || status === 403) { - return authRequiredError(["prisma auth login"]); - } - - return new CliError({ - code: apiCode ?? "ENV_API_ERROR", - domain: "app", - summary, - why: apiMessage ?? `The Management API returned status ${status || "unknown"}.`, - fix: apiHint ?? "Re-run with --trace for the underlying API response details.", - exitCode: 1, - nextSteps: [], - }); -} diff --git a/packages/cli/src/lib/app/env-file.ts b/packages/cli/src/lib/app/env-file.ts new file mode 100644 index 0000000..55db82c --- /dev/null +++ b/packages/cli/src/lib/app/env-file.ts @@ -0,0 +1,168 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { parse as parseDotenv } from "dotenv"; + +import { usageError } from "../../shell/errors"; +import { validateKey } from "./env-config"; + +export interface EnvFileAssignment { + key: string; + value: string; +} + +interface ParsedEnvFileKey { + key: string; + line: number; +} + +const ASSIGNMENT_KEY_PATTERN = /^\s*(?:export\s+)?([^#=\s]+)\s*=/; + +export async function readEnvFileAssignments( + cwd: string, + filePath: string, + command: "add" | "update", +): Promise { + const resolvedPath = path.resolve(cwd, filePath); + let contents: string; + try { + contents = await readFile(resolvedPath, "utf8"); + } catch (error) { + throw usageError( + `Failed to read env file "${filePath}"`, + error instanceof Error ? error.message : "The file could not be read.", + "Pass a readable dotenv file path.", + [`prisma-cli project env ${command} --file .env --role preview`], + "app", + ); + } + + return parseEnvFileContents(contents, filePath, command); +} + +export function parseEnvFileContents( + contents: string, + filePath: string, + command: "add" | "update", +): EnvFileAssignment[] { + const parsedKeys = extractParsedKeys(contents); + if (parsedKeys.length === 0) { + throw usageError( + `No environment variables found in "${filePath}"`, + "The file does not contain any KEY=VALUE assignments.", + "Pass a dotenv file with at least one non-empty variable.", + [], + "app", + ); + } + + const seen = new Map(); + for (const entry of parsedKeys) { + validateEnvFileKey(entry.key, entry.line, filePath, command); + const firstLine = seen.get(entry.key); + if (firstLine !== undefined) { + throw usageError( + `Duplicate environment variable "${entry.key}" in "${filePath}"`, + `Lines ${firstLine} and ${entry.line} both define ${entry.key}.`, + "Keep one assignment for each key before importing the file.", + [], + "app", + ); + } + seen.set(entry.key, entry.line); + } + + const parsedValues = parseDotenv(contents); + const assignments = parsedKeys.map(({ key }) => { + const value = parsedValues[key]; + if (typeof value !== "string" || value.length === 0) { + const line = seen.get(key); + throw usageError( + `Environment variable "${key}" in "${filePath}" has an empty value`, + line === undefined + ? `${key} has an empty value.` + : `Line ${line} defines ${key} with an empty value.`, + "Pass a non-empty value, or omit the key from the file.", + [], + "app", + ); + } + + return { key, value }; + }); + + return assignments; +} + +function extractParsedKeys(contents: string): ParsedEnvFileKey[] { + const keys: ParsedEnvFileKey[] = []; + let multilineQuote: string | null = null; + + const lines = contents.split(/\n/); + for (const [index, line] of lines.entries()) { + const lineNumber = index + 1; + if (multilineQuote !== null) { + if (hasClosingQuote(line, multilineQuote, 0)) { + multilineQuote = null; + } + continue; + } + + const match = ASSIGNMENT_KEY_PATTERN.exec(line); + if (!match) { + continue; + } + + const key = match[1]; + keys.push({ + key, + line: lineNumber, + }); + + const valueStart = line.slice(match[0].length).trimStart(); + const openingQuote = valueStart[0]; + if ( + (openingQuote === "\"" || openingQuote === "'" || openingQuote === "`") && + !hasClosingQuote(valueStart, openingQuote, 1) + ) { + multilineQuote = openingQuote; + } + } + + return keys; +} + +function validateEnvFileKey( + key: string, + line: number, + filePath: string, + command: "add" | "update", +): void { + try { + validateKey(key, command); + } catch { + throw usageError( + `Invalid environment variable "${key}" in "${filePath}"`, + `Line ${line} uses a key that does not match [A-Z_][A-Z0-9_]*.`, + "Rename the key to use uppercase letters, digits, and underscores.", + [], + "app", + ); + } +} + +function hasClosingQuote(value: string, quote: string, startIndex: number): boolean { + for (let index = startIndex; index < value.length; index += 1) { + if (value[index] === quote && !isEscaped(value, index)) { + return true; + } + } + return false; +} + +function isEscaped(value: string, index: number): boolean { + let backslashes = 0; + for (let cursor = index - 1; cursor >= 0 && value[cursor] === "\\"; cursor -= 1) { + backslashes += 1; + } + return backslashes % 2 === 1; +} diff --git a/packages/cli/src/presenters/app-env.ts b/packages/cli/src/presenters/app-env.ts index 3046823..deb3a89 100644 --- a/packages/cli/src/presenters/app-env.ts +++ b/packages/cli/src/presenters/app-env.ts @@ -38,6 +38,27 @@ export function renderEnvAdd( descriptor: CommandDescriptor, result: EnvAddResult, ): string[] { + if (result.variables !== undefined) { + return renderList( + { + title: "Setting new environment variables from file.", + descriptor, + parentContext: { + key: "target", + value: `${scopeLabel(result.scope)} from ${result.file.path}`, + }, + items: result.variables.map((variable) => ({ + noun: "variable", + label: `${variable.key} (${variable.source})`, + id: variable.id, + status: variable.isManagedBySystem ? "default" : null, + })), + emptyMessage: "No environment variables imported.", + }, + context.ui, + ); + } + return renderShow( { title: "Setting a new environment variable.", @@ -67,6 +88,27 @@ export function renderEnvUpdate( descriptor: CommandDescriptor, result: EnvUpdateResult, ): string[] { + if (result.variables !== undefined) { + return renderList( + { + title: "Replacing environment variable values from file.", + descriptor, + parentContext: { + key: "target", + value: `${scopeLabel(result.scope)} from ${result.file.path}`, + }, + items: result.variables.map((variable) => ({ + noun: "variable", + label: `${variable.key} (${variable.source})`, + id: variable.id, + status: variable.isManagedBySystem ? "default" : null, + })), + emptyMessage: "No environment variables updated.", + }, + context.ui, + ); + } + return renderShow( { title: "Replacing the environment variable's value.", diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 4eab405..a58f435 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -250,6 +250,7 @@ const DESCRIPTORS: CommandDescriptor[] = [ examples: [ "prisma-cli project env list", "prisma-cli project env add STRIPE_KEY=sk_test_xxx --role production", + "prisma-cli project env add --file .env --role preview", "prisma-cli project env add DATABASE_URL=postgresql://branch --branch feature/foo", "prisma-cli project env remove STRIPE_KEY --role preview", ], @@ -261,7 +262,9 @@ const DESCRIPTORS: CommandDescriptor[] = [ examples: [ "prisma-cli project env add STRIPE_KEY=sk_test_xxx --role production", "prisma-cli project env add STRIPE_KEY=sk_test_xxx --role preview", + "prisma-cli project env add --file .env --role preview", "prisma-cli project env add DATABASE_URL=postgresql://branch --branch feature/foo", + "prisma-cli project env add --file .env.local --branch feature/foo", "API_URL=https://api.example prisma-cli project env add API_URL --project proj_123 --role preview", ], }, @@ -272,6 +275,7 @@ const DESCRIPTORS: CommandDescriptor[] = [ examples: [ "prisma-cli project env update STRIPE_KEY=sk_new_xxx --role production", "prisma-cli project env update STRIPE_KEY=sk_new_xxx --role preview", + "prisma-cli project env update --file .env --role production", "prisma-cli project env update DATABASE_URL=postgresql://branch --branch feature/foo", ], }, diff --git a/packages/cli/src/types/app-env.ts b/packages/cli/src/types/app-env.ts index 46392cd..689d998 100644 --- a/packages/cli/src/types/app-env.ts +++ b/packages/cli/src/types/app-env.ts @@ -21,18 +21,31 @@ export interface EnvVariableMetadata { updatedAt: string; } -export interface EnvAddResult { +export interface EnvFileMetadata { + path: string; + count: number; +} + +export interface EnvSingleWriteResult { projectId: string; scope: EnvScopeDescriptor; variable: EnvVariableMetadata; + variables?: never; + file?: never; } -export interface EnvUpdateResult { +export interface EnvFileWriteResult { projectId: string; scope: EnvScopeDescriptor; - variable: EnvVariableMetadata; + variable?: never; + variables: EnvVariableMetadata[]; + file: EnvFileMetadata; } +export type EnvAddResult = EnvSingleWriteResult | EnvFileWriteResult; + +export type EnvUpdateResult = EnvSingleWriteResult | EnvFileWriteResult; + export interface EnvListResult { projectId: string; scope: EnvScopeDescriptor; diff --git a/packages/cli/tests/app-env-vars.test.ts b/packages/cli/tests/app-env-vars.test.ts index 9822a5a..aa0f6ad 100644 --- a/packages/cli/tests/app-env-vars.test.ts +++ b/packages/cli/tests/app-env-vars.test.ts @@ -73,6 +73,80 @@ function createResolveBranch(role: "preview" | "production" = "preview") { } describe("app env vars", () => { + it("parses dotenv file contents without expanding values", async () => { + const { parseEnvFileContents } = await import("../src/lib/app/env-file"); + + expect( + parseEnvFileContents( + [ + "# local settings", + "API_URL=https://api.example", + "QUOTED=\"hello world\"", + "export FEATURE_FLAG=enabled", + "LITERAL=${API_URL}/v1", + ].join("\n"), + ".env", + "add", + ), + ).toEqual([ + { key: "API_URL", value: "https://api.example" }, + { key: "QUOTED", value: "hello world" }, + { key: "FEATURE_FLAG", value: "enabled" }, + { key: "LITERAL", value: "${API_URL}/v1" }, + ]); + }); + + it("parses multiline dotenv values without treating nested KEY= text as assignments", async () => { + const { parseEnvFileContents } = await import("../src/lib/app/env-file"); + + expect( + parseEnvFileContents( + [ + "CERT=\"-----BEGIN CERT-----", + "API_URL=https://inside.example", + "-----END CERT-----\"", + "API_URL=https://api.example", + ].join("\n"), + ".env", + "add", + ), + ).toEqual([ + { + key: "CERT", + value: "-----BEGIN CERT-----\nAPI_URL=https://inside.example\n-----END CERT-----", + }, + { key: "API_URL", value: "https://api.example" }, + ]); + }); + + it("rejects invalid dotenv file entries without leaking values", async () => { + const { parseEnvFileContents } = await import("../src/lib/app/env-file"); + + expect(() => + parseEnvFileContents("API_URL=https://first\nAPI_URL=https://second\n", ".env", "add"), + ).toThrowError(expect.objectContaining({ + code: "USAGE_ERROR", + summary: 'Duplicate environment variable "API_URL" in ".env"', + })); + + expect(() => + parseEnvFileContents("lowercase-key=secret\n", ".env", "add"), + ).toThrowError(expect.objectContaining({ + code: "USAGE_ERROR", + summary: 'Invalid environment variable "lowercase-key" in ".env"', + })); + + try { + parseEnvFileContents("EMPTY=\n", ".env", "add"); + } catch (error) { + expect(error).toMatchObject({ + code: "USAGE_ERROR", + summary: 'Environment variable "EMPTY" in ".env" has an empty value', + }); + expect(JSON.stringify(error)).not.toContain("secret"); + } + }); + it("parses repeated env assignments and allows empty values", async () => { const { parseEnvAssignments } = await import("../src/lib/app/env-vars"); @@ -233,6 +307,92 @@ describe("app env vars", () => { }); }); + it("parses project env add --file through the CLI command layer", async () => { + const runEnvAdd = vi.fn().mockResolvedValue({ + command: "project.env.add", + result: { + projectId: "proj_123", + scope: { kind: "role", role: "preview" }, + variables: [ + { + id: "envvar_api", + key: "API_URL", + scope: { kind: "role", role: "preview" }, + source: "preview", + isManagedBySystem: false, + updatedAt: "2026-05-08T10:00:00.000Z", + }, + ], + file: { + path: ".env", + count: 1, + }, + }, + warnings: [], + nextSteps: [], + }); + + vi.doMock("../src/controllers/app-env", async () => { + const actual = await vi.importActual("../src/controllers/app-env"); + return { + ...actual, + runEnvAdd, + }; + }); + + const { createTempCwd, executeCli } = await import("./helpers"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + + const result = await executeCli({ + argv: [ + "project", + "env", + "add", + "--file", + ".env", + "--role", + "preview", + "--project", + "proj_123", + "--json", + ], + cwd, + stateDir, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + command: "project.env.add", + result: { + file: { + path: ".env", + count: 1, + }, + variables: [ + { + key: "API_URL", + }, + ], + }, + }); + expect(runEnvAdd).toHaveBeenCalledWith( + expect.anything(), + undefined, + { + roleName: "preview", + branchName: undefined, + projectRef: "proj_123", + filePath: ".env", + }, + ); + }); + it("passes env vars to provider deploy without surfacing values", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ diff --git a/packages/cli/tests/app-env.test.ts b/packages/cli/tests/app-env.test.ts index 8807c38..7b6df20 100644 --- a/packages/cli/tests/app-env.test.ts +++ b/packages/cli/tests/app-env.test.ts @@ -231,6 +231,119 @@ describe("env add", () => { ); }); + it("creates variables from a dotenv file via POST without surfacing values", async () => { + const client = createMockClient(); + client.envGET + .mockResolvedValueOnce({ + data: { data: [], pagination: { hasMore: false, nextCursor: null } }, + response: { status: 200 }, + }) + .mockResolvedValueOnce({ + data: { data: [], pagination: { hasMore: false, nextCursor: null } }, + response: { status: 200 }, + }); + client.POST + .mockResolvedValueOnce({ + data: { data: makeVariableRow({ id: "envvar_api", key: "API_URL", class: "preview" }) }, + response: { status: 201 }, + }) + .mockResolvedValueOnce({ + data: { data: makeVariableRow({ id: "envvar_stripe", key: "STRIPE_KEY", class: "preview" }) }, + response: { status: 201 }, + }); + + const { controllers, createTempCwd, createTestCommandContext } = + await loadControllers(client, "proj_123"); + const cwd = await createTempCwd(); + await writeLocalPin(cwd); + await writeFile( + path.join(cwd, ".env"), + "API_URL=https://api.example\nSTRIPE_KEY=sk_test_xxx\n", + "utf8", + ); + const { context } = await createTestCommandContext({ cwd }); + + const result = await controllers.runEnvAdd( + context, + undefined, + { roleName: "preview", filePath: ".env" }, + ); + + expect(client.POST).toHaveBeenNthCalledWith( + 1, + "/v1/environment-variables", + expect.objectContaining({ + body: { + projectId: "proj_123", + class: "preview", + key: "API_URL", + value: "https://api.example", + }, + }), + ); + expect(client.POST).toHaveBeenNthCalledWith( + 2, + "/v1/environment-variables", + expect.objectContaining({ + body: { + projectId: "proj_123", + class: "preview", + key: "STRIPE_KEY", + value: "sk_test_xxx", + }, + }), + ); + expect(result.result).toMatchObject({ + file: { path: ".env", count: 2 }, + variables: [ + { key: "API_URL", id: "envvar_api" }, + { key: "STRIPE_KEY", id: "envvar_stripe" }, + ], + }); + expect(JSON.stringify(result)).not.toContain("https://api.example"); + expect(JSON.stringify(result)).not.toContain("sk_test_xxx"); + }); + + it("preflights add --file conflicts before writing", async () => { + const client = createMockClient(); + client.envGET + .mockResolvedValueOnce({ + data: { data: [], pagination: { hasMore: false, nextCursor: null } }, + response: { status: 200 }, + }) + .mockResolvedValueOnce({ + data: { + data: [makeVariableRow({ key: "STRIPE_KEY", class: "preview" })], + pagination: { hasMore: false, nextCursor: null }, + }, + response: { status: 200 }, + }); + + const { controllers, createTempCwd, createTestCommandContext } = + await loadControllers(client, "proj_123"); + const cwd = await createTempCwd(); + await writeLocalPin(cwd); + await writeFile( + path.join(cwd, ".env"), + "API_URL=https://api.example\nSTRIPE_KEY=sk_test_xxx\n", + "utf8", + ); + const { context } = await createTestCommandContext({ cwd }); + + await expect( + controllers.runEnvAdd(context, undefined, { + roleName: "preview", + filePath: ".env", + }), + ).rejects.toMatchObject({ + code: "ENV_VARIABLE_ALREADY_EXISTS", + meta: { + keys: ["STRIPE_KEY"], + }, + }); + expect(client.POST).not.toHaveBeenCalled(); + }); + it("fails when the variable already exists", async () => { const client = createMockClient(); client.envGET.mockResolvedValueOnce({ @@ -317,6 +430,120 @@ describe("env add", () => { expect(JSON.stringify(result)).not.toContain("postgresql://branch"); }); + it("creates branch-scoped variables from a dotenv file", async () => { + const client = createMockClient(); + client.envGET + .mockResolvedValueOnce({ + data: { data: [makeBranchRow()], pagination: { hasMore: false, nextCursor: null } }, + response: { status: 200 }, + }) + .mockResolvedValueOnce({ + data: { data: [], pagination: { hasMore: false, nextCursor: null } }, + response: { status: 200 }, + }) + .mockResolvedValueOnce({ + data: { data: [], pagination: { hasMore: false, nextCursor: null } }, + response: { status: 200 }, + }); + client.POST.mockResolvedValueOnce({ + data: { + data: makeVariableRow({ + key: "DATABASE_URL", + branchId: "br_feature", + class: "preview", + }), + }, + response: { status: 201 }, + }); + + const { controllers, createTempCwd, createTestCommandContext } = + await loadControllers(client, "proj_123"); + const cwd = await createTempCwd(); + await writeLocalPin(cwd); + await writeFile(path.join(cwd, ".env.local"), "DATABASE_URL=postgresql://branch\n", "utf8"); + const { context } = await createTestCommandContext({ cwd }); + + const result = await controllers.runEnvAdd(context, undefined, { + branchName: "feature/foo", + filePath: ".env.local", + }); + + expect(client.POST).toHaveBeenCalledWith( + "/v1/environment-variables", + expect.objectContaining({ + body: { + projectId: "proj_123", + class: "preview", + branchId: "br_feature", + key: "DATABASE_URL", + value: "postgresql://branch", + }, + }), + ); + expect(result.result.scope).toEqual({ + kind: "branch", + branchName: "feature/foo", + branchId: "br_feature", + }); + expect(result.warnings[0]).toContain("does not exist in preview"); + }); + + it("reports partial state when add --file fails mid-apply", async () => { + const client = createMockClient(); + client.envGET + .mockResolvedValueOnce({ + data: { data: [], pagination: { hasMore: false, nextCursor: null } }, + response: { status: 200 }, + }) + .mockResolvedValueOnce({ + data: { data: [], pagination: { hasMore: false, nextCursor: null } }, + response: { status: 200 }, + }); + client.POST + .mockResolvedValueOnce({ + data: { data: makeVariableRow({ id: "envvar_api", key: "API_URL", class: "preview" }) }, + response: { status: 201 }, + }) + .mockResolvedValueOnce({ + error: { + error: { + message: "Environment variable service is unavailable.", + }, + }, + response: { status: 503 }, + }); + + const { controllers, createTempCwd, createTestCommandContext } = + await loadControllers(client, "proj_123"); + const cwd = await createTempCwd(); + await writeLocalPin(cwd); + await writeFile( + path.join(cwd, ".env"), + "API_URL=https://api.example\nSTRIPE_KEY=sk_test_xxx\n", + "utf8", + ); + const { context } = await createTestCommandContext({ cwd }); + + await expect( + controllers.runEnvAdd(context, undefined, { + roleName: "preview", + filePath: ".env", + }), + ).rejects.toMatchObject({ + code: "ENV_FILE_APPLY_FAILED", + meta: { + file: ".env", + failedKey: "STRIPE_KEY", + writtenKeys: ["API_URL"], + }, + nextSteps: [ + "prisma-cli project env list --role preview", + "prisma-cli project env add --file --role preview", + ], + }); + expect(client.POST).toHaveBeenCalledTimes(2); + }); + it("creates the branch before adding its first override", async () => { const client = createMockClient(); client.envGET @@ -463,6 +690,26 @@ describe("env add", () => { expectNoApiCalls(client); }); + it("rejects mutually exclusive assignment and --file inputs", async () => { + const client = createMockClient(); + const { controllers, createTempCwd, createTestCommandContext } = + await loadControllers(client, "proj_123"); + const cwd = await createTempCwd(); + await writeLocalPin(cwd); + const { context } = await createTestCommandContext({ cwd }); + + await expect( + controllers.runEnvAdd(context, "STRIPE_KEY=sk", { + roleName: "preview", + filePath: ".env", + }), + ).rejects.toMatchObject({ + code: "USAGE_ERROR", + summary: expect.stringContaining("either KEY=VALUE or --file"), + }); + expectNoApiCalls(client); + }); + it("rejects when --role is not provided (fail-fast on writes)", async () => { const client = createMockClient(); const { controllers, createTempCwd, createTestCommandContext } = @@ -636,6 +883,115 @@ describe("env update", () => { ); }); + it("updates variables from a dotenv file via PATCH", async () => { + const client = createMockClient(); + client.envGET + .mockResolvedValueOnce({ + data: { + data: [makeVariableRow({ id: "envvar_api", key: "API_URL", class: "preview" })], + pagination: { hasMore: false, nextCursor: null }, + }, + response: { status: 200 }, + }) + .mockResolvedValueOnce({ + data: { + data: [makeVariableRow({ id: "envvar_stripe", key: "STRIPE_KEY", class: "preview" })], + pagination: { hasMore: false, nextCursor: null }, + }, + response: { status: 200 }, + }); + client.PATCH + .mockResolvedValueOnce({ + data: { data: makeVariableRow({ id: "envvar_api", key: "API_URL", class: "preview" }) }, + response: { status: 200 }, + }) + .mockResolvedValueOnce({ + data: { data: makeVariableRow({ id: "envvar_stripe", key: "STRIPE_KEY", class: "preview" }) }, + response: { status: 200 }, + }); + + const { controllers, createTempCwd, createTestCommandContext } = + await loadControllers(client, "proj_123"); + const cwd = await createTempCwd(); + await writeLocalPin(cwd); + await writeFile( + path.join(cwd, ".env"), + "API_URL=https://api.example\nSTRIPE_KEY=sk_new_xxx\n", + "utf8", + ); + const { context } = await createTestCommandContext({ cwd }); + + const result = await controllers.runEnvUpdate(context, undefined, { + roleName: "preview", + filePath: ".env", + }); + + expect(client.PATCH).toHaveBeenNthCalledWith( + 1, + "/v1/environment-variables/{envVarId}", + expect.objectContaining({ + params: { path: { envVarId: "envvar_api" } }, + body: { value: "https://api.example" }, + }), + ); + expect(client.PATCH).toHaveBeenNthCalledWith( + 2, + "/v1/environment-variables/{envVarId}", + expect.objectContaining({ + params: { path: { envVarId: "envvar_stripe" } }, + body: { value: "sk_new_xxx" }, + }), + ); + expect(result.result).toMatchObject({ + file: { path: ".env", count: 2 }, + variables: [ + { key: "API_URL", id: "envvar_api" }, + { key: "STRIPE_KEY", id: "envvar_stripe" }, + ], + }); + expect(JSON.stringify(result)).not.toContain("sk_new_xxx"); + }); + + it("preflights update --file missing variables before writing", async () => { + const client = createMockClient(); + client.envGET + .mockResolvedValueOnce({ + data: { + data: [makeVariableRow({ id: "envvar_api", key: "API_URL", class: "preview" })], + pagination: { hasMore: false, nextCursor: null }, + }, + response: { status: 200 }, + }) + .mockResolvedValueOnce({ + data: { data: [], pagination: { hasMore: false, nextCursor: null } }, + response: { status: 200 }, + }); + + const { controllers, createTempCwd, createTestCommandContext } = + await loadControllers(client, "proj_123"); + const cwd = await createTempCwd(); + await writeLocalPin(cwd); + await writeFile( + path.join(cwd, ".env"), + "API_URL=https://api.example\nSTRIPE_KEY=sk_new_xxx\n", + "utf8", + ); + const { context } = await createTestCommandContext({ cwd }); + + await expect( + controllers.runEnvUpdate(context, undefined, { + roleName: "preview", + filePath: ".env", + }), + ).rejects.toMatchObject({ + code: "ENV_VARIABLE_NOT_FOUND", + meta: { + keys: ["STRIPE_KEY"], + }, + }); + expect(client.PATCH).not.toHaveBeenCalled(); + }); + it("rejects when --role is not provided (fail-fast on writes)", async () => { const client = createMockClient(); const { controllers, createTempCwd, createTestCommandContext } = diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fb6cac..f5f510c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,13 +37,16 @@ importers: version: 1.35.0 c12: specifier: 4.0.0-beta.5 - version: 4.0.0-beta.5(jiti@2.7.0)(magicast@0.5.3) + version: 4.0.0-beta.5(dotenv@17.4.2)(jiti@2.7.0)(magicast@0.5.3) colorette: specifier: ^2.0.20 version: 2.0.20 commander: specifier: ^14.0.3 version: 14.0.3 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 magicast: specifier: ^0.5.3 version: 0.5.3 @@ -909,6 +912,10 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dts-resolver@2.1.3: resolution: {integrity: sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==} engines: {node: '>=20.19.0'} @@ -1909,7 +1916,7 @@ snapshots: dependencies: run-applescript: 7.1.0 - c12@4.0.0-beta.5(jiti@2.7.0)(magicast@0.5.3): + c12@4.0.0-beta.5(dotenv@17.4.2)(jiti@2.7.0)(magicast@0.5.3): dependencies: confbox: 0.2.4 defu: 6.1.7 @@ -1918,6 +1925,7 @@ snapshots: pkg-types: 2.3.1 rc9: 3.0.1 optionalDependencies: + dotenv: 17.4.2 jiti: 2.7.0 magicast: 0.5.3 @@ -1946,6 +1954,8 @@ snapshots: destr@2.0.5: {} + dotenv@17.4.2: {} + dts-resolver@2.1.3: {} empathic@2.0.1: {} From 9bdf5bb6794fc277b03152b41a593454c3427502 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Thu, 4 Jun 2026 12:01:47 +0200 Subject: [PATCH 2/2] fix: address env file review feedback --- packages/cli/src/controllers/app-env-file.ts | 46 ++++++++++++++++---- packages/cli/src/lib/app/env-file.ts | 9 ++-- packages/cli/tests/app-env-vars.test.ts | 21 ++++++--- packages/cli/tests/app-env.test.ts | 12 +++++ 4 files changed, 72 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/controllers/app-env-file.ts b/packages/cli/src/controllers/app-env-file.ts index 1090a53..00e455a 100644 --- a/packages/cli/src/controllers/app-env-file.ts +++ b/packages/cli/src/controllers/app-env-file.ts @@ -47,11 +47,12 @@ export async function runEnvAddFile( domain: "app", summary: `${existingKeys.length} environment variable(s) already exist in ${formatScopeLabel(resolved.scope)}`, why: `Existing keys: ${formatKeyList(existingKeys)}.`, - fix: "Use `prisma-cli project env update --file` to change existing values.", + fix: "Split the input file by key state: update existing keys and add new keys separately.", exitCode: 1, - nextSteps: [ - `prisma-cli project env update --file ${filePath} ${formatScopeFlag(resolved.scope)}`, - ], + nextSteps: splitFileNextSteps(filePath, resolved.scope, { + existingKeys, + first: "update-existing", + }), meta: { keys: existingKeys }, }); } @@ -132,11 +133,12 @@ export async function runEnvUpdateFile( domain: "app", summary: `${missingKeys.length} environment variable(s) not found in ${formatScopeLabel(resolved.scope)}`, why: `Missing keys: ${formatKeyList(missingKeys)}.`, - fix: "Use `prisma-cli project env add --file` to create new variables first.", + fix: "Split the input file by key state: add missing keys and update existing keys separately.", exitCode: 1, - nextSteps: [ - `prisma-cli project env add --file ${filePath} ${formatScopeFlag(resolved.scope)}`, - ], + nextSteps: splitFileNextSteps(filePath, resolved.scope, { + missingKeys, + first: "add-missing", + }), meta: { keys: missingKeys }, }); } @@ -293,6 +295,34 @@ function retryStepForApplyFailure( return `prisma-cli project env add --file ${formatScopeFlag(scope)}`; } +function splitFileNextSteps( + filePath: string, + scope: EnvScope, + options: + | { first: "update-existing"; existingKeys: string[] } + | { first: "add-missing"; missingKeys: string[] }, +): string[] { + const scopeFlag = formatScopeFlag(scope); + const existingFile = `${filePath}.existing`; + const newFile = `${filePath}.new`; + + if (options.first === "update-existing") { + return [ + `# existing keys: ${formatKeyList(options.existingKeys)}`, + `prisma-cli project env update --file ${existingFile} ${scopeFlag}`, + "# new keys only", + `prisma-cli project env add --file ${newFile} ${scopeFlag}`, + ]; + } + + return [ + `# missing keys: ${formatKeyList(options.missingKeys)}`, + `prisma-cli project env add --file ${newFile} ${scopeFlag}`, + "# existing keys only", + `prisma-cli project env update --file ${existingFile} ${scopeFlag}`, + ]; +} + function formatKeyList(keys: string[]): string { return keys.map((key) => `"${key}"`).join(", "); } diff --git a/packages/cli/src/lib/app/env-file.ts b/packages/cli/src/lib/app/env-file.ts index 55db82c..e7025ff 100644 --- a/packages/cli/src/lib/app/env-file.ts +++ b/packages/cli/src/lib/app/env-file.ts @@ -139,11 +139,14 @@ function validateEnvFileKey( ): void { try { validateKey(key, command); - } catch { + } catch (error) { + const reason = error instanceof Error && error.message.length > 0 + ? error.message + : "Invalid environment variable key."; throw usageError( `Invalid environment variable "${key}" in "${filePath}"`, - `Line ${line} uses a key that does not match [A-Z_][A-Z0-9_]*.`, - "Rename the key to use uppercase letters, digits, and underscores.", + `Line ${line}: ${reason}`, + "Use a valid env-var key and retry the import.", [], "app", ); diff --git a/packages/cli/tests/app-env-vars.test.ts b/packages/cli/tests/app-env-vars.test.ts index aa0f6ad..20e21e1 100644 --- a/packages/cli/tests/app-env-vars.test.ts +++ b/packages/cli/tests/app-env-vars.test.ts @@ -136,15 +136,26 @@ describe("app env vars", () => { summary: 'Invalid environment variable "lowercase-key" in ".env"', })); + const longKey = `A${"B".repeat(256)}`; + expect(() => + parseEnvFileContents(`${longKey}=secret\n`, ".env", "add"), + ).toThrowError(expect.objectContaining({ + code: "USAGE_ERROR", + why: expect.stringContaining("exceeds the 256-character limit"), + })); + + let emptyValueError: unknown; try { parseEnvFileContents("EMPTY=\n", ".env", "add"); } catch (error) { - expect(error).toMatchObject({ - code: "USAGE_ERROR", - summary: 'Environment variable "EMPTY" in ".env" has an empty value', - }); - expect(JSON.stringify(error)).not.toContain("secret"); + emptyValueError = error; } + + expect(emptyValueError).toMatchObject({ + code: "USAGE_ERROR", + summary: 'Environment variable "EMPTY" in ".env" has an empty value', + }); + expect(JSON.stringify(emptyValueError)).not.toContain("secret"); }); it("parses repeated env assignments and allows empty values", async () => { diff --git a/packages/cli/tests/app-env.test.ts b/packages/cli/tests/app-env.test.ts index 7b6df20..3de9f37 100644 --- a/packages/cli/tests/app-env.test.ts +++ b/packages/cli/tests/app-env.test.ts @@ -340,6 +340,12 @@ describe("env add", () => { meta: { keys: ["STRIPE_KEY"], }, + nextSteps: [ + "# existing keys: \"STRIPE_KEY\"", + "prisma-cli project env update --file .env.existing --role preview", + "# new keys only", + "prisma-cli project env add --file .env.new --role preview", + ], }); expect(client.POST).not.toHaveBeenCalled(); }); @@ -988,6 +994,12 @@ describe("env update", () => { meta: { keys: ["STRIPE_KEY"], }, + nextSteps: [ + "# missing keys: \"STRIPE_KEY\"", + "prisma-cli project env add --file .env.new --role preview", + "# existing keys only", + "prisma-cli project env update --file .env.existing --role preview", + ], }); expect(client.PATCH).not.toHaveBeenCalled(); });