Compare commits

...

8 Commits

Author SHA1 Message Date
Mauricio Siu 12784ec0ff Merge remote-tracking branch 'origin/canary' into feature/managed-servers
# Conflicts:
#	apps/dokploy/drizzle/meta/0167_snapshot.json
#	apps/dokploy/drizzle/meta/0168_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
#	apps/dokploy/server/api/root.ts
#	pnpm-lock.yaml
2026-09-28 09:36:15 -06:00
Mauricio Siu 5bc53bfbff feat(managed-server): update managed server functionality and database schema
- Refactored the managed server creation process to utilize UpCloud services, including the introduction of new utility functions for server management.
- Updated the managed server schema to include new fields such as `providerVmId`, `zone`, and `sshKeyId`, enhancing server management capabilities.
- Implemented validation to prevent deletion of SSH keys that are in use by managed servers.
- Introduced a new SQL migration to add the `sshKeyId` column and associated foreign key constraints to the `managed_server` table.
- Enhanced the user experience by grouping available zones by continent in the UI.
- Updated pricing display logic to reflect new pricing structure and improved formatting.
2026-05-22 16:19:18 -06:00
Mauricio Siu 549b124fcd feat(database): add managed_server table and associated constraints
- Introduced a new SQL migration file "0167_dizzy_solo.sql" to create the "managed_server" table with relevant columns and a custom ENUM type for server status.
- Added foreign key constraints linking "organizationId" to the "organization" table and "serverId" to the "server" table, enhancing data integrity.
- Updated journal and snapshot metadata to reflect the new migration.
2026-05-13 01:25:40 -06:00
Mauricio Siu 0c5da0b36f Merge branch 'canary' into feature/managed-servers 2026-05-13 01:25:23 -06:00
Mauricio Siu ee18724dd7 chore(database): remove obsolete migration files for managed servers
- Deleted the SQL migration file and associated journal entry for the "0166_lame_meltdown" migration, as it is no longer required.
- This cleanup contributes to a more organized migration history and minimizes potential confusion in future database management.
2026-05-13 01:25:05 -06:00
Mauricio Siu 1ae9b4025c chore(database): remove deprecated cultured captain cross migration files
- Deleted the SQL migration file and associated journal entry for the "0167_cultured_captain_cross" migration, as it is no longer needed.
- This cleanup helps maintain a tidy migration history and reduces potential confusion in future database management.
2026-05-13 01:24:24 -06:00
Mauricio Siu 5bc870dc2d fix(hostinger): add error handling to getHostingerDataCenters function
- Wrapped the API call in a try/catch block to log errors when fetching data centers.
- Reformatted the OFFERED_PLAN_IDS array for improved readability.

This update enhances the robustness of the Hostinger data center retrieval process.
2026-05-07 13:36:30 -06:00
Mauricio Siu 299950a323 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.
2026-05-05 08:10:30 -06:00
21 changed files with 19234 additions and 2 deletions
+8403
View File
File diff suppressed because it is too large Load Diff
+3
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=
@@ -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",
@@ -10,6 +10,7 @@ import {
Loader2,
MinusIcon,
PlusIcon,
Server,
ShieldCheck,
} from "lucide-react";
import Link from "next/link";
@@ -83,6 +84,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",
@@ -0,0 +1,502 @@
import {
AlertCircle,
CheckCircle2,
Clock,
CreditCard,
ExternalLink,
FileText,
Loader2,
Plus,
RefreshCw,
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, memoryGb: number, storageGb: number) {
return `${cpus} vCPU · ${memoryGb} GB RAM · ${storageGb} GB NVMe`;
}
function formatPrice(priceCents: number) {
return `$${(priceCents / 100).toFixed(2)}/mo`;
}
const CONTINENT_ORDER = ["Europe", "Americas", "Asia-Pacific"];
function OrderServerDialog({ onSuccess }: { onSuccess: () => void }) {
const [open, setOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<string>("");
const [selectedZone, setSelectedZone] = useState<string>("");
const { data: plans, isLoading: loadingPlans } =
api.managedServer.getPlans.useQuery(undefined, { enabled: open });
const { data: zones, isLoading: loadingZones } =
api.managedServer.getDataCenters.useQuery(undefined, { enabled: open });
const isLoadingOptions = loadingPlans || loadingZones;
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 groupedZones = zones?.reduce<Record<string, typeof zones>>((acc, z) => {
if (!acc[z.continent]) acc[z.continent] = [];
acc[z.continent]!.push(z);
return acc;
}, {});
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-5">
{/* Plan cards */}
<div className="space-y-2">
<Label>Plan</Label>
<div className="grid grid-cols-2 gap-2">
{plans?.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setSelectedPlan(p.id)}
className={cn(
"flex flex-col gap-1 rounded-lg border p-3 text-left transition-colors",
selectedPlan === p.id
? "border-primary bg-primary/5"
: "border-border hover:border-muted-foreground",
)}
>
<div className="flex items-center justify-between">
<p className="font-semibold text-sm">{p.name}</p>
<p className="text-sm font-medium text-primary">
{formatPrice(p.priceCents)}
</p>
</div>
<p className="text-xs text-muted-foreground">
{formatSpecs(p.cpus, p.memoryGb, p.storageGb)}
</p>
</button>
))}
</div>
</div>
{/* Zone selector grouped by continent */}
<div className="space-y-2">
<Label>Location</Label>
<Select value={selectedZone} onValueChange={setSelectedZone}>
<SelectTrigger>
<SelectValue placeholder="Select a location..." />
</SelectTrigger>
<SelectContent
position="popper"
side="bottom"
sideOffset={4}
className="max-h-64 overflow-y-auto"
>
{CONTINENT_ORDER.filter((c) => groupedZones?.[c]).map((continent) => (
<div key={continent}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{continent}
</div>
{groupedZones?.[continent]?.map((z) => (
<SelectItem key={z.id} value={z.id}>
{z.description}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
</div>
{plan && selectedZone && (
<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">Specs</span>
<span className="font-medium">
{formatSpecs(plan.cpus, plan.memoryGb, plan.storageGb)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Price</span>
<span className="font-medium">{formatPrice(plan.priceCents)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Zone</span>
<span className="font-medium">
{zones?.find((z) => z.id === selectedZone)?.description ?? selectedZone}
</span>
</div>
</div>
)}
<Button
className="w-full"
disabled={!selectedPlan || !selectedZone || purchase.isPending}
onClick={() => {
if (!selectedPlan || !selectedZone) return;
purchase.mutate({
plan: selectedPlan,
zone: selectedZone,
});
}}
>
{purchase.isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Placing order...
</>
) : (
"Order Server"
)}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
const PLAN_LABELS: Record<string, string> = {
hobby: "Hobby",
starter: "Starter",
pro: "Pro",
business: "Business",
};
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 reconnect = api.managedServer.reconnect.useMutation({
onSuccess: () => {
toast.success("Server reconnected successfully.");
utils.managedServer.list.invalidate();
},
onError: (err) => toast.error(err.message),
});
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);
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">
{PLAN_LABELS[s.plan] ?? s.plan}
</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}` : ""}
{s.zone ? ` · ${s.zone}` : ""}
</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.serverId && (
<Button
variant="outline"
size="sm"
onClick={() =>
reconnect.mutate({
managedServerId: s.managedServerId,
})
}
disabled={reconnect.isPending}
>
{reconnect.isPending ? (
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
) : (
<RefreshCw className="size-3.5 mr-1.5" />
)}
Reconnect
</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>
);
};
@@ -0,0 +1,22 @@
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" text NOT NULL,
"status" "managedServerStatus" DEFAULT 'pending' NOT NULL,
"providerVmId" text,
"zone" text NOT NULL,
"sshKeyId" text,
"ipAddress" text,
"hostname" text,
"stripeSubscriptionId" text,
"stripePriceId" 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;--> statement-breakpoint
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_sshKeyId_ssh-key_sshKeyId_fk" FOREIGN KEY ("sshKeyId") REFERENCES "public"."ssh-key"("sshKeyId") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1380,6 +1380,13 @@
"when": 1788995453103,
"tag": "0196_worthless_ravenous",
"breakpoints": true
},
{
"idx": 197,
"version": "7",
"when": 1790609715152,
"tag": "0197_aberrant_psynapse",
"breakpoints": true
}
]
}
@@ -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: {} };
}
+2
View File
@@ -38,6 +38,7 @@ import { auditLogRouter } from "./routers/proprietary/audit-log";
import { customRoleRouter } from "./routers/proprietary/custom-role";
import { forwardAuthRouter } from "./routers/proprietary/forward-auth";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { managedServerRouter } from "./routers/proprietary/managed-server";
import { scimRouter } from "./routers/proprietary/scim";
import { ssoRouter } from "./routers/proprietary/sso";
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
@@ -122,6 +123,7 @@ export const appRouter = createTRPCRouter({
patch: patchRouter,
overview: overviewRouter,
transfer: transferRouter,
managedServer: managedServerRouter,
});
// export type definition of API
@@ -0,0 +1,333 @@
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 { createSshKey } from "@dokploy/server/services/ssh-key";
import { generateSSHKey } from "@dokploy/server/utils/filesystem/ssh";
import {
DOKPLOY_PLANS,
createUpCloudServer,
deleteUpCloudServer,
getPublicIPv4,
getUpCloudServer,
getUpCloudZones,
stopUpCloudServer,
} from "@dokploy/server/utils/upcloud";
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 DOKPLOY_PLANS;
}),
getDataCenters: adminProcedure.query(async () => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Managed servers are only available in Dokploy Cloud",
});
}
return getUpCloudZones();
}),
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 plan = DOKPLOY_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,
zone: input.zone,
status: "provisioning",
});
provisionManagedServer(
managedRecord.managedServerId,
plan.upcloudPlan,
input.zone,
hostname,
ctx.session.activeOrganizationId,
).catch(async (err) => {
const responseBody = err?.response?.data;
const detail =
responseBody?.message ??
responseBody?.error ??
(typeof responseBody === "string" ? responseBody : null);
const errorMessage = detail
? `${err?.message}: ${typeof detail === "object" ? JSON.stringify(detail) : detail}`
: (err?.message ?? "Unknown error during provisioning");
console.error("[managed-server] provisioning failed:", errorMessage, responseBody);
await updateManagedServer(managedRecord.managedServerId, {
status: "error",
errorMessage,
});
});
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",
});
const upcloudUuid = record.providerVmId;
terminateManagedServer(input.managedServerId, upcloudUuid ?? null).catch(
(err) => console.error("[managed-server] termination failed:", err),
);
return { ok: true };
}),
reconnect: 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.serverId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Server is already linked",
});
}
if (!record.ipAddress) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No IP address available to reconnect",
});
}
if (!record.sshKeyId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No SSH key found for this managed server",
});
}
const name = record.hostname ?? record.managedServerId;
const serverRecord = await createServer(
{
name: `Managed • ${name}`,
description: "Managed server provisioned by Dokploy Cloud",
ipAddress: record.ipAddress,
port: 22,
username: "root",
serverType: "deploy",
enableDockerCleanup: true,
sshKeyId: record.sshKeyId,
},
ctx.session.activeOrganizationId,
);
await updateManagedServer(input.managedServerId, {
serverId: serverRecord.serverId,
status: "ready",
});
return findManagedServerById(input.managedServerId);
}),
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" });
}
const upcloudUuid = record.providerVmId;
if (!upcloudUuid) return record;
const vm = await getUpCloudServer(upcloudUuid);
const ipAddress = getPublicIPv4(vm) ?? record.ipAddress ?? undefined;
await updateManagedServer(input.managedServerId, {
ipAddress,
hostname: vm.hostname ?? undefined,
status:
vm.state === "started"
? record.serverId
? "ready"
: "configuring"
: record.status,
});
return findManagedServerById(input.managedServerId);
}),
});
async function provisionManagedServer(
managedServerId: string,
upcloudPlan: string,
zone: string,
hostname: string,
organizationId: string,
) {
const { publicKey, privateKey } = await generateSSHKey("rsa");
const vm = await createUpCloudServer({
hostname,
upcloudPlan,
zone,
sshKey: publicKey,
});
await updateManagedServer(managedServerId, {
providerVmId: vm.uuid,
hostname: vm.hostname,
status: "configuring",
});
await waitForServerStarted(vm.uuid, managedServerId);
const finalVm = await getUpCloudServer(vm.uuid);
const finalIp = getPublicIPv4(finalVm);
if (!finalIp) {
throw new Error("VM is started but has no public IPv4 address");
}
const sshKey = await createSshKey({
name: hostname,
publicKey,
privateKey,
organizationId,
});
if (!sshKey) throw new Error("Failed to create SSH key");
const serverRecord = await createServer(
{
name: `Managed • ${hostname}`,
description: "Managed server provisioned by Dokploy Cloud",
ipAddress: finalIp,
port: 22,
username: "root",
serverType: "deploy",
enableDockerCleanup: true,
sshKeyId: sshKey.sshKeyId,
},
organizationId,
);
await updateManagedServer(managedServerId, {
serverId: serverRecord.serverId,
sshKeyId: sshKey.sshKeyId,
ipAddress: finalIp,
});
await serverSetup(serverRecord.serverId);
await updateManagedServer(managedServerId, { status: "ready" });
}
async function waitForServerStarted(
uuid: string,
_managedServerId: string,
maxAttempts = 30,
intervalMs = 10_000,
) {
for (let i = 0; i < maxAttempts; i++) {
await new Promise((r) => setTimeout(r, intervalMs));
const vm = await getUpCloudServer(uuid);
if (vm.state === "started") return;
if (vm.state === "error") throw new Error("VM entered error state");
}
throw new Error("Timed out waiting for VM to start");
}
async function waitForServerStopped(
uuid: string,
maxAttempts = 18,
intervalMs = 10_000,
) {
for (let i = 0; i < maxAttempts; i++) {
await new Promise((r) => setTimeout(r, intervalMs));
const vm = await getUpCloudServer(uuid);
if (vm.state === "stopped") return;
}
}
async function terminateManagedServer(
managedServerId: string,
upcloudUuid: string | null,
) {
if (upcloudUuid) {
await stopUpCloudServer(upcloudUuid);
await waitForServerStopped(upcloudUuid);
await deleteUpCloudServer(upcloudUuid);
}
await deleteManagedServer(managedServerId);
}
@@ -6,6 +6,7 @@ import {
updateSSHKeyById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { managedServer } from "@dokploy/server/db/schema/managed-server";
import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm";
import {
@@ -57,6 +58,16 @@ export const sshRouter = createTRPCRouter({
});
}
const linkedManagedServer = await db.query.managedServer.findFirst({
where: eq(managedServer.sshKeyId, input.sshKeyId),
});
if (linkedManagedServer) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This SSH key is used by a managed server and cannot be deleted.",
});
}
await audit(ctx, {
action: "delete",
resourceType: "sshKey",
+1
View File
@@ -65,6 +65,7 @@
"drizzle-dbml-generator": "0.10.0",
"drizzle-orm": "0.45.2",
"drizzle-zod": "0.5.1",
"hostinger-api-sdk": "^0.0.17",
"lodash": "4.17.21",
"micromatch": "4.0.8",
"nanoid": "3.3.18",
+1
View File
@@ -17,6 +17,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";
@@ -0,0 +1,76 @@
import { relations } from "drizzle-orm";
import { pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
import { server } from "./server";
import { sshKeys } from "./ssh-key";
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",
}),
/** UpCloud plan name, e.g. "2xCPU-4GB" */
plan: text("plan").notNull(),
status: managedServerStatus("status").notNull().default("pending"),
/** UpCloud server UUID */
providerVmId: text("providerVmId"),
/** UpCloud zone ID, e.g. "fi-hel1" */
zone: text("zone").notNull(),
/** SSH key used to access this server */
sshKeyId: text("sshKeyId").references(() => sshKeys.sshKeyId, {
onDelete: "set null",
}),
ipAddress: text("ipAddress"),
hostname: text("hostname"),
stripeSubscriptionId: text("stripeSubscriptionId"),
stripePriceId: text("stripePriceId"),
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),
zone: z.string().min(1),
});
export const apiFindOneManagedServer = z.object({
managedServerId: z.string().min(1),
});
export const apiDeleteManagedServer = z.object({
managedServerId: z.string().min(1),
});
+1
View File
@@ -50,6 +50,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/server-health";
export * from "./services/settings";
@@ -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));
};
+1 -1
View File
@@ -11,7 +11,7 @@ import { eq } from "drizzle-orm";
import type { z } from "zod";
export const createSshKey = async (input: z.infer<typeof apiCreateSshKey>) => {
await db.transaction(async (tx) => {
return db.transaction(async (tx) => {
const sshKey = await tx
.insert(sshKeys)
.values(input)
+205
View File
@@ -0,0 +1,205 @@
import {
BillingCatalogApi,
BillingSubscriptionsApi,
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() {
try {
const api = new VPSDataCentersApi(getConfig());
const res = await api.getDataCenterListV1();
return res.data;
} catch (error) {
console.log(error);
}
}
export async function getHostingerVpsCatalog() {
const api = new BillingCatalogApi(getConfig());
const res = await api.getCatalogItemListV1("VPS");
return res.data;
}
const FAKE_VM_ID = 999999;
export async function purchaseHostingerVps(
body: import("hostinger-api-sdk").VPSV1VirtualMachinePurchaseRequest,
): Promise<import("hostinger-api-sdk").BillingV1OrderVirtualMachineOrderResource> {
if (process.env.HOSTINGER_DRY_RUN === "true") {
console.log("[hostinger dry-run] purchaseHostingerVps called, skipping real purchase");
return {
virtual_machine: {
id: FAKE_VM_ID,
hostname: body.setup.hostname,
state: "initial",
ipv4: [],
subscription_id: "dry-run-sub-123",
},
} as never;
}
const api = getVmApi();
const res = await api.purchaseNewVirtualMachineV1(body);
return res.data;
}
export async function getHostingerVm(vmId: number) {
if (process.env.HOSTINGER_DRY_RUN === "true") {
console.log(`[hostinger dry-run] getHostingerVm(${vmId})`);
return {
id: vmId,
hostname: "dry-run-vm",
state: "running",
ipv4: [{ address: "1.2.3.4" }],
subscription_id: "dry-run-sub-123",
} as never;
}
const api = getVmApi();
const res = await api.getVirtualMachineDetailsV1(vmId);
return res.data;
}
export async function stopHostingerVm(vmId: number) {
if (process.env.HOSTINGER_DRY_RUN === "true") {
console.log(`[hostinger dry-run] stopHostingerVm(${vmId})`);
return;
}
const api = getVmApi();
await api.stopVirtualMachineV1(vmId);
}
export async function cancelHostingerSubscription(subscriptionId: string) {
if (process.env.HOSTINGER_DRY_RUN === "true") {
console.log(`[hostinger dry-run] cancelHostingerSubscription(${subscriptionId})`);
return;
}
const api = new BillingSubscriptionsApi(getConfig());
await api.cancelSubscriptionV1(subscriptionId, {
reason_code: "other",
cancel_option: "immediately",
});
}
/** 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;
+220
View File
@@ -0,0 +1,220 @@
import { defaultCommand } from "../setup/server-setup";
const UPCLOUD_BASE = "https://api.upcloud.com/1.3";
const UBUNTU_22_TEMPLATE_UUID = "01000000-0000-4000-8000-000030220200";
export { UBUNTU_22_TEMPLATE_UUID };
export interface DokployPlan {
id: string;
name: string;
priceCents: number;
upcloudPlan: string;
cpus: number;
memoryGb: number;
storageGb: number;
}
export const DOKPLOY_PLANS: DokployPlan[] = [
{
id: "hobby",
name: "Hobby",
priceCents: 2499,
upcloudPlan: "1xCPU-2GB",
cpus: 1,
memoryGb: 2,
storageGb: 50,
},
{
id: "starter",
name: "Starter",
priceCents: 4900,
upcloudPlan: "2xCPU-4GB",
cpus: 2,
memoryGb: 4,
storageGb: 80,
},
{
id: "pro",
name: "Pro",
priceCents: 9900,
upcloudPlan: "4xCPU-8GB",
cpus: 4,
memoryGb: 8,
storageGb: 160,
},
{
id: "business",
name: "Business",
priceCents: 19900,
upcloudPlan: "HIMEM-4xCPU-16GB",
cpus: 4,
memoryGb: 16,
storageGb: 320,
},
];
function getHeaders() {
const token = process.env.UPCLOUD_TOKEN;
if (!token) throw new Error("UPCLOUD_TOKEN is not set");
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json",
};
}
async function upcloudFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const res = await fetch(`${UPCLOUD_BASE}${path}`, {
...options,
headers: { ...getHeaders(), ...(options.headers ?? {}) },
});
if (!res.ok) {
let body: unknown;
try {
body = await res.json();
} catch {
body = await res.text();
}
const err = new Error(
`UpCloud API error ${res.status}: ${JSON.stringify(body)}`,
) as Error & { response: { status: number; data: unknown } };
err.response = { status: res.status, data: body };
throw err;
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export interface UpCloudZone {
id: string;
description: string;
public: "yes" | "no";
}
export type UpCloudZoneContinent = "Europe" | "Americas" | "Asia-Pacific";
export interface UpCloudZoneWithContinent extends UpCloudZone {
continent: UpCloudZoneContinent;
}
const ZONE_CONTINENT: Record<string, UpCloudZoneContinent> = {
"fi-hel1": "Europe",
"fi-hel2": "Europe",
"de-fra1": "Europe",
"nl-ams1": "Europe",
"uk-lon1": "Europe",
"se-sto1": "Europe",
"dk-cph1": "Europe",
"no-svg1": "Europe",
"pl-waw1": "Europe",
"es-mad1": "Europe",
"us-nyc1": "Americas",
"us-chi1": "Americas",
"us-sjo1": "Americas",
"sg-sin1": "Asia-Pacific",
"au-syd1": "Asia-Pacific",
};
export async function getUpCloudZones(): Promise<UpCloudZoneWithContinent[]> {
const data = await upcloudFetch<{ zones: { zone: UpCloudZone[] } }>("/zone");
return data.zones.zone
.filter((z) => z.public === "yes" && z.id in ZONE_CONTINENT)
.map((z) => ({ ...z, continent: ZONE_CONTINENT[z.id]! }))
.sort(
(a, b) =>
a.continent.localeCompare(b.continent) ||
a.description.localeCompare(b.description),
);
}
export interface UpCloudServer {
uuid: string;
hostname: string;
title: string;
state: "started" | "stopped" | "maintenance" | "error";
zone: string;
plan: string;
core_number: string;
memory_amount: string;
ip_addresses: {
ip_address: Array<{
access: "public" | "utility" | "private";
address: string;
family: "IPv4" | "IPv6";
}>;
};
}
export async function createUpCloudServer(params: {
hostname: string;
upcloudPlan: string;
zone: string;
sshKey: string;
}): Promise<UpCloudServer> {
const userDataScript = `#!/bin/bash\n${defaultCommand(false)}`;
const body = {
server: {
zone: params.zone,
title: params.hostname,
hostname: params.hostname,
plan: params.upcloudPlan,
firewall: "on",
metadata: "yes",
user_data: userDataScript,
login_user: {
username: "root",
ssh_keys: {
ssh_key: [params.sshKey],
},
create_password: "no",
},
storage_devices: {
storage_device: [
{
action: "clone",
storage: UBUNTU_22_TEMPLATE_UUID,
title: `${params.hostname}-disk`,
tier: "maxiops",
},
],
},
},
};
const data = await upcloudFetch<{ server: UpCloudServer }>("/server", {
method: "POST",
body: JSON.stringify(body),
});
return data.server;
}
export async function getUpCloudServer(uuid: string): Promise<UpCloudServer> {
const data = await upcloudFetch<{ server: UpCloudServer }>(`/server/${uuid}`);
return data.server;
}
export async function stopUpCloudServer(uuid: string): Promise<void> {
await upcloudFetch(`/server/${uuid}/stop`, {
method: "POST",
body: JSON.stringify({ stop_server: { stop_type: "hard", timeout: "60" } }),
});
}
export async function deleteUpCloudServer(uuid: string): Promise<void> {
await upcloudFetch(`/server/${uuid}?storages=1&backups=delete`, {
method: "DELETE",
});
}
export function getPublicIPv4(server: UpCloudServer): string | undefined {
return server.ip_addresses.ip_address.find(
(ip) => ip.access === "public" && ip.family === "IPv4",
)?.address;
}
+13
View File
@@ -680,6 +680,9 @@ importers:
drizzle-zod:
specifier: 0.5.1
version: 0.5.1(drizzle-orm@0.45.2(@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@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.29.3)(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@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)))(zod@4.3.6)
hostinger-api-sdk:
specifier: ^0.0.17
version: 0.0.17
lodash:
specifier: 4.17.21
version: 4.17.21
@@ -6307,6 +6310,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'}
@@ -15391,6 +15397,13 @@ snapshots:
hono@4.12.2: {}
hostinger-api-sdk@0.0.17:
dependencies:
axios: 1.18.0
transitivePeerDependencies:
- debug
- supports-color
html-to-text@9.0.5:
dependencies:
'@selderee/plugin-htmlparser2': 0.11.0