Add homelab installer and services (allprox, authelia, lldap)
This commit is contained in:
parent
68b23f1cbe
commit
bb754cdd8c
32 changed files with 2356 additions and 1 deletions
4
installer/.gitignore
vendored
Normal file
4
installer/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.tgz
|
||||
.env
|
||||
73
installer/README.md
Normal file
73
installer/README.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# homelab installer
|
||||
|
||||
A terminal UI (and CLI) written in TypeScript for [Bun](https://bun.sh) that installs and updates
|
||||
homelab services from this repository's GitHub-hosted catalog.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd installer
|
||||
bun install
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
bun run src/index.ts # interactive TUI
|
||||
bun run src/index.ts list # list the catalog
|
||||
bun run src/index.ts install portainer
|
||||
bun run src/index.ts update
|
||||
bun run src/index.ts uninstall portainer
|
||||
bun run src/index.ts status
|
||||
bun run src/index.ts info pihole
|
||||
```
|
||||
|
||||
Run `bun run src/index.ts --help` for the full flag reference.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
installer/
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── src/
|
||||
│ ├── index.ts # entry point + CLI argument parsing
|
||||
│ ├── tui.ts # interactive menu (powered by @clack/prompts)
|
||||
│ ├── commands.ts # install/update/uninstall/list/status/info actions
|
||||
│ ├── github.ts # GitHub CDN: catalog listing + raw metadata fetch
|
||||
│ ├── metadata.ts # metadata parsing + validation
|
||||
│ ├── compose.ts # docker-compose.yml generation from metadata
|
||||
│ ├── env.ts # env var resolution + .env read/write
|
||||
│ ├── docker.ts # `docker compose` runner
|
||||
│ ├── state.ts # installed-state persistence (~/.homelab/state.json)
|
||||
│ ├── paths.ts # config/data directory helpers
|
||||
│ ├── prompts.ts # @clack prompt wrappers
|
||||
│ ├── config.ts # source configuration (owner/repo/branch/local)
|
||||
│ └── util.ts # colors + table printer
|
||||
└── scripts/
|
||||
└── generate-catalog.ts # regenerate services/catalog.json
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
1. **List** — fetch `services/catalog.json` from GitHub (falling back to the `git/trees` API).
|
||||
2. **Fetch** — download each selected `metadata.json` from `raw.githubusercontent.com`.
|
||||
3. **Resolve env** — prompt for declared variables (or use defaults / `--env` values).
|
||||
4. **Generate** — write `~/.homelab/services/<id>/docker-compose.yml` + `.env`.
|
||||
5. **Deploy** — run `docker compose up -d` (or `pull` + `up -d` for updates).
|
||||
6. **Record** — persist the installed version in `~/.homelab/state.json`.
|
||||
|
||||
Set `HOMELAB_HOME` to relocate `~/.homelab`. For a private GitHub repository, set
|
||||
`HOMELAB_GITHUB_TOKEN` (or `GITHUB_TOKEN` / `HOMELAB_TOKEN`) to a personal access token so the
|
||||
installer can read the catalog and metadata.
|
||||
|
||||
## Developing services locally
|
||||
|
||||
Point the installer at your checkout while you write a new `metadata.json`:
|
||||
|
||||
```bash
|
||||
bun run src/index.ts list --local ../services
|
||||
bun run src/index.ts install my-service --local ../services --dry-run
|
||||
```
|
||||
|
||||
`--dry-run` prints the generated `docker-compose.yml` without running Docker.
|
||||
82
installer/bun.lock
Normal file
82
installer/bun.lock
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "homelab-installer",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.7.0",
|
||||
"yaml": "^2.9.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.4.0",
|
||||
"typescript": "^7.0.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="],
|
||||
|
||||
"@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
|
||||
|
||||
"@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
|
||||
|
||||
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
|
||||
|
||||
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
|
||||
|
||||
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
|
||||
|
||||
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
|
||||
|
||||
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
|
||||
|
||||
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
|
||||
|
||||
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
|
||||
|
||||
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
|
||||
|
||||
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
|
||||
|
||||
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
|
||||
|
||||
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
|
||||
|
||||
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
|
||||
|
||||
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
|
||||
|
||||
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
|
||||
|
||||
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
|
||||
|
||||
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
|
||||
|
||||
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
|
||||
|
||||
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
|
||||
|
||||
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
|
||||
|
||||
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
|
||||
|
||||
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
|
||||
|
||||
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
|
||||
|
||||
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
|
||||
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
}
|
||||
}
|
||||
23
installer/package.json
Normal file
23
installer/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "homelab-installer",
|
||||
"version": "1.0.0",
|
||||
"description": "Interactive TUI to install and update homelab services from a GitHub-hosted catalog.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"homelab": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "bun run src/index.ts",
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"catalog": "bun run scripts/generate-catalog.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.7.0",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.4.0",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
30
installer/scripts/generate-catalog.ts
Normal file
30
installer/scripts/generate-catalog.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Catalog, ServiceMetadata } from "../src/types.ts";
|
||||
|
||||
const here = import.meta.dir;
|
||||
const repoRoot = join(here, "..", "..");
|
||||
const servicesDir = join(repoRoot, "services");
|
||||
|
||||
const services: Catalog["services"] = [];
|
||||
|
||||
for (const name of readdirSync(servicesDir).sort()) {
|
||||
const metaPath = join(servicesDir, name, "metadata.json");
|
||||
if (!statSync(metaPath, { throwIfNoEntry: false })?.isFile()) continue;
|
||||
|
||||
const meta = JSON.parse(readFileSync(metaPath, "utf8")) as ServiceMetadata;
|
||||
services.push({
|
||||
id: meta.id ?? name,
|
||||
version: meta.version ?? "unknown",
|
||||
path: `services/${name}/metadata.json`,
|
||||
});
|
||||
}
|
||||
|
||||
const catalog: Catalog = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
services,
|
||||
};
|
||||
|
||||
const out = join(servicesDir, "catalog.json");
|
||||
writeFileSync(out, `${JSON.stringify(catalog, null, 2)}\n`, "utf8");
|
||||
console.log(`Wrote ${out} with ${services.length} services.`);
|
||||
240
installer/src/commands.ts
Normal file
240
installer/src/commands.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { ActionResult, InstalledService, ServiceMetadata } from "./types.ts";
|
||||
import type { Config } from "./config.ts";
|
||||
import { fetchMetadata, fetchServiceFiles, listServices, type ServiceFile } from "./github.ts";
|
||||
import { generateComposeFile } from "./compose.ts";
|
||||
import { collectEnvVars, envFileContent, readEnvFile } from "./env.ts";
|
||||
import { isDockerAvailable, runCommand, runDockerCompose, type RunResult } from "./docker.ts";
|
||||
import { loadState, markInstalled, removeInstalled } from "./state.ts";
|
||||
import { serviceDir } from "./paths.ts";
|
||||
import * as ui from "./util.ts";
|
||||
|
||||
/** Fetch every service in the catalog (skipping any that fail to parse). */
|
||||
export async function loadCatalog(cfg: Config): Promise<ServiceMetadata[]> {
|
||||
const entries = await listServices(cfg);
|
||||
const metas = await Promise.all(
|
||||
entries.map(async (e) => {
|
||||
try {
|
||||
return await fetchMetadata(e.id, cfg);
|
||||
} catch (err) {
|
||||
ui.warn(`Skipping ${e.id}: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return metas.filter((m): m is ServiceMetadata => m !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve env vars for a service. Interactive prompting only happens when
|
||||
* stdout is a TTY and `--yes` was not passed.
|
||||
*/
|
||||
export async function resolveEnv(
|
||||
meta: ServiceMetadata,
|
||||
cfg: Config,
|
||||
existing?: Record<string, string>,
|
||||
): Promise<Record<string, string>> {
|
||||
const interactive = !cfg.yes && Boolean(process.stdout.isTTY);
|
||||
const provided = { ...(existing ?? {}), ...cfg.env };
|
||||
return collectEnvVars(meta, { interactive, provided });
|
||||
}
|
||||
|
||||
export async function installService(
|
||||
meta: ServiceMetadata,
|
||||
cfg: Config,
|
||||
preEnv?: Record<string, string>,
|
||||
): Promise<ActionResult> {
|
||||
const dir = serviceDir(meta.id);
|
||||
const vars = preEnv ?? (await resolveEnv(meta, cfg));
|
||||
const composeContent = generateComposeFile(meta);
|
||||
|
||||
if (cfg.dryRun) {
|
||||
ui.info(`[dry-run] Would install ${meta.name} (${meta.version}) into ${dir}`);
|
||||
const envNames = Object.keys(vars);
|
||||
ui.info(
|
||||
`[dry-run] Environment: ${envNames.length > 0 ? envNames.join(", ") : "(none)"}`,
|
||||
);
|
||||
console.log(
|
||||
ui.dim(`\n--- docker-compose.yml ---\n${composeContent}----------------------------`),
|
||||
);
|
||||
const files = await fetchServiceFiles(meta.id, cfg);
|
||||
ui.info(
|
||||
`[dry-run] Files: ${files.length > 0 ? files.map((f) => f.path).join(", ") : "(none)"}`,
|
||||
);
|
||||
return { ok: true, message: `dry-run: prepared ${meta.id}` };
|
||||
}
|
||||
|
||||
writeFileSync(join(dir, "docker-compose.yml"), composeContent, "utf8");
|
||||
writeFileSync(join(dir, ".env"), envFileContent(vars), "utf8");
|
||||
materializeFiles(dir, await fetchServiceFiles(meta.id, cfg), vars);
|
||||
|
||||
await requireDocker();
|
||||
await ensureExternalNetworks(meta, cfg);
|
||||
const r = await runDockerCompose(dir, ["up", "-d"], !cfg.verbose);
|
||||
if (r.code !== 0) {
|
||||
printDockerFailure(r);
|
||||
throw new Error(`docker compose up failed for ${meta.id}`);
|
||||
}
|
||||
|
||||
markInstalled(meta.id, meta.name, meta.version);
|
||||
return { ok: true, message: `${meta.name} v${meta.version} installed` };
|
||||
}
|
||||
|
||||
export async function updateService(
|
||||
installed: InstalledService,
|
||||
meta: ServiceMetadata,
|
||||
cfg: Config,
|
||||
preEnv?: Record<string, string>,
|
||||
): Promise<ActionResult> {
|
||||
const dir = serviceDir(meta.id);
|
||||
const vars = preEnv ?? (await resolveEnv(meta, cfg, readEnvFile(dir)));
|
||||
const composeContent = generateComposeFile(meta);
|
||||
|
||||
if (cfg.dryRun) {
|
||||
ui.info(
|
||||
`[dry-run] Would update ${meta.name} from ${installed.version} to ${meta.version}`,
|
||||
);
|
||||
return { ok: true, message: `dry-run: update ${meta.id}` };
|
||||
}
|
||||
|
||||
writeFileSync(join(dir, "docker-compose.yml"), composeContent, "utf8");
|
||||
writeFileSync(join(dir, ".env"), envFileContent(vars), "utf8");
|
||||
materializeFiles(dir, await fetchServiceFiles(meta.id, cfg), vars);
|
||||
|
||||
await requireDocker();
|
||||
await ensureExternalNetworks(meta, cfg);
|
||||
const pull = await runDockerCompose(dir, ["pull"], !cfg.verbose);
|
||||
if (pull.code !== 0) {
|
||||
printDockerFailure(pull);
|
||||
throw new Error(`docker compose pull failed for ${meta.id}`);
|
||||
}
|
||||
const up = await runDockerCompose(dir, ["up", "-d"], !cfg.verbose);
|
||||
if (up.code !== 0) {
|
||||
printDockerFailure(up);
|
||||
throw new Error(`docker compose up failed for ${meta.id}`);
|
||||
}
|
||||
|
||||
markInstalled(meta.id, meta.name, meta.version);
|
||||
return { ok: true, message: `${meta.name} updated to v${meta.version}` };
|
||||
}
|
||||
|
||||
export async function uninstallService(id: string, cfg: Config): Promise<ActionResult> {
|
||||
const dir = serviceDir(id);
|
||||
|
||||
if (cfg.dryRun) {
|
||||
ui.info(`[dry-run] Would uninstall ${id} (data volumes preserved)`);
|
||||
return { ok: true, message: `dry-run: uninstall ${id}` };
|
||||
}
|
||||
|
||||
await requireDocker();
|
||||
const r = await runDockerCompose(dir, ["down"], !cfg.verbose);
|
||||
if (r.code !== 0) {
|
||||
ui.warn(`docker compose down reported an error for ${id}; removing local files anyway.`);
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
removeInstalled(id);
|
||||
return { ok: true, message: `${id} uninstalled` };
|
||||
}
|
||||
|
||||
export function printStatus(): void {
|
||||
const state = loadState();
|
||||
const rows = Object.values(state.services).map((s) => [
|
||||
s.id,
|
||||
s.name,
|
||||
s.version,
|
||||
s.updatedAt.slice(0, 19).replace("T", " "),
|
||||
]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
ui.info("No services installed yet.");
|
||||
return;
|
||||
}
|
||||
ui.table(["ID", "NAME", "VERSION", "UPDATED"], rows);
|
||||
}
|
||||
|
||||
export async function printAvailable(cfg: Config): Promise<void> {
|
||||
const metas = await loadCatalog(cfg);
|
||||
const state = loadState();
|
||||
|
||||
if (metas.length === 0) {
|
||||
ui.info("No services found in the catalog.");
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = metas.map((m) => [
|
||||
m.id,
|
||||
m.name,
|
||||
m.version,
|
||||
state.services[m.id] ? "installed" : "",
|
||||
]);
|
||||
ui.table(["ID", "NAME", "VERSION", "STATUS"], rows);
|
||||
}
|
||||
|
||||
export async function printInfo(id: string, cfg: Config): Promise<void> {
|
||||
if (!id) {
|
||||
ui.error("info requires a service id.");
|
||||
return;
|
||||
}
|
||||
const meta = await fetchMetadata(id, cfg);
|
||||
|
||||
console.log(ui.bold(`${meta.name} (${meta.id}) v${meta.version}`));
|
||||
console.log(meta.description);
|
||||
if (meta.homepage) console.log(ui.cyan(`Homepage: ${meta.homepage}`));
|
||||
if (meta.documentation) console.log(ui.cyan(`Docs: ${meta.documentation}`));
|
||||
if (meta.category) console.log(`Category: ${meta.category}`);
|
||||
if (meta.tags?.length) console.log(`Tags: ${meta.tags.join(", ")}`);
|
||||
if (meta.license) console.log(`License: ${meta.license}`);
|
||||
if (meta.dependsOn?.length) console.log(`Depends on: ${meta.dependsOn.join(", ")}`);
|
||||
|
||||
const env = meta.env ?? [];
|
||||
if (env.length > 0) {
|
||||
console.log(ui.bold("\nEnvironment variables:"));
|
||||
for (const e of env) {
|
||||
const req = e.required ? " (required)" : "";
|
||||
const def = e.default !== undefined ? ` (default: ${e.default})` : "";
|
||||
const desc = e.description ? ` — ${e.description}` : "";
|
||||
console.log(` ${e.name}${req}${def}${desc}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (meta.notes) console.log(`\n${meta.notes}`);
|
||||
}
|
||||
|
||||
function materializeFiles(dir: string, files: ServiceFile[], vars: Record<string, string>): void {
|
||||
for (const f of files) {
|
||||
const target = join(dir, f.path);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
const content = f.content.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, name: string) =>
|
||||
Object.prototype.hasOwnProperty.call(vars, name) ? vars[name] : m,
|
||||
);
|
||||
writeFileSync(target, content, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureExternalNetworks(meta: ServiceMetadata, cfg: Config): Promise<void> {
|
||||
if (cfg.dryRun) return;
|
||||
const nets = meta.networks ?? {};
|
||||
for (const [name, def] of Object.entries(nets)) {
|
||||
if (def && typeof def === "object" && (def as Record<string, unknown>).external) {
|
||||
await runCommand("docker", ["network", "create", name], { capture: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function requireDocker(): Promise<void> {
|
||||
if (!(await isDockerAvailable())) {
|
||||
throw new Error(
|
||||
"Docker is not available. Install Docker (or Docker Desktop) and make sure `docker` is on your PATH.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function printDockerFailure(r: RunResult): void {
|
||||
const tail = (r.stderr || r.stdout || "")
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.slice(-25)
|
||||
.join("\n");
|
||||
if (tail) ui.error(tail);
|
||||
}
|
||||
17
installer/src/compose.ts
Normal file
17
installer/src/compose.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { stringify } from "yaml";
|
||||
import type { ServiceMetadata } from "./types.ts";
|
||||
|
||||
/** Build the docker-compose.yml content for a service from its metadata. */
|
||||
export function generateComposeFile(meta: ServiceMetadata): string {
|
||||
const doc: Record<string, unknown> = {};
|
||||
|
||||
if (meta.networks && Object.keys(meta.networks).length > 0) {
|
||||
doc.networks = meta.networks;
|
||||
}
|
||||
if (meta.volumes && Object.keys(meta.volumes).length > 0) {
|
||||
doc.volumes = meta.volumes;
|
||||
}
|
||||
doc.services = { [meta.id]: meta.compose };
|
||||
|
||||
return stringify(doc, { lineWidth: 0 });
|
||||
}
|
||||
43
installer/src/config.ts
Normal file
43
installer/src/config.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { resolve } from "node:path";
|
||||
|
||||
export interface Config {
|
||||
owner: string;
|
||||
repo: string;
|
||||
branch: string;
|
||||
local: boolean;
|
||||
localPath?: string;
|
||||
dryRun: boolean;
|
||||
yes: boolean;
|
||||
force: boolean;
|
||||
verbose: boolean;
|
||||
env: Record<string, string>;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export function loadConfig(cli: Partial<Config> = {}): Config {
|
||||
const envLocal = process.env.HOMELAB_LOCAL;
|
||||
const localPath = cli.localPath ?? (envLocal || undefined);
|
||||
|
||||
return {
|
||||
owner: cli.owner ?? process.env.HOMELAB_OWNER ?? "ReinadoRojo",
|
||||
repo: cli.repo ?? process.env.HOMELAB_REPO ?? "homelab",
|
||||
branch: cli.branch ?? process.env.HOMELAB_BRANCH ?? "master",
|
||||
local: Boolean(localPath),
|
||||
localPath: localPath ? resolve(localPath) : undefined,
|
||||
dryRun: cli.dryRun ?? false,
|
||||
yes: cli.yes ?? false,
|
||||
force: cli.force ?? false,
|
||||
verbose: cli.verbose ?? false,
|
||||
env: cli.env ?? {},
|
||||
token:
|
||||
process.env.HOMELAB_GITHUB_TOKEN ??
|
||||
process.env.GITHUB_TOKEN ??
|
||||
process.env.HOMELAB_TOKEN,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeSource(cfg: Config): string {
|
||||
return cfg.local
|
||||
? `local: ${cfg.localPath}`
|
||||
: `${cfg.owner}/${cfg.repo}@${cfg.branch}`;
|
||||
}
|
||||
47
installer/src/docker.ts
Normal file
47
installer/src/docker.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
export interface RunResult {
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export async function runCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
opts?: { cwd?: string; capture?: boolean },
|
||||
): Promise<RunResult> {
|
||||
const proc = Bun.spawn([command, ...args], {
|
||||
cwd: opts?.cwd,
|
||||
stdin: "ignore",
|
||||
stdout: opts?.capture ? "pipe" : "inherit",
|
||||
stderr: opts?.capture ? "pipe" : "inherit",
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
if (opts?.capture) {
|
||||
[stdout, stderr] = await Promise.all([
|
||||
Bun.readableStreamToText(proc.stdout!),
|
||||
Bun.readableStreamToText(proc.stderr!),
|
||||
]);
|
||||
}
|
||||
|
||||
const code = await proc.exited;
|
||||
return { code, stdout, stderr };
|
||||
}
|
||||
|
||||
export async function isDockerAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const r = await runCommand("docker", ["--version"], { capture: true });
|
||||
return r.code === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function runDockerCompose(
|
||||
dir: string,
|
||||
args: string[],
|
||||
capture = true,
|
||||
): Promise<RunResult> {
|
||||
return runCommand("docker", ["compose", ...args], { cwd: dir, capture });
|
||||
}
|
||||
81
installer/src/env.ts
Normal file
81
installer/src/env.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { ServiceMetadata } from "./types.ts";
|
||||
import { promptEnvValue } from "./prompts.ts";
|
||||
|
||||
export interface EnvOptions {
|
||||
interactive: boolean;
|
||||
provided: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Resolve declared env vars from provided values, defaults, or interactive prompts. */
|
||||
export async function collectEnvVars(
|
||||
meta: ServiceMetadata,
|
||||
opts: EnvOptions,
|
||||
): Promise<Record<string, string>> {
|
||||
const vars: Record<string, string> = {};
|
||||
|
||||
for (const spec of meta.env ?? []) {
|
||||
let value: string | undefined = opts.provided[spec.name];
|
||||
if (value === undefined && spec.default !== undefined) value = spec.default;
|
||||
if (value === undefined && spec.generate) value = randomHex(32);
|
||||
if (value === undefined && opts.interactive) {
|
||||
value = await promptEnvValue(spec);
|
||||
}
|
||||
if (value === undefined && spec.required) {
|
||||
throw new Error(
|
||||
`required variable "${spec.name}" for "${meta.id}" was not provided. ` +
|
||||
`Pass --env ${spec.name}=VALUE or run interactively.`,
|
||||
);
|
||||
}
|
||||
if (value !== undefined) vars[spec.name] = value;
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
/** Serialize resolved vars into a dotenv file body. */
|
||||
export function envFileContent(vars: Record<string, string>): string {
|
||||
const lines = Object.entries(vars).map(([k, v]) => `${k}=${quote(v)}`);
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
/** Parse a dotenv file body into a plain object. */
|
||||
export function parseEnvFile(content: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
|
||||
if (!m) continue;
|
||||
out[m[1]] = unquote(m[2]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Read the .env file in a service dir (returns {} if absent). */
|
||||
export function readEnvFile(dir: string): Record<string, string> {
|
||||
try {
|
||||
return parseEnvFile(readFileSync(join(dir, ".env"), "utf8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function quote(v: string): string {
|
||||
return `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
function randomHex(bytes: number): string {
|
||||
const buf = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(buf);
|
||||
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function unquote(v: string): string {
|
||||
if (v.startsWith('"') && v.endsWith('"')) {
|
||||
return v
|
||||
.slice(1, -1)
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\\\/g, "\\");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
170
installer/src/github.ts
Normal file
170
installer/src/github.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { readdirSync, readFileSync, statSync, type Dirent } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Catalog, CatalogEntry, ServiceMetadata } from "./types.ts";
|
||||
import type { Config } from "./config.ts";
|
||||
import { parseAndValidate } from "./metadata.ts";
|
||||
|
||||
const UA = "homelab-installer/1.0";
|
||||
|
||||
function rawUrl(cfg: Config, path: string): string {
|
||||
return `https://raw.githubusercontent.com/${cfg.owner}/${cfg.repo}/${cfg.branch}/${path}`;
|
||||
}
|
||||
|
||||
function apiUrl(cfg: Config, path: string): string {
|
||||
return `https://api.github.com/repos/${cfg.owner}/${cfg.repo}/${path}`;
|
||||
}
|
||||
|
||||
function authHeaders(cfg: Config, scheme: "token" | "bearer"): Record<string, string> {
|
||||
return cfg.token ? { Authorization: `${scheme} ${cfg.token}` } : {};
|
||||
}
|
||||
|
||||
async function fetchText(
|
||||
url: string,
|
||||
cfg: Config,
|
||||
scheme: "token" | "bearer" = "bearer",
|
||||
): Promise<string> {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": UA,
|
||||
Accept: "application/vnd.github+json",
|
||||
...authHeaders(cfg, scheme),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status} for ${url}`);
|
||||
}
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, cfg: Config): Promise<T> {
|
||||
return JSON.parse(await fetchText(url, cfg)) as T;
|
||||
}
|
||||
|
||||
/** Return the list of services (id + path) from the configured source. */
|
||||
export async function listServices(cfg: Config): Promise<CatalogEntry[]> {
|
||||
if (cfg.local && cfg.localPath) {
|
||||
return listLocal(cfg.localPath);
|
||||
}
|
||||
|
||||
// Preferred: a single committed catalog.json served from raw.githubusercontent.com.
|
||||
try {
|
||||
const catalog = await fetchJson<Catalog>(rawUrl(cfg, "services/catalog.json"), cfg);
|
||||
if (Array.isArray(catalog.services)) return catalog.services;
|
||||
} catch {
|
||||
// Fall through to the git trees API below.
|
||||
}
|
||||
|
||||
try {
|
||||
return await listViaApi(cfg);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Could not read the catalog from ${cfg.owner}/${cfg.repo}@${cfg.branch}: ${(err as Error).message}. ` +
|
||||
`Check --owner/--repo/--branch (and that the repo is pushed), set HOMELAB_GITHUB_TOKEN for a private repo, or use --local <path>.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function listLocal(dir: string): CatalogEntry[] {
|
||||
const entries: CatalogEntry[] = [];
|
||||
let names: string[] = [];
|
||||
try {
|
||||
names = readdirSync(dir);
|
||||
} catch {
|
||||
throw new Error(`Local services directory not found: ${dir}`);
|
||||
}
|
||||
|
||||
for (const name of names.sort()) {
|
||||
const metaPath = join(dir, name, "metadata.json");
|
||||
if (!statSync(metaPath, { throwIfNoEntry: false })?.isFile()) continue;
|
||||
try {
|
||||
const meta = parseAndValidate(readFileSync(metaPath, "utf8"), name);
|
||||
entries.push({
|
||||
id: meta.id,
|
||||
version: meta.version,
|
||||
path: `services/${name}/metadata.json`,
|
||||
});
|
||||
} catch {
|
||||
// Ignore folders without a valid metadata.json.
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function listViaApi(cfg: Config): Promise<CatalogEntry[]> {
|
||||
const tree = await fetchJson<{ tree?: { path: string }[] }>(
|
||||
apiUrl(cfg, `git/trees/${cfg.branch}?recursive=1`),
|
||||
cfg,
|
||||
);
|
||||
const paths = (tree.tree ?? [])
|
||||
.map((t) => t.path)
|
||||
.filter((p) => /^services\/[^/]+\/metadata\.json$/.test(p));
|
||||
|
||||
return paths.map((p) => {
|
||||
const id = p.split("/")[1] ?? "unknown";
|
||||
return { id, version: "unknown", path: p };
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch and validate a single service's metadata.json. */
|
||||
export async function fetchMetadata(id: string, cfg: Config): Promise<ServiceMetadata> {
|
||||
let raw: string;
|
||||
if (cfg.local && cfg.localPath) {
|
||||
raw = readFileSync(join(cfg.localPath, id, "metadata.json"), "utf8");
|
||||
} else {
|
||||
raw = await fetchText(rawUrl(cfg, `services/${id}/metadata.json`), cfg, "token");
|
||||
}
|
||||
return parseAndValidate(raw, id);
|
||||
}
|
||||
|
||||
export interface ServiceFile {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Return every extra file in a service folder (excluding metadata.json). */
|
||||
export async function fetchServiceFiles(id: string, cfg: Config): Promise<ServiceFile[]> {
|
||||
if (cfg.local && cfg.localPath) {
|
||||
return listLocalFiles(join(cfg.localPath, id));
|
||||
}
|
||||
|
||||
const tree = await fetchJson<{ tree?: { path: string; type?: string }[] }>(
|
||||
apiUrl(cfg, `git/trees/${cfg.branch}?recursive=1`),
|
||||
cfg,
|
||||
);
|
||||
const prefix = `services/${id}/`;
|
||||
const paths = (tree.tree ?? [])
|
||||
.filter((t) => t.type === "blob" && t.path.startsWith(prefix) && t.path !== `${prefix}metadata.json`)
|
||||
.map((t) => t.path);
|
||||
|
||||
const files: ServiceFile[] = [];
|
||||
for (const p of paths) {
|
||||
const rel = p.slice(prefix.length);
|
||||
const content = await fetchText(rawUrl(cfg, p), cfg, "token");
|
||||
files.push({ path: rel, content });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function listLocalFiles(dir: string): ServiceFile[] {
|
||||
const out: ServiceFile[] = [];
|
||||
walk(dir, "", out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function walk(base: string, rel: string, out: ServiceFile[]): void {
|
||||
const full = rel ? join(base, rel) : base;
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(full, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const relPath = rel ? `${rel}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) {
|
||||
walk(base, relPath, out);
|
||||
} else if (!(rel === "" && e.name === "metadata.json")) {
|
||||
out.push({ path: relPath, content: readFileSync(join(base, relPath), "utf8") });
|
||||
}
|
||||
}
|
||||
}
|
||||
250
installer/src/index.ts
Normal file
250
installer/src/index.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
#!/usr/bin/env bun
|
||||
import type { Config } from "./config.ts";
|
||||
import { loadConfig } from "./config.ts";
|
||||
import { runTui } from "./tui.ts";
|
||||
import {
|
||||
installService,
|
||||
loadCatalog,
|
||||
printAvailable,
|
||||
printInfo,
|
||||
printStatus,
|
||||
uninstallService,
|
||||
updateService,
|
||||
} from "./commands.ts";
|
||||
import { loadState } from "./state.ts";
|
||||
import * as ui from "./util.ts";
|
||||
|
||||
interface CliArgs {
|
||||
command: string;
|
||||
ids: string[];
|
||||
flags: Record<string, string | boolean>;
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const ids: string[] = [];
|
||||
const flags: Record<string, string | boolean> = {};
|
||||
const env: Record<string, string> = {};
|
||||
let command = "";
|
||||
const valueFlags = new Set(["local", "owner", "repo", "branch"]);
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
|
||||
if (a === "--help" || a === "-h") {
|
||||
flags.help = true;
|
||||
continue;
|
||||
}
|
||||
if (a === "-e" || a === "--env") {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error(`${a} requires a KEY=VALUE argument.`);
|
||||
applyEnv(env, v);
|
||||
continue;
|
||||
}
|
||||
if (a.startsWith("--")) {
|
||||
let key = a.slice(2);
|
||||
let value: string | undefined;
|
||||
const eq = key.indexOf("=");
|
||||
if (eq >= 0) {
|
||||
value = key.slice(eq + 1);
|
||||
key = key.slice(0, eq);
|
||||
}
|
||||
if (value === undefined && valueFlags.has(key) && i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
||||
value = argv[++i];
|
||||
}
|
||||
flags[key] = value ?? true;
|
||||
continue;
|
||||
}
|
||||
if (!command) {
|
||||
command = a;
|
||||
} else {
|
||||
ids.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
return { command, ids, flags, env };
|
||||
}
|
||||
|
||||
function applyEnv(env: Record<string, string>, kv: string): void {
|
||||
const eq = kv.indexOf("=");
|
||||
if (eq <= 0) throw new Error(`invalid env "${kv}" (expected KEY=VALUE).`);
|
||||
env[kv.slice(0, eq)] = kv.slice(eq + 1);
|
||||
}
|
||||
|
||||
function asString(v: string | boolean | undefined): string | undefined {
|
||||
return typeof v === "string" ? v : undefined;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cfg: Config = loadConfig({
|
||||
localPath: asString(args.flags.local),
|
||||
owner: asString(args.flags.owner),
|
||||
repo: asString(args.flags.repo),
|
||||
branch: asString(args.flags.branch),
|
||||
dryRun: Boolean(args.flags["dry-run"]),
|
||||
yes: Boolean(args.flags.yes),
|
||||
force: Boolean(args.flags.force),
|
||||
verbose: Boolean(args.flags.verbose),
|
||||
env: args.env,
|
||||
});
|
||||
|
||||
if (args.flags.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.command) {
|
||||
if (!process.stdout.isTTY) {
|
||||
ui.error("No command provided and stdout is not a TTY. Run `homelab --help` for usage.");
|
||||
process.exit(1);
|
||||
}
|
||||
await runTui(cfg);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (args.command) {
|
||||
case "help":
|
||||
printHelp();
|
||||
return;
|
||||
case "list":
|
||||
await printAvailable(cfg);
|
||||
return;
|
||||
case "status":
|
||||
printStatus();
|
||||
return;
|
||||
case "info":
|
||||
await printInfo(args.ids[0], cfg);
|
||||
return;
|
||||
case "install":
|
||||
if (await runInstall(args.ids, cfg)) process.exit(1);
|
||||
return;
|
||||
case "update":
|
||||
if (await runUpdate(args.ids, cfg)) process.exit(1);
|
||||
return;
|
||||
case "uninstall":
|
||||
if (await runUninstall(args.ids, cfg)) process.exit(1);
|
||||
return;
|
||||
default:
|
||||
ui.error(`Unknown command "${args.command}".`);
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function runInstall(ids: string[], cfg: Config): Promise<boolean> {
|
||||
if (ids.length === 0) {
|
||||
ui.error("install requires at least one service id.");
|
||||
return true;
|
||||
}
|
||||
const metas = await loadCatalog(cfg);
|
||||
const byId = new Map(metas.map((m) => [m.id, m]));
|
||||
let failed = false;
|
||||
|
||||
for (const id of ids) {
|
||||
const meta = byId.get(id);
|
||||
if (!meta) {
|
||||
ui.error(`Unknown service "${id}". Run \`homelab list\` to see available services.`);
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const r = await installService(meta, cfg);
|
||||
ui.success(r.message);
|
||||
} catch (err) {
|
||||
ui.error(`Failed to install ${id}: ${(err as Error).message}`);
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
async function runUpdate(ids: string[], cfg: Config): Promise<boolean> {
|
||||
const state = loadState();
|
||||
const targets = ids.length > 0 ? ids : Object.keys(state.services);
|
||||
|
||||
if (targets.length === 0) {
|
||||
ui.info("No services installed. Nothing to update.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const metas = await loadCatalog(cfg);
|
||||
const byId = new Map(metas.map((m) => [m.id, m]));
|
||||
let failed = false;
|
||||
|
||||
for (const id of targets) {
|
||||
const installed = state.services[id];
|
||||
if (!installed) {
|
||||
ui.warn(`"${id}" is not installed. Skipping.`);
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
const meta = byId.get(id);
|
||||
if (!meta) {
|
||||
ui.warn(`"${id}" not found in the catalog. Skipping.`);
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
if (!cfg.force && meta.version === installed.version) {
|
||||
ui.info(`${id} is already up to date (${installed.version}).`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const r = await updateService(installed, meta, cfg);
|
||||
ui.success(r.message);
|
||||
} catch (err) {
|
||||
ui.error(`Failed to update ${id}: ${(err as Error).message}`);
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
async function runUninstall(ids: string[], cfg: Config): Promise<boolean> {
|
||||
if (ids.length === 0) {
|
||||
ui.error("uninstall requires at least one service id.");
|
||||
return true;
|
||||
}
|
||||
let failed = false;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const r = await uninstallService(id, cfg);
|
||||
ui.success(r.message);
|
||||
} catch (err) {
|
||||
ui.error(`Failed to uninstall ${id}: ${(err as Error).message}`);
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`homelab — install and update services for your homelab
|
||||
|
||||
Usage:
|
||||
homelab Launch the interactive TUI
|
||||
homelab list List available services
|
||||
homelab status Show installed services
|
||||
homelab info <id> Show details for a service
|
||||
homelab install <id...> Install one or more services
|
||||
homelab update [id...] Update installed services (all if no id)
|
||||
homelab uninstall <id...> Uninstall one or more services
|
||||
|
||||
Options:
|
||||
--local <path> Read services from a local directory instead of GitHub
|
||||
--owner <name> GitHub repository owner (default: env HOMELAB_OWNER)
|
||||
--repo <name> GitHub repository name (default: env HOMELAB_REPO)
|
||||
--branch <name> Git branch used as the catalog (default: env HOMELAB_BRANCH)
|
||||
--env KEY=VALUE Set an environment variable (repeatable)
|
||||
--dry-run Show what would happen without running docker
|
||||
--yes Non-interactive: use defaults/provided values, never prompt
|
||||
--force Force update even if versions match
|
||||
--verbose Stream docker compose output
|
||||
--help Show this help
|
||||
`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
ui.error((err as Error).message);
|
||||
process.exit(1);
|
||||
});
|
||||
52
installer/src/metadata.ts
Normal file
52
installer/src/metadata.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { ServiceMetadata } from "./types.ts";
|
||||
|
||||
export interface ValidationIssue {
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function parseAndValidate(raw: string, fallbackId: string): ServiceMetadata {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
throw new Error(`metadata.json is not valid JSON: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||
throw new Error("metadata.json must be a JSON object");
|
||||
}
|
||||
|
||||
const meta = data as ServiceMetadata;
|
||||
const issues = validateMetadata(meta, fallbackId);
|
||||
if (issues.length > 0) {
|
||||
const detail = issues.map((i) => `${i.path}: ${i.message}`).join("; ");
|
||||
throw new Error(`invalid metadata.json (${detail})`);
|
||||
}
|
||||
|
||||
meta.id = meta.id || fallbackId;
|
||||
return meta;
|
||||
}
|
||||
|
||||
export function validateMetadata(meta: ServiceMetadata, fallbackId: string): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
const id = meta.id || fallbackId;
|
||||
|
||||
if (!meta.name || typeof meta.name !== "string") {
|
||||
issues.push({ path: "name", message: "required string" });
|
||||
}
|
||||
if (!meta.description || typeof meta.description !== "string") {
|
||||
issues.push({ path: "description", message: "required string" });
|
||||
}
|
||||
if (!meta.version || typeof meta.version !== "string") {
|
||||
issues.push({ path: "version", message: "required string" });
|
||||
}
|
||||
if (!meta.compose || typeof meta.compose !== "object" || Array.isArray(meta.compose)) {
|
||||
issues.push({ path: "compose", message: "required object (docker-compose service spec)" });
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
|
||||
issues.push({ path: "id", message: "must be kebab-case and match the folder name" });
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
24
installer/src/paths.ts
Normal file
24
installer/src/paths.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
export function getHome(): string {
|
||||
return process.env.HOMELAB_HOME ?? join(homedir(), ".homelab");
|
||||
}
|
||||
|
||||
export function stateFile(): string {
|
||||
return join(getHome(), "state.json");
|
||||
}
|
||||
|
||||
export function servicesRoot(): string {
|
||||
return ensureDir(join(getHome(), "services"));
|
||||
}
|
||||
|
||||
export function serviceDir(id: string): string {
|
||||
return ensureDir(join(servicesRoot(), id));
|
||||
}
|
||||
|
||||
function ensureDir(p: string): string {
|
||||
mkdirSync(p, { recursive: true });
|
||||
return p;
|
||||
}
|
||||
27
installer/src/prompts.ts
Normal file
27
installer/src/prompts.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { isCancel, password, select, text } from "@clack/prompts";
|
||||
import type { EnvSpec } from "./types.ts";
|
||||
|
||||
/** Prompt for a single env var (only called in interactive mode). */
|
||||
export async function promptEnvValue(spec: EnvSpec): Promise<string | undefined> {
|
||||
const message = spec.label ? `${spec.label} (${spec.name})` : spec.name;
|
||||
|
||||
if (spec.options && spec.options.length > 0) {
|
||||
const res = await select({
|
||||
message,
|
||||
options: spec.options.map((o) => ({ value: o, label: o })),
|
||||
});
|
||||
return isCancel(res) ? undefined : (res as string);
|
||||
}
|
||||
|
||||
if (spec.secret) {
|
||||
const res = await password({ message });
|
||||
return isCancel(res) ? undefined : (res as string);
|
||||
}
|
||||
|
||||
const res = await text({
|
||||
message,
|
||||
placeholder: spec.description,
|
||||
defaultValue: spec.default ?? "",
|
||||
});
|
||||
return isCancel(res) ? undefined : (res as string);
|
||||
}
|
||||
46
installer/src/state.ts
Normal file
46
installer/src/state.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { InstalledService, State } from "./types.ts";
|
||||
import { stateFile } from "./paths.ts";
|
||||
|
||||
const EMPTY: State = { version: 1, services: {} };
|
||||
|
||||
export function loadState(): State {
|
||||
const file = stateFile();
|
||||
if (!existsSync(file)) return EMPTY;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<State>;
|
||||
return {
|
||||
version: parsed.version ?? 1,
|
||||
services: parsed.services ?? {},
|
||||
};
|
||||
} catch {
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveState(state: State): void {
|
||||
const file = stateFile();
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export function markInstalled(id: string, name: string, version: string): void {
|
||||
const state = loadState();
|
||||
const now = new Date().toISOString();
|
||||
const existing: InstalledService | undefined = state.services[id];
|
||||
state.services[id] = {
|
||||
id,
|
||||
name,
|
||||
version,
|
||||
installedAt: existing?.installedAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
saveState(state);
|
||||
}
|
||||
|
||||
export function removeInstalled(id: string): void {
|
||||
const state = loadState();
|
||||
delete state.services[id];
|
||||
saveState(state);
|
||||
}
|
||||
196
installer/src/tui.ts
Normal file
196
installer/src/tui.ts
Normal 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}`));
|
||||
}
|
||||
}
|
||||
59
installer/src/types.ts
Normal file
59
installer/src/types.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
export interface EnvSpec {
|
||||
name: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
default?: string;
|
||||
required?: boolean;
|
||||
secret?: boolean;
|
||||
options?: string[];
|
||||
generate?: boolean;
|
||||
}
|
||||
|
||||
export interface ServiceMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
icon?: string;
|
||||
author?: string;
|
||||
license?: string;
|
||||
homepage?: string;
|
||||
documentation?: string;
|
||||
compose: Record<string, unknown>;
|
||||
volumes?: Record<string, unknown>;
|
||||
networks?: Record<string, unknown>;
|
||||
env?: EnvSpec[];
|
||||
dependsOn?: string[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface InstalledService {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
version: number;
|
||||
services: Record<string, InstalledService>;
|
||||
}
|
||||
|
||||
export interface CatalogEntry {
|
||||
id: string;
|
||||
version: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface Catalog {
|
||||
generatedAt?: string;
|
||||
services: CatalogEntry[];
|
||||
}
|
||||
|
||||
export interface ActionResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
66
installer/src/util.ts
Normal file
66
installer/src/util.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
export const c = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
gray: "\x1b[90m",
|
||||
} as const;
|
||||
|
||||
export function color(code: string, s: string): string {
|
||||
return `${code}${s}${c.reset}`;
|
||||
}
|
||||
|
||||
export const green = (s: string): string => color(c.green, s);
|
||||
export const red = (s: string): string => color(c.red, s);
|
||||
export const yellow = (s: string): string => color(c.yellow, s);
|
||||
export const cyan = (s: string): string => color(c.cyan, s);
|
||||
export const bold = (s: string): string => color(c.bold, s);
|
||||
export const dim = (s: string): string => color(c.dim, s);
|
||||
|
||||
export function info(msg: string): void {
|
||||
console.log(`${color(c.gray, "•")} ${msg}`);
|
||||
}
|
||||
|
||||
export function success(msg: string): void {
|
||||
console.log(`${color(c.green, "✔")} ${msg}`);
|
||||
}
|
||||
|
||||
export function warn(msg: string): void {
|
||||
console.error(`${color(c.yellow, "⚠")} ${msg}`);
|
||||
}
|
||||
|
||||
export function error(msg: string): void {
|
||||
console.error(`${color(c.red, "✖")} ${msg}`);
|
||||
}
|
||||
|
||||
export function table(headers: string[], rows: string[][]): void {
|
||||
const all = [headers, ...rows];
|
||||
const widths = headers.map((_, i) =>
|
||||
Math.max(...all.map((r) => (r[i] ?? "").length)),
|
||||
);
|
||||
const fmt = (row: string[]): string =>
|
||||
row
|
||||
.map((cell, i) => (cell ?? "").padEnd(widths[i] ?? 0))
|
||||
.join(" ")
|
||||
.trimEnd();
|
||||
|
||||
console.log(bold(fmt(headers)));
|
||||
for (const row of rows) console.log(fmt(row));
|
||||
}
|
||||
|
||||
export function banner(): void {
|
||||
console.log(
|
||||
color(
|
||||
c.cyan,
|
||||
" _ _ ___ __ __ ___ _ ___ ___ \n" +
|
||||
" | || |/ _ \\| \\/ |/ _ \\| | / __|| _ ) \n" +
|
||||
" | __ | (_) | |\\/| | (_) | |__ | (__ | _ \\ \n" +
|
||||
" |_||_|\\___/|_| |_|\\___/|____| \\___||___/ \n",
|
||||
),
|
||||
);
|
||||
}
|
||||
16
installer/tsconfig.json
Normal file
16
installer/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ESNext"],
|
||||
"types": ["bun"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "scripts/**/*.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue