Merge pull request #3574 from ivanpadavan/patches-impl

feat: Add patches functionality
This commit is contained in:
Mauricio Siu
2026-02-18 20:19:06 -06:00
committed by GitHub
31 changed files with 10535 additions and 149 deletions

View File

@@ -28,6 +28,9 @@ vi.mock("@dokploy/server/db", () => {
applications: {
findFirst: vi.fn(),
},
patch: {
findMany: vi.fn().mockResolvedValue([]),
},
},
},
};

View File

@@ -29,6 +29,9 @@ vi.mock("@dokploy/server/db", () => {
applications: {
findFirst: vi.fn(),
},
patch: {
findMany: vi.fn().mockResolvedValue([]),
},
},
},
};

View File

@@ -0,0 +1,107 @@
import { FilePlus } from "lucide-react";
import { useState } from "react";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface Props {
folderPath: string;
onCreate: (filename: string, content: string) => void;
onOpenChange: (open: boolean) => void;
alwaysVisible?: boolean;
}
export const CreateFileDialog = ({
folderPath,
onCreate,
onOpenChange,
alwaysVisible = false,
}: Props) => {
const [filename, setFilename] = useState("");
const [content, setContent] = useState("");
const handleCreate = () => {
if (!filename.trim()) return;
onCreate(filename.trim(), content);
setFilename("");
setContent("");
onOpenChange(false);
};
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
type="button"
className={`h-6 w-6 ${alwaysVisible ? "" : "opacity-0 group-hover:opacity-100"}`}
title="Create file"
>
<FilePlus className="h-3 w-3" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<form
onSubmit={(e) => {
e.preventDefault();
handleCreate();
}}
>
<DialogHeader>
<DialogTitle>Create file</DialogTitle>
<DialogDescription>
{folderPath ? `New file in ${folderPath}/` : "New file in root"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="filename">Filename</Label>
<Input
id="filename"
placeholder="e.g. .env.example"
value={filename}
onChange={(e) => setFilename(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Content</Label>
<div className="h-[200px] rounded-md border">
<CodeEditor
value={content}
onChange={(v) => setContent(v ?? "")}
className="h-full"
wrapperClassName="h-[200px]"
lineWrapping
/>
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" type="button">
Cancel
</Button>
</DialogClose>
<DialogClose asChild>
<Button type="submit" disabled={!filename.trim()}>
Create
</Button>
</DialogClose>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,102 @@
import { Loader2, Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
interface Props {
patchId: string;
entityId: string;
type: "application" | "compose";
onSuccess?: () => void;
}
export const EditPatchDialog = ({
patchId,
entityId,
type,
onSuccess,
}: Props) => {
const { data: patch, isLoading: isPatchLoading } = api.patch.one.useQuery(
{ patchId },
{ enabled: !!patchId },
);
const [content, setContent] = useState("");
useEffect(() => {
if (patch) {
setContent(patch.content);
}
}, [patch]);
const utils = api.useUtils();
const updatePatch = api.patch.update.useMutation();
const handleSave = () => {
updatePatch
.mutateAsync({ patchId, content })
.then(() => {
toast.success("Patch saved");
utils.patch.byEntityId.invalidate({ id: entityId, type });
onSuccess?.();
})
.catch((err) => {
toast.error(err.message);
});
};
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" title="Edit patch">
<Pencil className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-4xl max-h-[85vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle>Edit Patch</DialogTitle>
<DialogDescription>
{patch ? `Editing: ${patch.filePath}` : "Loading patch..."}
</DialogDescription>
</DialogHeader>
{isPatchLoading ? (
<div className="flex flex-1 items-center justify-center px-6 py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="flex-1 min-h-0 px-6 overflow-hidden flex flex-col">
<CodeEditor
value={content}
onChange={(value) => setContent(value ?? "")}
className="h-[400px] w-full"
wrapperClassName="h-[400px]"
lineWrapping
/>
</div>
)}
<DialogFooter className="px-6 ">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button onClick={handleSave} isLoading={updatePatch.isLoading}>
{updatePatch.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./show-patches";
export * from "./patch-editor";

View File

@@ -0,0 +1,368 @@
import {
ArrowLeft,
ChevronRight,
File,
Folder,
Loader2,
Save,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { api } from "@/utils/api";
import { CreateFileDialog } from "./create-file-dialog";
interface Props {
id: string;
type: "application" | "compose";
repoPath: string;
onClose: () => void;
}
type DirectoryEntry = {
name: string;
path: string;
type: "file" | "directory";
children?: DirectoryEntry[];
};
export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string>("");
const [createFolderPath, setCreateFolderPath] = useState<string | null>(null);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set(),
);
const utils = api.useUtils();
const { data: directories, isLoading: isDirLoading } =
api.patch.readRepoDirectories.useQuery(
{ id: id, type, repoPath },
{ enabled: !!repoPath },
);
const { data: patches } = api.patch.byEntityId.useQuery(
{ id, type },
{ enabled: !!id },
);
const { mutateAsync: saveAsPatch, isLoading: isSavingPatch } =
api.patch.saveFileAsPatch.useMutation();
const { mutateAsync: markForDeletion, isLoading: isMarkingDeletion } =
api.patch.markFileForDeletion.useMutation();
const updatePatch = api.patch.update.useMutation();
const { data: fileData, isFetching: isFileLoading } =
api.patch.readRepoFile.useQuery(
{
id,
type,
filePath: selectedFile || "",
},
{
enabled: !!selectedFile,
},
);
useEffect(() => {
if (fileData !== undefined) {
setFileContent(fileData);
}
}, [fileData]);
const handleFileSelect = (filePath: string) => {
setSelectedFile(filePath);
};
const toggleFolder = (path: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
};
const handleSave = () => {
if (!selectedFile) return;
saveAsPatch({
id,
type,
filePath: selectedFile,
content: fileContent,
patchType: "update",
})
.then(() => {
toast.success("Patch saved");
utils.patch.byEntityId.invalidate({ id, type });
})
.catch(() => {
toast.error("Failed to save patch");
});
};
const handleMarkForDeletion = () => {
if (!selectedFile) return;
markForDeletion({ id, type, filePath: selectedFile })
.then(() => {
toast.success("File marked for deletion");
utils.patch.byEntityId.invalidate({ id, type });
})
.catch(() => {
toast.error("Failed to mark file for deletion");
});
};
const handleCreateFile = useCallback(
(folderPath: string, filename: string, content: string) => {
const filePath = folderPath ? `${folderPath}/${filename}` : filename;
saveAsPatch({
id,
type,
filePath,
content,
patchType: "create",
})
.then(() => {
toast.success("File created");
utils.patch.byEntityId.invalidate({ id, type });
})
.catch(() => {
toast.error("Failed to create file");
});
},
[id, type, saveAsPatch, utils],
);
const selectedFilePatch = patches?.find(
(p) => p.filePath === selectedFile && p.type === "delete",
);
const handleUnmarkDeletion = () => {
if (!selectedFilePatch) return;
updatePatch
.mutateAsync({
patchId: selectedFilePatch.patchId,
type: "update",
content: fileData || "",
})
.then(() => {
toast.success("Deletion unmarked");
utils.patch.byEntityId.invalidate({ id, type });
})
.catch(() => {
toast.error("Failed to unmark deletion");
});
};
const hasChanges = fileData !== undefined && fileContent !== fileData;
const renderTree = useCallback(
(entries: DirectoryEntry[], depth = 0) => {
return entries
.sort((a, b) => {
// Directories first, then alphabetically
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
})
.map((entry) => {
const isExpanded = expandedFolders.has(entry.path);
const isSelected = selectedFile === entry.path;
if (entry.type === "directory") {
return (
<div key={entry.path}>
<div className="group flex items-center">
<button
type="button"
onClick={() => toggleFolder(entry.path)}
className={
"flex-1 flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors text-left min-w-0"
}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
<ChevronRight
className={`h-4 w-4 shrink-0 transition-transform ${
isExpanded ? "rotate-90" : ""
}`}
/>
<Folder className="h-4 w-4 shrink-0 text-blue-500" />
<span className="truncate">{entry.name}</span>
</button>
<CreateFileDialog
folderPath={entry.path}
onCreate={(filename, content) =>
handleCreateFile(entry.path, filename, content)
}
onOpenChange={(open) =>
setCreateFolderPath(open ? entry.path : null)
}
/>
</div>
{isExpanded && entry.children && (
<div>{renderTree(entry.children, depth + 1)}</div>
)}
</div>
);
}
const isMarkedForDeletion = patches?.some(
(p) => p.filePath === entry.path && p.type === "delete",
);
return (
<button
type="button"
key={entry.path}
onClick={() => handleFileSelect(entry.path)}
className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors ${
isSelected ? "bg-muted" : ""
} ${isMarkedForDeletion ? "text-destructive" : ""}`}
style={{ paddingLeft: `${depth * 12 + 28}px` }}
>
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{entry.name}</span>
{isMarkedForDeletion && (
<Trash2 className="h-3 w-3 shrink-0 text-destructive ml-auto" />
)}
</button>
);
});
},
[expandedFolders, selectedFile, patches, handleCreateFile],
);
return (
<Card className="bg-background overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between pb-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={onClose}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<CardTitle>Edit File</CardTitle>
<CardDescription>
{selectedFile
? `Editing: ${selectedFile}`
: "Select a file from the tree to edit"}
</CardDescription>
</div>
</div>
{selectedFile && (
<div className="flex items-center gap-2">
{selectedFilePatch ? (
<Button
variant="outline"
size="sm"
onClick={handleUnmarkDeletion}
disabled={updatePatch.isPending}
>
{updatePatch.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Unmark deletion
</Button>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={handleMarkForDeletion}
disabled={isMarkingDeletion}
>
{isMarkingDeletion && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Trash2 className="mr-2 h-4 w-4" />
Mark for deletion
</Button>
<Button
onClick={handleSave}
disabled={isSavingPatch || !hasChanges}
>
{isSavingPatch && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Save className="mr-2 h-4 w-4" />
Save Patch
</Button>
</>
)}
</div>
)}
</CardHeader>
<CardContent className="p-0">
<div className="grid grid-cols-[250px_1fr] border-t h-[600px]">
<div className="border-r h-full overflow-hidden">
<ScrollArea className="h-full">
<div className="p-2 space-y-1">
<div className="group flex items-center gap-2 px-2 py-1.5 mb-1">
<CreateFileDialog
folderPath=""
alwaysVisible
onCreate={(filename, content) =>
handleCreateFile("", filename, content)
}
onOpenChange={(open) =>
setCreateFolderPath(open ? "" : null)
}
/>
<span className="text-xs text-muted-foreground">
New file in root
</span>
</div>
{isDirLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : directories ? (
renderTree(directories)
) : (
<div className="text-sm text-muted-foreground p-4">
No files found
</div>
)}
</div>
</ScrollArea>
</div>
<div className="h-full overflow-hidden relative">
{isFileLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : selectedFile ? (
<CodeEditor
value={fileData || ""}
onChange={(value) => setFileContent(value || "")}
className="h-full w-full"
wrapperClassName="h-full"
lineWrapping
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
Select a file to edit
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,225 @@
import { File, FilePlus2, Loader2, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
import { EditPatchDialog } from "./edit-patch-dialog";
import { PatchEditor } from "./patch-editor";
interface Props {
id: string;
type: "application" | "compose";
}
export const ShowPatches = ({ id, type }: Props) => {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [repoPath, setRepoPath] = useState<string | null>(null);
const [isLoadingRepo, setIsLoadingRepo] = useState(false);
const utils = api.useUtils();
const { data: patches, isLoading: isPatchesLoading } =
api.patch.byEntityId.useQuery({ id, type }, { enabled: !!id });
const mutationMap = {
application: () => api.patch.delete.useMutation(),
compose: () => api.patch.delete.useMutation(),
};
const ensureRepo = api.patch.ensureRepo.useMutation();
const togglePatch = api.patch.toggleEnabled.useMutation();
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.patch.delete.useMutation();
const handleCloseEditor = () => {
setSelectedFile(null);
setRepoPath(null);
};
if (repoPath) {
return (
<PatchEditor
id={id}
type={type}
repoPath={repoPath || ""}
onClose={handleCloseEditor}
/>
);
}
const handleOpenEditor = async () => {
setIsLoadingRepo(true);
await ensureRepo
.mutateAsync({ id, type })
.then((result) => {
setRepoPath(result);
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setIsLoadingRepo(false);
});
};
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Patches</CardTitle>
<CardDescription>
Apply code patches to your repository during build. Patches are
applied after cloning the repository and before building.
</CardDescription>
</div>
{patches && patches?.length > 0 && (
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
{isLoadingRepo && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<FilePlus2 className="mr-2 h-4 w-4" />
Create Patch
</Button>
)}
</CardHeader>
<CardContent>
{isPatchesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : patches?.length === 0 ? (
<div className="flex min-h-[40vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<FilePlus2 className="h-10 w-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No patches yet</p>
<p className="max-w-sm text-sm text-muted-foreground">
Add file patches to modify your repo before each buildconfigs,
env, or code. Create your first patch to get started.
</p>
</div>
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
{isLoadingRepo && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<FilePlus2 className="mr-2 h-4 w-4" />
Create Patch
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>File Path</TableHead>
<TableHead className="w-[80px]">Type</TableHead>
<TableHead className="w-[100px]">Enabled</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{patches?.map((patch) => (
<TableRow key={patch.patchId}>
<TableCell className="font-mono text-sm">
<div className="flex items-center gap-2">
<File className="h-4 w-4 text-muted-foreground shrink-0" />
{patch.filePath}
</div>
</TableCell>
<TableCell>
<Badge
variant={
patch.type === "delete"
? "destructive"
: patch.type === "create"
? "default"
: "secondary"
}
className="font-normal"
>
{patch.type}
</Badge>
</TableCell>
<TableCell>
<Switch
checked={patch.enabled}
onCheckedChange={(checked) => {
togglePatch
.mutateAsync({
patchId: patch.patchId,
enabled: checked,
})
.then(() => {
toast.success("Patch updated");
utils.patch.byEntityId.invalidate({
id,
type,
});
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setIsLoadingRepo(false);
});
}}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{(patch.type === "update" || patch.type === "create") && (
<EditPatchDialog
patchId={patch.patchId}
entityId={id}
type={type}
/>
)}
<Button
variant="ghost"
size="icon"
onClick={() => {
mutateAsync({ patchId: patch.patchId })
.then(() => {
toast.success("Patch deleted");
utils.patch.byEntityId.invalidate({
id,
type,
});
})
.catch((err) => {
toast.error(err.message);
});
}}
title="Delete patch"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
};

View File

@@ -40,6 +40,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
isLoading: cleanStoppedContainersIsLoading,
} = api.settings.cleanStoppedContainers.useMutation();
const { mutateAsync: cleanPatchRepos, isLoading: cleanPatchReposIsLoading } =
api.patch.cleanPatchRepos.useMutation();
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -49,7 +52,8 @@ export const ShowStorageActions = ({ serverId }: Props) => {
cleanDockerBuilderIsLoading ||
cleanUnusedImagesIsLoading ||
cleanUnusedVolumesIsLoading ||
cleanStoppedContainersIsLoading
cleanStoppedContainersIsLoading ||
cleanPatchReposIsLoading
}
>
<Button
@@ -58,7 +62,8 @@ export const ShowStorageActions = ({ serverId }: Props) => {
cleanDockerBuilderIsLoading ||
cleanUnusedImagesIsLoading ||
cleanUnusedVolumesIsLoading ||
cleanStoppedContainersIsLoading
cleanStoppedContainersIsLoading ||
cleanPatchReposIsLoading
}
variant="outline"
>
@@ -119,6 +124,23 @@ export const ShowStorageActions = ({ serverId }: Props) => {
<span>Clean stopped containers</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
onClick={async () => {
await cleanPatchRepos({
serverId: serverId,
})
.then(async () => {
toast.success("Cleaned Patch Caches");
})
.catch(() => {
toast.error("Error cleaning Patch Caches");
});
}}
>
<span>Clean Patch Caches</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
onClick={async () => {

View File

@@ -0,0 +1,17 @@
CREATE TYPE "public"."patchType" AS ENUM('create', 'update', 'delete');--> statement-breakpoint
CREATE TABLE "patch" (
"patchId" text PRIMARY KEY NOT NULL,
"type" "patchType" DEFAULT 'update' NOT NULL,
"filePath" text NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"content" text NOT NULL,
"createdAt" text NOT NULL,
"updatedAt" text,
"applicationId" text,
"composeId" text,
CONSTRAINT "patch_filepath_application_unique" UNIQUE("filePath","applicationId"),
CONSTRAINT "patch_filepath_compose_unique" UNIQUE("filePath","composeId")
);
--> statement-breakpoint
ALTER TABLE "patch" ADD CONSTRAINT "patch_applicationId_application_applicationId_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."application"("applicationId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "patch" ADD CONSTRAINT "patch_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -1016,6 +1016,13 @@
"when": 1771297084611,
"tag": "0144_odd_gunslinger",
"breakpoints": true
},
{
"idx": 145,
"version": "7",
"when": 1771447229358,
"tag": "0145_remarkable_titania",
"breakpoints": true
}
]
}

View File

@@ -26,6 +26,7 @@ import { ShowDomains } from "@/components/dashboard/application/domains/show-dom
import { ShowEnvironment } from "@/components/dashboard/application/environment/show";
import { ShowGeneralApplication } from "@/components/dashboard/application/general/show";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
import { ShowPreviewDeployments } from "@/components/dashboard/application/preview-deployments/show-preview-deployments";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { UpdateApplication } from "@/components/dashboard/application/update-application";
@@ -248,6 +249,9 @@ const Service = (
Volume Backups
</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{data?.sourceType !== "docker" && (
<TabsTrigger value="patches">Patches</TabsTrigger>
)}
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)}
@@ -359,6 +363,11 @@ const Service = (
<ShowDomains id={applicationId} type="application" />
</div>
</TabsContent>
<TabsContent value="patches" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowPatches id={applicationId} type="application" />
</div>
</TabsContent>
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<AddCommand applicationId={applicationId} />

View File

@@ -17,6 +17,7 @@ import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes
import { ShowDeployments } from "@/components/dashboard/application/deployments/show-deployments";
import { ShowDomains } from "@/components/dashboard/application/domains/show-domains";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
@@ -237,6 +238,9 @@ const Service = (
Volume Backups
</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{data?.sourceType !== "raw" && (
<TabsTrigger value="patches">Patches</TabsTrigger>
)}
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)}
@@ -361,6 +365,12 @@ const Service = (
</div>
</TabsContent>
<TabsContent value="patches" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowPatches id={composeId} type="compose" />
</div>
</TabsContent>
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<AddCommandCompose composeId={composeId} />

View File

@@ -22,6 +22,7 @@ import { mountRouter } from "./routers/mount";
import { mysqlRouter } from "./routers/mysql";
import { notificationRouter } from "./routers/notification";
import { organizationRouter } from "./routers/organization";
import { patchRouter } from "./routers/patch";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso";
import { portRouter } from "./routers/port";
@@ -90,6 +91,7 @@ export const appRouter = createTRPCRouter({
rollback: rollbackRouter,
volumeBackups: volumeBackupsRouter,
environment: environmentRouter,
patch: patchRouter,
});
// export type definition of API

View File

@@ -0,0 +1,348 @@
import {
checkServiceAccess,
cleanPatchRepos,
createPatch,
deletePatch,
ensurePatchRepo,
findApplicationById,
findComposeById,
findPatchByFilePath,
findPatchById,
findPatchesByEntityId,
markPatchForDeletion,
readPatchRepoDirectory,
readPatchRepoFile,
updatePatch,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
} from "@/server/api/trpc";
import {
apiCreatePatch,
apiDeletePatch,
apiFindPatch,
apiTogglePatchEnabled,
apiUpdatePatch,
} from "@/server/db/schema";
export const patchRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreatePatch)
.mutation(async ({ input, ctx }) => {
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.applicationId,
ctx.session.activeOrganizationId,
"access",
);
}
} else if (input.composeId) {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
}
return await createPatch(input);
}),
one: protectedProcedure.input(apiFindPatch).query(async ({ input }) => {
return await findPatchById(input.patchId);
}),
byEntityId: protectedProcedure
.input(
z.object({ id: z.string(), type: z.enum(["application", "compose"]) }),
)
.query(async ({ input, ctx }) => {
if (input.type === "application") {
const app = await findApplicationById(input.id);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
} else if (input.type === "compose") {
const compose = await findComposeById(input.id);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
}
const result = await findPatchesByEntityId(input.id, input.type);
return result;
}),
update: protectedProcedure
.input(apiUpdatePatch)
.mutation(async ({ input }) => {
const { patchId, ...data } = input;
return await updatePatch(patchId, data);
}),
delete: protectedProcedure
.input(apiDeletePatch)
.mutation(async ({ input }) => {
return await deletePatch(input.patchId);
}),
toggleEnabled: protectedProcedure
.input(apiTogglePatchEnabled)
.mutation(async ({ input }) => {
return await updatePatch(input.patchId, { enabled: input.enabled });
}),
// Repository Operations
ensureRepo: protectedProcedure
.input(
z.object({
id: z.string(),
type: z.enum(["application", "compose"]),
}),
)
.mutation(async ({ input }) => {
return await ensurePatchRepo({
type: input.type,
id: input.id,
});
}),
readRepoDirectories: protectedProcedure
.input(
z.object({
id: z.string().min(1),
type: z.enum(["application", "compose"]),
repoPath: z.string(),
}),
)
.query(async ({ input, ctx }) => {
let serverId: string | null = null;
if (input.type === "application") {
const app = await findApplicationById(input.id);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
serverId = app.serverId;
}
if (input.type === "compose") {
const compose = await findComposeById(input.id);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
serverId = compose.serverId;
}
return await readPatchRepoDirectory(input.repoPath, serverId);
}),
readRepoFile: protectedProcedure
.input(
z.object({
id: z.string().min(1),
type: z.enum(["application", "compose"]),
filePath: z.string(),
}),
)
.query(async ({ input, ctx }) => {
let serverId: string | null = null;
if (input.type === "application") {
const app = await findApplicationById(input.id);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
serverId = app.serverId;
} else if (input.type === "compose") {
const compose = await findComposeById(input.id);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
serverId = compose.serverId;
} else {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
});
}
const existingPatch = await findPatchByFilePath(
input.filePath,
input.id,
input.type,
);
// For delete patches, show current file content from repo (what will be deleted)
if (existingPatch?.type === "delete") {
try {
return await readPatchRepoFile(input.id, input.type, input.filePath);
} catch {
return "(File not found in repo - will be removed if it exists)";
}
}
if (existingPatch?.content) {
return existingPatch.content;
}
return await readPatchRepoFile(input.id, input.type, input.filePath);
}),
saveFileAsPatch: protectedProcedure
.input(
z.object({
id: z.string().min(1),
type: z.enum(["application", "compose"]),
filePath: z.string(),
content: z.string(),
patchType: z.enum(["create", "update"]).default("update"),
}),
)
.mutation(async ({ input, ctx }) => {
if (input.type === "application") {
const app = await findApplicationById(input.id);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
} else if (input.type === "compose") {
const compose = await findComposeById(input.id);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
} else {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either application or compose must be provided",
});
}
const existingPatch = await findPatchByFilePath(
input.filePath,
input.id,
input.type,
);
if (!existingPatch) {
return await createPatch({
filePath: input.filePath,
content: input.content,
type: input.patchType,
applicationId: input.type === "application" ? input.id : undefined,
composeId: input.type === "compose" ? input.id : undefined,
});
}
return await updatePatch(existingPatch.patchId, {
content: input.content,
type: input.patchType,
});
}),
markFileForDeletion: protectedProcedure
.input(
z.object({
id: z.string().min(1),
type: z.enum(["application", "compose"]),
filePath: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
if (input.type === "application") {
const app = await findApplicationById(input.id);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
} else if (input.type === "compose") {
const compose = await findComposeById(input.id);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
}
return await markPatchForDeletion(input.filePath, input.id, input.type);
}),
cleanPatchRepos: adminProcedure
.input(z.object({ serverId: z.string().optional() }))
.mutation(async ({ input }) => {
await cleanPatchRepos(input.serverId);
return true;
}),
});

File diff suppressed because it is too large Load Diff

View File

@@ -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`,
};
};

View File

@@ -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),
}),
);

View File

@@ -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, {

View File

@@ -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";

View File

@@ -0,0 +1,100 @@
import { relations } from "drizzle-orm";
import { boolean, pgEnum, 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 patchType = pgEnum("patchType", ["create", "update", "delete"]);
export const patch = pgTable(
"patch",
{
patchId: text("patchId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
type: patchType("type").notNull().default("update"),
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(),
type: z.enum(["create", "update", "delete"]).optional(),
enabled: z.boolean().optional(),
applicationId: z.string().optional(),
composeId: z.string().optional(),
});
export const apiCreatePatch = createSchema.pick({
filePath: true,
content: true,
type: 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(),
});

View File

@@ -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";

View File

@@ -44,6 +44,7 @@ import {
issueCommentExists,
updateIssueComment,
} from "./github";
import { generateApplyPatchesCommand } from "./patch";
import {
findPreviewDeploymentById,
updatePreviewDeployment,
@@ -202,6 +203,14 @@ export const deployApplication = async ({
command += await buildRemoteDocker(application);
}
if (application.sourceType !== "docker") {
command += await generateApplyPatchesCommand({
id: application.applicationId,
type: "application",
serverId,
});
}
command += await getBuildCommand(application);
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;

View File

@@ -40,6 +40,7 @@ import {
updateDeployment,
updateDeploymentStatus,
} from "./deployment";
import { generateApplyPatchesCommand } from "./patch";
import { validUniqueServerAppName } from "./project";
export type Compose = typeof compose.$inferSelect;
@@ -247,8 +248,15 @@ export const deployCompose = async ({
} else {
await execAsync(commandWithLog);
}
command = "set -e;";
if (compose.sourceType !== "raw") {
command += await generateApplyPatchesCommand({
id: compose.composeId,
type: "compose",
serverId: compose.serverId,
});
}
command += await getBuildComposeCommand(entity);
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {

View File

@@ -0,0 +1,197 @@
import { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import { TRPCError } from "@trpc/server";
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
import { cloneBitbucketRepository } from "../utils/providers/bitbucket";
import { cloneGitRepository } from "../utils/providers/git";
import { cloneGiteaRepository } from "../utils/providers/gitea";
import { cloneGithubRepository } from "../utils/providers/github";
import { cloneGitlabRepository } from "../utils/providers/gitlab";
import { findApplicationById } from "./application";
import { findComposeById } from "./compose";
interface PatchRepoConfig {
type: "application" | "compose";
id: string;
}
/**
* Ensure patch repo exists and is up-to-date
* Returns path to the repo
*/
export const ensurePatchRepo = async ({
type,
id,
}: PatchRepoConfig): Promise<string> => {
let serverId: string | null = null;
if (type === "application") {
const application = await findApplicationById(id);
serverId = application.buildServerId || application.serverId;
} else {
const compose = await findComposeById(id);
serverId = compose.serverId;
}
const application =
type === "application"
? await findApplicationById(id)
: await findComposeById(id);
const { PATCH_REPOS_PATH } = paths(!!serverId);
const repoPath = join(PATCH_REPOS_PATH, type, application.appName);
const applicationEntity = {
...application,
type,
serverId: serverId,
outputPathOverride: repoPath,
};
let command = "set -e;";
if (application.sourceType === "github") {
command += await cloneGithubRepository(applicationEntity);
} else if (application.sourceType === "gitlab") {
command += await cloneGitlabRepository(applicationEntity);
} else if (application.sourceType === "gitea") {
command += await cloneGiteaRepository(applicationEntity);
} else if (application.sourceType === "bitbucket") {
command += await cloneBitbucketRepository(applicationEntity);
} else if (application.sourceType === "git") {
command += await cloneGitRepository(applicationEntity);
}
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
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;
};
export const readPatchRepoFile = async (
id: string,
type: "application" | "compose",
filePath: string,
) => {
let serverId: string | null = null;
if (type === "application") {
const application = await findApplicationById(id);
serverId = application.buildServerId || application.serverId;
} else {
const compose = await findComposeById(id);
serverId = compose.serverId;
}
const { PATCH_REPOS_PATH } = paths(!!serverId);
const application =
type === "application"
? await findApplicationById(id)
: await findComposeById(id);
const repoPath = join(PATCH_REPOS_PATH, type, application.appName);
const fullPath = join(repoPath, filePath);
const command = `cat "${fullPath}"`;
if (serverId) {
const result = await execAsyncRemote(serverId, command);
return result.stdout;
}
const result = await execAsync(command);
return result.stdout;
};
/**
* 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);
}
};

View File

@@ -0,0 +1,171 @@
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 { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { encodeBase64 } from "../utils/docker/utils";
import { findApplicationById } from "./application";
import { findComposeById } from "./compose";
export type Patch = typeof patch.$inferSelect;
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,
enabled: true,
})
.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 findPatchesByEntityId = async (
id: string,
type: "application" | "compose",
) => {
return await db.query.patch.findMany({
where: eq(
type === "application" ? patch.applicationId : patch.composeId,
id,
),
orderBy: (patch, { asc }) => [asc(patch.filePath)],
});
};
export const findPatchByFilePath = async (
filePath: string,
id: string,
type: "application" | "compose",
) => {
return await db.query.patch.findFirst({
where: and(
eq(patch.filePath, filePath),
eq(type === "application" ? patch.applicationId : patch.composeId, id),
),
});
};
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];
};
export const markPatchForDeletion = async (
filePath: string,
entityId: string,
entityType: "application" | "compose",
) => {
const existing = await findPatchByFilePath(filePath, entityId, entityType);
if (existing) {
return await updatePatch(existing.patchId, { type: "delete", content: "" });
}
return await createPatch({
filePath,
content: "",
type: "delete",
applicationId: entityType === "application" ? entityId : undefined,
composeId: entityType === "compose" ? entityId : undefined,
});
};
interface ApplyPatchesOptions {
id: string;
type: "application" | "compose";
serverId: string | null;
}
export const generateApplyPatchesCommand = async ({
id,
type,
serverId,
}: ApplyPatchesOptions) => {
const entity =
type === "application"
? await findApplicationById(id)
: await findComposeById(id);
const { COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
const codePath = join(basePath, entity.appName, "code");
const resultPatches = await findPatchesByEntityId(id, type);
const patches = resultPatches.filter((p) => p.enabled);
let command = `echo "Applying ${patches.length} patch(es)...";`;
for (const p of patches) {
const filePath = join(codePath, p.filePath);
if (p.type === "delete") {
command += `
rm -f "${filePath}";
`;
} else {
command += `
file="${filePath}"
dir="$(dirname "$file")"
mkdir -p "$dir"
echo "${encodeBase64(p.content)}" | base64 -d > "$file"
`;
}
}
return command;
};

View File

@@ -86,6 +86,7 @@ interface CloneBitbucketRepository {
enableSubmodules: boolean;
serverId: string | null;
type?: "application" | "compose";
outputPathOverride?: string;
}
export const cloneBitbucketRepository = async ({
@@ -101,6 +102,7 @@ export const cloneBitbucketRepository = async ({
bitbucketId,
enableSubmodules,
serverId,
outputPathOverride,
} = entity;
const { COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
@@ -115,7 +117,7 @@ export const cloneBitbucketRepository = async ({
return command;
}
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`;
const repoToUse = entity.bitbucketRepositorySlug || bitbucketRepository;

View File

@@ -14,6 +14,7 @@ interface CloneGitRepository {
enableSubmodules?: boolean;
serverId: string | null;
type?: "application" | "compose";
outputPathOverride?: string;
}
export const cloneGitRepository = async ({
@@ -28,6 +29,7 @@ export const cloneGitRepository = async ({
customGitSSHKeyId,
enableSubmodules,
serverId,
outputPathOverride,
} = entity;
const { SSH_PATH, COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
@@ -47,7 +49,7 @@ export const cloneGitRepository = async ({
`;
}
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
const knownHostsPath = path.join(SSH_PATH, "known_hosts");
if (!isHttpOrHttps(customGitUrl)) {

View File

@@ -130,6 +130,7 @@ interface CloneGiteaRepository {
enableSubmodules: boolean;
serverId: string | null;
type?: "application" | "compose";
outputPathOverride?: string;
}
export const cloneGiteaRepository = async ({
@@ -145,6 +146,7 @@ export const cloneGiteaRepository = async ({
giteaRepository,
enableSubmodules,
serverId,
outputPathOverride,
} = entity;
const { APPLICATIONS_PATH, COMPOSE_PATH } = paths(!!serverId);
@@ -162,7 +164,7 @@ export const cloneGiteaRepository = async ({
}
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`;

View File

@@ -121,6 +121,7 @@ interface CloneGithubRepository {
type?: "application" | "compose";
enableSubmodules: boolean;
serverId: string | null;
outputPathOverride?: string;
}
export const cloneGithubRepository = async ({
type = "application",
@@ -136,6 +137,7 @@ export const cloneGithubRepository = async ({
githubId,
enableSubmodules,
serverId,
outputPathOverride,
} = entity;
const { APPLICATIONS_PATH, COMPOSE_PATH } = paths(!!serverId);
@@ -155,7 +157,7 @@ export const cloneGithubRepository = async ({
const githubProvider = await findGithubById(githubId);
const basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
const octokit = authGithub(githubProvider);
const token = await getGithubToken(octokit);
const repoclone = `github.com/${owner}/${repository}.git`;

View File

@@ -107,6 +107,7 @@ interface CloneGitlabRepository {
enableSubmodules: boolean;
serverId: string | null;
type?: "application" | "compose";
outputPathOverride?: string;
}
export const cloneGitlabRepository = async ({
@@ -121,6 +122,7 @@ export const cloneGitlabRepository = async ({
gitlabPathNamespace,
enableSubmodules,
serverId,
outputPathOverride,
} = entity;
const { COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
@@ -141,7 +143,7 @@ export const cloneGitlabRepository = async ({
}
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`;
const repoClone = getGitlabRepoClone(gitlab, gitlabPathNamespace);