diff --git a/.env.example b/.env.example index af7ebdc..91d8f38 100644 --- a/.env.example +++ b/.env.example @@ -59,3 +59,7 @@ SYNC_PATH=/mnt/sync # Syncthing data directory # Wallabag (read-it-later) # Generate a random secret: openssl rand -hex 32 WALLABAG_SECRET= + +# KOReader Sync Server — password hashing salt +# Generate with: openssl rand -hex 32 +KOSYNC_PASSWORD_SALT= diff --git a/.light/sessions/2026-04-19-add-koreader-sync-server-execution.md b/.light/sessions/2026-04-19-add-koreader-sync-server-execution.md new file mode 100644 index 0000000..57aaf76 --- /dev/null +++ b/.light/sessions/2026-04-19-add-koreader-sync-server-execution.md @@ -0,0 +1,19 @@ +# Execution Log: add-koreader-sync-server (2026-04-19) + +Epic: epic add-koreader-sync-server + +[DISPATCHED] group epic-add-koreader-sync-server-21z5 phase-1-docker-compose — agent type: no-test, mode: sync +[GATE PASS] group epic-add-koreader-sync-server-21z5 phase-1-docker-compose — GREEN gate passed +[CLOSED] group epic-add-koreader-sync-server-21z5 phase-1-docker-compose +[DISPATCHED] group epic-add-koreader-sync-server-21z5 phase-2-env-example — agent type: no-test, mode: sync +[GATE PASS] group epic-add-koreader-sync-server-21z5 phase-2-env-example — GREEN gate passed +[CLOSED] group epic-add-koreader-sync-server-21z5 phase-2-env-example +[DISPATCHED] group epic-add-koreader-sync-server-21z5 phase-3-homepage — agent type: no-test, mode: sync +[GATE PASS] group epic-add-koreader-sync-server-21z5 phase-3-homepage — GREEN gate passed +[CLOSED] group epic-add-koreader-sync-server-21z5 phase-3-homepage +[DISPATCHED] group epic-add-koreader-sync-server-21z5 phase-4-readme — agent type: no-test, mode: sync +[GATE PASS] group epic-add-koreader-sync-server-21z5 phase-4-readme — GREEN gate passed +[CLOSED] group epic-add-koreader-sync-server-21z5 phase-4-readme +[DISPATCHED] group epic-add-koreader-sync-server-21z5 phase-5-lint-verification — agent type: no-test, mode: sync +[GATE PASS] group epic-add-koreader-sync-server-21z5 phase-5-lint-verification — GREEN gate passed +[CLOSED] group epic-add-koreader-sync-server-21z5 phase-5-lint-verification diff --git a/.light/sessions/2026-04-19-add-koreader-sync-server.manifest.json b/.light/sessions/2026-04-19-add-koreader-sync-server.manifest.json new file mode 100644 index 0000000..8ef8ee1 --- /dev/null +++ b/.light/sessions/2026-04-19-add-koreader-sync-server.manifest.json @@ -0,0 +1,20 @@ +{ + "session_date": "2026-04-19", + "topic": "add-koreader-sync-server", + "workflow_steps": ["research", "plan", "implement"], + "files_created": [], + "files_modified": [ + "docker-compose.yml", + ".env.example", + "homepage/config/services.yaml", + "README.md" + ], + "gates": { + "RED": 0, + "GREEN": 5, + "VALIDATE": 0 + }, + "phases_completed": 5, + "total_phases": 5, + "test_suite_passed": true +} diff --git a/.light/sessions/2026-04-19-add-koreader-sync-server.md b/.light/sessions/2026-04-19-add-koreader-sync-server.md new file mode 100644 index 0000000..3d568ce --- /dev/null +++ b/.light/sessions/2026-04-19-add-koreader-sync-server.md @@ -0,0 +1,51 @@ +# Session: Add KOReader Sync Server (2026-04-19) + +## Research Summary + +- **Image chosen:** `ghcr.io/nperez0111/koreader-sync:latest` — TypeScript/Bun/Hono, SQLite storage at `/app/data`, port 3000, built-in health check +- **Alternative considered:** `koreader/kosync` (official, Lua/OpenResty + Redis) — heavier, not chosen +- `PASSWORD_SALT` env var required for account creation; named Docker volume for SQLite persistence +- No Homepage widget available for kosync — link-only entry using `si-koreader` (Simple Icons) +- No `HOMEPAGE_VAR_*` references needed → lint script passes without homepage environment changes + +## Plan Summary + +**Goal:** Add `kosync` to the stack so KOReader devices sync reading positions to `https://kosync.woggles.work`. + +**Phases:** + +1. Docker Compose — add `kosync` service block + `kosync-data` named volume +2. Environment — add `KOSYNC_PASSWORD_SALT=` to `.env.example` +3. Homepage — add KOReader Sync link under Reading category in `services.yaml` +4. README — add KOReader Sync row to services table +5. Lint Verification — confirm `./scripts/lint-config.sh` exits 0 + +**Acceptance Criteria (all met):** + +- `docker-compose.yml` includes `kosync` service with Traefik labels and `kosync-data` volume +- `.env.example` has `KOSYNC_PASSWORD_SALT=` with generation comment +- `homepage/config/services.yaml` has KOReader Sync link in Reading category +- `README.md` services table includes KOReader Sync row +- `./scripts/lint-config.sh` exits 0 + +## Execution Log + +[DISPATCHED] group epic-add-koreader-sync-server-21z5 phase-1-docker-compose — agent type: no-test, mode: sync +[GATE PASS] group epic-add-koreader-sync-server-21z5 phase-1-docker-compose — GREEN gate passed +[CLOSED] group epic-add-koreader-sync-server-21z5 phase-1-docker-compose +[DISPATCHED] group epic-add-koreader-sync-server-21z5 phase-2-env-example — agent type: no-test, mode: sync +[GATE PASS] group epic-add-koreader-sync-server-21z5 phase-2-env-example — GREEN gate passed +[CLOSED] group epic-add-koreader-sync-server-21z5 phase-2-env-example +[DISPATCHED] group epic-add-koreader-sync-server-21z5 phase-3-homepage — agent type: no-test, mode: sync +[GATE PASS] group epic-add-koreader-sync-server-21z5 phase-3-homepage — GREEN gate passed +[CLOSED] group epic-add-koreader-sync-server-21z5 phase-3-homepage +[DISPATCHED] group epic-add-koreader-sync-server-21z5 phase-4-readme — agent type: no-test, mode: sync +[GATE PASS] group epic-add-koreader-sync-server-21z5 phase-4-readme — GREEN gate passed +[CLOSED] group epic-add-koreader-sync-server-21z5 phase-4-readme +[DISPATCHED] group epic-add-koreader-sync-server-21z5 phase-5-lint-verification — agent type: no-test, mode: sync +[GATE PASS] group epic-add-koreader-sync-server-21z5 phase-5-lint-verification — GREEN gate passed +[CLOSED] group epic-add-koreader-sync-server-21z5 phase-5-lint-verification + +## Outcome + +All 5 phases completed. `./scripts/lint-config.sh` exits 0 — 6 HOMEPAGE*VAR*\* references and 11 .env.example vars all verified. All acceptance criteria met. diff --git a/.light/sessions/add-koreader-sync-server-plan.md b/.light/sessions/add-koreader-sync-server-plan.md new file mode 100644 index 0000000..9fcf036 --- /dev/null +++ b/.light/sessions/add-koreader-sync-server-plan.md @@ -0,0 +1,249 @@ +# Plan: Add KOReader Sync Server + +**tracker: yaks** + +--- + +## Context + +KOReader is an e-reader app (Kindle, Kobo, Android) that syncs reading progress across devices via a kosync-compatible server. The public service (`sync.koreader.rocks`) is a third-party dependency; this adds a self-hosted replacement. + +Research findings: + +- **Image:** `ghcr.io/nperez0111/koreader-sync:latest` — TypeScript/Bun/Hono, SQLite storage, port 3000, built-in health check at `GET /health` +- **Alternative considered:** `koreader/kosync` (official Lua/OpenResty + Redis) — heavier, less conventional; not chosen +- **Storage:** Named Docker volume `kosync-data` at `/app/data`; SQLite, no external DB needed +- **Auth:** `PASSWORD_SALT` env var required for account creation (generate via `openssl rand -hex 32`) +- **Routing:** Traefik on `proxy` network; no `ports:` mapping needed +- **Homepage:** Link-only entry (no widget API available); `si-koreader` icon via Simple Icons; Reading category already exists (contains Wallabag) +- **README:** Services table exists at lines 13–20; Wallabag was not added to it (out of scope here — we add KOReader Sync only) +- **lint-config.sh:** No `HOMEPAGE_VAR_*` references needed → script should pass without changes + +--- + +## Goal + +Add the `kosync` service to `docker-compose.yml` so KOReader devices can sync reading positions to the home server at `https://kosync.woggles.work`, and surface it on the Homepage dashboard. + +--- + +## Acceptance Criteria + +- [ ] `docker-compose.yml` includes `kosync` service with Traefik labels, `kosync-data` named volume, and `KOSYNC_PASSWORD_SALT` env var +- [ ] `kosync-data` volume declared at the compose root `volumes:` block +- [ ] `.env.example` has `KOSYNC_PASSWORD_SALT=` with a generation comment +- [ ] `homepage/config/services.yaml` has a KOReader Sync link in the Reading category +- [ ] `README.md` services table includes a KOReader Sync row +- [ ] `./scripts/lint-config.sh` exits 0 with no errors + +--- + +## Files to Modify + +| File | Change | +| ------------------------------- | ---------------------------------------------------------------- | +| `docker-compose.yml` | Add `kosync` service block; add `kosync-data` to root `volumes:` | +| `.env.example` | Add `KOSYNC_PASSWORD_SALT=` with generation comment | +| `homepage/config/services.yaml` | Add KOReader Sync link under Reading category | +| `README.md` | Add `KOReader Sync` row to services table | + +--- + +## Implementation Phases + +### Phase 1: Docker Compose — Add kosync service [no-test] + +**Goal:** Wire the kosync container into the stack with Traefik routing and persistent storage. + +**Tasks:** + +- Add `kosync` service block after the Wallabag service in `docker-compose.yml` +- Add `kosync-data:` to the root `volumes:` block + +**Verification:** + +- [ ] `docker-compose config --quiet` exits 0 (compose file is valid) +- [ ] `kosync` service appears with `traefik.enable=true` label +- [ ] `kosync-data` volume is declared at root level + +#### Agent Context + +``` +Files to modify: + - docker-compose.yml + +Service block to add (after wallabag service): + kosync: + container_name: kosync + image: ghcr.io/nperez0111/koreader-sync:latest + environment: + - TZ=${TZ:-America/New_York} + - PASSWORD_SALT=${KOSYNC_PASSWORD_SALT:-} + volumes: + - kosync-data:/app/data + networks: + - proxy + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.http.routers.kosync.rule=Host(`kosync.woggles.work`)" + - "traefik.http.routers.kosync.entrypoints=websecure" + - "traefik.http.routers.kosync.tls.certresolver=cloudflare" + - "traefik.http.services.kosync.loadbalancer.server.port=3000" + +Root volumes block (currently has only `wallabag_images:`): + Add: kosync-data: + +Verification command: docker-compose config --quiet +GREEN gate: exits 0 +``` + +--- + +### Phase 2: Environment — Add KOSYNC_PASSWORD_SALT [no-test] + +**Goal:** Document the required secret in `.env.example`. + +**Tasks:** + +- Add `KOSYNC_PASSWORD_SALT=` entry to `.env.example` with a comment + +**Verification:** + +- [ ] `KOSYNC_PASSWORD_SALT=` appears in `.env.example` +- [ ] Comment explains how to generate the value + +#### Agent Context + +``` +Files to modify: + - .env.example + +Entry to add (near other service secrets, e.g. after WALLABAG_SECRET block): + + # KOReader Sync Server — password hashing salt + # Generate with: openssl rand -hex 32 + KOSYNC_PASSWORD_SALT= + +Verification: grep KOSYNC_PASSWORD_SALT .env.example +GREEN gate: line is present +``` + +--- + +### Phase 3: Homepage — Add KOReader Sync link [no-test] + +**Goal:** Surface kosync on the dashboard under the Reading category. + +**Tasks:** + +- Add KOReader Sync link entry in `homepage/config/services.yaml` under Reading category + +**Verification:** + +- [ ] Entry appears under `- Reading:` in services.yaml +- [ ] `href` points to `https://kosync.woggles.work` + +#### Agent Context + +``` +Files to modify: + - homepage/config/services.yaml + +Current Reading category (line ~64): + - Reading: + - Wallabag: + href: https://wallabag.woggles.work + description: ... + icon: wallabag + +Add after Wallabag: + - KOReader Sync: + href: https://kosync.woggles.work + description: KOReader reading progress sync + icon: si-koreader + +No HOMEPAGE_VAR_* variables needed — link only, no widget block. + +Verification: grep -A3 "KOReader" homepage/config/services.yaml +GREEN gate: href line is present +``` + +--- + +### Phase 4: README — Add services table row [no-test] + +**Goal:** Keep the README services table accurate. + +**Tasks:** + +- Add KOReader Sync row to the services table in `README.md` (lines 13–20) + +**Verification:** + +- [ ] KOReader Sync row appears in the services table + +#### Agent Context + +``` +Files to modify: + - README.md + +Services table currently ends with: + | FileBrowser | https://files.woggles.work | File manager | + +Add after FileBrowser row: + | KOReader Sync | https://kosync.woggles.work | Reading progress sync | + +Verification: grep "KOReader" README.md +GREEN gate: line is present +``` + +--- + +### Phase 5: Lint Verification [no-test] + +**Goal:** Confirm the lint script passes with no HOMEPAGE_VAR mismatches or missing .env.example vars. + +**Tasks:** + +- Run `./scripts/lint-config.sh` + +**Verification:** + +- [ ] Script exits 0 +- [ ] No HOMEPAGE*VAR*\* reference errors +- [ ] No missing .env.example variable errors + +#### Agent Context + +``` +Files to read: + - scripts/lint-config.sh (read-only — do not modify) + - docker-compose.yml + - .env.example + - homepage/config/services.yaml + +Verification command: ./scripts/lint-config.sh +GREEN gate: exits 0 with no error output + +If it fails: check for any accidentally added HOMEPAGE_VAR_* references +in services.yaml that are not wired in docker-compose.yml, or any +${VAR:-} patterns in docker-compose.yml missing from .env.example. +``` + +--- + +## Constraints & Considerations + +- **No application code** — all phases are config/YAML edits; no tests beyond compose validation and lint +- **Named volume, not bind mount** — SQLite data lives in `kosync-data` Docker volume; no host path needed +- **No ports: mapping** — Traefik handles all ingress on the `proxy` network +- **Icon fallback** — `si-koreader` resolves via Simple Icons; if it doesn't render, `mdi-book-sync` is a safe fallback +- **Post-deploy KOReader setup** (out of scope for this PR): Settings → Progress sync → Custom sync server → `https://kosync.woggles.work` + +## Out of Scope + +- Adding Wallabag to the README services table (pre-existing omission; separate PR) +- KOReader app configuration documentation +- Monitoring/alerting for the kosync service diff --git a/.light/sessions/add-koreader-sync-server-research.md b/.light/sessions/add-koreader-sync-server-research.md new file mode 100644 index 0000000..f5b4dab --- /dev/null +++ b/.light/sessions/add-koreader-sync-server-research.md @@ -0,0 +1,127 @@ +# Research: Add KOReader Sync Server + +## Feature Summary + +Add a self-hosted KOReader progress-sync server so KOReader (e-reader app) can sync reading positions across devices without relying on the public `sync.koreader.rocks` service. The server stores only reading positions (keyed by file MD5 hash), not book files. + +--- + +## Recommended Image + +### `ghcr.io/nperez0111/koreader-sync:latest` + +**Confidence: High** — actively maintained, powers the public kosync.nickthesick.com demo, cross-referenced across GitHub + Docker Hub. + +- Language: TypeScript (Bun + Hono) +- Storage: SQLite — no external DB service needed +- Port: `3000` +- Health endpoint: `GET /health` → HTTP 200 (Docker HEALTHCHECK built in) + +**Alternative:** `koreader/kosync:latest` (official, Lua/OpenResty) — bundles Redis inside the container, port 17200 behind a proxy. Protocol-compatible but heavier and less conventionally configured. + +--- + +## Integration Points + +### docker-compose.yml — new service block + +```yaml +kosync: + container_name: kosync + image: ghcr.io/nperez0111/koreader-sync:latest + environment: + - TZ=${TZ:-America/New_York} + - PASSWORD_SALT=${KOSYNC_PASSWORD_SALT:-} + volumes: + - kosync-data:/app/data + networks: + - proxy + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.http.routers.kosync.rule=Host(`kosync.woggles.work`)" + - "traefik.http.routers.kosync.entrypoints=websecure" + - "traefik.http.routers.kosync.tls.certresolver=cloudflare" + - "traefik.http.services.kosync.loadbalancer.server.port=3000" +``` + +Named volume at the compose root: + +```yaml +volumes: + kosync-data: +``` + +### .env.example — new variable + +``` +# KOReader Sync Server +# Generate with: openssl rand -hex 32 +KOSYNC_PASSWORD_SALT= +``` + +### homepage/config/services.yaml — new entry + +The "Reading" category already exists (contains Wallabag). Add kosync there. No official Homepage widget type exists for kosync, so this will be a simple link entry. + +```yaml +- Reading: + - KOReader Sync: + href: https://kosync.woggles.work + description: KOReader reading progress sync + icon: si-koreader +``` + +(`koreader` is not in the walkxcode/dashboard-icons pack, but `si-koreader` resolves via Simple Icons and renders in KOReader's brand teal.) + +No `HOMEPAGE_VAR_*` variable needed — no API widget, just a link. + +### docker-compose.yml homepage environment block + +No changes needed (no HOMEPAGE*VAR*\* required for a link-only entry). + +### scripts/lint-config.sh + +No HOMEPAGE_VAR references to add, so the lint script should pass without changes. Verify after wiring. + +--- + +## Constraints and Decisions + +### PASSWORD_SALT is required + +The env var `PASSWORD_SALT` must be set or accounts cannot be created. Use `openssl rand -hex 32` to generate. Fits the existing pattern of secret env vars like `WALLABAG_SECRET`. + +### Named volume vs bind mount + +The SQLite database at `/app/data` must be persisted. A named Docker volume (`kosync-data`) is the right choice — no host path needed, matches how Wallabag uses named volumes (`wallabag_images`). + +### No port exposure needed + +All traffic goes through Traefik (`proxy` network). No `ports:` mapping is needed. KOReader connects via `https://kosync.woggles.work`. + +### Homepage widget: link only + +No Homepage widget type exists for KOReader sync. The entry will be a link (no `widget:` block), same pattern as services that don't expose an API dashboard hook. The icon `koreader` may or may not be in the Homepage icon library — fallback to `mdi-book-sync` if not found. + +### KOReader app setup (post-deploy) + +In the KOReader app: Settings → Progress sync → Custom sync server → enter `https://kosync.woggles.work` → Register with username + password. Each device registers once and auto-syncs on open/close. + +--- + +## Open Questions + +None — all questions resolved during research. + +--- + +## Files to Change + +| File | Change | +| ------------------------------- | ---------------------------------------------------------- | +| `docker-compose.yml` | Add `kosync` service block; add `kosync-data` named volume | +| `.env.example` | Add `KOSYNC_PASSWORD_SALT=` with generation instructions | +| `homepage/config/services.yaml` | Add link entry to Reading category | +| `README.md` | Add kosync row to services table if one exists | +| `docs/` | Check if a per-service doc page is needed | diff --git a/README.md b/README.md index 75afa2f..9187e1b 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,16 @@ Self-hosted home server stack running on Ubuntu Server (Lenovo ThinkCentre), man ## Services -| Service | URL | Description | -| ----------- | --------------------------------- | -------------------- | -| Homepage | https://homepage.woggles.work | Dashboard | -| Pi-hole | https://pihole.woggles.work/admin | DNS ad-blocker | -| Traefik | https://traefik.woggles.work | Reverse proxy | -| Jellyfin | https://jellyfin.woggles.work | Media server | -| Syncthing | https://syncthing.woggles.work | File sync | -| Portainer | https://portainer.woggles.work | Container management | -| FileBrowser | https://files.woggles.work | File manager | +| Service | URL | Description | +| ------------- | --------------------------------- | --------------------- | +| Homepage | https://homepage.woggles.work | Dashboard | +| Pi-hole | https://pihole.woggles.work/admin | DNS ad-blocker | +| Traefik | https://traefik.woggles.work | Reverse proxy | +| Jellyfin | https://jellyfin.woggles.work | Media server | +| Syncthing | https://syncthing.woggles.work | File sync | +| Portainer | https://portainer.woggles.work | Container management | +| FileBrowser | https://files.woggles.work | File manager | +| KOReader Sync | https://kosync.woggles.work | Reading progress sync | ## Prerequisites @@ -135,7 +136,18 @@ If any service fails, the script skips it and prints instructions. Re-run it aft **FileBrowser** generates a random password on first start. Find it with `docker logs filebrowser` — look for "User 'admin' initialized with randomly generated password". Log in at `https://files.woggles.work` and change it immediately. -### 9. Enable remote access via Tailscale +### 9. Set up KOReader Sync + +Generate and add the password salt before starting the service: + +```bash +echo "KOSYNC_PASSWORD_SALT=$(openssl rand -hex 32)" >> .env +docker compose up -d kosync +``` + +Then in the KOReader app on each device: **Settings → Progress sync → Custom sync server** → enter `https://kosync.woggles.work` → Register with a username and password. Each device registers once and syncs automatically on open/close. + +### 10. Enable remote access via Tailscale The setup script installs Tailscale automatically on Linux. To activate it, authenticate with your Tailscale account: diff --git a/docker-compose.yml b/docker-compose.yml index ff9a0da..2c72134 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -212,5 +212,24 @@ services: - "traefik.http.routers.wallabag.tls.certresolver=cloudflare" - "traefik.http.services.wallabag.loadbalancer.server.port=80" + kosync: + container_name: kosync + image: ghcr.io/nperez0111/koreader-sync:latest + environment: + - TZ=${TZ:-America/New_York} + - PASSWORD_SALT=${KOSYNC_PASSWORD_SALT} + volumes: + - kosync-data:/app/data + networks: + - proxy + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.http.routers.kosync.rule=Host(`kosync.woggles.work`)" + - "traefik.http.routers.kosync.entrypoints=websecure" + - "traefik.http.routers.kosync.tls.certresolver=cloudflare" + - "traefik.http.services.kosync.loadbalancer.server.port=3000" + volumes: wallabag_images: + kosync-data: diff --git a/homepage/config/services.yaml b/homepage/config/services.yaml index 334f8a8..935f122 100644 --- a/homepage/config/services.yaml +++ b/homepage/config/services.yaml @@ -66,3 +66,6 @@ href: https://wallabag.woggles.work description: Read-it-later icon: wallabag + - KOReader Sync: + href: https://kosync.woggles.work + description: Reading progress sync (no UI) diff --git a/scripts/setup.sh b/scripts/setup.sh index 9df8ccd..a92d9cd 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -228,6 +228,24 @@ else fi echo -e "${GREEN}✅ WALLABAG_SECRET generated and written to .env${NC}" fi + +# Generate KOReader Sync password salt if not already set +echo "🔐 Setting up KOReader Sync password salt..." +if grep -q "^KOSYNC_PASSWORD_SALT=.\+" .env 2>/dev/null; then + echo -e "${GREEN}✅ KOSYNC_PASSWORD_SALT already set in .env${NC}" +else + KOSYNC_PASSWORD_SALT=$(openssl rand -hex 32) + if grep -q "^KOSYNC_PASSWORD_SALT=" .env; then + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' "s|^KOSYNC_PASSWORD_SALT=.*|KOSYNC_PASSWORD_SALT=$KOSYNC_PASSWORD_SALT|" .env + else + sed -i "s|^KOSYNC_PASSWORD_SALT=.*|KOSYNC_PASSWORD_SALT=$KOSYNC_PASSWORD_SALT|" .env + fi + else + echo "KOSYNC_PASSWORD_SALT=$KOSYNC_PASSWORD_SALT" >> .env + fi + echo -e "${GREEN}✅ KOSYNC_PASSWORD_SALT generated and written to .env${NC}" +fi echo "" echo -e "${GREEN}✅ Setup complete!${NC}" echo ""