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
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# environment files (keep examples)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# local installer state
|
||||||
|
.homelab/
|
||||||
|
|
||||||
|
# OS noise
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.log
|
||||||
167
README.md
167
README.md
|
|
@ -1 +1,168 @@
|
||||||
# homelab
|
# homelab
|
||||||
|
|
||||||
|
A common, reusable repository for running services on a personal homelab.
|
||||||
|
|
||||||
|
Every service lives in its own folder under [`services/`](services/) and is described by a
|
||||||
|
`metadata.json` file (plus any extra files the service needs, such as a `Caddyfile`). The
|
||||||
|
[`installer/`](installer/) directory contains a Bun + TypeScript
|
||||||
|
terminal UI (TUI) that reads the catalog straight from GitHub (used as the CDN) and installs or
|
||||||
|
updates services with Docker.
|
||||||
|
|
||||||
|
```
|
||||||
|
homelab/
|
||||||
|
├── services/ # one folder per service
|
||||||
|
│ ├── catalog.json # generated index used by the installer (CDN listing)
|
||||||
|
│ ├── allprox/ # Caddy reverse proxy + portal
|
||||||
|
│ │ ├── metadata.json
|
||||||
|
│ │ ├── Caddyfile # proxy rules (domains → local IPs)
|
||||||
|
│ │ └── portal/index.html
|
||||||
|
│ ├── authelia/ # SSO / IdP (login portal, OIDC, forward-auth)
|
||||||
|
│ │ ├── metadata.json
|
||||||
|
│ │ ├── configuration.yml
|
||||||
|
│ │ └── users_database.yml
|
||||||
|
│ ├── lldap/ # lightweight LDAP user store
|
||||||
|
│ │ └── metadata.json
|
||||||
|
│ └── ...
|
||||||
|
├── installer/ # Bun + TypeScript TUI
|
||||||
|
│ ├── src/
|
||||||
|
│ └── scripts/
|
||||||
|
└── docs/
|
||||||
|
└── metadata-schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Identity stack (SSO)
|
||||||
|
|
||||||
|
Three services work together for single sign-on:
|
||||||
|
|
||||||
|
```
|
||||||
|
browser ──▶ allprox (Caddy) ──forward_auth──▶ authelia ──LDAP──▶ lldap
|
||||||
|
```
|
||||||
|
|
||||||
|
- [`allprox`](services/allprox) — reverse proxy; protected routes use Caddy `forward_auth` to Authelia.
|
||||||
|
- [`authelia`](services/authelia) — the SSO server (login portal, OIDC, 2FA) that authenticates users against…
|
||||||
|
- [`lldap`](services/lldap) — the lightweight LDAP directory (web UI at `http://<host>:17170`).
|
||||||
|
|
||||||
|
They talk to each other over a shared external Docker network named `homelab`, which the installer
|
||||||
|
creates automatically. Install `lldap` first, then `authelia`, then `allprox`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run src/index.ts install lldap authelia allprox
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install Bun: https://bun.sh
|
||||||
|
|
||||||
|
# 2. Install the installer's dependencies
|
||||||
|
cd installer
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# 3. Launch the TUI
|
||||||
|
bun run src/index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The TUI lets you browse the catalog, install services, update them, uninstall them, and inspect
|
||||||
|
what is currently running. Services are deployed with `docker compose` into `~/.homelab/services/<id>/`
|
||||||
|
(a `docker-compose.yml` and a `.env` are generated from each `metadata.json`).
|
||||||
|
|
||||||
|
> **Requirement:** Docker must be installed and the `docker` command available on your `PATH`.
|
||||||
|
|
||||||
|
## Non-interactive CLI
|
||||||
|
|
||||||
|
The same app works as a plain CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run src/index.ts list
|
||||||
|
bun run src/index.ts info allprox
|
||||||
|
bun run src/index.ts install allprox
|
||||||
|
bun run src/index.ts update # update everything installed
|
||||||
|
bun run src/index.ts update allprox # update one service
|
||||||
|
bun run src/index.ts uninstall allprox
|
||||||
|
bun run src/index.ts status
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful flags:
|
||||||
|
|
||||||
|
| Flag | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `--local <path>` | Read services from a local folder instead of GitHub (handy while developing a new service). |
|
||||||
|
| `--owner/--repo/--branch` | Override the GitHub source (also available as `HOMELAB_OWNER`, `HOMELAB_REPO`, `HOMELAB_BRANCH`). |
|
||||||
|
| `--env KEY=VALUE` | Provide an environment value non-interactively (repeatable). |
|
||||||
|
| `--yes` | Never prompt; use defaults or provided `--env` values. |
|
||||||
|
| `--dry-run` | Print what would happen (including the generated compose file) without touching Docker. |
|
||||||
|
| `--force` | Re-run an update even if versions already match. |
|
||||||
|
| `--verbose` | Stream `docker compose` output instead of only showing failures. |
|
||||||
|
|
||||||
|
## How the CDN works
|
||||||
|
|
||||||
|
The installer treats this GitHub repository as its content delivery network:
|
||||||
|
|
||||||
|
1. It lists services from [`services/catalog.json`](services/catalog.json) (one request). If that
|
||||||
|
file is missing it falls back to the GitHub `git/trees` API.
|
||||||
|
2. Each service's `metadata.json` is fetched from
|
||||||
|
`https://raw.githubusercontent.com/<owner>/<repo>/<branch>/services/<id>/metadata.json`.
|
||||||
|
|
||||||
|
Pointing the installer at your own fork is just a matter of setting `--owner`/`--repo` (or the
|
||||||
|
`HOMELAB_*` environment variables). For a **private** repository, export a GitHub token and the
|
||||||
|
installer authenticates its API and raw requests automatically: `HOMELAB_GITHUB_TOKEN` (or
|
||||||
|
`GITHUB_TOKEN` / `HOMELAB_TOKEN`). The repository must be pushed — the installer reads from GitHub,
|
||||||
|
not from your local checkout.
|
||||||
|
|
||||||
|
Adding a new service means adding a folder plus its `metadata.json` and regenerating the catalog:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd installer
|
||||||
|
bun run catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding a service
|
||||||
|
|
||||||
|
1. Create `services/<id>/metadata.json` (the folder name **must** equal the `id`).
|
||||||
|
2. Fill in the [metadata schema](docs/metadata-schema.json) — see [`services/README.md`](services/README.md)
|
||||||
|
for a walkthrough of every field. Add any extra files the service needs (a `Caddyfile`, a
|
||||||
|
`Dockerfile`, a static `portal/`, …) alongside `metadata.json`; the installer copies them into
|
||||||
|
the deploy directory, so reference them with relative bind mounts in `compose.volumes`.
|
||||||
|
3. Regenerate the catalog: `cd installer && bun run catalog`.
|
||||||
|
4. Commit and push. The installer will now offer the new service.
|
||||||
|
|
||||||
|
## Service metadata at a glance
|
||||||
|
|
||||||
|
`metadata.json` is the single source of truth for a service. The essential shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "allprox",
|
||||||
|
"name": "allprox",
|
||||||
|
"description": "Caddy reverse proxy with an SSO-ready portal",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"category": "network",
|
||||||
|
"compose": {
|
||||||
|
"image": "caddy:2-alpine",
|
||||||
|
"container_name": "allprox",
|
||||||
|
"restart": "unless-stopped",
|
||||||
|
"ports": ["80:80", "443:443"],
|
||||||
|
"volumes": ["./Caddyfile:/etc/caddy/Caddyfile:ro", "allprox_data:/data"],
|
||||||
|
"environment": ["PORTAL_AUTH_HASH=${PORTAL_AUTH_HASH}"]
|
||||||
|
},
|
||||||
|
"volumes": { "allprox_data": {} },
|
||||||
|
"env": [
|
||||||
|
{ "name": "PORTAL_AUTH_HASH", "label": "Portal admin password hash", "secret": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `compose` is a standard Docker Compose *service* definition (image, ports, volumes, environment, …).
|
||||||
|
The installer wraps it in a generated `docker-compose.yml`.
|
||||||
|
- `volumes` / `networks` are optional top-level named volumes/networks to declare.
|
||||||
|
- `env` declares variables the installer should resolve for you. Use `${NAME}` in `compose` to
|
||||||
|
reference them — the installer writes the resolved values to a `.env` file next to the compose file.
|
||||||
|
- Any other files in the service folder (e.g. `Caddyfile`, `portal/index.html`) are copied into the
|
||||||
|
deploy directory, so you can mount them with relative paths (`./Caddyfile:...`) in `compose.volumes`.
|
||||||
|
|
||||||
|
See [`docs/metadata-schema.json`](docs/metadata-schema.json) for the complete, machine-readable schema.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This repository is a personal template. Service entries reference their upstream projects; each
|
||||||
|
service's own license applies to the software it deploys.
|
||||||
|
|
|
||||||
68
docs/metadata-schema.json
Normal file
68
docs/metadata-schema.json
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "https://raw.githubusercontent.com/ReinadoRojo/homelab/master/docs/metadata-schema.json",
|
||||||
|
"title": "Homelab Service Metadata",
|
||||||
|
"description": "Schema for a service's metadata.json in a homelab repository.",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "name", "description", "version", "compose"],
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unique kebab-case id. Must equal the folder name.",
|
||||||
|
"pattern": "^[a-z0-9][a-z0-9-]*$"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable service name."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Short description of the service."
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Version string used to detect updates."
|
||||||
|
},
|
||||||
|
"compose": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "A Docker Compose service definition (image, ports, volumes, environment, ...)."
|
||||||
|
},
|
||||||
|
"volumes": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Top-level named volumes to declare."
|
||||||
|
},
|
||||||
|
"networks": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Top-level networks to declare."
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Variables the installer resolves and writes to .env.",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"label": { "type": "string" },
|
||||||
|
"description": { "type": "string" },
|
||||||
|
"default": { "type": "string" },
|
||||||
|
"required": { "type": "boolean" },
|
||||||
|
"secret": { "type": "boolean" },
|
||||||
|
"options": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"generate": { "type": "boolean" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"category": { "type": "string" },
|
||||||
|
"tags": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"icon": { "type": "string", "format": "uri" },
|
||||||
|
"author": { "type": "string" },
|
||||||
|
"license": { "type": "string" },
|
||||||
|
"homepage": { "type": "string", "format": "uri" },
|
||||||
|
"documentation": { "type": "string", "format": "uri" },
|
||||||
|
"dependsOn": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"notes": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
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"]
|
||||||
|
}
|
||||||
131
services/README.md
Normal file
131
services/README.md
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
# services
|
||||||
|
|
||||||
|
One folder per service. Each folder must contain a `metadata.json` describing the service
|
||||||
|
(including the Docker Compose definition used to deploy it). The folder may also contain any extra
|
||||||
|
files the service needs — a `Caddyfile`, a `Dockerfile`, static assets — which the installer copies
|
||||||
|
into the deploy directory.
|
||||||
|
|
||||||
|
## Folder conventions
|
||||||
|
|
||||||
|
- The folder name **must** equal the service `id` in `metadata.json` (lowercase kebab-case).
|
||||||
|
- Only folders that contain a valid `metadata.json` are treated as services by the installer.
|
||||||
|
- After adding, editing, or removing a service, regenerate the index:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd installer
|
||||||
|
bun run catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
This rewrites [`catalog.json`](catalog.json), which the installer uses as a one-request listing.
|
||||||
|
|
||||||
|
## Extra files
|
||||||
|
|
||||||
|
A service folder can contain files beyond `metadata.json` — for example the `allprox` service ships
|
||||||
|
a `Caddyfile` and a `portal/index.html`. On install/update the installer copies the whole folder
|
||||||
|
(except `metadata.json`) into `~/.homelab/services/<id>/`, preserving subdirectories.
|
||||||
|
|
||||||
|
Reference those files from `compose.volumes` with **relative** paths:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"volumes": [
|
||||||
|
"./Caddyfile:/etc/caddy/Caddyfile:ro",
|
||||||
|
"./portal:/srv/portal:ro"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the generated `docker-compose.yml` lives in the same directory, `docker compose` resolves
|
||||||
|
the `./...` paths against it.
|
||||||
|
|
||||||
|
## Shared networks
|
||||||
|
|
||||||
|
Services that need to talk to each other (e.g. `allprox` → `authelia` → `lldap`) join an external
|
||||||
|
Docker network. Declare it at the top level of `metadata.json` and attach the service to it:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"networks": { "homelab": { "external": true } },
|
||||||
|
"compose": {
|
||||||
|
"networks": ["homelab"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer creates the external network automatically (if it doesn't already exist) before
|
||||||
|
running `docker compose up`.
|
||||||
|
|
||||||
|
## `metadata.json` reference
|
||||||
|
|
||||||
|
| Field | Required | Type | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `id` | ✅ | string | Unique kebab-case id, equal to the folder name. |
|
||||||
|
| `name` | ✅ | string | Human-readable name shown in the TUI. |
|
||||||
|
| `description` | ✅ | string | Short description. |
|
||||||
|
| `version` | ✅ | string | Version string for change detection on update. |
|
||||||
|
| `compose` | ✅ | object | A Docker Compose **service** definition (`image`, `ports`, `volumes`, `environment`, …). The installer wraps it in a generated `docker-compose.yml`. |
|
||||||
|
| `volumes` | — | object | Optional top-level named volumes to declare. |
|
||||||
|
| `networks` | — | object | Optional top-level networks to declare. |
|
||||||
|
| `env` | — | array | Variables the installer resolves for you (see below). |
|
||||||
|
| `category` | — | string | Grouping shown as a hint in the TUI. |
|
||||||
|
| `tags` | — | string[] | Free-form tags. |
|
||||||
|
| `icon` | — | string | URL to an icon. |
|
||||||
|
| `author` | — | string | Upstream author. |
|
||||||
|
| `license` | — | string | License of the deployed software. |
|
||||||
|
| `homepage` | — | string | Project homepage URL. |
|
||||||
|
| `documentation` | — | string | Documentation URL. |
|
||||||
|
| `dependsOn` | — | string[] | Ids of services that should be installed first (informational). |
|
||||||
|
| `notes` | — | string | Free-form notes shown by `info`. |
|
||||||
|
|
||||||
|
A machine-readable JSON Schema is available at [`docs/metadata-schema.json`](../docs/metadata-schema.json).
|
||||||
|
|
||||||
|
### `compose` (the service definition)
|
||||||
|
|
||||||
|
This object is the value you would normally put under a service key in `docker-compose.yml`. For
|
||||||
|
example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"image": "ghcr.io/example/myapp:1.0.0",
|
||||||
|
"container_name": "myapp",
|
||||||
|
"restart": "unless-stopped",
|
||||||
|
"ports": ["8080:8080"],
|
||||||
|
"volumes": ["myapp_data:/data"],
|
||||||
|
"environment": ["TZ=${TZ}"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer turns it into:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
myapp:
|
||||||
|
image: ghcr.io/example/myapp:1.0.0
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Pin images to a tag so updates are predictable; the installer compares `version` to decide whether
|
||||||
|
an update is available.
|
||||||
|
|
||||||
|
### `env` (interactive variables)
|
||||||
|
|
||||||
|
Each entry describes a variable the installer should collect (and write to `.env`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "WEBPASSWORD",
|
||||||
|
"label": "Web admin password",
|
||||||
|
"description": "Password for the web UI",
|
||||||
|
"default": "change-me",
|
||||||
|
"required": true,
|
||||||
|
"secret": true,
|
||||||
|
"options": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `name` (required) — the variable name. Reference it in `compose` as `${NAME}`.
|
||||||
|
- `label` / `description` — shown when prompting.
|
||||||
|
- `default` — used when the user accepts the default or runs non-interactively.
|
||||||
|
- `required` — a missing value is an error in non-interactive mode unless a `default` is set.
|
||||||
|
- `secret` — mask input at the prompt (e.g. passwords).
|
||||||
|
- `options` — if provided, the installer offers a fixed choice list instead of free text.
|
||||||
|
- `generate` — generate a random 64-char hex secret instead of prompting (for JWT/session secrets).
|
||||||
|
|
||||||
|
Docker Compose automatically reads the `.env` written next to the generated compose file, so
|
||||||
|
`${NAME}` references resolve at `docker compose up` time.
|
||||||
63
services/allprox/Caddyfile
Normal file
63
services/allprox/Caddyfile
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# allprox — Caddy reverse proxy
|
||||||
|
#
|
||||||
|
# Maps domains to local IPs and hosts an authenticated portal (web UI).
|
||||||
|
# Edit the domain names below to match your DNS, and set the upstream
|
||||||
|
# IP:port values in the installer's .env (APP1_UPSTREAM, APP2_UPSTREAM, ...).
|
||||||
|
# Duplicate a "Proxied services" block to add more services.
|
||||||
|
#
|
||||||
|
# Authentication:
|
||||||
|
# * Today: basic_auth (a single admin account; hash set via PORTAL_AUTH_HASH).
|
||||||
|
# * Later: Authelia SSO — replace the basic_auth blocks with the forward_auth
|
||||||
|
# block shown below (see https://www.authelia.com/integration/proxies/caddy/).
|
||||||
|
|
||||||
|
{
|
||||||
|
# For public domains with automatic HTTPS, set your ACME email here:
|
||||||
|
# email you@example.com
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Web UI — authenticated portal (change the domain to your own)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
portal.example.com {
|
||||||
|
# `tls internal` issues a self-signed cert for LAN use. Remove this line
|
||||||
|
# and set the ACME email above when exposing a real public domain.
|
||||||
|
tls internal
|
||||||
|
|
||||||
|
basic_auth {
|
||||||
|
admin {$PORTAL_AUTH_HASH}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Authelia SSO (later): replace the basic_auth block above with:
|
||||||
|
# forward_auth authelia:9091 {
|
||||||
|
# uri /api/authz/forward-auth
|
||||||
|
# copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
|
||||||
|
# }
|
||||||
|
|
||||||
|
root * /srv/portal
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Authelia portal — the SSO login page (proxy to the authelia container).
|
||||||
|
# With Authelia running, users are redirected here to sign in.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
auth.example.com {
|
||||||
|
tls internal
|
||||||
|
reverse_proxy authelia:9091
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Proxied services — copy a block per service. The local IP:port comes from
|
||||||
|
# the {$APPx_UPSTREAM} variable in .env.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
app1.example.com {
|
||||||
|
tls internal
|
||||||
|
# basic_auth { admin {$PORTAL_AUTH_HASH} } # or the Authelia forward_auth block
|
||||||
|
reverse_proxy {$APP1_UPSTREAM}
|
||||||
|
}
|
||||||
|
|
||||||
|
app2.example.com {
|
||||||
|
tls internal
|
||||||
|
# basic_auth { admin {$PORTAL_AUTH_HASH} }
|
||||||
|
reverse_proxy {$APP2_UPSTREAM}
|
||||||
|
}
|
||||||
64
services/allprox/metadata.json
Normal file
64
services/allprox/metadata.json
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
{
|
||||||
|
"id": "allprox",
|
||||||
|
"name": "allprox",
|
||||||
|
"description": "Caddy reverse proxy that routes domains to local IPs, hosts an authenticated portal (web UI), and is SSO-ready for Authelia (OAuth2/OIDC).",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"category": "network",
|
||||||
|
"tags": ["reverse-proxy", "caddy", "authelia", "sso", "https"],
|
||||||
|
"author": "Caddy / Authelia",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"homepage": "https://caddyserver.com",
|
||||||
|
"documentation": "https://www.authelia.com/integration/proxies/caddy/",
|
||||||
|
"compose": {
|
||||||
|
"image": "caddy:2-alpine",
|
||||||
|
"container_name": "allprox",
|
||||||
|
"restart": "unless-stopped",
|
||||||
|
"ports": ["80:80", "443:443", "443:443/udp"],
|
||||||
|
"volumes": [
|
||||||
|
"./Caddyfile:/etc/caddy/Caddyfile:ro",
|
||||||
|
"./portal:/srv/portal:ro",
|
||||||
|
"allprox_data:/data",
|
||||||
|
"allprox_config:/config"
|
||||||
|
],
|
||||||
|
"environment": [
|
||||||
|
"APP1_UPSTREAM=${APP1_UPSTREAM}",
|
||||||
|
"APP2_UPSTREAM=${APP2_UPSTREAM}",
|
||||||
|
"PORTAL_AUTH_HASH=${PORTAL_AUTH_HASH}"
|
||||||
|
],
|
||||||
|
"networks": ["homelab"]
|
||||||
|
},
|
||||||
|
"volumes": {
|
||||||
|
"allprox_data": {},
|
||||||
|
"allprox_config": {}
|
||||||
|
},
|
||||||
|
"networks": {
|
||||||
|
"homelab": { "external": true }
|
||||||
|
},
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "PORTAL_AUTH_HASH",
|
||||||
|
"label": "Portal admin password (bcrypt hash)",
|
||||||
|
"description": "bcrypt hash for basic_auth (default is 'changeme'). Generate your own with: docker compose exec allprox caddy hash-password",
|
||||||
|
"default": "$2b$10$fKkpKSlwLZtOBXpInl8pG.8mS65kiEfjOVsuvBj7ikHgtfqEa7h4y",
|
||||||
|
"required": false,
|
||||||
|
"secret": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "APP1_UPSTREAM",
|
||||||
|
"label": "Upstream for app1.example.com",
|
||||||
|
"description": "Local IP:port to proxy app1.example.com to",
|
||||||
|
"default": "127.0.0.1:3000",
|
||||||
|
"required": false,
|
||||||
|
"secret": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "APP2_UPSTREAM",
|
||||||
|
"label": "Upstream for app2.example.com",
|
||||||
|
"description": "Local IP:port to proxy app2.example.com to",
|
||||||
|
"default": "127.0.0.1:8080",
|
||||||
|
"required": false,
|
||||||
|
"secret": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": "Edit services/allprox/Caddyfile to add domains and change upstreams. The portal (web UI) is at portal.example.com (change the domain). Interim auth is basic_auth (admin / 'changeme' by default) — swap to the Authelia forward_auth block in the Caddyfile for OAuth2/OIDC SSO."
|
||||||
|
}
|
||||||
80
services/allprox/portal/index.html
Normal file
80
services/allprox/portal/index.html
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>allprox — portal</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
main { width: 100%; max-width: 680px; padding: 2rem; }
|
||||||
|
h1 { font-size: 2rem; margin: 0 0 .25rem; letter-spacing: -.02em; }
|
||||||
|
.muted { color: #94a3b8; }
|
||||||
|
.card {
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.1rem 1.35rem;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
}
|
||||||
|
.card strong { display: block; margin-bottom: .25rem; }
|
||||||
|
ul { list-style: none; padding: 0; margin: 0; }
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: .55rem 0;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
}
|
||||||
|
li:last-child { border-bottom: none; }
|
||||||
|
a { color: #7dd3fc; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
code {
|
||||||
|
background: #0b1220;
|
||||||
|
padding: .15rem .45rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: .85em;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
font-size: .7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
color: #86efac;
|
||||||
|
border: 1px solid #166534;
|
||||||
|
background: #052e16;
|
||||||
|
padding: .15rem .5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>allprox <span class="badge">signed in</span></h1>
|
||||||
|
<p class="muted">Authenticated access portal — you are signed in.</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<strong>Services</strong>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://app1.example.com">app1.example.com</a> <code>127.0.0.1:3000</code></li>
|
||||||
|
<li><a href="https://app2.example.com">app2.example.com</a> <code>127.0.0.1:8080</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="muted">
|
||||||
|
Edit this page at <code>services/allprox/portal/index.html</code> and the proxy
|
||||||
|
rules in <code>services/allprox/Caddyfile</code>.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
71
services/authelia/configuration.yml
Normal file
71
services/authelia/configuration.yml
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# Authelia configuration — https://www.authelia.com/configuration/prologue/introduction/
|
||||||
|
#
|
||||||
|
# Secrets (JWT_SECRET, RESET_JWT_SECRET, SESSION_SECRET, LDAP_ADMIN_PASSWORD) are
|
||||||
|
# resolved from the service's .env by the installer and substituted into this file
|
||||||
|
# on install/update, so they are not committed here.
|
||||||
|
|
||||||
|
theme: dark
|
||||||
|
|
||||||
|
jwt_secret: '${JWT_SECRET}'
|
||||||
|
|
||||||
|
server:
|
||||||
|
address: 'tcp://0.0.0.0:9091/'
|
||||||
|
endpoints:
|
||||||
|
authz:
|
||||||
|
forward-auth:
|
||||||
|
implementation: 'ForwardAuth'
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
totp:
|
||||||
|
issuer: 'homelab'
|
||||||
|
|
||||||
|
identity_validation:
|
||||||
|
reset_password:
|
||||||
|
jwt_secret: '${RESET_JWT_SECRET}'
|
||||||
|
|
||||||
|
authentication_backend:
|
||||||
|
password_reset:
|
||||||
|
disable: true
|
||||||
|
refresh_interval: '5m'
|
||||||
|
ldap:
|
||||||
|
implementation: 'lldap'
|
||||||
|
address: 'ldap://lldap:3890'
|
||||||
|
base_dn: 'dc=homelab,dc=local'
|
||||||
|
user: 'uid=admin,ou=people,dc=homelab,dc=local'
|
||||||
|
password: '${LDAP_ADMIN_PASSWORD}'
|
||||||
|
|
||||||
|
access_control:
|
||||||
|
default_policy: deny
|
||||||
|
rules:
|
||||||
|
- domain: 'auth.example.com'
|
||||||
|
policy: bypass
|
||||||
|
- domain: 'portal.example.com'
|
||||||
|
policy: one_factor
|
||||||
|
- domain: '*.example.com'
|
||||||
|
policy: one_factor
|
||||||
|
|
||||||
|
session:
|
||||||
|
name: 'authelia_session'
|
||||||
|
secret: '${SESSION_SECRET}'
|
||||||
|
expiration: '1h'
|
||||||
|
inactivity: '5m'
|
||||||
|
remember_me: '1M'
|
||||||
|
cookies:
|
||||||
|
- domain: 'example.com'
|
||||||
|
authelia_url: 'https://auth.example.com'
|
||||||
|
default_redirection_url: 'https://portal.example.com'
|
||||||
|
|
||||||
|
regulation:
|
||||||
|
max_retries: 3
|
||||||
|
find_time: '2m'
|
||||||
|
ban_time: '5m'
|
||||||
|
|
||||||
|
storage:
|
||||||
|
local:
|
||||||
|
path: '/config/db.sqlite3'
|
||||||
|
|
||||||
|
notifier:
|
||||||
|
filesystem:
|
||||||
|
filename: '/config/notification.txt'
|
||||||
57
services/authelia/metadata.json
Normal file
57
services/authelia/metadata.json
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
{
|
||||||
|
"id": "authelia",
|
||||||
|
"name": "Authelia",
|
||||||
|
"description": "Open-source authentication and authorization server providing SSO and 2FA for the homelab.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"category": "identity",
|
||||||
|
"tags": ["sso", "authentication", "2fa", "oidc", "forward-auth"],
|
||||||
|
"author": "Authelia",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"homepage": "https://www.authelia.com",
|
||||||
|
"documentation": "https://www.authelia.com/configuration/prologue/introduction/",
|
||||||
|
"compose": {
|
||||||
|
"image": "authelia/authelia:latest",
|
||||||
|
"container_name": "authelia",
|
||||||
|
"restart": "unless-stopped",
|
||||||
|
"ports": ["9091:9091"],
|
||||||
|
"volumes": [
|
||||||
|
"authelia_config:/config",
|
||||||
|
"./configuration.yml:/config/configuration.yml:ro",
|
||||||
|
"./users_database.yml:/config/users_database.yml:ro"
|
||||||
|
],
|
||||||
|
"networks": ["homelab"]
|
||||||
|
},
|
||||||
|
"volumes": {
|
||||||
|
"authelia_config": {}
|
||||||
|
},
|
||||||
|
"networks": {
|
||||||
|
"homelab": { "external": true }
|
||||||
|
},
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "JWT_SECRET",
|
||||||
|
"label": "JWT secret (auto-generated)",
|
||||||
|
"generate": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RESET_JWT_SECRET",
|
||||||
|
"label": "Reset-password JWT secret (auto-generated)",
|
||||||
|
"generate": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SESSION_SECRET",
|
||||||
|
"label": "Session secret (auto-generated)",
|
||||||
|
"generate": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LDAP_ADMIN_PASSWORD",
|
||||||
|
"label": "LDAP admin password",
|
||||||
|
"description": "Must match lldap's LLDAP_LDAP_USER_PASS",
|
||||||
|
"default": "changeme-admin",
|
||||||
|
"required": false,
|
||||||
|
"secret": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependsOn": ["lldap"],
|
||||||
|
"notes": "Authelia's portal is normally reached through allprox at auth.example.com (see the allprox Caddyfile). The forward-auth endpoint is http://authelia:9091/api/authz/forward-auth. Default access-control rules protect portal.example.com and *.example.com — edit configuration.yml to match your domains."
|
||||||
|
}
|
||||||
3
services/authelia/users_database.yml
Normal file
3
services/authelia/users_database.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Local users (file backend). We authenticate against LLDAP, so this stays empty.
|
||||||
|
# Authelia still requires the file to exist.
|
||||||
|
users: {}
|
||||||
20
services/catalog.json
Normal file
20
services/catalog.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"generatedAt": "2026-09-01T16:20:56.550Z",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"id": "allprox",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"path": "services/allprox/metadata.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "authelia",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"path": "services/authelia/metadata.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lldap",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"path": "services/lldap/metadata.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
66
services/lldap/metadata.json
Normal file
66
services/lldap/metadata.json
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
{
|
||||||
|
"id": "lldap",
|
||||||
|
"name": "LLDAP",
|
||||||
|
"description": "Lightweight LDAP server with a simple web UI for managing users and groups.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"category": "identity",
|
||||||
|
"tags": ["ldap", "authentication", "identity", "web-ui"],
|
||||||
|
"author": "LLDAP",
|
||||||
|
"license": "MIT",
|
||||||
|
"homepage": "https://github.com/lldap/lldap",
|
||||||
|
"documentation": "https://github.com/lldap/lldap#readme",
|
||||||
|
"compose": {
|
||||||
|
"image": "lldap/lldap:stable",
|
||||||
|
"container_name": "lldap",
|
||||||
|
"restart": "unless-stopped",
|
||||||
|
"ports": ["17170:17170", "3890:3890"],
|
||||||
|
"volumes": ["lldap_data:/data"],
|
||||||
|
"environment": [
|
||||||
|
"LLDAP_LDAP_BASE_DN=${LLDAP_LDAP_BASE_DN}",
|
||||||
|
"LLDAP_LDAP_USER_DN=admin",
|
||||||
|
"LLDAP_LDAP_USER_PASS=${LLDAP_LDAP_USER_PASS}",
|
||||||
|
"LLDAP_LDAP_USER_EMAIL=${LLDAP_LDAP_USER_EMAIL}",
|
||||||
|
"LLDAP_JWT_SECRET=${LLDAP_JWT_SECRET}",
|
||||||
|
"LLDAP_LDAP_PORT=3890",
|
||||||
|
"LLDAP_HTTP_PORT=17170"
|
||||||
|
],
|
||||||
|
"networks": ["homelab"]
|
||||||
|
},
|
||||||
|
"volumes": {
|
||||||
|
"lldap_data": {}
|
||||||
|
},
|
||||||
|
"networks": {
|
||||||
|
"homelab": { "external": true }
|
||||||
|
},
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "LLDAP_LDAP_BASE_DN",
|
||||||
|
"label": "LDAP base DN",
|
||||||
|
"description": "Base DN for users and groups (keep in sync with Authelia)",
|
||||||
|
"default": "dc=homelab,dc=local",
|
||||||
|
"required": false,
|
||||||
|
"secret": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LLDAP_LDAP_USER_PASS",
|
||||||
|
"label": "Admin password",
|
||||||
|
"description": "Admin bind password — must match authelia's LDAP_ADMIN_PASSWORD",
|
||||||
|
"default": "changeme-admin",
|
||||||
|
"required": false,
|
||||||
|
"secret": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LLDAP_LDAP_USER_EMAIL",
|
||||||
|
"label": "Admin email",
|
||||||
|
"default": "admin@homelab.local",
|
||||||
|
"required": false,
|
||||||
|
"secret": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LLDAP_JWT_SECRET",
|
||||||
|
"label": "JWT secret (auto-generated)",
|
||||||
|
"generate": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": "Web UI at http://<host>:17170 — log in with 'admin' and your admin password. LDAP endpoint is ldap://lldap:3890 (base DN dc=homelab,dc=local). Create users and groups here; Authelia authenticates against them."
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue