fix(dashboard): preserve env across deploy entry paths

This commit is contained in:
Hydra
2026-09-04 14:37:18 +03:00
parent 062bb6d868
commit 73cfb646a1
10 changed files with 476 additions and 65 deletions
@@ -1137,6 +1137,7 @@ function mergeSourceEnvDefaults(
encryptedProjectEnv: Record<string, string> | null,
sourceInfo: SourceEnvInfo | undefined,
submitted?: Record<string, string>,
selectedSourceEnvKeys: readonly string[] = [],
): {
encrypted: Record<string, string> | null;
additions: PersistableProjectEnv[];
@@ -1148,16 +1149,16 @@ function mergeSourceEnvDefaults(
sourceDefaults.set(key, openshipEnvValue(value));
}
// A root `.env` remains opt-in. The wizard represents an imported value by
// sending its masked key; recover only those selected keys, never every value
// discovered beside the source tree.
// A root `.env` remains opt-in. New clients send a values-free key list so an
// existing project's full env map stays server-owned; masked entries in the
// legacy full payload remain supported. Recover only those selected keys,
// never every value discovered beside the source tree.
const selectedRootEnvKeys = new Set(selectedSourceEnvKeys);
for (const [key, value] of Object.entries(submitted ?? {})) {
if (
isMaskedValue(value) &&
!sourceDefaults.has(key) &&
sourceInfo?.rootEnv &&
Object.hasOwn(sourceInfo.rootEnv, key)
) {
if (isMaskedValue(value)) selectedRootEnvKeys.add(key);
}
for (const key of selectedRootEnvKeys) {
if (!sourceDefaults.has(key) && sourceInfo?.rootEnv && Object.hasOwn(sourceInfo.rootEnv, key)) {
sourceDefaults.set(key, {
value: sourceInfo.rootEnv[key]!,
isSecret: looksLikeSecretKey(key),
@@ -1421,6 +1422,7 @@ export async function requestBuildAccess(
branch,
environment,
envVars,
sourceEnvKeys,
publicEndpoints,
buildStrategy,
deployTarget,
@@ -1584,7 +1586,12 @@ export async function requestBuildAccess(
// this deployment. This is the missing link in #795: the downstream Docker
// adapter already consumes deployment env as build args, but the declared
// values previously never entered that snapshot.
const sourceEnv = mergeSourceEnvDefaults(deploymentEnvVars, sourceInfo, envVars);
const sourceEnv = mergeSourceEnvDefaults(
deploymentEnvVars,
sourceInfo,
envVars,
sourceEnvKeys,
);
deploymentEnvVars = sourceEnv.encrypted;
// Source interpolation and the wizard payload have different ownership. The
@@ -134,6 +134,13 @@ export const BuildAccessBody = Type.Object({
envVars: Type.Optional(
Type.Record(Type.String(), Type.String(), { description: "Runtime env vars { KEY: value }." }),
),
sourceEnvKeys: Type.Optional(
Type.Array(Type.String({ minLength: 1, maxLength: 256 }), {
maxItems: 100,
description:
"Root .env keys explicitly selected for trusted server-side import; values never cross the browser boundary.",
}),
),
publicEndpoints: Type.Optional(
Type.Array(PublicEndpointInput, {
description: "Domains/routes; omit to auto-derive a free subdomain from the project slug.",
@@ -1971,6 +1971,36 @@ describe("requestBuildAccess — folder-upload compose services", () => {
expect(captured.NOT_IMPORTED).toBeUndefined();
});
it("imports selected root .env keys without replacing an existing project env", async () => {
const storedSecret = encrypt("keep-me");
const uploadSessionId = seedSession({
rootEnv: { IMPORTED: "from-dotenv", NOT_IMPORTED: "leave-out" },
});
scanFolderSession.mockResolvedValueOnce({
services: scannedServices,
rootEnv: { IMPORTED: "from-dotenv", NOT_IMPORTED: "leave-out" },
});
repos.project.getEnvMap.mockResolvedValue({ AUTH_SECRET: storedSecret });
await requestBuildAccess(ctx, {
projectId: "project-1",
uploadSessionId,
sourceEnvKeys: ["IMPORTED"],
});
const captured = repos.deployment.create.mock.calls.at(-1)?.[0]?.envVars;
expect(captured.AUTH_SECRET).toBe(storedSecret);
expect(decrypt(captured.IMPORTED)).toBe("from-dotenv");
expect(captured.NOT_IMPORTED).toBeUndefined();
expect(repos.project.bulkSetEnvVars).not.toHaveBeenCalled();
expect(repos.project.mergeEnvVars).toHaveBeenCalledWith(
"project-1",
"production",
[expect.objectContaining({ key: "IMPORTED" })],
[],
);
});
it("keeps a literal image override made after folder scan", async () => {
const initial = {
...scannedServices[0],
@@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest";
import { ENV_MASK } from "@repo/core";
import type { EnvironmentVariable } from "@/components/import-project/types";
import type { PersistedProjectEnv } from "@/lib/project-env-diff";
import { mergePreparedSourceEnv, planDeploymentEnvPersistence } from "./env-payload";
import {
mergePreparedSourceEnv,
planDeploymentEnvPersistence,
planMatchedExistingProjectEnvPersistence,
} from "./env-payload";
const row = (
key: string,
@@ -104,7 +108,7 @@ describe("planDeploymentEnvPersistence", () => {
});
});
it("leaves untracked preserved source rows to the server-side source resolver", () => {
it("sends untracked preserved rows as explicit server-side source imports", () => {
const result = planDeploymentEnvPersistence({
projectId: "project-1",
envVars: [
@@ -118,6 +122,7 @@ describe("planDeploymentEnvPersistence", () => {
ok: true,
merge: { upserts: [], deletes: [] },
buildAccessEnvVars: undefined,
sourceEnvKeys: ["SOURCE_DEFAULT"],
});
});
@@ -131,3 +136,104 @@ describe("planDeploymentEnvPersistence", () => {
expect(result).toMatchObject({ ok: false });
});
});
describe("planMatchedExistingProjectEnvPersistence", () => {
it("preserves saved rows omitted by a wizard that ensure matched to an existing project", () => {
const result = planMatchedExistingProjectEnvPersistence({
envVars: [row("NEW_SETTING", "new")],
persisted: {
rows: [
row("AUTH_SECRET", "", {
originalKey: "AUTH_SECRET",
preserveValue: true,
isSecret: true,
}),
row("EXISTING_SETTING", "keep", {
originalKey: "EXISTING_SETTING",
isSecret: false,
}),
],
baseline: [saved("AUTH_SECRET", ENV_MASK, true), saved("EXISTING_SETTING", "keep")],
},
});
expect(result).toEqual({
ok: true,
merge: {
upserts: [{ key: "NEW_SETTING", value: "new", isSecret: false }],
deletes: [],
},
buildAccessEnvVars: undefined,
});
});
it("adopts a matching saved key and preserves an unreadable secret", () => {
const result = planMatchedExistingProjectEnvPersistence({
envVars: [row("AUTH_SECRET", ENV_MASK, { preserveValue: true })],
persisted: {
rows: [
row("AUTH_SECRET", "", {
originalKey: "AUTH_SECRET",
preserveValue: true,
isSecret: true,
}),
],
baseline: [saved("AUTH_SECRET", ENV_MASK, true)],
},
});
expect(result).toEqual({
ok: true,
merge: { upserts: [], deletes: [] },
buildAccessEnvVars: undefined,
});
});
it("applies an explicitly entered replacement to a matching saved secret", () => {
const result = planMatchedExistingProjectEnvPersistence({
envVars: [row("AUTH_SECRET", "replacement")],
persisted: {
rows: [
row("AUTH_SECRET", "", {
originalKey: "AUTH_SECRET",
preserveValue: true,
isSecret: true,
}),
],
baseline: [saved("AUTH_SECRET", ENV_MASK, true)],
},
});
expect(result).toEqual({
ok: true,
merge: {
upserts: [{ key: "AUTH_SECRET", value: "replacement", isSecret: true }],
deletes: [],
},
buildAccessEnvVars: undefined,
});
});
it("keeps an untracked masked source selection without replacing saved rows", () => {
const result = planMatchedExistingProjectEnvPersistence({
envVars: [row("IMPORTED_FROM_DOTENV", ENV_MASK, { preserveValue: true })],
persisted: {
rows: [
row("AUTH_SECRET", "", {
originalKey: "AUTH_SECRET",
preserveValue: true,
isSecret: true,
}),
],
baseline: [saved("AUTH_SECRET", ENV_MASK, true)],
},
});
expect(result).toEqual({
ok: true,
merge: { upserts: [], deletes: [] },
buildAccessEnvVars: undefined,
sourceEnvKeys: ["IMPORTED_FROM_DOTENV"],
});
});
});
@@ -4,6 +4,7 @@ import {
computeProjectEnvDiff,
serializeNewProjectEnv,
type PersistedProjectEnv,
type ProjectEnvEditState,
type ProjectEnvDiff,
} from "@/lib/project-env-diff";
@@ -47,8 +48,28 @@ export type DeploymentEnvPersistencePlan =
merge: ProjectEnvDiff | null;
/** Only a new project sends env through build/access. */
buildAccessEnvVars: Record<string, string> | undefined;
/** Masked source rows explicitly selected for server-side import. */
sourceEnvKeys?: string[];
};
function selectedSourceEnvKeys(
rows: readonly EnvironmentVariable[],
baseline: readonly PersistedProjectEnv[],
): string[] {
const baselineKeys = new Set(baseline.map((row) => row.key));
const selected = new Set<string>();
for (const row of rows) {
const key = row.key.trim();
const tracksSavedValue = row.originalKey !== undefined && baselineKeys.has(row.originalKey);
if (key && !tracksSavedValue && (row.preserveValue || isMaskedValue(row.value))) {
selected.add(key);
}
}
return [...selected];
}
/**
* Decide which persistence path owns deployment-wizard env changes.
*
@@ -86,5 +107,63 @@ export function planDeploymentEnvPersistence({
// scan. They intentionally have no originalKey/project-env baseline row.
ignoreUntrackedPreserved: true,
});
return result.ok ? { ok: true, merge: result.diff, buildAccessEnvVars: undefined } : result;
if (!result.ok) return result;
const sourceEnvKeys = selectedSourceEnvKeys(envVars, baseline);
return {
ok: true,
merge: result.diff,
buildAccessEnvVars: undefined,
...(sourceEnvKeys.length > 0 ? { sourceEnvKeys } : {}),
};
}
/**
* `projects/ensure` can resolve a nominally new wizard to an existing project
* (for example, when the same repository/branch is opened from the library).
* Those wizard rows were never loaded from that project's env store, so their
* absence cannot mean "delete". Adopt matching keys, retain every unseen saved
* row, and then use the ordinary diff engine. This keeps the merge semantics in
* one place while preventing ensure's name-based de-duplication from turning a
* first-deploy payload into a destructive replacement.
*/
export function planMatchedExistingProjectEnvPersistence({
envVars,
persisted,
}: {
envVars: readonly EnvironmentVariable[];
persisted: ProjectEnvEditState;
}): DeploymentEnvPersistencePlan {
const baselineByKey = new Map(persisted.baseline.map((row) => [row.key, row]));
const submittedKeys = new Set<string>();
const adoptedRows = envVars.map((row) => {
const key = row.key.trim();
if (key) submittedKeys.add(key);
const original = baselineByKey.get(key);
return {
...row,
// Do not trust an originalKey carried by stale wizard state. The env
// snapshot fetched for the project ensure actually returned is the only
// baseline this merge is allowed to address.
originalKey: original?.key,
isSecret: row.isSecret ?? original?.isSecret,
};
});
const retainedRows = persisted.rows.filter((row) => !submittedKeys.has(row.key));
const reconciledRows = [...adoptedRows, ...retainedRows];
const result = computeProjectEnvDiff(reconciledRows, persisted.baseline, {
ignoreUntrackedPreserved: true,
});
if (!result.ok) return result;
const sourceEnvKeys = selectedSourceEnvKeys(reconciledRows, persisted.baseline);
return {
ok: true,
merge: result.diff,
buildAccessEnvVars: undefined,
...(sourceEnvKeys.length > 0 ? { sourceEnvKeys } : {}),
};
}
@@ -0,0 +1,70 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const buildSource = readFileSync(new URL("./useDeploymentBuild.tsx", import.meta.url), "utf8");
const configSource = readFileSync(new URL("./useDeploymentConfig.ts", import.meta.url), "utf8");
function section(source: string, start: string, end: string): string {
const startIndex = source.indexOf(start);
const endIndex = source.indexOf(end, startIndex);
expect(startIndex).toBeGreaterThan(-1);
expect(endIndex).toBeGreaterThan(startIndex);
return source.slice(startIndex, endIndex);
}
describe("project env persistence wiring", () => {
it("persists the env diff before reporting a config-only save as successful", () => {
const saveOnly = section(buildSource, "if (saveConfigOnly)", "const isServiceDeployment");
const mergeIndex = saveOnly.indexOf("persistProjectEnvDiff");
const successIndex = saveOnly.indexOf('showToast("Configuration saved"');
expect(mergeIndex).toBeGreaterThan(-1);
expect(successIndex).toBeGreaterThan(mergeIndex);
});
it("persists an existing-project merge before build/access and omits its full payload", () => {
const deploy = section(
buildSource,
"let resolvedEnvPlan = envPlan",
"if (data.success && data.deployment_id)",
);
const mergeIndex = deploy.indexOf("persistProjectEnvDiff");
const buildIndex = deploy.indexOf("deployApi.buildAccess");
expect(mergeIndex).toBeGreaterThan(-1);
expect(buildIndex).toBeGreaterThan(mergeIndex);
expect(deploy).toContain("envVars: resolvedEnvPlan.buildAccessEnvVars");
expect(deploy).toContain("sourceEnvKeys: resolvedEnvPlan.sourceEnvKeys");
});
it("re-reads env when ensure unexpectedly matches an existing project", () => {
const matched = section(
buildSource,
"if (!config.projectId && projectData.created !== true)",
"// Existing-project env is authoritative",
);
expect(matched).toContain("projectsApi.getEnv(projectData.project_id)");
expect(matched).toContain("planMatchedExistingProjectEnvPersistence");
});
});
describe("existing-project env hydration wiring", () => {
it.each([
["repository retry", "const initializeFromRepo", "const initializeFromLocal"],
["local retry", "const initializeFromLocal", "const rescanWithComposePath"],
["folder-upload retry", "const initializeFromUpload", "const initializeFromProject"],
["saved-project edit", "const initializeFromProject", "return {"],
])("loads env with project metadata for %s", (_name, start, end) => {
expect(section(configSource, start, end)).toContain("loadPersistedProjectState");
});
it("keeps unsaved env edits during a compose-path rescan", () => {
const rescan = section(
configSource,
"const rescanWithComposePath",
"const initializeFromUpload",
);
expect(rescan.match(/preserveEnvState: true/g)).toHaveLength(2);
});
});
+13 -2
View File
@@ -899,11 +899,22 @@ export interface DeploymentContextType {
owner: string,
repo: string,
force?: string,
context?: { branch?: string; projectId?: string; composePath?: string },
context?: {
branch?: string;
projectId?: string;
composePath?: string;
env?: Record<string, string>;
preserveEnvState?: boolean;
},
) => Promise<{ success: boolean; error?: string; errorType?: string; buildInProgress?: boolean }>;
initializeFromLocal: (
path: string,
context?: { projectId?: string; composePath?: string },
context?: {
projectId?: string;
composePath?: string;
env?: Record<string, string>;
preserveEnvState?: boolean;
},
) => Promise<{ success: boolean; error?: string; errorType?: string }>;
/**
* Re-run detection pinned to an explicit compose file path (or clear it with
@@ -18,8 +18,11 @@ import { DeployCredentialModal } from "@/components/deployments/DeployCredential
import { useServerGitHubConnectModal } from "@/components/github/ServerGitHubConnect";
import type { DeploymentConfig, DeploymentState, DeploymentStatus, ServiceDeployStatus } from "./types";
import { syncActiveModeSnapshot } from "./mode-config";
import { planDeploymentEnvPersistence } from "./env-payload";
import type { ProjectEnvDiff } from "@/lib/project-env-diff";
import {
planDeploymentEnvPersistence,
planMatchedExistingProjectEnvPersistence,
} from "./env-payload";
import { createProjectEnvEditState, type ProjectEnvDiff } from "@/lib/project-env-diff";
import {
BUILD_PHASES,
DEFAULT_CONFIG,
@@ -861,11 +864,29 @@ export function useDeploymentBuild(
// errors but the project row already exists at this point.
ensuredProjectId = projectData.project_id;
let resolvedEnvPlan = envPlan;
if (!config.projectId && projectData.created !== true) {
// `ensure` de-duplicates by project slug/branch. A wizard opened as a
// nominally new repo can therefore resolve to an existing project even
// though it never loaded that project's env. Re-read the authoritative
// store and turn the wizard rows into a non-destructive partial merge:
// submitted values may update matching keys, omitted saved keys remain.
const envRes = await projectsApi.getEnv(projectData.project_id);
const matchedEnvPlan = planMatchedExistingProjectEnvPersistence({
envVars: config.envVars,
persisted: createProjectEnvEditState(envRes?.data ?? []),
});
if (!matchedEnvPlan.ok) {
throw new Error(matchedEnvPlan.error);
}
resolvedEnvPlan = matchedEnvPlan;
}
// Existing-project env is authoritative in its project store. Apply only
// the editor diff before build/access; omitting its envVars payload avoids
// the endpoint's legacy full-replace behavior. New projects still send
// their initial values through build/access, which creates that store.
await persistProjectEnvDiff(projectData.project_id, envPlan.merge);
// the endpoint's legacy full-replace behavior. A genuinely new project
// still sends its initial values through build/access to create the store.
await persistProjectEnvDiff(projectData.project_id, resolvedEnvPlan.merge);
// Step 2: Create deployment with config snapshot + env vars
const data = await deployApi.buildAccess({
@@ -873,7 +894,8 @@ export function useDeploymentBuild(
branch: config.branch || undefined,
// Folder-upload: adopt the uploaded source (workspace or staging dir).
uploadSessionId: config.uploadSessionId || undefined,
envVars: envPlan.buildAccessEnvVars,
envVars: resolvedEnvPlan.buildAccessEnvVars,
sourceEnvKeys: resolvedEnvPlan.sourceEnvKeys,
// "None" routing → explicit [] (no public URL). Must be [], not
// undefined: undefined makes the backend auto-derive a free subdomain.
publicEndpoints: !isServiceDeployment
@@ -40,7 +40,7 @@ import {
} from "./types";
import { buildSingleModeSnapshot, syncActiveModeSnapshot } from "./mode-config";
import { mergePreparedSourceEnv } from "./env-payload";
import { createProjectEnvEditState } from "@/lib/project-env-diff";
import { createProjectEnvEditState, type ProjectEnvEditState } from "@/lib/project-env-diff";
import { normalizeSubdomain } from "@/utils/subdomain";
import { useDefaultDomainType } from "@/context/CloudContext";
@@ -58,6 +58,46 @@ interface PreparedConfigArgs {
uploadSessionId?: string;
}
interface LoadedProjectState {
project: PersistedProject;
envState: ProjectEnvEditState | null;
}
/**
* Existing-project configuration and env are one hydration boundary. Loading
* project metadata without its env baseline leaves the deploy wizard unable to
* distinguish an empty environment from a failed or unattempted read.
*/
async function loadPersistedProjectState(projectId: string): Promise<LoadedProjectState> {
const projectResponse = await projectsApi.getInfo(projectId);
const project: PersistedProject =
projectResponse?.data?.project ?? projectResponse?.project ?? null;
if (!project) return { project: null, envState: null };
const envResponse = await projectsApi.getEnv(projectId);
return {
project,
envState: createProjectEnvEditState(envResponse?.data ?? []),
};
}
function seedLoadedProjectEnv(
prev: DeploymentConfig,
projectId: string | undefined,
envState: ProjectEnvEditState | null,
preserveCurrent: boolean,
): DeploymentConfig {
if (!projectId || !envState) return prev;
if (preserveCurrent && prev.projectId === projectId && prev.projectEnvBaseline !== null) {
return prev;
}
return {
...prev,
envVars: envState.rows,
projectEnvBaseline: envState.baseline,
};
}
interface PreparedProjectContext {
projectType: DeploymentConfig["projectType"];
serviceDeploymentMode: DeploymentConfig["serviceDeploymentMode"];
@@ -902,6 +942,7 @@ export function useDeploymentConfig() {
projectId?: string;
composePath?: string;
env?: Record<string, string>;
preserveEnvState?: boolean;
},
): Promise<{
success: boolean;
@@ -911,10 +952,12 @@ export function useDeploymentConfig() {
}> => {
try {
let project: PersistedProject = null;
let projectEnvState: ProjectEnvEditState | null = null;
if (context?.projectId) {
const projectResponse = await projectsApi.getInfo(context.projectId);
project = projectResponse?.data?.project ?? projectResponse?.project ?? null;
const loaded = await loadPersistedProjectState(context.projectId);
project = loaded.project;
projectEnvState = loaded.envState;
if (!project) {
return {
@@ -959,15 +1002,23 @@ export function useDeploymentConfig() {
? [selectedBranch, ...branches]
: branches;
setConfig((prev) =>
buildPreparedConfig(prev, {
response,
project,
repoName,
owner: response.repository.owner?.login || sourceOwner,
branch: selectedBranch,
branches: branchOptions,
projectId: context?.projectId,
}),
buildPreparedConfig(
seedLoadedProjectEnv(
prev,
context?.projectId,
projectEnvState,
context?.preserveEnvState === true,
),
{
response,
project,
repoName,
owner: response.repository.owner?.login || sourceOwner,
branch: selectedBranch,
branches: branchOptions,
projectId: context?.projectId,
},
),
);
return { success: true };
@@ -988,14 +1039,24 @@ export function useDeploymentConfig() {
const initializeFromLocal = useCallback(
async (
path: string,
context?: { projectId?: string; composePath?: string; env?: Record<string, string> },
context?: {
projectId?: string;
composePath?: string;
env?: Record<string, string>;
preserveEnvState?: boolean;
},
): Promise<{ success: boolean; error?: string; errorType?: string }> => {
try {
let project: PersistedProject = null;
let projectEnvState: ProjectEnvEditState | null = null;
if (context?.projectId) {
const projectResponse = await projectsApi.getInfo(context.projectId);
project = projectResponse?.data?.project ?? projectResponse?.project ?? null;
const loaded = await loadPersistedProjectState(context.projectId);
project = loaded.project;
projectEnvState = loaded.envState;
if (!project) {
return { success: false, error: "Project was not found", errorType: "api_error" };
}
}
const response = await deployApi.prepare({
@@ -1011,16 +1072,24 @@ export function useDeploymentConfig() {
const name = response.repository.name || path.split("/").pop() || "project";
setConfig((prev) =>
buildPreparedConfig(prev, {
response,
project,
repoName: name,
owner: "local",
branch: project?.gitBranch || response.repository.default_branch || "main",
branches: [],
projectId: context?.projectId,
localPath: path,
}),
buildPreparedConfig(
seedLoadedProjectEnv(
prev,
context?.projectId,
projectEnvState,
context?.preserveEnvState === true,
),
{
response,
project,
repoName: name,
owner: "local",
branch: project?.gitBranch || response.repository.default_branch || "main",
branches: [],
projectId: context?.projectId,
localPath: path,
},
),
);
return { success: true };
@@ -1057,6 +1126,7 @@ export function useDeploymentConfig() {
return initializeFromLocal(config.localPath, {
projectId: config.projectId,
composePath: trimmed,
preserveEnvState: true,
...env,
});
}
@@ -1071,6 +1141,7 @@ export function useDeploymentConfig() {
branch: config.branch,
projectId: config.projectId,
composePath: trimmed,
preserveEnvState: true,
...env,
});
return { success: result.success, error: result.error, errorType: result.errorType };
@@ -1099,9 +1170,14 @@ export function useDeploymentConfig() {
): Promise<{ success: boolean; error?: string; errorType?: string }> => {
try {
let project: PersistedProject = null;
let projectEnvState: ProjectEnvEditState | null = null;
if (context?.projectId) {
const projectResponse = await projectsApi.getInfo(context.projectId);
project = projectResponse?.data?.project ?? projectResponse?.project ?? null;
const loaded = await loadPersistedProjectState(context.projectId);
project = loaded.project;
projectEnvState = loaded.envState;
if (!project) {
return { success: false, error: "Project was not found", errorType: "api_error" };
}
}
// The upload wizard has the user pick the stack up front (like the
@@ -1181,16 +1257,19 @@ export function useDeploymentConfig() {
}
setConfig((prev) =>
buildPreparedConfig(prev, {
response,
project,
repoName: name,
owner: "upload",
branch: "main",
branches: [],
projectId: context?.projectId,
uploadSessionId: sessionId,
}),
buildPreparedConfig(
seedLoadedProjectEnv(prev, context?.projectId, projectEnvState, false),
{
response,
project,
repoName: name,
owner: "upload",
branch: "main",
branches: [],
projectId: context?.projectId,
uploadSessionId: sessionId,
},
),
);
return { success: true };
@@ -1227,8 +1306,8 @@ export function useDeploymentConfig() {
savedTarget?: DeployTarget | null;
}> => {
try {
const res = await projectsApi.getInfo(projectId);
const project: PersistedProject = res?.data?.project ?? res?.project ?? null;
const loaded = await loadPersistedProjectState(projectId);
const project = loaded.project;
if (!project) {
return { success: false, error: "Project was not found", errorType: "api_error" };
}
@@ -1240,11 +1319,9 @@ export function useDeploymentConfig() {
const svcRes = await servicesApi.list(projectId).catch(() => null);
const serviceRows: Service[] = svcRes?.services ?? [];
// Load editable production env and its immutable baseline as one unit.
// This read is data-loss-sensitive: fail the whole initialization if it
// fails, rather than treating an unknown environment as an empty one.
const envRes = await projectsApi.getEnv(projectId);
const envState = createProjectEnvEditState(envRes?.data ?? []);
// The shared loader makes this a data-loss-sensitive boundary: a failed
// env read fails initialization rather than masquerading as an empty env.
const envState = loaded.envState!;
const response = buildSavedProjectResponse(project, serviceRows);
const repoName = project.gitRepo || project.name || "project";
+2
View File
@@ -335,6 +335,8 @@ export const deployApi = {
branch?: string;
environment?: string;
envVars?: Record<string, string>;
/** Masked root .env rows explicitly selected for trusted server-side import. */
sourceEnvKeys?: string[];
publicEndpoints?: Array<{
port?: string;
targetPath?: string;