Skip to content

Commit acb39c6

Browse files
fix(cli): support env files in app deploy (#72)
* fix(cli): support env files in app deploy Signed-off-by: Aman Varshney <amanvarshney.work@gmail.com> * fix(cli): clarify deploy env file database handling Signed-off-by: Aman Varshney <amanvarshney.work@gmail.com> * fix(cli): reject empty deploy env values Signed-off-by: Aman Varshney <amanvarshney.work@gmail.com> * fix(cli): validate deploy env names Signed-off-by: Aman Varshney <amanvarshney.work@gmail.com> --------- Signed-off-by: Aman Varshney <amanvarshney.work@gmail.com>
1 parent e793063 commit acb39c6

10 files changed

Lines changed: 132 additions & 32 deletions

File tree

docs/product/command-spec.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -592,7 +592,7 @@ prisma-cli app run --build-type nextjs
592592
prisma-cli app run --build-type bun --entry server.ts --port 3000
593593
```
594594

595-
## `prisma-cli app deploy --project <id-or-name> --create-project <name> --app <name> --branch <name> --framework <nextjs|hono|tanstack-start|bun> --entry <path> --http-port <port> --env <name=value> --db --no-db --prod`
595+
## `prisma-cli app deploy --project <id-or-name> --create-project <name> --app <name> --branch <name> --framework <nextjs|hono|tanstack-start|bun> --entry <path> --http-port <port> --env <name=value|file> --db --no-db --prod`
596596

597597
Purpose:
598598

@@ -630,7 +630,7 @@ Behavior:
630630
- after setup, deploy prints `Deploying to <Project> / <Branch> / <App>`; later deploys print a compact target header such as `Deploying ./j1 to j1 / main / j1`
631631
- deploy progress uses short stage copy (`Building locally...`, `Built <size>`, `Uploading...`, `Uploaded`, `Deploying...`, `Deployed`) and never prints `Status: running` or `Deployment is running at ...`
632632
- success human output prints `Live in <duration>`, the URL on its own line, and `Logs prisma-cli app logs`
633-
- accepts repeated `--env NAME=VALUE` flags
633+
- accepts repeated `--env NAME=VALUE` flags and dotenv file paths such as `--env .env`
634634
- 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
635635
- supports `--no-db` to suppress automatic database prompting for the deploy
636636
- `--db` and `--no-db` are mutually exclusive; passing both is rejected
@@ -645,7 +645,7 @@ Behavior:
645645
- when no supported Prisma schema source is found, `--db` still creates the database and env overrides but skips schema setup
646646
- known non-Postgres Prisma sources do not trigger automatic database prompting; explicit `--db` is rejected because the created database is Prisma Postgres
647647
- if schema setup fails, deploy stops before the app build/deploy starts
648-
- inline `--env DATABASE_URL=...` or `--env DIRECT_URL=...` suppresses automatic database prompting; combining those inline env vars with `--db` is rejected
648+
- `--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
649649
- maps user-facing framework names to deploy build strategies
650650
- 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
651651
- supports vanilla Bun apps with `--framework bun` using `package.json#main` or `package.json#module`, or with `--entry <path>`

packages/cli/src/commands/app/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ function createDeployCommand(runtime: CliRuntime): Command {
180180
.addOption(new Option("--entry <path>", "Entrypoint path for Bun deploys"))
181181
.addOption(new Option("--http-port <port>", "HTTP port override for the deployed app"))
182182
.addOption(
183-
new Option("--env <name=value>", "Environment variable")
183+
new Option("--env <name=value|file>", "Environment variable assignment or dotenv file")
184184
.argParser(collectRepeatableValues),
185185
)
186186
.addOption(new Option("--db", "Create and wire an isolated database for the preview Branch"))

packages/cli/src/controllers/app.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import type { ProjectResolution, ProjectSummary } from "../types/project";
3939
import { requireComputeAuth } from "../lib/auth/guard";
4040
import { readAuthState } from "../lib/auth/auth-ops";
4141
import { getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "../lib/auth/client";
42-
import { envVarNames, parseEnvAssignments } from "../lib/app/env-vars";
42+
import { envVarNames, parseEnvInputs } from "../lib/app/env-vars";
4343
import { renderDeployOutputRows, renderDeploySettingsPreview } from "../lib/app/deploy-output";
4444
import {
4545
DEFAULT_LOCAL_DEV_PORT,
@@ -280,7 +280,7 @@ export async function runAppDeploy(
280280
let runtime = resolveDeployRuntime(options?.httpPort, framework);
281281
assertSupportedEntrypoint(framework.buildType, options?.entrypoint, "deploy");
282282
const envVars = toOptionalEnvVars(
283-
parseEnvAssignments(options?.envAssignments, {
283+
await parseEnvInputs(context.runtime.cwd, options?.envAssignments, {
284284
commandName: "deploy",
285285
}),
286286
);
@@ -325,7 +325,7 @@ export async function runAppDeploy(
325325
const portMapping = parseDeployPortMapping(String(runtime.port));
326326
const branchDatabaseSetup = await maybeSetupBranchDatabase(context, provider, projectId, toBranchDatabaseDeployBranch(target.branch), {
327327
db: options?.db,
328-
inlineEnvVars: envVars,
328+
providedEnvVars: envVars,
329329
});
330330

331331
const progressState = createPreviewDeployProgressState();

packages/cli/src/lib/app/branch-database-deploy.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,19 +46,19 @@ export async function maybeSetupBranchDatabase(
4646
branch: BranchDatabaseDeployBranch,
4747
options: {
4848
db: boolean | undefined;
49-
inlineEnvVars: Record<string, string> | undefined;
49+
providedEnvVars: Record<string, string> | undefined;
5050
},
5151
): Promise<BranchDatabaseSetupOutcome> {
5252
if (options.db === false) {
5353
return emptyBranchDatabaseSetupOutcome();
5454
}
5555

56-
if (hasInlineDatabaseEnvVars(options.inlineEnvVars)) {
56+
if (hasProvidedDatabaseEnvVars(options.providedEnvVars)) {
5757
if (options.db === true) {
5858
throw usageError(
59-
"Branch database setup cannot be combined with inline database env vars",
60-
"The deploy command received --db and an inline DATABASE_URL or DIRECT_URL value.",
61-
"Remove the inline --env database value to let --db create a branch override, or remove --db to deploy with the provided value.",
59+
"Branch database setup cannot be combined with provided database env vars",
60+
"The deploy command received --db and a DATABASE_URL or DIRECT_URL value from --env.",
61+
"Remove the --env database value to let --db create a branch override, or remove --db to deploy with the provided value.",
6262
[
6363
"prisma-cli app deploy --db",
6464
"prisma-cli app deploy --env DATABASE_URL=postgresql://example",
@@ -336,7 +336,7 @@ function findEnvVar(
336336
return rows.find((row) => row.branchId === options.branchId) ?? null;
337337
}
338338

339-
function hasInlineDatabaseEnvVars(envVars: Record<string, string> | undefined): boolean {
339+
function hasProvidedDatabaseEnvVars(envVars: Record<string, string> | undefined): boolean {
340340
return Boolean(envVars && ("DATABASE_URL" in envVars || "DIRECT_URL" in envVars));
341341
}
342342

packages/cli/src/lib/app/env-file.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export interface EnvFileAssignment {
1010
value: string;
1111
}
1212

13+
type EnvFileCommand = "add" | "update" | "deploy";
14+
1315
interface ParsedEnvFileKey {
1416
key: string;
1517
line: number;
@@ -20,7 +22,7 @@ const ASSIGNMENT_KEY_PATTERN = /^\s*(?:export\s+)?([^#=\s]+)\s*=/;
2022
export async function readEnvFileAssignments(
2123
cwd: string,
2224
filePath: string,
23-
command: "add" | "update",
25+
command: EnvFileCommand,
2426
): Promise<EnvFileAssignment[]> {
2527
const resolvedPath = path.resolve(cwd, filePath);
2628
let contents: string;
@@ -31,7 +33,11 @@ export async function readEnvFileAssignments(
3133
`Failed to read env file "${filePath}"`,
3234
error instanceof Error ? error.message : "The file could not be read.",
3335
"Pass a readable dotenv file path.",
34-
[`prisma-cli project env ${command} --file .env --role preview`],
36+
[
37+
command === "deploy"
38+
? "prisma-cli app deploy --env .env"
39+
: `prisma-cli project env ${command} --file .env --role preview`,
40+
],
3541
"app",
3642
);
3743
}
@@ -42,7 +48,7 @@ export async function readEnvFileAssignments(
4248
export function parseEnvFileContents(
4349
contents: string,
4450
filePath: string,
45-
command: "add" | "update",
51+
command: EnvFileCommand,
4652
): EnvFileAssignment[] {
4753
const parsedKeys = extractParsedKeys(contents);
4854
if (parsedKeys.length === 0) {
@@ -135,10 +141,10 @@ function validateEnvFileKey(
135141
key: string,
136142
line: number,
137143
filePath: string,
138-
command: "add" | "update",
144+
command: EnvFileCommand,
139145
): void {
140146
try {
141-
validateKey(key, command);
147+
validateKey(key, command === "deploy" ? "add" : command);
142148
} catch (error) {
143149
const reason = error instanceof Error && error.message.length > 0
144150
? error.message

packages/cli/src/lib/app/env-vars.ts

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { usageError } from "../../shell/errors";
2+
import { validateKey } from "./env-config";
3+
import { readEnvFileAssignments } from "./env-file";
4+
5+
type EnvAssignmentOptions = {
6+
commandName: "deploy";
7+
requireAtLeastOne?: boolean;
8+
};
29

310
export function parseEnvAssignments(
411
assignments: string[] | undefined,
5-
options: {
6-
commandName: "deploy";
7-
requireAtLeastOne?: boolean;
8-
},
12+
options: EnvAssignmentOptions,
913
): Record<string, string> {
1014
const values = assignments ?? [];
1115

@@ -44,6 +48,7 @@ export function parseEnvAssignments(
4448
"app",
4549
);
4650
}
51+
validateEnvAssignmentName(name, options.commandName);
4752

4853
if (seen.has(name)) {
4954
throw usageError(
@@ -55,13 +60,64 @@ export function parseEnvAssignments(
5560
);
5661
}
5762

63+
const value = assignment.slice(separatorIndex + 1);
64+
if (value.length === 0) {
65+
throw usageError(
66+
`Environment variable "${name}" has an empty value`,
67+
`A provided --env flag defines ${name} with no value.`,
68+
"Pass a non-empty value, or omit the key from the deploy command.",
69+
[`prisma-cli app ${options.commandName} --env ${name}=value`],
70+
"app",
71+
);
72+
}
73+
5874
seen.add(name);
59-
parsed[name] = assignment.slice(separatorIndex + 1);
75+
parsed[name] = value;
6076
}
6177

6278
return parsed;
6379
}
6480

81+
export async function parseEnvInputs(
82+
cwd: string,
83+
inputs: string[] | undefined,
84+
options: EnvAssignmentOptions,
85+
): Promise<Record<string, string>> {
86+
const values = inputs ?? [];
87+
const expandedAssignments: string[] = [];
88+
89+
for (const value of values) {
90+
if (value.includes("=")) {
91+
expandedAssignments.push(value);
92+
continue;
93+
}
94+
95+
const fileAssignments = await readEnvFileAssignments(cwd, value, options.commandName);
96+
expandedAssignments.push(
97+
...fileAssignments.map((assignment) => `${assignment.key}=${assignment.value}`),
98+
);
99+
}
100+
101+
return parseEnvAssignments(expandedAssignments, options);
102+
}
103+
104+
function validateEnvAssignmentName(name: string, commandName: EnvAssignmentOptions["commandName"]): void {
105+
try {
106+
validateKey(name, "add");
107+
} catch (error) {
108+
const reason = error instanceof Error && error.message.length > 0
109+
? error.message
110+
: "Invalid environment variable name.";
111+
throw usageError(
112+
`Invalid environment variable "${name}"`,
113+
reason,
114+
"Use a valid env-var name and retry the deploy.",
115+
[`prisma-cli app ${commandName} --env DATABASE_URL=postgresql://example`],
116+
"app",
117+
);
118+
}
119+
}
120+
65121
export function envVarNames(
66122
envVars: Record<string, string | null> | undefined,
67123
): string[] {

packages/cli/src/shell/command-meta.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ const DESCRIPTORS: CommandDescriptor[] = [
146146
"prisma-cli app deploy --project proj_123",
147147
"prisma-cli app deploy --create-project my-app --yes",
148148
"prisma-cli app deploy --app my-app --env DATABASE_URL=postgresql://example",
149+
"prisma-cli app deploy --env .env",
149150
"prisma-cli app deploy --db",
150151
"prisma-cli app deploy --db --yes",
151152
"prisma-cli app deploy --app my-app --framework nextjs --http-port 3000",

packages/cli/tests/app-branch-database.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -887,7 +887,7 @@ describe("app deploy branch database setup", () => {
887887
expect(createBranchDatabase).toHaveBeenCalled();
888888
});
889889

890-
it("rejects --db when deploy also passes inline database env vars", async () => {
890+
it("rejects --db when deploy also passes database env vars", async () => {
891891
const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient());
892892
const createBranchDatabase = vi.fn();
893893
const deployApp = vi.fn();
@@ -914,6 +914,7 @@ describe("app deploy branch database setup", () => {
914914
const { createTempCwd, createTestCommandContext } = await import("./helpers");
915915
const { runAppDeploy } = await import("../src/controllers/app");
916916
const cwd = await createTempCwd();
917+
await writeFile(path.join(cwd, ".env"), "DATABASE_URL=postgresql://example\n");
917918
const { context } = await createTestCommandContext({
918919
cwd,
919920
stateDir: path.join(cwd, ".state"),
@@ -930,12 +931,12 @@ describe("app deploy branch database setup", () => {
930931
projectRef: "proj_123",
931932
branchName: "feature/db",
932933
framework: "hono",
933-
envAssignments: ["DATABASE_URL=postgresql://example"],
934+
envAssignments: [".env"],
934935
db: true,
935936
})).rejects.toMatchObject({
936937
code: "USAGE_ERROR",
937938
domain: "app",
938-
summary: "Branch database setup cannot be combined with inline database env vars",
939+
summary: "Branch database setup cannot be combined with provided database env vars",
939940
});
940941
expect(createBranchDatabase).not.toHaveBeenCalled();
941942
expect(deployApp).not.toHaveBeenCalled();

packages/cli/tests/app-env-vars.test.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,20 +158,41 @@ describe("app env vars", () => {
158158
expect(JSON.stringify(emptyValueError)).not.toContain("secret");
159159
});
160160

161-
it("parses repeated env assignments and allows empty values", async () => {
161+
it("parses repeated env assignments", async () => {
162162
const { parseEnvAssignments } = await import("../src/lib/app/env-vars");
163163

164164
expect(
165165
parseEnvAssignments(
166166
[
167167
"DATABASE_URL=postgresql://example",
168-
"EMPTY=",
168+
"TOKEN=value=with=equals",
169169
],
170170
{ commandName: "deploy" },
171171
),
172172
).toEqual({
173173
DATABASE_URL: "postgresql://example",
174-
EMPTY: "",
174+
TOKEN: "value=with=equals",
175+
});
176+
});
177+
178+
it("parses deploy env inputs from assignments and dotenv files", async () => {
179+
const { createTempCwd } = await import("./helpers");
180+
const { parseEnvInputs } = await import("../src/lib/app/env-vars");
181+
const cwd = await createTempCwd();
182+
await writeFile(
183+
path.join(cwd, ".env"),
184+
[
185+
"DATABASE_URL=postgresql://example",
186+
"FEATURE_FLAG=enabled",
187+
].join("\n"),
188+
);
189+
190+
await expect(
191+
parseEnvInputs(cwd, [".env", "INLINE_FLAG=enabled"], { commandName: "deploy" }),
192+
).resolves.toEqual({
193+
DATABASE_URL: "postgresql://example",
194+
FEATURE_FLAG: "enabled",
195+
INLINE_FLAG: "enabled",
175196
});
176197
});
177198

@@ -190,6 +211,19 @@ describe("app env vars", () => {
190211
summary: "Environment variable name is required",
191212
}),
192213
);
214+
expect(() => parseEnvAssignments(["lowercase-key=secret"], { commandName: "deploy" })).toThrowError(
215+
expect.objectContaining({
216+
code: "USAGE_ERROR",
217+
summary: 'Invalid environment variable "lowercase-key"',
218+
why: expect.stringContaining("must match the POSIX env-var shape"),
219+
}),
220+
);
221+
expect(() => parseEnvAssignments(["EMPTY="], { commandName: "deploy" })).toThrowError(
222+
expect.objectContaining({
223+
code: "USAGE_ERROR",
224+
summary: 'Environment variable "EMPTY" has an empty value',
225+
}),
226+
);
193227

194228
try {
195229
parseEnvAssignments(
@@ -446,6 +480,7 @@ describe("app env vars", () => {
446480
const { createTempCwd, createTestCommandContext } = await import("./helpers");
447481
const { runAppDeploy } = await import("../src/controllers/app");
448482
const cwd = await createTempCwd();
483+
await writeFile(path.join(cwd, ".env"), "FEATURE_FLAG=enabled\n");
449484
const stateDir = path.join(cwd, ".state");
450485
const { context } = await createTestCommandContext({
451486
cwd,
@@ -462,7 +497,7 @@ describe("app env vars", () => {
462497
{
463498
projectRef: "proj_123",
464499
framework: "hono",
465-
envAssignments: ["DATABASE_URL=postgresql://example", "FEATURE_FLAG=enabled", "EMPTY="],
500+
envAssignments: ["DATABASE_URL=postgresql://example", ".env", "INLINE_FLAG=enabled"],
466501
},
467502
);
468503

@@ -473,7 +508,7 @@ describe("app env vars", () => {
473508
envVars: {
474509
DATABASE_URL: "postgresql://example",
475510
FEATURE_FLAG: "enabled",
476-
EMPTY: "",
511+
INLINE_FLAG: "enabled",
477512
},
478513
}),
479514
);

packages/cli/tests/app.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ describe("app commands", () => {
150150
expect(deployHelp.stderr).toContain("$ prisma-cli app deploy");
151151
expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --project proj_123");
152152
expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --create-project my-app --yes");
153+
expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --env .env");
153154
expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --db");
154155
expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --db --yes");
155156
expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --app my-app --framework nextjs --http-port 3000");
@@ -159,7 +160,7 @@ describe("app commands", () => {
159160
expect(deployHelp.stderr).toContain("--framework <name>");
160161
expect(deployHelp.stderr).not.toContain("--build-type <type>");
161162
expect(deployHelp.stderr).toContain("--http-port <port>");
162-
expect(deployHelp.stderr).toContain("--env <name=value>");
163+
expect(deployHelp.stderr).toContain("--env <name=value|file>");
163164
expect(deployHelp.stderr).toContain("--db");
164165
expect(deployHelp.stderr).toContain("--no-db");
165166

0 commit comments

Comments
 (0)