mirror of
https://github.com/oblien/openship.git
synced 2026-10-02 07:44:35 +08:00
fix(dashboard): preserve env across deploy entry paths
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user