Skip to content

Commit db48a36

Browse files
authored
Merge pull request #65 from prisma/feat/env-list-branch-targeting
fix: resolve env list from active branch
2 parents 8930881 + 95334b0 commit db48a36

10 files changed

Lines changed: 585 additions & 80 deletions

File tree

docs/product/command-spec.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -687,7 +687,9 @@ Every write targets exactly one scope:
687687
- `--role` and `--branch` are mutually exclusive.
688688
- For write verbs (`add`, `update`, `remove`), one scope flag is required
689689
so the CLI never silently writes to production.
690-
- For read verbs (`list`), omitting `--role` defaults to `--role production`.
690+
- For read verbs (`list`), omitting `--role` or `--branch` resolves from
691+
the active local Git branch when one exists; outside a Git branch it
692+
shows a production/preview project-level overview.
691693

692694
### `prisma-cli project env add KEY=VALUE (--role <production|preview> | --branch <git-name>)`
693695

@@ -751,11 +753,20 @@ Purpose:
751753
Behavior:
752754

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

760771
Examples:
761772

packages/cli/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ Useful next commands:
5757
npx prisma-cli app logs
5858
npx prisma-cli app open
5959
npx prisma-cli project env add DATABASE_URL=postgresql://example --role preview
60+
npx prisma-cli project env list
6061
npx prisma-cli project env list --role preview
6162
```
6263

packages/cli/src/controllers/app-env.ts

Lines changed: 189 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import {
88
type EnvVarRole,
99
} from "../lib/app/env-config";
1010
import { requireComputeAuth } from "../lib/auth/guard";
11+
import { readLocalGitBranch } from "../lib/git/local-branch";
1112
import { authRequiredError, CliError, usageError, workspaceRequiredError } from "../shell/errors";
1213
import type { CommandSuccess } from "../shell/output";
1314
import type { CommandContext } from "../shell/runtime";
1415
import { resolveProjectTarget } from "../lib/project/resolution";
1516
import type {
1617
EnvAddResult,
18+
EnvListTarget,
1719
EnvListResult,
1820
EnvRmResult,
1921
EnvScopeDescriptor,
@@ -29,6 +31,21 @@ interface ResolvedScope {
2931
apiTarget: { class: EnvVarRole; branchId: string | null };
3032
}
3133

34+
type ResolvedListScope =
35+
| {
36+
kind: "scoped";
37+
descriptor: EnvScopeDescriptor;
38+
target: EnvListTarget;
39+
apiTarget: { class: EnvVarRole; branchId: string | null };
40+
addScope: EnvScope;
41+
}
42+
| {
43+
kind: "overview";
44+
descriptor: { kind: "overview" };
45+
target: EnvListTarget;
46+
addScope: EnvScope;
47+
};
48+
3249
interface EnvCommandFlags {
3350
roleName?: string;
3451
branchName?: string;
@@ -47,13 +64,10 @@ interface RawEnvironmentVariable {
4764
interface RawBranchRecord {
4865
id: string;
4966
gitName: string;
67+
role: EnvVarRole;
5068
isDefault: boolean;
5169
}
5270

53-
function defaultRoleScope(): EnvScope {
54-
return { kind: "role", role: "production" };
55-
}
56-
5771
export async function runEnvAdd(
5872
context: CommandContext,
5973
rawAssignment: string | undefined,
@@ -204,25 +218,31 @@ export async function runEnvList(
204218
flags: EnvCommandFlags,
205219
): Promise<CommandSuccess<EnvListResult>> {
206220
const explicit = resolveEnvScope(flags, { requireExplicit: false, command: "list" });
207-
const scope = explicit ?? defaultRoleScope();
208221

209222
const { client, projectId } = await requireClientAndProject(context, flags.projectRef, "project env list");
210-
const resolved = await resolveScopeToApi(client, projectId, scope, {
211-
createBranchIfMissing: false,
223+
const resolved = await resolveListScopeToApi(client, projectId, explicit, {
224+
cwd: context.runtime.cwd,
212225
signal: context.runtime.signal,
213226
});
214-
const variables = await listVariables(client, projectId, resolved, context.runtime.signal);
227+
const variables = resolved.kind === "scoped"
228+
? await listVariables(client, projectId, {
229+
scope: resolved.addScope,
230+
descriptor: resolved.descriptor,
231+
apiTarget: resolved.apiTarget,
232+
}, context.runtime.signal)
233+
: await listOverviewVariables(client, projectId, context.runtime.signal);
215234

216235
return {
217236
command: "project.env.list",
218237
result: {
219238
projectId,
220239
scope: resolved.descriptor,
240+
target: resolved.target,
221241
variables: variables.map((row) => toMetadata(row, resolved.descriptor)),
222242
},
223243
warnings: [],
224244
nextSteps: variables.length === 0
225-
? [`prisma-cli project env add KEY=value ${formatScopeFlag(scope)}`]
245+
? [`prisma-cli project env add KEY=value ${formatScopeFlag(resolved.addScope)}`]
226246
: [],
227247
};
228248
}
@@ -339,13 +359,13 @@ async function resolveScopeToApi(
339359
? await resolveOrCreateBranch(client, projectId, scope.branchName, options.signal)
340360
: await resolveExistingBranch(client, projectId, scope.branchName, options.signal);
341361

342-
if (branch.isDefault) {
362+
if (branch.role === "production") {
343363
throw new CliError({
344364
code: "ENV_BRANCH_SCOPE_IS_PRODUCTION",
345365
domain: "app",
346-
summary: `Branch "${scope.branchName}" is the default branch`,
366+
summary: `Branch "${scope.branchName}" is the production branch`,
347367
why: "Production variables are project-level only; branch overrides apply to preview branches.",
348-
fix: "Use --role production for the default branch.",
368+
fix: "Use --role production for the production branch.",
349369
exitCode: 1,
350370
nextSteps: ["prisma-cli project env list --role production"],
351371
});
@@ -362,6 +382,117 @@ async function resolveScopeToApi(
362382
};
363383
}
364384

385+
async function resolveListScopeToApi(
386+
client: ManagementApiClient,
387+
projectId: string,
388+
explicit: EnvScope | undefined,
389+
options: { cwd: string; signal: AbortSignal },
390+
): Promise<ResolvedListScope> {
391+
if (explicit) {
392+
const resolved = await resolveScopeToApi(client, projectId, explicit, {
393+
createBranchIfMissing: false,
394+
signal: options.signal,
395+
});
396+
return {
397+
kind: "scoped",
398+
descriptor: resolved.descriptor,
399+
target: targetFromExplicitScope(resolved.descriptor),
400+
apiTarget: resolved.apiTarget,
401+
addScope: resolved.scope,
402+
};
403+
}
404+
405+
const gitBranch = await readLocalGitBranch(options.cwd, options.signal);
406+
if (gitBranch) {
407+
const branch = (await listBranchesByName(client, projectId, gitBranch, options.signal))[0];
408+
if (!branch) {
409+
return {
410+
kind: "scoped",
411+
descriptor: { kind: "role", role: "preview" },
412+
target: {
413+
source: "local-git",
414+
branchName: gitBranch,
415+
branchExists: false,
416+
envMap: "preview",
417+
},
418+
apiTarget: { class: "preview", branchId: null },
419+
addScope: { kind: "branch", branchName: gitBranch },
420+
};
421+
}
422+
423+
if (branch.role === "production") {
424+
return {
425+
kind: "scoped",
426+
descriptor: { kind: "role", role: "production" },
427+
target: {
428+
source: "local-git",
429+
branchName: branch.gitName,
430+
branchId: branch.id,
431+
branchRole: branch.role,
432+
branchExists: true,
433+
envMap: "production",
434+
},
435+
apiTarget: { class: "production", branchId: null },
436+
addScope: { kind: "role", role: "production" },
437+
};
438+
}
439+
440+
return {
441+
kind: "scoped",
442+
descriptor: {
443+
kind: "branch",
444+
branchName: branch.gitName,
445+
branchId: branch.id,
446+
},
447+
target: {
448+
source: "local-git",
449+
branchName: branch.gitName,
450+
branchId: branch.id,
451+
branchRole: branch.role,
452+
branchExists: true,
453+
envMap: "preview",
454+
},
455+
apiTarget: { class: "preview", branchId: branch.id },
456+
addScope: { kind: "branch", branchName: branch.gitName },
457+
};
458+
}
459+
460+
return {
461+
kind: "overview",
462+
descriptor: { kind: "overview" },
463+
target: {
464+
source: "overview",
465+
envMap: "overview",
466+
},
467+
addScope: { kind: "role", role: "preview" },
468+
};
469+
}
470+
471+
function targetFromExplicitScope(scope: EnvScopeDescriptor): EnvListTarget {
472+
if (scope.kind === "branch") {
473+
return {
474+
source: "explicit",
475+
branchName: scope.branchName,
476+
branchId: scope.branchId,
477+
branchRole: "preview",
478+
branchExists: true,
479+
envMap: "preview",
480+
};
481+
}
482+
483+
if (scope.kind === "role") {
484+
return {
485+
source: "explicit",
486+
envMap: scope.role,
487+
};
488+
}
489+
490+
return {
491+
source: "overview",
492+
envMap: "overview",
493+
};
494+
}
495+
365496
function formatScopeFlag(scope: EnvScope): string {
366497
if (scope.kind === "role") {
367498
return `--role ${scope.role}`;
@@ -529,16 +660,49 @@ async function listVariables(
529660
projectId: string,
530661
resolved: ResolvedScope,
531662
signal: AbortSignal,
663+
): Promise<RawEnvironmentVariable[]> {
664+
const collected = await collectEnvironmentVariables(client, projectId, signal, {
665+
className: resolved.apiTarget.class,
666+
filter: (row) => rowMatchesScope(row, resolved),
667+
});
668+
669+
return materializeEffectiveRows(collected, resolved);
670+
}
671+
672+
async function listOverviewVariables(
673+
client: ManagementApiClient,
674+
projectId: string,
675+
signal: AbortSignal,
676+
): Promise<RawEnvironmentVariable[]> {
677+
const collected = await collectEnvironmentVariables(client, projectId, signal, {
678+
filter: (row) =>
679+
row.branchId === null && (row.class === "production" || row.class === "preview"),
680+
});
681+
682+
return collected.sort((left, right) => {
683+
const roleOrder = roleSortOrder(left.class) - roleSortOrder(right.class);
684+
return roleOrder !== 0 ? roleOrder : left.key.localeCompare(right.key);
685+
});
686+
}
687+
688+
async function collectEnvironmentVariables(
689+
client: ManagementApiClient,
690+
projectId: string,
691+
signal: AbortSignal,
692+
options: {
693+
className?: EnvVarRole;
694+
filter(row: RawEnvironmentVariable): boolean;
695+
},
532696
): Promise<RawEnvironmentVariable[]> {
533697
const collected: RawEnvironmentVariable[] = [];
534698
let cursor: string | undefined;
535699

536700
// eslint-disable-next-line no-constant-condition
537701
while (true) {
538-
const query: Record<string, string | undefined> = {
539-
projectId,
540-
class: resolved.apiTarget.class,
541-
};
702+
const query: Record<string, string | undefined> = { projectId };
703+
if (options.className !== undefined) {
704+
query.class = options.className;
705+
}
542706
if (cursor !== undefined) {
543707
query.cursor = cursor;
544708
}
@@ -555,9 +719,7 @@ async function listVariables(
555719
);
556720
}
557721

558-
const page = (result.data.data as RawEnvironmentVariable[]).filter((row) =>
559-
rowMatchesScope(row, resolved),
560-
);
722+
const page = (result.data.data as RawEnvironmentVariable[]).filter(options.filter);
561723
collected.push(...page);
562724

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

569-
return materializeEffectiveRows(collected, resolved);
731+
return collected;
732+
}
733+
734+
function roleSortOrder(role: EnvVarRole): number {
735+
return role === "production" ? 0 : 1;
570736
}
571737

572738
function rowMatchesScope(
@@ -638,6 +804,9 @@ function formatDescriptorLabel(scope: EnvScopeDescriptor): string {
638804
if (scope.kind === "role") {
639805
return scope.role ?? "unknown";
640806
}
807+
if (scope.kind === "overview") {
808+
return "overview";
809+
}
641810
return `branch:${scope.branchName ?? scope.branchId ?? "unknown"}`;
642811
}
643812

0 commit comments

Comments
 (0)