Add homelab installer and services (allprox, authelia, lldap)

This commit is contained in:
Ezequiel C. 2026-09-01 18:32:56 +02:00
parent 68b23f1cbe
commit bb754cdd8c
32 changed files with 2356 additions and 1 deletions

196
installer/src/tui.ts Normal file
View file

@ -0,0 +1,196 @@
import {
confirm,
intro,
isCancel,
multiselect,
outro,
select,
spinner,
} from "@clack/prompts";
import type { ServiceMetadata } from "./types.ts";
import { describeSource, type Config } from "./config.ts";
import {
installService,
loadCatalog,
printAvailable,
printStatus,
resolveEnv,
uninstallService,
updateService,
} from "./commands.ts";
import { readEnvFile } from "./env.ts";
import { serviceDir } from "./paths.ts";
import { loadState } from "./state.ts";
import * as ui from "./util.ts";
export async function runTui(cfg: Config): Promise<void> {
ui.banner();
intro(ui.cyan("Homelab Installer"));
ui.info(`Source: ${describeSource(cfg)}`);
let running = true;
while (running) {
const action = await select({
message: "What would you like to do?",
options: [
{ value: "install", label: "Install services", hint: "add new services" },
{ value: "update", label: "Update services", hint: "pull newer versions" },
{ value: "uninstall", label: "Uninstall services", hint: "stop and remove" },
{ value: "list", label: "List available services" },
{ value: "status", label: "Show installed services" },
{ value: "exit", label: "Exit" },
],
});
if (isCancel(action)) {
running = false;
break;
}
switch (action) {
case "install":
await tuiInstall(cfg);
break;
case "update":
await tuiUpdate(cfg);
break;
case "uninstall":
await tuiUninstall(cfg);
break;
case "list":
await printAvailable(cfg);
break;
case "status":
printStatus();
break;
case "exit":
running = false;
break;
default:
break;
}
}
outro(ui.green("Goodbye!"));
}
async function tuiInstall(cfg: Config): Promise<void> {
const metas = await loadCatalog(cfg);
if (metas.length === 0) {
ui.warn("No services found in the catalog.");
return;
}
const state = loadState();
const choices = metas
.filter((m) => !state.services[m.id])
.map((m) => ({
value: m.id,
label: `${m.name} (${m.version})`,
hint: m.category,
}));
if (choices.length === 0) {
ui.info("All available services are already installed.");
return;
}
const selected = await multiselect({
message: "Select services to install (space to toggle, enter to confirm)",
options: choices,
required: true,
});
if (isCancel(selected)) return;
const byId = new Map(metas.map((m) => [m.id, m]));
for (const id of selected as string[]) {
const meta = byId.get(id);
if (!meta) continue;
await runWithSpinner(`Installing ${meta.name}...`, meta, cfg, async () => {
const vars = await resolveEnv(meta, cfg);
return installService(meta, cfg, vars);
});
}
}
async function tuiUpdate(cfg: Config): Promise<void> {
const state = loadState();
const installed = Object.values(state.services);
if (installed.length === 0) {
ui.info("No services installed yet.");
return;
}
const metas = await loadCatalog(cfg);
const byId = new Map(metas.map((m) => [m.id, m]));
const options = installed.map((s) => {
const meta = byId.get(s.id);
const newer = meta && meta.version !== s.version;
return {
value: s.id,
label: `${s.name} (${s.version})`,
hint: newer && meta ? `update to ${meta.version}` : "up to date",
};
});
const selected = await multiselect({
message: "Select services to update",
options,
required: true,
});
if (isCancel(selected)) return;
for (const id of selected as string[]) {
const meta = byId.get(id);
const installedService = state.services[id];
if (!meta || !installedService) continue;
await runWithSpinner(`Updating ${meta.name}...`, meta, cfg, async () => {
const vars = await resolveEnv(meta, cfg, readEnvFile(serviceDir(id)));
return updateService(installedService, meta, cfg, vars);
});
}
}
async function tuiUninstall(cfg: Config): Promise<void> {
const state = loadState();
const installed = Object.values(state.services);
if (installed.length === 0) {
ui.info("No services installed yet.");
return;
}
const selected = await multiselect({
message: "Select services to uninstall",
options: installed.map((s) => ({ value: s.id, label: s.name, hint: s.version })),
required: true,
});
if (isCancel(selected)) return;
const confirmed = await confirm({
message: "Uninstall selected services? (data volumes are preserved)",
});
if (isCancel(confirmed) || !confirmed) return;
for (const id of selected as string[]) {
await runWithSpinner(`Uninstalling ${id}...`, null, cfg, async () => {
return uninstallService(id, cfg);
});
}
}
async function runWithSpinner(
label: string,
meta: ServiceMetadata | null,
cfg: Config,
fn: () => Promise<{ message: string }>,
): Promise<void> {
const s = spinner();
s.start(label);
try {
const result = await fn();
s.stop(ui.green(result.message));
} catch (err) {
s.stop(ui.red(`Failed: ${(err as Error).message}`));
}
}