mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-07 12:32:16 +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: {
|
applications: {
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
},
|
},
|
||||||
|
patch: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
applications: {
|
applications: {
|
||||||
findFirst: vi.fn(),
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+24
-2
@@ -40,6 +40,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
isLoading: cleanStoppedContainersIsLoading,
|
isLoading: cleanStoppedContainersIsLoading,
|
||||||
} = api.settings.cleanStoppedContainers.useMutation();
|
} = api.settings.cleanStoppedContainers.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: cleanPatchRepos, isLoading: cleanPatchReposIsLoading } =
|
||||||
|
api.patch.cleanPatchRepos.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
@@ -49,7 +52,8 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
cleanDockerBuilderIsLoading ||
|
cleanDockerBuilderIsLoading ||
|
||||||
cleanUnusedImagesIsLoading ||
|
cleanUnusedImagesIsLoading ||
|
||||||
cleanUnusedVolumesIsLoading ||
|
cleanUnusedVolumesIsLoading ||
|
||||||
cleanStoppedContainersIsLoading
|
cleanStoppedContainersIsLoading ||
|
||||||
|
cleanPatchReposIsLoading
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -58,7 +62,8 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
cleanDockerBuilderIsLoading ||
|
cleanDockerBuilderIsLoading ||
|
||||||
cleanUnusedImagesIsLoading ||
|
cleanUnusedImagesIsLoading ||
|
||||||
cleanUnusedVolumesIsLoading ||
|
cleanUnusedVolumesIsLoading ||
|
||||||
cleanStoppedContainersIsLoading
|
cleanStoppedContainersIsLoading ||
|
||||||
|
cleanPatchReposIsLoading
|
||||||
}
|
}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
@@ -119,6 +124,23 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
<span>Clean stopped containers</span>
|
<span>Clean stopped containers</span>
|
||||||
</DropdownMenuItem>
|
</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
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer"
|
className="w-full cursor-pointer"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
|||||||
@@ -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
@@ -1016,6 +1016,13 @@
|
|||||||
"when": 1771297084611,
|
"when": 1771297084611,
|
||||||
"tag": "0144_odd_gunslinger",
|
"tag": "0144_odd_gunslinger",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 145,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1771447229358,
|
||||||
|
"tag": "0145_remarkable_titania",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+9
@@ -26,6 +26,7 @@ import { ShowDomains } from "@/components/dashboard/application/domains/show-dom
|
|||||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show";
|
import { ShowEnvironment } from "@/components/dashboard/application/environment/show";
|
||||||
import { ShowGeneralApplication } from "@/components/dashboard/application/general/show";
|
import { ShowGeneralApplication } from "@/components/dashboard/application/general/show";
|
||||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/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 { ShowPreviewDeployments } from "@/components/dashboard/application/preview-deployments/show-preview-deployments";
|
||||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||||
import { UpdateApplication } from "@/components/dashboard/application/update-application";
|
import { UpdateApplication } from "@/components/dashboard/application/update-application";
|
||||||
@@ -248,6 +249,9 @@ const Service = (
|
|||||||
Volume Backups
|
Volume Backups
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||||
|
{data?.sourceType !== "docker" && (
|
||||||
|
<TabsTrigger value="patches">Patches</TabsTrigger>
|
||||||
|
)}
|
||||||
{((data?.serverId && isCloud) || !data?.server) && (
|
{((data?.serverId && isCloud) || !data?.server) && (
|
||||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
@@ -359,6 +363,11 @@ const Service = (
|
|||||||
<ShowDomains id={applicationId} type="application" />
|
<ShowDomains id={applicationId} type="application" />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="advanced">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<AddCommand applicationId={applicationId} />
|
<AddCommand applicationId={applicationId} />
|
||||||
|
|||||||
+10
@@ -17,6 +17,7 @@ import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes
|
|||||||
import { ShowDeployments } from "@/components/dashboard/application/deployments/show-deployments";
|
import { ShowDeployments } from "@/components/dashboard/application/deployments/show-deployments";
|
||||||
import { ShowDomains } from "@/components/dashboard/application/domains/show-domains";
|
import { ShowDomains } from "@/components/dashboard/application/domains/show-domains";
|
||||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
|
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 { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||||
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
||||||
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
||||||
@@ -237,6 +238,9 @@ const Service = (
|
|||||||
Volume Backups
|
Volume Backups
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||||
|
{data?.sourceType !== "raw" && (
|
||||||
|
<TabsTrigger value="patches">Patches</TabsTrigger>
|
||||||
|
)}
|
||||||
{((data?.serverId && isCloud) || !data?.server) && (
|
{((data?.serverId && isCloud) || !data?.server) && (
|
||||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
@@ -361,6 +365,12 @@ const Service = (
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="advanced">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<AddCommandCompose composeId={composeId} />
|
<AddCommandCompose composeId={composeId} />
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { mountRouter } from "./routers/mount";
|
|||||||
import { mysqlRouter } from "./routers/mysql";
|
import { mysqlRouter } from "./routers/mysql";
|
||||||
import { notificationRouter } from "./routers/notification";
|
import { notificationRouter } from "./routers/notification";
|
||||||
import { organizationRouter } from "./routers/organization";
|
import { organizationRouter } from "./routers/organization";
|
||||||
|
import { patchRouter } from "./routers/patch";
|
||||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||||
import { ssoRouter } from "./routers/proprietary/sso";
|
import { ssoRouter } from "./routers/proprietary/sso";
|
||||||
import { portRouter } from "./routers/port";
|
import { portRouter } from "./routers/port";
|
||||||
@@ -90,6 +91,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
rollback: rollbackRouter,
|
rollback: rollbackRouter,
|
||||||
volumeBackups: volumeBackupsRouter,
|
volumeBackups: volumeBackupsRouter,
|
||||||
environment: environmentRouter,
|
environment: environmentRouter,
|
||||||
|
patch: patchRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}),
|
||||||
|
});
|
||||||
+1340
-141
File diff suppressed because it is too large
Load Diff
@@ -32,5 +32,6 @@ export const paths = (isServer = false) => {
|
|||||||
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
|
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
|
||||||
VOLUME_BACKUPS_PATH: `${BASE_PATH}/volume-backups`,
|
VOLUME_BACKUPS_PATH: `${BASE_PATH}/volume-backups`,
|
||||||
VOLUME_BACKUP_LOCK_PATH: `${BASE_PATH}/volume-backup-lock`,
|
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 { github } from "./github";
|
||||||
import { gitlab } from "./gitlab";
|
import { gitlab } from "./gitlab";
|
||||||
import { mounts } from "./mount";
|
import { mounts } from "./mount";
|
||||||
|
import { patch } from "./patch";
|
||||||
import { ports } from "./port";
|
import { ports } from "./port";
|
||||||
import { previewDeployments } from "./preview-deployments";
|
import { previewDeployments } from "./preview-deployments";
|
||||||
import { redirects } from "./redirects";
|
import { redirects } from "./redirects";
|
||||||
@@ -286,6 +287,7 @@ export const applicationsRelations = relations(
|
|||||||
references: [registry.registryId],
|
references: [registry.registryId],
|
||||||
relationName: "applicationRollbackRegistry",
|
relationName: "applicationRollbackRegistry",
|
||||||
}),
|
}),
|
||||||
|
patches: many(patch),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { gitea } from "./gitea";
|
|||||||
import { github } from "./github";
|
import { github } from "./github";
|
||||||
import { gitlab } from "./gitlab";
|
import { gitlab } from "./gitlab";
|
||||||
import { mounts } from "./mount";
|
import { mounts } from "./mount";
|
||||||
|
import { patch } from "./patch";
|
||||||
import { schedules } from "./schedule";
|
import { schedules } from "./schedule";
|
||||||
import { server } from "./server";
|
import { server } from "./server";
|
||||||
import { applicationStatus, triggerType } from "./shared";
|
import { applicationStatus, triggerType } from "./shared";
|
||||||
@@ -143,6 +144,7 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
|
|||||||
}),
|
}),
|
||||||
backups: many(backups),
|
backups: many(backups),
|
||||||
schedules: many(schedules),
|
schedules: many(schedules),
|
||||||
|
patches: many(patch),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createSchema = createInsertSchema(compose, {
|
const createSchema = createInsertSchema(compose, {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export * from "./mongo";
|
|||||||
export * from "./mount";
|
export * from "./mount";
|
||||||
export * from "./mysql";
|
export * from "./mysql";
|
||||||
export * from "./notification";
|
export * from "./notification";
|
||||||
|
export * from "./patch";
|
||||||
export * from "./port";
|
export * from "./port";
|
||||||
export * from "./postgres";
|
export * from "./postgres";
|
||||||
export * from "./preview-deployments";
|
export * from "./preview-deployments";
|
||||||
|
|||||||
@@ -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/mount";
|
||||||
export * from "./services/mysql";
|
export * from "./services/mysql";
|
||||||
export * from "./services/notification";
|
export * from "./services/notification";
|
||||||
|
export * from "./services/patch";
|
||||||
|
export * from "./services/patch-repo";
|
||||||
export * from "./services/port";
|
export * from "./services/port";
|
||||||
export * from "./services/postgres";
|
export * from "./services/postgres";
|
||||||
export * from "./services/preview-deployment";
|
export * from "./services/preview-deployment";
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
issueCommentExists,
|
issueCommentExists,
|
||||||
updateIssueComment,
|
updateIssueComment,
|
||||||
} from "./github";
|
} from "./github";
|
||||||
|
import { generateApplyPatchesCommand } from "./patch";
|
||||||
import {
|
import {
|
||||||
findPreviewDeploymentById,
|
findPreviewDeploymentById,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
@@ -202,6 +203,14 @@ export const deployApplication = async ({
|
|||||||
command += await buildRemoteDocker(application);
|
command += await buildRemoteDocker(application);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (application.sourceType !== "docker") {
|
||||||
|
command += await generateApplyPatchesCommand({
|
||||||
|
id: application.applicationId,
|
||||||
|
type: "application",
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
command += await getBuildCommand(application);
|
command += await getBuildCommand(application);
|
||||||
|
|
||||||
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
updateDeployment,
|
updateDeployment,
|
||||||
updateDeploymentStatus,
|
updateDeploymentStatus,
|
||||||
} from "./deployment";
|
} from "./deployment";
|
||||||
|
import { generateApplyPatchesCommand } from "./patch";
|
||||||
import { validUniqueServerAppName } from "./project";
|
import { validUniqueServerAppName } from "./project";
|
||||||
|
|
||||||
export type Compose = typeof compose.$inferSelect;
|
export type Compose = typeof compose.$inferSelect;
|
||||||
@@ -247,8 +248,15 @@ export const deployCompose = async ({
|
|||||||
} else {
|
} else {
|
||||||
await execAsync(commandWithLog);
|
await execAsync(commandWithLog);
|
||||||
}
|
}
|
||||||
|
|
||||||
command = "set -e;";
|
command = "set -e;";
|
||||||
|
if (compose.sourceType !== "raw") {
|
||||||
|
command += await generateApplyPatchesCommand({
|
||||||
|
id: compose.composeId,
|
||||||
|
type: "compose",
|
||||||
|
serverId: compose.serverId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
command += await getBuildComposeCommand(entity);
|
command += await getBuildComposeCommand(entity);
|
||||||
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||||
if (compose.serverId) {
|
if (compose.serverId) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
enableSubmodules: boolean;
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
type?: "application" | "compose";
|
type?: "application" | "compose";
|
||||||
|
outputPathOverride?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cloneBitbucketRepository = async ({
|
export const cloneBitbucketRepository = async ({
|
||||||
@@ -101,6 +102,7 @@ export const cloneBitbucketRepository = async ({
|
|||||||
bitbucketId,
|
bitbucketId,
|
||||||
enableSubmodules,
|
enableSubmodules,
|
||||||
serverId,
|
serverId,
|
||||||
|
outputPathOverride,
|
||||||
} = entity;
|
} = entity;
|
||||||
const { COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
|
const { COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
|
||||||
|
|
||||||
@@ -115,7 +117,7 @@ export const cloneBitbucketRepository = async ({
|
|||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
|
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 += `rm -rf ${outputPath};`;
|
||||||
command += `mkdir -p ${outputPath};`;
|
command += `mkdir -p ${outputPath};`;
|
||||||
const repoToUse = entity.bitbucketRepositorySlug || bitbucketRepository;
|
const repoToUse = entity.bitbucketRepositorySlug || bitbucketRepository;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface CloneGitRepository {
|
|||||||
enableSubmodules?: boolean;
|
enableSubmodules?: boolean;
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
type?: "application" | "compose";
|
type?: "application" | "compose";
|
||||||
|
outputPathOverride?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cloneGitRepository = async ({
|
export const cloneGitRepository = async ({
|
||||||
@@ -28,6 +29,7 @@ export const cloneGitRepository = async ({
|
|||||||
customGitSSHKeyId,
|
customGitSSHKeyId,
|
||||||
enableSubmodules,
|
enableSubmodules,
|
||||||
serverId,
|
serverId,
|
||||||
|
outputPathOverride,
|
||||||
} = entity;
|
} = entity;
|
||||||
const { SSH_PATH, COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
|
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 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");
|
const knownHostsPath = path.join(SSH_PATH, "known_hosts");
|
||||||
|
|
||||||
if (!isHttpOrHttps(customGitUrl)) {
|
if (!isHttpOrHttps(customGitUrl)) {
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ interface CloneGiteaRepository {
|
|||||||
enableSubmodules: boolean;
|
enableSubmodules: boolean;
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
type?: "application" | "compose";
|
type?: "application" | "compose";
|
||||||
|
outputPathOverride?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cloneGiteaRepository = async ({
|
export const cloneGiteaRepository = async ({
|
||||||
@@ -145,6 +146,7 @@ export const cloneGiteaRepository = async ({
|
|||||||
giteaRepository,
|
giteaRepository,
|
||||||
enableSubmodules,
|
enableSubmodules,
|
||||||
serverId,
|
serverId,
|
||||||
|
outputPathOverride,
|
||||||
} = entity;
|
} = entity;
|
||||||
const { APPLICATIONS_PATH, COMPOSE_PATH } = paths(!!serverId);
|
const { APPLICATIONS_PATH, COMPOSE_PATH } = paths(!!serverId);
|
||||||
|
|
||||||
@@ -162,7 +164,7 @@ export const cloneGiteaRepository = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
|
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 += `rm -rf ${outputPath};`;
|
||||||
command += `mkdir -p ${outputPath};`;
|
command += `mkdir -p ${outputPath};`;
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ interface CloneGithubRepository {
|
|||||||
type?: "application" | "compose";
|
type?: "application" | "compose";
|
||||||
enableSubmodules: boolean;
|
enableSubmodules: boolean;
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
|
outputPathOverride?: string;
|
||||||
}
|
}
|
||||||
export const cloneGithubRepository = async ({
|
export const cloneGithubRepository = async ({
|
||||||
type = "application",
|
type = "application",
|
||||||
@@ -136,6 +137,7 @@ export const cloneGithubRepository = async ({
|
|||||||
githubId,
|
githubId,
|
||||||
enableSubmodules,
|
enableSubmodules,
|
||||||
serverId,
|
serverId,
|
||||||
|
outputPathOverride,
|
||||||
} = entity;
|
} = entity;
|
||||||
const { APPLICATIONS_PATH, COMPOSE_PATH } = paths(!!serverId);
|
const { APPLICATIONS_PATH, COMPOSE_PATH } = paths(!!serverId);
|
||||||
|
|
||||||
@@ -155,7 +157,7 @@ export const cloneGithubRepository = async ({
|
|||||||
|
|
||||||
const githubProvider = await findGithubById(githubId);
|
const githubProvider = await findGithubById(githubId);
|
||||||
const basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH;
|
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 octokit = authGithub(githubProvider);
|
||||||
const token = await getGithubToken(octokit);
|
const token = await getGithubToken(octokit);
|
||||||
const repoclone = `github.com/${owner}/${repository}.git`;
|
const repoclone = `github.com/${owner}/${repository}.git`;
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ interface CloneGitlabRepository {
|
|||||||
enableSubmodules: boolean;
|
enableSubmodules: boolean;
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
type?: "application" | "compose";
|
type?: "application" | "compose";
|
||||||
|
outputPathOverride?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cloneGitlabRepository = async ({
|
export const cloneGitlabRepository = async ({
|
||||||
@@ -121,6 +122,7 @@ export const cloneGitlabRepository = async ({
|
|||||||
gitlabPathNamespace,
|
gitlabPathNamespace,
|
||||||
enableSubmodules,
|
enableSubmodules,
|
||||||
serverId,
|
serverId,
|
||||||
|
outputPathOverride,
|
||||||
} = entity;
|
} = entity;
|
||||||
const { COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
|
const { COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
|
||||||
|
|
||||||
@@ -141,7 +143,7 @@ export const cloneGitlabRepository = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
|
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 += `rm -rf ${outputPath};`;
|
||||||
command += `mkdir -p ${outputPath};`;
|
command += `mkdir -p ${outputPath};`;
|
||||||
const repoClone = getGitlabRepoClone(gitlab, gitlabPathNamespace);
|
const repoClone = getGitlabRepoClone(gitlab, gitlabPathNamespace);
|
||||||
|
|||||||
Reference in New Issue
Block a user