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
19 changes: 15 additions & 4 deletions docs/product/command-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,9 @@ Every write targets exactly one scope:
- `--role` and `--branch` are mutually exclusive.
- For write verbs (`add`, `update`, `remove`), one scope flag is required
so the CLI never silently writes to production.
- For read verbs (`list`), omitting `--role` defaults to `--role production`.
- For read verbs (`list`), omitting `--role` or `--branch` resolves from
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>)`

Expand Down Expand Up @@ -751,11 +753,20 @@ Purpose:
Behavior:

- requires auth and a resolved project; accepts `--project <id-or-name>` as an explicit fallback
- defaults to `--role production` when `--role` is not supplied
- `--branch` lists the resolved preview branch view: preview defaults
- explicit `--role production|preview` lists that project-level map
- explicit `--branch` lists the resolved preview branch view: preview defaults
plus branch overrides, with source metadata
- with no scope and an active local Git branch, resolves the matching
Platform Branch and lists the env map for its role; preview Branches
include preview defaults plus Branch overrides
- with no scope and an active local Git branch that has no Platform
Branch yet, lists preview template metadata and marks the target as
not created yet
- with no scope and no local Git branch, lists an overview of the
production and preview project-level maps, excluding Branch overrides
- never prints values (never-reveal)
- emits `key`, `id`, `last updated`, and a `scope` annotation per row
- emits `key`, `id`, `last updated`, and source/scope annotations per
row, plus resolved target metadata for human and JSON output

Examples:

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 list
npx prisma-cli project env list --role preview
```

Expand Down
209 changes: 189 additions & 20 deletions packages/cli/src/controllers/app-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import {
type EnvVarRole,
} from "../lib/app/env-config";
import { requireComputeAuth } from "../lib/auth/guard";
import { readLocalGitBranch } from "../lib/git/local-branch";
import { authRequiredError, CliError, usageError, workspaceRequiredError } from "../shell/errors";
import type { CommandSuccess } from "../shell/output";
import type { CommandContext } from "../shell/runtime";
import { resolveProjectTarget } from "../lib/project/resolution";
import type {
EnvAddResult,
EnvListTarget,
EnvListResult,
EnvRmResult,
EnvScopeDescriptor,
Expand All @@ -29,6 +31,21 @@ interface ResolvedScope {
apiTarget: { class: EnvVarRole; branchId: string | null };
}

type ResolvedListScope =
| {
kind: "scoped";
descriptor: EnvScopeDescriptor;
target: EnvListTarget;
apiTarget: { class: EnvVarRole; branchId: string | null };
addScope: EnvScope;
}
| {
kind: "overview";
descriptor: { kind: "overview" };
target: EnvListTarget;
addScope: EnvScope;
};

interface EnvCommandFlags {
roleName?: string;
branchName?: string;
Expand All @@ -47,13 +64,10 @@ interface RawEnvironmentVariable {
interface RawBranchRecord {
id: string;
gitName: string;
role: EnvVarRole;
isDefault: boolean;
}

function defaultRoleScope(): EnvScope {
return { kind: "role", role: "production" };
}

export async function runEnvAdd(
context: CommandContext,
rawAssignment: string | undefined,
Expand Down Expand Up @@ -204,25 +218,31 @@ export async function runEnvList(
flags: EnvCommandFlags,
): Promise<CommandSuccess<EnvListResult>> {
const explicit = resolveEnvScope(flags, { requireExplicit: false, command: "list" });
const scope = explicit ?? defaultRoleScope();

const { client, projectId } = await requireClientAndProject(context, flags.projectRef, "project env list");
const resolved = await resolveScopeToApi(client, projectId, scope, {
createBranchIfMissing: false,
const resolved = await resolveListScopeToApi(client, projectId, explicit, {
cwd: context.runtime.cwd,
signal: context.runtime.signal,
});
const variables = await listVariables(client, projectId, resolved, context.runtime.signal);
const variables = resolved.kind === "scoped"
? await listVariables(client, projectId, {
scope: resolved.addScope,
descriptor: resolved.descriptor,
apiTarget: resolved.apiTarget,
}, context.runtime.signal)
: await listOverviewVariables(client, projectId, context.runtime.signal);

return {
command: "project.env.list",
result: {
projectId,
scope: resolved.descriptor,
target: resolved.target,
variables: variables.map((row) => toMetadata(row, resolved.descriptor)),
},
warnings: [],
nextSteps: variables.length === 0
? [`prisma-cli project env add KEY=value ${formatScopeFlag(scope)}`]
? [`prisma-cli project env add KEY=value ${formatScopeFlag(resolved.addScope)}`]
: [],
};
}
Expand Down Expand Up @@ -339,13 +359,13 @@ async function resolveScopeToApi(
? await resolveOrCreateBranch(client, projectId, scope.branchName, options.signal)
: await resolveExistingBranch(client, projectId, scope.branchName, options.signal);

if (branch.isDefault) {
if (branch.role === "production") {
throw new CliError({
code: "ENV_BRANCH_SCOPE_IS_PRODUCTION",
domain: "app",
summary: `Branch "${scope.branchName}" is the default branch`,
summary: `Branch "${scope.branchName}" is the production branch`,
why: "Production variables are project-level only; branch overrides apply to preview branches.",
fix: "Use --role production for the default branch.",
fix: "Use --role production for the production branch.",
exitCode: 1,
nextSteps: ["prisma-cli project env list --role production"],
});
Expand All @@ -362,6 +382,117 @@ async function resolveScopeToApi(
};
}

async function resolveListScopeToApi(
client: ManagementApiClient,
projectId: string,
explicit: EnvScope | undefined,
options: { cwd: string; signal: AbortSignal },
): Promise<ResolvedListScope> {
if (explicit) {
const resolved = await resolveScopeToApi(client, projectId, explicit, {
createBranchIfMissing: false,
signal: options.signal,
});
return {
kind: "scoped",
descriptor: resolved.descriptor,
target: targetFromExplicitScope(resolved.descriptor),
apiTarget: resolved.apiTarget,
addScope: resolved.scope,
};
}

const gitBranch = await readLocalGitBranch(options.cwd, options.signal);
if (gitBranch) {
const branch = (await listBranchesByName(client, projectId, gitBranch, options.signal))[0];
if (!branch) {
return {
kind: "scoped",
descriptor: { kind: "role", role: "preview" },
target: {
source: "local-git",
branchName: gitBranch,
branchExists: false,
envMap: "preview",
},
apiTarget: { class: "preview", branchId: null },
addScope: { kind: "branch", branchName: gitBranch },
};
}

if (branch.role === "production") {
return {
kind: "scoped",
descriptor: { kind: "role", role: "production" },
target: {
source: "local-git",
branchName: branch.gitName,
branchId: branch.id,
branchRole: branch.role,
branchExists: true,
envMap: "production",
},
apiTarget: { class: "production", branchId: null },
addScope: { kind: "role", role: "production" },
};
}

return {
kind: "scoped",
descriptor: {
kind: "branch",
branchName: branch.gitName,
branchId: branch.id,
},
target: {
source: "local-git",
branchName: branch.gitName,
branchId: branch.id,
branchRole: branch.role,
branchExists: true,
envMap: "preview",
},
apiTarget: { class: "preview", branchId: branch.id },
addScope: { kind: "branch", branchName: branch.gitName },
};
}

return {
kind: "overview",
descriptor: { kind: "overview" },
target: {
source: "overview",
envMap: "overview",
},
addScope: { kind: "role", role: "preview" },
};
}

function targetFromExplicitScope(scope: EnvScopeDescriptor): EnvListTarget {
if (scope.kind === "branch") {
return {
source: "explicit",
branchName: scope.branchName,
branchId: scope.branchId,
branchRole: "preview",
branchExists: true,
envMap: "preview",
};
}

if (scope.kind === "role") {
return {
source: "explicit",
envMap: scope.role,
};
}

return {
source: "overview",
envMap: "overview",
};
}

function formatScopeFlag(scope: EnvScope): string {
if (scope.kind === "role") {
return `--role ${scope.role}`;
Expand Down Expand Up @@ -529,16 +660,49 @@ async function listVariables(
projectId: string,
resolved: ResolvedScope,
signal: AbortSignal,
): Promise<RawEnvironmentVariable[]> {
const collected = await collectEnvironmentVariables(client, projectId, signal, {
className: resolved.apiTarget.class,
filter: (row) => rowMatchesScope(row, resolved),
});

return materializeEffectiveRows(collected, resolved);
}

async function listOverviewVariables(
client: ManagementApiClient,
projectId: string,
signal: AbortSignal,
): Promise<RawEnvironmentVariable[]> {
const collected = await collectEnvironmentVariables(client, projectId, signal, {
filter: (row) =>
row.branchId === null && (row.class === "production" || row.class === "preview"),
});

return collected.sort((left, right) => {
const roleOrder = roleSortOrder(left.class) - roleSortOrder(right.class);
return roleOrder !== 0 ? roleOrder : left.key.localeCompare(right.key);
});
}

async function collectEnvironmentVariables(
client: ManagementApiClient,
projectId: string,
signal: AbortSignal,
options: {
className?: EnvVarRole;
filter(row: RawEnvironmentVariable): boolean;
},
): Promise<RawEnvironmentVariable[]> {
const collected: RawEnvironmentVariable[] = [];
let cursor: string | undefined;

// eslint-disable-next-line no-constant-condition
while (true) {
const query: Record<string, string | undefined> = {
projectId,
class: resolved.apiTarget.class,
};
const query: Record<string, string | undefined> = { projectId };
if (options.className !== undefined) {
query.class = options.className;
}
if (cursor !== undefined) {
query.cursor = cursor;
}
Expand All @@ -555,9 +719,7 @@ async function listVariables(
);
}

const page = (result.data.data as RawEnvironmentVariable[]).filter((row) =>
rowMatchesScope(row, resolved),
);
const page = (result.data.data as RawEnvironmentVariable[]).filter(options.filter);
collected.push(...page);

if (!result.data.pagination.hasMore || !result.data.pagination.nextCursor) {
Expand All @@ -566,7 +728,11 @@ async function listVariables(
cursor = result.data.pagination.nextCursor;
}

return materializeEffectiveRows(collected, resolved);
return collected;
}

function roleSortOrder(role: EnvVarRole): number {
return role === "production" ? 0 : 1;
}

function rowMatchesScope(
Expand Down Expand Up @@ -638,6 +804,9 @@ 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"}`;
}

Expand Down
Loading
Loading