diff --git a/apps/dokploy/__test__/env/application-env-upsert.test.ts b/apps/dokploy/__test__/env/application-env-upsert.test.ts new file mode 100644 index 0000000000..c0c564b1fd --- /dev/null +++ b/apps/dokploy/__test__/env/application-env-upsert.test.ts @@ -0,0 +1,124 @@ +import { upsertApplicationEnvironment } from "@dokploy/server/services/application"; +import { getApplicationEnvRevision } from "@dokploy/server/utils/env-upsert"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const dbMocks = vi.hoisted(() => { + const returning = vi.fn(); + const where = vi.fn(() => ({ returning })); + const set = vi.fn(() => ({ where })); + const update = vi.fn(() => ({ set })); + const findFirst = vi.fn(); + + return { + findFirst, + returning, + set, + update, + where, + }; +}); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + applications: { + findFirst: dbMocks.findFirst, + }, + }, + update: dbMocks.update, + }, +})); + +const mockApplication = (env: string | null = null) => ({ + applicationId: "app_1", + env, +}); + +describe("upsertApplicationEnvironment", () => { + beforeEach(() => { + vi.clearAllMocks(); + dbMocks.returning.mockResolvedValue([mockApplication()]); + }); + + it("returns dry run metadata without saving raw values", async () => { + dbMocks.findFirst.mockResolvedValue( + mockApplication("API_URL=https://old.example.com\nREDIS_PASSWORD=old"), + ); + + const result = await upsertApplicationEnvironment({ + applicationId: "app_1", + variables: { + API_URL: "https://api.example.com", + REDIS_PASSWORD: "new", + }, + dryRun: true, + }); + + expect(dbMocks.update).not.toHaveBeenCalled(); + expect(result).toEqual({ + applicationId: "app_1", + changed: true, + revision: getApplicationEnvRevision( + "app_1", + "API_URL=https://old.example.com\nREDIS_PASSWORD=old", + ), + dryRun: true, + variables: [ + { + name: "API_URL", + action: "updated", + secret: false, + }, + { + name: "REDIS_PASSWORD", + action: "updated", + secret: true, + }, + ], + }); + expect(JSON.stringify(result)).not.toContain("new"); + }); + + it("saves only the merged environment when the revision matches", async () => { + const currentEnv = "API_URL=https://old.example.com\nREDIS_PASSWORD=old"; + dbMocks.findFirst.mockResolvedValue(mockApplication(currentEnv)); + + const result = await upsertApplicationEnvironment({ + applicationId: "app_1", + variables: { + API_URL: "https://api.example.com", + REDIS_HOST: "redis-dev", + }, + expectedRevision: getApplicationEnvRevision("app_1", currentEnv), + }); + + expect(dbMocks.set).toHaveBeenCalledWith({ + env: "API_URL=https://api.example.com\nREDIS_PASSWORD=old\nREDIS_HOST=redis-dev", + }); + expect(result.changed).toBe(true); + expect(result.revision).toBe( + getApplicationEnvRevision( + "app_1", + "API_URL=https://api.example.com\nREDIS_PASSWORD=old\nREDIS_HOST=redis-dev", + ), + ); + }); + + it("rejects stale expected revisions without writing", async () => { + dbMocks.findFirst.mockResolvedValue(mockApplication("API_URL=https://old")); + + await expect( + upsertApplicationEnvironment({ + applicationId: "app_1", + variables: { + API_URL: "https://api.example.com", + }, + expectedRevision: "env:stale", + }), + ).rejects.toMatchObject({ + code: "CONFLICT", + message: "Application environment revision does not match", + }); + expect(dbMocks.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/env/upsert.test.ts b/apps/dokploy/__test__/env/upsert.test.ts new file mode 100644 index 0000000000..45b0664fde --- /dev/null +++ b/apps/dokploy/__test__/env/upsert.test.ts @@ -0,0 +1,86 @@ +import { + getApplicationEnvRevision, + isSecretEnvName, + upsertEnvVariables, +} from "@dokploy/server/utils/env-upsert"; +import { describe, expect, it } from "vitest"; + +describe("upsertEnvVariables", () => { + it("updates existing variables and appends new variables without removing existing secrets", () => { + const result = upsertEnvVariables( + `# Existing app env +API_URL=https://old.example.com +REDIS_PASSWORD=secret-value +`, + { + API_URL: "https://api.example.com", + REDIS_HOST: "redis-dev", + REDIS_PASSWORD: "secret-value", + }, + ); + + expect(result.changed).toBe(true); + expect(result.env).toBe(`# Existing app env +API_URL=https://api.example.com +REDIS_PASSWORD=secret-value +REDIS_HOST=redis-dev +`); + expect(result.variables).toEqual([ + { + name: "API_URL", + action: "updated", + secret: false, + }, + { + name: "REDIS_HOST", + action: "created", + secret: false, + }, + { + name: "REDIS_PASSWORD", + action: "unchanged", + secret: true, + }, + ]); + }); + + it("quotes values that would be unsafe as plain dotenv values", () => { + const result = upsertEnvVariables("", { + PLAIN_VALUE: "redis-dev", + SPACED_VALUE: "redis dev", + MULTILINE_VALUE: "first\nsecond", + HASH_VALUE: "value # not a comment", + }); + + expect(result.env).toBe(`PLAIN_VALUE=redis-dev +SPACED_VALUE="redis dev" +MULTILINE_VALUE="first\\nsecond" +HASH_VALUE="value # not a comment"`); + }); + + it("marks common credential names as secret metadata", () => { + expect(isSecretEnvName("REDIS_PASSWORD")).toBe(true); + expect(isSecretEnvName("API_TOKEN")).toBe(true); + expect(isSecretEnvName("PUBLIC_URL")).toBe(false); + }); + + it("creates a stable opaque revision that changes when the env changes", () => { + const firstRevision = getApplicationEnvRevision( + "app_1", + "REDIS_PASSWORD=secret-value", + ); + const sameRevision = getApplicationEnvRevision( + "app_1", + "REDIS_PASSWORD=secret-value", + ); + const nextRevision = getApplicationEnvRevision( + "app_1", + "REDIS_PASSWORD=new-secret-value", + ); + + expect(firstRevision).toBe(sameRevision); + expect(firstRevision).not.toBe(nextRevision); + expect(firstRevision).toMatch(/^env:/); + expect(firstRevision).not.toContain("secret-value"); + }); +}); diff --git a/apps/dokploy/pages/api/[...trpc].ts b/apps/dokploy/pages/api/[...trpc].ts index 83ff9b0503..20b0904a2f 100644 --- a/apps/dokploy/pages/api/[...trpc].ts +++ b/apps/dokploy/pages/api/[...trpc].ts @@ -3,8 +3,24 @@ import { createOpenApiNextHandler } from "@dokploy/trpc-openapi"; import type { NextApiRequest, NextApiResponse } from "next"; import { appRouter } from "@/server/api/root"; import { createTRPCContext } from "@/server/api/trpc"; +import { handleApplicationEnvUpsert } from "./application.env.upsert"; const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const slashPath = Array.isArray(req.query.trpc) + ? req.query.trpc.join("/") + : req.query.trpc; + const dotPath = Array.isArray(req.query.trpc) + ? req.query.trpc.join(".") + : req.query.trpc; + + if ( + slashPath === "application/env/upsert" || + dotPath === "application.env.upsert" + ) { + await handleApplicationEnvUpsert(req, res); + return; + } + const { session, user } = await validateRequest(req); if (!user || !session) { diff --git a/apps/dokploy/pages/api/application.env.upsert.ts b/apps/dokploy/pages/api/application.env.upsert.ts new file mode 100644 index 0000000000..faa60a2a41 --- /dev/null +++ b/apps/dokploy/pages/api/application.env.upsert.ts @@ -0,0 +1,154 @@ +import { + findApplicationById, + IS_CLOUD, + upsertApplicationEnvironment, + validateRequest, +} from "@dokploy/server"; +import { apiUpsertApplicationEnv } from "@dokploy/server/db/schema"; +import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission"; +import { TRPCError } from "@trpc/server"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { ZodError } from "zod"; +import { audit } from "@/server/api/utils/audit"; +import type { DeploymentJob } from "@/server/queues/queue-types"; +import { myQueue } from "@/server/queues/queueSetup"; +import { deploy } from "@/server/utils/deploy"; + +const getErrorStatus = (error: unknown) => { + if (error instanceof ZodError) { + return 400; + } + + if (!(error instanceof TRPCError)) { + return 500; + } + + switch (error.code) { + case "BAD_REQUEST": + return 400; + case "UNAUTHORIZED": + return 403; + case "NOT_FOUND": + return 404; + case "CONFLICT": + return 409; + default: + return 500; + } +}; + +const getErrorMessage = (error: unknown) => { + if (error instanceof ZodError) { + return "Invalid request body"; + } + + if (error instanceof Error) { + return error.message; + } + + return "Internal server error"; +}; + +export const handleApplicationEnvUpsert = async ( + req: NextApiRequest, + res: NextApiResponse, +) => { + if (req.method !== "POST") { + res.setHeader("Allow", "POST"); + res.status(405).json({ message: "Method Not Allowed" }); + return; + } + + try { + const { session, user } = await validateRequest(req); + + if (!user || !session) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const ctx = { + session: { + ...session, + activeOrganizationId: session.activeOrganizationId || "", + }, + user: { + ...user, + role: user.role as "owner" | "member" | "admin", + }, + }; + + const input = apiUpsertApplicationEnv.parse(req.body); + + await checkServicePermissionAndAccess( + ctx, + input.applicationId, + input.redeploy + ? { + envVars: ["write"], + deployment: ["create"], + } + : { + envVars: ["write"], + }, + ); + + const result = await upsertApplicationEnvironment(input); + let redeployed = false; + + if (!result.dryRun && result.changed) { + const application = await findApplicationById(input.applicationId); + + await audit(ctx, { + action: "update", + resourceType: "application", + resourceId: application.applicationId, + resourceName: application.appName, + }); + + if (input.redeploy) { + const jobData: DeploymentJob = { + applicationId: input.applicationId, + titleLog: "Rebuild deployment", + descriptionLog: "Environment variables updated", + type: "redeploy", + applicationType: "application", + server: !!application.serverId, + }; + + if (IS_CLOUD && application.serverId) { + jobData.serverId = application.serverId; + deploy(jobData).catch((error) => { + console.error("Background deployment failed:", error); + }); + } else { + await myQueue.add( + "deployments", + { ...jobData }, + { + removeOnComplete: true, + removeOnFail: true, + }, + ); + } + + await audit(ctx, { + action: "rebuild", + resourceType: "application", + resourceId: application.applicationId, + resourceName: application.appName, + }); + redeployed = true; + } + } + + res.status(200).json({ + ...result, + redeployed, + }); + } catch (error) { + res.status(getErrorStatus(error)).json({ message: getErrorMessage(error) }); + } +}; + +export default handleApplicationEnvUpsert; diff --git a/apps/dokploy/pages/api/application/env/upsert.ts b/apps/dokploy/pages/api/application/env/upsert.ts new file mode 100644 index 0000000000..42f68c2aa5 --- /dev/null +++ b/apps/dokploy/pages/api/application/env/upsert.ts @@ -0,0 +1,4 @@ +export { + default, + handleApplicationEnvUpsert, +} from "../../application.env.upsert"; diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index de847a3014..6b39ffa065 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -26,6 +26,7 @@ import { updateApplication, updateApplicationStatus, updateDeploymentStatus, + upsertApplicationEnvironment, writeConfig, writeConfigRemote, } from "@dokploy/server"; @@ -64,6 +65,8 @@ import { apiSaveGitlabProvider, apiSaveGitProvider, apiUpdateApplication, + apiUpsertApplicationEnv, + apiUpsertApplicationEnvResponse, applications, environments, projects, @@ -369,6 +372,85 @@ export const applicationRouter = createTRPCRouter({ resourceName: application.appName, }); }), + env: createTRPCRouter({ + upsert: protectedProcedure + .meta({ + openapi: { + path: "/application/env/upsert", + method: "POST", + }, + }) + .input(apiUpsertApplicationEnv) + .output(apiUpsertApplicationEnvResponse) + .mutation(async ({ input, ctx }) => { + await checkServicePermissionAndAccess( + ctx, + input.applicationId, + input.redeploy + ? { + envVars: ["write"], + deployment: ["create"], + } + : { + envVars: ["write"], + }, + ); + + const result = await upsertApplicationEnvironment(input); + let redeployed = false; + + if (!result.dryRun && result.changed) { + const application = await findApplicationById(input.applicationId); + + await audit(ctx, { + action: "update", + resourceType: "application", + resourceId: application.applicationId, + resourceName: application.appName, + }); + + if (input.redeploy) { + const jobData: DeploymentJob = { + applicationId: input.applicationId, + titleLog: "Rebuild deployment", + descriptionLog: "Environment variables updated", + type: "redeploy", + applicationType: "application", + server: !!application.serverId, + }; + + if (IS_CLOUD && application.serverId) { + jobData.serverId = application.serverId; + deploy(jobData).catch((error) => { + console.error("Background deployment failed:", error); + }); + } else { + await myQueue.add( + "deployments", + { ...jobData }, + { + removeOnComplete: true, + removeOnFail: true, + }, + ); + } + + await audit(ctx, { + action: "rebuild", + resourceType: "application", + resourceId: application.applicationId, + resourceName: application.appName, + }); + redeployed = true; + } + } + + return { + ...result, + redeployed, + }; + }), + }), saveEnvironment: protectedProcedure .input(apiSaveEnvironmentVariables) .mutation(async ({ input, ctx }) => { diff --git a/apps/dokploy/server/server.ts b/apps/dokploy/server/server.ts index 4de4d76897..4222ec9dc4 100644 --- a/apps/dokploy/server/server.ts +++ b/apps/dokploy/server/server.ts @@ -1,3 +1,4 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; import http from "node:http"; import { createDefaultMiddlewares, @@ -14,8 +15,10 @@ import { setupDirectories, } from "@dokploy/server"; import { config } from "dotenv"; +import type { NextApiRequest, NextApiResponse } from "next"; import next from "next"; import packageInfo from "../package.json"; +import { handleApplicationEnvUpsert } from "../pages/api/application.env.upsert"; import { setupDockerContainerLogsWebSocketServer } from "./wss/docker-container-logs"; import { setupDockerContainerTerminalWebSocketServer } from "./wss/docker-container-terminal"; import { setupDockerStatsMonitoringSocketServer } from "./wss/docker-stats"; @@ -39,10 +42,115 @@ if (process.env.NODE_ENV === "production" && !IS_CLOUD) { const app = next({ dev, turbopack: process.env.TURBOPACK === "1" }); const handle = app.getRequestHandler(); + +type NodeNextApiResponse = ServerResponse & { + status: (code: number) => NodeNextApiResponse; + json: (body: unknown) => NodeNextApiResponse; +}; + +const normalizeRequestPath = (value: string) => { + try { + return new URL(value, "http://localhost").pathname.replace(/\/+$/, ""); + } catch { + return ""; + } +}; + +const isApplicationEnvUpsertPath = (value: string) => { + const pathname = normalizeRequestPath(value); + + return ( + pathname === "/api/application/env/upsert" || + pathname === "/api/application.env.upsert" + ); +}; + +const getForwardedUriValues = (req: IncomingMessage) => { + const forwardedUri = req.headers["x-forwarded-uri"]; + + if (Array.isArray(forwardedUri)) { + return forwardedUri; + } + + return typeof forwardedUri === "string" ? [forwardedUri] : []; +}; + +const isApplicationEnvUpsertRequest = (req: IncomingMessage) => + [req.url ?? "/", ...getForwardedUriValues(req)].some((path) => + isApplicationEnvUpsertPath(path), + ); + +const readJsonBody = async (req: IncomingMessage) => + new Promise((resolve, reject) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + if (!body) { + resolve({}); + return; + } + + try { + resolve(JSON.parse(body)); + } catch (error) { + reject(error); + } + }); + req.on("error", reject); + }); + +const handleApplicationEnvUpsertRequest = async ( + req: IncomingMessage, + res: ServerResponse, +) => { + try { + (req as IncomingMessage & { body: unknown }).body = await readJsonBody(req); + } catch { + res.statusCode = 400; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ message: "Invalid request body" })); + return; + } + + const nextResponse = res as NodeNextApiResponse; + + nextResponse.status = (code: number) => { + res.statusCode = code; + return nextResponse; + }; + nextResponse.json = (body: unknown) => { + if (!res.headersSent) { + res.setHeader("Content-Type", "application/json"); + } + res.end(JSON.stringify(body)); + return nextResponse; + }; + + try { + await handleApplicationEnvUpsert( + req as NextApiRequest, + nextResponse as unknown as NextApiResponse, + ); + } catch { + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + } + res.end(JSON.stringify({ message: "Internal server error" })); + } +}; + void app.prepare().then(async () => { try { console.log("Running DokployVersion: ", packageInfo.version); const server = http.createServer((req, res) => { + if (isApplicationEnvUpsertRequest(req)) { + void handleApplicationEnvUpsertRequest(req, res); + return; + } + handle(req, res); }); diff --git a/packages/server/src/db/schema/application.ts b/packages/server/src/db/schema/application.ts index 59dfd37161..afcf6275fd 100644 --- a/packages/server/src/db/schema/application.ts +++ b/packages/server/src/db/schema/application.ts @@ -530,6 +530,43 @@ export const apiSaveEnvironmentVariables = createSchema }) .required(); +const ENV_VARIABLE_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export const apiUpsertApplicationEnv = z.object({ + applicationId: z.string().min(1), + variables: z + .record( + z + .string() + .regex( + ENV_VARIABLE_NAME_REGEX, + "Environment variable names must start with a letter or underscore and contain only letters, numbers, and underscores", + ), + z.string(), + ) + .refine((variables) => Object.keys(variables).length > 0, { + message: "At least one environment variable is required", + }), + redeploy: z.boolean().optional(), + dryRun: z.boolean().optional(), + expectedRevision: z.string().optional(), +}); + +export const apiUpsertApplicationEnvResponse = z.object({ + applicationId: z.string(), + changed: z.boolean(), + revision: z.string(), + dryRun: z.boolean(), + redeployed: z.boolean(), + variables: z.array( + z.object({ + name: z.string(), + action: z.enum(["created", "updated", "unchanged"]), + secret: z.boolean(), + }), + ), +}); + export const apiFindMonitoringStats = z.object({ appName: z.string().min(1), }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7bda4615ad..4ecbd1a950 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -100,6 +100,7 @@ export * from "./utils/docker/compose/volume"; export * from "./utils/docker/domain"; export * from "./utils/docker/types"; export * from "./utils/docker/utils"; +export * from "./utils/env-upsert"; export * from "./utils/filesystem/directory"; export * from "./utils/filesystem/ssh"; export * from "./utils/git-branch-validation"; diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index c8ebb3be77..40a6398961 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -2,6 +2,7 @@ import { docker } from "@dokploy/server/constants"; import { db } from "@dokploy/server/db"; import { type apiCreateApplication, + type apiUpsertApplicationEnv, applications, buildAppName, } from "@dokploy/server/db/schema"; @@ -31,6 +32,10 @@ import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import type { z } from "zod"; import { encodeBase64 } from "../utils/docker/utils"; +import { + getApplicationEnvRevision, + upsertEnvVariables, +} from "../utils/env-upsert"; import { getDokployUrl } from "./admin"; import { createDeployment, @@ -146,6 +151,43 @@ export const updateApplication = async ( return application[0]; }; +export const upsertApplicationEnvironment = async ( + input: z.infer, +) => { + const application = await findApplicationById(input.applicationId); + const currentEnv = application.env ?? ""; + const currentRevision = getApplicationEnvRevision( + input.applicationId, + currentEnv, + ); + + if (input.expectedRevision && input.expectedRevision !== currentRevision) { + throw new TRPCError({ + code: "CONFLICT", + message: "Application environment revision does not match", + }); + } + + const result = upsertEnvVariables(currentEnv, input.variables); + const nextRevision = result.changed + ? getApplicationEnvRevision(input.applicationId, result.env) + : currentRevision; + + if (!input.dryRun && result.changed) { + await updateApplication(input.applicationId, { + env: result.env, + }); + } + + return { + applicationId: input.applicationId, + changed: result.changed, + revision: input.dryRun ? currentRevision : nextRevision, + dryRun: input.dryRun ?? false, + variables: result.variables, + }; +}; + export const updateApplicationStatus = async ( applicationId: string, applicationStatus: Application["applicationStatus"], diff --git a/packages/server/src/utils/env-upsert.ts b/packages/server/src/utils/env-upsert.ts new file mode 100644 index 0000000000..8c44ba8a77 --- /dev/null +++ b/packages/server/src/utils/env-upsert.ts @@ -0,0 +1,130 @@ +import { createHmac } from "node:crypto"; +import { parse } from "dotenv"; +import { betterAuthSecret } from "../lib/auth-secret"; + +export type EnvUpsertAction = "created" | "updated" | "unchanged"; + +export type EnvUpsertVariableResult = { + name: string; + action: EnvUpsertAction; + secret: boolean; +}; + +export type EnvUpsertResult = { + env: string; + changed: boolean; + variables: EnvUpsertVariableResult[]; +}; + +const ENV_ASSIGNMENT_REGEX = + /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=\s*)(.*)$/; + +const SECRET_NAME_REGEX = + /(^|_)(AUTH|CREDENTIAL|KEY|PASS|PASSWORD|PRIVATE|SECRET|TOKEN|WEBHOOK)($|_)/i; + +export const isSecretEnvName = (name: string) => SECRET_NAME_REGEX.test(name); + +export const getApplicationEnvRevision = ( + applicationId: string, + env: string | null | undefined, +) => + `env:${createHmac("sha256", betterAuthSecret) + .update(applicationId) + .update("\0") + .update(env ?? "") + .digest("base64url") + .slice(0, 32)}`; + +const serializeEnvValue = (value: string) => { + if (value === "") { + return ""; + } + + if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) { + return value; + } + + return `"${value + .replace(/\\/g, "\\\\") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/"/g, '\\"')}"`; +}; + +const splitEnvLines = (env: string) => { + const normalized = env.replace(/\r\n/g, "\n"); + const trailingNewline = normalized.endsWith("\n"); + const lines = normalized.length > 0 ? normalized.split("\n") : []; + + if (trailingNewline) { + lines.pop(); + } + + return { + lines, + trailingNewline, + }; +}; + +export const upsertEnvVariables = ( + currentEnv: string | null | undefined, + variables: Record, +): EnvUpsertResult => { + const env = currentEnv ?? ""; + const currentValues = parse(env); + const { lines, trailingNewline } = splitEnvLines(env); + const lastLineByName = new Map(); + + lines.forEach((line, index) => { + const match = ENV_ASSIGNMENT_REGEX.exec(line); + if (match) { + lastLineByName.set(match[2]!, index); + } + }); + + let changed = false; + const variableResults: EnvUpsertVariableResult[] = []; + + for (const [name, value] of Object.entries(variables)) { + const currentValue = currentValues[name]; + const action = + currentValue === undefined + ? "created" + : currentValue === value + ? "unchanged" + : "updated"; + + variableResults.push({ + name, + action, + secret: isSecretEnvName(name), + }); + + if (action === "unchanged") { + continue; + } + + changed = true; + const serializedValue = serializeEnvValue(value); + const existingLineIndex = lastLineByName.get(name); + + if (existingLineIndex !== undefined) { + const match = ENV_ASSIGNMENT_REGEX.exec(lines[existingLineIndex]!); + if (match) { + lines[existingLineIndex] = + `${match[1]!}${name}${match[3]!}${serializedValue}`; + } + continue; + } + + lines.push(`${name}=${serializedValue}`); + } + + const nextEnv = lines.join("\n"); + + return { + env: trailingNewline && nextEnv.length > 0 ? `${nextEnv}\n` : nextEnv, + changed, + variables: variableResults, + }; +};