196 lines
5.1 KiB
TypeScript
196 lines
5.1 KiB
TypeScript
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}`));
|
|
}
|
|
}
|