diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 5027317..eedba2c 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -592,7 +592,7 @@ prisma-cli app run --build-type nextjs prisma-cli app run --build-type bun --entry server.ts --port 3000 ``` -## `prisma-cli app deploy --project --create-project --app --branch --framework --entry --http-port --env --db --no-db --prod` +## `prisma-cli app deploy --project --create-project --app --branch --framework --entry --http-port --env --db --no-db --prod` Purpose: @@ -630,7 +630,7 @@ Behavior: - after setup, deploy prints `Deploying to / / `; later deploys print a compact target header such as `Deploying ./j1 to j1 / main / j1` - deploy progress uses short stage copy (`Building locally...`, `Built `, `Uploading...`, `Uploaded`, `Deploying...`, `Deployed`) and never prints `Status: running` or `Deployment is running at ...` - success human output prints `Live in `, the URL on its own line, and `Logs prisma-cli app logs` -- accepts repeated `--env NAME=VALUE` flags +- accepts repeated `--env NAME=VALUE` flags and dotenv file paths such as `--env .env` - supports `--db` for preview Branches to create a new empty Prisma Postgres database, apply a supported local Prisma schema source when one exists, and write branch-scoped `DATABASE_URL` and `DIRECT_URL` overrides through the existing `project env` storage - supports `--no-db` to suppress automatic database prompting for the deploy - `--db` and `--no-db` are mutually exclusive; passing both is rejected @@ -645,7 +645,7 @@ Behavior: - when no supported Prisma schema source is found, `--db` still creates the database and env overrides but skips schema setup - known non-Postgres Prisma sources do not trigger automatic database prompting; explicit `--db` is rejected because the created database is Prisma Postgres - if schema setup fails, deploy stops before the app build/deploy starts -- inline `--env DATABASE_URL=...` or `--env DIRECT_URL=...` suppresses automatic database prompting; combining those inline env vars with `--db` is rejected +- `--env DATABASE_URL=...`, `--env DIRECT_URL=...`, or the same keys loaded from an env file suppress automatic database prompting; combining those database env vars with `--db` is rejected - maps user-facing framework names to deploy build strategies - uses `src/index.ts` as the Hono deploy entrypoint when the app has no `package.json#main` or `package.json#module` and that file exists - supports vanilla Bun apps with `--framework bun` using `package.json#main` or `package.json#module`, or with `--entry ` diff --git a/packages/cli/src/commands/app/index.ts b/packages/cli/src/commands/app/index.ts index 109cfae..c37c582 100644 --- a/packages/cli/src/commands/app/index.ts +++ b/packages/cli/src/commands/app/index.ts @@ -180,7 +180,7 @@ function createDeployCommand(runtime: CliRuntime): Command { .addOption(new Option("--entry ", "Entrypoint path for Bun deploys")) .addOption(new Option("--http-port ", "HTTP port override for the deployed app")) .addOption( - new Option("--env ", "Environment variable") + new Option("--env ", "Environment variable assignment or dotenv file") .argParser(collectRepeatableValues), ) .addOption(new Option("--db", "Create and wire an isolated database for the preview Branch")) diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 54dbd57..76eb7cc 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -39,7 +39,7 @@ import type { ProjectResolution, ProjectSummary } from "../types/project"; import { requireComputeAuth } from "../lib/auth/guard"; import { readAuthState } from "../lib/auth/auth-ops"; import { getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "../lib/auth/client"; -import { envVarNames, parseEnvAssignments } from "../lib/app/env-vars"; +import { envVarNames, parseEnvInputs } from "../lib/app/env-vars"; import { renderDeployOutputRows, renderDeploySettingsPreview } from "../lib/app/deploy-output"; import { DEFAULT_LOCAL_DEV_PORT, @@ -280,7 +280,7 @@ export async function runAppDeploy( let runtime = resolveDeployRuntime(options?.httpPort, framework); assertSupportedEntrypoint(framework.buildType, options?.entrypoint, "deploy"); const envVars = toOptionalEnvVars( - parseEnvAssignments(options?.envAssignments, { + await parseEnvInputs(context.runtime.cwd, options?.envAssignments, { commandName: "deploy", }), ); @@ -325,7 +325,7 @@ export async function runAppDeploy( const portMapping = parseDeployPortMapping(String(runtime.port)); const branchDatabaseSetup = await maybeSetupBranchDatabase(context, provider, projectId, toBranchDatabaseDeployBranch(target.branch), { db: options?.db, - inlineEnvVars: envVars, + providedEnvVars: envVars, }); const progressState = createPreviewDeployProgressState(); diff --git a/packages/cli/src/lib/app/branch-database-deploy.ts b/packages/cli/src/lib/app/branch-database-deploy.ts index 01114ec..1dec731 100644 --- a/packages/cli/src/lib/app/branch-database-deploy.ts +++ b/packages/cli/src/lib/app/branch-database-deploy.ts @@ -46,19 +46,19 @@ export async function maybeSetupBranchDatabase( branch: BranchDatabaseDeployBranch, options: { db: boolean | undefined; - inlineEnvVars: Record | undefined; + providedEnvVars: Record | undefined; }, ): Promise { if (options.db === false) { return emptyBranchDatabaseSetupOutcome(); } - if (hasInlineDatabaseEnvVars(options.inlineEnvVars)) { + if (hasProvidedDatabaseEnvVars(options.providedEnvVars)) { if (options.db === true) { throw usageError( - "Branch database setup cannot be combined with inline database env vars", - "The deploy command received --db and an inline DATABASE_URL or DIRECT_URL value.", - "Remove the inline --env database value to let --db create a branch override, or remove --db to deploy with the provided value.", + "Branch database setup cannot be combined with provided database env vars", + "The deploy command received --db and a DATABASE_URL or DIRECT_URL value from --env.", + "Remove the --env database value to let --db create a branch override, or remove --db to deploy with the provided value.", [ "prisma-cli app deploy --db", "prisma-cli app deploy --env DATABASE_URL=postgresql://example", @@ -336,7 +336,7 @@ function findEnvVar( return rows.find((row) => row.branchId === options.branchId) ?? null; } -function hasInlineDatabaseEnvVars(envVars: Record | undefined): boolean { +function hasProvidedDatabaseEnvVars(envVars: Record | undefined): boolean { return Boolean(envVars && ("DATABASE_URL" in envVars || "DIRECT_URL" in envVars)); } diff --git a/packages/cli/src/lib/app/env-file.ts b/packages/cli/src/lib/app/env-file.ts index e7025ff..3e2687c 100644 --- a/packages/cli/src/lib/app/env-file.ts +++ b/packages/cli/src/lib/app/env-file.ts @@ -10,6 +10,8 @@ export interface EnvFileAssignment { value: string; } +type EnvFileCommand = "add" | "update" | "deploy"; + interface ParsedEnvFileKey { key: string; line: number; @@ -20,7 +22,7 @@ const ASSIGNMENT_KEY_PATTERN = /^\s*(?:export\s+)?([^#=\s]+)\s*=/; export async function readEnvFileAssignments( cwd: string, filePath: string, - command: "add" | "update", + command: EnvFileCommand, ): Promise { const resolvedPath = path.resolve(cwd, filePath); let contents: string; @@ -31,7 +33,11 @@ export async function readEnvFileAssignments( `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`], + [ + command === "deploy" + ? "prisma-cli app deploy --env .env" + : `prisma-cli project env ${command} --file .env --role preview`, + ], "app", ); } @@ -42,7 +48,7 @@ export async function readEnvFileAssignments( export function parseEnvFileContents( contents: string, filePath: string, - command: "add" | "update", + command: EnvFileCommand, ): EnvFileAssignment[] { const parsedKeys = extractParsedKeys(contents); if (parsedKeys.length === 0) { @@ -135,10 +141,10 @@ function validateEnvFileKey( key: string, line: number, filePath: string, - command: "add" | "update", + command: EnvFileCommand, ): void { try { - validateKey(key, command); + validateKey(key, command === "deploy" ? "add" : command); } catch (error) { const reason = error instanceof Error && error.message.length > 0 ? error.message diff --git a/packages/cli/src/lib/app/env-vars.ts b/packages/cli/src/lib/app/env-vars.ts index 66a4e3e..d27c53c 100644 --- a/packages/cli/src/lib/app/env-vars.ts +++ b/packages/cli/src/lib/app/env-vars.ts @@ -1,11 +1,15 @@ import { usageError } from "../../shell/errors"; +import { validateKey } from "./env-config"; +import { readEnvFileAssignments } from "./env-file"; + +type EnvAssignmentOptions = { + commandName: "deploy"; + requireAtLeastOne?: boolean; +}; export function parseEnvAssignments( assignments: string[] | undefined, - options: { - commandName: "deploy"; - requireAtLeastOne?: boolean; - }, + options: EnvAssignmentOptions, ): Record { const values = assignments ?? []; @@ -44,6 +48,7 @@ export function parseEnvAssignments( "app", ); } + validateEnvAssignmentName(name, options.commandName); if (seen.has(name)) { throw usageError( @@ -55,13 +60,64 @@ export function parseEnvAssignments( ); } + const value = assignment.slice(separatorIndex + 1); + if (value.length === 0) { + throw usageError( + `Environment variable "${name}" has an empty value`, + `A provided --env flag defines ${name} with no value.`, + "Pass a non-empty value, or omit the key from the deploy command.", + [`prisma-cli app ${options.commandName} --env ${name}=value`], + "app", + ); + } + seen.add(name); - parsed[name] = assignment.slice(separatorIndex + 1); + parsed[name] = value; } return parsed; } +export async function parseEnvInputs( + cwd: string, + inputs: string[] | undefined, + options: EnvAssignmentOptions, +): Promise> { + const values = inputs ?? []; + const expandedAssignments: string[] = []; + + for (const value of values) { + if (value.includes("=")) { + expandedAssignments.push(value); + continue; + } + + const fileAssignments = await readEnvFileAssignments(cwd, value, options.commandName); + expandedAssignments.push( + ...fileAssignments.map((assignment) => `${assignment.key}=${assignment.value}`), + ); + } + + return parseEnvAssignments(expandedAssignments, options); +} + +function validateEnvAssignmentName(name: string, commandName: EnvAssignmentOptions["commandName"]): void { + try { + validateKey(name, "add"); + } catch (error) { + const reason = error instanceof Error && error.message.length > 0 + ? error.message + : "Invalid environment variable name."; + throw usageError( + `Invalid environment variable "${name}"`, + reason, + "Use a valid env-var name and retry the deploy.", + [`prisma-cli app ${commandName} --env DATABASE_URL=postgresql://example`], + "app", + ); + } +} + export function envVarNames( envVars: Record | undefined, ): string[] { diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index a58f435..9e191e9 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -146,6 +146,7 @@ const DESCRIPTORS: CommandDescriptor[] = [ "prisma-cli app deploy --project proj_123", "prisma-cli app deploy --create-project my-app --yes", "prisma-cli app deploy --app my-app --env DATABASE_URL=postgresql://example", + "prisma-cli app deploy --env .env", "prisma-cli app deploy --db", "prisma-cli app deploy --db --yes", "prisma-cli app deploy --app my-app --framework nextjs --http-port 3000", diff --git a/packages/cli/tests/app-branch-database.test.ts b/packages/cli/tests/app-branch-database.test.ts index 4a42d37..4805961 100644 --- a/packages/cli/tests/app-branch-database.test.ts +++ b/packages/cli/tests/app-branch-database.test.ts @@ -887,7 +887,7 @@ describe("app deploy branch database setup", () => { expect(createBranchDatabase).toHaveBeenCalled(); }); - it("rejects --db when deploy also passes inline database env vars", async () => { + it("rejects --db when deploy also passes database env vars", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const createBranchDatabase = vi.fn(); const deployApp = vi.fn(); @@ -914,6 +914,7 @@ describe("app deploy branch database setup", () => { const { createTempCwd, createTestCommandContext } = await import("./helpers"); const { runAppDeploy } = await import("../src/controllers/app"); const cwd = await createTempCwd(); + await writeFile(path.join(cwd, ".env"), "DATABASE_URL=postgresql://example\n"); const { context } = await createTestCommandContext({ cwd, stateDir: path.join(cwd, ".state"), @@ -930,12 +931,12 @@ describe("app deploy branch database setup", () => { projectRef: "proj_123", branchName: "feature/db", framework: "hono", - envAssignments: ["DATABASE_URL=postgresql://example"], + envAssignments: [".env"], db: true, })).rejects.toMatchObject({ code: "USAGE_ERROR", domain: "app", - summary: "Branch database setup cannot be combined with inline database env vars", + summary: "Branch database setup cannot be combined with provided database env vars", }); expect(createBranchDatabase).not.toHaveBeenCalled(); expect(deployApp).not.toHaveBeenCalled(); diff --git a/packages/cli/tests/app-env-vars.test.ts b/packages/cli/tests/app-env-vars.test.ts index 20e21e1..277bdec 100644 --- a/packages/cli/tests/app-env-vars.test.ts +++ b/packages/cli/tests/app-env-vars.test.ts @@ -158,20 +158,41 @@ describe("app env vars", () => { expect(JSON.stringify(emptyValueError)).not.toContain("secret"); }); - it("parses repeated env assignments and allows empty values", async () => { + it("parses repeated env assignments", async () => { const { parseEnvAssignments } = await import("../src/lib/app/env-vars"); expect( parseEnvAssignments( [ "DATABASE_URL=postgresql://example", - "EMPTY=", + "TOKEN=value=with=equals", ], { commandName: "deploy" }, ), ).toEqual({ DATABASE_URL: "postgresql://example", - EMPTY: "", + TOKEN: "value=with=equals", + }); + }); + + it("parses deploy env inputs from assignments and dotenv files", async () => { + const { createTempCwd } = await import("./helpers"); + const { parseEnvInputs } = await import("../src/lib/app/env-vars"); + const cwd = await createTempCwd(); + await writeFile( + path.join(cwd, ".env"), + [ + "DATABASE_URL=postgresql://example", + "FEATURE_FLAG=enabled", + ].join("\n"), + ); + + await expect( + parseEnvInputs(cwd, [".env", "INLINE_FLAG=enabled"], { commandName: "deploy" }), + ).resolves.toEqual({ + DATABASE_URL: "postgresql://example", + FEATURE_FLAG: "enabled", + INLINE_FLAG: "enabled", }); }); @@ -190,6 +211,19 @@ describe("app env vars", () => { summary: "Environment variable name is required", }), ); + expect(() => parseEnvAssignments(["lowercase-key=secret"], { commandName: "deploy" })).toThrowError( + expect.objectContaining({ + code: "USAGE_ERROR", + summary: 'Invalid environment variable "lowercase-key"', + why: expect.stringContaining("must match the POSIX env-var shape"), + }), + ); + expect(() => parseEnvAssignments(["EMPTY="], { commandName: "deploy" })).toThrowError( + expect.objectContaining({ + code: "USAGE_ERROR", + summary: 'Environment variable "EMPTY" has an empty value', + }), + ); try { parseEnvAssignments( @@ -446,6 +480,7 @@ describe("app env vars", () => { const { createTempCwd, createTestCommandContext } = await import("./helpers"); const { runAppDeploy } = await import("../src/controllers/app"); const cwd = await createTempCwd(); + await writeFile(path.join(cwd, ".env"), "FEATURE_FLAG=enabled\n"); const stateDir = path.join(cwd, ".state"); const { context } = await createTestCommandContext({ cwd, @@ -462,7 +497,7 @@ describe("app env vars", () => { { projectRef: "proj_123", framework: "hono", - envAssignments: ["DATABASE_URL=postgresql://example", "FEATURE_FLAG=enabled", "EMPTY="], + envAssignments: ["DATABASE_URL=postgresql://example", ".env", "INLINE_FLAG=enabled"], }, ); @@ -473,7 +508,7 @@ describe("app env vars", () => { envVars: { DATABASE_URL: "postgresql://example", FEATURE_FLAG: "enabled", - EMPTY: "", + INLINE_FLAG: "enabled", }, }), ); diff --git a/packages/cli/tests/app.test.ts b/packages/cli/tests/app.test.ts index 5ad6623..9f8ebda 100644 --- a/packages/cli/tests/app.test.ts +++ b/packages/cli/tests/app.test.ts @@ -150,6 +150,7 @@ describe("app commands", () => { expect(deployHelp.stderr).toContain("$ prisma-cli app deploy"); expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --project proj_123"); expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --create-project my-app --yes"); + expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --env .env"); expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --db"); expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --db --yes"); expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --app my-app --framework nextjs --http-port 3000"); @@ -159,7 +160,7 @@ describe("app commands", () => { expect(deployHelp.stderr).toContain("--framework "); expect(deployHelp.stderr).not.toContain("--build-type "); expect(deployHelp.stderr).toContain("--http-port "); - expect(deployHelp.stderr).toContain("--env "); + expect(deployHelp.stderr).toContain("--env "); expect(deployHelp.stderr).toContain("--db"); expect(deployHelp.stderr).toContain("--no-db");