From 61527cb10a1cdae7cd4994bd2166c53c83bf9066 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Wed, 3 Jun 2026 04:25:59 +0200 Subject: [PATCH 1/2] fix: resolve env list from active branch --- docs/product/command-spec.md | 19 +- packages/cli/README.md | 1 + packages/cli/src/controllers/app-env.ts | 194 ++++++++++++++- packages/cli/src/controllers/app.ts | 48 +--- packages/cli/src/lib/git/local-branch.ts | 49 ++++ packages/cli/src/presenters/app-env.ts | 24 +- packages/cli/src/shell/command-meta.ts | 3 +- packages/cli/src/types/app-env.ts | 13 +- packages/cli/tests/app-env-presenter.test.ts | 56 +++++ packages/cli/tests/app-env.test.ts | 243 ++++++++++++++++++- 10 files changed, 578 insertions(+), 72 deletions(-) create mode 100644 packages/cli/src/lib/git/local-branch.ts create mode 100644 packages/cli/tests/app-env-presenter.test.ts diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 4787c5c..c468bd3 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -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 | --branch )` @@ -751,11 +753,20 @@ Purpose: Behavior: - requires auth and a resolved project; accepts `--project ` 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: diff --git a/packages/cli/README.md b/packages/cli/README.md index 1820c42..3323644 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 ``` diff --git a/packages/cli/src/controllers/app-env.ts b/packages/cli/src/controllers/app-env.ts index 7385d8a..3f5e6b2 100644 --- a/packages/cli/src/controllers/app-env.ts +++ b/packages/cli/src/controllers/app-env.ts @@ -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, @@ -29,6 +31,13 @@ interface ResolvedScope { apiTarget: { class: EnvVarRole; branchId: string | null }; } +interface ResolvedListScope { + descriptor: EnvScopeDescriptor; + target: EnvListTarget; + apiTarget: { class: EnvVarRole; branchId: string | null } | null; + addScope: EnvScope; +} + interface EnvCommandFlags { roleName?: string; branchName?: string; @@ -47,13 +56,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, @@ -204,25 +210,31 @@ export async function runEnvList( flags: EnvCommandFlags, ): Promise> { 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.apiTarget + ? 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)}`] : [], }; } @@ -339,13 +351,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"], }); @@ -362,6 +374,113 @@ async function resolveScopeToApi( }; } +async function resolveListScopeToApi( + client: ManagementApiClient, + projectId: string, + explicit: EnvScope | undefined, + options: { cwd: string; signal: AbortSignal }, +): Promise { + if (explicit) { + const resolved = await resolveScopeToApi(client, projectId, explicit, { + createBranchIfMissing: false, + signal: options.signal, + }); + return { + 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 { + 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 { + 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 { + 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 { + descriptor: { kind: "overview" }, + target: { + source: "overview", + envMap: "overview", + }, + apiTarget: null, + 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}`; @@ -569,6 +688,54 @@ async function listVariables( return materializeEffectiveRows(collected, resolved); } +async function listOverviewVariables( + client: ManagementApiClient, + projectId: string, + signal: AbortSignal, +): Promise { + const collected: RawEnvironmentVariable[] = []; + let cursor: string | undefined; + + // eslint-disable-next-line no-constant-condition + while (true) { + const query: Record = { projectId }; + if (cursor !== undefined) { + query.cursor = cursor; + } + + const result = await client.GET("/v1/environment-variables", { + params: { query }, + signal, + }); + if (result.error || !result.data) { + throw apiCallError( + `Failed to list environment variables`, + result.response, + result.error, + ); + } + + const page = (result.data.data as RawEnvironmentVariable[]).filter((row) => + row.branchId === null && (row.class === "production" || row.class === "preview") + ); + collected.push(...page); + + if (!result.data.pagination.hasMore || !result.data.pagination.nextCursor) { + break; + } + cursor = result.data.pagination.nextCursor; + } + + return collected.sort((left, right) => { + const roleOrder = roleSortOrder(left.class) - roleSortOrder(right.class); + return roleOrder !== 0 ? roleOrder : left.key.localeCompare(right.key); + }); +} + +function roleSortOrder(role: EnvVarRole): number { + return role === "production" ? 0 : 1; +} + function rowMatchesScope( row: RawEnvironmentVariable, resolved: ResolvedScope, @@ -638,6 +805,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"}`; } diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 20eca81..a4aa3ab 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -70,6 +70,7 @@ import { readLocalResolutionPin, type LocalResolutionPinReadResult, } from "../lib/project/local-pin"; +import { readLocalGitBranch } from "../lib/git/local-branch"; import { executePreviewBuild, PREVIEW_BUILD_TYPES, @@ -2534,53 +2535,6 @@ async function resolveDeployBranch(context: CommandContext, explicitBranchName: }; } -async function readLocalGitBranch(cwd: string, signal: AbortSignal): Promise { - const gitPath = path.join(cwd, ".git"); - const headPath = await resolveGitHeadPath(gitPath, signal); - if (!headPath) { - return null; - } - - try { - const head = (await readFile(headPath, { encoding: "utf8", signal })).trim(); - const refPrefix = "ref: refs/heads/"; - if (head.startsWith(refPrefix)) { - return head.slice(refPrefix.length); - } - } catch (error) { - if (signal.aborted) throw error; - return null; - } - - return null; -} - -async function resolveGitHeadPath(gitPath: string, signal: AbortSignal): Promise { - signal.throwIfAborted(); - try { - const raw = await readFile(gitPath, { encoding: "utf8", signal }); - const prefix = "gitdir:"; - if (raw.startsWith(prefix)) { - return path.join(path.resolve(path.dirname(gitPath), raw.slice(prefix.length).trim()), "HEAD"); - } - } catch (error) { - if (signal.aborted) throw error; - // Fall through to try the normal .git directory shape below. - // Common cases: EISDIR (normal git repo), EACCES, ENOENT. - } - - signal.throwIfAborted(); - try { - // access does not accept AbortSignal; check before and after the filesystem boundary. - await access(path.join(gitPath, "HEAD")); - signal.throwIfAborted(); - return path.join(gitPath, "HEAD"); - } catch (error) { - if (signal.aborted) throw error; - return null; - } -} - interface ResolvedDeployFramework { key: string; buildType: ResolvedPreviewBuildType; diff --git a/packages/cli/src/lib/git/local-branch.ts b/packages/cli/src/lib/git/local-branch.ts new file mode 100644 index 0000000..0e99579 --- /dev/null +++ b/packages/cli/src/lib/git/local-branch.ts @@ -0,0 +1,49 @@ +import { access, readFile } from "node:fs/promises"; +import path from "node:path"; + +export async function readLocalGitBranch(cwd: string, signal: AbortSignal): Promise { + const gitPath = path.join(cwd, ".git"); + const headPath = await resolveGitHeadPath(gitPath, signal); + if (!headPath) { + return null; + } + + try { + const head = (await readFile(headPath, { encoding: "utf8", signal })).trim(); + const refPrefix = "ref: refs/heads/"; + if (head.startsWith(refPrefix)) { + return head.slice(refPrefix.length); + } + } catch (error) { + if (signal.aborted) throw error; + return null; + } + + return null; +} + +async function resolveGitHeadPath(gitPath: string, signal: AbortSignal): Promise { + signal.throwIfAborted(); + try { + const raw = await readFile(gitPath, { encoding: "utf8", signal }); + const prefix = "gitdir:"; + if (raw.startsWith(prefix)) { + return path.join(path.resolve(path.dirname(gitPath), raw.slice(prefix.length).trim()), "HEAD"); + } + } catch (error) { + if (signal.aborted) throw error; + // Fall through to try the normal .git directory shape below. + // Common cases: EISDIR (normal git repo), EACCES, ENOENT. + } + + signal.throwIfAborted(); + try { + // access does not accept AbortSignal; check before and after the filesystem boundary. + await access(path.join(gitPath, "HEAD")); + signal.throwIfAborted(); + return path.join(gitPath, "HEAD"); + } catch (error) { + if (signal.aborted) throw error; + return null; + } +} diff --git a/packages/cli/src/presenters/app-env.ts b/packages/cli/src/presenters/app-env.ts index 2bcf721..3046823 100644 --- a/packages/cli/src/presenters/app-env.ts +++ b/packages/cli/src/presenters/app-env.ts @@ -13,9 +13,26 @@ function scopeLabel(scope: EnvScopeDescriptor): string { if (scope.kind === "role") { return scope.role ?? "unknown"; } + if (scope.kind === "overview") { + return "overview"; + } return `branch:${scope.branchName ?? scope.branchId ?? "unknown"}`; } +function listTargetLabel(result: EnvListResult): string { + const target = result.target; + if (target.source === "overview") { + return "overview"; + } + + if (target.branchName) { + const suffix = target.branchExists === false ? " (not created yet)" : ""; + return `branch:${target.branchName} -> ${target.envMap}${suffix}`; + } + + return scopeLabel(result.scope); +} + export function renderEnvAdd( context: CommandContext, descriptor: CommandDescriptor, @@ -84,8 +101,8 @@ export function renderEnvList( title: "Listing environment variables for the selected scope.", descriptor, parentContext: { - key: "scope", - value: scopeLabel(result.scope), + key: "target", + value: listTargetLabel(result), }, items: result.variables.map((variable) => ({ noun: "variable", @@ -103,9 +120,10 @@ export function serializeEnvList(result: EnvListResult) { return { projectId: result.projectId, scope: result.scope, + target: result.target, ...serializeList({ context: { - scope: scopeLabel(result.scope), + target: listTargetLabel(result), }, items: result.variables.map((variable) => ({ noun: "variable", diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 511dcdb..114bff2 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -258,7 +258,7 @@ const DESCRIPTORS: CommandDescriptor[] = [ path: ["prisma", "project", "env"], description: "Manage environment variables for the active project", examples: [ - "prisma-cli project env list --role production", + "prisma-cli project env list", "prisma-cli project env add STRIPE_KEY=sk_test_xxx --role production", "prisma-cli project env add DATABASE_URL=postgresql://branch --branch feature/foo", "prisma-cli project env remove STRIPE_KEY --role preview", @@ -290,6 +290,7 @@ const DESCRIPTORS: CommandDescriptor[] = [ path: ["prisma", "project", "env", "list"], description: "List environment variable metadata for a scope (no values).", examples: [ + "prisma-cli project env list", "prisma-cli project env list --role production", "prisma-cli project env list --role preview", "prisma-cli project env list --branch feature/foo", diff --git a/packages/cli/src/types/app-env.ts b/packages/cli/src/types/app-env.ts index 850479f..46392cd 100644 --- a/packages/cli/src/types/app-env.ts +++ b/packages/cli/src/types/app-env.ts @@ -1,6 +1,16 @@ export type EnvScopeDescriptor = | { kind: "role"; role: "production" | "preview" } - | { kind: "branch"; branchName: string; branchId: string }; + | { kind: "branch"; branchName: string; branchId: string } + | { kind: "overview" }; + +export interface EnvListTarget { + source: "explicit" | "local-git" | "overview"; + envMap: "production" | "preview" | "overview"; + branchName?: string; + branchId?: string; + branchRole?: "production" | "preview"; + branchExists?: boolean; +} export interface EnvVariableMetadata { id: string; @@ -26,6 +36,7 @@ export interface EnvUpdateResult { export interface EnvListResult { projectId: string; scope: EnvScopeDescriptor; + target: EnvListTarget; variables: EnvVariableMetadata[]; } diff --git a/packages/cli/tests/app-env-presenter.test.ts b/packages/cli/tests/app-env-presenter.test.ts new file mode 100644 index 0000000..d012bdc --- /dev/null +++ b/packages/cli/tests/app-env-presenter.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import stripAnsi from "strip-ansi"; + +import { renderEnvList, serializeEnvList } from "../src/presenters/app-env"; +import { getCommandDescriptor } from "../src/shell/command-meta"; +import type { EnvListResult } from "../src/types/app-env"; +import { createTestCommandContext } from "./helpers"; + +describe("app env presenters", () => { + it("renders and serializes inferred missing-branch targets", async () => { + const { context } = await createTestCommandContext({}); + const result: EnvListResult = { + projectId: "proj_123", + scope: { kind: "role", role: "preview" }, + target: { + source: "local-git", + branchName: "feature/not-created", + branchExists: false, + envMap: "preview", + }, + variables: [ + { + id: "envvar_preview", + key: "API_URL", + scope: { kind: "role", role: "preview" }, + source: "preview", + isManagedBySystem: false, + updatedAt: "2026-05-08T10:00:00.000Z", + }, + ], + }; + + const human = stripAnsi(renderEnvList( + context, + getCommandDescriptor("project.env.list"), + result, + ).join("\n")); + const json = serializeEnvList(result); + + expect(human).toContain("target:"); + expect(human).toContain("branch:feature/not-created -> preview (not created yet)"); + expect(json).toMatchObject({ + projectId: "proj_123", + scope: { kind: "role", role: "preview" }, + target: { + source: "local-git", + branchName: "feature/not-created", + branchExists: false, + envMap: "preview", + }, + context: { + target: "branch:feature/not-created -> preview (not created yet)", + }, + }); + }); +}); diff --git a/packages/cli/tests/app-env.test.ts b/packages/cli/tests/app-env.test.ts index e0207d4..8807c38 100644 --- a/packages/cli/tests/app-env.test.ts +++ b/packages/cli/tests/app-env.test.ts @@ -76,6 +76,11 @@ async function writeLocalPin(cwd: string, projectId = "proj_123") { ); } +async function writeGitHead(cwd: string, branchName: string) { + await mkdir(path.join(cwd, ".git"), { recursive: true }); + await writeFile(path.join(cwd, ".git", "HEAD"), `ref: refs/heads/${branchName}\n`, "utf8"); +} + async function loadControllers(client: MockClient, projectId: string) { vi.resetModules(); void projectId; @@ -131,11 +136,13 @@ function makeVariableRow(overrides: Partial<{ function makeBranchRow(overrides: Partial<{ id: string; gitName: string; + role: "production" | "preview"; isDefault: boolean; }> = {}) { return { id: "br_feature", gitName: "feature/foo", + role: "preview", isDefault: false, ...overrides, }; @@ -682,6 +689,10 @@ describe("env list", () => { }), ); expect(result.result.scope).toEqual({ kind: "role", role: "production" }); + expect(result.result.target).toEqual({ + source: "explicit", + envMap: "production", + }); expect(result.result.variables.map((v) => v.key)).toEqual([ "STRIPE_KEY", "SENDGRID_KEY", @@ -690,17 +701,116 @@ describe("env list", () => { expect(flattened).not.toMatch(/"value"\s*:/); }); - it("defaults to --role production when no scope flag is provided", async () => { + it("infers the active Git preview branch when no scope flag is provided", async () => { const client = createMockClient(); - client.envGET.mockResolvedValueOnce({ - data: { data: [], pagination: { hasMore: false, nextCursor: null } }, - response: { status: 200 }, + client.envGET + .mockResolvedValueOnce({ + data: { + data: [makeBranchRow({ id: "br_feature", gitName: "feature/foo", role: "preview" })], + pagination: { hasMore: false, nextCursor: null }, + }, + response: { status: 200 }, + }) + .mockResolvedValueOnce({ + data: { + data: [ + makeVariableRow({ + id: "envvar_preview", + key: "DATABASE_URL", + class: "preview", + branchId: null, + }), + makeVariableRow({ + id: "envvar_branch", + key: "DATABASE_URL", + class: "preview", + branchId: "br_feature", + }), + ], + pagination: { hasMore: false, nextCursor: null }, + }, + response: { status: 200 }, + }); + + const { controllers, createTempCwd, createTestCommandContext } = + await loadControllers(client, "proj_123"); + const cwd = await createTempCwd(); + await writeLocalPin(cwd); + await writeGitHead(cwd, "feature/foo"); + const { context } = await createTestCommandContext({ cwd }); + + const result = await controllers.runEnvList(context, {}); + + expect(client.GET).toHaveBeenCalledWith( + "/v1/projects/{projectId}/branches", + expect.objectContaining({ + params: { + path: { projectId: "proj_123" }, + query: { gitName: "feature/foo" }, + }, + }), + ); + expect(client.GET).toHaveBeenCalledWith( + "/v1/environment-variables", + expect.objectContaining({ + params: { + query: expect.objectContaining({ + projectId: "proj_123", + class: "preview", + }), + }, + }), + ); + expect(result.result.scope).toEqual({ + kind: "branch", + branchName: "feature/foo", + branchId: "br_feature", + }); + expect(result.result.target).toEqual({ + source: "local-git", + branchName: "feature/foo", + branchId: "br_feature", + branchRole: "preview", + branchExists: true, + envMap: "preview", }); + expect(result.result.variables.map((variable) => ({ + key: variable.key, + id: variable.id, + source: variable.source, + }))).toEqual([ + { key: "DATABASE_URL", id: "envvar_branch", source: "branch:feature/foo" }, + ]); + }); + + it("infers the active Git production branch when no scope flag is provided", async () => { + const client = createMockClient(); + client.envGET + .mockResolvedValueOnce({ + data: { + data: [makeBranchRow({ + id: "br_main", + gitName: "main", + role: "production", + isDefault: true, + })], + pagination: { hasMore: false, nextCursor: null }, + }, + response: { status: 200 }, + }) + .mockResolvedValueOnce({ + data: { + data: [makeVariableRow({ id: "envvar_prod", key: "STRIPE_KEY", class: "production" })], + pagination: { hasMore: false, nextCursor: null }, + }, + response: { status: 200 }, + }); const { controllers, createTempCwd, createTestCommandContext } = await loadControllers(client, "proj_123"); const cwd = await createTempCwd(); await writeLocalPin(cwd); + await writeGitHead(cwd, "main"); const { context } = await createTestCommandContext({ cwd }); const result = await controllers.runEnvList(context, {}); @@ -717,6 +827,123 @@ describe("env list", () => { }), ); expect(result.result.scope).toEqual({ kind: "role", role: "production" }); + expect(result.result.target).toEqual({ + source: "local-git", + branchName: "main", + branchId: "br_main", + branchRole: "production", + branchExists: true, + envMap: "production", + }); + expect(result.result.variables.map((variable) => ({ + key: variable.key, + source: variable.source, + }))).toEqual([ + { key: "STRIPE_KEY", source: "production" }, + ]); + }); + + it("shows preview template metadata when the active Git branch has no Platform branch yet", async () => { + const client = createMockClient(); + client.envGET + .mockResolvedValueOnce({ + data: { data: [], pagination: { hasMore: false, nextCursor: null } }, + response: { status: 200 }, + }) + .mockResolvedValueOnce({ + data: { + data: [ + makeVariableRow({ + id: "envvar_preview", + key: "API_URL", + class: "preview", + branchId: null, + }), + makeVariableRow({ + id: "envvar_other_branch", + key: "DATABASE_URL", + class: "preview", + branchId: "br_other", + }), + ], + pagination: { hasMore: false, nextCursor: null }, + }, + response: { status: 200 }, + }); + + const { controllers, createTempCwd, createTestCommandContext } = + await loadControllers(client, "proj_123"); + const cwd = await createTempCwd(); + await writeLocalPin(cwd); + await writeGitHead(cwd, "feature/not-created"); + const { context } = await createTestCommandContext({ cwd }); + + const result = await controllers.runEnvList(context, {}); + + expect(result.result.scope).toEqual({ kind: "role", role: "preview" }); + expect(result.result.target).toEqual({ + source: "local-git", + branchName: "feature/not-created", + branchExists: false, + envMap: "preview", + }); + expect(result.result.variables.map((variable) => ({ + key: variable.key, + source: variable.source, + }))).toEqual([ + { key: "API_URL", source: "preview" }, + ]); + }); + + it("shows a production and preview overview when no local Git branch exists", async () => { + const client = createMockClient(); + client.envGET.mockResolvedValueOnce({ + data: { + data: [ + makeVariableRow({ id: "envvar_preview", key: "API_URL", class: "preview" }), + makeVariableRow({ id: "envvar_prod", key: "STRIPE_KEY", class: "production" }), + makeVariableRow({ + id: "envvar_branch", + key: "DATABASE_URL", + class: "preview", + branchId: "br_feature", + }), + ], + pagination: { hasMore: false, nextCursor: null }, + }, + response: { status: 200 }, + }); + + const { controllers, createTempCwd, createTestCommandContext } = + await loadControllers(client, "proj_123"); + const cwd = await createTempCwd(); + await writeLocalPin(cwd); + const { context } = await createTestCommandContext({ cwd }); + + const result = await controllers.runEnvList(context, {}); + + expect(client.GET).toHaveBeenCalledWith( + "/v1/environment-variables", + expect.objectContaining({ + params: { + query: { + projectId: "proj_123", + }, + }, + }), + ); + expect(result.result.scope).toEqual({ kind: "overview" }); + expect(result.result.target).toEqual({ + source: "overview", + envMap: "overview", + }); + expect(result.result.variables.map((variable) => ({ + key: variable.key, + source: variable.source, + }))).toEqual([ + { key: "STRIPE_KEY", source: "production" }, + { key: "API_URL", source: "preview" }, + ]); }); it("lists a resolved branch view with preview defaults and branch overrides", async () => { @@ -768,6 +995,14 @@ describe("env list", () => { branchName: "feature/foo", branchId: "br_feature", }); + expect(result.result.target).toEqual({ + source: "explicit", + branchName: "feature/foo", + branchId: "br_feature", + branchRole: "preview", + branchExists: true, + envMap: "preview", + }); expect(result.result.variables.map((variable) => ({ key: variable.key, id: variable.id, From 95334b0dd8c9eafcec805c92f6a4296a875cafa4 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Wed, 3 Jun 2026 04:35:24 +0200 Subject: [PATCH 2/2] refactor: share env variable collection --- packages/cli/src/controllers/app-env.ts | 99 ++++++++++++------------- 1 file changed, 49 insertions(+), 50 deletions(-) diff --git a/packages/cli/src/controllers/app-env.ts b/packages/cli/src/controllers/app-env.ts index 3f5e6b2..665fa5a 100644 --- a/packages/cli/src/controllers/app-env.ts +++ b/packages/cli/src/controllers/app-env.ts @@ -31,12 +31,20 @@ interface ResolvedScope { apiTarget: { class: EnvVarRole; branchId: string | null }; } -interface ResolvedListScope { - descriptor: EnvScopeDescriptor; - target: EnvListTarget; - apiTarget: { class: EnvVarRole; branchId: string | null } | null; - addScope: EnvScope; -} +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; @@ -216,7 +224,7 @@ export async function runEnvList( cwd: context.runtime.cwd, signal: context.runtime.signal, }); - const variables = resolved.apiTarget + const variables = resolved.kind === "scoped" ? await listVariables(client, projectId, { scope: resolved.addScope, descriptor: resolved.descriptor, @@ -386,6 +394,7 @@ async function resolveListScopeToApi( signal: options.signal, }); return { + kind: "scoped", descriptor: resolved.descriptor, target: targetFromExplicitScope(resolved.descriptor), apiTarget: resolved.apiTarget, @@ -398,6 +407,7 @@ async function resolveListScopeToApi( const branch = (await listBranchesByName(client, projectId, gitBranch, options.signal))[0]; if (!branch) { return { + kind: "scoped", descriptor: { kind: "role", role: "preview" }, target: { source: "local-git", @@ -412,6 +422,7 @@ async function resolveListScopeToApi( if (branch.role === "production") { return { + kind: "scoped", descriptor: { kind: "role", role: "production" }, target: { source: "local-git", @@ -427,6 +438,7 @@ async function resolveListScopeToApi( } return { + kind: "scoped", descriptor: { kind: "branch", branchName: branch.gitName, @@ -446,12 +458,12 @@ async function resolveListScopeToApi( } return { + kind: "overview", descriptor: { kind: "overview" }, target: { source: "overview", envMap: "overview", }, - apiTarget: null, addScope: { kind: "role", role: "preview" }, }; } @@ -649,41 +661,10 @@ async function listVariables( resolved: ResolvedScope, signal: AbortSignal, ): Promise { - const collected: RawEnvironmentVariable[] = []; - let cursor: string | undefined; - - // eslint-disable-next-line no-constant-condition - while (true) { - const query: Record = { - projectId, - class: resolved.apiTarget.class, - }; - if (cursor !== undefined) { - query.cursor = cursor; - } - - const result = await client.GET("/v1/environment-variables", { - params: { query }, - signal, - }); - if (result.error || !result.data) { - throw apiCallError( - `Failed to list environment variables`, - result.response, - result.error, - ); - } - - const page = (result.data.data as RawEnvironmentVariable[]).filter((row) => - rowMatchesScope(row, resolved), - ); - collected.push(...page); - - if (!result.data.pagination.hasMore || !result.data.pagination.nextCursor) { - break; - } - cursor = result.data.pagination.nextCursor; - } + const collected = await collectEnvironmentVariables(client, projectId, signal, { + className: resolved.apiTarget.class, + filter: (row) => rowMatchesScope(row, resolved), + }); return materializeEffectiveRows(collected, resolved); } @@ -692,6 +673,26 @@ async function listOverviewVariables( client: ManagementApiClient, projectId: string, signal: AbortSignal, +): Promise { + 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 { const collected: RawEnvironmentVariable[] = []; let cursor: string | undefined; @@ -699,6 +700,9 @@ async function listOverviewVariables( // eslint-disable-next-line no-constant-condition while (true) { const query: Record = { projectId }; + if (options.className !== undefined) { + query.class = options.className; + } if (cursor !== undefined) { query.cursor = cursor; } @@ -715,9 +719,7 @@ async function listOverviewVariables( ); } - const page = (result.data.data as RawEnvironmentVariable[]).filter((row) => - row.branchId === null && (row.class === "production" || row.class === "preview") - ); + const page = (result.data.data as RawEnvironmentVariable[]).filter(options.filter); collected.push(...page); if (!result.data.pagination.hasMore || !result.data.pagination.nextCursor) { @@ -726,10 +728,7 @@ async function listOverviewVariables( cursor = result.data.pagination.nextCursor; } - return collected.sort((left, right) => { - const roleOrder = roleSortOrder(left.class) - roleSortOrder(right.class); - return roleOrder !== 0 ? roleOrder : left.key.localeCompare(right.key); - }); + return collected; } function roleSortOrder(role: EnvVarRole): number {