Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
19 changes: 17 additions & 2 deletions docs/product/command-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <production|preview> | --branch <git-name>)`
### `prisma-cli project env add (KEY=VALUE | --file <path>) (--role <production|preview> | --branch <git-name>)`

Purpose:

Expand All @@ -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 <path>` 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
Expand All @@ -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 <production|preview> | --branch <git-name>)`
### `prisma-cli project env update (KEY=VALUE | --file <path>) (--role <production|preview> | --branch <git-name>)`

Purpose:

Expand All @@ -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 <path>` 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
Expand All @@ -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
```

Expand Down
1 change: 1 addition & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 10 additions & 6 deletions packages/cli/src/commands/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ function createEnvAddCommand(runtime: CliRuntime): Command {
);

command
.argument("<assignment>", "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 <path>", "Read KEY=VALUE assignments from a dotenv file"))
.addOption(
new Option(
"--role <role>",
Expand All @@ -55,16 +56,17 @@ function createEnvAddCommand(runtime: CliRuntime): Command {
.addOption(new Option("--project <id-or-name>", "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<EnvAddResult>(
runtime,
"project.env.add",
options as Record<string, unknown>,
(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),
Expand All @@ -82,7 +84,8 @@ function createEnvUpdateCommand(runtime: CliRuntime): Command {
);

command
.argument("<assignment>", "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 <path>", "Read KEY=VALUE assignments from a dotenv file"))
.addOption(
new Option(
"--role <role>",
Expand All @@ -93,16 +96,17 @@ function createEnvUpdateCommand(runtime: CliRuntime): Command {
.addOption(new Option("--project <id-or-name>", "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<EnvUpdateResult>(
runtime,
"project.env.update",
options as Record<string, unknown>,
(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),
Expand Down
116 changes: 116 additions & 0 deletions packages/cli/src/controllers/app-env-api.ts
Original file line number Diff line number Diff line change
@@ -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<RawEnvironmentVariable | null> {
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"}`;
}
Loading
Loading