mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-28 01:05:25 +02:00
impl
This commit is contained in:
@@ -32,5 +32,6 @@ export const paths = (isServer = false) => {
|
||||
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
|
||||
VOLUME_BACKUPS_PATH: `${BASE_PATH}/volume-backups`,
|
||||
VOLUME_BACKUP_LOCK_PATH: `${BASE_PATH}/volume-backup-lock`,
|
||||
PATCH_REPOS_PATH: `${BASE_PATH}/patch-repos`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import { gitea } from "./gitea";
|
||||
import { github } from "./github";
|
||||
import { gitlab } from "./gitlab";
|
||||
import { mounts } from "./mount";
|
||||
import { patch } from "./patch";
|
||||
import { ports } from "./port";
|
||||
import { previewDeployments } from "./preview-deployments";
|
||||
import { redirects } from "./redirects";
|
||||
@@ -286,6 +287,7 @@ export const applicationsRelations = relations(
|
||||
references: [registry.registryId],
|
||||
relationName: "applicationRollbackRegistry",
|
||||
}),
|
||||
patches: many(patch),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { gitea } from "./gitea";
|
||||
import { github } from "./github";
|
||||
import { gitlab } from "./gitlab";
|
||||
import { mounts } from "./mount";
|
||||
import { patch } from "./patch";
|
||||
import { schedules } from "./schedule";
|
||||
import { server } from "./server";
|
||||
import { applicationStatus, triggerType } from "./shared";
|
||||
@@ -143,6 +144,7 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
|
||||
}),
|
||||
backups: many(backups),
|
||||
schedules: many(schedules),
|
||||
patches: many(patch),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(compose, {
|
||||
|
||||
@@ -18,6 +18,7 @@ export * from "./mongo";
|
||||
export * from "./mount";
|
||||
export * from "./mysql";
|
||||
export * from "./notification";
|
||||
export * from "./patch";
|
||||
export * from "./port";
|
||||
export * from "./postgres";
|
||||
export * from "./preview-deployments";
|
||||
|
||||
95
packages/server/src/db/schema/patch.ts
Normal file
95
packages/server/src/db/schema/patch.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { boolean, pgTable, text, unique } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { applications } from "./application";
|
||||
import { compose } from "./compose";
|
||||
|
||||
export const patch = pgTable(
|
||||
"patch",
|
||||
{
|
||||
patchId: text("patchId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
filePath: text("filePath").notNull(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
content: text("content").notNull(),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
updatedAt: text("updatedAt").$defaultFn(() => new Date().toISOString()),
|
||||
// Relations - one of these must be set
|
||||
applicationId: text("applicationId").references(
|
||||
() => applications.applicationId,
|
||||
{ onDelete: "cascade" },
|
||||
),
|
||||
composeId: text("composeId").references(() => compose.composeId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
},
|
||||
(table) => [
|
||||
// Unique constraint: one patch per file per application/compose
|
||||
unique("patch_filepath_application_unique").on(
|
||||
table.filePath,
|
||||
table.applicationId,
|
||||
),
|
||||
unique("patch_filepath_compose_unique").on(table.filePath, table.composeId),
|
||||
],
|
||||
);
|
||||
|
||||
export const patchRelations = relations(patch, ({ one }) => ({
|
||||
application: one(applications, {
|
||||
fields: [patch.applicationId],
|
||||
references: [applications.applicationId],
|
||||
}),
|
||||
compose: one(compose, {
|
||||
fields: [patch.composeId],
|
||||
references: [compose.composeId],
|
||||
}),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(patch, {
|
||||
filePath: z.string().min(1),
|
||||
content: z.string(),
|
||||
enabled: z.boolean().optional(),
|
||||
applicationId: z.string().optional(),
|
||||
composeId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const apiCreatePatch = createSchema.pick({
|
||||
filePath: true,
|
||||
content: true,
|
||||
enabled: true,
|
||||
applicationId: true,
|
||||
composeId: true,
|
||||
});
|
||||
|
||||
export const apiFindPatch = z.object({
|
||||
patchId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiFindPatchesByApplicationId = z.object({
|
||||
applicationId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiFindPatchesByComposeId = z.object({
|
||||
composeId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiUpdatePatch = createSchema
|
||||
.partial()
|
||||
.extend({
|
||||
patchId: z.string().min(1),
|
||||
})
|
||||
.omit({ applicationId: true, composeId: true });
|
||||
|
||||
export const apiDeletePatch = z.object({
|
||||
patchId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiTogglePatchEnabled = z.object({
|
||||
patchId: z.string().min(1),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
@@ -27,6 +27,8 @@ export * from "./services/mongo";
|
||||
export * from "./services/mount";
|
||||
export * from "./services/mysql";
|
||||
export * from "./services/notification";
|
||||
export * from "./services/patch";
|
||||
export * from "./services/patch-repo";
|
||||
export * from "./services/port";
|
||||
export * from "./services/postgres";
|
||||
export * from "./services/preview-deployment";
|
||||
|
||||
@@ -44,6 +44,10 @@ import {
|
||||
issueCommentExists,
|
||||
updateIssueComment,
|
||||
} from "./github";
|
||||
import {
|
||||
findPatchesByApplicationId,
|
||||
generateApplyPatchesCommand,
|
||||
} from "./patch";
|
||||
import {
|
||||
findPreviewDeploymentById,
|
||||
updatePreviewDeployment,
|
||||
@@ -202,6 +206,20 @@ export const deployApplication = async ({
|
||||
command += await buildRemoteDocker(application);
|
||||
}
|
||||
|
||||
// Apply patches after cloning (for non-docker sources only)
|
||||
if (application.sourceType !== "docker") {
|
||||
const patches = await findPatchesByApplicationId(application.applicationId);
|
||||
const enabledPatches = patches.filter(p => p.enabled);
|
||||
if (enabledPatches.length > 0) {
|
||||
command += generateApplyPatchesCommand({
|
||||
appName: application.appName,
|
||||
type: "application",
|
||||
serverId,
|
||||
patches: enabledPatches,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
command += await getBuildCommand(application);
|
||||
|
||||
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||
|
||||
@@ -40,6 +40,10 @@ import {
|
||||
updateDeployment,
|
||||
updateDeploymentStatus,
|
||||
} from "./deployment";
|
||||
import {
|
||||
findPatchesByComposeId,
|
||||
generateApplyPatchesCommand,
|
||||
} from "./patch";
|
||||
import { validUniqueServerAppName } from "./project";
|
||||
|
||||
export type Compose = typeof compose.$inferSelect;
|
||||
@@ -248,6 +252,26 @@ export const deployCompose = async ({
|
||||
await execAsync(commandWithLog);
|
||||
}
|
||||
|
||||
// Apply patches after cloning (for non-raw sources only)
|
||||
if (compose.sourceType !== "raw") {
|
||||
const patches = await findPatchesByComposeId(compose.composeId);
|
||||
const enabledPatches = patches.filter(p => p.enabled);
|
||||
if (enabledPatches.length > 0) {
|
||||
const patchCommand = generateApplyPatchesCommand({
|
||||
appName: compose.appName,
|
||||
type: "compose",
|
||||
serverId: compose.serverId,
|
||||
patches: enabledPatches,
|
||||
});
|
||||
const patchCommandWithLog = `(${patchCommand}) >> ${deployment.logPath} 2>&1`;
|
||||
if (compose.serverId) {
|
||||
await execAsyncRemote(compose.serverId, patchCommandWithLog);
|
||||
} else {
|
||||
await execAsync(patchCommandWithLog);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
command = "set -e;";
|
||||
command += await getBuildComposeCommand(entity);
|
||||
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||
|
||||
308
packages/server/src/services/patch-repo.ts
Normal file
308
packages/server/src/services/patch-repo.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import path, { join } from "node:path";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import { findSSHKeyById } from "@dokploy/server/services/ssh-key";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
|
||||
|
||||
interface PatchRepoConfig {
|
||||
appName: string;
|
||||
type: "application" | "compose";
|
||||
gitUrl: string;
|
||||
gitBranch: string;
|
||||
sshKeyId?: string | null;
|
||||
serverId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure patch repo exists and is up-to-date
|
||||
* Returns path to the repo
|
||||
*/
|
||||
export const ensurePatchRepo = async ({
|
||||
appName,
|
||||
type,
|
||||
gitUrl,
|
||||
gitBranch,
|
||||
sshKeyId,
|
||||
serverId,
|
||||
}: PatchRepoConfig): Promise<string> => {
|
||||
const { PATCH_REPOS_PATH, SSH_PATH } = paths(!!serverId);
|
||||
const repoPath = join(PATCH_REPOS_PATH, type, appName);
|
||||
const knownHostsPath = path.join(SSH_PATH, "known_hosts");
|
||||
|
||||
// Check if repo exists
|
||||
const checkCommand = `test -d "${repoPath}/.git" && echo "exists" || echo "not_exists"`;
|
||||
|
||||
let exists = false;
|
||||
if (serverId) {
|
||||
const result = await execAsyncRemote(serverId, checkCommand);
|
||||
exists = result.stdout.trim() === "exists";
|
||||
} else {
|
||||
const result = await execAsync(checkCommand);
|
||||
exists = result.stdout.trim() === "exists";
|
||||
}
|
||||
|
||||
// Setup SSH if needed
|
||||
let sshSetup = "";
|
||||
if (sshKeyId) {
|
||||
const sshKey = await findSSHKeyById(sshKeyId);
|
||||
const temporalKeyPath = "/tmp/patch_repo_id_rsa";
|
||||
sshSetup = `
|
||||
echo "${sshKey.privateKey}" > ${temporalKeyPath};
|
||||
chmod 600 ${temporalKeyPath};
|
||||
export GIT_SSH_COMMAND="ssh -i ${temporalKeyPath} -o UserKnownHostsFile=${knownHostsPath} -o StrictHostKeyChecking=accept-new";
|
||||
`;
|
||||
}
|
||||
|
||||
if (!exists) {
|
||||
// Clone the repo
|
||||
const cloneCommand = `
|
||||
set -e;
|
||||
${sshSetup}
|
||||
mkdir -p "${repoPath}";
|
||||
git clone --branch ${gitBranch} --progress "${gitUrl}" "${repoPath}";
|
||||
echo "Repository cloned successfully";
|
||||
`;
|
||||
|
||||
try {
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, cloneCommand);
|
||||
} else {
|
||||
await execAsync(cloneCommand);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Failed to clone repository: ${error}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Repo exists - check if on correct branch and update
|
||||
const updateCommand = `
|
||||
set -e;
|
||||
cd "${repoPath}";
|
||||
${sshSetup}
|
||||
|
||||
# Fetch all updates including tags
|
||||
git fetch origin --tags --force
|
||||
|
||||
# Checkout the target (branch or tag) - this handles switching branches/tags
|
||||
git checkout --force "${gitBranch}"
|
||||
|
||||
# If it's a branch that corresponds to a remote branch, hard reset to match remote
|
||||
# This ensures we pull the latest changes for that branch.
|
||||
# If it's a tag, we are already at the correct commit after checkout.
|
||||
if git rev-parse --verify "origin/${gitBranch}" >/dev/null 2>&1; then
|
||||
git reset --hard "origin/${gitBranch}"
|
||||
fi
|
||||
|
||||
echo "Updated repository to ${gitBranch}"
|
||||
`;
|
||||
|
||||
try {
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, updateCommand);
|
||||
} else {
|
||||
await execAsync(updateCommand);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Failed to update repository: ${error}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return repoPath;
|
||||
};
|
||||
|
||||
interface DirectoryEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "directory";
|
||||
children?: DirectoryEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read directory tree of the patch repo
|
||||
*/
|
||||
export const readPatchRepoDirectory = async (
|
||||
repoPath: string,
|
||||
serverId?: string | null,
|
||||
): Promise<DirectoryEntry[]> => {
|
||||
// Use git ls-tree to get tracked files only
|
||||
const command = `cd "${repoPath}" && git ls-tree -r --name-only HEAD`;
|
||||
|
||||
let stdout: string;
|
||||
try {
|
||||
if (serverId) {
|
||||
const result = await execAsyncRemote(serverId, command);
|
||||
stdout = result.stdout;
|
||||
} else {
|
||||
const result = await execAsync(command);
|
||||
stdout = result.stdout;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Failed to read repository: ${error}`,
|
||||
});
|
||||
}
|
||||
|
||||
const files = stdout.trim().split("\n").filter(Boolean);
|
||||
|
||||
// Build tree structure
|
||||
const root: DirectoryEntry[] = [];
|
||||
const dirMap = new Map<string, DirectoryEntry>();
|
||||
|
||||
for (const filePath of files) {
|
||||
const parts = filePath.split("/");
|
||||
let currentPath = "";
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (!part) continue;
|
||||
|
||||
const isFile = i === parts.length - 1;
|
||||
const parentPath = currentPath;
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||
|
||||
if (!dirMap.has(currentPath)) {
|
||||
const entry: DirectoryEntry = {
|
||||
name: part,
|
||||
path: currentPath,
|
||||
type: isFile ? "file" : "directory",
|
||||
children: isFile ? undefined : [],
|
||||
};
|
||||
|
||||
dirMap.set(currentPath, entry);
|
||||
|
||||
if (parentPath) {
|
||||
const parent = dirMap.get(parentPath);
|
||||
parent?.children?.push(entry);
|
||||
} else {
|
||||
root.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
};
|
||||
|
||||
interface ReadFileResult {
|
||||
content: string;
|
||||
patchError?: boolean;
|
||||
patchErrorMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content from patch repo, optionally with patch applied
|
||||
*/
|
||||
export const readPatchRepoFile = async (
|
||||
repoPath: string,
|
||||
filePath: string,
|
||||
patchContent?: string,
|
||||
serverId?: string | null,
|
||||
): Promise<ReadFileResult> => {
|
||||
const fullPath = join(repoPath, filePath);
|
||||
|
||||
// Read original file
|
||||
const command = `cat "${fullPath}" 2>/dev/null || echo "__FILE_NOT_FOUND__"`;
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
if (serverId) {
|
||||
const result = await execAsyncRemote(serverId, command);
|
||||
content = result.stdout;
|
||||
} else {
|
||||
const result = await execAsync(command);
|
||||
content = result.stdout;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `File not found: ${filePath}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (content.trim() === "__FILE_NOT_FOUND__") {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `File not found: ${filePath}`,
|
||||
});
|
||||
}
|
||||
|
||||
// If no patch, return original content
|
||||
if (!patchContent) {
|
||||
return { content };
|
||||
}
|
||||
|
||||
// Try to apply patch
|
||||
const tempDir = `/tmp/patch_apply_${Date.now()}`;
|
||||
const encodedContent = Buffer.from(content).toString("base64");
|
||||
const encodedPatch = Buffer.from(patchContent).toString("base64");
|
||||
|
||||
// We need to recreate the file structure for git apply to work
|
||||
// git diff usually uses paths relative to repo root
|
||||
const applyCommand = `
|
||||
set -e;
|
||||
mkdir -p "${tempDir}";
|
||||
cd "${tempDir}";
|
||||
git init -q;
|
||||
# Create file with correct path
|
||||
mkdir -p "$(dirname "${filePath}")";
|
||||
echo "${encodedContent}" | base64 -d > "${filePath}";
|
||||
# Save patch
|
||||
echo "${encodedPatch}" | base64 -d > "patch.diff";
|
||||
# Apply patch
|
||||
git apply --ignore-space-change --ignore-whitespace patch.diff;
|
||||
# Read result
|
||||
cat "${filePath}";
|
||||
rm -rf "${tempDir}";
|
||||
`;
|
||||
|
||||
try {
|
||||
let patchedContent: string;
|
||||
if (serverId) {
|
||||
const result = await execAsyncRemote(serverId, applyCommand);
|
||||
patchedContent = result.stdout;
|
||||
} else {
|
||||
const result = await execAsync(applyCommand);
|
||||
patchedContent = result.stdout;
|
||||
}
|
||||
return { content: patchedContent };
|
||||
} catch (error) {
|
||||
// Patch failed - return original content with error
|
||||
const cleanupCommand = `rm -rf "${tempDir}" 2>/dev/null || true`;
|
||||
try {
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, cleanupCommand);
|
||||
} else {
|
||||
await execAsync(cleanupCommand);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
patchError: true,
|
||||
patchErrorMessage: `Failed to apply patch: ${error}`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean all patch repos
|
||||
*/
|
||||
export const cleanPatchRepos = async (serverId?: string | null): Promise<void> => {
|
||||
const { PATCH_REPOS_PATH } = paths(!!serverId);
|
||||
|
||||
const command = `rm -rf "${PATCH_REPOS_PATH}"/* 2>/dev/null || true`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
};
|
||||
295
packages/server/src/services/patch.ts
Normal file
295
packages/server/src/services/patch.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { join } from "node:path";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
type apiCreatePatch,
|
||||
patch,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import {
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
|
||||
export type Patch = typeof patch.$inferSelect;
|
||||
|
||||
// CRUD Operations
|
||||
|
||||
export const createPatch = async (input: typeof apiCreatePatch._type) => {
|
||||
if (!input.applicationId && !input.composeId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Either applicationId or composeId must be provided",
|
||||
});
|
||||
}
|
||||
|
||||
const newPatch = await db
|
||||
.insert(patch)
|
||||
.values({
|
||||
...input,
|
||||
content: input.content.endsWith("\n")
|
||||
? input.content
|
||||
: `${input.content}\n`,
|
||||
})
|
||||
.returning()
|
||||
.then((value) => value[0]);
|
||||
|
||||
if (!newPatch) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error creating the patch",
|
||||
});
|
||||
}
|
||||
|
||||
return newPatch;
|
||||
};
|
||||
|
||||
export const findPatchById = async (patchId: string) => {
|
||||
const result = await db.query.patch.findFirst({
|
||||
where: eq(patch.patchId, patchId),
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Patch not found",
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const findPatchesByApplicationId = async (applicationId: string) => {
|
||||
return await db.query.patch.findMany({
|
||||
where: and(
|
||||
eq(patch.applicationId, applicationId),
|
||||
isNotNull(patch.applicationId),
|
||||
),
|
||||
orderBy: (patch, { asc }) => [asc(patch.filePath)],
|
||||
});
|
||||
};
|
||||
|
||||
export const findPatchesByComposeId = async (composeId: string) => {
|
||||
return await db.query.patch.findMany({
|
||||
where: and(eq(patch.composeId, composeId), isNotNull(patch.composeId)),
|
||||
orderBy: (patch, { asc }) => [asc(patch.filePath)],
|
||||
});
|
||||
};
|
||||
|
||||
export const findPatchByFilePath = async (
|
||||
filePath: string,
|
||||
applicationId?: string,
|
||||
composeId?: string,
|
||||
) => {
|
||||
if (applicationId) {
|
||||
return await db.query.patch.findFirst({
|
||||
where: and(
|
||||
eq(patch.filePath, filePath),
|
||||
eq(patch.applicationId, applicationId),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (composeId) {
|
||||
return await db.query.patch.findFirst({
|
||||
where: and(eq(patch.filePath, filePath), eq(patch.composeId, composeId)),
|
||||
});
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const updatePatch = async (
|
||||
patchId: string,
|
||||
data: Partial<Patch>,
|
||||
) => {
|
||||
const result = await db
|
||||
.update(patch)
|
||||
.set({
|
||||
...data,
|
||||
...(data.content && {
|
||||
content: data.content.endsWith("\n")
|
||||
? data.content
|
||||
: `${data.content}\n`,
|
||||
}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(patch.patchId, patchId))
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
};
|
||||
|
||||
export const deletePatch = async (patchId: string) => {
|
||||
const result = await db
|
||||
.delete(patch)
|
||||
.where(eq(patch.patchId, patchId))
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
};
|
||||
|
||||
// Patch Application Functions
|
||||
|
||||
interface ApplyPatchesOptions {
|
||||
appName: string;
|
||||
type: "application" | "compose";
|
||||
serverId: string | null;
|
||||
patches: Patch[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate shell commands to apply patches to cloned repository
|
||||
* Uses git apply to apply unified diff patches
|
||||
*/
|
||||
export const generateApplyPatchesCommand = ({
|
||||
appName,
|
||||
type,
|
||||
patches,
|
||||
serverId,
|
||||
}: ApplyPatchesOptions): string => {
|
||||
if (patches.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const { COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
|
||||
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
|
||||
const codePath = join(basePath, appName, "code");
|
||||
|
||||
let command = `echo "Applying ${patches.length} patch(es)...";`;
|
||||
|
||||
for (const p of patches) {
|
||||
// Create a temporary patch file and apply it
|
||||
const patchFileName = `/tmp/patch_${p.patchId}.patch`;
|
||||
// Escape content for shell - use base64 encoding
|
||||
const encodedContent = Buffer.from(p.content).toString("base64");
|
||||
|
||||
command += `
|
||||
echo "${encodedContent}" | base64 -d > ${patchFileName};
|
||||
cd ${codePath} && git apply --whitespace=fix ${patchFileName} && echo "✅ Applied patch for: ${p.filePath}" || echo "⚠️ Warning: Failed to apply patch for: ${p.filePath}";
|
||||
rm -f ${patchFileName};
|
||||
`;
|
||||
}
|
||||
|
||||
return command;
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply patches during build process
|
||||
*/
|
||||
export const applyPatches = async ({
|
||||
appName,
|
||||
type,
|
||||
serverId,
|
||||
patches,
|
||||
}: ApplyPatchesOptions): Promise<void> => {
|
||||
const enabledPatches = patches.filter((p) => p.enabled);
|
||||
|
||||
if (enabledPatches.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const command = generateApplyPatchesCommand({
|
||||
appName,
|
||||
type,
|
||||
serverId,
|
||||
patches: enabledPatches,
|
||||
});
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
};
|
||||
|
||||
interface GeneratePatchOptions {
|
||||
codePath: string;
|
||||
filePath: string;
|
||||
newContent: string;
|
||||
serverId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a patch from modified file content using git diff
|
||||
*/
|
||||
export const generatePatch = async ({
|
||||
codePath,
|
||||
filePath,
|
||||
newContent,
|
||||
serverId,
|
||||
}: GeneratePatchOptions): Promise<string> => {
|
||||
const fullPath = join(codePath, filePath);
|
||||
|
||||
// Write new content to the file
|
||||
const encodedContent = Buffer.from(newContent).toString("base64");
|
||||
const writeCommand = `echo "${encodedContent}" | base64 -d > "${fullPath}"`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, writeCommand);
|
||||
} else {
|
||||
await execAsync(writeCommand);
|
||||
}
|
||||
|
||||
// Generate diff
|
||||
const diffCommand = `cd "${codePath}" && git diff -- "${filePath}"`;
|
||||
|
||||
let diffResult: string;
|
||||
if (serverId) {
|
||||
const result = await execAsyncRemote(serverId, diffCommand);
|
||||
diffResult = result.stdout;
|
||||
} else {
|
||||
const result = await execAsync(diffCommand);
|
||||
diffResult = result.stdout;
|
||||
}
|
||||
|
||||
// Reset the file to original state
|
||||
const resetCommand = `cd "${codePath}" && git checkout -- "${filePath}"`;
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, resetCommand);
|
||||
} else {
|
||||
await execAsync(resetCommand);
|
||||
}
|
||||
|
||||
return diffResult;
|
||||
};
|
||||
|
||||
interface ApplyPatchToContentOptions {
|
||||
originalContent: string;
|
||||
patchContent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a patch to content in memory (for preview purposes)
|
||||
* Returns the patched content or throws an error if patch fails
|
||||
*/
|
||||
export const applyPatchToContent = async ({
|
||||
originalContent,
|
||||
patchContent,
|
||||
}: ApplyPatchToContentOptions): Promise<string> => {
|
||||
// Create temp files and apply patch
|
||||
const tempDir = "/tmp/patch_preview_" + Date.now();
|
||||
const tempFile = `${tempDir}/file`;
|
||||
const patchFile = `${tempDir}/patch.diff`;
|
||||
|
||||
const encodedOriginal = Buffer.from(originalContent).toString("base64");
|
||||
const encodedPatch = Buffer.from(patchContent).toString("base64");
|
||||
|
||||
const command = `
|
||||
mkdir -p "${tempDir}";
|
||||
echo "${encodedOriginal}" | base64 -d > "${tempFile}";
|
||||
echo "${encodedPatch}" | base64 -d > "${patchFile}";
|
||||
cd "${tempDir}" && patch -p0 < "${patchFile}" 2>/dev/null;
|
||||
cat "${tempFile}";
|
||||
rm -rf "${tempDir}";
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await execAsync(command);
|
||||
return result.stdout;
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Failed to apply patch to content",
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user