feat(managed-servers): add managed servers functionality and API integration

- Introduced a new API for managing servers, including endpoints for listing, purchasing, and retrieving server plans.
- Added a new page and components for displaying managed servers in the dashboard.
- Updated the sidebar to include navigation for managed servers.
- Created database migrations for managed server types and status.
- Enhanced environment configuration with a new API key for Hostinger services.

This update enables users to manage their servers directly from the Dokploy dashboard, improving the overall user experience and functionality.
This commit is contained in:
Mauricio Siu
2026-05-05 08:10:30 -06:00
parent 9b416b3699
commit 299950a323
20 changed files with 26499 additions and 29 deletions

8403
api-1.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,6 @@
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
PORT=3000
NODE_ENV=development
# Managed Servers (Dokploy Cloud only) — API token from https://hpanel.hostinger.com/profile/api
HOSTINGER_API_KEY=

View File

@@ -1,4 +1,4 @@
import { CreditCard, FileText } from "lucide-react";
import { CreditCard, FileText, Server } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import {
@@ -17,6 +17,11 @@ const navigationItems = [
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Managed Servers",
href: "/dashboard/settings/managed-servers",
icon: Server,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",

View File

@@ -9,6 +9,7 @@ import {
Loader2,
MinusIcon,
PlusIcon,
Server,
ShieldCheck,
} from "lucide-react";
import Link from "next/link";
@@ -82,6 +83,11 @@ const navigationItems = [
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Managed Servers",
href: "/dashboard/settings/managed-servers",
icon: Server,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",

View File

@@ -0,0 +1,493 @@
import {
AlertCircle,
CheckCircle2,
Clock,
CreditCard,
ExternalLink,
FileText,
Loader2,
Plus,
Server,
Trash2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
const navigationItems = [
{
name: "Subscription",
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Managed Servers",
href: "/dashboard/settings/managed-servers",
icon: Server,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
icon: FileText,
},
];
const STATUS_MAP: Record<
string,
{
label: string;
icon: React.ReactNode;
variant: "default" | "secondary" | "destructive" | "outline";
}
> = {
pending: {
label: "Pending",
icon: <Clock className="size-3" />,
variant: "secondary",
},
provisioning: {
label: "Provisioning",
icon: <Loader2 className="size-3 animate-spin" />,
variant: "secondary",
},
configuring: {
label: "Installing Dokploy",
icon: <Loader2 className="size-3 animate-spin" />,
variant: "secondary",
},
ready: {
label: "Ready",
icon: <CheckCircle2 className="size-3" />,
variant: "default",
},
error: {
label: "Error",
icon: <XCircle className="size-3" />,
variant: "destructive",
},
terminating: {
label: "Terminating",
icon: <Loader2 className="size-3 animate-spin" />,
variant: "secondary",
},
terminated: {
label: "Terminated",
icon: <AlertCircle className="size-3" />,
variant: "outline",
},
};
function formatSpecs(cpus: number, memoryMb: number, diskMb: number, bandwidthMb: number) {
const bandwidthTb = bandwidthMb / 1024 / 1024;
const bandwidthLabel = bandwidthTb >= 1 ? `${bandwidthTb.toFixed(0)} TB` : `${Math.round(bandwidthMb / 1024)} GB`;
return `${cpus} vCPU · ${Math.round(memoryMb / 1024)} GB RAM · ${Math.round(diskMb / 1024)} GB NVMe · ${bandwidthLabel} bandwidth`;
}
function centsToDisplay(cents: number) {
return (cents / 100).toFixed(2).replace(/\.00$/, "");
}
function OrderServerDialog({ onSuccess }: { onSuccess: () => void }) {
const [open, setOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<string>("");
const [selectedDc, setSelectedDc] = useState<string>("");
const [isAnnual, setIsAnnual] = useState(false);
const { data: plans, isLoading: loadingPlans } =
api.managedServer.getPlans.useQuery(undefined, { enabled: open });
const { data: dataCenters, isLoading: loadingDcs } =
api.managedServer.getDataCenters.useQuery(undefined, { enabled: open });
const isLoadingOptions = loadingPlans || loadingDcs;
const purchase = api.managedServer.purchase.useMutation({
onSuccess: () => {
toast.success("Server order placed! Provisioning will take ~5 minutes.");
setOpen(false);
onSuccess();
},
onError: (err) => {
toast.error(err.message);
},
});
const plan = plans?.find((p) => p.id === selectedPlan);
const displayPrice = (p: NonNullable<typeof plan>) =>
isAnnual
? `$${centsToDisplay(p.dokployPriceCentsAnnual)}/yr`
: `$${centsToDisplay(p.dokployPriceCentsMonthly)}/mo`;
const displayPriceSmall = (p: NonNullable<typeof plan>) =>
isAnnual
? `$${centsToDisplay(Math.round(p.dokployPriceCentsAnnual / 12))}/mo billed annually`
: `$${centsToDisplay(p.dokployPriceCentsAnnual)}/yr if annual`;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="size-4 mr-2" />
Order Server
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Order a Managed Server</DialogTitle>
<DialogDescription>
We'll provision and configure a server for you automatically. Ready
in ~5 minutes.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2">
{isLoadingOptions ? (
<div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
<p className="text-sm">Loading available plans...</p>
</div>
) : (
<div className="space-y-4">
{/* Billing period toggle */}
<div className="flex items-center gap-1 rounded-lg border p-1 bg-muted/40 w-fit">
<button
type="button"
onClick={() => setIsAnnual(false)}
className={cn(
"px-4 py-1.5 rounded-md text-sm font-medium transition-colors",
!isAnnual
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
Monthly
</button>
<button
type="button"
onClick={() => setIsAnnual(true)}
className={cn(
"px-4 py-1.5 rounded-md text-sm font-medium transition-colors flex items-center gap-1.5",
isAnnual
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
Annual
<span className="text-xs bg-green-500/15 text-green-600 dark:text-green-400 px-1.5 py-0.5 rounded font-semibold">
Save ~20%
</span>
</button>
</div>
{/* Plan selector */}
<div className="space-y-2">
<Label>Plan</Label>
<div className="grid gap-2">
{plans?.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setSelectedPlan(p.id)}
className={cn(
"flex items-center justify-between rounded-lg border p-3 text-left transition-colors",
selectedPlan === p.id
? "border-primary bg-primary/5"
: "border-border hover:border-muted-foreground",
)}
>
<div>
<p className="font-medium text-sm">{p.name}</p>
<p className="text-xs text-muted-foreground">
{formatSpecs(p.cpus, p.memoryMb, p.diskMb, p.bandwidthMb)}
</p>
</div>
<div className="text-right">
<p className="font-semibold text-sm">
{displayPrice(p)}
</p>
<p className="text-xs text-muted-foreground">
{displayPriceSmall(p)}
</p>
</div>
</button>
))}
</div>
</div>
{/* Data center selector */}
<div className="space-y-2">
<Label>Data Center</Label>
<Select value={selectedDc} onValueChange={setSelectedDc}>
<SelectTrigger>
<SelectValue placeholder="Select a location..." />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4} className="max-h-56 overflow-y-auto">
{dataCenters?.map((dc) => (
<SelectItem key={dc.id} value={String(dc.id)}>
{dc.city} — {dc.continent}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{plan && selectedDc && (
<div className="rounded-lg bg-muted p-3 text-sm space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Plan</span>
<span className="font-medium">{plan.name}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Billing</span>
<span className="font-medium">{isAnnual ? "Annual" : "Monthly"}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Total</span>
<span className="font-semibold">{displayPrice(plan)}</span>
</div>
</div>
)}
<Button
className="w-full"
disabled={!selectedPlan || !selectedDc || purchase.isPending}
onClick={() => {
if (!selectedPlan || !selectedDc) return;
purchase.mutate({
plan: selectedPlan,
dataCenterId: Number(selectedDc),
isAnnual,
});
}}
>
{purchase.isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Placing order...
</>
) : (
"Order Server"
)}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
export const ShowManagedServers = () => {
const router = useRouter();
const utils = api.useUtils();
const { data: servers, isLoading } = api.managedServer.list.useQuery();
const syncStatus = api.managedServer.syncStatus.useMutation({
onSuccess: () => utils.managedServer.list.invalidate(),
});
const deleteServer = api.managedServer.delete.useMutation({
onSuccess: () => {
toast.success("Server terminated.");
utils.managedServer.list.invalidate();
},
onError: (err) => toast.error(err.message),
});
return (
<div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Server className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<CardDescription>
Manage your subscription and servers
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 py-4 border-t">
<nav className="flex space-x-2 border-b">
{navigationItems.map((item) => {
const Icon = item.icon;
const isActive = router.pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
isActive
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
)}
>
<Icon className="h-4 w-4" />
{item.name}
</Link>
);
})}
</nav>
<div className="mt-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-base">Managed Servers</h3>
<p className="text-sm text-muted-foreground">
Servers provisioned and managed by Dokploy Cloud
</p>
</div>
<OrderServerDialog
onSuccess={() => utils.managedServer.list.invalidate()}
/>
</div>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : servers?.length === 0 ? (
<div className="text-center py-12 border rounded-lg border-dashed">
<Server className="size-10 mx-auto text-muted-foreground mb-3" />
<p className="text-sm font-medium">No managed servers yet</p>
<p className="text-xs text-muted-foreground mt-1">
Order a server and we'll provision and configure it for you
automatically.
</p>
</div>
) : (
<div className="space-y-3">
{servers?.map((s) => {
const status =
STATUS_MAP[s.status] ?? STATUS_MAP.error!;
const isProvisioning = [
"pending",
"provisioning",
"configuring",
].includes(s.status);
const planLabel = s.plan
.split("-")
.slice(-2)
.join(" ")
.toUpperCase();
return (
<div
key={s.managedServerId}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-3">
<Server className="size-5 text-muted-foreground shrink-0" />
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{planLabel}
</span>
<Badge
variant={status?.variant}
className="flex items-center gap-1 text-xs h-5"
>
{status?.icon}
{status?.label}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{s.hostname ?? ""}
{s.ipAddress ? ` · ${s.ipAddress}` : ""}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isProvisioning && (
<Button
variant="ghost"
size="sm"
onClick={() =>
syncStatus.mutate({
managedServerId: s.managedServerId,
})
}
disabled={syncStatus.isPending}
>
<Loader2
className={cn(
"size-4",
syncStatus.isPending && "animate-spin",
)}
/>
</Button>
)}
{s.status === "ready" && s.server && (
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/settings/server?serverId=${s.serverId}`}
>
<ExternalLink className="size-3.5 mr-1.5" />
Open
</Link>
</Button>
)}
<DialogAction
title="Terminate Server"
description="This will permanently destroy the server and all data on it. This action cannot be undone."
type="destructive"
onClick={() =>
deleteServer.mutate({
managedServerId: s.managedServerId,
})
}
>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
>
<Trash2 className="size-4" />
</Button>
</DialogAction>
</div>
</div>
);
})}
</div>
)}
</div>
</CardContent>
</div>
</Card>
</div>
);
};

View File

@@ -0,0 +1,23 @@
CREATE TYPE "public"."managedServerPlan" AS ENUM('kvm2', 'kvm4', 'kvm8');--> statement-breakpoint
CREATE TYPE "public"."managedServerStatus" AS ENUM('pending', 'provisioning', 'configuring', 'ready', 'error', 'terminating', 'terminated');--> statement-breakpoint
CREATE TABLE "managed_server" (
"managedServerId" text PRIMARY KEY NOT NULL,
"organizationId" text NOT NULL,
"serverId" text,
"plan" "managedServerPlan" NOT NULL,
"status" "managedServerStatus" DEFAULT 'pending' NOT NULL,
"hostingerVmId" integer,
"hostingerSubscriptionId" text,
"dataCenterId" integer NOT NULL,
"ipAddress" text,
"hostname" text,
"stripeSubscriptionId" text,
"stripePriceId" text,
"rootPassword" text,
"errorMessage" text,
"createdAt" text NOT NULL,
"updatedAt" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "managed_server" ALTER COLUMN "plan" SET DATA TYPE text;--> statement-breakpoint
DROP TYPE "public"."managedServerPlan";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1163,6 +1163,20 @@
"when": 1775845419261,
"tag": "0165_abnormal_greymalkin",
"breakpoints": true
},
{
"idx": 166,
"version": "7",
"when": 1777967101978,
"tag": "0166_lame_meltdown",
"breakpoints": true
},
{
"idx": 167,
"version": "7",
"when": 1777967443440,
"tag": "0167_cultured_captain_cross",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,39 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import { ShowManagedServers } from "@/components/dashboard/settings/billing/show-managed-servers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
const Page = () => {
return <ShowManagedServers />;
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Managed Servers">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext,
) {
if (!IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
},
};
}
const { user } = await validateRequest(ctx.req);
if (!user || user.role !== "owner") {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
return { props: {} };
}

View File

@@ -31,6 +31,7 @@ import { projectRouter } from "./routers/project";
import { auditLogRouter } from "./routers/proprietary/audit-log";
import { customRoleRouter } from "./routers/proprietary/custom-role";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { managedServerRouter } from "./routers/proprietary/managed-server";
import { ssoRouter } from "./routers/proprietary/sso";
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
import { redirectsRouter } from "./routers/redirects";
@@ -102,6 +103,7 @@ export const appRouter = createTRPCRouter({
environment: environmentRouter,
tag: tagRouter,
patch: patchRouter,
managedServer: managedServerRouter,
});
// export type definition of API

View File

@@ -0,0 +1,247 @@
import {
createServer,
IS_CLOUD,
serverSetup,
} from "@dokploy/server";
import {
apiCreateManagedServer,
apiDeleteManagedServer,
apiFindOneManagedServer,
} from "@dokploy/server/db/schema/managed-server";
import {
createManagedServer,
deleteManagedServer,
findManagedServerById,
findManagedServersByOrg,
updateManagedServer,
} from "@dokploy/server/services/managed-server";
import {
getHostingerDataCenters,
getHostingerVm,
getManagedServerPlans,
purchaseHostingerVps,
stopHostingerVm,
UBUNTU_22_TEMPLATE_ID,
} from "@dokploy/server/utils/hostinger";
import { TRPCError } from "@trpc/server";
import { nanoid } from "nanoid";
import { adminProcedure, createTRPCRouter } from "../../trpc";
export const managedServerRouter = createTRPCRouter({
getPlans: adminProcedure.query(async () => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Managed servers are only available in Dokploy Cloud",
});
}
return getManagedServerPlans();
}),
getDataCenters: adminProcedure.query(async () => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Managed servers are only available in Dokploy Cloud",
});
}
return getHostingerDataCenters();
}),
list: adminProcedure.query(async ({ ctx }) => {
if (!IS_CLOUD) return [];
return findManagedServersByOrg(ctx.session.activeOrganizationId);
}),
one: adminProcedure
.input(apiFindOneManagedServer)
.query(async ({ input, ctx }) => {
if (!IS_CLOUD) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
}
const record = await findManagedServerById(input.managedServerId);
if (record.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return record;
}),
purchase: adminProcedure
.input(apiCreateManagedServer)
.mutation(async ({ input, ctx }) => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Managed servers are only available in Dokploy Cloud",
});
}
const plans = await getManagedServerPlans();
const plan = plans.find((p) => p.id === input.plan);
if (!plan) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid plan" });
}
const hostname =
`dokploy-${ctx.session.activeOrganizationId.slice(0, 8)}-${nanoid(6)}`.toLowerCase();
const managedRecord = await createManagedServer({
organizationId: ctx.session.activeOrganizationId,
plan: input.plan,
dataCenterId: input.dataCenterId,
status: "provisioning",
});
const hostingerItemId = input.isAnnual
? plan.hostingerItemIdAnnual
: plan.hostingerItemIdMonthly;
provisionManagedServer(
managedRecord.managedServerId,
hostingerItemId,
input.dataCenterId,
hostname,
ctx.session.activeOrganizationId,
).catch(async (err) => {
await updateManagedServer(managedRecord.managedServerId, {
status: "error",
errorMessage: err?.message ?? "Unknown error during provisioning",
});
});
return managedRecord;
}),
delete: adminProcedure
.input(apiDeleteManagedServer)
.mutation(async ({ input, ctx }) => {
if (!IS_CLOUD) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
}
const record = await findManagedServerById(input.managedServerId);
if (record.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
await updateManagedServer(input.managedServerId, {
status: "terminating",
});
if (record.hostingerVmId) {
try {
await stopHostingerVm(record.hostingerVmId);
} catch (_) {
// Best-effort
}
}
await deleteManagedServer(input.managedServerId);
return { ok: true };
}),
syncStatus: adminProcedure
.input(apiFindOneManagedServer)
.mutation(async ({ input, ctx }) => {
if (!IS_CLOUD) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
}
const record = await findManagedServerById(input.managedServerId);
if (record.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (!record.hostingerVmId) return record;
const vm = await getHostingerVm(record.hostingerVmId);
const ipAddress = vm.ipv4?.[0]?.address ?? record.ipAddress;
await updateManagedServer(input.managedServerId, {
ipAddress: ipAddress ?? undefined,
hostname: vm.hostname ?? undefined,
status:
vm.state === "running"
? record.serverId
? "ready"
: "configuring"
: record.status,
});
return findManagedServerById(input.managedServerId);
}),
});
async function provisionManagedServer(
managedServerId: string,
hostingerItemId: string,
dataCenterId: number,
hostname: string,
organizationId: string,
) {
const result = await purchaseHostingerVps({
item_id: hostingerItemId,
payment_method_id: 0,
setup: {
template_id: UBUNTU_22_TEMPLATE_ID,
data_center_id: dataCenterId,
hostname,
enable_backups: false,
},
coupons: [],
});
const vm = result.virtual_machine;
await updateManagedServer(managedServerId, {
hostingerVmId: vm.id,
hostingerSubscriptionId: vm.subscription_id ?? undefined,
ipAddress: vm.ipv4?.[0]?.address ?? undefined,
hostname: vm.hostname ?? undefined,
status: "configuring",
});
await waitForVmRunning(vm.id!, managedServerId);
const finalVm = await getHostingerVm(vm.id!);
const finalIp = finalVm.ipv4?.[0]?.address;
if (!finalIp) {
throw new Error("VM is running but has no IPv4 address");
}
const serverRecord = await createServer(
{
name: `Managed • ${hostname}`,
description: "Managed server provisioned by Dokploy Cloud",
ipAddress: finalIp,
port: 22,
username: "root",
serverType: "deploy",
},
organizationId,
);
await updateManagedServer(managedServerId, {
serverId: serverRecord.serverId,
ipAddress: finalIp,
});
await serverSetup(serverRecord.serverId);
await updateManagedServer(managedServerId, { status: "ready" });
}
async function waitForVmRunning(
vmId: number,
_managedServerId: string,
maxAttempts = 30,
intervalMs = 10_000,
) {
for (let i = 0; i < maxAttempts; i++) {
await new Promise((r) => setTimeout(r, intervalMs));
const vm = await getHostingerVm(vmId);
if (vm.state === "running") return;
if (vm.state === "error") {
throw new Error("VM entered error state");
}
}
throw new Error("Timed out waiting for VM to become running");
}

View File

@@ -61,6 +61,7 @@
"drizzle-dbml-generator": "0.10.0",
"drizzle-orm": "0.45.1",
"drizzle-zod": "0.5.1",
"hostinger-api-sdk": "^0.0.17",
"lodash": "4.17.21",
"micromatch": "4.0.8",
"nanoid": "3.3.11",

View File

@@ -15,6 +15,7 @@ export * from "./gitea";
export * from "./github";
export * from "./gitlab";
export * from "./libsql";
export * from "./managed-server";
export * from "./mariadb";
export * from "./mongo";
export * from "./mount";

View File

@@ -0,0 +1,72 @@
import { relations } from "drizzle-orm";
import { integer, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
import { server } from "./server";
export const managedServerStatus = pgEnum("managedServerStatus", [
"pending",
"provisioning",
"configuring",
"ready",
"error",
"terminating",
"terminated",
]);
export const managedServer = pgTable("managed_server", {
managedServerId: text("managedServerId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
serverId: text("serverId").references(() => server.serverId, {
onDelete: "set null",
}),
/** Hostinger catalog item id, e.g. "hostingercom-vps-kvm2" */
plan: text("plan").notNull(),
status: managedServerStatus("status").notNull().default("pending"),
hostingerVmId: integer("hostingerVmId"),
hostingerSubscriptionId: text("hostingerSubscriptionId"),
dataCenterId: integer("dataCenterId").notNull(),
ipAddress: text("ipAddress"),
hostname: text("hostname"),
stripeSubscriptionId: text("stripeSubscriptionId"),
stripePriceId: text("stripePriceId"),
rootPassword: text("rootPassword"),
errorMessage: text("errorMessage"),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
updatedAt: text("updatedAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
});
export const managedServerRelations = relations(managedServer, ({ one }) => ({
organization: one(organization, {
fields: [managedServer.organizationId],
references: [organization.id],
}),
server: one(server, {
fields: [managedServer.serverId],
references: [server.serverId],
}),
}));
export const apiCreateManagedServer = z.object({
plan: z.string().min(1),
dataCenterId: z.number().int().positive(),
isAnnual: z.boolean().default(false),
});
export const apiFindOneManagedServer = z.object({
managedServerId: z.string().min(1),
});
export const apiDeleteManagedServer = z.object({
managedServerId: z.string().min(1),
});

View File

@@ -43,6 +43,7 @@ export * from "./services/registry";
export * from "./services/rollbacks";
export * from "./services/schedule";
export * from "./services/security";
export * from "./services/managed-server";
export * from "./services/server";
export * from "./services/settings";
export * from "./services/ssh-key";

View File

@@ -0,0 +1,54 @@
import { db } from "@dokploy/server/db";
import { managedServer } from "@dokploy/server/db/schema/managed-server";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
export type ManagedServer = typeof managedServer.$inferSelect;
export const createManagedServer = async (
input: typeof managedServer.$inferInsert,
) => {
const record = await db
.insert(managedServer)
.values(input)
.returning()
.then((r) => r[0]);
if (!record) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
return record;
};
export const findManagedServerById = async (managedServerId: string) => {
const record = await db.query.managedServer.findFirst({
where: eq(managedServer.managedServerId, managedServerId),
with: { server: true },
});
if (!record)
throw new TRPCError({ code: "NOT_FOUND", message: "Managed server not found" });
return record;
};
export const findManagedServersByOrg = async (organizationId: string) => {
return db.query.managedServer.findMany({
where: eq(managedServer.organizationId, organizationId),
with: { server: true },
orderBy: (t, { desc }) => [desc(t.createdAt)],
});
};
export const updateManagedServer = async (
managedServerId: string,
data: Partial<typeof managedServer.$inferInsert>,
) => {
return db
.update(managedServer)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(managedServer.managedServerId, managedServerId))
.returning()
.then((r) => r[0]);
};
export const deleteManagedServer = async (managedServerId: string) => {
return db
.delete(managedServer)
.where(eq(managedServer.managedServerId, managedServerId));
};

View File

@@ -0,0 +1,155 @@
import {
BillingCatalogApi,
Configuration,
VPSDataCentersApi,
VPSVirtualMachineApi,
} from "hostinger-api-sdk";
export type {
BillingV1CatalogCatalogItemResource as HostingerCatalogItem,
VPSV1DataCenterDataCenterResource as HostingerDataCenter,
VPSV1VirtualMachinePurchaseRequest as HostingerPurchaseRequest,
VPSV1VirtualMachineVirtualMachineResource as HostingerVM,
} from "hostinger-api-sdk";
// Correct base URL — api.hostinger.com returns 530, developers.hostinger.com is the real gateway
const HOSTINGER_BASE_PATH = "https://developers.hostinger.com";
function getConfig() {
const apiKey = process.env.HOSTINGER_API_KEY;
if (!apiKey) throw new Error("HOSTINGER_API_KEY is not set");
return new Configuration({
basePath: HOSTINGER_BASE_PATH,
accessToken: apiKey,
});
}
function getVmApi() {
return new VPSVirtualMachineApi(getConfig());
}
export async function getHostingerDataCenters() {
const api = new VPSDataCentersApi(getConfig());
const res = await api.getDataCenterListV1();
return res.data;
}
export async function getHostingerVpsCatalog() {
const api = new BillingCatalogApi(getConfig());
const res = await api.getCatalogItemListV1("VPS");
return res.data;
}
export async function purchaseHostingerVps(
body: import("hostinger-api-sdk").VPSV1VirtualMachinePurchaseRequest,
) {
const api = getVmApi();
const res = await api.purchaseNewVirtualMachineV1(body);
return res.data;
}
export async function getHostingerVm(vmId: number) {
const api = getVmApi();
const res = await api.getVirtualMachineDetailsV1(vmId);
return res.data;
}
export async function stopHostingerVm(vmId: number) {
const api = getVmApi();
await api.stopVirtualMachineV1(vmId);
}
/** Ubuntu 22.04 LTS template ID on Hostinger */
export const UBUNTU_22_TEMPLATE_ID = 1009;
/**
* Markup multiplier applied to Hostinger's catalog price to get Dokploy's user price.
* Hostinger KVM2 = ~$24.49/mo → Dokploy charges $45/mo (~84% markup).
*/
const MARKUP = 1.84;
export interface ManagedServerPlan {
id: string;
name: string;
hostingerItemIdMonthly: string;
hostingerItemIdAnnual: string;
cpus: number;
memoryMb: number;
diskMb: number;
bandwidthMb: number;
/** Price in cents Hostinger charges us monthly */
hostingerPriceCentsMonthly: number;
/** Price in cents we charge the user monthly */
dokployPriceCentsMonthly: number;
/** Price in cents we charge the user annually */
dokployPriceCentsAnnual: number;
}
/** KVM plan IDs offered through Dokploy (excludes Game Panel plans) */
const OFFERED_PLAN_IDS = ["hostingercom-vps-kvm1", "hostingercom-vps-kvm2", "hostingercom-vps-kvm4", "hostingercom-vps-kvm8"];
/**
* Fetches live VPS plans from Hostinger catalog and applies Dokploy markup.
* Only returns standard KVM plans (not Game Panel variants).
*/
export async function getManagedServerPlans(): Promise<ManagedServerPlan[]> {
const catalog = await getHostingerVpsCatalog();
const plans: ManagedServerPlan[] = [];
for (const item of catalog) {
if (!OFFERED_PLAN_IDS.includes(item.id ?? "")) continue;
const meta = item.metadata as Record<string, string> | null;
const cpus = Number(meta?.cpus ?? 0);
const memoryMb = Number(meta?.memory ?? 0);
const diskMb = Number(meta?.disk_space ?? 0);
const bandwidthMb = Number(meta?.bandwidth ?? 0);
const monthlyPrice = item.prices?.find(
(p) => p.period === 1 && p.period_unit === "month",
);
const annualPrice = item.prices?.find(
(p) => p.period === 1 && p.period_unit === "year",
);
if (!monthlyPrice) continue;
const hostingerMonthly = monthlyPrice.price ?? 0;
const hostingerAnnual = annualPrice?.price ?? hostingerMonthly * 12;
// Apply markup and round to nearest $0.50 (50 cents)
const dokployMonthly = Math.ceil((hostingerMonthly * MARKUP) / 50) * 50;
const dokployAnnual = Math.ceil((hostingerAnnual * MARKUP) / 50) * 50;
// Derive hostinger item IDs for monthly and annual billing
const hostingerItemIdMonthly = monthlyPrice.id ?? `${item.id}-usd-1m`;
const hostingerItemIdAnnual = annualPrice?.id ?? `${item.id}-usd-1y`;
// Map hostinger plan names to friendly names
const friendlyNames: Record<string, string> = {
"hostingercom-vps-kvm1": "Starter",
"hostingercom-vps-kvm2": "Basic",
"hostingercom-vps-kvm4": "Growth",
"hostingercom-vps-kvm8": "Scale",
};
plans.push({
id: item.id ?? "",
name: friendlyNames[item.id ?? ""] ?? item.name ?? item.id ?? "",
hostingerItemIdMonthly,
hostingerItemIdAnnual,
cpus,
memoryMb,
diskMb,
bandwidthMb,
hostingerPriceCentsMonthly: hostingerMonthly,
dokployPriceCentsMonthly: dokployMonthly,
dokployPriceCentsAnnual: dokployAnnual,
});
}
return plans;
}
export type ManagedServerPlanId = string;

69
pnpm-lock.yaml generated
View File

@@ -115,10 +115,10 @@ importers:
version: 2.0.30(zod@4.3.6)
'@better-auth/api-key':
specifier: 1.5.4
version: 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(febde88eaf587188179e6ecc47119e50))
version: 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(219be630f7f6fef2e235cef94eaddc25))
'@better-auth/sso':
specifier: 1.5.4
version: 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(febde88eaf587188179e6ecc47119e50))(better-call@2.0.2(zod@4.3.6))
version: 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(219be630f7f6fef2e235cef94eaddc25))(better-call@2.0.2(zod@4.3.6))
'@codemirror/autocomplete':
specifier: ^6.18.6
version: 6.20.0
@@ -280,7 +280,7 @@ importers:
version: 5.1.1
better-auth:
specifier: 1.5.4
version: 1.5.4(febde88eaf587188179e6ecc47119e50)
version: 1.5.4(219be630f7f6fef2e235cef94eaddc25)
bl:
specifier: 6.0.11
version: 6.0.11
@@ -539,10 +539,10 @@ importers:
version: 5.9.3
vite-tsconfig-paths:
specifier: 4.3.2
version: 4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1))
version: 4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1))
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
apps/schedules:
dependencies:
@@ -630,10 +630,10 @@ importers:
version: 2.0.30(zod@4.3.6)
'@better-auth/api-key':
specifier: 1.5.4
version: 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(334901c35c1fcda64bb596793b2e4934))
version: 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(8933545d763d3f096150f97f9213a424))
'@better-auth/sso':
specifier: 1.5.4
version: 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(334901c35c1fcda64bb596793b2e4934))(better-call@2.0.2(zod@4.3.6))
version: 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(8933545d763d3f096150f97f9213a424))(better-call@2.0.2(zod@4.3.6))
'@better-auth/utils':
specifier: 0.3.1
version: 0.3.1
@@ -672,7 +672,7 @@ importers:
version: 5.1.1
better-auth:
specifier: 1.5.4
version: 1.5.4(334901c35c1fcda64bb596793b2e4934)
version: 1.5.4(8933545d763d3f096150f97f9213a424)
better-call:
specifier: 2.0.2
version: 2.0.2(zod@4.3.6)
@@ -700,6 +700,9 @@ importers:
drizzle-zod:
specifier: 0.5.1
version: 0.5.1(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))(zod@4.3.6)
hostinger-api-sdk:
specifier: ^0.0.17
version: 0.0.17
lodash:
specifier: 4.17.21
version: 4.17.21
@@ -775,7 +778,7 @@ importers:
devDependencies:
'@better-auth/cli':
specifier: 1.4.21
version: 1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(drizzle-kit@0.31.9)(jose@6.1.3)(kysely@0.28.11)(mongodb@7.1.0)(mysql2@3.15.3)(nanostores@1.1.1)(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1))
version: 1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(drizzle-kit@0.31.9)(jose@6.1.3)(kysely@0.28.11)(mongodb@7.1.0)(mysql2@3.15.3)(nanostores@1.1.1)(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1))
'@types/adm-zip':
specifier: ^0.5.7
version: 0.5.7
@@ -3460,6 +3463,7 @@ packages:
'@react-email/components@0.0.21':
resolution: {integrity: sha512-fwGfH7FF+iuq+IdPcbEO5HoF0Pakk9big+fFW9+3kiyvbSNuo8Io1rhPTMLd8q41XomN4g7mgWovdAeS/8PHrA==}
engines: {node: '>=18.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.2.0
@@ -5743,6 +5747,9 @@ packages:
resolution: {integrity: sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==}
engines: {node: '>=16.9.0'}
hostinger-api-sdk@0.0.17:
resolution: {integrity: sha512-PGIS2P4bwwvztlUHTdXYia7sAJsmDd9qsSE2tr8wDMAAjYow0J979w4dHcuHfC4ovo8nZoj3btqTDJtIIeSPYw==}
html-to-text@9.0.5:
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
engines: {node: '>=14'}
@@ -8618,21 +8625,21 @@ snapshots:
'@balena/dockerignore@1.0.2': {}
'@better-auth/api-key@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(334901c35c1fcda64bb596793b2e4934))':
'@better-auth/api-key@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(219be630f7f6fef2e235cef94eaddc25))':
dependencies:
'@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
better-auth: 1.5.4(334901c35c1fcda64bb596793b2e4934)
better-auth: 1.5.4(219be630f7f6fef2e235cef94eaddc25)
zod: 4.3.6
'@better-auth/api-key@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(febde88eaf587188179e6ecc47119e50))':
'@better-auth/api-key@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(8933545d763d3f096150f97f9213a424))':
dependencies:
'@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
better-auth: 1.5.4(febde88eaf587188179e6ecc47119e50)
better-auth: 1.5.4(8933545d763d3f096150f97f9213a424)
zod: 4.3.6
'@better-auth/cli@1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(drizzle-kit@0.31.9)(jose@6.1.3)(kysely@0.28.11)(mongodb@7.1.0)(mysql2@3.15.3)(nanostores@1.1.1)(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1))':
'@better-auth/cli@1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(drizzle-kit@0.31.9)(jose@6.1.3)(kysely@0.28.11)(mongodb@7.1.0)(mysql2@3.15.3)(nanostores@1.1.1)(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1))':
dependencies:
'@babel/core': 7.29.0
'@babel/preset-react': 7.28.5(@babel/core@7.29.0)
@@ -8644,7 +8651,7 @@ snapshots:
'@mrleebo/prisma-ast': 0.13.1
'@prisma/client': 5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3))
'@types/pg': 8.16.0
better-auth: 1.4.21(b1bc00b9e18c5e6af4e13b03fc4304ac)
better-auth: 1.4.21(db78b83f9b5449d160708cdf9d272aa3)
better-sqlite3: 12.6.2
c12: 3.3.3
chalk: 5.6.2
@@ -8778,24 +8785,24 @@ snapshots:
'@prisma/client': 5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3))
prisma: 7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)
'@better-auth/sso@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(334901c35c1fcda64bb596793b2e4934))(better-call@2.0.2(zod@4.3.6))':
'@better-auth/sso@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(219be630f7f6fef2e235cef94eaddc25))(better-call@2.0.2(zod@4.3.6))':
dependencies:
'@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
better-auth: 1.5.4(334901c35c1fcda64bb596793b2e4934)
better-auth: 1.5.4(219be630f7f6fef2e235cef94eaddc25)
better-call: 2.0.2(zod@4.3.6)
fast-xml-parser: 5.5.1
jose: 6.1.3
samlify: 2.10.2
zod: 4.3.6
'@better-auth/sso@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(febde88eaf587188179e6ecc47119e50))(better-call@2.0.2(zod@4.3.6))':
'@better-auth/sso@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(8933545d763d3f096150f97f9213a424))(better-call@2.0.2(zod@4.3.6))':
dependencies:
'@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
better-auth: 1.5.4(febde88eaf587188179e6ecc47119e50)
better-auth: 1.5.4(8933545d763d3f096150f97f9213a424)
better-call: 2.0.2(zod@4.3.6)
fast-xml-parser: 5.5.1
jose: 6.1.3
@@ -12275,6 +12282,7 @@ snapshots:
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
optional: true
'@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1))':
dependencies:
@@ -12283,7 +12291,6 @@ snapshots:
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
optional: true
'@vitest/pretty-format@4.0.18':
dependencies:
@@ -12488,7 +12495,7 @@ snapshots:
before-after-hook@2.2.3: {}
better-auth@1.4.21(b1bc00b9e18c5e6af4e13b03fc4304ac):
better-auth@1.4.21(db78b83f9b5449d160708cdf9d272aa3):
dependencies:
'@better-auth/core': 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))
@@ -12514,9 +12521,9 @@ snapshots:
prisma: 7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
better-auth@1.5.4(334901c35c1fcda64bb596793b2e4934):
better-auth@1.5.4(219be630f7f6fef2e235cef94eaddc25):
dependencies:
'@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/drizzle-adapter': 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))
@@ -12551,7 +12558,7 @@ snapshots:
transitivePeerDependencies:
- '@cloudflare/workers-types'
better-auth@1.5.4(febde88eaf587188179e6ecc47119e50):
better-auth@1.5.4(8933545d763d3f096150f97f9213a424):
dependencies:
'@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/drizzle-adapter': 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))
@@ -13733,6 +13740,12 @@ snapshots:
hono@4.12.2: {}
hostinger-api-sdk@0.0.17:
dependencies:
axios: 1.13.5
transitivePeerDependencies:
- debug
html-to-text@9.0.5:
dependencies:
'@selderee/plugin-htmlparser2': 0.11.0
@@ -16309,13 +16322,13 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
vite-tsconfig-paths@4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)):
vite-tsconfig-paths@4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)):
dependencies:
debug: 4.4.3
globrex: 0.1.2
tsconfck: 3.1.6(typescript@5.9.3)
optionalDependencies:
vite: 7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
transitivePeerDependencies:
- supports-color
- typescript
@@ -16334,6 +16347,7 @@ snapshots:
jiti: 1.21.7
tsx: 4.16.2
yaml: 2.8.1
optional: true
vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1):
dependencies:
@@ -16349,7 +16363,6 @@ snapshots:
jiti: 2.6.1
tsx: 4.16.2
yaml: 2.8.1
optional: true
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1):
dependencies:
@@ -16388,6 +16401,7 @@ snapshots:
- terser
- tsx
- yaml
optional: true
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1):
dependencies:
@@ -16426,7 +16440,6 @@ snapshots:
- terser
- tsx
- yaml
optional: true
w3c-keyname@2.2.8: {}