homeio
PublicForked from doctor-io/homeio
custom-apps.ts
import "server-only";
import { randomUUID } from "node:crypto";
import path from "node:path";
import { desc, eq, sql } from "drizzle-orm";
import yaml from "js-yaml";
import { db } from "@/lib/server/db/drizzle";
import { customStoreApps } from "@/lib/server/db/schema";
import { withServerTiming } from "@/lib/server/logging/logger";
import { findInstalledStackByAppId } from "@/lib/server/modules/apps/stacks-repository";
import {
parseComposeContentToApp,
type AppDefinition,
} from "@/lib/server/modules/store/casaos-compose-mapper";
import type { StoreCatalogTemplate } from "@/lib/server/modules/store/catalog";
export type CustomStoreSourceType = "docker-compose" | "docker-run";
export type CustomStoreTemplate = AppDefinition & {
isCustom: true;
sourceType: CustomStoreSourceType;
composeContent: string;
sourceText: string;
};
export type StoreTemplateSource = StoreCatalogTemplate | CustomStoreTemplate;
type UpsertCustomStoreTemplateInput = {
name: string;
iconUrl?: string;
sourceType: CustomStoreSourceType;
sourceText: string;
repositoryUrl?: string;
};
type ComposeLike = {
name?: unknown;
services?: Record<string, Record<string, unknown>>;
[key: string]: unknown;
};
function normalize(value: string | undefined) {
if (!value) return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function slugify(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function sanitizeServiceName(value: string) {
const sanitized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "");
return sanitized || "app";
}
function quoteYaml(value: string) {
return `'${value.replace(/'/g, "''")}'`;
}
function splitShellCommand(input: string) {
const tokens: string[] = [];
let current = "";
let quote: "'" | '"' | null = null;
for (let index = 0; index < input.length; index += 1) {
const char = input[index];
if (quote) {
if (char === quote) {
quote = null;
continue;
}
if (char === "\\" && quote === '"' && index + 1 < input.length) {
current += input[index + 1];
index += 1;
continue;
}
current += char;
continue;
}
if (char === "'" || char === '"') {
quote = char;
continue;
}
if (/\s/.test(char)) {
if (current.length > 0) {
tokens.push(current);
current = "";
}
continue;
}
if (char === "\\" && index + 1 < input.length) {
current += input[index + 1];
index += 1;
continue;
}
current += char;
}
if (quote) {
throw new Error("Invalid docker run command: unmatched quote");
}
if (current.length > 0) {
tokens.push(current);
}
return tokens;
}
function readOptionValue(tokens: string[], index: number) {
if (index + 1 >= tokens.length) {
throw new Error(`Invalid docker run command: missing value for ${tokens[index]}`);
}
return tokens[index + 1];
}
export function convertDockerRunToCompose(command: string, fallbackServiceName: string) {
const tokens = splitShellCommand(command.trim());
let index = 0;
if (tokens[index] === "docker") {
index += 1;
}
if (tokens[index] === "container" && tokens[index + 1] === "run") {
index += 2;
} else if (tokens[index] === "run") {
index += 1;
} else {
throw new Error("Invalid docker run command: must start with `docker run`");
}
const env: string[] = [];
const ports: string[] = [];
const volumes: string[] = [];
let containerName = "";
while (index < tokens.length) {
const token = tokens[index];
if (token === "--") {
index += 1;
break;
}
if (!token.startsWith("-")) {
break;
}
if (token === "--name") {
containerName = readOptionValue(tokens, index);
index += 2;
continue;
}
if (token.startsWith("--name=")) {
containerName = token.slice("--name=".length);
index += 1;
continue;
}
if (token === "-p" || token === "--publish") {
ports.push(readOptionValue(tokens, index));
index += 2;
continue;
}
if (token.startsWith("--publish=")) {
ports.push(token.slice("--publish=".length));
index += 1;
continue;
}
if (token.startsWith("-p") && token.length > 2) {
ports.push(token.slice(2));
index += 1;
continue;
}
if (token === "-e" || token === "--env") {
env.push(readOptionValue(tokens, index));
index += 2;
continue;
}
if (token.startsWith("--env=")) {
env.push(token.slice("--env=".length));
index += 1;
continue;
}
if (token.startsWith("-e") && token.length > 2) {
env.push(token.slice(2));
index += 1;
continue;
}
if (token === "-v" || token === "--volume") {
volumes.push(readOptionValue(tokens, index));
index += 2;
continue;
}
if (token.startsWith("--volume=")) {
volumes.push(token.slice("--volume=".length));
index += 1;
continue;
}
if (token.startsWith("-v") && token.length > 2) {
volumes.push(token.slice(2));
index += 1;
continue;
}
if (token.includes("=")) {
index += 1;
continue;
}
if (token.startsWith("--") && index + 1 < tokens.length && !tokens[index + 1].startsWith("-")) {
index += 2;
continue;
}
index += 1;
}
const image = tokens[index];
if (!image) {
throw new Error("Invalid docker run command: image is required");
}
index += 1;
const commandArgs = tokens.slice(index);
const serviceName = sanitizeServiceName(containerName || fallbackServiceName);
const lines = [
"services:",
` ${serviceName}:`,
` image: ${quoteYaml(image)}`,
" restart: unless-stopped",
];
if (containerName) {
lines.push(` container_name: ${quoteYaml(containerName)}`);
}
if (env.length > 0) {
lines.push(" environment:");
for (const item of env) {
lines.push(` - ${quoteYaml(item)}`);
}
}
if (ports.length > 0) {
lines.push(" ports:");
for (const port of ports) {
lines.push(` - ${quoteYaml(port)}`);
}
}
if (volumes.length > 0) {
lines.push(" volumes:");
for (const volume of volumes) {
lines.push(` - ${quoteYaml(volume)}`);
}
}
if (commandArgs.length > 0) {
lines.push(` command: ${quoteYaml(commandArgs.join(" "))}`);
}
return lines.join("\n");
}
function parseComposeDocument(content: string) {
const parsed = yaml.load(content) as ComposeLike | null;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Docker compose source must be valid YAML");
}
if (!parsed.services || typeof parsed.services !== "object" || Array.isArray(parsed.services)) {
throw new Error("Docker compose source must include a services section");
}
return parsed;
}
function parseFirstPublishedPort(service: Record<string, unknown>) {
const ports = service.ports;
if (!Array.isArray(ports)) return null;
for (const entry of ports) {
if (typeof entry === "string") {
const [mappingPart] = entry.split("/");
const segments = mappingPart.split(":").filter(Boolean);
const published = segments.length >= 2 ? segments[segments.length - 2] : segments[0];
const parsed = Number.parseInt(published, 10);
if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535) {
return parsed;
}
continue;
}
if (!entry || typeof entry !== "object") continue;
const candidate = entry as { published?: unknown; target?: unknown };
const published = typeof candidate.published === "number" || typeof candidate.published === "string"
? Number.parseInt(String(candidate.published), 10)
: typeof candidate.target === "number" || typeof candidate.target === "string"
? Number.parseInt(String(candidate.target), 10)
: NaN;
if (Number.isInteger(published) && published >= 1 && published <= 65535) {
return published;
}
}
return null;
}
function augmentCustomComposeMetadata(input: {
appId: string;
name: string;
iconUrl?: string;
sourceType: CustomStoreSourceType;
composeContent: string;
}) {
const parsed = parseComposeDocument(input.composeContent);
const services = parsed.services as Record<string, Record<string, unknown>>;
const existingCasaos =
parsed["x-casaos"] && typeof parsed["x-casaos"] === "object" && !Array.isArray(parsed["x-casaos"])
? (parsed["x-casaos"] as Record<string, unknown>)
: {};
const serviceNames = Object.keys(services);
const mainService =
typeof existingCasaos.main === "string" && services[existingCasaos.main]
? existingCasaos.main
: serviceNames[0] ?? "app";
const detectedPort = parseFirstPublishedPort(services[mainService] ?? {});
parsed.name = typeof parsed.name === "string" && parsed.name.trim().length > 0 ? parsed.name : input.appId;
parsed["x-casaos"] = {
...existingCasaos,
main: mainService,
title:
existingCasaos.title && typeof existingCasaos.title === "object"
? existingCasaos.title
: { en_us: input.name },
icon:
typeof existingCasaos.icon === "string" && existingCasaos.icon.trim().length > 0
? existingCasaos.icon
: input.iconUrl,
category:
typeof existingCasaos.category === "string" && existingCasaos.category.trim().length > 0
? existingCasaos.category
: "Custom",
description:
existingCasaos.description && typeof existingCasaos.description === "object"
? existingCasaos.description
: {
en_us:
input.sourceType === "docker-run"
? "Custom app installed from docker run"
: "Custom app installed from docker compose",
},
tips:
existingCasaos.tips && typeof existingCasaos.tips === "object"
? existingCasaos.tips
: {
before_install: {
en_us: "Managed as a custom CasaOS compose app.",
},
},
scheme:
typeof existingCasaos.scheme === "string" && existingCasaos.scheme.trim().length > 0
? existingCasaos.scheme
: "http",
index:
typeof existingCasaos.index === "string" && existingCasaos.index.trim().length > 0
? existingCasaos.index
: "/",
port_map:
existingCasaos.port_map ?? (detectedPort !== null ? String(detectedPort) : undefined),
};
return yaml.dump(parsed, {
lineWidth: -1,
noRefs: true,
sortKeys: false,
});
}
function mapRow(row: typeof customStoreApps.$inferSelect): CustomStoreTemplate {
const sourceType =
row.sourceType === "docker-run" || row.sourceType === "docker-compose"
? row.sourceType
: "docker-compose";
const virtualComposePath = path.join(process.cwd(), "custom", row.appId, "docker-compose.yml");
const parsed = parseComposeContentToApp(virtualComposePath, row.composeContent);
return {
...parsed,
repositoryUrl: row.repositoryUrl ?? "custom://local",
stackFile: `custom/${row.appId}/docker-compose.yml`,
isCustom: true,
sourceType,
composeContent: row.composeContent,
sourceText: row.sourceText,
};
}
async function hasCustomStoreAppsTable() {
const check = await db.execute<{ table_exists: string | null }>(
sql`SELECT to_regclass('public.custom_store_apps') AS table_exists`,
);
return Boolean(check.rows[0]?.table_exists);
}
function normalizeComposeContent(input: {
sourceType: CustomStoreSourceType;
sourceText: string;
fallbackServiceName: string;
}) {
const sourceText = input.sourceText.trim();
if (!sourceText) {
throw new Error("Custom app source cannot be empty");
}
if (input.sourceType === "docker-run") {
return convertDockerRunToCompose(sourceText, input.fallbackServiceName);
}
parseComposeDocument(sourceText);
return sourceText;
}
export function isCustomStoreTemplate(template: StoreTemplateSource): template is CustomStoreTemplate {
return "isCustom" in template && template.isCustom === true;
}
export async function listCustomStoreTemplates() {
return withServerTiming(
{
level: "debug",
layer: "service",
action: "store.customApps.list",
},
async () => {
if (!(await hasCustomStoreAppsTable())) return [];
const rows = await db
.select()
.from(customStoreApps)
.orderBy(desc(customStoreApps.updatedAt));
return rows.map(mapRow);
},
);
}
export async function findCustomStoreTemplateByAppId(appId: string) {
return withServerTiming(
{
level: "debug",
layer: "service",
action: "store.customApps.findById",
meta: { appId },
},
async () => {
if (!(await hasCustomStoreAppsTable())) return null;
const rows = await db
.select()
.from(customStoreApps)
.where(eq(customStoreApps.appId, appId))
.limit(1);
const row = rows[0];
return row ? mapRow(row) : null;
},
);
}
export async function upsertCustomStoreTemplate(input: UpsertCustomStoreTemplateInput) {
return withServerTiming(
{
layer: "service",
action: "store.customApps.upsert",
meta: { sourceType: input.sourceType },
},
async () => {
if (!(await hasCustomStoreAppsTable())) {
throw new Error("custom_store_apps table is missing. Run `npm run db:init`.");
}
const name = input.name.trim();
if (!name) {
throw new Error("Custom app name is required");
}
const slug = slugify(name);
const appId = slug ? `custom-${slug}` : `custom-${randomUUID().slice(0, 8)}`;
const composeContent = normalizeComposeContent({
sourceType: input.sourceType,
sourceText: input.sourceText,
fallbackServiceName: name,
});
const casaosComposeContent = augmentCustomComposeMetadata({
appId,
name,
iconUrl: input.iconUrl,
sourceType: input.sourceType,
composeContent,
});
const rows = await db
.insert(customStoreApps)
.values({
appId,
name,
iconUrl: normalize(input.iconUrl),
webUiUrl: null,
sourceType: input.sourceType,
sourceText: input.sourceText.trim(),
composeContent: casaosComposeContent,
repositoryUrl: normalize(input.repositoryUrl),
updatedAt: sql`NOW()`,
})
.onConflictDoUpdate({
target: customStoreApps.appId,
set: {
name,
iconUrl: normalize(input.iconUrl),
webUiUrl: null,
sourceType: input.sourceType,
sourceText: input.sourceText.trim(),
composeContent: casaosComposeContent,
repositoryUrl: normalize(input.repositoryUrl),
updatedAt: sql`NOW()`,
},
})
.returning();
return mapRow(rows[0]);
},
);
}
/**
* Forget a custom app definition. Trial-and-error compose pastes pile up in the
* Custom tab with no way out (#35).
*
* The stack has to be uninstalled first: the definition is what redeploys and
* updates read from, so dropping it under a running app would strand it.
*/
export async function deleteCustomStoreTemplate(appId: string) {
return withServerTiming(
{
layer: "service",
action: "store.customApps.delete",
meta: { appId },
},
async () => {
if (!(await hasCustomStoreAppsTable())) {
throw new Error("Custom app not found");
}
const installed = await findInstalledStackByAppId(appId);
if (installed && installed.status !== "not_installed") {
throw new Error("Uninstall this app before removing its custom definition");
}
const rows = await db
.delete(customStoreApps)
.where(eq(customStoreApps.appId, appId))
.returning();
if (rows.length === 0) {
throw new Error("Custom app not found");
}
return mapRow(rows[0]);
},
);
}