mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-02-25 20:35:10 +00:00
Merge pull request #3574 from ivanpadavan/patches-impl
feat: Add patches functionality
This commit is contained in:
@@ -28,6 +28,9 @@ vi.mock("@dokploy/server/db", () => {
|
||||
applications: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
patch: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -29,6 +29,9 @@ vi.mock("@dokploy/server/db", () => {
|
||||
applications: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
patch: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./show-patches";
|
||||
export * from "./patch-editor";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 build—configs,
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
17
apps/dokploy/drizzle/0145_remarkable_titania.sql
Normal file
17
apps/dokploy/drizzle/0145_remarkable_titania.sql
Normal 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;
|
||||
7459
apps/dokploy/drizzle/meta/0145_snapshot.json
Normal file
7459
apps/dokploy/drizzle/meta/0145_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1016,6 +1016,13 @@
|
||||
"when": 1771297084611,
|
||||
"tag": "0144_odd_gunslinger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 145,
|
||||
"version": "7",
|
||||
"when": 1771447229358,
|
||||
"tag": "0145_remarkable_titania",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
348
apps/dokploy/server/api/routers/patch.ts
Normal file
348
apps/dokploy/server/api/routers/patch.ts
Normal 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;
|
||||
}),
|
||||
});
|
||||
1481
openapi.json
1481
openapi.json
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
|
||||
100
packages/server/src/db/schema/patch.ts
Normal file
100
packages/server/src/db/schema/patch.ts
Normal 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(),
|
||||
});
|
||||
@@ -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,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`;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
197
packages/server/src/services/patch-repo.ts
Normal file
197
packages/server/src/services/patch-repo.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
171
packages/server/src/services/patch.ts
Normal file
171
packages/server/src/services/patch.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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};`;
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user