From 6fdc17dc18d6850f44c29a64144270baf967093b Mon Sep 17 00:00:00 2001 From: Nort <118631044+Nort1346@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:46:55 +0200 Subject: [PATCH 1/8] feat(setup): replace shell installers with a cross-platform TypeScript CLI --- .github/workflows/cli.yml | 136 +++ .gitignore | 3 + AGENTS.md | 10 + cli/AGENTS.md | 51 + cli/bun.lock | 386 ++++++ cli/eslint.config.mjs | 32 + cli/package.json | 26 + cli/pnpm-workspace.yaml | 3 + cli/src/constants.ts | 114 ++ cli/src/core/clipboard.ts | 52 + cli/src/core/compose.ts | 15 + cli/src/core/docker.ts | 92 ++ cli/src/core/download.ts | 35 + cli/src/core/env.ts | 50 + cli/src/core/errors.ts | 9 + cli/src/core/exec.ts | 43 + cli/src/core/fs.ts | 8 + cli/src/core/hyperlink.ts | 9 + cli/src/core/platform.ts | 54 + cli/src/core/ports.ts | 48 + cli/src/core/prompt.ts | 113 ++ cli/src/core/secrets.ts | 15 + cli/src/core/state.ts | 73 ++ cli/src/core/version.ts | 4 + cli/src/index.ts | 68 ++ cli/src/steps/banner.ts | 11 + cli/src/steps/components.ts | 106 ++ cli/src/steps/compose-files.ts | 51 + cli/src/steps/database.ts | 36 + cli/src/steps/detect.ts | 31 + cli/src/steps/discord.ts | 24 + cli/src/steps/env-file.ts | 30 + cli/src/steps/jellyfin.ts | 35 + cli/src/steps/lode-version.ts | 33 + cli/src/steps/prerequisites.ts | 73 ++ cli/src/steps/prowlarr.ts | 36 + cli/src/steps/qbittorrent.ts | 40 + cli/src/steps/secrets-gen.ts | 21 + cli/src/steps/services.ts | 146 +++ cli/src/steps/start-lode.ts | 74 ++ cli/src/steps/summary.ts | 60 + cli/src/steps/tmdb.ts | 23 + cli/src/types.ts | 80 ++ cli/test/constants.test.ts | 59 + cli/test/core/compose.test.ts | 43 + cli/test/core/env.test.ts | 59 + cli/test/core/secrets.test.ts | 21 + cli/test/core/state.test.ts | 26 + cli/test/steps/components.test.ts | 38 + cli/test/steps/services.test.ts | 17 + cli/test/steps/start-lode.test.ts | 19 + cli/tsconfig.json | 21 + cli/vitest.config.ts | 9 + eslint.config.mjs | 4 +- setup.ps1 | 1764 ++-------------------------- setup.sh | 1808 +---------------------------- 56 files changed, 2785 insertions(+), 3462 deletions(-) create mode 100644 .github/workflows/cli.yml create mode 100644 cli/AGENTS.md create mode 100644 cli/bun.lock create mode 100644 cli/eslint.config.mjs create mode 100644 cli/package.json create mode 100644 cli/pnpm-workspace.yaml create mode 100644 cli/src/constants.ts create mode 100644 cli/src/core/clipboard.ts create mode 100644 cli/src/core/compose.ts create mode 100644 cli/src/core/docker.ts create mode 100644 cli/src/core/download.ts create mode 100644 cli/src/core/env.ts create mode 100644 cli/src/core/errors.ts create mode 100644 cli/src/core/exec.ts create mode 100644 cli/src/core/fs.ts create mode 100644 cli/src/core/hyperlink.ts create mode 100644 cli/src/core/platform.ts create mode 100644 cli/src/core/ports.ts create mode 100644 cli/src/core/prompt.ts create mode 100644 cli/src/core/secrets.ts create mode 100644 cli/src/core/state.ts create mode 100644 cli/src/core/version.ts create mode 100644 cli/src/index.ts create mode 100644 cli/src/steps/banner.ts create mode 100644 cli/src/steps/components.ts create mode 100644 cli/src/steps/compose-files.ts create mode 100644 cli/src/steps/database.ts create mode 100644 cli/src/steps/detect.ts create mode 100644 cli/src/steps/discord.ts create mode 100644 cli/src/steps/env-file.ts create mode 100644 cli/src/steps/jellyfin.ts create mode 100644 cli/src/steps/lode-version.ts create mode 100644 cli/src/steps/prerequisites.ts create mode 100644 cli/src/steps/prowlarr.ts create mode 100644 cli/src/steps/qbittorrent.ts create mode 100644 cli/src/steps/secrets-gen.ts create mode 100644 cli/src/steps/services.ts create mode 100644 cli/src/steps/start-lode.ts create mode 100644 cli/src/steps/summary.ts create mode 100644 cli/src/steps/tmdb.ts create mode 100644 cli/src/types.ts create mode 100644 cli/test/constants.test.ts create mode 100644 cli/test/core/compose.test.ts create mode 100644 cli/test/core/env.test.ts create mode 100644 cli/test/core/secrets.test.ts create mode 100644 cli/test/core/state.test.ts create mode 100644 cli/test/steps/components.test.ts create mode 100644 cli/test/steps/services.test.ts create mode 100644 cli/test/steps/start-lode.test.ts create mode 100644 cli/tsconfig.json create mode 100644 cli/vitest.config.ts diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml new file mode 100644 index 0000000..fce86a9 --- /dev/null +++ b/.github/workflows/cli.yml @@ -0,0 +1,136 @@ +name: cli + +on: + push: + tags: + - 'v*' + paths: + - 'cli/**' + - '.github/workflows/cli.yml' + pull_request: + paths: + - 'cli/**' + - '.github/workflows/cli.yml' + +# Builds the standalone setup CLI (cli/) as native binaries for 6 targets and +# publishes them to the same GitHub release that the docker workflow creates. +# The publish job polls for that release because it lands after the (much +# slower) docker image build. + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.4.2' + + - name: Install dependencies + working-directory: cli + run: bun install --frozen-lockfile + + - name: Typecheck + working-directory: cli + run: bun run typecheck + + - name: Lint + working-directory: cli + run: bun run lint + + - name: Test + working-directory: cli + run: bun run test + + build: + needs: check + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + timeout-minutes: 20 + + # Cross-compiles from a single Linux runner - Bun embeds the runtime, so + # each target is one fast `bun build --compile` with no toolchain needed. + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.4.2' + + - name: Install dependencies + working-directory: cli + run: bun install --frozen-lockfile + + - name: Compile binaries + working-directory: cli + run: | + set -euo pipefail + declare -A targets=( + [linux-x64]='bun-linux-x64' + [linux-arm64]='bun-linux-arm64' + [darwin-x64]='bun-darwin-x64' + [darwin-arm64]='bun-darwin-arm64' + [windows-x64]='bun-windows-x64' + [windows-arm64]='bun-windows-arm64' + ) + for name in "${!targets[@]}"; do + ext="" + [[ "$name" == windows-* ]] && ext=".exe" + bun build src/index.ts \ + --compile --minify \ + --target="${targets[$name]}" \ + --define BUILD_VERSION='"${GITHUB_REF_NAME}"' \ + --outfile "dist/lode-setup-${name}${ext}" + done + ls -la dist + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: lode-setup-binaries + path: cli/dist + + publish: + needs: [check, build] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + timeout-minutes: 90 + + permissions: + contents: write + + steps: + - name: Download artifacts + uses: actions/download-artifact@v5 + with: + name: lode-setup-binaries + path: cli/dist + + # The docker workflow creates the release after its image build + # finishes, which can outlast this pipeline - poll for it. + - name: Wait for the GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + for _ in $(seq 1 450); do + if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + echo "Release ${GITHUB_REF_NAME} found" + exit 0 + fi + sleep 10 + done + echo "GitHub release ${GITHUB_REF_NAME} was not created within 75 minutes" >&2 + exit 1 + + - name: Upload binaries to the release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${GITHUB_REF_NAME}" cli/dist/* --clobber diff --git a/.gitignore b/.gitignore index 0df7868..2ca05e3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ logs # pnpm local store .pnpm-store +# Bun compile cache +.bun-cache + coverage # Local env files diff --git a/AGENTS.md b/AGENTS.md index 9c5b840..b50a281 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,16 @@ pnpm test:coverage # Run tests with V8 coverage reporting pnpm typecheck:test # Type-check the test suite (test/tsconfig.json) ``` +## The `cli/` project (standalone) + +`cli/` is the setup CLI (the `lode-setup` binary): a TypeScript + @clack/prompts app that holds all installer logic. The root `setup.sh` / `setup.ps1` are thin bootstraps that download the prebuilt binary for the platform from the latest GitHub release and exec it. + +- **Standalone project**: its own `package.json`, `bun.lock`, `tsconfig.json`, `eslint.config.mjs`, `vitest.config.ts`, and a `pnpm-workspace.yaml` (`packages: []`) that keeps it out of the root workspace. Root `pnpm` commands don't touch it, and root ESLint ignores `cli/**`. +- **Runtime tooling is Bun**: `bun install`, `bun run typecheck|lint|test`; release binaries are built with `bun build --compile --target=bun--` (see `.github/workflows/cli.yml`). +- **Commands** (run from `cli/`): `bun install`, `bun run typecheck`, `bun run lint`, `bun run test` (Vitest, `test/**/*.test.ts` mirrors `src/`). +- **CI**: `cli.yml` runs check on PRs/pushes touching `cli/**` and, on `v*` tags, builds 6 platform binaries and uploads them to the same GitHub release that `docker.yml` creates. +- Details: [cli/AGENTS.md](./cli/AGENTS.md) + ## Commit Messages Use **Conventional Commits** format: diff --git a/cli/AGENTS.md b/cli/AGENTS.md new file mode 100644 index 0000000..8b581e5 --- /dev/null +++ b/cli/AGENTS.md @@ -0,0 +1,51 @@ +# AGENTS.md - cli/ + +Setup CLI for Lode (`lode-setup`). Interactive TTY app (TypeScript + @clack/prompts) compiled to standalone native binaries with Bun. The root `setup.sh` / `setup.ps1` are thin bootstraps that download this binary from the latest GitHub release - all installer logic lives here. + +## Commands + +```bash +bun install --frozen-lockfile +bun run typecheck # tsc --noEmit +bun run lint # eslint . (type-checked flat config) +bun run test # vitest run +# Release build (one of 6 targets: bun-{linux,darwin,windows}-{x64,arm64}): +bun build src/index.ts --compile --minify --target=bun-linux-x64 --define BUILD_VERSION='"1.2.3"' --outfile dist/lode-setup-linux-x64 +``` + +## Structure + +``` +src/ +├── index.ts # Entry: TTY gate + 15-step pipeline + error handling +├── types.ts # All shared types (no inline types in implementation files) +├── constants.ts # Env keys, ports, timeouts, option labels, compose file selection +├── core/ # clack prompt wrappers, docker compose helpers, env/state file +│ # editing, secret generation, downloads, port waits, clipboard, +│ # OSC 8/52, platform helpers +└── steps/ # One file per setup step (banner -> summary) +test/ # Vitest suite mirroring src/ +``` + +## Conventions + +- Same code style as the repo root: no semicolons, single quotes, 2-space indent, no trailing commas, 120 char width; comments only where the "why" isn't obvious. +- Strict TS (`strict`, `noUncheckedIndexedAccess`, `verbatimModuleSyntax`); ESLint is type-checked (`recommendedTypeChecked` with `project`). +- No `any`, no non-null assertions, no floating promises, `eqeqeq: always`. +- UI strings are plain English (local tool, no i18n). +- Steps never use `console.*` - use the `log` / `note` / `intro` / `outro` wrappers from `core/prompt.ts` (the pre-TTY error in `index.ts` is the only exception). +- Esc at any prompt aborts the whole setup: clack's `CANCEL_SYMBOL` -> `cancel()` + `process.exit(0)` (see `guard` in `core/prompt.ts`). +- State file `.lode-setup` is flat `key=value` (see `core/state.ts`); `.env` edits go through `core/env.ts` (replace / uncomment / append - string-based, never sed-style, so values with `&`/`$` survive). +- `askSelect` in `core/prompt.ts` calls `select` and narrows the result: clack's `Option` is a deferred conditional type, so a generic call site cannot be type-checked. +- @clack/prompts 1.x spinner API: `const s = spinner(); s.start(msg); s.message(msg); s.stop(msg)` - it is a factory function, not `spinner.start()`. +- Hard failures throw `SetupFailure(message, detailLines)`; `index.ts` prints them and exits 1. + +## Release pipeline + +`.github/workflows/cli.yml`: + +- `check` (PRs + `v*` tags): `bun install --frozen-lockfile`, typecheck, lint, test +- `build` (tags): cross-compiles all 6 targets from one ubuntu runner +- `publish` (tags): polls for the GitHub release that `docker.yml` creates (the docker build finishes long after this pipeline), then uploads the 6 binaries + +Binary names are part of the bootstrap contract (`lode-setup--[.exe]`) - `setup.sh` / `setup.ps1` resolve the asset from the `releases/latest/download/` redirect, so renaming them breaks both. diff --git a/cli/bun.lock b/cli/bun.lock new file mode 100644 index 0000000..b895bf1 --- /dev/null +++ b/cli/bun.lock @@ -0,0 +1,386 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "lode-setup-cli", + "dependencies": { + "@clack/prompts": "^1.2.0", + }, + "devDependencies": { + "@eslint/js": "^9.25.0", + "@types/node": "^24.0.0", + "eslint": "^9.25.0", + "globals": "^16.0.0", + "typescript": "^5.8.0", + "typescript-eslint": "^8.30.0", + "vitest": "^4.0.0", + }, + }, + }, + "packages": { + "@clack/core": ["@clack/core@1.5.1", "", { "dependencies": { "fast-wrap-ansi": "0.2.2", "sisteransi": "1.0.5" } }, "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ=="], + + "@clack/prompts": ["@clack/prompts@1.8.1", "", { "dependencies": { "@clack/core": "1.5.1", "fast-string-width": "3.0.2", "fast-wrap-ansi": "0.2.2", "sisteransi": "1.0.5" } }, "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "2.1.7", "debug": "4.4.3", "minimatch": "3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.7", "", { "dependencies": { "ajv": "6.15.0", "debug": "4.4.3", "espree": "10.4.0", "globals": "14.0.0", "ignore": "5.3.2", "import-fresh": "3.3.1", "js-yaml": "4.3.2", "minimatch": "3.1.5", "strip-json-comments": "3.1.1" } }, "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw=="], + + "@eslint/js": ["@eslint/js@9.39.5", "", {}, "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "0.17.0", "levn": "0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "0.19.2", "@humanfs/types": "0.15.0", "@humanwhocodes/retry": "0.4.3" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@oxc-project/types": ["@oxc-project/types@0.150.0", "", {}, "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw=="], + + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.9", "", { "os": "android", "cpu": "arm" }, "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.9", "", { "os": "android", "cpu": "arm64" }, "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.9", "", { "os": "linux", "cpu": "arm" }, "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.9", "", { "os": "none", "cpu": "arm64" }, "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "4.0.2", "assertion-error": "2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@24.13.5", "", { "dependencies": { "undici-types": "7.18.2" } }, "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.70.0", "", { "dependencies": { "@eslint-community/regexpp": "4.12.2", "@typescript-eslint/scope-manager": "8.70.0", "@typescript-eslint/type-utils": "8.70.0", "@typescript-eslint/utils": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0", "ignore": "7.0.9", "natural-compare": "1.4.0", "ts-api-utils": "2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.70.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.70.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.70.0", "@typescript-eslint/types": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0", "debug": "4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.70.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "8.70.0", "@typescript-eslint/types": "8.70.0", "debug": "4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.70.0", "", { "dependencies": { "@typescript-eslint/types": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0" } }, "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.70.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.70.0", "", { "dependencies": { "@typescript-eslint/types": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0", "@typescript-eslint/utils": "8.70.0", "debug": "4.4.3", "ts-api-utils": "2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.70.0", "", {}, "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.70.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.70.0", "@typescript-eslint/tsconfig-utils": "8.70.0", "@typescript-eslint/types": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0", "debug": "4.4.3", "minimatch": "10.2.6", "semver": "7.8.5", "tinyglobby": "0.2.17", "ts-api-utils": "2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.70.0", "", { "dependencies": { "@eslint-community/eslint-utils": "4.10.1", "@typescript-eslint/scope-manager": "8.70.0", "@typescript-eslint/types": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.70.0", "", { "dependencies": { "@typescript-eslint/types": "8.70.0", "eslint-visitor-keys": "5.0.1" } }, "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ=="], + + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "@types/chai": "5.2.3", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "6.2.2", "tinyrainbow": "3.1.1" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "3.0.3", "magic-string": "0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "3.1.1" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], + + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "0.30.21", "pathe": "2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], + + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], + + "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "2.0.0", "tinyrainbow": "3.1.1" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-json-stable-stringify": "2.1.0", "json-schema-traverse": "0.4.1", "uri-js": "4.4.1" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "brace-expansion": ["brace-expansion@1.1.21", "", { "dependencies": { "balanced-match": "1.0.2", "concat-map": "0.0.1" } }, "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "4.3.0", "supports-color": "7.2.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "3.1.1", "shebang-command": "2.0.0", "which": "2.0.2" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "4.10.1", "@eslint-community/regexpp": "4.12.2", "@eslint/config-array": "0.21.2", "@eslint/config-helpers": "0.4.2", "@eslint/core": "0.17.0", "@eslint/eslintrc": "3.3.7", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "0.4.1", "@humanfs/node": "0.16.8", "@humanwhocodes/module-importer": "1.0.1", "@humanwhocodes/retry": "0.4.3", "@types/estree": "1.0.9", "ajv": "6.15.0", "chalk": "4.1.2", "cross-spawn": "7.0.6", "debug": "4.4.3", "escape-string-regexp": "4.0.0", "eslint-scope": "8.4.0", "eslint-visitor-keys": "4.2.1", "espree": "10.4.0", "esquery": "1.7.0", "esutils": "2.0.3", "fast-deep-equal": "3.1.3", "file-entry-cache": "8.0.0", "find-up": "5.0.0", "glob-parent": "6.0.2", "ignore": "5.3.2", "imurmurhash": "0.1.4", "is-glob": "4.0.3", "json-stable-stringify-without-jsonify": "1.0.1", "lodash.merge": "4.6.2", "minimatch": "3.1.5", "natural-compare": "1.4.0", "optionator": "0.9.4" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "4.3.0", "estraverse": "5.3.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "8.18.0", "acorn-jsx": "5.3.2", "eslint-visitor-keys": "4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "1.0.9" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "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.3" } }, "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=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "4.0.1" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "6.0.0", "path-exists": "4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "3.4.4", "keyv": "4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "1.0.1", "resolve-from": "4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "1.2.1", "type-check": "0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.6.0" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "1.1.21" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "0.1.4", "fast-levenshtein": "2.0.6", "levn": "0.4.1", "prelude-ls": "1.2.1", "type-check": "0.4.0", "word-wrap": "1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "3.1.0" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "3.1.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "3.3.19", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "rolldown": ["rolldown@1.2.9", "", { "dependencies": { "@oxc-project/types": "0.150.0", "@rolldown/pluginutils": "1.0.1" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.9", "@rolldown/binding-android-arm64": "1.2.9", "@rolldown/binding-darwin-arm64": "1.2.9", "@rolldown/binding-darwin-x64": "1.2.9", "@rolldown/binding-freebsd-x64": "1.2.9", "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", "@rolldown/binding-linux-arm64-gnu": "1.2.9", "@rolldown/binding-linux-arm64-musl": "1.2.9", "@rolldown/binding-linux-ppc64-gnu": "1.2.9", "@rolldown/binding-linux-s390x-gnu": "1.2.9", "@rolldown/binding-linux-x64-gnu": "1.2.9", "@rolldown/binding-linux-x64-musl": "1.2.9", "@rolldown/binding-openharmony-arm64": "1.2.9", "@rolldown/binding-win32-arm64-msvc": "1.2.9", "@rolldown/binding-win32-x64-msvc": "1.2.9" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.7" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "typescript-eslint": ["typescript-eslint@8.70.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.70.0", "@typescript-eslint/parser": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0", "@typescript-eslint/utils": "8.70.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "2.3.1" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "1.33.0", "picomatch": "4.0.7", "postcss": "8.5.28", "rolldown": "1.2.9", "tinyglobby": "0.2.17" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="], + + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "2.3.2", "expect-type": "1.4.0", "magic-string": "0.30.21", "obug": "2.2.1", "pathe": "2.0.3", "picomatch": "4.0.7", "std-env": "4.2.0", "tinybench": "2.9.0", "tinyexec": "1.3.1", "tinyglobby": "0.2.17", "tinyrainbow": "3.1.1", "why-is-node-running": "2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.9", "", {}, "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "5.0.12" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "4.0.4" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + } +} diff --git a/cli/eslint.config.mjs b/cli/eslint.config.mjs new file mode 100644 index 0000000..42c0ef5 --- /dev/null +++ b/cli/eslint.config.mjs @@ -0,0 +1,32 @@ +// @ts-check +import js from '@eslint/js' +import { defineConfig } from 'eslint/config' +import tseslint from 'typescript-eslint' +import globals from 'globals' + +export default defineConfig( + { ignores: ['dist', 'node_modules', 'eslint.config.mjs'] }, + { + files: [tseslint.globs.ts], + extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked], + languageOptions: { + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: import.meta.dirname + }, + globals: { + ...globals.node + } + }, + rules: { + 'no-console': 'off', + eqeqeq: ['error', 'always'], + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'as' }], + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }] + } + } +) diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..b108cc6 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,26 @@ +{ + "name": "lode-setup-cli", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Interactive cross-platform setup CLI for Lode, compiled to a standalone binary with Bun.", + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@clack/prompts": "^1.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.25.0", + "@types/node": "^24.0.0", + "eslint": "^9.25.0", + "globals": "^16.0.0", + "typescript": "^5.8.0", + "typescript-eslint": "^8.30.0", + "vitest": "^4.0.0" + } +} diff --git a/cli/pnpm-workspace.yaml b/cli/pnpm-workspace.yaml new file mode 100644 index 0000000..8663473 --- /dev/null +++ b/cli/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +# Standalone pnpm project root: keeps /cli out of the repo-root workspace so +# the Nuxt toolchain never resolves or installs its dependencies. +packages: [] diff --git a/cli/src/constants.ts b/cli/src/constants.ts new file mode 100644 index 0000000..fdd9308 --- /dev/null +++ b/cli/src/constants.ts @@ -0,0 +1,114 @@ +import type { ComposeFileName, SetupSelection } from './types' + +export const REPO_OWNER = 'Nort1346' +export const REPO_NAME = 'Lode' +export const REPO_RAW = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/main` +export const RELEASE_API_URL = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest` + +export const LODE_IMAGE = 'ghcr.io/nort1346/lode' + +export const ENV_FILE = '.env' +export const ENV_EXAMPLE_FILE = '.env.example' +export const COMPOSE_BASE = 'docker-compose.yml' +/** Flat key=value selection memory for re-runs. */ +export const STATE_FILE = '.lode-setup' +export const STATE_VERSION = 1 + +export const MEDIA_DIRS = ['media/Movies', 'media/Series'] as const + +export const ENV_KEYS = { + sessionPassword: 'NUXT_SESSION_PASSWORD', + trackerEncryptionKey: 'NUXT_TRACKER_ENCRYPTION_KEY', + postgresPassword: 'POSTGRES_PASSWORD', + dbDriver: 'DB_DRIVER', + redisUrl: 'NUXT_REDIS_URL', + databaseUrl: 'DATABASE_URL', + qbittorrentUrl: 'NUXT_QBITTORRENT_URL', + qbittorrentApiKey: 'NUXT_QBITTORRENT_API_KEY', + prowlarrUrl: 'NUXT_PROWLARR_URL', + prowlarrApiKey: 'NUXT_PROWLARR_API_KEY', + jellyfinUrl: 'NUXT_JELLYFIN_URL', + jellyfinApiKey: 'NUXT_JELLYFIN_API_KEY', + flaresolverrUrl: 'NUXT_FLARESOLVERR_URL', + tmdbApiKey: 'NUXT_TMDB_API_KEY', + discordWebhookUrl: 'NUXT_DISCORD_WEBHOOK_URL' +} as const + +export const INTERNAL_URLS = { + qbittorrent: 'http://qbittorrent:8080', + prowlarr: 'http://prowlarr:9696', + jellyfin: 'http://jellyfin:8096', + flaresolverr: 'http://flaresolverr:8191', + redis: 'redis://redis:6379' +} as const + +export const PORTS = { + lode: 5757, + qbittorrent: 8080, + jellyfin: 8096, + prowlarr: 9900, + flaresolverr: 8191, + dozzle: 8082, + redis: 6379, + postgres: 5432 +} as const + +export const PORT_TIMEOUTS = { + redis: 30_000, + qbittorrent: 60_000, + prowlarr: 60_000, + jellyfin: 90_000, + postgres: 30_000, + lode: 120_000 +} as const + +export const SECRET_MIN_LENGTH = 32 + +export const PASSWORD_LENGTH = 32 + +export const TRACKER_KEY_BYTES = 32 + +export const TOTAL_STEPS = 15 + +export const DOCS_LINKS = { + dockerMac: 'https://docs.docker.com/desktop/install/mac-install/', + dockerWindows: 'https://docs.docker.com/desktop/wsl/', + dockerLinux: 'https://docs.docker.com/engine/install/', + dockerGeneric: 'https://docs.docker.com/get-docker/', + composeLinux: 'https://docs.docker.com/compose/install/linux/', + tmdbApi: 'https://www.themoviedb.org/settings/api' +} as const + +export const OPTION_LABELS = { + qbitLocal: 'Local qBittorrent container (recommended)', + qbitExternal: 'External qBittorrent (you host it)', + prowlarrLocal: 'Local Prowlarr container (recommended)', + prowlarrExternal: 'External Prowlarr (you host it)', + jellyfinLocal: 'Jellyfin (local container)', + jellyfinExternal: 'Jellyfin (external)', + mediaNone: 'No media server', + flaresolverr: 'FlareSolverr - CAPTCHA bypass for private trackers', + dozzle: 'Dozzle - Docker log viewer' +} as const + +export function composeFilesFor(selection: SetupSelection): ComposeFileName[] { + const files: ComposeFileName[] = ['docker-compose.yml'] + if (selection.dbDriver === 'postgres') files.push('docker-compose.postgres.yml') + if (selection.qbittorrent === 'local') files.push('docker-compose.qbittorrent.yml') + if (selection.prowlarr === 'local') files.push('docker-compose.prowlarr.yml') + if (selection.mediaMode === 'local') files.push('docker-compose.jellyfin.yml') + if (selection.flaresolverr) files.push('docker-compose.flaresolverr.yml') + if (selection.dozzle) files.push('docker-compose.dozzle.yml') + return files +} + +export function infraServicesFor(selection: SetupSelection): string[] { + const services = ['redis'] + if (selection.dbDriver === 'postgres') services.push('postgres') + if (selection.qbittorrent === 'local') services.push('qbittorrent') + if (selection.prowlarr === 'local') services.push('prowlarr') + if (selection.mediaMode === 'local') services.push('jellyfin') + if (selection.flaresolverr) services.push('flaresolverr') + if (selection.dozzle) services.push('dozzle') + return services +} diff --git a/cli/src/core/clipboard.ts b/cli/src/core/clipboard.ts new file mode 100644 index 0000000..d35df94 --- /dev/null +++ b/cli/src/core/clipboard.ts @@ -0,0 +1,52 @@ +import { commandExists, run } from './exec' + +const ESC = '\u001b' + +// OSC 52 clipboard write: emitted unconditionally, the terminal decides +// whether it honors it; the visible value is the fallback. +export function copyToClipboardOsc52(text: string): void { + if (!text) return + const encoded = Buffer.from(text, 'utf8').toString('base64') + process.stdout.write(`${ESC}]52;c;${encoded}${ESC}\\`) +} + +interface ClipboardReader { + command: string + args: string[] +} + +async function linuxReaders(): Promise { + const readers: ClipboardReader[] = [] + if (process.env.WAYLAND_DISPLAY) { + if (await commandExists('wl-paste')) readers.push({ command: 'wl-paste', args: ['--no-newline'] }) + } else { + if (await commandExists('xclip')) readers.push({ command: 'xclip', args: ['-selection', 'clipboard', '-o'] }) + if (await commandExists('xsel')) readers.push({ command: 'xsel', args: ['--clipboard', '--output'] }) + } + return readers +} + +export async function clipboardAvailable(): Promise { + const { platform } = process + if (platform === 'darwin') return commandExists('pbpaste') + if (platform === 'win32') + return (await run('powershell', ['-NoProfile', '-Command', 'Get-Clipboard -Raw'])).code === 0 + return (await linuxReaders()).length > 0 +} + +export async function readClipboard(): Promise { + const { platform } = process + let result: { code: number; stdout: string } | null = null + if (platform === 'darwin') { + result = await run('pbpaste') + } else if (platform === 'win32') { + result = await run('powershell', ['-NoProfile', '-Command', 'Get-Clipboard -Raw']) + } else { + for (const reader of await linuxReaders()) { + result = await run(reader.command, reader.args) + if (result.code === 0 && result.stdout.trim() !== '') break + } + } + if (!result || result.code !== 0) return '' + return result.stdout.replace(/\r/g, '').trim() +} diff --git a/cli/src/core/compose.ts b/cli/src/core/compose.ts new file mode 100644 index 0000000..49bd661 --- /dev/null +++ b/cli/src/core/compose.ts @@ -0,0 +1,15 @@ +const IMAGE_LINE = 'ghcr\\.io\\/nort1346\\/lode:' + +const MASK_PATTERN = new RegExp(`^(\\s*(?:#\\s*)?)(image:\\s*${IMAGE_LINE})\\S+`, 'gm') +const TAG_PATTERN = new RegExp(`^(\\s*image:\\s*${IMAGE_LINE})\\S+`, 'gm') +const HAS_IMAGE_LINE = new RegExp(`^\\s*image:\\s*${IMAGE_LINE}`, 'm') + +// Mask the lode image tag so tag-only differences never look like a changed file. +export function maskLodeImageTag(content: string): string { + return content.replace(MASK_PATTERN, (_match, prefix: string, imagePart: string) => `${prefix}${imagePart}`) +} + +export function setLodeImageTag(content: string, tag: string): { content: string; changed: boolean } { + if (!HAS_IMAGE_LINE.test(content)) return { content, changed: false } + return { content: content.replace(TAG_PATTERN, (_match, prefix: string) => `${prefix}${tag}`), changed: true } +} diff --git a/cli/src/core/docker.ts b/cli/src/core/docker.ts new file mode 100644 index 0000000..42d52fb --- /dev/null +++ b/cli/src/core/docker.ts @@ -0,0 +1,92 @@ +import { LODE_IMAGE } from '../constants' +import type { ComposeFileName } from '../types' +import { run } from './exec' + +export function composeFileArgs(files: readonly ComposeFileName[]): string[] { + // One -f per file: a single -f with bare filenames after it makes compose dump its usage. + const args: string[] = [] + for (const file of files) args.push('-f', file) + return args +} + +export function compose(files: readonly ComposeFileName[], ...args: string[]) { + return run('docker', ['compose', ...composeFileArgs(files), ...args]) +} + +// Copy-pasteable prefix for manual-command hints. +export function dcCmdPrefix(files: readonly ComposeFileName[]): string { + return `docker compose ${composeFileArgs(files).join(' ')}` +} + +export async function dockerVersion(): Promise { + const result = await run('docker', ['--version']) + if (result.code !== 0) return null + return /version ([^ ,]+)/.exec(result.stdout)?.[1] ?? null +} + +export async function composeVersion(): Promise { + const result = await run('docker', ['compose', 'version', '--short']) + if (result.code !== 0) return null + return result.stdout.trim() || null +} + +const DAEMON_ERROR_PATTERN = + /failed to connect|cannot connect|permission denied|connection refused|cannot find|no such file/i + +export interface DaemonCheck { + reachable: boolean + errorLines: string[] + permissionDenied: boolean +} + +export async function checkDaemon(): Promise { + const result = await run('docker', ['info']) + if (result.code === 0) return { reachable: true, errorLines: [], permissionDenied: false } + const output = `${result.stdout}\n${result.stderr}` + // `docker info` prints the whole client section before failing - surface only the error lines. + const lines = output + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + const matched = lines.filter((line) => DAEMON_ERROR_PATTERN.test(line)).slice(0, 3) + return { + reachable: false, + errorLines: matched.length > 0 ? matched : lines.slice(-3), + permissionDenied: /permission denied/i.test(output) + } +} + +export async function imageExists(tag: string): Promise { + const result = await run('docker', ['image', 'inspect', `${LODE_IMAGE}:${tag}`]) + return result.code === 0 +} + +// `ps -q` exits 0 even when nothing matches, so an empty stdout means "not running". +export async function serviceRunning(files: readonly ComposeFileName[], service: string): Promise { + const result = await compose(files, 'ps', '-q', service) + return result.code === 0 && result.stdout.trim().length > 0 +} + +export async function serviceLastLogLine(files: readonly ComposeFileName[], service: string): Promise { + const result = await compose(files, 'logs', service, '--tail', '3') + const lines = (result.stdout + result.stderr) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + return lines.at(-1) ?? '' +} + +export async function logsTail(files: readonly ComposeFileName[], service: string, tail: number): Promise { + const result = await compose(files, 'logs', '--no-color', '--tail', String(tail), service) + return `${result.stdout}\n${result.stderr}` +} + +export async function containerExists(container: string): Promise { + const result = await run('docker', ['ps', '-q', '--filter', `name=^${container}$`]) + return result.stdout.trim().length > 0 +} + +export async function stopAndRemoveContainer(container: string): Promise { + await run('docker', ['stop', container]) + await run('docker', ['rm', container]) +} diff --git a/cli/src/core/download.ts b/cli/src/core/download.ts new file mode 100644 index 0000000..ef6a2e5 --- /dev/null +++ b/cli/src/core/download.ts @@ -0,0 +1,35 @@ +import { unlink, writeFile } from 'node:fs/promises' +import { renameSync } from 'node:fs' + +export class DownloadError extends Error {} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +// Downloads to a sibling temp file and renames into place, so an interrupted +// download never leaves a truncated file at the destination. +export async function downloadFile(url: string, dest: string): Promise { + const tmp = `${dest}.tmp` + const response = await fetch(url).catch((error: unknown) => { + throw new DownloadError(`Could not reach ${url}: ${errorMessage(error)}`) + }) + try { + if (!response.ok) throw new DownloadError(`Download failed (HTTP ${response.status}): ${url}`) + const data = Buffer.from(await response.arrayBuffer()) + await writeFile(tmp, data) + renameSync(tmp, dest) + } catch (error) { + await unlink(tmp).catch(() => {}) + if (error instanceof DownloadError) throw error + throw new DownloadError(`Download failed: ${url} (${errorMessage(error)})`) + } +} + +export async function fetchText(url: string): Promise { + const response = await fetch(url).catch((error: unknown) => { + throw new DownloadError(`Could not reach ${url}: ${errorMessage(error)}`) + }) + if (!response.ok) throw new DownloadError(`HTTP ${response.status} for ${url}`) + return response.text() +} diff --git a/cli/src/core/env.ts b/cli/src/core/env.ts new file mode 100644 index 0000000..9d16f5e --- /dev/null +++ b/cli/src/core/env.ts @@ -0,0 +1,50 @@ +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { writeAtomic } from './fs' + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function envFilePath(dir: string, file: string): string { + return path.join(dir, file) +} + +export function readEnvValue(dir: string, file: string, key: string): string { + if (!existsSync(envFilePath(dir, file))) return '' + const lines = readFileSync(envFilePath(dir, file), 'utf8').split('\n') + const pattern = new RegExp(`^${escapeRegExp(key)}=`) + for (const line of lines) { + if (pattern.test(line)) return line.slice(line.indexOf('=') + 1) + } + return '' +} + +export function envValueMeetsMinLength(dir: string, file: string, key: string, min: number): boolean { + const value = readEnvValue(dir, file, key) + return value.length >= min +} + +// Replace an active KEY= line, else uncomment a "# KEY=" line, else append. +// String-based (not sed-style) so values containing & or $ survive intact. +export function updateEnvFile(content: string, key: string, value: string): string { + const lines = content.split('\n') + const escapedKey = escapeRegExp(key) + const activeIndex = lines.findIndex((line) => new RegExp(`^${escapedKey}=`).test(line)) + if (activeIndex !== -1) { + lines[activeIndex] = `${key}=${value}` + return lines.join('\n') + } + const commentedIndex = lines.findIndex((line) => new RegExp(`^#\\s*${escapedKey}=`).test(line)) + if (commentedIndex !== -1) { + lines[commentedIndex] = `${key}=${value}` + return lines.join('\n') + } + return `${content.replace(/\s+$/, '')}\n${key}=${value}\n` +} + +export function updateEnv(dir: string, file: string, key: string, value: string): void { + const filePath = envFilePath(dir, file) + if (!existsSync(filePath)) return + writeAtomic(filePath, updateEnvFile(readFileSync(filePath, 'utf8'), key, value)) +} diff --git a/cli/src/core/errors.ts b/cli/src/core/errors.ts new file mode 100644 index 0000000..04c8f26 --- /dev/null +++ b/cli/src/core/errors.ts @@ -0,0 +1,9 @@ +// Hard setup failure: the caller prints the message plus detail lines and exits 1. +export class SetupFailure extends Error { + constructor( + message: string, + public readonly detailLines: readonly string[] = [] + ) { + super(message) + } +} diff --git a/cli/src/core/exec.ts b/cli/src/core/exec.ts new file mode 100644 index 0000000..9ed45be --- /dev/null +++ b/cli/src/core/exec.ts @@ -0,0 +1,43 @@ +import { spawn } from 'node:child_process' +import type { CommandResult } from '../types' + +interface RunOptions { + cwd?: string +} + +// Never throws: spawn failures (ENOENT, etc.) resolve with code 127 so callers +// can treat "command missing" uniformly with a non-zero exit. +export function run(command: string, args: readonly string[] = [], options: RunOptions = {}): Promise { + return new Promise((resolve) => { + let stdout = '' + let stderr = '' + let settled = false + const child = spawn(command, args, { cwd: options.cwd }) + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString() + }) + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + child.on('error', (error: NodeJS.ErrnoException) => { + if (settled) return + settled = true + resolve({ code: 127, stdout, stderr: `${stderr}${stderr ? '\n' : ''}${error.message}`.trim() }) + }) + child.on('close', (code) => { + if (settled) return + settled = true + resolve({ code: code ?? 1, stdout, stderr }) + }) + }) +} + +export async function commandExists(command: string): Promise { + if (process.platform === 'win32') { + const result = await run('where', [command]) + return result.code === 0 + } + const quoted = `'${command.replace(/'/g, `'\\''`)}'` + const result = await run('sh', ['-c', `command -v ${quoted}`]) + return result.code === 0 +} diff --git a/cli/src/core/fs.ts b/cli/src/core/fs.ts new file mode 100644 index 0000000..e15a946 --- /dev/null +++ b/cli/src/core/fs.ts @@ -0,0 +1,8 @@ +import { renameSync, writeFileSync } from 'node:fs' + +// Temp file + rename so an interrupted run never leaves a partial file behind. +export function writeAtomic(filePath: string, content: string): void { + const tmp = `${filePath}.tmp` + writeFileSync(tmp, content, 'utf8') + renameSync(tmp, filePath) +} diff --git a/cli/src/core/hyperlink.ts b/cli/src/core/hyperlink.ts new file mode 100644 index 0000000..bcfbed7 --- /dev/null +++ b/cli/src/core/hyperlink.ts @@ -0,0 +1,9 @@ +// OSC 8 hyperlink: emitted unconditionally for http(s) URLs, deliberately +// independent of NO_COLOR (ECMA-48 terminals ignore unknown OSC sequences). +export function hyperlink(url: string, label?: string): string { + const text = label && label !== url ? label : url + if (/^https?:\/\//.test(url)) { + return `\u001b]8;;${url}\u001b\\${text}\u001b]8;;\u001b\\` + } + return label && label !== url ? `${label}: ${url}` : url +} diff --git a/cli/src/core/platform.ts b/cli/src/core/platform.ts new file mode 100644 index 0000000..55d7746 --- /dev/null +++ b/cli/src/core/platform.ts @@ -0,0 +1,54 @@ +import readline from 'node:readline' +import { run } from './exec' + +// Parents that close their console window when the child exits (double-click, +// Start Menu, RDP, VS Code integrated terminal). +const TRANSIENT_PARENTS = new Set(['explorer.exe', 'conhost.exe', 'openconsole.exe', 'windowsterminal.exe', 'code.exe']) +// Intermediaries to walk through without concluding. +const PASSTHROUGH_PARENTS = new Set(['cmd.exe', 'conhost.exe', 'openconsole.exe']) + +export function isTty(): boolean { + return process.stdin.isTTY === true && process.stdout.isTTY === true +} + +async function parentInfo(pid: number): Promise<{ pid: number; name: string } | null> { + const script = + `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction SilentlyContinue; ` + + 'if ($p) { "${$p.ParentProcessId}|${$p.Name}" }' + const result = await run('powershell', ['-NoProfile', '-Command', script]) + if (result.code !== 0) return null + const line = result.stdout.trim() + const sep = line.lastIndexOf('|') + if (sep === -1) return null + const parentPid = Number(line.slice(0, sep)) + const name = line.slice(sep + 1) + if (!Number.isInteger(parentPid) || parentPid <= 0 || !name) return null + return { pid: parentPid, name } +} + +export async function isTransientWindowsWindow(): Promise { + let current = process.ppid + for (let depth = 0; depth < 6 && current > 0; depth++) { + const parent = await parentInfo(current) + // Process info unavailable - assume the window closes so the output stays readable. + if (!parent) return true + const name = parent.name.toLowerCase() + if (TRANSIENT_PARENTS.has(name)) return true + if (!PASSTHROUGH_PARENTS.has(name)) return false + current = parent.pid + } + return true +} + +export async function pauseBeforeExit(): Promise { + if (process.platform !== 'win32') return + if (!(await isTransientWindowsWindow())) return + process.stdout.write('\n') + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + await new Promise((resolve) => { + rl.question('Press Enter to exit', () => { + rl.close() + resolve() + }) + }) +} diff --git a/cli/src/core/ports.ts b/cli/src/core/ports.ts new file mode 100644 index 0000000..72c3dde --- /dev/null +++ b/cli/src/core/ports.ts @@ -0,0 +1,48 @@ +import net from 'node:net' + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function checkPort(host: string, port: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ host, port }) + let done = false + const finish = (result: boolean) => { + if (done) return + done = true + socket.destroy() + resolve(result) + } + const timer = setTimeout(() => finish(false), timeoutMs) + socket.once('connect', () => { + clearTimeout(timer) + finish(true) + }) + socket.once('error', () => { + clearTimeout(timer) + finish(false) + }) + }) +} + +export interface PortWaitOptions { + intervalMs?: number + onTick?: (attempt: number, maxAttempts: number) => void +} + +export async function waitForPort( + host: string, + port: number, + timeoutMs: number, + options: PortWaitOptions = {} +): Promise { + const interval = options.intervalMs ?? 2000 + const maxAttempts = Math.max(1, Math.floor(timeoutMs / interval)) + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (await checkPort(host, port, 2000)) return true + options.onTick?.(attempt, maxAttempts) + if (attempt < maxAttempts) await sleep(interval) + } + return false +} diff --git a/cli/src/core/prompt.ts b/cli/src/core/prompt.ts new file mode 100644 index 0000000..124adb3 --- /dev/null +++ b/cli/src/core/prompt.ts @@ -0,0 +1,113 @@ +import { + CANCEL_SYMBOL, + cancel, + confirm, + isCancel, + log, + multiselect, + note, + password, + select, + spinner, + text +} from '@clack/prompts' +import type { SpinnerResult } from '@clack/prompts' +import { TOTAL_STEPS } from '../constants' +import type { PromptOption } from '../types' +import { clipboardAvailable, readClipboard } from './clipboard' + +export { cancel, intro, isCancel, log, note, outro, spinner } from '@clack/prompts' + +export function stepHeader(step: number, title: string): void { + log.step(`[${step}/${TOTAL_STEPS}] ${title}`) +} + +export function instructions(title: string, lines: readonly string[]): void { + note(lines.join('\n'), title) +} + +// Esc at any prompt aborts the whole setup (parity with the old scripts: print and exit 0). +function guard(value: T | typeof CANCEL_SYMBOL): T { + if (isCancel(value)) { + cancel('Aborted.') + process.exit(0) + } + return value +} + +export async function askConfirm(message: string, initialValue = false): Promise { + return guard(await confirm({ message, initialValue })) +} + +export async function askSelect( + message: string, + options: readonly PromptOption[], + initialValue?: T +): Promise { + // clack's Option is a deferred conditional type, so the generic call site + // cannot be checked; the result is always one of the provided values. + const result = await select({ + message, + options: options.map((option) => ({ value: option.value, label: option.label, hint: option.hint })), + initialValue + }) + return guard(result as T | typeof CANCEL_SYMBOL) +} + +export async function askMultiSelect( + message: string, + options: readonly PromptOption[], + initialValues: readonly string[] = [] +): Promise { + const result = await multiselect({ + message, + options: options.map((option) => ({ value: option.value, label: option.label, hint: option.hint })), + initialValues: [...initialValues] + }) + return guard(result) +} + +export async function askText(message: string, initialValue = ''): Promise { + return guard(await text({ message, initialValue })) +} + +export async function askPassword(message: string): Promise { + return guard(await password({ message })) +} + +// Primary secret entry: copy the value in the browser, pick "Read from +// clipboard" here; manual paste stays available for headless servers. +export async function askSecret(name: string): Promise { + if (!(await clipboardAvailable())) { + log.message('No clipboard tool available - paste manually (right-click or Ctrl+Shift+V, not Ctrl+C).') + return askPassword(`Paste your ${name} (Enter to skip)`) + } + for (;;) { + const mode = await askSelect<'clipboard' | 'manual'>(`Copy your ${name} to your clipboard, then choose:`, [ + { value: 'clipboard', label: 'Read from clipboard' }, + { value: 'manual', label: 'Paste manually' } + ]) + if (mode === 'manual') return askPassword(`Paste your ${name} (Enter to skip)`) + const value = await readClipboard() + if (value) { + log.success(`Received from clipboard (${value.length} characters)`) + return value + } + log.warn('Clipboard is empty - paste manually instead.') + log.message('Paste with right-click or Ctrl+Shift+V (not Ctrl+C).') + return askPassword(`Paste your ${name} (Enter to skip)`) + } +} + +export async function withSpinner( + message: string, + task: (update: (message: string) => void) => Promise +): Promise { + const active: SpinnerResult = spinner() + active.start(message) + try { + return await task((next) => active.message(next)) + } finally { + active.stop('') + } +} diff --git a/cli/src/core/secrets.ts b/cli/src/core/secrets.ts new file mode 100644 index 0000000..f482516 --- /dev/null +++ b/cli/src/core/secrets.ts @@ -0,0 +1,15 @@ +import { randomBytes } from 'node:crypto' +import { PASSWORD_LENGTH, TRACKER_KEY_BYTES } from '../constants' + +const ALPHANUMERIC = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + +export function generatePassword(length: number = PASSWORD_LENGTH): string { + const bytes = randomBytes(length) + let password = '' + for (const byte of bytes) password += ALPHANUMERIC.charAt(byte % ALPHANUMERIC.length) + return password +} + +export function generateHexKey(byteLength: number = TRACKER_KEY_BYTES): string { + return randomBytes(byteLength).toString('hex') +} diff --git a/cli/src/core/state.ts b/cli/src/core/state.ts new file mode 100644 index 0000000..2e74591 --- /dev/null +++ b/cli/src/core/state.ts @@ -0,0 +1,73 @@ +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { STATE_FILE } from '../constants' +import type { SetupSelection } from '../types' +import { writeAtomic } from './fs' + +export const DEFAULT_SELECTION: SetupSelection = { + dbDriver: 'sqlite', + imageTag: 'latest', + qbittorrent: 'local', + prowlarr: 'local', + mediaProvider: 'jellyfin', + mediaMode: 'local', + flaresolverr: false, + dozzle: false +} + +function oneOf(value: string | undefined, allowed: readonly T[], fallback: T): T { + if (value !== undefined && (allowed as readonly string[]).includes(value)) return value as T + return fallback +} + +// Unknown/missing fields fall back to defaults instead of failing a re-run. +export function parseSelection(raw: Record): SetupSelection { + return { + dbDriver: oneOf(raw['dbDriver'], ['sqlite', 'postgres'] as const, 'sqlite'), + imageTag: oneOf(raw['imageTag'], ['latest', 'nightly'] as const, 'latest'), + qbittorrent: oneOf(raw['qbittorrent'], ['local', 'external'] as const, 'local'), + prowlarr: oneOf(raw['prowlarr'], ['local', 'external'] as const, 'local'), + mediaProvider: oneOf(raw['mediaProvider'], ['jellyfin', 'none'] as const, 'jellyfin'), + mediaMode: oneOf(raw['mediaMode'], ['local', 'external', 'none'] as const, 'local'), + flaresolverr: raw['flaresolverr'] === 'true', + dozzle: raw['dozzle'] === 'true' + } +} + +export function parseStateFile(content: string): Record { + const raw: Record = {} + for (const line of content.split('\n')) { + const eq = line.indexOf('=') + if (eq === -1) continue + const key = line.slice(0, eq).trim() + if (!key || key in raw) continue + raw[key] = line.slice(eq + 1).trim() + } + return raw +} + +export function loadSelection(dir: string): SetupSelection | null { + const file = path.join(dir, STATE_FILE) + if (!existsSync(file)) return null + return parseSelection(parseStateFile(readFileSync(file, 'utf8'))) +} + +export function selectionToFile(selection: SetupSelection): string { + return ( + [ + 'version=1', + `dbDriver=${selection.dbDriver}`, + `imageTag=${selection.imageTag}`, + `qbittorrent=${selection.qbittorrent}`, + `prowlarr=${selection.prowlarr}`, + `mediaProvider=${selection.mediaProvider}`, + `mediaMode=${selection.mediaMode}`, + `flaresolverr=${selection.flaresolverr}`, + `dozzle=${selection.dozzle}` + ].join('\n') + '\n' + ) +} + +export function saveSelection(dir: string, selection: SetupSelection): void { + writeAtomic(path.join(dir, STATE_FILE), selectionToFile(selection)) +} diff --git a/cli/src/core/version.ts b/cli/src/core/version.ts new file mode 100644 index 0000000..c64d3c9 --- /dev/null +++ b/cli/src/core/version.ts @@ -0,0 +1,4 @@ +// Injected at compile time via `bun build --define BUILD_VERSION='""'`. +declare const BUILD_VERSION: string + +export const VERSION: string = typeof BUILD_VERSION === 'string' ? BUILD_VERSION : 'dev' diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 0000000..0becf65 --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,68 @@ +import { SetupFailure } from './core/errors' +import { isTty, pauseBeforeExit } from './core/platform' +import { log, outro } from './core/prompt' +import { DEFAULT_SELECTION } from './core/state' +import { showBanner } from './steps/banner' +import { checkPrerequisites } from './steps/prerequisites' +import { setupEnvFile } from './steps/env-file' +import { detectExistingSetup } from './steps/detect' +import { generateSecrets } from './steps/secrets-gen' +import { chooseDatabase } from './steps/database' +import { chooseComponents } from './steps/components' +import { downloadComposeFiles } from './steps/compose-files' +import { chooseLodeVersion } from './steps/lode-version' +import { startServices } from './steps/services' +import { jellyfinApiKey } from './steps/jellyfin' +import { qbittorrentSetup } from './steps/qbittorrent' +import { prowlarrApiKey } from './steps/prowlarr' +import { tmdbApiKey } from './steps/tmdb' +import { discordWebhook } from './steps/discord' +import { startLode } from './steps/start-lode' +import { showSummary } from './steps/summary' +import type { StepContext } from './types' + +async function main(): Promise { + if (!isTty()) { + console.error('Interactive terminal required, but none is available.') + console.error('Run it from a terminal - `curl | bash` and `irm | iex` are handled by the setup scripts.') + process.exit(1) + } + const ctx: StepContext = { + stateFound: false, + previousSelection: null, + selection: { ...DEFAULT_SELECTION }, + urls: { qbittorrent: '', prowlarr: '', jellyfin: '' }, + composeFiles: ['docker-compose.yml'], + qbitTempPass: '', + adminPass: '' + } + await showBanner() + await checkPrerequisites() + await setupEnvFile() + await detectExistingSetup(ctx) + generateSecrets() + await chooseDatabase(ctx) + await chooseComponents(ctx) + await downloadComposeFiles(ctx) + await chooseLodeVersion(ctx) + await startServices(ctx) + await jellyfinApiKey(ctx) + await qbittorrentSetup(ctx) + await prowlarrApiKey(ctx) + await tmdbApiKey() + await discordWebhook() + await startLode(ctx) + showSummary(ctx) + outro('Setup complete - enjoy!') + await pauseBeforeExit() +} + +main().catch((error: unknown) => { + if (error instanceof SetupFailure) { + log.error(error.message) + for (const line of error.detailLines) console.log(line) + process.exit(1) + } + log.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +}) diff --git a/cli/src/steps/banner.ts b/cli/src/steps/banner.ts new file mode 100644 index 0000000..3b161b5 --- /dev/null +++ b/cli/src/steps/banner.ts @@ -0,0 +1,11 @@ +import { askConfirm, intro, log, outro } from '../core/prompt' +import { VERSION } from '../core/version' + +export async function showBanner(): Promise { + intro(`Lode Auto-Setup ${VERSION}`) + log.message(['This will set up Lode and the services you choose.', 'All data will be stored in Docker volumes.']) + if (!(await askConfirm('Do you want to continue?'))) { + outro('Aborted.') + process.exit(0) + } +} diff --git a/cli/src/steps/components.ts b/cli/src/steps/components.ts new file mode 100644 index 0000000..b7172ec --- /dev/null +++ b/cli/src/steps/components.ts @@ -0,0 +1,106 @@ +import { composeFilesFor, ENV_FILE, ENV_KEYS, INTERNAL_URLS, OPTION_LABELS } from '../constants' +import { readEnvValue } from '../core/env' +import { askMultiSelect, askSelect, askText, log, stepHeader } from '../core/prompt' +import type { MediaMode, ServiceMode, StepContext } from '../types' + +export function isValidHttpUrl(value: string): boolean { + return /^https?:\/\//.test(value) +} + +// The current .env value, unless it is the internal in-network URL (not useful as +// a default for an externally hosted service). +export function externalUrlDefault(dir: string, key: string, internalUrl: string): string { + const value = readEnvValue(dir, ENV_FILE, key) + return value !== '' && value !== internalUrl ? value : '' +} + +export async function chooseComponents(ctx: StepContext): Promise { + stepHeader(6, 'Selecting components') + log.message('Lode and Redis are always deployed. Choose the rest:') + const dir = process.cwd() + + const qbit = await askSelect( + 'How should Lode download torrents?', + [ + { value: 'local', label: OPTION_LABELS.qbitLocal }, + { value: 'external', label: OPTION_LABELS.qbitExternal } + ], + ctx.selection.qbittorrent + ) + ctx.selection.qbittorrent = qbit + if (qbit === 'external') { + const url = await askText( + 'External qBittorrent URL (http://host:8080)', + externalUrlDefault(dir, ENV_KEYS.qbittorrentUrl, INTERNAL_URLS.qbittorrent) + ) + if (!isValidHttpUrl(url)) + log.warn('External qBittorrent URL does not start with http(s):// - Lode will not be able to reach it') + ctx.urls.qbittorrent = url + } else { + ctx.urls.qbittorrent = '' + } + log.success(`qBittorrent: ${qbit}`) + + const prowlarr = await askSelect( + 'How should Lode index torrents?', + [ + { value: 'local', label: OPTION_LABELS.prowlarrLocal }, + { value: 'external', label: OPTION_LABELS.prowlarrExternal } + ], + ctx.selection.prowlarr + ) + ctx.selection.prowlarr = prowlarr + if (prowlarr === 'external') { + const url = await askText( + 'External Prowlarr URL (http://host:9696)', + externalUrlDefault(dir, ENV_KEYS.prowlarrUrl, INTERNAL_URLS.prowlarr) + ) + if (!isValidHttpUrl(url)) + log.warn('External Prowlarr URL does not start with http(s):// - Lode will not be able to reach it') + ctx.urls.prowlarr = url + } else { + ctx.urls.prowlarr = '' + } + log.success(`Prowlarr: ${prowlarr}`) + + const media = await askSelect( + 'Media server (library detection)?', + [ + { value: 'local', label: OPTION_LABELS.jellyfinLocal }, + { value: 'external', label: OPTION_LABELS.jellyfinExternal }, + { value: 'none', label: OPTION_LABELS.mediaNone } + ], + ctx.selection.mediaMode + ) + ctx.selection.mediaMode = media + ctx.selection.mediaProvider = media === 'none' ? 'none' : 'jellyfin' + if (media === 'external') { + const url = await askText( + 'External Jellyfin URL (http://host:8096)', + externalUrlDefault(dir, ENV_KEYS.jellyfinUrl, INTERNAL_URLS.jellyfin) + ) + if (!isValidHttpUrl(url)) + log.warn('External Jellyfin URL does not start with http(s):// - Lode will not be able to reach it') + ctx.urls.jellyfin = url + } else { + ctx.urls.jellyfin = '' + } + log.success(`Media server: ${ctx.selection.mediaProvider} (${media})`) + + const addonInitial: string[] = [] + if (ctx.selection.flaresolverr) addonInitial.push('flaresolverr') + if (ctx.selection.dozzle) addonInitial.push('dozzle') + const addons = await askMultiSelect( + 'Select optional add-ons:', + [ + { value: 'flaresolverr', label: OPTION_LABELS.flaresolverr }, + { value: 'dozzle', label: OPTION_LABELS.dozzle } + ], + addonInitial + ) + ctx.selection.flaresolverr = addons.includes('flaresolverr') + ctx.selection.dozzle = addons.includes('dozzle') + log.success(`Add-ons: FlareSolverr=${ctx.selection.flaresolverr} Dozzle=${ctx.selection.dozzle}`) + + ctx.composeFiles = composeFilesFor(ctx.selection) +} diff --git a/cli/src/steps/compose-files.ts b/cli/src/steps/compose-files.ts new file mode 100644 index 0000000..4106e80 --- /dev/null +++ b/cli/src/steps/compose-files.ts @@ -0,0 +1,51 @@ +import { copyFileSync, existsSync, readFileSync, renameSync, unlinkSync } from 'node:fs' +import { REPO_RAW } from '../constants' +import { maskLodeImageTag } from '../core/compose' +import { downloadFile } from '../core/download' +import { SetupFailure } from '../core/errors' +import { askConfirm, log, stepHeader, withSpinner } from '../core/prompt' +import type { StepContext } from '../types' + +export async function downloadComposeFiles(ctx: StepContext): Promise { + stepHeader(7, 'Downloading compose files') + const existing = ctx.composeFiles.filter((file) => existsSync(file)) + const missing = ctx.composeFiles.filter((file) => !existsSync(file)) + + for (const file of missing) { + try { + await withSpinner(`Downloading ${file}...`, () => downloadFile(`${REPO_RAW}/${file}`, file)) + } catch { + throw new SetupFailure(`Failed to download ${file} from GitHub.`, [ + ' Check your internet connection and try again.' + ]) + } + log.success(`${file} downloaded`) + } + + if (existing.length === 0) return + const doUpdate = await askConfirm( + `${existing.length} compose file(s) already exist. Download the latest versions from GitHub? (changed files keep a .bak backup)` + ) + for (const file of existing) { + if (!doUpdate) { + log.success(`Using existing ${file}`) + continue + } + try { + await downloadFile(`${REPO_RAW}/${file}`, `${file}.tmp`) + } catch { + log.warn(`Could not download ${file} - keeping your local copy`) + continue + } + const local = readFileSync(file, 'utf8') + const remote = readFileSync(`${file}.tmp`, 'utf8') + if (maskLodeImageTag(local) !== maskLodeImageTag(remote)) { + copyFileSync(file, `${file}.bak`) + renameSync(`${file}.tmp`, file) + log.success(`${file} updated (backup saved as ${file}.bak)`) + } else { + unlinkSync(`${file}.tmp`) + log.success(`${file} is already up to date`) + } + } +} diff --git a/cli/src/steps/database.ts b/cli/src/steps/database.ts new file mode 100644 index 0000000..6403e67 --- /dev/null +++ b/cli/src/steps/database.ts @@ -0,0 +1,36 @@ +import { ENV_FILE, ENV_KEYS, PASSWORD_LENGTH, SECRET_MIN_LENGTH } from '../constants' +import { envValueMeetsMinLength, readEnvValue, updateEnv } from '../core/env' +import { askSelect, log, stepHeader } from '../core/prompt' +import { generatePassword } from '../core/secrets' +import type { DbDriver, StepContext } from '../types' + +export async function chooseDatabase(ctx: StepContext): Promise { + stepHeader(5, 'Database driver') + let initial: DbDriver = 'sqlite' + if (ctx.stateFound) { + initial = ctx.selection.dbDriver + } else { + const existing = readEnvValue(process.cwd(), ENV_FILE, ENV_KEYS.dbDriver) + if (existing === 'postgres') initial = 'postgres' + else if (existing !== '' && existing !== 'sqlite') log.info(`Existing database driver: ${existing}`) + } + const choice = await askSelect( + 'Select database driver:', + [ + { value: 'sqlite', label: 'SQLite (recommended)', hint: 'Zero config, file-based' }, + { value: 'postgres', label: 'PostgreSQL', hint: 'Full-featured, requires more resources' } + ], + initial + ) + ctx.selection.dbDriver = choice + log.success(`Database driver: ${choice}`) + if (choice === 'postgres') { + const dir = process.cwd() + if (envValueMeetsMinLength(dir, ENV_FILE, ENV_KEYS.postgresPassword, SECRET_MIN_LENGTH)) { + log.success('PostgreSQL password already set - keeping it') + } else { + updateEnv(dir, ENV_FILE, ENV_KEYS.postgresPassword, generatePassword(PASSWORD_LENGTH)) + log.success('PostgreSQL password generated') + } + } +} diff --git a/cli/src/steps/detect.ts b/cli/src/steps/detect.ts new file mode 100644 index 0000000..d8ae153 --- /dev/null +++ b/cli/src/steps/detect.ts @@ -0,0 +1,31 @@ +import { ENV_FILE, ENV_KEYS, STATE_FILE } from '../constants' +import { readEnvValue } from '../core/env' +import { askConfirm, log, stepHeader } from '../core/prompt' +import { loadSelection } from '../core/state' +import type { StepContext } from '../types' + +export async function detectExistingSetup(ctx: StepContext): Promise { + stepHeader(3, 'Detecting existing setup') + const existing = loadSelection(process.cwd()) + if (!existing) return + ctx.stateFound = true + ctx.previousSelection = existing + ctx.selection = existing + ctx.urls = { + qbittorrent: readEnvValue(process.cwd(), ENV_FILE, ENV_KEYS.qbittorrentUrl), + prowlarr: readEnvValue(process.cwd(), ENV_FILE, ENV_KEYS.prowlarrUrl), + jellyfin: readEnvValue(process.cwd(), ENV_FILE, ENV_KEYS.jellyfinUrl) + } + log.info(`Existing setup found (${STATE_FILE}):`) + log.message([ + ` Database: ${existing.dbDriver}`, + ` qBittorrent: ${existing.qbittorrent}`, + ` Prowlarr: ${existing.prowlarr}`, + ` Media: ${existing.mediaProvider} (${existing.mediaMode})`, + ` Add-ons: FlareSolverr=${existing.flaresolverr} Dozzle=${existing.dozzle}` + ]) + if (!(await askConfirm('Reconfigure your existing Lode setup?', true))) { + log.success('Keeping existing setup - no changes made.') + process.exit(0) + } +} diff --git a/cli/src/steps/discord.ts b/cli/src/steps/discord.ts new file mode 100644 index 0000000..54e4fee --- /dev/null +++ b/cli/src/steps/discord.ts @@ -0,0 +1,24 @@ +import { ENV_FILE, ENV_KEYS } from '../constants' +import { updateEnv } from '../core/env' +import { askConfirm, askSecret, instructions, log, stepHeader } from '../core/prompt' + +export async function discordWebhook(): Promise { + stepHeader(14, 'Discord Webhook (optional)') + if (!(await askConfirm('Get notified in Discord when downloads complete. Set up a webhook now?'))) { + log.info('Skipped - set NUXT_DISCORD_WEBHOOK_URL in .env later if you change your mind.') + return + } + instructions('To set up a Discord webhook', [ + '1. Open your Discord server', + '2. Go to Server Settings > Integrations > Webhooks', + '3. Click New Webhook', + '4. Name it, choose a channel, click Copy Webhook URL' + ]) + const key = await askSecret('Discord Webhook URL') + if (key) { + updateEnv(process.cwd(), ENV_FILE, ENV_KEYS.discordWebhookUrl, key) + log.success('Discord webhook URL saved') + } else { + log.warn('Skipping Discord webhook -- set it later in .env') + } +} diff --git a/cli/src/steps/env-file.ts b/cli/src/steps/env-file.ts new file mode 100644 index 0000000..a30d253 --- /dev/null +++ b/cli/src/steps/env-file.ts @@ -0,0 +1,30 @@ +import { copyFileSync, existsSync, mkdirSync, renameSync } from 'node:fs' +import { ENV_EXAMPLE_FILE, ENV_FILE, MEDIA_DIRS, REPO_RAW } from '../constants' +import { downloadFile } from '../core/download' +import { SetupFailure } from '../core/errors' +import { log, stepHeader, withSpinner } from '../core/prompt' + +export async function setupEnvFile(): Promise { + stepHeader(2, 'Setting up .env file') + if (existsSync(ENV_FILE)) { + log.warn('.env exists - keeping existing config') + } else { + if (!existsSync(ENV_EXAMPLE_FILE)) { + log.info('Downloading .env.example from GitHub...') + try { + await withSpinner('Downloading .env.example...', () => + downloadFile(`${REPO_RAW}/${ENV_EXAMPLE_FILE}`, ENV_EXAMPLE_FILE) + ) + } catch { + throw new SetupFailure('Failed to download .env.example from GitHub.', [ + ' Check your internet connection and try again.' + ]) + } + } + copyFileSync(ENV_EXAMPLE_FILE, `${ENV_FILE}.tmp`) + renameSync(`${ENV_FILE}.tmp`, ENV_FILE) + log.success('Created .env from .env.example') + } + for (const dir of MEDIA_DIRS) mkdirSync(dir, { recursive: true }) + log.success('Created media directories (media/Movies, media/Series)') +} diff --git a/cli/src/steps/jellyfin.ts b/cli/src/steps/jellyfin.ts new file mode 100644 index 0000000..ce079ed --- /dev/null +++ b/cli/src/steps/jellyfin.ts @@ -0,0 +1,35 @@ +import { ENV_FILE, ENV_KEYS } from '../constants' +import { updateEnv } from '../core/env' +import { hyperlink } from '../core/hyperlink' +import { askSecret, instructions, log, stepHeader } from '../core/prompt' +import type { StepContext } from '../types' + +export async function jellyfinApiKey(ctx: StepContext): Promise { + stepHeader(10, 'Jellyfin API key') + const dir = process.cwd() + if (ctx.selection.mediaProvider === 'none') { + updateEnv(dir, ENV_FILE, ENV_KEYS.jellyfinUrl, '') + updateEnv(dir, ENV_FILE, ENV_KEYS.jellyfinApiKey, '') + log.info('No media server selected - NUXT_JELLYFIN_URL and NUXT_JELLYFIN_API_KEY cleared') + return + } + if (ctx.selection.mediaMode === 'local') { + instructions('Follow these steps to get your Jellyfin API key', [ + `1. Open ${hyperlink('http://localhost:8096')} in your browser`, + '2. Complete the setup wizard (create your admin account)', + '3. Go to Dashboard (gear icon) > API Keys', + '4. Click the + button, name it Lode, click OK', + '5. Copy the generated API key' + ]) + } else { + log.message(`Your external Jellyfin instance: ${hyperlink(ctx.urls.jellyfin)}`) + log.message('Create an API key in Jellyfin: Dashboard (gear icon) > API Keys') + } + const key = await askSecret('Jellyfin API key') + if (key) { + updateEnv(dir, ENV_FILE, ENV_KEYS.jellyfinApiKey, key) + log.success('Jellyfin API key saved') + } else { + log.warn('Skipping Jellyfin API key -- set it later in .env') + } +} diff --git a/cli/src/steps/lode-version.ts b/cli/src/steps/lode-version.ts new file mode 100644 index 0000000..841623a --- /dev/null +++ b/cli/src/steps/lode-version.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { COMPOSE_BASE, LODE_IMAGE, STATE_FILE } from '../constants' +import { setLodeImageTag } from '../core/compose' +import { writeAtomic } from '../core/fs' +import { askSelect, log, stepHeader } from '../core/prompt' +import { saveSelection } from '../core/state' +import type { LodeTag, StepContext } from '../types' + +export async function chooseLodeVersion(ctx: StepContext): Promise { + stepHeader(8, 'Lode version') + const tag = await askSelect( + 'Select version:', + [ + { value: 'latest', label: 'latest (recommended)', hint: 'Stable release' }, + { value: 'nightly', label: 'nightly', hint: 'Latest dev build from main (may be unstable)' } + ], + ctx.selection.imageTag + ) + ctx.selection.imageTag = tag + + const base = path.join(process.cwd(), COMPOSE_BASE) + const { content: updated, changed } = setLodeImageTag(readFileSync(base, 'utf8'), tag) + if (changed) { + writeAtomic(base, updated) + log.success(`Lode version: ${tag} (image: ${LODE_IMAGE}:${tag})`) + } else { + log.warn(`No lode image line found in ${COMPOSE_BASE} - check the image tag manually`) + } + + saveSelection(process.cwd(), ctx.selection) + log.success(`Selection saved to ${STATE_FILE}`) +} diff --git a/cli/src/steps/prerequisites.ts b/cli/src/steps/prerequisites.ts new file mode 100644 index 0000000..e62ca50 --- /dev/null +++ b/cli/src/steps/prerequisites.ts @@ -0,0 +1,73 @@ +import { DOCS_LINKS } from '../constants' +import { checkDaemon, composeVersion, dockerVersion } from '../core/docker' +import { SetupFailure } from '../core/errors' +import { run } from '../core/exec' +import { hyperlink } from '../core/hyperlink' +import { log, stepHeader, withSpinner } from '../core/prompt' + +function dockerInstallHints(): string[] { + switch (process.platform) { + case 'darwin': + return [' Install Docker Desktop for macOS:', ` ${hyperlink(DOCS_LINKS.dockerMac)}`] + case 'win32': + return [' Install Docker Desktop for Windows (WSL2 backend):', ` ${hyperlink(DOCS_LINKS.dockerWindows)}`] + default: + return [' Install Docker Engine for Linux:', ` ${hyperlink(DOCS_LINKS.dockerLinux)}`] + } +} + +async function daemonHints(permissionDenied: boolean): Promise { + switch (process.platform) { + case 'darwin': + return [' Start Docker Desktop and try again.'] + case 'win32': + return [' Start Docker Desktop (WSL2 backend) and try again.'] + default: { + if (!permissionDenied) return [' Start the daemon: sudo systemctl start docker'] + const user = (await run('sh', ['-c', 'id -un'])).stdout.trim() + const groupLine = (await run('sh', ['-c', 'getent group docker 2>/dev/null'])).stdout + const members = groupLine.split(':')[3]?.split(',') ?? [] + const groups = (await run('sh', ['-c', 'id -nG'])).stdout.trim().split(/\s+/) + if (user && members.includes(user) && !groups.includes('docker')) { + // Group membership is re-evaluated at login, so a session opened before the + // usermod cannot see the docker group until it re-logs. + return [ + ' You are in the docker group, but this session predates the change.', + ' Log out and back in, or run: newgrp docker' + ] + } + return [ + ' The error looks like a permission problem - add your user to the docker group:', + ` sudo usermod -aG docker ${user || 'YOUR_USER'} (then log out and back in)` + ] + } + } +} + +export async function checkPrerequisites(): Promise { + stepHeader(1, 'Checking prerequisites') + + const version = await withSpinner('Checking Docker...', dockerVersion) + if (version === null) throw new SetupFailure('Docker is not installed.', dockerInstallHints()) + log.success(`Docker ${version}`) + + const daemon = await withSpinner('Checking Docker daemon...', checkDaemon) + if (!daemon.reachable) { + throw new SetupFailure('Docker daemon is not running or not reachable from this shell.', [ + ...daemon.errorLines.map((line) => ` ${line}`), + ...(await daemonHints(daemon.permissionDenied)) + ]) + } + log.success('Docker daemon running') + + const compose = await withSpinner('Checking Docker Compose...', composeVersion) + if (compose === null) { + throw new SetupFailure('Docker Compose plugin is not installed.', [ + ' macOS/Windows: install or update Docker Desktop', + ` ${hyperlink(DOCS_LINKS.dockerGeneric)}`, + ' Linux: install the Docker Compose plugin', + ` ${hyperlink(DOCS_LINKS.composeLinux)}` + ]) + } + log.success(`Docker Compose ${compose}`) +} diff --git a/cli/src/steps/prowlarr.ts b/cli/src/steps/prowlarr.ts new file mode 100644 index 0000000..3b8b515 --- /dev/null +++ b/cli/src/steps/prowlarr.ts @@ -0,0 +1,36 @@ +import { ENV_FILE, ENV_KEYS } from '../constants' +import { updateEnv } from '../core/env' +import { hyperlink } from '../core/hyperlink' +import { askSecret, instructions, log, stepHeader } from '../core/prompt' +import type { StepContext } from '../types' + +export async function prowlarrApiKey(ctx: StepContext): Promise { + stepHeader(12, 'Prowlarr API key') + const dir = process.cwd() + if (ctx.selection.prowlarr === 'local') { + instructions('Follow these steps to get your Prowlarr API key', [ + `1. Open ${hyperlink('http://localhost:9900')} in your browser`, + '2. Go to Settings > General', + '3. Find the API Key field', + '4. Copy the API key' + ]) + log.warn('IMPORTANT: Prowlarr needs indexers before Lode can find anything.') + log.message([' Add at least one indexer (e.g. YTS): Settings > Indexers > Add']) + if (ctx.selection.flaresolverr) { + log.message([ + ' For private trackers: Settings > Indexers > Add > FlareSolverr', + ' Set URL: http://flaresolverr:8191' + ]) + } + } else { + log.message(`Your external Prowlarr instance: ${hyperlink(ctx.urls.prowlarr)}`) + log.message('Find the API key in Prowlarr: Settings > General') + } + const key = await askSecret('Prowlarr API key') + if (key) { + updateEnv(dir, ENV_FILE, ENV_KEYS.prowlarrApiKey, key) + log.success('Prowlarr API key saved') + } else { + log.warn('Skipping Prowlarr API key -- set it later in .env') + } +} diff --git a/cli/src/steps/qbittorrent.ts b/cli/src/steps/qbittorrent.ts new file mode 100644 index 0000000..b6d4caa --- /dev/null +++ b/cli/src/steps/qbittorrent.ts @@ -0,0 +1,40 @@ +import { ENV_FILE, ENV_KEYS } from '../constants' +import { copyToClipboardOsc52 } from '../core/clipboard' +import { updateEnv } from '../core/env' +import { dcCmdPrefix } from '../core/docker' +import { hyperlink } from '../core/hyperlink' +import { askSecret, instructions, log, stepHeader } from '../core/prompt' +import type { StepContext } from '../types' + +export async function qbittorrentSetup(ctx: StepContext): Promise { + stepHeader(11, 'qBittorrent WebUI + API key') + const dir = process.cwd() + if (ctx.selection.qbittorrent === 'local') { + if (ctx.qbitTempPass) { + copyToClipboardOsc52(ctx.qbitTempPass) + log.message(`qBittorrent temporary password: ${ctx.qbitTempPass}`) + log.message('Copied to clipboard (this replaces your previous clipboard contents)') + } else { + log.warn(`Could not extract qBittorrent temp password - check: ${dcCmdPrefix(ctx.composeFiles)} logs qbittorrent`) + } + instructions('Follow these steps to configure qBittorrent', [ + `1. Open ${hyperlink('http://localhost:8080')} in your browser`, + '2. Login with: Username: admin / Password: [temporary password shown above]', + '3. Go to Tools > Options > Web UI', + '4. Change the password to something you remember', + '5. Save changes', + '6. Go to Tools > Options > Web UI > API Key section', + '7. Copy the API Key' + ]) + } else { + log.message(`Your external qBittorrent instance: ${hyperlink(ctx.urls.qbittorrent)}`) + log.message('Find the API key in qBittorrent: Tools > Options > Web UI') + } + const key = await askSecret('qBittorrent API key') + if (key) { + updateEnv(dir, ENV_FILE, ENV_KEYS.qbittorrentApiKey, key) + log.success('qBittorrent API key saved') + } else { + log.warn('Skipping qBittorrent API key -- set it later in .env') + } +} diff --git a/cli/src/steps/secrets-gen.ts b/cli/src/steps/secrets-gen.ts new file mode 100644 index 0000000..8e3fb4b --- /dev/null +++ b/cli/src/steps/secrets-gen.ts @@ -0,0 +1,21 @@ +import { ENV_FILE, ENV_KEYS, PASSWORD_LENGTH, SECRET_MIN_LENGTH, TRACKER_KEY_BYTES } from '../constants' +import { envValueMeetsMinLength, updateEnv } from '../core/env' +import { log, stepHeader } from '../core/prompt' +import { generateHexKey, generatePassword } from '../core/secrets' + +export function generateSecrets(): void { + stepHeader(4, 'Generating secrets') + const dir = process.cwd() + if (envValueMeetsMinLength(dir, ENV_FILE, ENV_KEYS.sessionPassword, SECRET_MIN_LENGTH)) { + log.success('Session password already set - keeping it') + } else { + updateEnv(dir, ENV_FILE, ENV_KEYS.sessionPassword, generatePassword(PASSWORD_LENGTH)) + log.success('Session password generated') + } + if (envValueMeetsMinLength(dir, ENV_FILE, ENV_KEYS.trackerEncryptionKey, SECRET_MIN_LENGTH)) { + log.success('Tracker encryption key already set - keeping it') + } else { + updateEnv(dir, ENV_FILE, ENV_KEYS.trackerEncryptionKey, generateHexKey(TRACKER_KEY_BYTES)) + log.success('Tracker encryption key generated') + } +} diff --git a/cli/src/steps/services.ts b/cli/src/steps/services.ts new file mode 100644 index 0000000..ad39b65 --- /dev/null +++ b/cli/src/steps/services.ts @@ -0,0 +1,146 @@ +import { + compose, + containerExists, + dcCmdPrefix, + imageExists, + logsTail, + serviceLastLogLine, + serviceRunning, + stopAndRemoveContainer +} from '../core/docker' +import { SetupFailure } from '../core/errors' +import { askConfirm, log, stepHeader, withSpinner } from '../core/prompt' +import { sleep, waitForPort } from '../core/ports' +import { infraServicesFor, LODE_IMAGE, PORTS, PORT_TIMEOUTS } from '../constants' +import type { SetupSelection, StepContext } from '../types' + +const QBIT_TEMP_PASSWORD = /A temporary password is provided for this session:\s*(\S+)/g + +export function extractQbitTempPassword(logs: string): string { + let last = '' + for (const match of logs.matchAll(QBIT_TEMP_PASSWORD)) last = match[1] ?? '' + return last +} + +interface DeselectionCheck { + service: string + stateKey: keyof SetupSelection + activeValue: string +} + +const DESELECTION_CHECKS: readonly DeselectionCheck[] = [ + { service: 'qbittorrent', stateKey: 'qbittorrent', activeValue: 'local' }, + { service: 'prowlarr', stateKey: 'prowlarr', activeValue: 'local' }, + { service: 'jellyfin', stateKey: 'mediaMode', activeValue: 'local' }, + { service: 'flaresolverr', stateKey: 'flaresolverr', activeValue: 'true' }, + { service: 'dozzle', stateKey: 'dozzle', activeValue: 'true' }, + { service: 'postgres', stateKey: 'dbDriver', activeValue: 'postgres' } +] + +async function removeDeselectedServices(ctx: StepContext): Promise { + const previous = ctx.previousSelection + if (!previous) return + for (const check of DESELECTION_CHECKS) { + if (String(previous[check.stateKey]) !== check.activeValue) continue + if (String(ctx.selection[check.stateKey]) === check.activeValue) continue + const container = `lode-${check.service}` + if (!(await containerExists(container))) continue + const remove = await askConfirm( + `${container} is no longer selected. Stop and remove the container? (its volume is kept)`, + true + ) + if (remove) { + await stopAndRemoveContainer(container) + log.success(`Removed ${container} (volume kept)`) + } else { + log.warn(`Keeping ${container} - remove it manually with: docker rm ${container}`) + } + } +} + +async function waitReady(label: string, port: number, timeoutMs: number): Promise { + log.info(`Waiting for ${label}...`) + await withSpinner(`Waiting for ${label} on port ${port}...`, (update) => + waitForPort('localhost', port, timeoutMs, { + onTick: (attempt, maxAttempts) => update(`Waiting for ${label}... (${attempt}/${maxAttempts})`) + }) + ) +} + +export async function startServices(ctx: StepContext): Promise { + stepHeader(9, 'Starting selected services') + const files = ctx.composeFiles + + const config = await compose(files, 'config', '-q') + if (config.code !== 0) { + throw new SetupFailure('The selected compose files do not merge correctly.', [`Files: ${files.join(' ')}`]) + } + + if (ctx.stateFound) await removeDeselectedServices(ctx) + + const services = infraServicesFor(ctx.selection) + + await withSpinner('Pulling images...', () => compose(files, 'pull')) + if (!(await imageExists(ctx.selection.imageTag))) { + throw new SetupFailure( + `Failed to pull the Lode image (${LODE_IMAGE}:${ctx.selection.imageTag}). Check your network and try again.`, + [`You can also try manually: ${dcCmdPrefix(files)} pull lode`] + ) + } + + await withSpinner('Starting selected services...', () => compose(files, 'up', '-d', ...services)) + + const failed: string[] = [] + for (const service of services) { + if (await serviceRunning(files, service)) continue + failed.push(service) + log.warn(`${service} failed to start: ${await serviceLastLogLine(files, service)}`) + } + + if (failed.includes('redis')) { + throw new SetupFailure('Redis failed to start. Cannot continue.', [`Check logs: ${dcCmdPrefix(files)} logs redis`]) + } + if (ctx.selection.dbDriver === 'postgres' && failed.includes('postgres')) { + throw new SetupFailure('PostgreSQL failed to start. Cannot continue.', [ + `Check logs: ${dcCmdPrefix(files)} logs postgres` + ]) + } + if (ctx.selection.qbittorrent === 'local' && failed.includes('qbittorrent')) { + throw new SetupFailure('qBittorrent failed to start. Cannot continue.', [ + `Check logs: ${dcCmdPrefix(files)} logs qbittorrent` + ]) + } + + await waitReady('Redis', PORTS.redis, PORT_TIMEOUTS.redis) + + if (ctx.selection.qbittorrent === 'local') + await waitReady('qBittorrent', PORTS.qbittorrent, PORT_TIMEOUTS.qbittorrent) + + await sleep(3000) + if (ctx.selection.qbittorrent === 'local') { + ctx.qbitTempPass = extractQbitTempPassword(await logsTail(files, 'qbittorrent', 100_000)) + } + + if (ctx.selection.prowlarr === 'local') { + if (failed.includes('prowlarr')) { + log.warn('Prowlarr not running - you can configure it later (step 12)') + } else { + await waitReady('Prowlarr', PORTS.prowlarr, PORT_TIMEOUTS.prowlarr) + } + } + + if (ctx.selection.mediaMode === 'local') { + if (failed.includes('jellyfin')) { + log.warn('Jellyfin not running - you can configure it later (step 10)') + } else { + await waitReady('Jellyfin', PORTS.jellyfin, PORT_TIMEOUTS.jellyfin) + } + } + + log.success('Selected services are running') + + if (ctx.selection.dbDriver === 'postgres') await waitReady('PostgreSQL', PORTS.postgres, PORT_TIMEOUTS.postgres) + + log.info('Waiting 10s for services to fully initialize...') + await withSpinner('Settling services...', () => sleep(10_000)) +} diff --git a/cli/src/steps/start-lode.ts b/cli/src/steps/start-lode.ts new file mode 100644 index 0000000..7bbaf5b --- /dev/null +++ b/cli/src/steps/start-lode.ts @@ -0,0 +1,74 @@ +import { ENV_FILE, ENV_KEYS, INTERNAL_URLS, PORTS, PORT_TIMEOUTS } from '../constants' +import { compose, dcCmdPrefix, logsTail, serviceRunning } from '../core/docker' +import { SetupFailure } from '../core/errors' +import { readEnvValue, updateEnv } from '../core/env' +import { hyperlink } from '../core/hyperlink' +import { log, stepHeader, withSpinner } from '../core/prompt' +import { sleep, waitForPort } from '../core/ports' +import type { StepContext } from '../types' + +// First match wins: the password is printed once on first start, and a restart +// would print a stale value first in the tail. +export function extractAdminPassword(logs: string): string { + for (const match of logs.matchAll(/Admin password:\s*([^\s"]+)/g)) { + const value = match[1] + if (value) return value + } + return '' +} + +export async function startLode(ctx: StepContext): Promise { + stepHeader(15, 'Starting Lode') + const dir = process.cwd() + const files = ctx.composeFiles + const { selection, urls } = ctx + + updateEnv(dir, ENV_FILE, ENV_KEYS.redisUrl, INTERNAL_URLS.redis) + updateEnv(dir, ENV_FILE, ENV_KEYS.dbDriver, selection.dbDriver) + updateEnv( + dir, + ENV_FILE, + ENV_KEYS.qbittorrentUrl, + selection.qbittorrent === 'local' ? INTERNAL_URLS.qbittorrent : urls.qbittorrent + ) + updateEnv( + dir, + ENV_FILE, + ENV_KEYS.prowlarrUrl, + selection.prowlarr === 'local' ? INTERNAL_URLS.prowlarr : urls.prowlarr + ) + updateEnv( + dir, + ENV_FILE, + ENV_KEYS.jellyfinUrl, + selection.mediaMode === 'local' ? INTERNAL_URLS.jellyfin : urls.jellyfin + ) + updateEnv(dir, ENV_FILE, ENV_KEYS.flaresolverrUrl, selection.flaresolverr ? INTERNAL_URLS.flaresolverr : '') + if (selection.dbDriver === 'postgres') { + const pgPass = readEnvValue(dir, ENV_FILE, ENV_KEYS.postgresPassword) + updateEnv(dir, ENV_FILE, ENV_KEYS.databaseUrl, `postgresql://lode:${pgPass}@postgres:5432/lode`) + } + + log.info('Starting Lode...') + await withSpinner('Starting Lode container...', () => compose(files, 'up', '-d', 'lode')) + if (!(await serviceRunning(files, 'lode'))) { + throw new SetupFailure('Lode container failed to start.', [ + `Check logs: ${dcCmdPrefix(files)} logs lode`, + `To retry: ${dcCmdPrefix(files)} up -d lode` + ]) + } + + log.info('Waiting for Lode to start (first start may take 1-2 minutes)...') + await withSpinner('Waiting for Lode on port 5757...', (update) => + waitForPort('localhost', PORTS.lode, PORT_TIMEOUTS.lode, { + onTick: (attempt, maxAttempts) => update(`Waiting for Lode... (${attempt}/${maxAttempts})`) + }) + ) + log.success(`Lode is running at ${hyperlink('http://localhost:5757')}`) + + for (let retry = 0; retry < 5; retry++) { + ctx.adminPass = extractAdminPassword(await logsTail(files, 'lode', 200)) + if (ctx.adminPass) break + await sleep(2000) + } +} diff --git a/cli/src/steps/summary.ts b/cli/src/steps/summary.ts new file mode 100644 index 0000000..66a3575 --- /dev/null +++ b/cli/src/steps/summary.ts @@ -0,0 +1,60 @@ +import { copyToClipboardOsc52 } from '../core/clipboard' +import { dcCmdPrefix } from '../core/docker' +import { hyperlink } from '../core/hyperlink' +import { log, note } from '../core/prompt' +import type { StepContext, SummaryRow } from '../types' + +function buildRows(ctx: StepContext): SummaryRow[] { + const { selection, urls } = ctx + const rows: SummaryRow[] = [{ label: 'Lode', value: 'http://localhost:5757', url: 'http://localhost:5757' }] + const qbit = selection.qbittorrent === 'local' ? 'http://localhost:8080' : urls.qbittorrent + rows.push({ label: 'qBittorrent', value: qbit, url: /^https?:\/\//.test(qbit) ? qbit : undefined }) + const prowlarr = selection.prowlarr === 'local' ? 'http://localhost:9900' : urls.prowlarr + rows.push({ label: 'Prowlarr', value: prowlarr, url: /^https?:\/\//.test(prowlarr) ? prowlarr : undefined }) + if (selection.mediaMode === 'local') { + rows.push({ label: 'Jellyfin', value: 'http://localhost:8096', url: 'http://localhost:8096' }) + } else if (selection.mediaMode === 'external') { + rows.push({ + label: 'Jellyfin', + value: urls.jellyfin, + url: /^https?:\/\//.test(urls.jellyfin) ? urls.jellyfin : undefined + }) + } else { + rows.push({ label: 'Jellyfin', value: '(disabled)' }) + } + if (selection.flaresolverr) + rows.push({ label: 'FlareSolverr', value: 'http://localhost:8191', url: 'http://localhost:8191' }) + rows.push({ label: 'Database', value: selection.dbDriver }) + if (selection.dbDriver === 'postgres') rows.push({ label: 'PostgreSQL', value: 'localhost:5432 / lode' }) + if (selection.dozzle) rows.push({ label: 'Dozzle', value: 'http://localhost:8082', url: 'http://localhost:8082' }) + return rows +} + +export function showSummary(ctx: StepContext): void { + log.success('Lode is ready!') + const rows = buildRows(ctx).map((row) => ` ${row.label.padEnd(18)} ${row.url ? hyperlink(row.value) : row.value}`) + note(rows.join('\n'), 'Services') + + log.message('Username: admin') + if (ctx.adminPass) { + copyToClipboardOsc52(ctx.adminPass) + log.message(`Password: ${ctx.adminPass}`) + log.message('Copied to clipboard (this replaces your previous clipboard contents)') + } else { + log.message(`Password: check '${dcCmdPrefix(ctx.composeFiles)} logs lode'`) + } + log.message('Change this password after first login.') + + const { selection } = ctx + const required = + selection.prowlarr === 'local' + ? [ + 'Prowlarr has no indexers yet - Lode cannot find torrents until you add them.', + `Open ${hyperlink('http://localhost:9900')} and add at least one indexer (e.g. YTS).`, + ...(selection.flaresolverr + ? ['For private trackers: Settings > Indexers > Add > FlareSolverr, URL: http://flaresolverr:8191'] + : []) + ] + : ['Your external Prowlarr needs at least one indexer before Lode can find torrents.'] + note(required.map((line) => ` ${line}`).join('\n'), 'Required before first use') +} diff --git a/cli/src/steps/tmdb.ts b/cli/src/steps/tmdb.ts new file mode 100644 index 0000000..0be5adc --- /dev/null +++ b/cli/src/steps/tmdb.ts @@ -0,0 +1,23 @@ +import { DOCS_LINKS, ENV_FILE, ENV_KEYS } from '../constants' +import { updateEnv } from '../core/env' +import { hyperlink } from '../core/hyperlink' +import { askSecret, instructions, log, stepHeader } from '../core/prompt' + +export async function tmdbApiKey(): Promise { + stepHeader(13, 'TMDB API key') + instructions('Follow these steps to get your TMDB API key', [ + `1. Go to ${hyperlink(DOCS_LINKS.tmdbApi)}`, + '2. Create a free account (or log in)', + '3. Click the link to generate an API key', + '4. Fill in the form: Application Name: Lode / Application URL: http://localhost:5757', + '5. Copy your API Key (v3 auth)' + ]) + log.info('This is required for movie/TV metadata.') + const key = await askSecret('TMDB API key') + if (key) { + updateEnv(process.cwd(), ENV_FILE, ENV_KEYS.tmdbApiKey, key) + log.success('TMDB API key saved') + } else { + log.warn('Skipping TMDB API key -- set it later in .env') + } +} diff --git a/cli/src/types.ts b/cli/src/types.ts new file mode 100644 index 0000000..5b4d926 --- /dev/null +++ b/cli/src/types.ts @@ -0,0 +1,80 @@ +/** Database backend for Lode. */ +export type DbDriver = 'sqlite' | 'postgres' + +/** Lode image tag. */ +export type LodeTag = 'latest' | 'nightly' + +/** Whether a service runs as a local container or an external instance. */ +export type ServiceMode = 'local' | 'external' + +/** Media server backend for library detection. */ +export type MediaProvider = 'jellyfin' | 'none' + +/** Deployment mode for the media server. */ +export type MediaMode = 'local' | 'external' | 'none' + +/** Compose files shipped with the repo (base + per-service overlays). */ +export type ComposeFileName = + | 'docker-compose.yml' + | 'docker-compose.postgres.yml' + | 'docker-compose.qbittorrent.yml' + | 'docker-compose.prowlarr.yml' + | 'docker-compose.jellyfin.yml' + | 'docker-compose.flaresolverr.yml' + | 'docker-compose.dozzle.yml' + +/** User selection persisted in .lode-setup so re-runs can prefill prompts. */ +export interface SetupSelection { + dbDriver: DbDriver + imageTag: LodeTag + qbittorrent: ServiceMode + prowlarr: ServiceMode + mediaProvider: MediaProvider + mediaMode: MediaMode + flaresolverr: boolean + dozzle: boolean +} + +/** URLs for externally hosted services (only meaningful when the mode is external). */ +export interface ExternalUrls { + qbittorrent: string + prowlarr: string + jellyfin: string +} + +/** Mutable context threaded through every setup step. */ +export interface StepContext { + /** True when a .lode-setup state file existed before this run. */ + stateFound: boolean + /** Selection loaded from the state file before this run's prompts (drives deselection cleanup). */ + previousSelection: SetupSelection | null + selection: SetupSelection + urls: ExternalUrls + composeFiles: ComposeFileName[] + /** qBittorrent temporary password extracted from logs (local mode only). */ + qbitTempPass: string + /** Admin password extracted from lode logs after first start. */ + adminPass: string +} + +/** An option for select-style prompts. */ +export interface PromptOption { + value: T + label: string + hint?: string +} + +/** Result of an external process run. */ +export interface CommandResult { + code: number + stdout: string + stderr: string +} + +/** A row in the final services summary table. */ +export interface SummaryRow { + label: string + value: string + /** http(s) values are rendered as OSC 8 hyperlinks. */ + url?: string +} diff --git a/cli/test/constants.test.ts b/cli/test/constants.test.ts new file mode 100644 index 0000000..1b102fb --- /dev/null +++ b/cli/test/constants.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { composeFilesFor, infraServicesFor } from '../src/constants' +import { DEFAULT_SELECTION } from '../src/core/state' +import type { SetupSelection } from '../src/types' + +describe('composeFilesFor', () => { + it('returns only the base file when everything is external or off', () => { + const selection: SetupSelection = { + ...DEFAULT_SELECTION, + dbDriver: 'sqlite', + qbittorrent: 'external', + prowlarr: 'external', + mediaMode: 'none' + } + expect(composeFilesFor(selection)).toEqual(['docker-compose.yml']) + }) + + it('returns all overlay files for a full-stack selection', () => { + const selection: SetupSelection = { + ...DEFAULT_SELECTION, + dbDriver: 'postgres', + flaresolverr: true, + dozzle: true + } + expect(composeFilesFor(selection)).toEqual([ + 'docker-compose.yml', + 'docker-compose.postgres.yml', + 'docker-compose.qbittorrent.yml', + 'docker-compose.prowlarr.yml', + 'docker-compose.jellyfin.yml', + 'docker-compose.flaresolverr.yml', + 'docker-compose.dozzle.yml' + ]) + }) +}) + +describe('infraServicesFor', () => { + it('always includes redis', () => { + expect(infraServicesFor(DEFAULT_SELECTION)[0]).toBe('redis') + }) + + it('includes every locally selected service', () => { + const selection: SetupSelection = { + ...DEFAULT_SELECTION, + dbDriver: 'postgres', + flaresolverr: true, + dozzle: true + } + expect(infraServicesFor(selection)).toEqual([ + 'redis', + 'postgres', + 'qbittorrent', + 'prowlarr', + 'jellyfin', + 'flaresolverr', + 'dozzle' + ]) + }) +}) diff --git a/cli/test/core/compose.test.ts b/cli/test/core/compose.test.ts new file mode 100644 index 0000000..9864f2e --- /dev/null +++ b/cli/test/core/compose.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { maskLodeImageTag, setLodeImageTag } from '../../src/core/compose' + +const CONTENT = `services: + lode: + image: ghcr.io/nort1346/lode:latest + redis: + image: redis:7 +` + +describe('maskLodeImageTag', () => { + it('masks the lode image tag only', () => { + const masked = maskLodeImageTag(CONTENT) + expect(masked).toContain('image: ghcr.io/nort1346/lode:') + expect(masked).toContain('image: redis:7') + }) + + it('masks commented-out image lines too', () => { + const masked = maskLodeImageTag(' # image: ghcr.io/nort1346/lode:nightly\n') + expect(masked).toBe(' # image: ghcr.io/nort1346/lode:\n') + }) + + it('makes tag-only differences compare equal', () => { + const a = maskLodeImageTag(CONTENT) + const b = maskLodeImageTag(CONTENT.replace('lode:latest', 'lode:nightly')) + expect(a).toBe(b) + }) +}) + +describe('setLodeImageTag', () => { + it('replaces the tag and reports a change', () => { + const { content, changed } = setLodeImageTag(CONTENT, 'nightly') + expect(changed).toBe(true) + expect(content).toContain('image: ghcr.io/nort1346/lode:nightly') + expect(content).toContain('image: redis:7') + }) + + it('reports no change when there is no lode image line', () => { + const { content, changed } = setLodeImageTag('services:\n redis:\n image: redis:7\n', 'nightly') + expect(changed).toBe(false) + expect(content).toBe('services:\n redis:\n image: redis:7\n') + }) +}) diff --git a/cli/test/core/env.test.ts b/cli/test/core/env.test.ts new file mode 100644 index 0000000..3ef283b --- /dev/null +++ b/cli/test/core/env.test.ts @@ -0,0 +1,59 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { envValueMeetsMinLength, readEnvValue, updateEnv, updateEnvFile } from '../../src/core/env' + +describe('updateEnvFile', () => { + it('replaces an active KEY= line', () => { + expect(updateEnvFile('A=1\nB=2\n', 'B', 'new')).toBe('A=1\nB=new\n') + }) + + it('uncomments a "# KEY=" line in place', () => { + expect(updateEnvFile('A=1\n# B=\nC=3\n', 'B', 'v')).toBe('A=1\nB=v\nC=3\n') + }) + + it('appends when the key is missing', () => { + expect(updateEnvFile('A=1\n', 'B', 'v')).toBe('A=1\nB=v\n') + }) + + it('keeps values containing & and $ intact (no sed-style mangling)', () => { + const value = 'a&b$c%d' + const output = updateEnvFile('X=\n', 'X', value) + expect(output).toContain(`X=${value}`) + }) +}) + +describe('env file access', () => { + let dir: string + beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'lode-env-')) + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('readEnvValue returns the value, or empty when key/file is missing', () => { + writeFileSync(path.join(dir, '.env'), 'A=1\n') + expect(readEnvValue(dir, '.env', 'A')).toBe('1') + expect(readEnvValue(dir, '.env', 'B')).toBe('') + expect(readEnvValue(dir, 'missing.env', 'A')).toBe('') + }) + + it('updateEnv is a no-op when the file does not exist', () => { + expect(() => updateEnv(dir, 'missing.env', 'A', '1')).not.toThrow() + }) + + it('updateEnv writes through the replace/uncomment/append semantics', () => { + writeFileSync(path.join(dir, '.env'), '# B=\n') + updateEnv(dir, '.env', 'B', 'v') + expect(readEnvValue(dir, '.env', 'B')).toBe('v') + }) + + it('envValueMeetsMinLength checks the stored value length', () => { + writeFileSync(path.join(dir, '.env'), 'A=12345\n') + expect(envValueMeetsMinLength(dir, '.env', 'A', 5)).toBe(true) + expect(envValueMeetsMinLength(dir, '.env', 'A', 6)).toBe(false) + expect(envValueMeetsMinLength(dir, '.env', 'B', 1)).toBe(false) + }) +}) diff --git a/cli/test/core/secrets.test.ts b/cli/test/core/secrets.test.ts new file mode 100644 index 0000000..8e2b8de --- /dev/null +++ b/cli/test/core/secrets.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { generateHexKey, generatePassword } from '../../src/core/secrets' + +describe('generatePassword', () => { + it('returns the requested length of alphanumerics', () => { + const password = generatePassword(32) + expect(password).toHaveLength(32) + expect(password).toMatch(/^[a-zA-Z0-9]+$/) + }) + + it('generates different values on each call', () => { + expect(generatePassword(32)).not.toBe(generatePassword(32)) + }) +}) + +describe('generateHexKey', () => { + it('returns lowercase hex of twice the byte length', () => { + const key = generateHexKey(32) + expect(key).toMatch(/^[0-9a-f]{64}$/) + }) +}) diff --git a/cli/test/core/state.test.ts b/cli/test/core/state.test.ts new file mode 100644 index 0000000..010d4de --- /dev/null +++ b/cli/test/core/state.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { DEFAULT_SELECTION, parseSelection, parseStateFile, selectionToFile } from '../../src/core/state' + +describe('parseStateFile', () => { + it('parses flat key=value lines and keeps the first occurrence', () => { + const raw = parseStateFile('version=1\ndbDriver=postgres\n\nbadline\ndbDriver=sqlite\n') + expect(raw).toEqual({ version: '1', dbDriver: 'postgres' }) + }) +}) + +describe('parseSelection', () => { + it('falls back to defaults for unknown or missing fields', () => { + const selection = parseSelection({ dbDriver: 'mysql', qbittorrent: 'external', dozzle: 'true' }) + expect(selection.dbDriver).toBe('sqlite') + expect(selection.qbittorrent).toBe('external') + expect(selection.dozzle).toBe(true) + expect(selection.mediaMode).toBe('local') + expect(selection.imageTag).toBe('latest') + }) + + it('round-trips through selectionToFile', () => { + const selection = { ...DEFAULT_SELECTION, dbDriver: 'postgres' as const, dozzle: true } + const parsed = parseSelection(parseStateFile(selectionToFile(selection))) + expect(parsed).toEqual(selection) + }) +}) diff --git a/cli/test/steps/components.test.ts b/cli/test/steps/components.test.ts new file mode 100644 index 0000000..23be14b --- /dev/null +++ b/cli/test/steps/components.test.ts @@ -0,0 +1,38 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { externalUrlDefault, isValidHttpUrl } from '../../src/steps/components' + +describe('isValidHttpUrl', () => { + it('accepts http and https URLs', () => { + expect(isValidHttpUrl('http://localhost:8080')).toBe(true) + expect(isValidHttpUrl('https://nas.example:9696')).toBe(true) + }) + + it('rejects everything else', () => { + expect(isValidHttpUrl('ftp://x')).toBe(false) + expect(isValidHttpUrl('localhost')).toBe(false) + expect(isValidHttpUrl('')).toBe(false) + }) +}) + +describe('externalUrlDefault', () => { + let dir: string + beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'lode-components-')) + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('falls back to empty when the stored value is the internal URL', () => { + writeFileSync(path.join(dir, '.env'), 'NUXT_QBITTORRENT_URL=http://qbittorrent:8080\n') + expect(externalUrlDefault(dir, 'NUXT_QBITTORRENT_URL', 'http://qbittorrent:8080')).toBe('') + }) + + it('returns the stored external URL', () => { + writeFileSync(path.join(dir, '.env'), 'NUXT_QBITTORRENT_URL=http://nas:8080\n') + expect(externalUrlDefault(dir, 'NUXT_QBITTORRENT_URL', 'http://qbittorrent:8080')).toBe('http://nas:8080') + }) +}) diff --git a/cli/test/steps/services.test.ts b/cli/test/steps/services.test.ts new file mode 100644 index 0000000..edb86b4 --- /dev/null +++ b/cli/test/steps/services.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { extractQbitTempPassword } from '../../src/steps/services' + +describe('extractQbitTempPassword', () => { + it('extracts the last temporary password (container restarts print a new one)', () => { + const logs = [ + 'qbit | A temporary password is provided for this session: firstpass', + 'qbit | restart', + 'qbit | A temporary password is provided for this session: secondpass' + ].join('\n') + expect(extractQbitTempPassword(logs)).toBe('secondpass') + }) + + it('returns an empty string when the logs contain no password line', () => { + expect(extractQbitTempPassword('nothing relevant here\n')).toBe('') + }) +}) diff --git a/cli/test/steps/start-lode.test.ts b/cli/test/steps/start-lode.test.ts new file mode 100644 index 0000000..09e8922 --- /dev/null +++ b/cli/test/steps/start-lode.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { extractAdminPassword } from '../../src/steps/start-lode' + +describe('extractAdminPassword', () => { + it('extracts the first unquoted password', () => { + const logs = 'Admin password: abc123\n' + expect(extractAdminPassword(logs)).toBe('abc123') + }) + + it('prefers the first match over later ones', () => { + const logs = 'Admin password: first\nAdmin password: second\n' + expect(extractAdminPassword(logs)).toBe('first') + }) + + it('skips quoted values and returns an empty string when nothing matches', () => { + expect(extractAdminPassword('Admin password: "quoted"\n')).toBe('') + expect(extractAdminPassword('no password here\n')).toBe('') + }) +}) diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..3e24082 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts new file mode 100644 index 0000000..b973d68 --- /dev/null +++ b/cli/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['test/**/*.test.ts'], + testTimeout: 30_000 + } +}) diff --git a/eslint.config.mjs b/eslint.config.mjs index 61e97bb..b2ee54f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -163,8 +163,8 @@ export default withNuxt( } }, - // Global ignores + // Global ignores (cli/ is a standalone project with its own eslint config) { - ignores: ['dist/**', '.output/**', '.nuxt/**', 'node_modules/**'] + ignores: ['dist/**', '.output/**', '.nuxt/**', 'node_modules/**', 'cli/**'] } ) diff --git a/setup.ps1 b/setup.ps1 index fdbdcd4..2bc5b2a 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -1,1698 +1,82 @@ -<# -.SYNOPSIS - Lode Auto-Setup Script for Windows -.DESCRIPTION - Sets up Lode with the services you choose. The Docker stack is split - into a base compose file (Lode + Redis) plus per-service overlays - (postgres, qbittorrent, prowlarr, jellyfin, flaresolverr, dozzle). - This script downloads the files that match your selection and starts - them with `docker compose -f -f ...`, so the compose - files stay small, readable, and usable manually. - - Your selection is saved in .lode-setup so re-runs can prefill - the prompts; .env keeps all secrets and URLs. -.EXAMPLE - .\setup.ps1 -#> - -$ErrorActionPreference = "Continue" - -# -- Keep window open on exit ---------------------------------------- -# When the script runs in a console window that closes when the script -# exits (double-click, Start Menu launch, Windows Terminal, VS Code, RDP), -# the window vanishes before the user can read the summary or any error. -# Walk the parent chain to detect that; set SETUP_PAUSE so Stop-Setup -# waits for Enter before exiting. - -function Test-TransientWindow { - $transient = @('explorer.exe', 'conhost.exe', 'openconsole.exe', 'WindowsTerminal.exe', 'Code.exe') - $passthrough = @('cmd.exe', 'conhost.exe', 'openconsole.exe') - try { - $id = $PID - for ($i = 0; $i -lt 6; $i++) { - $proc = Get-CimInstance Win32_Process -Filter "ProcessId = $id" - if (-not $proc -or -not $proc.ParentProcessId) { break } - $parent = Get-CimInstance Win32_Process -Filter "ProcessId = $($proc.ParentProcessId)" - if (-not $parent) { break } - if ($transient -contains $parent.Name) { return $true } - if ($passthrough -notcontains $parent.Name) { return $false } - $id = $proc.ParentProcessId - } - } catch { - # Process info unavailable - assume the window will close so the - # user keeps the output - return $true - } - return $false -} - -if (Test-TransientWindow) { - $env:SETUP_PAUSE = 1 -} - -# A file run (powershell -File, double-click) is its own process, so exit -# is safe. A piped run (irm | iex) executes inside the user's session, -# where exit would kill the whole terminal - always pause first so the -# result is readable. -$script:IsScriptFile = [bool]$MyInvocation.MyCommand.Path - -function Stop-Setup { - param([int]$Code = 0) - if ($env:SETUP_PAUSE) { - Write-Host "" - Read-Host "Press Enter to continue" | Out-Null - } - exit $Code -} - -# -- Constants -------------------------------------------------------- - -$REPO_RAW = "https://raw.githubusercontent.com/Nort1346/Lode/main" -$SETUP_URL = "$REPO_RAW/setup.ps1" -$SETUP_NEW = Join-Path $env:TEMP "setup.ps1.new" -$SETUP_SELF = $MyInvocation.MyCommand.Path - -$COMPOSE_BASE = "docker-compose.yml" -$script:STATE_FILE = ".lode-setup" -# Legacy state file name (pre-rename) - migrate it once on re-run. -if (-not (Test-Path $script:STATE_FILE) -and (Test-Path ".lode-setup.json")) { - Rename-Item ".lode-setup.json" ".lode-setup" -} -$script:COMPOSE_FILES = @() - -# Option labels (also used for --selected prefill, so keep them stable) -$QBIT_OPT_LOCAL = "Local qBittorrent container (recommended)" -$QBIT_OPT_EXTERNAL = "External qBittorrent (you host it)" -$PROWLARR_OPT_LOCAL = "Local Prowlarr container (recommended)" -$PROWLARR_OPT_EXTERNAL = "External Prowlarr (you host it)" -$MEDIA_OPT_JELLYFIN_LOCAL = "Jellyfin (local container)" -$MEDIA_OPT_JELLYFIN_EXTERNAL = "Jellyfin (external)" -$MEDIA_OPT_NONE = "No media server" -$ADDON_OPT_FLARESOLVERR = "FlareSolverr - CAPTCHA bypass for private trackers" -$ADDON_OPT_DOZZLE = "Dozzle - Docker log viewer" - -# Captured before .env is created in step 2: a pre-existing .env without -# a state file means the old all-or-nothing installer was used. -$script:LEGACY_INSTALL = $false -if ((Test-Path ".env") -and -not (Test-Path $script:STATE_FILE)) { - $script:LEGACY_INSTALL = $true -} - -# -- gum bootstrap ---------------------------------------------------- - -$script:HAS_GUM = $false - -function Test-GumUsable { - # gum prompts are TUIs: they only render in a real terminal and need - # v0.12.0+ (gum log). On any failure we fall back to plain output. - if (-not (Get-Command gum -ErrorAction SilentlyContinue)) { return $false } - if ([System.Console]::IsInputRedirected) { return $false } - if ([System.Console]::IsOutputRedirected) { return $false } - if ($env:NO_COLOR) { return $false } - if ($env:TERM -eq "dumb") { return $false } - try { - $verRaw = (gum --version 2>$null) -join " " - if ($verRaw -match '(\d+\.\d+\.\d+)') { - return ([version]$Matches[1]) -ge ([version]"0.12.0") - } - } catch { - # version check failed - } - return $false -} - -function Install-Gum { - if (Test-GumUsable) { - $script:HAS_GUM = $true - return - } - - Write-Host "Installing gum (charm) for beautiful output..." - - if (Get-Command winget -ErrorAction SilentlyContinue) { - winget install Charmbracelet.Gum --accept-source-agreements --accept-package-agreements 2>$null - } - elseif (Get-Command scoop -ErrorAction SilentlyContinue) { - scoop install charm-gum - } - else { - # Fallback: official release binary. gum publishes exact-version - # assets only (no "latest" alias, no glob), so resolve the tag - # first. Windows assets exist for x86_64 and i386 only - ARM64 - # runs the x86_64 binary through WoW64 emulation. - $arch = "x86_64" - try { - $tag = (Invoke-RestMethod -Uri "https://api.github.com/repos/charmbracelet/gum/releases/latest").tag_name - $url = "https://github.com/charmbracelet/gum/releases/download/$tag/gum_$($tag.TrimStart('v'))_Windows_${arch}.zip" - $tmp = Join-Path $env:TEMP "gum-download" - New-Item -ItemType Directory -Path $tmp -Force | Out-Null - $zip = Join-Path $tmp "gum.zip" - Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing - Expand-Archive -Path $zip -DestinationPath $tmp -Force - $gumExe = Get-ChildItem -Path $tmp -Filter "gum.exe" -Recurse | Select-Object -First 1 - if ($gumExe) { - $destDir = Join-Path $env:LOCALAPPDATA "Programs\gum" - New-Item -ItemType Directory -Path $destDir -Force | Out-Null - Copy-Item $gumExe.FullName (Join-Path $destDir "gum.exe") -Force - $env:Path = "$destDir;$env:Path" - } - } - catch { - Write-Host "Could not install gum automatically - continuing without it." - } - finally { - Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue - } - } - - # winget/scoop update the persistent PATH, but this process still - # holds the PATH from startup - merge the fresh machine+user values - # into the current session PATH (replacing it would drop entries - # added by the current shell). - $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') - $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') - $entries = @() - foreach ($entry in (($env:Path + ';' + $machinePath + ';' + $userPath) -split ';')) { - if ($entry -and $entries -notcontains $entry) { $entries += $entry } - } - $env:Path = $entries -join ';' - - $gumCmd = Get-Command gum -ErrorAction SilentlyContinue - if ($gumCmd) { - $script:HAS_GUM = Test-GumUsable - if (-not $script:HAS_GUM) { - Write-Host "gum cannot render prompts in this terminal - using plain output." -ForegroundColor Yellow - } - } -} - -Install-Gum - -# -- Hyperlinks (OSC 8) ------------------------------------------------ -# OSC 8 is emitted unconditionally for http(s) URLs. ECMA-48-compliant -# terminals ignore unknown OSC sequences. This is deliberately separate -# from SGR color support: NO_COLOR / TERM=dumb affect colors only. - -$script:VT_ENABLED = $false -$script:ConsoleNative = $null - -function Enable-VirtualTerminalProcessing { - if ($script:VT_ENABLED) { return $true } - - if ($env:OS -eq "Windows_NT") { - if (-not $script:ConsoleNative) { - try { - Add-Type -TypeDefinition @' -using System; -using System.Runtime.InteropServices; - -public static class LodeSetupConsoleNative -{ - [DllImport("kernel32.dll", SetLastError = true)] - public static extern IntPtr GetStdHandle(int nStdHandle); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode); -} -'@ -ErrorAction Stop - $script:ConsoleNative = [LodeSetupConsoleNative] - } catch { - return $false - } - } - - try { - $handle = $script:ConsoleNative::GetStdHandle(-11) - $mode = [uint32]0 - if ($script:ConsoleNative::GetConsoleMode($handle, [ref]$mode)) { - [void]$script:ConsoleNative::SetConsoleMode($handle, $mode -bor 0x4) - $script:VT_ENABLED = $true - } - } catch { - # Leave VT disabled; hyperlink output falls back to plain text. - } - } else { - $script:VT_ENABLED = $true - } - - return $script:VT_ENABLED -} - -function Get-Hyperlink { - param([string]$Url, [string]$Label = '') - $urlOnly = [string]::IsNullOrWhiteSpace($Label) -or $Label -eq $Url - if ($urlOnly) { $Label = $Url } - - if ($Url -match '^https?://') { - $esc = [char]27 - $bs = [char]92 - return "${esc}]8;;${Url}${esc}${bs}${Label}${esc}]8;;${esc}${bs}" - } - - if ($urlOnly) { return $Url } - return "${Label}: ${Url}" -} - -function Write-RawLine { - param([string]$Msg) - [void](Enable-VirtualTerminalProcessing) - [Console]::Out.Write("${Msg}`n") -} - -# -- Clipboard (OSC 52) ------------------------------------------------- -# OSC 52 is emitted unconditionally, like OSC 8. The same VirtualTerminal -# Processing flag used for OSC 8 is enough to pass the sequence to the -# terminal; the terminal itself decides whether clipboard writes are allowed. -# Some terminals disable OSC 52 by default for security; that's fine - the -# visible password is the fallback. - -function Set-ClipboardOsc52 { - param([string]$Text) - - if ([string]::IsNullOrWhiteSpace($Text)) { return } - - [void](Enable-VirtualTerminalProcessing) - $encoded = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Text)) - $esc = [char]27 - [Console]::Out.Write("${esc}]52;c;${encoded}${esc}\") - [Console]::Out.Flush() -} - -# -- Output helpers --------------------------------------------------- - -function Write-Info { - param([string]$Msg) - $esc = [char]27 - if ($Msg.Contains("${esc}]8")) { - Write-RawLine "[INFO] $Msg" - return - } - if ($script:HAS_GUM) { gum log --level info $Msg } else { Write-Host "[INFO] $Msg" -ForegroundColor Blue } -} - -function Write-Ok { - param([string]$Msg) - $esc = [char]27 - if ($Msg.Contains("${esc}]8")) { - Write-RawLine "[ OK ] $Msg" - return - } - if ($script:HAS_GUM) { gum log --level info $Msg } else { Write-Host "[ OK ] $Msg" -ForegroundColor Green } -} - -function Write-Warn { - param([string]$Msg) - $esc = [char]27 - if ($Msg.Contains("${esc}]8")) { - Write-RawLine "[WARN] $Msg" - return - } - if ($script:HAS_GUM) { gum log --level warn $Msg } else { Write-Host "[WARN] $Msg" -ForegroundColor Yellow } -} - -function Write-Err { - param([string]$Msg) - $esc = [char]27 - if ($Msg.Contains("${esc}]8")) { - Write-RawLine "[ERR ] $Msg" - return - } - if ($script:HAS_GUM) { gum log --level error $Msg } else { Write-Host "[ERR ] $Msg" -ForegroundColor Red } -} - -function Write-Header { - param([string]$Msg) - if ($script:HAS_GUM) { - Write-Host "" - gum style --bold $Msg - } else { - Write-Host "" - Write-Host $Msg -ForegroundColor White - Write-Host "" - } -} - -function Write-Step { - param([string]$Msg) - if ($script:HAS_GUM) { - Write-Host "" - gum style --bold --foreground cyan $Msg - } else { - Write-Host "" - Write-Host $Msg -ForegroundColor Cyan - } -} - -function Write-Dim { - param([string]$Msg) - $esc = [char]27 - if ($Msg.Contains("${esc}]8")) { - Write-RawLine $Msg - return - } - if ($script:HAS_GUM) { - gum style --foreground 14 $Msg - } else { - Write-Host $Msg -ForegroundColor DarkGray - } -} - -function Write-Callout { - param([string]$Title, [string]$Body) - $esc = [char]27 - if ($Body.Contains("${esc}]8")) { - Write-RawLine $Title - Write-RawLine $Body - return - } - if ($script:HAS_GUM) { - gum style --bold --foreground 11 $Title - gum style --foreground 14 $Body - } else { - Write-Host $Title -ForegroundColor Yellow - Write-Host $Body -ForegroundColor DarkGray - } -} - -function Write-SummarySection { - param([string]$Msg) - $esc = [char]27 - if ($Msg.Contains("${esc}]8")) { - Write-RawLine $Msg - return - } - if ($script:HAS_GUM) { - gum style --border normal --border-foreground 240 --padding "0 2" $Msg - } else { - Write-Host $Msg -ForegroundColor Gray - } -} - -function Get-SummaryRow { - param([string]$Label, [string]$Url) - return (" {0,-18} {1}" -f $Label, (Get-Hyperlink $Url)) -} - -# -- Helpers ---------------------------------------------------------- - -function New-SecretPassword { - param([int]$Length = 32) - $bytes = [byte[]]::new($Length) - $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() - $rng.GetBytes($bytes) - $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' - $result = -join ($bytes | ForEach-Object { $chars[$_ % $chars.Length] }) - return $result.Substring(0, $Length) -} - -function New-SecretHex { - param([int]$Bytes = 16) - $buf = [byte[]]::new($Bytes) - $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() - $rng.GetBytes($buf) - return ([System.BitConverter]::ToString($buf) -replace '-', '').ToLower() -} - -function Test-EnvMinLength { - param([string]$Key, [int]$MinLength) - $line = Select-String -Path ".env" -Pattern "^$([regex]::Escape($Key))=(.*)" -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($line) { - $value = $line.Matches[0].Groups[1].Value - return $value.Length -ge $MinLength - } - return $false -} - -function Read-EnvValue { - param([string]$Key) - $line = Select-String -Path ".env" -Pattern "^$([regex]::Escape($Key))=(.*)" -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($line) { return $line.Matches[0].Groups[1].Value } - return "" -} - -function Test-UrlValue { - param([string]$Url) - return ($Url -match '^https?://') -} - -# Returns the current .env value unless it is the internal (in-network) -# URL, which is not a useful default for an external instance. -function Get-ExternalUrlDefault { - param([string]$EnvKey, [string]$InternalUrl) - $v = Read-EnvValue $EnvKey - if ($v -and $v -ne $InternalUrl) { return $v } - return "" -} - -function Test-Port { - param([string]$Host_, [int]$Port, [int]$Timeout = 60, [int]$Interval = 2) - try { - $ips = [System.Net.Dns]::GetHostAddresses($Host_) - } - catch { - Write-Err "Could not resolve host: ${Host_}" - return $false - } - $maxAttempts = [Math]::Max(1, [Math]::Floor($Timeout / $Interval)) - for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { - $connected = $false - foreach ($ip in $ips) { - $client = $null - try { - $client = New-Object System.Net.Sockets.TcpClient - $ar = $client.BeginConnect($ip, $Port, $null, $null) - # Bound the wait: a refused port fails fast, a dropped one does not - if ($ar.AsyncWaitHandle.WaitOne(2000)) { - try { - $client.EndConnect($ar) - $connected = $client.Connected - } - catch { - # connection failed - } - } - } - catch { } - finally { - if ($client) { $client.Close() } - } - if ($connected) { break } - } - if ($connected) { return $true } - if ($attempt -lt $maxAttempts) { - Write-Dim "Waiting for ${Host_}:${Port}... ($attempt/$maxAttempts)" - Start-Sleep -Seconds $Interval - } - } - Write-Err "Timeout waiting for ${Host_}:${Port} after ${Timeout}s" - return $false -} - -function Update-EnvFile { - param([string]$Key, [string]$Value) - $envPath = ".env" - if (-not (Test-Path $envPath)) { return } - - $content = Get-Content $envPath -Raw - $pattern = "(?m)^$([regex]::Escape($Key))=.*" - # Escape $ so values like "a$b1" survive -replace (where $1 is a group ref) - $replacement = ("$Key=$Value").Replace('$', '$$') - - if ($content -match $pattern) { - $content = $content -replace $pattern, $replacement - } - elseif ($content -match "(?m)^#\s*$([regex]::Escape($Key))=.*") { - $content = $content -replace "(?m)^#\s*$([regex]::Escape($Key))=.*", $replacement - } - else { - $content = $content.TrimEnd() + "`n$Key=$Value`n" - } - - Set-Content -Path $envPath -Value $content -NoNewline -} - -# -- State file (selection memory for re-runs) ------------------------ -# Flat key=value file - no JSON parser needed in PowerShell or bash. -# Only selection state lives here; secrets and URLs stay in .env. - -function Get-StateValue { - param([string]$Key) - if (-not (Test-Path $script:STATE_FILE)) { return $null } - $line = Select-String -Path $script:STATE_FILE -Pattern "^$([regex]::Escape($Key))=(.*)" -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($line) { return $line.Matches[0].Groups[1].Value } - return $null -} - -function Write-StateFile { - $lines = @( - "version=1", - "dbDriver=$DB_DRIVER_CHOICE", - "imageTag=$LODE_TAG", - "qbittorrent=$QBIT_MODE", - "prowlarr=$PROWLARR_MODE", - "mediaProvider=$MEDIA_PROVIDER", - "mediaMode=$MEDIA_MODE", - "flaresolverr=$(if ($USE_FLARESOLVERR) { 'true' } else { 'false' })", - "dozzle=$(if ($USE_DOZZLE) { 'true' } else { 'false' })" - ) - # Temp file + rename: an interrupted run (Ctrl+C, kill, closed terminal) can never leave a corrupt state file behind. - $tmp = "$($script:STATE_FILE).tmp" - try { - Set-Content -Path $tmp -Value (($lines -join "`n") + "`n") -NoNewline - Move-Item -Path $tmp -Destination $script:STATE_FILE -Force - } finally { - Remove-Item $tmp -Force -ErrorAction SilentlyContinue - } -} - -# -- Compose file helpers --------------------------------------------- - -# Every docker compose call takes the -f file list built in the -# selection step, so it stays in one place. -function Get-DcFileArgs { - if (-not $script:COMPOSE_FILES.Count) { - Write-Err "No compose files selected - internal error." - Stop-Setup 1 - } - $args_ = @() - foreach ($f in $script:COMPOSE_FILES) { $args_ += @('-f', $f) } - return $args_ -} - -function Get-DcCommandPrefix { - $parts = @() - foreach ($f in $script:COMPOSE_FILES) { $parts += "-f $f" } - return "docker compose " + ($parts -join " ") -} - -# Pre-split compose files defined every service inline. The new postgres -# overlay only defines postgres, so a qbittorrent service block marks a -# file as a legacy monolith. -function Test-LegacyMonolith { - param([string]$Path) - if (-not (Test-Path $Path)) { return $false } - $content = Get-Content $Path -Raw - return ($content -match '(?m)^ qbittorrent:\s*$') -} - -# On re-runs, a service that used to be deployed locally but is no longer -# selected leaves a stopped container behind (we never use -# --remove-orphans). Confirm, then stop and remove the container; its -# volume is kept so switching back keeps all data. -function Remove-DeselectedService { - param([string]$Service, [string]$StateKey, [string]$ActiveValue, [string]$CurrentValue) - $oldValue = Get-StateValue $StateKey - if ($oldValue -ne $ActiveValue) { return } - if ($CurrentValue -eq $ActiveValue) { return } - $container = "lode-$Service" - $running = docker ps -q --filter ('name=^' + $container + '$') 2>$null - if (-not $running) { return } - $keep = $true - $msg = "$container is no longer selected. Stop and remove the container? (its volume is kept)" - if ($script:HAS_GUM) { - gum confirm --default=true $msg | Out-Null - if ($LASTEXITCODE -ne 0) { $keep = $false } - } else { - $answer = Read-Host "$msg [Y/n]" - if ($answer -match '^[Nn]') { $keep = $false } - } - if ($keep) { - docker stop $container 2>$null | Out-Null - docker rm $container 2>$null | Out-Null - Write-Ok "Removed $container (volume kept)" - } else { - Write-Warn "Keeping $container - remove it manually with: docker rm $container" - } -} - -# -- Prompt helpers (gum-aware) ---------------------------------------- - -function Read-GumInput { - param([string]$Placeholder) - if ($script:HAS_GUM) { - return gum input --placeholder $Placeholder - } - return Read-Host $Placeholder -} - -function Read-GumInputDefault { - param([string]$Placeholder, [string]$Default) - if ($script:HAS_GUM) { - if ($Default) { - return gum input --value $Default --placeholder $Placeholder - } - return gum input --placeholder $Placeholder - } - $prompt = $Placeholder - if ($Default) { $prompt = "$Placeholder [$Default]" } - $result = Read-Host $prompt - if (-not $result) { return $Default } - return $result -} - -function ConvertFrom-SecureStringPlaintext { - param([System.Security.SecureString]$Secure) - $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) - try { - return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) - } finally { - [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) - } -} - -# Masked input for API keys (gum --password, SecureString fallback). -function Read-GumPassword { - param([string]$Placeholder) - if ($script:HAS_GUM) { - return gum input --password --placeholder $Placeholder - } - $secure = Read-Host $Placeholder -AsSecureString - return ConvertFrom-SecureStringPlaintext $secure -} - -function Select-GumMenu { - param([string]$Prompt, [string[]]$Options, [string]$Preselect = "") - if ($script:HAS_GUM) { - $gumArgs = @('choose', '--header', $Prompt) - if ($Preselect -and ($Options -contains $Preselect)) { - $gumArgs += @('--selected', $Preselect) - } - return gum @gumArgs $Options - } - # Plain numbered menu; the preselected option is the default answer. - $default = 1 - for ($i = 0; $i -lt $Options.Count; $i++) { - if ($Preselect -and $Options[$i] -eq $Preselect) { $default = $i + 1; break } - } - Write-Host $Prompt - for ($i = 0; $i -lt $Options.Count; $i++) { - Write-Host " $($i + 1)) $($Options[$i])" - } - while ($true) { - $choice = Read-Host "Enter choice [1-$($Options.Count)] (default $default)" - if (-not $choice) { $choice = "$default" } - if ($choice -match '^\d+$') { - $num = [int]$choice - 1 - if ($num -ge 0 -and $num -lt $Options.Count) { - return $Options[$num] - } - } - Write-Host " Invalid choice. Please enter a number between 1 and $($Options.Count)." -ForegroundColor Yellow - } -} - -function Select-GumAddons { - param([string[]]$Options, [string[]]$Preselected = @()) - if ($script:HAS_GUM) { - $gumArgs = @('choose', '--no-limit', '--header', 'Select optional add-ons:') - foreach ($sel in $Preselected) { - if ($Options -contains $sel) { $gumArgs += @('--selected', $sel) } - } - return @(gum @gumArgs $Options) - } - # Plain: numbered comma input; Enter keeps the preselection. - $preSel = "" - for ($i = 0; $i -lt $Options.Count; $i++) { - if ($Preselected -contains $Options[$i]) { $preSel += "$($i + 1)," } - } - $preSel = $preSel.TrimEnd(',') - if ($preSel) { $defaultNote = "keep current ($preSel)" } else { $defaultNote = "none" } - Write-Host "Select optional add-ons (numbers separated by commas, e.g. 1,2 - Enter for $defaultNote):" - for ($i = 0; $i -lt $Options.Count; $i++) { - Write-Host " $($i + 1)) $($Options[$i])" - } - $result = @() - $input = Read-Host "Add-ons" - if (-not $input) { - $result = @($Preselected) - } else { - foreach ($part in ($input -split ',')) { - $n = $part.Trim() - if ($n -match '^\d+$') { - $num = [int]$n - if ($num -ge 1 -and $num -le $Options.Count) { - $result += $Options[$num - 1] - } else { - Write-Host " Invalid selection: $n" -ForegroundColor Yellow - } - } elseif ($n) { - Write-Host " Invalid selection: $n" -ForegroundColor Yellow - } - } - } - return $result -} - -# -- Self-update check -------------------------------------------------- - -try { - Invoke-WebRequest -Uri $SETUP_URL -OutFile $SETUP_NEW -UseBasicParsing 2>$null - # $LASTEXITCODE is not set by cmdlets like Invoke-WebRequest, and - # $SETUP_SELF is empty when the script is piped via irm | iex. - if ($SETUP_SELF -and (Test-Path $SETUP_NEW)) { - $currentHash = (Get-FileHash $SETUP_SELF -Algorithm SHA256).Hash - $newHash = (Get-FileHash $SETUP_NEW -Algorithm SHA256).Hash - if ($currentHash -ne $newHash) { - Write-Host "" - if ($script:HAS_GUM) { - gum style --foreground 11 --bold "A newer version of setup.ps1 is available." - } else { - Write-Host " A newer version of setup.ps1 is available." -ForegroundColor Yellow - } - Write-Host "" - if ($script:HAS_GUM) { - gum confirm --default=false "Update setup.ps1 and restart?" - if ($LASTEXITCODE -eq 0) { - Copy-Item $SETUP_SELF "$SETUP_SELF.bak" -Force - Copy-Item $SETUP_NEW "$SETUP_SELF.tmp" -Force - Move-Item -Path "$SETUP_SELF.tmp" -Destination $SETUP_SELF -Force - Write-Ok "Updated setup.ps1. Restarting..." - & $SETUP_SELF - exit $LASTEXITCODE - } - } else { - $answer = Read-Host "Update setup.ps1 and restart? (y/N)" - if ($answer -match '^[Yy]$') { - Copy-Item $SETUP_SELF "$SETUP_SELF.bak" -Force - Copy-Item $SETUP_NEW "$SETUP_SELF.tmp" -Force - Move-Item -Path "$SETUP_SELF.tmp" -Destination $SETUP_SELF -Force - Write-Ok "Updated setup.ps1. Restarting..." - & $SETUP_SELF - exit $LASTEXITCODE - } - } - Write-Warn "Continuing with current version..." - } - } -} catch { - # Silently continue if update check fails -} finally { - if ($script:IsScriptFile -and (Test-Path $SETUP_NEW)) { Remove-Item $SETUP_NEW -Force -ErrorAction SilentlyContinue } - Remove-Item "$SETUP_SELF.tmp" -Force -ErrorAction SilentlyContinue -} - -# -- Re-exec as child process (piped mode safety) ---------------------- -# When run via 'irm | iex', the script executes in the user's current -# PowerShell session. Calling 'exit' anywhere (via Stop-Setup) would -# close that session. Re-launch as a real file process so exit is safe. - -if (-not $script:IsScriptFile) { - Write-Host "" - Write-Warn "Running via 'irm | iex' - restarting as a standalone process so this terminal isn't affected." - $childScript = Join-Path $env:TEMP "lode-setup-run.ps1" - try { - if (Test-Path $SETUP_NEW) { - Copy-Item $SETUP_NEW $childScript -Force - } else { - Invoke-WebRequest -Uri $SETUP_URL -OutFile $childScript -UseBasicParsing - } - } catch { - Write-Err "Could not download setup.ps1 to run as a standalone process. Check your connection and try again." - return - } - & powershell -NoProfile -ExecutionPolicy Bypass -File $childScript - Remove-Item $childScript -Force -ErrorAction SilentlyContinue - if (Test-Path $SETUP_NEW) { Remove-Item $SETUP_NEW -Force -ErrorAction SilentlyContinue } - return -} - -# -- Banner ----------------------------------------------------------- - -Write-Header "Lode Auto-Setup v1.1" - -Write-Host "" -Write-Dim "This will set up Lode and the services you choose." -Write-Dim "All data will be stored in Docker volumes." -Write-Host "" - -if ($script:HAS_GUM) { - gum confirm --default=false "Do you want to continue?" | Out-Null - if ($LASTEXITCODE -ne 0) { - Write-Host "Aborted." -ForegroundColor Yellow - Stop-Setup 0 - } -} else { - $confirm = Read-Host "Do you want to continue? (y/N)" - if ($confirm -notmatch '^[yY]') { - Write-Host "Aborted." -ForegroundColor Yellow - Stop-Setup 0 - } -} - -# -- 1. Prerequisites ------------------------------------------------- - -Write-Step "[1/15] Checking prerequisites" - -if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { - Write-Err "Docker is not installed." - Write-Host " Install Docker Desktop for Windows:" -ForegroundColor Yellow - Write-RawLine " $(Get-Hyperlink 'https://docs.docker.com/desktop/windows-install/')" - Stop-Setup 1 -} -Write-Ok "Docker $((docker --version) -replace '.*version ([^ ,]+).*', '$1')" - -$dockerInfo = docker info 2>&1 | ForEach-Object { $_.ToString() } -if ($LASTEXITCODE -ne 0) { - Write-Err "Docker daemon is not running or not reachable from this shell." - # docker info prints the whole client section before the failure - - # show only the error lines (fall back to the last lines). - $dockerErrLines = @($dockerInfo | Where-Object { $_ -match 'failed to connect|cannot connect|permission denied|connection refused|cannot find the file' } | Select-Object -First 3) - if (-not $dockerErrLines) { $dockerErrLines = @($dockerInfo | Select-Object -Last 3) } - foreach ($line in $dockerErrLines) { Write-Host " $line" } - Write-Host " Start Docker Desktop (WSL2 backend) and try again." -ForegroundColor Yellow - Stop-Setup 1 -} -Write-Ok "Docker daemon running" - -try { - $composeVersion = docker compose version 2>&1 | Out-Null - if ($LASTEXITCODE -ne 0) { throw "Docker Compose not found" } -} catch { - Write-Err "Docker Compose plugin is not installed." - Write-Host " Install or update Docker Desktop (includes Compose):" -ForegroundColor Yellow - Write-RawLine " $(Get-Hyperlink 'https://docs.docker.com/get-docker/')" - Stop-Setup 1 -} -Write-Ok "Docker Compose available" - -if (-not (Get-Command curl -ErrorAction SilentlyContinue)) { - Write-Err "curl is not installed." - Write-Host " On Windows 10+ curl is built-in." -ForegroundColor Yellow - Write-RawLine " If missing, download from: $(Get-Hyperlink 'https://curl.se/windows/')" - Stop-Setup 1 -} -Write-Ok "curl available" - -# -- 2. Create .env --------------------------------------------------- - -Write-Step "[2/15] Setting up .env file" - -if (-not (Test-Path .env)) { - if (-not (Test-Path .env.example)) { - Write-Info "Downloading .env.example from GitHub..." - try { - Invoke-WebRequest -Uri "$REPO_RAW/.env.example" -OutFile ".env.example.tmp" -UseBasicParsing 2>$null - Move-Item -Path ".env.example.tmp" -Destination ".env.example" -Force - } catch { - Write-Err "Failed to download .env.example" - Stop-Setup 1 - } finally { - Remove-Item ".env.example.tmp" -Force -ErrorAction SilentlyContinue - } - } - try { - Copy-Item .env.example ".env.tmp" - Move-Item -Path ".env.tmp" -Destination ".env" -Force - } finally { - Remove-Item ".env.tmp" -Force -ErrorAction SilentlyContinue - } - Write-Ok "Created .env from .env.example" -} else { - Write-Warn ".env exists - keeping existing config" -} - -New-Item -ItemType Directory -Path "media/Movies" -Force | Out-Null -New-Item -ItemType Directory -Path "media/Series" -Force | Out-Null -Write-Ok "Created media directories (media/Movies, media/Series)" - -# -- 3. Detect existing setup ------------------------------------------ - -Write-Step "[3/15] Detecting existing setup" - -$stateFileFound = Test-Path $script:STATE_FILE -if ($stateFileFound) { - Write-Info "Existing setup found ($($script:STATE_FILE)):" - Write-Dim " Database: $(Get-StateValue 'dbDriver')" - Write-Dim " qBittorrent: $(Get-StateValue 'qbittorrent')" - Write-Dim " Prowlarr: $(Get-StateValue 'prowlarr')" - Write-Dim " Media: $(Get-StateValue 'mediaProvider') ($(Get-StateValue 'mediaMode'))" - Write-Dim " Add-ons: FlareSolverr=$(Get-StateValue 'flaresolverr') Dozzle=$(Get-StateValue 'dozzle')" - Write-Host "" - $reconf = $true - if ($script:HAS_GUM) { - gum confirm --default=true "Reconfigure your existing Lode setup?" | Out-Null - $reconf = ($LASTEXITCODE -eq 0) - } else { - $answer = Read-Host "Reconfigure your existing Lode setup? [Y/n]" - $reconf = -not ($answer -match '^[Nn]') - } - if (-not $reconf) { - Write-Ok "Keeping existing setup - no changes made." - Stop-Setup 0 - } -} - -# Legacy installs (old all-or-nothing installer) deployed the full -# stack, so preselect everything local including Dozzle. -if ($script:LEGACY_INSTALL) { - Write-Info "Existing .env without $($script:STATE_FILE) - assuming a previous full-stack install." -} - -# Migrate legacy monolith compose files (they predate the split). -if (Test-LegacyMonolith "docker-compose.sqlite.yml") { - Move-Item "docker-compose.sqlite.yml" "docker-compose.sqlite.yml.legacy" -Force - Write-Warn "Moved legacy docker-compose.sqlite.yml to docker-compose.sqlite.yml.legacy" -} -if (Test-LegacyMonolith "docker-compose.postgres.yml") { - Move-Item "docker-compose.postgres.yml" "docker-compose.postgres.yml.legacy" -Force - Write-Warn "Moved legacy docker-compose.postgres.yml to docker-compose.postgres.yml.legacy" -} -Write-Ok "Setup detection complete" - -# -- 4. Generate secrets (idempotent) ---------------------------------- -# Secrets are only generated when missing or too short, so re-running -# the script never invalidates existing sessions or encrypted data. - -Write-Step "[4/15] Generating secrets" - -if (Test-EnvMinLength -Key "NUXT_SESSION_PASSWORD" -MinLength 32) { - Write-Ok "Session password already set - keeping it" -} else { - Update-EnvFile "NUXT_SESSION_PASSWORD" (New-SecretPassword -Length 32) - Write-Ok "Session password generated" -} - -if (Test-EnvMinLength -Key "NUXT_TRACKER_ENCRYPTION_KEY" -MinLength 32) { - Write-Ok "Tracker encryption key already set - keeping it" -} else { - Update-EnvFile "NUXT_TRACKER_ENCRYPTION_KEY" (New-SecretHex -Bytes 32) - Write-Ok "Tracker encryption key generated" -} - -# -- 5. Database driver choice ---------------------------------------- - -Write-Step "[5/15] Database driver" - -$DB_DRIVER_CHOICE = "sqlite" - -$dbChoicePre = "" -if ($stateFileFound) { - switch (Get-StateValue 'dbDriver') { - 'postgres' { $dbChoicePre = "PostgreSQL" } - 'sqlite' { $dbChoicePre = "SQLite (recommended)" } - } -} else { - $existingDbDriver = Read-EnvValue "DB_DRIVER" - if ($existingDbDriver -eq "postgres") { - $dbChoicePre = "PostgreSQL" - } elseif ($existingDbDriver -and $existingDbDriver -ne "sqlite") { - Write-Info "Existing database driver: $existingDbDriver" - } -} - -if (-not $script:HAS_GUM) { - Write-Host "" - Write-Host "Choose your database driver:" -ForegroundColor White - Write-Dim " SQLite - Zero config, file-based, recommended for most users" - Write-Dim " PostgreSQL - Full-featured, requires more resources" - Write-Host "" -} - -$dbChoice = Select-GumMenu -Prompt "Select database driver:" -Options @("SQLite (recommended)", "PostgreSQL") -Preselect $dbChoicePre - -if ($dbChoice -match "PostgreSQL") { - $DB_DRIVER_CHOICE = "postgres" -} else { - $DB_DRIVER_CHOICE = "sqlite" -} - -Write-Ok "Database driver: $DB_DRIVER_CHOICE" - -if ($DB_DRIVER_CHOICE -eq "postgres") { - if (Test-EnvMinLength -Key "POSTGRES_PASSWORD" -MinLength 32) { - Write-Ok "PostgreSQL password already set - keeping it" - } else { - Update-EnvFile "POSTGRES_PASSWORD" (New-SecretPassword -Length 32) - Write-Ok "PostgreSQL password generated" - } -} - -# -- 6. Component selection --------------------------------------------- - -Write-Step "[6/15] Selecting components" - -Write-Host "" -Write-Dim "Lode and Redis are always deployed. Choose the rest:" -Write-Host "" - -# Preselect: state file (re-run) > legacy full-stack install > defaults. -$qbitPre = $QBIT_OPT_LOCAL -$prowlarrPre = $PROWLARR_OPT_LOCAL -$mediaPre = $MEDIA_OPT_JELLYFIN_LOCAL -$USE_FLARESOLVERR = $false -$USE_DOZZLE = $false - -if ($stateFileFound) { - switch (Get-StateValue 'qbittorrent') { - 'external' { $qbitPre = $QBIT_OPT_EXTERNAL } - } - switch (Get-StateValue 'prowlarr') { - 'external' { $prowlarrPre = $PROWLARR_OPT_EXTERNAL } - } - switch (Get-StateValue 'mediaMode') { - 'external' { $mediaPre = $MEDIA_OPT_JELLYFIN_EXTERNAL } - 'none' { $mediaPre = $MEDIA_OPT_NONE } - } - $USE_FLARESOLVERR = ((Get-StateValue 'flaresolverr') -eq 'true') - $USE_DOZZLE = ((Get-StateValue 'dozzle') -eq 'true') -} elseif ($script:LEGACY_INSTALL) { - # The old installer ran Dozzle as part of the full stack. - $USE_DOZZLE = $true -} - -# 6a. Torrent client -$qbitChoice = Select-GumMenu -Prompt "How should Lode download torrents?" -Options @($QBIT_OPT_LOCAL, $QBIT_OPT_EXTERNAL) -Preselect $qbitPre -if ($qbitChoice -match "External qBittorrent") { - $QBIT_MODE = "external" - $QBIT_URL = Read-GumInputDefault "External qBittorrent URL (http://host:8080)" (Get-ExternalUrlDefault "NUXT_QBITTORRENT_URL" "http://qbittorrent:8080") - if (-not (Test-UrlValue $QBIT_URL)) { - Write-Warn "External qBittorrent URL does not start with http(s):// - Lode will not be able to reach it" - } -} else { - $QBIT_MODE = "local" - $QBIT_URL = "" -} -Write-Ok "qBittorrent: $QBIT_MODE" - -# 6b. Indexer -$prowlarrChoice = Select-GumMenu -Prompt "How should Lode index torrents?" -Options @($PROWLARR_OPT_LOCAL, $PROWLARR_OPT_EXTERNAL) -Preselect $prowlarrPre -if ($prowlarrChoice -match "External Prowlarr") { - $PROWLARR_MODE = "external" - $PROWLARR_URL = Read-GumInputDefault "External Prowlarr URL (http://host:9696)" (Get-ExternalUrlDefault "NUXT_PROWLARR_URL" "http://prowlarr:9696") -} else { - $PROWLARR_MODE = "local" - $PROWLARR_URL = "" -} -if ($PROWLARR_MODE -eq "external" -and -not (Test-UrlValue $PROWLARR_URL)) { - Write-Warn "External Prowlarr URL does not start with http(s):// - Lode will not be able to reach it" -} -Write-Ok "Prowlarr: $PROWLARR_MODE" - -# 6c. Media server -$mediaChoice = Select-GumMenu -Prompt "Media server (library detection)?" -Options @($MEDIA_OPT_JELLYFIN_LOCAL, $MEDIA_OPT_JELLYFIN_EXTERNAL, $MEDIA_OPT_NONE) -Preselect $mediaPre -switch -regex ($mediaChoice) { - 'Jellyfin \(external\)' { - $MEDIA_PROVIDER = "jellyfin" - $MEDIA_MODE = "external" - $JELLYFIN_URL = Read-GumInputDefault "External Jellyfin URL (http://host:8096)" (Get-ExternalUrlDefault "NUXT_JELLYFIN_URL" "http://jellyfin:8096") - } - 'No media server' { - $MEDIA_PROVIDER = "none" - $MEDIA_MODE = "none" - $JELLYFIN_URL = "" - } - default { - $MEDIA_PROVIDER = "jellyfin" - $MEDIA_MODE = "local" - $JELLYFIN_URL = "" - } -} -if ($MEDIA_MODE -eq "external" -and -not (Test-UrlValue $JELLYFIN_URL)) { - Write-Warn "External Jellyfin URL does not start with http(s):// - Lode will not be able to reach it" -} -Write-Ok "Media server: $MEDIA_PROVIDER ($MEDIA_MODE)" - -# 6d. Add-ons (multi-select) -$preselectedAddons = @() -if ($USE_FLARESOLVERR) { $preselectedAddons += $ADDON_OPT_FLARESOLVERR } -if ($USE_DOZZLE) { $preselectedAddons += $ADDON_OPT_DOZZLE } -$chosenAddons = @(Select-GumAddons -Options @($ADDON_OPT_FLARESOLVERR, $ADDON_OPT_DOZZLE) -Preselected $preselectedAddons) -$USE_FLARESOLVERR = ($chosenAddons -contains $ADDON_OPT_FLARESOLVERR) -$USE_DOZZLE = ($chosenAddons -contains $ADDON_OPT_DOZZLE) -Write-Ok "Add-ons: FlareSolverr=$(if ($USE_FLARESOLVERR) { 'true' } else { 'false' }) Dozzle=$(if ($USE_DOZZLE) { 'true' } else { 'false' })" - -# Build the compose file list for the selected stack. -$script:COMPOSE_FILES = @($COMPOSE_BASE) -if ($DB_DRIVER_CHOICE -eq "postgres") { $script:COMPOSE_FILES += "docker-compose.postgres.yml" } -if ($QBIT_MODE -eq "local") { $script:COMPOSE_FILES += "docker-compose.qbittorrent.yml" } -if ($PROWLARR_MODE -eq "local") { $script:COMPOSE_FILES += "docker-compose.prowlarr.yml" } -if ($MEDIA_MODE -eq "local") { $script:COMPOSE_FILES += "docker-compose.jellyfin.yml" } -if ($USE_FLARESOLVERR) { $script:COMPOSE_FILES += "docker-compose.flaresolverr.yml" } -if ($USE_DOZZLE) { $script:COMPOSE_FILES += "docker-compose.dozzle.yml" } - -# -- 7. Download compose files ------------------------------------------ -# The lode image tag is written by the version step below - mask it -# when comparing, so tag-only differences never trigger a replace prompt. - -function Get-ComposeTagMasked { - param([string]$Path) - (Get-Content $Path) -replace '(?m)^(\s*(#\s*)?)(image:\s*ghcr\.io/nort1346/lode:)\S+$', '$1$3' -} - -Write-Step "[7/15] Downloading compose files" - -# Split into missing files (downloaded directly, no prompt) and files -# that already exist (one prompt for the whole group). -# Downloads land in a temp file first so an interrupted run never leaves a truncated compose file behind. -$newFiles = @() -$existingFiles = @() -foreach ($composeFile in $script:COMPOSE_FILES) { - if (Test-Path $composeFile) { $existingFiles += $composeFile } else { $newFiles += $composeFile } -} - -foreach ($composeFile in $newFiles) { - Write-Info "Downloading $composeFile..." - try { - Invoke-WebRequest -Uri "$REPO_RAW/$composeFile" -OutFile "$composeFile.tmp" -UseBasicParsing - Move-Item -Path "$composeFile.tmp" -Destination $composeFile -Force - } catch { - Write-Err "Failed to download $composeFile from GitHub." - Write-Host " Check your internet connection and try again." -ForegroundColor Yellow - Stop-Setup 1 - } finally { - Remove-Item "$composeFile.tmp" -Force -ErrorAction SilentlyContinue - } - Write-Ok "$composeFile downloaded" -} - -if ($existingFiles.Count -gt 0) { - $doUpdate = $false - if ($script:HAS_GUM) { - gum confirm --default=false "$($existingFiles.Count) compose file(s) already exist. Download the latest versions from GitHub? (changed files keep a .bak backup)" | Out-Null - $doUpdate = ($LASTEXITCODE -eq 0) - } else { - $answer = Read-Host "$($existingFiles.Count) compose file(s) already exist. Download the latest versions from GitHub? [y/N]" - $doUpdate = ($answer -match '^[Yy]$') - } - foreach ($composeFile in $existingFiles) { - if ($doUpdate) { - try { - Invoke-WebRequest -Uri "$REPO_RAW/$composeFile" -OutFile "$composeFile.tmp" -UseBasicParsing 2>$null - $composeDiff = Compare-Object (Get-ComposeTagMasked $composeFile) (Get-ComposeTagMasked "$composeFile.tmp") - if ($composeDiff) { - Copy-Item $composeFile "$composeFile.bak" -Force - Move-Item -Path "$composeFile.tmp" -Destination $composeFile -Force - Write-Ok "$composeFile updated (backup saved as $composeFile.bak)" - } else { - Write-Ok "$composeFile is already up to date" - } - } catch { - Write-Warn "Could not download $composeFile - keeping your local copy" - } finally { - Remove-Item "$composeFile.tmp" -Force -ErrorAction SilentlyContinue - } - } else { - Write-Ok "Using existing $composeFile" - } - } -} - -# -- 8. Lode version choice -------------------------------------- -# The version choice is the single source of truth for the image tag: -# it is written into the base compose file here, after the download step. - -Write-Step "[8/15] Lode version" - -$LODE_TAG = "latest" - -$versionPre = "latest (recommended)" -if ($stateFileFound -and (Get-StateValue 'imageTag') -eq "nightly") { - $versionPre = "nightly" -} - -if (-not $script:HAS_GUM) { - Write-Host "" - Write-Host "Which Lode image do you want to use?" -ForegroundColor White - Write-Dim " latest - Stable release (recommended)" - Write-Dim " nightly - Latest dev build from main (may be unstable)" - Write-Host "" -} - -$versionChoice = Select-GumMenu -Prompt "Select version:" -Options @("latest (recommended)", "nightly") -Preselect $versionPre - -if ($versionChoice -match "nightly") { - $LODE_TAG = "nightly" -} else { - $LODE_TAG = "latest" -} - -$composeContent = Get-Content $COMPOSE_BASE -Raw -if ($composeContent -match '(?m)^\s*image:\s*ghcr.io/nort1346/lode:') { - $composeContent = $composeContent -replace '(?m)^(\s*image:\s*ghcr\.io/nort1346/lode:)\S+', ('$1' + $LODE_TAG) - Set-Content -Path $COMPOSE_BASE -Value $composeContent -NoNewline - Write-Ok "Lode version: $LODE_TAG (image: ghcr.io/nort1346/lode:$LODE_TAG)" -} else { - Write-Warn "No lode image line found in $COMPOSE_BASE - check the image tag manually" -} - -# Remember the selection so the next run can prefill the prompts. -Write-StateFile -Write-Ok "Selection saved to $($script:STATE_FILE)" - -# -- 9. Pull and start selected services ------------------------------ - -Write-Step "[9/15] Starting selected services" - -$dcArgs = Get-DcFileArgs - -# Validate the merged configuration before touching the daemon. -$null = docker compose $dcArgs config -q 2>$null -if ($LASTEXITCODE -ne 0) { - Write-Err "The selected compose files do not merge correctly." - Write-Err "Files: $($script:COMPOSE_FILES -join ' ')" - Stop-Setup 1 -} - -# Remove containers for services that are no longer selected locally. -if ($stateFileFound) { - $fsState = if ($USE_FLARESOLVERR) { "true" } else { "false" } - $dozzleState = if ($USE_DOZZLE) { "true" } else { "false" } - Remove-DeselectedService -Service "qbittorrent" -StateKey "qbittorrent" -ActiveValue "local" -CurrentValue $QBIT_MODE - Remove-DeselectedService -Service "prowlarr" -StateKey "prowlarr" -ActiveValue "local" -CurrentValue $PROWLARR_MODE - Remove-DeselectedService -Service "jellyfin" -StateKey "mediaMode" -ActiveValue "local" -CurrentValue $MEDIA_MODE - Remove-DeselectedService -Service "flaresolverr" -StateKey "flaresolverr" -ActiveValue "true" -CurrentValue $fsState - Remove-DeselectedService -Service "dozzle" -StateKey "dozzle" -ActiveValue "true" -CurrentValue $dozzleState - Remove-DeselectedService -Service "postgres" -StateKey "dbDriver" -ActiveValue "postgres" -CurrentValue $DB_DRIVER_CHOICE -} - -$INFRA_SERVICES = @("redis") -if ($DB_DRIVER_CHOICE -eq "postgres") { $INFRA_SERVICES += "postgres" } -if ($QBIT_MODE -eq "local") { $INFRA_SERVICES += "qbittorrent" } -if ($PROWLARR_MODE -eq "local") { $INFRA_SERVICES += "prowlarr" } -if ($MEDIA_MODE -eq "local") { $INFRA_SERVICES += "jellyfin" } -if ($USE_FLARESOLVERR) { $INFRA_SERVICES += "flaresolverr" } -if ($USE_DOZZLE) { $INFRA_SERVICES += "dozzle" } - -Write-Info "Pulling images..." -docker compose $dcArgs pull - -if (-not (docker image inspect "ghcr.io/nort1346/lode:$LODE_TAG" 2>$null)) { - Write-Err "Failed to pull the Lode image (ghcr.io/nort1346/lode:$LODE_TAG). Check your network and try again." - Write-Err "You can also try manually: $(Get-DcCommandPrefix) pull lode" - Stop-Setup 1 -} - -docker compose $dcArgs up -d $INFRA_SERVICES - -# `ps -q` is stable across Compose versions (the JSON output format -# changed in Compose 2.21, so it is not used for state checks). -$failedServices = @() -foreach ($svc in $INFRA_SERVICES) { - $svcId = docker compose $dcArgs ps -q $svc 2>$null - if (-not $svcId) { - $failedServices += $svc - $lastLog = docker compose $dcArgs logs $svc --tail 3 2>&1 | Select-Object -Last 1 - Write-Warn "$svc failed to start: $lastLog" - } -} - -if ($failedServices -contains 'redis') { - Write-Err "Redis failed to start. Cannot continue." - Write-Host " Check logs: $(Get-DcCommandPrefix) logs redis" -ForegroundColor Yellow - Stop-Setup 1 -} -if ($DB_DRIVER_CHOICE -eq "postgres" -and $failedServices -contains 'postgres') { - Write-Err "PostgreSQL failed to start. Cannot continue." - Write-Host " Check logs: $(Get-DcCommandPrefix) logs postgres" -ForegroundColor Yellow - Stop-Setup 1 -} -if ($QBIT_MODE -eq "local" -and $failedServices -contains 'qbittorrent') { - Write-Err "qBittorrent failed to start. Cannot continue." - Write-Host " Check logs: $(Get-DcCommandPrefix) logs qbittorrent" -ForegroundColor Yellow - Stop-Setup 1 -} - -Write-Info "Waiting for Redis..." -Test-Port -Host_ "localhost" -Port 6379 -Timeout 30 | Out-Null - -if ($QBIT_MODE -eq "local") { - Write-Info "Waiting for qBittorrent..." - Test-Port -Host_ "localhost" -Port 8080 -Timeout 60 | Out-Null -} - -Start-Sleep -Seconds 3 - -$QBIT_TEMP_PASS = $null -if ($QBIT_MODE -eq "local") { - $QBIT_TEMP_PASS = docker compose $dcArgs logs qbittorrent 2>&1 | - Select-String 'A temporary password is provided for this session:' | - ForEach-Object { ($_ -replace '.*A temporary password is provided for this session:\s*', '').Trim() } | - Select-Object -First 1 -} - -if ($PROWLARR_MODE -eq "local") { - if ($failedServices -contains 'prowlarr') { - Write-Warn "Prowlarr not running -- you can configure it later (step 12)" - } else { - Write-Info "Waiting for Prowlarr..." - Test-Port -Host_ "localhost" -Port 9900 -Timeout 60 | Out-Null - } -} - -if ($MEDIA_MODE -eq "local") { - if ($failedServices -contains 'jellyfin') { - Write-Warn "Jellyfin not running -- you can configure it later (step 10)" - } else { - Write-Info "Waiting for Jellyfin..." - Test-Port -Host_ "localhost" -Port 8096 -Timeout 90 | Out-Null - } -} - -Write-Ok "Selected services are running" - -if ($DB_DRIVER_CHOICE -eq "postgres") { - Write-Info "Waiting for PostgreSQL..." - Test-Port -Host_ "localhost" -Port 5432 -Timeout 30 | Out-Null -} - -Write-Info "Waiting 10s for services to fully initialize..." -Start-Sleep -Seconds 10 - -# -- Clipboard read (primary secret entry) ----------------------------- -# Secrets are entered by copying the value in the browser and pressing -# Enter here - the script reads it from the clipboard. Type m at the -# gate to paste manually for that one field. If Get-Clipboard is -# unavailable (e.g. no interactive desktop), the manual prompt is -# used directly. - -$script:HasClipboard = $false -try { - $null = Get-Clipboard -Raw -ErrorAction Stop - $script:HasClipboard = $true -} catch { } - -# Read-SecretValue -# Gate: press Enter to read the clipboard, or type m to paste manually. -# Prompt lines use Write-Host directly: this function runs inside $() -# capture, and the Write-* helpers emit native gum output (success stream) -# when gum is installed, which would be captured together with the value. -function Read-SecretValue { - param([string]$Name) - if (-not $script:HasClipboard) { - Write-Host "No clipboard tool available - paste manually (right-click or Ctrl+Shift+V, not Ctrl+C)." -ForegroundColor DarkGray - return Read-GumPassword "Paste your $Name (Enter to skip)" - } - while ($true) { - Write-Host "Copy your $Name to your clipboard, then press Enter" -ForegroundColor White - Write-Host "Type m and press Enter to paste manually instead." -ForegroundColor DarkGray - $answer = Read-Host ">" - if ($answer -match '^[Mm]$') { - return Read-GumPassword "Paste your $Name (Enter to skip)" - } - if ($answer -ne '') { - Write-Host "[WARN] Press Enter to read the clipboard, or m to paste manually." -ForegroundColor Yellow - continue - } - $value = $null - try { $value = Get-Clipboard -Raw -ErrorAction Stop } catch { $value = $null } - if ($null -ne $value) { $value = $value.Trim() } - if ($value) { - Write-Host "[ OK ] Received from clipboard ($($value.Length) characters)" -ForegroundColor Green - return $value - } - Write-Host "[WARN] Clipboard is empty - paste manually instead." -ForegroundColor Yellow - Write-Host "Paste with right-click or Ctrl+Shift+V (not Ctrl+C)." -ForegroundColor DarkGray - return Read-GumPassword "Paste your $Name (Enter to skip)" - } -} - -# -- 10. Jellyfin API Key ---------------------------------------------- - -Write-Step "[10/15] Jellyfin API key" - -if ($MEDIA_PROVIDER -eq "none") { - Update-EnvFile "NUXT_JELLYFIN_URL" "" - Update-EnvFile "NUXT_JELLYFIN_API_KEY" "" - Write-Info "No media server selected - NUXT_JELLYFIN_URL and NUXT_JELLYFIN_API_KEY cleared" -} else { - if ($MEDIA_MODE -eq "local") { - Write-Host "" - Write-Host "Follow these steps to get your Jellyfin API key:" -ForegroundColor White - Write-Dim " 1. Open $(Get-Hyperlink 'http://localhost:8096') in your browser" - Write-Dim " 2. Complete the setup wizard (create your admin account)" - Write-Dim " 3. Go to Dashboard (gear icon) > API Keys" - Write-Dim ' 4. Click the + button, name it Lode, click OK' - Write-Dim " 5. Copy the generated API key" - } else { - Write-Host "" - Write-RawLine "Your external Jellyfin instance: $(Get-Hyperlink $JELLYFIN_URL)" - Write-Dim "Create an API key in Jellyfin: Dashboard (gear icon) > API Keys" - } - Write-Host "" - - $jellyfinKey = Read-SecretValue "Jellyfin API key" - - if ($jellyfinKey) { - Update-EnvFile "NUXT_JELLYFIN_API_KEY" $jellyfinKey - Write-Ok "Jellyfin API key saved" - } else { - Write-Warn "Skipping Jellyfin API key -- set it later in .env" - } -} - -# -- 11. qBittorrent WebUI + API Key ----------------------------------- - -Write-Step "[11/15] qBittorrent WebUI + API key" - -if ($QBIT_MODE -eq "local") { - if ($QBIT_TEMP_PASS) { - Set-ClipboardOsc52 $QBIT_TEMP_PASS - Write-Host "" - Write-Host "qBittorrent temporary password: $QBIT_TEMP_PASS" -ForegroundColor Yellow - Write-Dim "$([char]0x2713) Copied to clipboard (this replaces your previous clipboard contents)" - Write-Host "" - } else { - Write-Warn "Could not extract qBittorrent temp password - check: $(Get-DcCommandPrefix) logs qbittorrent" - } - - Write-Host "Follow these steps to configure qBittorrent:" -ForegroundColor White - Write-Dim " 1. Open $(Get-Hyperlink 'http://localhost:8080') in your browser" - Write-Dim " 2. Login with:" - Write-Dim " Username: admin" - Write-Dim " Password: [temporary password shown above]" - Write-Dim " 3. Go to Tools > Options > Web UI" - Write-Dim " 4. Change the password to something you remember" - Write-Dim " 5. Save changes" - Write-Dim " 6. Go to Tools > Options > Web UI > API Key section" - Write-Dim " 7. Copy the API Key" -} else { - Write-Host "" - Write-RawLine "Your external qBittorrent instance: $(Get-Hyperlink $QBIT_URL)" - Write-Dim "Find the API key in qBittorrent: Tools > Options > Web UI" -} -Write-Host "" - - $qbitKey = Read-SecretValue "qBittorrent API key" - -if ($qbitKey) { - Update-EnvFile "NUXT_QBITTORRENT_API_KEY" $qbitKey - Write-Ok "qBittorrent API key saved" -} else { - Write-Warn "Skipping qBittorrent API key -- set it later in .env" -} - -# -- 12. Prowlarr API Key ---------------------------------------------- - -Write-Step "[12/15] Prowlarr API key" - -if ($PROWLARR_MODE -eq "local") { - Write-Host "" - Write-Host "Follow these steps to get your Prowlarr API key:" -ForegroundColor White - Write-Dim " 1. Open $(Get-Hyperlink 'http://localhost:9900') in your browser" - Write-Dim " 2. Go to Settings > General" - Write-Dim " 3. Find the API Key field" - Write-Dim " 4. Copy the API key" - Write-Host "" - $indexerLines = @(" 1. Add at least one indexer (e.g. YTS): Settings > Indexers > Add") - if ($USE_FLARESOLVERR) { - $indexerLines += @(" 2. For private trackers: Settings > Indexers > Add > FlareSolverr", " Set URL: http://flaresolverr:8191") - } - Write-Callout "IMPORTANT: Prowlarr needs indexers before Lode can find anything." ($indexerLines -join "`n") - Write-Host "" -} else { - Write-Host "" - Write-RawLine "Your external Prowlarr instance: $(Get-Hyperlink $PROWLARR_URL)" - Write-Dim "Find the API key in Prowlarr: Settings > General" - Write-Host "" -} - -$prowlarrKey = Read-SecretValue "Prowlarr API key" - -if ($prowlarrKey) { - Update-EnvFile "NUXT_PROWLARR_API_KEY" $prowlarrKey - Write-Ok "Prowlarr API key saved" -} else { - Write-Warn "Skipping Prowlarr API key -- set it later in .env" -} - -# -- 13. TMDB API Key -------------------------------------------------- - -Write-Step "[13/15] TMDB API key" - -Write-Host "" -Write-Host "Follow these steps to get your TMDB API key:" -ForegroundColor White -Write-Dim " 1. Go to $(Get-Hyperlink 'https://www.themoviedb.org/settings/api')" -Write-Dim " 2. Create a free account (or log in)" -Write-Dim ' 3. Click the link to generate an API key' -Write-Dim " 4. Fill in the form:" -Write-Dim " Application Name: Lode" -Write-Dim " Application URL: $(Get-Hyperlink 'http://localhost:5757')" -Write-Dim " 5. Copy your API Key (v3 auth)" -Write-Host "" -Write-Dim "This is required for movie/TV metadata." -Write-Host "" - -$tmdbKey = Read-SecretValue "TMDB API key" - -if ($tmdbKey) { - Update-EnvFile "NUXT_TMDB_API_KEY" $tmdbKey - Write-Ok "TMDB API key saved" -} else { - Write-Warn "Skipping TMDB API key -- set it later in .env" -} - -# -- 14. Discord Webhook (optional) ------------------------------------ - -Write-Step "[14/15] Discord Webhook (optional)" - -$setWebhook = $false -if ($script:HAS_GUM) { - gum confirm --default=false "Get notified in Discord when downloads complete. Set up a webhook now?" | Out-Null - $setWebhook = ($LASTEXITCODE -eq 0) -} else { - $answer = Read-Host "Get notified in Discord when downloads complete. Set up a webhook now? [y/N]" - $setWebhook = ($answer -match '^[Yy]$') -} - -if ($setWebhook) { - Write-Host "" - Write-Host "To set up a Discord webhook:" -ForegroundColor Gray - Write-Dim " 1. Open your Discord server" - Write-Dim " 2. Go to Server Settings > Integrations > Webhooks" - Write-Dim ' 3. Click New Webhook' - Write-Dim " 4. Name it, choose a channel, click Copy Webhook URL" - Write-Host "" - - $discordKey = Read-SecretValue "Discord Webhook URL" - - if ($discordKey) { - Update-EnvFile "NUXT_DISCORD_WEBHOOK_URL" $discordKey - Write-Ok "Discord webhook URL saved" - } else { - Write-Warn "Skipping Discord webhook -- set it later in .env" - } -} else { - Write-Dim "Skipped -- set NUXT_DISCORD_WEBHOOK_URL in .env later if you change your mind." -} - -# -- 15. Start Lode ---------------------------------------------------- - -Write-Step "[15/15] Starting Lode" - +# -- Lode Setup Bootstrap (Windows) ---------------------------------- +# Downloads the prebuilt lode-setup binary for this platform from the +# latest GitHub release and runs it. All setup logic lives in the +# binary (cli/), so this script only detects the architecture, +# downloads the cached binary, and launches it. +# +# Usage: +# irm https://raw.githubusercontent.com/Nort1346/Lode/main/setup.ps1 | iex +# .\setup.ps1 +# -------------------------------------------------------------------- + +$ErrorActionPreference = 'Stop' + +$Repo = 'Nort1346/Lode' + +function Stop-WithError([string]$Message) { + Write-Error $Message + exit 1 +} + +# PROCESSOR_ARCHITECTURE reports 32-bit inside a WoW64 shell, so also check +# the W6432 variable. +$archName = $null +$arch = $env:PROCESSOR_ARCHITECTURE +if ($arch -eq 'AMD64') { + $archName = 'x64' +} +elseif ($arch -eq 'ARM64') { + $archName = 'arm64' +} +else { + $w6432 = $env:PROCESSOR_ARCHITEW6432 + if ($w6432 -eq 'AMD64') { + $archName = 'x64' + } + elseif ($w6432 -eq 'ARM64') { + $archName = 'arm64' + } +} +if (-not $archName) { + Stop-WithError "Unsupported CPU architecture: $arch" +} + +$asset = "lode-setup-windows-${archName}.exe" +$url = "https://github.com/${Repo}/releases/latest/download/${asset}" + +# Tag from the redirect Location (…/releases/download//), +# used only to version the cache directory. +$tag = 'latest' try { - Update-EnvFile "NUXT_REDIS_URL" "redis://redis:6379" - Update-EnvFile "DB_DRIVER" $DB_DRIVER_CHOICE - - switch ($QBIT_MODE) { - 'local' { Update-EnvFile "NUXT_QBITTORRENT_URL" "http://qbittorrent:8080" } - 'external' { Update-EnvFile "NUXT_QBITTORRENT_URL" $QBIT_URL } - } - switch ($PROWLARR_MODE) { - 'local' { Update-EnvFile "NUXT_PROWLARR_URL" "http://prowlarr:9696" } - 'external' { Update-EnvFile "NUXT_PROWLARR_URL" $PROWLARR_URL } - } - switch ($MEDIA_MODE) { - 'local' { Update-EnvFile "NUXT_JELLYFIN_URL" "http://jellyfin:8096" } - 'external' { Update-EnvFile "NUXT_JELLYFIN_URL" $JELLYFIN_URL } - } - if ($USE_FLARESOLVERR) { - Update-EnvFile "NUXT_FLARESOLVERR_URL" "http://flaresolverr:8191" - } else { - Update-EnvFile "NUXT_FLARESOLVERR_URL" "" - } - - if ($DB_DRIVER_CHOICE -eq "postgres") { - $pgPass = Read-EnvValue "POSTGRES_PASSWORD" - Update-EnvFile "DATABASE_URL" "postgresql://lode:$pgPass@postgres:5432/lode" - } - - $upOutput = (docker compose $dcArgs up -d lode 2>&1 | Out-String).Trim() - - Start-Sleep -Seconds 3 - $lodeId = docker compose $dcArgs ps -q lode 2>$null - if (-not $lodeId) { - if ($upOutput) { - throw "Lode container did not start:`n$upOutput" - } - throw "Lode container did not start." - } - - Write-Info "Waiting for Lode to start (first start may take 1-2 minutes)..." - if (-not (Test-Port -Host_ "localhost" -Port 5757 -Timeout 120 -Interval 4)) { - throw "Lode did not open $(Get-Hyperlink 'http://localhost:5757') within 120s." - } - - Write-Ok "Lode is running at $(Get-Hyperlink 'http://localhost:5757')" + $http = [System.Net.Http.HttpClient]::new() + $http.AllowAutoRedirect = $false + $response = $http.GetAsync($url, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() + try { + if ($response.Headers.Location) { + $suffix = [string]$response.Headers.Location -replace '.*releases/download/', '' + $tag = ($suffix -split '/')[0] + } + } + finally { + $response.Dispose() + $http.Dispose() + } } catch { - Write-Err "Could not start Lode: $_" - Write-Host "" - Write-Host " The other containers are left running. To see what went wrong:" -ForegroundColor Yellow - Write-Host " $(Get-DcCommandPrefix) logs lode" -ForegroundColor Yellow - Write-Host " To retry:" -ForegroundColor Yellow - Write-Host " $(Get-DcCommandPrefix) up -d lode" -ForegroundColor Yellow - Write-Host "" - Stop-Setup 1 -} - -# -- Extract admin password from logs --------------------------------- - -$adminPass = $null -for ($retry = 1; $retry -le 5; $retry++) { - $adminPass = docker compose $dcArgs logs --no-color --tail 200 lode 2>&1 | - Select-String 'Admin password:' | - ForEach-Object { if ($_ -match 'Admin password: ([^"]+)') { $matches[1].Trim() } } | - Select-Object -First 1 - if ($adminPass) { break } - Start-Sleep -Seconds 2 -} - -# -- Summary ---------------------------------------------------------- - -Write-Host "" -Write-Host "Lode is ready!" -ForegroundColor Green -Write-Host "" - -# -- Services table -$services = @((Get-SummaryRow "Lode" "http://localhost:5757")) -switch ($QBIT_MODE) { - 'local' { $services += (Get-SummaryRow "qBittorrent" "http://localhost:8080") } - 'external' { $services += (Get-SummaryRow "qBittorrent" $QBIT_URL) } -} -switch ($PROWLARR_MODE) { - 'local' { $services += (Get-SummaryRow "Prowlarr" "http://localhost:9900") } - 'external' { $services += (Get-SummaryRow "Prowlarr" $PROWLARR_URL) } -} -switch ($MEDIA_MODE) { - 'local' { $services += (Get-SummaryRow "Jellyfin" "http://localhost:8096") } - 'external' { $services += (Get-SummaryRow "Jellyfin" $JELLYFIN_URL) } - 'none' { $services += (Get-SummaryRow "Jellyfin" "(disabled)") } -} -if ($USE_FLARESOLVERR) { - $services += (Get-SummaryRow "FlareSolverr" "http://localhost:8191") + # No redirect info - fall back to the "latest" cache directory. } -$services += (Get-SummaryRow "Database" $DB_DRIVER_CHOICE) -if ($DB_DRIVER_CHOICE -eq "postgres") { - $services += (Get-SummaryRow "PostgreSQL" "localhost:5432 / lode") -} -if ($USE_DOZZLE) { - $services += (Get-SummaryRow "Dozzle" "http://localhost:8082") -} -$servicesMsg = $services -join "`n" -Write-SummarySection $servicesMsg - -Write-Host "" -# -- Credentials -$credsUser = if ($script:HAS_GUM) { gum style --bold --foreground 11 'admin' } else { 'admin' } -Write-Host "Username: $credsUser" -if ($adminPass) { - Set-ClipboardOsc52 $adminPass - $credsPass = if ($script:HAS_GUM) { gum style --bold --foreground 11 $adminPass } else { $adminPass } - Write-Host "Password: $credsPass" - Write-Dim "$([char]0x2713) Copied to clipboard (this replaces your previous clipboard contents)" -} else { - Write-Dim "Password: check '$(Get-DcCommandPrefix) logs lode'" -} -Write-Dim "Change this password after first login." - -Write-Host "" +$cacheDir = Join-Path $env:LOCALAPPDATA "LodeSetup\$tag" +$bin = Join-Path $cacheDir $asset -# -- Required before first use -if ($PROWLARR_MODE -eq "local") { - $requiredLines = @( - " Prowlarr has no indexers yet - Lode cannot find torrents until you add them.", - " Open $(Get-Hyperlink 'http://localhost:9900') and add at least one indexer (e.g. YTS)." - ) - if ($USE_FLARESOLVERR) { - $requiredLines += " For private trackers: Settings > Indexers > Add > FlareSolverr, URL: http://flaresolverr:8191" - } -} else { - $requiredLines = @(" Your external Prowlarr needs at least one indexer before Lode can find torrents.") +if (-not (Test-Path $bin)) { + Write-Host "Downloading $asset ($tag)..." + New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null + Invoke-WebRequest -Uri $url -OutFile (Join-Path $cacheDir "$asset.tmp") + Move-Item -Force (Join-Path $cacheDir "$asset.tmp") $bin } -Write-Callout "Required before first use:" ($requiredLines -join "`n") -Stop-Setup 0 +# The child runs in this console window, so the interactive session +# survives by construction. Arguments are passed through. +& $bin @args +exit $LASTEXITCODE diff --git a/setup.sh b/setup.sh index d7d15c8..2a2e72d 100755 --- a/setup.sh +++ b/setup.sh @@ -1,1795 +1,63 @@ #!/bin/bash set -euo pipefail -# -- Lode Auto-Setup Script -------------------------------------- -# Sets up Lode with the services you choose. The Docker stack is split -# into a base compose file (Lode + Redis) plus per-service overlays -# (postgres, qbittorrent, prowlarr, jellyfin, flaresolverr, dozzle). -# This script downloads the files that match your selection and starts -# them with `docker compose -f -f ...`, so the compose -# files stay small, readable, and usable manually. -# -# Your selection is saved in .lode-setup so re-runs can prefill -# the prompts; .env keeps all secrets and URLs. +# -- Lode Setup Bootstrap -------------------------------------------- +# Downloads the prebuilt lode-setup binary for this platform from the +# latest GitHub release and runs it. All setup logic lives in the +# binary (cli/), so this script only detects the platform, resolves +# the latest release, and execs the cached binary. # # Usage: # curl -fsSL https://raw.githubusercontent.com/Nort1346/Lode/main/setup.sh | bash # ./setup.sh # ---------------------------------------------------------------------- -# -- Constants --------------------------------------------------------- - -REPO_RAW="https://raw.githubusercontent.com/Nort1346/Lode/main" -SETUP_URL="${REPO_RAW}/setup.sh" -SETUP_SELF="$0" -SETUP_NEW="$(mktemp)" - -COMPOSE_BASE="docker-compose.yml" -STATE_FILE=".lode-setup" -# Legacy state file name (pre-rename) - migrate it once on re-run. -if [ ! -f "$STATE_FILE" ] && [ -f ".lode-setup.json" ]; then - mv ".lode-setup.json" "$STATE_FILE" -fi -COMPOSE_FILES=() - -cleanup() { - rm -f "$SETUP_NEW" "${SETUP_SELF}.tmp" - rm -f .env.example.tmp .env.tmp "${STATE_FILE}.tmp" docker-compose*.tmp -} -trap cleanup EXIT - -# Option labels (also used for --selected prefill, so keep them stable) -QBIT_OPT_LOCAL="Local qBittorrent container (recommended)" -QBIT_OPT_EXTERNAL="External qBittorrent (you host it)" -PROWLARR_OPT_LOCAL="Local Prowlarr container (recommended)" -PROWLARR_OPT_EXTERNAL="External Prowlarr (you host it)" -MEDIA_OPT_JELLYFIN_LOCAL="Jellyfin (local container)" -MEDIA_OPT_JELLYFIN_EXTERNAL="Jellyfin (external)" -MEDIA_OPT_NONE="No media server" -ADDON_OPT_FLARESOLVERR="FlareSolverr - CAPTCHA bypass for private trackers" -ADDON_OPT_DOZZLE="Dozzle - Docker log viewer" - -# Captured before .env is created in step 2: a pre-existing .env without -# a state file means the old all-or-nothing installer was used. -LEGACY_INSTALL=false -if [ -f .env ] && [ ! -f "$STATE_FILE" ]; then - LEGACY_INSTALL=true -fi - -# -- Privilege helper -------------------------------------------------- -# SUDO stays empty when running as root or when no privilege tool exists. -SUDO="" -if [ "$(id -u)" -ne 0 ]; then - if command -v sudo >/dev/null 2>&1; then - SUDO="sudo" - elif command -v doas >/dev/null 2>&1; then - SUDO="doas" - fi -fi - -run_privileged() { - if [ -n "$SUDO" ]; then - # shellcheck disable=SC2086 - $SUDO "$@" - else - "$@" - fi -} - -# -- Output helpers (plain ANSI - available before gum is known) ------- -# Colors degrade to nothing when output is not a terminal or when -# NO_COLOR / TERM=dumb is set, so the script stays readable in old -# terminals, logs, and piped output. - -if [ -n "${NO_COLOR:-}" ] || [ "${TERM:-}" = "dumb" ] || [ ! -t 1 ]; then - RED='' - GREEN='' - YELLOW='' - BLUE='' - CYAN='' - GRAY='' - BOLD='' - NC='' -else - RED='\033[0;31m' - GREEN='\033[0;32m' - YELLOW='\033[1;33m' - BLUE='\033[0;34m' - CYAN='\033[0;36m' - GRAY='\033[0;90m' - BOLD='\033[1m' - NC='\033[0m' -fi - -# -- Hyperlinks (OSC 8) ------------------------------------------------ -# OSC 8 is emitted unconditionally for http(s) URLs. ECMA-48-compliant -# terminals ignore unknown OSC sequences; NO_COLOR only affects SGR -# colors above and never disables hyperlinks. - -hyperlink() { - local url=$1 - local label=${2-} - local url_only=false - - if [ -z "$label" ] || [ "$label" = "$url" ]; then - url_only=true - label=$url - fi +REPO="Nort1346/Lode" - if [[ "$url" =~ ^https?:// ]]; then - printf '\033]8;;%s\033\\%s\033]8;;\033\\' "$url" "$label" - elif [ "$url_only" = true ]; then - printf '%s' "$url" - else - printf '%s: %s' "$label" "$url" - fi -} - -message_has_osc8() { - case "$1" in - *$'\e]8'*) return 0 ;; - *) return 1 ;; - esac -} - -# -- Clipboard (OSC 52) ------------------------------------------------- -# OSC 52 is emitted unconditionally, like OSC 8. Some terminals disable it -# by default for security; that's fine - the visible password is fallback. - -copy_to_clipboard() { - local text=$1 - local encoded - encoded=$(printf '%s' "$text" | base64 | tr -d '\n') || return 0 - printf '\033]52;c;%s\033\\' "$encoded" -} - -info() { echo -e "${BLUE}[INFO]${NC} $*"; } -ok() { echo -e "${GREEN}[ OK ]${NC} $*"; } -warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -err() { echo -e "${RED}[ERR ]${NC} $*"; } - -header() { echo -e "\n${BOLD}$1${NC}\n"; } -step() { echo -e "\n${CYAN}${BOLD}$1${NC}"; } - -# -- Piped stdin guard (curl | bash) ----------------------------------- -# When piped, the script text arrives on stdin, so interactive prompts -# would read the consumed pipe. Re-exec the freshly downloaded copy with -# the controlling terminal as stdin so the guided prompts work. -if [ ! -t 0 ] && [ -z "${LODE_SETUP_REEXEC:-}" ]; then - # Note: `[ -r /dev/tty ]` is unreliable here (access() succeeds even - # without a controlling terminal) - actually opening it is the real test. - if { : < /dev/tty; } 2>/dev/null && curl -fsSL "$SETUP_URL" -o "$SETUP_NEW" 2>/dev/null; then - export LODE_SETUP_REEXEC=1 - chmod +x "$SETUP_NEW" - exec bash "$SETUP_NEW" < /dev/tty "$@" - fi - err "Interactive terminal required, but none is available." - err "Download the script and run it directly: bash setup.sh" +die() { + echo "error: $*" >&2 exit 1 -fi - -# -- gum bootstrap (optional, never fatal) ----------------------------- -# gum is cosmetic: on any failure we continue with plain output. -# The official binary is downloaded from the GitHub release, so no -# package manager, gpg key, or /etc writes are needed. - -HAS_GUM=false - -install_gum() { - if command -v gum >/dev/null 2>&1; then - HAS_GUM=true - return 0 - fi - if ! command -v curl >/dev/null 2>&1; then - return 0 - fi - - local os arch - os=$(uname -s) - arch=$(uname -m) - case "$os" in - Darwin) - if command -v brew >/dev/null 2>&1; then - info "Installing gum via Homebrew..." - if brew install gum >/dev/null 2>&1; then - HAS_GUM=true - return 0 - fi - fi - case "$arch" in - arm64 | x86_64) ;; - *) - warn "Unsupported architecture: $arch - continuing without gum." - return 0 - ;; - esac - ;; - Linux) - case "$arch" in - x86_64 | amd64) arch="x86_64" ;; - aarch64 | arm64) arch="arm64" ;; - *) - warn "Unsupported architecture: $arch - continuing without gum." - return 0 - ;; - esac - ;; - *) - return 0 - ;; - esac - - info "Installing gum (charm) for beautiful output..." - - local tag url tmpdir gum_bin - tag=$(curl -fsSL https://api.github.com/repos/charmbracelet/gum/releases/latest 2>/dev/null \ - | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1) || tag="" - if [ -z "$tag" ]; then - warn "Could not resolve the latest gum version - continuing without gum." - return 0 - fi - - url="https://github.com/charmbracelet/gum/releases/download/${tag}/gum_${tag#v}_${os}_${arch}.tar.gz" - tmpdir=$(mktemp -d) - - if ! curl -fsSL "$url" -o "${tmpdir}/gum.tar.gz" 2>/dev/null || ! tar -xzf "${tmpdir}/gum.tar.gz" -C "$tmpdir" 2>/dev/null; then - rm -rf "$tmpdir" - warn "Could not download gum - continuing without gum." - return 0 - fi - - gum_bin=$(find "$tmpdir" -type f -name gum 2>/dev/null | head -n 1) - if [ -z "$gum_bin" ]; then - rm -rf "$tmpdir" - warn "gum binary not found in the download - continuing without gum." - return 0 - fi - - if run_privileged install -m 0755 "$gum_bin" /usr/local/bin/gum 2>/dev/null && command -v gum >/dev/null 2>&1; then - rm -rf "$tmpdir" - HAS_GUM=true - return 0 - fi - - local userbin - if [ -n "${HOME:-}" ]; then - userbin="${HOME}/.local/bin/gum" - if mkdir -p "${HOME}/.local/bin" 2>/dev/null && install -m 0755 "$gum_bin" "$userbin" 2>/dev/null; then - export PATH="${HOME}/.local/bin:${PATH}" - rm -rf "$tmpdir" - HAS_GUM=true - return 0 - fi - fi - - rm -rf "$tmpdir" - warn "Could not install gum - continuing without gum." - return 0 -} - -install_gum - -# gum prompts are TUIs: they only render in a real terminal and need -# v0.12.0+ (gum log). On any failure we fall back to plain output. -gum_usable() { - command -v gum >/dev/null 2>&1 || return 1 - [ -t 0 ] || return 1 - [ -t 1 ] || return 1 - [ "${TERM:-}" != "dumb" ] || return 1 - [ -z "${NO_COLOR:-}" ] || return 1 - local ver - ver=$(gum --version 2>/dev/null | sed -n 's/.*version v\{0,1\}\([0-9][0-9.]*\).*/\1/p' | head -n 1) - [ -n "$ver" ] || return 1 - [ "$(printf '%s\n' "$ver" "0.12.0" | sort -V | head -n 1)" = "0.12.0" ] || return 1 - command -v tput >/dev/null 2>&1 && tput cols >/dev/null 2>&1 || return 1 - return 0 -} - -if [ "$HAS_GUM" = true ] && ! gum_usable; then - HAS_GUM=false - warn "gum cannot render prompts in this terminal - using plain output." -fi - -# -- Prompt & summary helpers (gum-aware) ------------------------------ - -if [ "$HAS_GUM" = true ]; then - info() { - local msg="$*" - if message_has_osc8 "$msg"; then - echo -e "${BLUE}[INFO]${NC} ${msg}" - else - gum log --level info "$msg" - fi - } - ok() { - local msg="$*" - if message_has_osc8 "$msg"; then - echo -e "${GREEN}[ OK ]${NC} ${msg}" - else - gum log --level info "$msg" - fi - } - warn() { - local msg="$*" - if message_has_osc8 "$msg"; then - echo -e "${YELLOW}[WARN]${NC} ${msg}" - else - gum log --level warn "$msg" - fi - } - err() { - local msg="$*" - if message_has_osc8 "$msg"; then - echo -e "${RED}[ERR ]${NC} ${msg}" - else - gum log --level error "$msg" - fi - } - - summary_section() { - if message_has_osc8 "$1"; then - echo -e "${CYAN}$1${NC}" - else - gum style \ - --border normal --border-foreground 240 \ - --padding "0 2" "$1" - fi - } - - summary_row() { - local label="$1" url="$2" - printf " %-18s %s\n" "$label" "$(hyperlink "$url")" - } - - bold() { - if message_has_osc8 "$1"; then - echo -e "${BOLD}$1${NC}" - else - gum style --bold --foreground 11 "$1" - fi - } - dim() { - if message_has_osc8 "$1"; then - echo -e "${GRAY}$1${NC}" - else - gum style --foreground 14 "$1" - fi - } - - read_input() { - gum input --placeholder "$1" - } - - # read_input_default - # Prefills the input so Enter accepts the default. - read_input_default() { - gum input --value "$2" --placeholder "$1" - } - - # read_password (masked input for API keys) - read_password() { - gum input --password --placeholder "$1" - } - - gum_menu() { - local prompt="$1"; shift - gum choose --header "$prompt" "$@" - } - - # gum_menu_selected - # The preselected option starts highlighted; press Enter to accept it. - gum_menu_selected() { - local prompt="$1" preselect="$2"; shift 2 - if [ -n "$preselect" ] && printf '%s\n' "$@" | grep -qxF "$preselect"; then - gum choose --header "$prompt" --selected "$preselect" "$@" - else - gum choose --header "$prompt" "$@" - fi - } -else - summary_section() { echo -e "${CYAN}$1${NC}"; } - - summary_row() { - local label="$1" url="$2" - printf " %-18s %s\n" "$label" "$(hyperlink "$url")" - } - - bold() { echo -e "${BOLD}$1${NC}"; } - dim() { echo -e "${GRAY}$1${NC}"; } - - read_input() { - local result="" - read -rp "$1: " result || result="" - echo "$result" - } - - # read_input_default - read_input_default() { - local placeholder="$1" def="$2" result="" - if [ -n "$def" ]; then - read -rp "$placeholder [$def]: " result || result="" - else - read -rp "$placeholder: " result || result="" - fi - if [ -z "$result" ]; then - echo "$def" - else - echo "$result" - fi - } - - # read_password (masked input for API keys) - read_password() { - local result="" - read -rsp "$1: " result || result="" - echo "" >&2 - echo "$result" - } - - gum_menu() { - local prompt="$1"; shift - plain_menu "$prompt" 1 "$@" - } - - # gum_menu_selected - gum_menu_selected() { - local prompt="$1" preselect="$2"; shift 2 - local default=1 i=1 opt - for opt in "$@"; do - if [ "$opt" = "$preselect" ]; then - default=$i - fi - i=$((i + 1)) - done - plain_menu "$prompt" "$default" "$@" - } - - plain_menu() { - local prompt="$1" default="$2"; shift 2 - local options=("$@") - local i=1 - echo "$prompt" - for opt in "${options[@]}"; do - echo " $i) $opt" - i=$((i + 1)) - done - local choice="" - while true; do - if ! read -rp "Enter choice [1-${#options[@]}] (default $default): " choice; then - choice="$default" - break - fi - if [ -z "$choice" ]; then - choice="$default" - fi - if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#options[@]}" ]; then - break - fi - echo " Invalid choice. Please enter a number between 1 and ${#options[@]}." - done - echo "${options[$((choice - 1))]}" - } -fi - -# -- Helpers ----------------------------------------------------------- - -generate_password() { - local raw - raw=$(openssl rand -base64 96 | tr -dc 'a-zA-Z0-9') - printf '%s' "${raw:0:$1}" -} - -generate_hex() { - openssl rand -hex "$1" -} - -validate_env_min_length() { - local key="$1" min="$2" - local value - value=$(grep "^${key}=" .env 2>/dev/null | head -n 1 | cut -d= -f2-) - if [ -z "$value" ] || [ ${#value} -lt "$min" ]; then - return 1 - fi - return 0 -} - -read_env_value() { - local key="$1" - grep "^${key}=" .env 2>/dev/null | head -n 1 | cut -d= -f2- -} - -validate_url() { - case "$1" in - http://* | https://*) return 0 ;; - *) return 1 ;; - esac } -# external_url_default -# Returns the current .env value unless it is the internal (in-network) -# URL, which is not a useful default for an external instance. -external_url_default() { - local v - v=$(read_env_value "$1") - if [ -n "$v" ] && [ "$v" != "$2" ]; then - echo "$v" - fi -} - -wait_for_port() { - local host="$1" port="$2" timeout="${3:-60}" elapsed=0 - while ! nc -z "$host" "$port" 2>/dev/null; do - sleep 2 - elapsed=$((elapsed + 2)) - if [ "$elapsed" -ge "$timeout" ]; then - err "Timeout waiting for $host:$port after ${timeout}s" - return 1 - fi - done -} - -# dc -# Every docker compose call goes through dc() so the -f file list -# (built in the selection step) stays in one place. -dc() { - if [ ${#COMPOSE_FILES[@]} -eq 0 ]; then - err "No compose files selected - internal error." - exit 1 - fi - # Compose needs one -f per file; a single -f with bare filenames after it - # makes compose dump its usage and fail with "unknown docker command". - local -a file_args=() - local f - for f in "${COMPOSE_FILES[@]}"; do - file_args+=("-f" "$f") - done - docker compose "${file_args[@]}" "$@" -} - -# Renders the copy-pasteable `docker compose -f a -f b ...` prefix for hints. -dc_cmd_prefix() { - local out="" f - for f in "${COMPOSE_FILES[@]}"; do - out+="-f $f " - done - printf 'docker compose %s' "${out% }" -} - -service_running() { - # `ps -q` is stable across Compose versions (the JSON output format - # changed in Compose 2.21, so it is not used for state checks). - [ -n "$(dc ps -q "$1" 2>/dev/null)" ] -} - -update_env() { - local key="$1" value="$2" - if grep -q "^${key}=" .env 2>/dev/null; then - sed -i.bak "s|^${key}=.*|${key}=${value}|" .env && rm -f .env.bak - elif grep -q "^# *${key}=" .env 2>/dev/null; then - sed -i.bak "s|^# *${key}=.*|${key}=${value}|" .env && rm -f .env.bak - else - echo "${key}=${value}" >> .env - fi -} - -# -- State file (selection memory for re-runs) ------------------------- -# Flat key=value file - no JSON parser needed in bash or PowerShell. -# Only selection state lives here; secrets and URLs stay in .env. - -state_get() { - local key="$1" - if [ ! -f "$STATE_FILE" ]; then - return 0 - fi - grep "^${key}=" "$STATE_FILE" 2>/dev/null | head -n 1 | cut -d= -f2- -} - -write_state() { - # Temp file + rename: an interrupted run (Ctrl+C, kill, closed terminal) can never leave a corrupt state file behind. - { - echo "version=1" - echo "dbDriver=${DB_DRIVER_CHOICE}" - echo "imageTag=${LODE_TAG}" - echo "qbittorrent=${QBIT_MODE}" - echo "prowlarr=${PROWLARR_MODE}" - echo "mediaProvider=${MEDIA_PROVIDER}" - echo "mediaMode=${MEDIA_MODE}" - echo "flaresolverr=${USE_FLARESOLVERR}" - echo "dozzle=${USE_DOZZLE}" - } > "${STATE_FILE}.tmp" && mv -f "${STATE_FILE}.tmp" "$STATE_FILE" -} - -# is_legacy_monolith -# Pre-split compose files defined every service inline. The new postgres -# overlay only defines postgres, so a qbittorrent service block marks a -# file as a legacy monolith. -is_legacy_monolith() { - [ -f "$1" ] && grep -qE '^ qbittorrent:[[:space:]]*$' "$1" -} - -# stop_service_if_deselected -# On re-runs, a service that used to be deployed locally but is no longer -# selected leaves a stopped container behind (we never use -# --remove-orphans). Confirm, then stop and remove the container; its -# volume is kept so switching back keeps all data. -stop_service_if_deselected() { - local svc="$1" key="$2" active_value="$3" current_value="$4" - local old_value - old_value=$(state_get "$key") - if [ "$old_value" != "$active_value" ]; then - return 0 - fi - if [ "$current_value" = "$active_value" ]; then - return 0 - fi - local container="lode-${svc}" - if ! docker ps -q --filter "name=^${container}\$" 2>/dev/null | grep -q .; then - return 0 - fi - local keep=true - if [ "$HAS_GUM" = true ]; then - if ! gum confirm --default=true "$container is no longer selected. Stop and remove the container? (its volume is kept)"; then - keep=false - fi - else - local answer - read -rp "$container is no longer selected. Stop and remove the container? (its volume is kept) [Y/n] " answer || answer="y" - if [[ "$answer" =~ ^[Nn] ]]; then - keep=false - fi - fi - if [ "$keep" = true ]; then - docker stop "$container" >/dev/null 2>&1 || true - docker rm "$container" >/dev/null 2>&1 || true - ok "Removed $container (volume kept)" - else - warn "Keeping $container - remove it manually with: docker rm $container" - fi - return 0 -} - -# -- Self-update check (continue with local copy on failure) ----------- - -if curl -fsSL "$SETUP_URL" -o "$SETUP_NEW" 2>/dev/null; then - if ! diff -q "$SETUP_SELF" "$SETUP_NEW" &>/dev/null; then - echo "" - if [ "$HAS_GUM" = true ]; then - gum style --foreground 11 --bold "A newer version of setup.sh is available." - else - echo -e "${YELLOW}${BOLD} A newer version of setup.sh is available.${NC}" - fi - echo "" - if [ "$HAS_GUM" = true ]; then - gum confirm --default=false "Update setup.sh and restart?" && { - cp "$SETUP_SELF" "${SETUP_SELF}.bak" - cp "$SETUP_NEW" "${SETUP_SELF}.tmp" && mv -f "${SETUP_SELF}.tmp" "$SETUP_SELF" - chmod +x "$SETUP_SELF" - ok "Updated setup.sh. Restarting..." - exec "$SETUP_SELF" "$@" - } - else - read -rp "Update setup.sh and restart? [y/N] " answer || answer="" - if [[ "$answer" =~ ^[Yy]$ ]]; then - cp "$SETUP_SELF" "${SETUP_SELF}.bak" - cp "$SETUP_NEW" "${SETUP_SELF}.tmp" && mv -f "${SETUP_SELF}.tmp" "$SETUP_SELF" - chmod +x "$SETUP_SELF" - ok "Updated setup.sh. Restarting..." - exec "$SETUP_SELF" "$@" - fi - fi - warn "Continuing with current version..." - fi -fi - -# -- Banner ------------------------------------------------------------ - -header "Lode Auto-Setup v1.1" - -echo "" -dim "This will set up Lode and the services you choose." -dim "All data will be stored in Docker volumes." -echo "" - -if [ "$HAS_GUM" = true ]; then - gum confirm --default=false "Do you want to continue?" || { echo "Aborted."; exit 0; } -else - read -rp "Do you want to continue? (y/N) " confirm || confirm="" - [[ "$confirm" =~ ^[yY] ]] || { echo "Aborted."; exit 0; } -fi - -# -- 1. Prerequisites -------------------------------------------------- - -step "[1/15] Checking prerequisites" - -if ! command -v docker &> /dev/null; then - err "Docker is not installed." - case "$(uname -s)" in - Darwin) - echo " Install Docker Desktop for macOS:" - echo " $(hyperlink 'https://docs.docker.com/desktop/install/mac-install/')" - ;; - Linux) - if grep -qi microsoft /proc/version 2>/dev/null; then - echo " Install Docker Desktop for Windows (WSL2):" - echo " $(hyperlink 'https://docs.docker.com/desktop/wsl/')" - else - echo " Install Docker Engine for Linux:" - echo " $(hyperlink 'https://docs.docker.com/engine/install/')" - fi - ;; - *) - echo " Install Docker:" - echo " $(hyperlink 'https://docs.docker.com/get-docker/')" - ;; - esac - exit 1 -fi -ok "Docker $(docker --version | sed -n 's/.*version \([^ ,]*\).*/\1/p')" - -if ! docker_info_err="$(docker info 2>&1)"; then - err "Docker daemon is not running or not reachable from this shell." - # `docker info` prints the whole client section before the failure - # (the client part works even when the daemon is off) - show only - # the error lines, falling back to the last lines of the output. - docker_info_lines="$(printf '%s\n' "$docker_info_err" | grep -iE 'failed to connect|cannot connect|permission denied|connection refused|cannot find|no such file' | head -n 3 || true)" - if [ -z "$docker_info_lines" ]; then - docker_info_lines="$(printf '%s\n' "$docker_info_err" | tail -n 3)" - fi - printf '%s\n' "$docker_info_lines" | sed -e 's/^/ /' - case "$(uname -s)" in - Darwin) - echo " Start Docker Desktop and try again." - ;; - MSYS* | MINGW* | CYGWIN*) - echo " Start Docker Desktop (WSL2 backend) and try again." - ;; - Linux) - echo " Start the daemon: sudo systemctl start docker" - if printf '%s' "$docker_info_err" | grep -qi 'permission'; then - current_user="${USER:-$(id -un)}" - if getent group docker 2>/dev/null | cut -d: -f4 | tr ',' '\n' | grep -qx "$current_user" \ - && ! id -nG 2>/dev/null | tr ' ' '\n' | grep -qx docker; then - echo " You are in the docker group, but this session predates the change." - echo " Log out and back in, or run: newgrp docker" - else - echo " The error looks like a permission problem - add your user to the docker group:" - echo " sudo usermod -aG docker ${current_user} (then log out and back in)" - fi - fi - ;; - *) - echo " Start the Docker daemon and try again." - ;; - esac - exit 1 -fi -ok "Docker daemon running" - -if ! docker compose version &> /dev/null 2>&1; then - err "Docker Compose plugin is not installed." - echo " Install Docker Compose:" - echo " macOS/Windows: Install or update Docker Desktop" - echo " $(hyperlink 'https://docs.docker.com/get-docker/')" - echo " Linux: Install the Docker Compose plugin" - echo " $(hyperlink 'https://docs.docker.com/compose/install/linux/')" - exit 1 -fi -ok "Docker Compose $(docker compose version --short 2>/dev/null || echo 'available')" - -if ! command -v curl &> /dev/null; then - err "curl is not installed." - case "$(uname -s)" in - Darwin) - echo " Install curl on macOS:" - echo " brew install curl" - echo " Or install Xcode Command Line Tools:" - echo " xcode-select --install" - ;; - Linux) - if command -v apt-get &> /dev/null; then - echo " Install curl: sudo apt-get install -y curl" - elif command -v yum &> /dev/null; then - echo " Install curl: sudo yum install -y curl" - elif command -v dnf &> /dev/null; then - echo " Install curl: sudo dnf install -y curl" - elif command -v apk &> /dev/null; then - echo " Install curl: apk add --no-cache curl" - else - echo " Install curl using your package manager." - fi - ;; - *) - echo " Download from: $(hyperlink 'https://curl.se/download.html')" - ;; - esac - exit 1 -fi -ok "curl available" - -if ! command -v openssl &> /dev/null; then - err "openssl is not installed." - case "$(uname -s)" in - Darwin) - echo " Install Xcode Command Line Tools: xcode-select --install" - ;; - Linux) - if command -v apt-get &> /dev/null; then - echo " Install openssl: sudo apt-get install -y openssl" - elif command -v yum &> /dev/null; then - echo " Install openssl: sudo yum install -y openssl" - elif command -v dnf &> /dev/null; then - echo " Install openssl: sudo dnf install -y openssl" - elif command -v apk &> /dev/null; then - echo " Install openssl: apk add --no-cache openssl" - else - echo " Install openssl using your package manager." - fi - ;; - *) - echo " Install openssl using your package manager." - ;; - esac - exit 1 -fi -ok "openssl available" - -if ! command -v nc &> /dev/null; then - err "nc (netcat) is not installed." - case "$(uname -s)" in - Darwin) - echo " nc ships with macOS - check that /usr/bin is in your PATH." - ;; - Linux) - if command -v apt-get &> /dev/null; then - echo " Install netcat: sudo apt-get install -y netcat-openbsd" - elif command -v yum &> /dev/null; then - echo " Install netcat: sudo yum install -y nmap-ncat" - elif command -v dnf &> /dev/null; then - echo " Install netcat: sudo dnf install -y nmap-ncat" - elif command -v apk &> /dev/null; then - echo " Install netcat: apk add --no-cache netcat-openbsd" - else - echo " Install netcat using your package manager." - fi - ;; - *) - echo " Install netcat using your package manager." - ;; - esac - exit 1 -fi -ok "nc available" - -# -- 2. Create .env ---------------------------------------------------- - -step "[2/15] Setting up .env file" - -if [ ! -f .env ]; then - if [ ! -f .env.example ]; then - info "Downloading .env.example from GitHub..." - curl -fsSL "${REPO_RAW}/.env.example" -o .env.example.tmp || { - err "Failed to download .env.example from GitHub." - echo " Check your internet connection and try again." - exit 1 - } - mv -f .env.example.tmp .env.example - fi - cp .env.example .env.tmp && mv -f .env.tmp .env - ok "Created .env from .env.example" -else - warn ".env exists -- keeping existing config" -fi - -mkdir -p media/Movies media/Series -ok "Created media directories (media/Movies, media/Series)" - -# -- 3. Detect existing setup ------------------------------------------ - -step "[3/15] Detecting existing setup" - -state_file_found=false -if [ -f "$STATE_FILE" ]; then - state_file_found=true - info "Existing setup found ($STATE_FILE):" - dim " Database: $(state_get dbDriver)" - dim " qBittorrent: $(state_get qbittorrent)" - dim " Prowlarr: $(state_get prowlarr)" - dim " Media: $(state_get mediaProvider) ($(state_get mediaMode))" - dim " Add-ons: FlareSolverr=$(state_get flaresolverr) Dozzle=$(state_get dozzle)" - echo "" - if [ "$HAS_GUM" = true ]; then - gum confirm --default=true "Reconfigure your existing Lode setup?" || { - ok "Keeping existing setup - no changes made." - exit 0 - } - else - read -rp "Reconfigure your existing Lode setup? [Y/n] " reconf || reconf="y" - if [[ "$reconf" =~ ^[Nn] ]]; then - ok "Keeping existing setup - no changes made." - exit 0 - fi - fi -fi - -# Legacy installs (old all-or-nothing installer) deployed the full -# stack, so preselect everything local including Dozzle. -if [ "$LEGACY_INSTALL" = true ]; then - info "Existing .env without $STATE_FILE - assuming a previous full-stack install." -fi - -# Migrate legacy monolith compose files (they predate the split). -if is_legacy_monolith "docker-compose.sqlite.yml"; then - mv "docker-compose.sqlite.yml" "docker-compose.sqlite.yml.legacy" - warn "Moved legacy docker-compose.sqlite.yml to docker-compose.sqlite.yml.legacy" -fi -if is_legacy_monolith "docker-compose.postgres.yml"; then - mv "docker-compose.postgres.yml" "docker-compose.postgres.yml.legacy" - warn "Moved legacy docker-compose.postgres.yml to docker-compose.postgres.yml.legacy" -fi -ok "Setup detection complete" - -# -- 4. Generate secrets (idempotent) ---------------------------------- -# Secrets are only generated when missing or too short, so re-running -# the script never invalidates existing sessions or encrypted data. - -step "[4/15] Generating secrets" - -if validate_env_min_length "NUXT_SESSION_PASSWORD" 32; then - ok "Session password already set - keeping it" -else - SESSION_PASSWORD=$(generate_password 32) - update_env "NUXT_SESSION_PASSWORD" "$SESSION_PASSWORD" - ok "Session password generated" -fi - -if validate_env_min_length "NUXT_TRACKER_ENCRYPTION_KEY" 32; then - ok "Tracker encryption key already set - keeping it" -else - TRACKER_KEY=$(generate_hex 32) - update_env "NUXT_TRACKER_ENCRYPTION_KEY" "$TRACKER_KEY" - ok "Tracker encryption key generated" -fi - -# -- 5. Database driver choice ---------------------------------------- - -step "[5/15] Database driver" - -DB_DRIVER_CHOICE="sqlite" - -DB_CHOICE_PRE="" -if [ "$state_file_found" = true ]; then - case "$(state_get dbDriver)" in - postgres) DB_CHOICE_PRE="PostgreSQL" ;; - sqlite) DB_CHOICE_PRE="SQLite (recommended)" ;; - esac -else - existing_db_driver=$(read_env_value "DB_DRIVER") - if [ "$existing_db_driver" = "postgres" ]; then - DB_CHOICE_PRE="PostgreSQL" - elif [ -n "$existing_db_driver" ] && [ "$existing_db_driver" != "sqlite" ]; then - info "Existing database driver: $existing_db_driver" - fi -fi - -if [ "$HAS_GUM" != true ]; then - echo "" - echo "Choose your database driver:" - echo " SQLite - Zero config, file-based, recommended for most users" - echo " PostgreSQL - Full-featured, requires more resources" - echo "" -fi - -DB_CHOICE=$(gum_menu_selected "Select database driver:" "$DB_CHOICE_PRE" "SQLite (recommended)" "PostgreSQL") - -if [[ "$DB_CHOICE" == *"PostgreSQL"* ]]; then - DB_DRIVER_CHOICE="postgres" -else - DB_DRIVER_CHOICE="sqlite" -fi - -ok "Database driver: $DB_DRIVER_CHOICE" - -if [ "$DB_DRIVER_CHOICE" = "postgres" ]; then - if validate_env_min_length "POSTGRES_PASSWORD" 32; then - ok "PostgreSQL password already set - keeping it" - else - POSTGRES_PASSWORD=$(generate_password 32) - update_env "POSTGRES_PASSWORD" "$POSTGRES_PASSWORD" - ok "PostgreSQL password generated" - fi -fi - -# -- 6. Component selection --------------------------------------------- - -step "[6/15] Selecting components" - -echo "" -dim "Lode and Redis are always deployed. Choose the rest:" -echo "" - -# Preselect: state file (re-run) > legacy full-stack install > defaults. -QBIT_PRE="$QBIT_OPT_LOCAL" -PROWLARR_PRE="$PROWLARR_OPT_LOCAL" -MEDIA_PRE="$MEDIA_OPT_JELLYFIN_LOCAL" -USE_FLARESOLVERR=false -USE_DOZZLE=false - -if [ "$state_file_found" = true ]; then - case "$(state_get qbittorrent)" in - external) QBIT_PRE="$QBIT_OPT_EXTERNAL" ;; - esac - case "$(state_get prowlarr)" in - external) PROWLARR_PRE="$PROWLARR_OPT_EXTERNAL" ;; - esac - case "$(state_get mediaMode)" in - external) MEDIA_PRE="$MEDIA_OPT_JELLYFIN_EXTERNAL" ;; - none) MEDIA_PRE="$MEDIA_OPT_NONE" ;; - esac - if [ "$(state_get flaresolverr)" = "true" ]; then - USE_FLARESOLVERR=true - else - USE_FLARESOLVERR=false - fi - if [ "$(state_get dozzle)" = "true" ]; then - USE_DOZZLE=true - else - USE_DOZZLE=false - fi -elif [ "$LEGACY_INSTALL" = true ]; then - # The old installer ran Dozzle as part of the full stack. - USE_DOZZLE=true -fi - -# 6a. Torrent client -QBIT_CHOICE=$(gum_menu_selected "How should Lode download torrents?" "$QBIT_PRE" "$QBIT_OPT_LOCAL" "$QBIT_OPT_EXTERNAL") -case "$QBIT_CHOICE" in - *"External qBittorrent"*) - QBIT_MODE="external" - QBIT_URL=$(read_input_default "External qBittorrent URL (http://host:8080)" "$(external_url_default NUXT_QBITTORRENT_URL http://qbittorrent:8080)") - if ! validate_url "$QBIT_URL"; then - warn "External qBittorrent URL does not start with http(s):// - Lode will not be able to reach it" - fi - ;; - *) - QBIT_MODE="local" - QBIT_URL="" - ;; +case "$(uname -s)" in + Linux) OS="linux" ;; + Darwin) OS="darwin" ;; + *) die "unsupported OS: $(uname -s) - on Windows, use setup.ps1" ;; esac -ok "qBittorrent: $QBIT_MODE" - -# 6b. Indexer -PROWLARR_CHOICE=$(gum_menu_selected "How should Lode index torrents?" "$PROWLARR_PRE" "$PROWLARR_OPT_LOCAL" "$PROWLARR_OPT_EXTERNAL") -case "$PROWLARR_CHOICE" in - *"External Prowlarr"*) - PROWLARR_MODE="external" - PROWLARR_URL=$(read_input_default "External Prowlarr URL (http://host:9696)" "$(external_url_default NUXT_PROWLARR_URL http://prowlarr:9696)") - ;; - *) - PROWLARR_MODE="local" - PROWLARR_URL="" - ;; -esac -if [ "$PROWLARR_MODE" = "external" ]; then - if ! validate_url "$PROWLARR_URL"; then - warn "External Prowlarr URL does not start with http(s):// - Lode will not be able to reach it" - fi -fi -ok "Prowlarr: $PROWLARR_MODE" - -# 6c. Media server -MEDIA_CHOICE=$(gum_menu_selected "Media server (library detection)?" "$MEDIA_PRE" "$MEDIA_OPT_JELLYFIN_LOCAL" "$MEDIA_OPT_JELLYFIN_EXTERNAL" "$MEDIA_OPT_NONE") -case "$MEDIA_CHOICE" in - *"Jellyfin (external)"*) - MEDIA_PROVIDER="jellyfin" - MEDIA_MODE="external" - JELLYFIN_URL=$(read_input_default "External Jellyfin URL (http://host:8096)" "$(external_url_default NUXT_JELLYFIN_URL http://jellyfin:8096)") - ;; - *"No media server"*) - MEDIA_PROVIDER="none" - MEDIA_MODE="none" - JELLYFIN_URL="" - ;; - *) - MEDIA_PROVIDER="jellyfin" - MEDIA_MODE="local" - JELLYFIN_URL="" - ;; -esac -if [ "$MEDIA_MODE" = "external" ]; then - if ! validate_url "$JELLYFIN_URL"; then - warn "External Jellyfin URL does not start with http(s):// - Lode will not be able to reach it" - fi -fi -ok "Media server: ${MEDIA_PROVIDER} (${MEDIA_MODE})" - -# 6d. Add-ons (multi-select) -if [ "$HAS_GUM" = true ]; then - sel_args=() - if [ "$USE_FLARESOLVERR" = true ]; then - sel_args+=(--selected "$ADDON_OPT_FLARESOLVERR") - fi - if [ "$USE_DOZZLE" = true ]; then - sel_args+=(--selected "$ADDON_OPT_DOZZLE") - fi - if [ ${#sel_args[@]} -gt 0 ]; then - choices=$(gum choose --no-limit --header "Select optional add-ons:" "${sel_args[@]}" "$ADDON_OPT_FLARESOLVERR" "$ADDON_OPT_DOZZLE") || choices="" - else - choices=$(gum choose --no-limit --header "Select optional add-ons:" "$ADDON_OPT_FLARESOLVERR" "$ADDON_OPT_DOZZLE") || choices="" - fi -else - # Enter keeps the prefilled selection (state file / legacy / defaults). - pre_sel="" - if [ "$USE_FLARESOLVERR" = true ]; then - pre_sel="${pre_sel}1," - fi - if [ "$USE_DOZZLE" = true ]; then - pre_sel="${pre_sel}2," - fi - pre_sel="${pre_sel%,}" - if [ -n "$pre_sel" ]; then - default_note="keep current ($pre_sel)" - else - default_note="none" - fi - echo "Select optional add-ons (numbers separated by commas, e.g. 1,2 - Enter for $default_note):" - echo " 1) $ADDON_OPT_FLARESOLVERR" - echo " 2) $ADDON_OPT_DOZZLE" - choices="" - addon_input="" - read -rp "Add-ons: " addon_input || addon_input="" - if [ -z "$addon_input" ]; then - if [ "$USE_FLARESOLVERR" = true ]; then - choices="${choices}${ADDON_OPT_FLARESOLVERR}"$'\n' - fi - if [ "$USE_DOZZLE" = true ]; then - choices="${choices}${ADDON_OPT_DOZZLE}"$'\n' - fi - else - IFS=',' read -ra addon_parts <<< "$addon_input" - for part in "${addon_parts[@]}"; do - part="${part// /}" - case "$part" in - 1) choices="${choices}${ADDON_OPT_FLARESOLVERR}"$'\n' ;; - 2) choices="${choices}${ADDON_OPT_DOZZLE}"$'\n' ;; - "") : ;; - *) echo " Invalid selection: $part" ;; - esac - done - fi -fi -USE_FLARESOLVERR=false -USE_DOZZLE=false -while IFS= read -r choice_line; do - case "$choice_line" in - FlareSolverr*) USE_FLARESOLVERR=true ;; - Dozzle*) USE_DOZZLE=true ;; - esac -done <<< "$choices" -ok "Add-ons: FlareSolverr=$USE_FLARESOLVERR Dozzle=$USE_DOZZLE" - -# Build the compose file list for the selected stack. -COMPOSE_FILES=("$COMPOSE_BASE") -if [ "$DB_DRIVER_CHOICE" = "postgres" ]; then - COMPOSE_FILES+=("docker-compose.postgres.yml") -fi -if [ "$QBIT_MODE" = "local" ]; then - COMPOSE_FILES+=("docker-compose.qbittorrent.yml") -fi -if [ "$PROWLARR_MODE" = "local" ]; then - COMPOSE_FILES+=("docker-compose.prowlarr.yml") -fi -if [ "$MEDIA_MODE" = "local" ]; then - COMPOSE_FILES+=("docker-compose.jellyfin.yml") -fi -if [ "$USE_FLARESOLVERR" = true ]; then - COMPOSE_FILES+=("docker-compose.flaresolverr.yml") -fi -if [ "$USE_DOZZLE" = true ]; then - COMPOSE_FILES+=("docker-compose.dozzle.yml") -fi - -# -- 7. Download compose files ------------------------------------------ -# The lode image tag is written by the version step below - mask it -# when comparing, so tag-only differences never trigger a replace prompt. - -normalize_compose_tag() { - sed -E 's|^([[:space:]]*(#[[:space:]]*)?)(image:[[:space:]]*ghcr\.io/nort1346/lode:)[^[:space:]]+|\1\3|' "$1" -} - -step "[7/15] Downloading compose files" - -# Only files that existed before this step can be out of date - -# freshly downloaded files never get an update prompt. -EXISTING_COMPOSE_FILES=() -for compose_file in "${COMPOSE_FILES[@]}"; do - if [ -f "$compose_file" ]; then - EXISTING_COMPOSE_FILES+=("$compose_file") - fi -done - -# Missing files are downloaded directly - no prompt needed. -# Downloads land in a temp file first so an interrupted run never leaves a truncated compose file behind. -for compose_file in "${COMPOSE_FILES[@]}"; do - if [ ! -f "$compose_file" ]; then - info "Downloading ${compose_file}..." - curl -fsSL "${REPO_RAW}/${compose_file}" -o "${compose_file}.tmp" || { - err "Failed to download ${compose_file} from GitHub." - echo " Check your internet connection and try again." - exit 1 - } - mv -f "${compose_file}.tmp" "$compose_file" - ok "${compose_file} downloaded" - fi -done - -if [ ${#EXISTING_COMPOSE_FILES[@]} -gt 0 ]; then - do_update=false - if [ "$HAS_GUM" = true ]; then - if gum confirm --default=false "${#EXISTING_COMPOSE_FILES[@]} compose file(s) already exist. Download the latest versions from GitHub? (changed files keep a .bak backup)"; then - do_update=true - fi - else - read -rp "${#EXISTING_COMPOSE_FILES[@]} compose file(s) already exist. Download the latest versions from GitHub? [y/N] " answer || answer="" - if [[ "$answer" =~ ^[Yy]$ ]]; then - do_update=true - fi - fi - for compose_file in "${EXISTING_COMPOSE_FILES[@]}"; do - if [ "$do_update" = true ]; then - if curl -fsSL "${REPO_RAW}/${compose_file}" -o "${compose_file}.tmp" 2>/dev/null; then - if ! diff -q <(normalize_compose_tag "$compose_file") <(normalize_compose_tag "${compose_file}.tmp") &>/dev/null; then - cp "$compose_file" "${compose_file}.bak" - mv -f "${compose_file}.tmp" "$compose_file" - ok "${compose_file} updated (backup saved as ${compose_file}.bak)" - else - rm -f "${compose_file}.tmp" - ok "${compose_file} is already up to date" - fi - else - rm -f "${compose_file}.tmp" - warn "Could not download ${compose_file} - keeping your local copy" - fi - else - ok "Using existing ${compose_file}" - fi - done -fi - -# -- 8. Lode version choice -------------------------------------- -# The version choice is the single source of truth for the image tag: -# it is written into the base compose file here, after the download step. - -step "[8/15] Lode version" - -LODE_TAG="latest" - -VERSION_PRE="latest (recommended)" -if [ "$state_file_found" = true ] && [ "$(state_get imageTag)" = "nightly" ]; then - VERSION_PRE="nightly" -fi - -if [ "$HAS_GUM" != true ]; then - echo "" - echo "Which Lode image do you want to use?" - echo " latest - Stable release (recommended)" - echo " nightly - Latest dev build from main (may be unstable)" - echo "" -fi - -LODE_TAG_CHOICE=$(gum_menu_selected "Select version:" "$VERSION_PRE" "latest (recommended)" "nightly") - -if [[ "$LODE_TAG_CHOICE" == *"nightly"* ]]; then - LODE_TAG="nightly" -else - LODE_TAG="latest" -fi - -if grep -qE '^[[:space:]]*image:[[:space:]]*ghcr\.io/nort1346/lode:' "$COMPOSE_BASE"; then - sed -i.bak -E "s|^([[:space:]]*image:[[:space:]]*ghcr\.io/nort1346/lode:)[^[:space:]]+|\1${LODE_TAG}|" "$COMPOSE_BASE" && rm -f "${COMPOSE_BASE}.bak" - ok "Lode version: $LODE_TAG (image: ghcr.io/nort1346/lode:${LODE_TAG})" -else - warn "No lode image line found in $COMPOSE_BASE - check the image tag manually" -fi - -# Remember the selection so the next run can prefill the prompts. -write_state -ok "Selection saved to $STATE_FILE" - -# -- 9. Pull and start selected services ------------------------------ - -step "[9/15] Starting selected services" - -# Validate the merged configuration before touching the daemon. -if ! dc config -q 2>/dev/null; then - err "The selected compose files do not merge correctly." - err "Files: ${COMPOSE_FILES[*]}" - exit 1 -fi - -# Remove containers for services that are no longer selected locally. -if [ "$state_file_found" = true ]; then - stop_service_if_deselected "qbittorrent" "qbittorrent" "local" "$QBIT_MODE" - stop_service_if_deselected "prowlarr" "prowlarr" "local" "$PROWLARR_MODE" - stop_service_if_deselected "jellyfin" "mediaMode" "local" "$MEDIA_MODE" - stop_service_if_deselected "flaresolverr" "flaresolverr" "true" "$USE_FLARESOLVERR" - stop_service_if_deselected "dozzle" "dozzle" "true" "$USE_DOZZLE" - stop_service_if_deselected "postgres" "dbDriver" "postgres" "$DB_DRIVER_CHOICE" -fi - -INFRA_SERVICES=(redis) -if [ "$DB_DRIVER_CHOICE" = "postgres" ]; then - INFRA_SERVICES+=(postgres) -fi -if [ "$QBIT_MODE" = "local" ]; then - INFRA_SERVICES+=(qbittorrent) -fi -if [ "$PROWLARR_MODE" = "local" ]; then - INFRA_SERVICES+=(prowlarr) -fi -if [ "$MEDIA_MODE" = "local" ]; then - INFRA_SERVICES+=(jellyfin) -fi -if [ "$USE_FLARESOLVERR" = true ]; then - INFRA_SERVICES+=(flaresolverr) -fi -if [ "$USE_DOZZLE" = true ]; then - INFRA_SERVICES+=(dozzle) -fi - -info "Pulling images..." -dc pull || true - -if ! docker image inspect "ghcr.io/nort1346/lode:${LODE_TAG}" &> /dev/null; then - err "Failed to pull the Lode image (ghcr.io/nort1346/lode:${LODE_TAG}). Check your network and try again." - err "You can also try manually: $(dc_cmd_prefix) pull lode" - exit 1 -fi - -dc up -d "${INFRA_SERVICES[@]}" - -failed_services="" -for svc in "${INFRA_SERVICES[@]}"; do - if ! service_running "$svc"; then - failed_services="$failed_services $svc" - last_log=$(dc logs "$svc" --tail 3 2>&1 | tail -1) - warn "$svc failed to start: $last_log" - fi -done - -if [[ " $failed_services " =~ " redis " ]]; then - err "Redis failed to start. Cannot continue." - echo " Check logs: $(dc_cmd_prefix) logs redis" - exit 1 -fi -if [ "$DB_DRIVER_CHOICE" = "postgres" ] && [[ " $failed_services " =~ " postgres " ]]; then - err "PostgreSQL failed to start. Cannot continue." - echo " Check logs: $(dc_cmd_prefix) logs postgres" - exit 1 -fi -if [ "$QBIT_MODE" = "local" ] && [[ " $failed_services " =~ " qbittorrent " ]]; then - err "qBittorrent failed to start. Cannot continue." - echo " Check logs: $(dc_cmd_prefix) logs qbittorrent" - exit 1 -fi - -info "Waiting for Redis..." -wait_for_port "localhost" "6379" 30 || true -if [ "$QBIT_MODE" = "local" ]; then - info "Waiting for qBittorrent..." - wait_for_port "localhost" "8080" 60 || true -fi - -sleep 3 - -QBIT_TEMP_PASS="" -if [ "$QBIT_MODE" = "local" ]; then - QBIT_TEMP_PASS=$(dc logs qbittorrent 2>&1 | sed -n 's/.*A temporary password is provided for this session: *//p' | tail -1) || true -fi - -if [ "$PROWLARR_MODE" = "local" ]; then - if [[ " $failed_services " =~ " prowlarr " ]]; then - warn "Prowlarr not running -- you can configure it later (step 12)" - else - info "Waiting for Prowlarr..." - wait_for_port "localhost" "9900" 60 || true - fi -fi - -if [ "$MEDIA_MODE" = "local" ]; then - if [[ " $failed_services " =~ " jellyfin " ]]; then - warn "Jellyfin not running -- you can configure it later (step 10)" - else - info "Waiting for Jellyfin..." - wait_for_port "localhost" "8096" 90 || true - fi -fi - -ok "Selected services are running" - -if [ "$DB_DRIVER_CHOICE" = "postgres" ]; then - info "Waiting for PostgreSQL..." - wait_for_port "localhost" "5432" 30 || true -fi - -info "Waiting 10s for services to fully initialize..." -sleep 10 - -# -- Clipboard read (primary secret entry) ----------------------------- -# Secrets are entered by copying the value in the browser and pressing -# Enter here - the script reads it from the clipboard. Type m at the -# gate to paste manually for that one field. Without a clipboard tool -# (headless server), the manual prompt is used directly. - -CLIPBOARD_CMD="" - -detect_clipboard() { - case "$(uname -s)" in - Darwin) - command -v pbpaste >/dev/null 2>&1 && CLIPBOARD_CMD="pbpaste" - ;; - Linux) - if [ -n "${WAYLAND_DISPLAY:-}" ]; then - command -v wl-paste >/dev/null 2>&1 && CLIPBOARD_CMD="wl-paste" - elif command -v xclip >/dev/null 2>&1; then - CLIPBOARD_CMD="xclip" - elif command -v xsel >/dev/null 2>&1; then - CLIPBOARD_CMD="xsel" - fi - ;; - esac -} - -# Prints the clipboard content to stdout (exact content, no extras). -read_clipboard() { - case "$CLIPBOARD_CMD" in - pbpaste) pbpaste ;; - xclip) xclip -selection clipboard -o ;; - xsel) xsel --clipboard --output ;; - wl-paste) wl-paste --no-newline ;; - esac -} - -# read_secret -# Prints the value to stdout (captured by the caller); every user-facing -# line goes to stderr so command substitution captures only the value. -read_secret() { - local name="$1" answer="" value="" - if [ -z "$CLIPBOARD_CMD" ]; then - dim "No clipboard tool available - paste manually (right-click or Ctrl+Shift+V, not Ctrl+C)." >&2 - read_password "Paste your $name (Enter to skip)" - return - fi - while true; do - echo "Copy your $name to your clipboard, then press Enter" >&2 - dim "Type m and press Enter to paste manually instead." >&2 - read -rp "> " answer || return - case "$answer" in - m | M) - read_password "Paste your $name (Enter to skip)" - return - ;; - "") - value=$(read_clipboard 2>/dev/null) - value="${value//$'\r'/}" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - if [ -n "$value" ]; then - ok "Received from clipboard (${#value} characters)" >&2 - printf '%s' "$value" - else - warn "Clipboard is empty - paste manually instead." >&2 - dim "Paste with right-click or Ctrl+Shift+V (not Ctrl+C)." >&2 - read_password "Paste your $name (Enter to skip)" - fi - return - ;; - *) - warn "Press Enter to read the clipboard, or m to paste manually." >&2 - ;; - esac - done -} - -detect_clipboard - -# -- 10. Jellyfin API Key ---------------------------------------------- - -step "[10/15] Jellyfin API key" - -if [ "$MEDIA_PROVIDER" = "none" ]; then - update_env "NUXT_JELLYFIN_URL" "" - update_env "NUXT_JELLYFIN_API_KEY" "" - info "No media server selected - NUXT_JELLYFIN_URL and NUXT_JELLYFIN_API_KEY cleared" -elif [ "$MEDIA_MODE" = "local" ]; then - echo "" - echo "Follow these steps to get your Jellyfin API key:" - dim " 1. Open $(hyperlink 'http://localhost:8096') in your browser" - dim " 2. Complete the setup wizard (create your admin account)" - dim " 3. Go to Dashboard (gear icon) > API Keys" - dim ' 4. Click the + button, name it Lode, click OK' - dim " 5. Copy the generated API key" - echo "" - jellyfinKey=$(read_secret "Jellyfin API key") - if [ -n "$jellyfinKey" ]; then - update_env "NUXT_JELLYFIN_API_KEY" "$jellyfinKey" - ok "Jellyfin API key saved" - else - warn "Skipping Jellyfin API key -- set it later in .env" - fi -else - echo "" - echo "Your external Jellyfin instance: $(hyperlink "$JELLYFIN_URL")" - dim "Create an API key in Jellyfin: Dashboard (gear icon) > API Keys" - echo "" - jellyfinKey=$(read_secret "Jellyfin API key") - if [ -n "$jellyfinKey" ]; then - update_env "NUXT_JELLYFIN_API_KEY" "$jellyfinKey" - ok "Jellyfin API key saved" - else - warn "Skipping Jellyfin API key -- set it later in .env" - fi -fi - -# -- 11. qBittorrent WebUI + API Key ----------------------------------- - -step "[11/15] qBittorrent WebUI + API key" - -if [ "$QBIT_MODE" = "local" ]; then - if [ -n "${QBIT_TEMP_PASS:-}" ]; then - copy_to_clipboard "$QBIT_TEMP_PASS" - echo "" - echo -e "${BOLD}${YELLOW}qBittorrent temporary password: ${QBIT_TEMP_PASS}${NC}" - dim "✓ Copied to clipboard (this replaces your previous clipboard contents)" - echo "" - else - warn "Could not extract qBittorrent temp password - check: $(dc_cmd_prefix) logs qbittorrent" - fi - - echo "Follow these steps to configure qBittorrent:" - dim " 1. Open $(hyperlink 'http://localhost:8080') in your browser" - dim " 2. Login with:" - dim " Username: admin" - dim " Password: [temporary password shown above]" - dim " 3. Go to Tools > Options > Web UI" - dim " 4. Change the password to something you remember" - dim " 5. Save changes" - dim " 6. Go to Tools > Options > Web UI > API Key section" - dim " 7. Copy the API Key" - echo "" - - qbitKey=$(read_secret "qBittorrent API key") - if [ -n "$qbitKey" ]; then - update_env "NUXT_QBITTORRENT_API_KEY" "$qbitKey" - ok "qBittorrent API key saved" - else - warn "Skipping qBittorrent API key -- set it later in .env" - fi -else - echo "" - echo "Your external qBittorrent instance: $(hyperlink "$QBIT_URL")" - dim "Find the API key in qBittorrent: Tools > Options > Web UI" - echo "" - qbitKey=$(read_secret "qBittorrent API key") - if [ -n "$qbitKey" ]; then - update_env "NUXT_QBITTORRENT_API_KEY" "$qbitKey" - ok "qBittorrent API key saved" - else - warn "Skipping qBittorrent API key -- set it later in .env" - fi -fi - -# -- 12. Prowlarr API Key ---------------------------------------------- - -step "[12/15] Prowlarr API key" - -if [ "$PROWLARR_MODE" = "local" ]; then - echo "" - echo "Follow these steps to get your Prowlarr API key:" - dim " 1. Open $(hyperlink 'http://localhost:9900') in your browser" - dim " 2. Go to Settings > General" - dim " 3. Find the API Key field" - dim " 4. Copy the API key" - echo "" - echo -e "${BOLD}${YELLOW}IMPORTANT: Prowlarr needs indexers before Lode can find anything.${NC}" - dim " 1. Add at least one indexer (e.g. YTS): Settings > Indexers > Add" - if [ "$USE_FLARESOLVERR" = true ]; then - dim " 2. For private trackers: Settings > Indexers > Add > FlareSolverr" - dim " Set URL: http://flaresolverr:8191" - fi - echo "" -else - echo "" - echo "Your external Prowlarr instance: $(hyperlink "$PROWLARR_URL")" - dim "Find the API key in Prowlarr: Settings > General" - echo "" -fi - -prowlarrKey=$(read_secret "Prowlarr API key") - -if [ -n "$prowlarrKey" ]; then - update_env "NUXT_PROWLARR_API_KEY" "$prowlarrKey" - ok "Prowlarr API key saved" -else - warn "Skipping Prowlarr API key -- set it later in .env" -fi - -# -- 13. TMDB API Key -------------------------------------------------- - -step "[13/15] TMDB API key" - -echo "" -echo "Follow these steps to get your TMDB API key:" -dim " 1. Go to $(hyperlink 'https://www.themoviedb.org/settings/api')" -dim " 2. Create a free account (or log in)" -dim ' 3. Click the link to generate an API key' -dim " 4. Fill in the form:" -dim " Application Name: Lode" -dim " Application URL: $(hyperlink 'http://localhost:5757')" -dim " 5. Copy your API Key (v3 auth)" -echo "" -dim "This is required for movie/TV metadata." -echo "" - -tmdbKey=$(read_secret "TMDB API key") - -if [ -n "$tmdbKey" ]; then - update_env "NUXT_TMDB_API_KEY" "$tmdbKey" - ok "TMDB API key saved" -else - warn "Skipping TMDB API key -- set it later in .env" -fi - -# -- 14. Discord Webhook (optional) ----------------------------------- - -step "[14/15] Discord Webhook (optional)" - -set_webhook=false -if [ "$HAS_GUM" = true ]; then - if gum confirm --default=false "Get notified in Discord when downloads complete. Set up a webhook now?"; then - set_webhook=true - fi -else - read -rp "Get notified in Discord when downloads complete. Set up a webhook now? [y/N] " answer || answer="" - if [[ "$answer" =~ ^[Yy]$ ]]; then - set_webhook=true - fi -fi - -if [ "$set_webhook" = true ]; then - echo "" - echo "To set up a Discord webhook:" - dim " 1. Open your Discord server" - dim " 2. Go to Server Settings > Integrations > Webhooks" - dim ' 3. Click New Webhook' - dim " 4. Name it, choose a channel, click Copy Webhook URL" - echo "" - - discordKey=$(read_secret "Discord Webhook URL") - - if [ -n "$discordKey" ]; then - update_env "NUXT_DISCORD_WEBHOOK_URL" "$discordKey" - ok "Discord webhook URL saved" - else - warn "Skipping Discord webhook -- set it later in .env" - fi -else - dim "Skipped -- set NUXT_DISCORD_WEBHOOK_URL in .env later if you change your mind." -fi - -# -- 15. Start Lode ---------------------------------------------------- - -step "[15/15] Starting Lode" - -update_env "NUXT_REDIS_URL" "redis://redis:6379" -update_env "DB_DRIVER" "$DB_DRIVER_CHOICE" - -case "$QBIT_MODE" in - local) update_env "NUXT_QBITTORRENT_URL" "http://qbittorrent:8080" ;; - external) update_env "NUXT_QBITTORRENT_URL" "$QBIT_URL" ;; -esac -case "$PROWLARR_MODE" in - local) update_env "NUXT_PROWLARR_URL" "http://prowlarr:9696" ;; - external) update_env "NUXT_PROWLARR_URL" "$PROWLARR_URL" ;; +case "$(uname -m)" in + x86_64 | amd64) ARCH="x64" ;; + arm64 | aarch64) ARCH="arm64" ;; + *) die "unsupported CPU architecture: $(uname -m)" ;; esac -case "$MEDIA_MODE" in - local) update_env "NUXT_JELLYFIN_URL" "http://jellyfin:8096" ;; - external) update_env "NUXT_JELLYFIN_URL" "$JELLYFIN_URL" ;; -esac -if [ "$USE_FLARESOLVERR" = true ]; then - update_env "NUXT_FLARESOLVERR_URL" "http://flaresolverr:8191" -else - update_env "NUXT_FLARESOLVERR_URL" "" -fi -if [ "$DB_DRIVER_CHOICE" = "postgres" ]; then - PG_PASS=$(read_env_value "POSTGRES_PASSWORD") - update_env "DATABASE_URL" "postgresql://lode:${PG_PASS}@postgres:5432/lode" -fi +ASSET="lode-setup-${OS}-${ARCH}" +# releases/latest/download/ redirects to the asset in the current +# latest release; the tag is recoverable from the Location header. +ASSET_URL="https://github.com/${REPO}/releases/latest/download/${ASSET}" -info "Starting Lode..." -dc up -d lode || true - -if ! service_running lode; then - err "Lode container failed to start. Check logs:" - err " $(dc_cmd_prefix) logs lode" - exit 1 +TAG="latest" +LOCATION="$(curl -fsSI "$ASSET_URL" 2>/dev/null | tr -d '\r' | awk 'tolower($1) == "location:" { print $2 }' | head -n 1 || true)" +if [[ "${LOCATION:-}" == *"releases/download/"* ]]; then + TAG="${LOCATION##*releases/download/}" + TAG="${TAG%%/*}" fi -info "Waiting for Lode to start (first start may take 1-2 minutes)..." -wait_for_port "localhost" "5757" 120 || true - -ok "Lode is running at $(hyperlink 'http://localhost:5757')" - -# -- Extract admin password from logs --------------------------------- - -ADMIN_PASS="" -for _retry in 1 2 3 4 5; do - ADMIN_PASS=$(dc logs --no-color --tail 200 lode 2>&1 \ - | grep 'Admin password:' \ - | sed 's/.*Admin password: //' | sed 's/".*//' | head -1) || true - if [ -n "$ADMIN_PASS" ]; then break; fi - sleep 2 -done - -# -- Summary ----------------------------------------------------------- - -echo "" -echo -e "${BOLD}${GREEN}Lode is ready!${NC}" -echo "" +CACHE_DIR="${XDG_CACHE_HOME:-${HOME}/.cache}/lode-setup/${TAG}" +BIN="${CACHE_DIR}/${ASSET}" -# -- Services table -ROWS=("$(summary_row "Lode" "http://localhost:5757")") -case "$QBIT_MODE" in - local) ROWS+=("$(summary_row "qBittorrent" "http://localhost:8080")") ;; - external) ROWS+=("$(summary_row "qBittorrent" "$QBIT_URL")") ;; -esac -case "$PROWLARR_MODE" in - local) ROWS+=("$(summary_row "Prowlarr" "http://localhost:9900")") ;; - external) ROWS+=("$(summary_row "Prowlarr" "$PROWLARR_URL")") ;; -esac -case "$MEDIA_MODE" in - local) ROWS+=("$(summary_row "Jellyfin" "http://localhost:8096")") ;; - external) ROWS+=("$(summary_row "Jellyfin" "$JELLYFIN_URL")") ;; - none) ROWS+=("$(summary_row "Jellyfin" "(disabled)")") ;; -esac -if [ "$USE_FLARESOLVERR" = true ]; then - ROWS+=("$(summary_row "FlareSolverr" "http://localhost:8191")") -fi -ROWS+=("$(summary_row "Database" "$DB_DRIVER_CHOICE")") -if [ "$DB_DRIVER_CHOICE" = "postgres" ]; then - ROWS+=("$(summary_row "PostgreSQL" "localhost:5432 / lode")") -fi -if [ "$USE_DOZZLE" = true ]; then - ROWS+=("$(summary_row "Dozzle" "http://localhost:8082")") +if [ ! -x "$BIN" ]; then + echo "Downloading ${ASSET} (${TAG})..." + mkdir -p "$CACHE_DIR" + curl -fsSL -o "${BIN}.tmp" "$ASSET_URL" || die "download failed: ${ASSET_URL}" + chmod +x "${BIN}.tmp" + mv -f "${BIN}.tmp" "$BIN" fi -summary_section "$(printf '%s\n' "${ROWS[@]}")" - -echo "" - -# -- Credentials -echo "Username: $(bold admin)" -if [ -n "$ADMIN_PASS" ]; then - copy_to_clipboard "$ADMIN_PASS" - echo "Password: $(bold "$ADMIN_PASS")" - dim "✓ Copied to clipboard (this replaces your previous clipboard contents)" -else - dim "Password: check '$(dc_cmd_prefix) logs lode'" -fi -dim "Change this password after first login." - -echo "" -# -- Required before first use -echo -e "${BOLD}${YELLOW}Required before first use:${NC}" -if [ "$PROWLARR_MODE" = "local" ]; then - dim " Prowlarr has no indexers yet - Lode cannot find torrents until you add them." - dim " Open $(hyperlink 'http://localhost:9900') and add at least one indexer (e.g. YTS)." - if [ "$USE_FLARESOLVERR" = true ]; then - dim " For private trackers: Settings > Indexers > Add > FlareSolverr, URL: http://flaresolverr:8191" - fi -else - dim " Your external Prowlarr needs at least one indexer before Lode can find torrents." +# `curl | bash` feeds this script a pipe on stdin; the interactive setup +# needs a real TTY, so re-point stdin at the terminal when available. +if [ ! -t 0 ] && [ -e /dev/tty ]; then + exec < /dev/tty fi -exit 0 +exec "$BIN" "$@" From cf003425c4e96fe03dc35f2c553dd4019670ccff Mon Sep 17 00:00:00 2001 From: Nort <118631044+Nort1346@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:42:10 +0200 Subject: [PATCH 2/8] feat(setup): add Lode title banner at setup start and end --- cli/bun.lock | 27 +++++++++++++- cli/package.json | 3 +- cli/src/core/banner.ts | 22 ++++++++++++ cli/src/index.ts | 3 ++ cli/test/core/banner.test.ts | 70 ++++++++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 cli/src/core/banner.ts create mode 100644 cli/test/core/banner.test.ts diff --git a/cli/bun.lock b/cli/bun.lock index b895bf1..0ebc219 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -6,6 +6,7 @@ "name": "lode-setup-cli", "dependencies": { "@clack/prompts": "^1.2.0", + "cfonts": "^3.3.1", }, "devDependencies": { "@eslint/js": "^9.25.0", @@ -151,6 +152,8 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "cfonts": ["cfonts@3.3.1", "", { "dependencies": { "supports-color": "^8", "window-size": "^1" }, "bin": { "cfonts": "bin/index.js" } }, "sha512-ZGEmN3W9mViWEDjsuPo4nK4h39sfh6YtoneFYp9WLPI/rw8BaSSrfQC6jkrGW3JMvV3ZnExJB/AEqXc/nHYxkw=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "4.3.0", "supports-color": "7.2.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -169,6 +172,8 @@ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "define-property": ["define-property@1.0.0", "", { "dependencies": { "is-descriptor": "^1.0.0" } }, "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], @@ -219,22 +224,36 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "1.0.1", "resolve-from": "4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "is-accessor-descriptor": ["is-accessor-descriptor@1.0.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-AIbwAcazqP3R65dGvqk1V+a+vE5Fg1yu/ZKMOiBWSUIXXiwQkYmXQcVa2O0nh0tSDKDFKxG2mY7dB1Sr4hEP1g=="], + + "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="], + + "is-data-descriptor": ["is-data-descriptor@1.0.1", "", { "dependencies": { "hasown": "^2.0.0" } }, "sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw=="], + + "is-descriptor": ["is-descriptor@1.0.4", "", { "dependencies": { "is-accessor-descriptor": "^1.0.2", "is-data-descriptor": "^1.0.1" } }, "sha512-bv5z95W0dDtLfKwDfkTNxaRxmISBD3eQBKJeVxv2AQ7MjuUnDNG7cIQqvFtMOUYhsILWHhMayWdoGqNqYYYjww=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-number": ["is-number@3.0.0", "", { "dependencies": { "kind-of": "^3.0.2" } }, "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], @@ -247,6 +266,8 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "1.2.1", "type-check": "0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], @@ -335,7 +356,7 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -365,6 +386,8 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "window-size": ["window-size@1.1.1", "", { "dependencies": { "define-property": "^1.0.0", "is-number": "^3.0.0" }, "bin": { "window-size": "cli.js" } }, "sha512-5D/9vujkmVQ7pSmc0SCBmHXbkv6eaHwXEx65MywhmUMsI8sGqJ972APq1lotfcwMKPFLuCFfL8xGHLIp7jaBmA=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -379,6 +402,8 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "4.0.4" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], diff --git a/cli/package.json b/cli/package.json index b108cc6..dfa6f5b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -12,7 +12,8 @@ "test:watch": "vitest" }, "dependencies": { - "@clack/prompts": "^1.2.0" + "@clack/prompts": "^1.2.0", + "cfonts": "^3.3.1" }, "devDependencies": { "@eslint/js": "^9.25.0", diff --git a/cli/src/core/banner.ts b/cli/src/core/banner.ts new file mode 100644 index 0000000..ec19b91 --- /dev/null +++ b/cli/src/core/banner.ts @@ -0,0 +1,22 @@ +// Large "Lode" title, shown at startup and again at the end of the setup. +const GRADIENT = ['#fbbf24', '#f59e0b'] as const + +function colorDisabled(): boolean { + return process.env.NO_COLOR !== undefined || process.env.TERM === 'dumb' +} + +export async function showTitleBanner(): Promise { + // Non-TTY (CI, pipes) - skip the banner entirely. + if (process.stdout.isTTY !== true) return + // cfonts bundles every font it ships; defer the cost until it is actually needed. + const { render } = await import('cfonts') + const width = process.stdout.columns ?? 80 + // NO_COLOR / TERM=dumb: same art, no colour codes. + // transitionGradient blends the two stops directly - without it cfonts invents a + // full hue-wheel "impressive" gradient, which reads as a rainbow. + const settings: object = colorDisabled() + ? { font: 'block', align: 'left', spaceless: true } + : { font: 'block', gradient: [...GRADIENT], transitionGradient: true, align: 'left', spaceless: true } + const out = render('Lode', settings, false, 0, { width, height: 30 }) + if (out) process.stdout.write(out.string + '\n') +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 0becf65..36e7669 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,3 +1,4 @@ +import { showTitleBanner } from './core/banner' import { SetupFailure } from './core/errors' import { isTty, pauseBeforeExit } from './core/platform' import { log, outro } from './core/prompt' @@ -27,6 +28,7 @@ async function main(): Promise { console.error('Run it from a terminal - `curl | bash` and `irm | iex` are handled by the setup scripts.') process.exit(1) } + await showTitleBanner() const ctx: StepContext = { stateFound: false, previousSelection: null, @@ -54,6 +56,7 @@ async function main(): Promise { await startLode(ctx) showSummary(ctx) outro('Setup complete - enjoy!') + await showTitleBanner() await pauseBeforeExit() } diff --git a/cli/test/core/banner.test.ts b/cli/test/core/banner.test.ts new file mode 100644 index 0000000..d9c976b --- /dev/null +++ b/cli/test/core/banner.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { showTitleBanner } from '../../src/core/banner' + +// ESC byte, built without a control character in the source. +const ESC = String.fromCharCode(27) +const BLOCK = '\u2588' + +function setStdout(tty: boolean, columns: number | undefined): void { + Object.defineProperty(process.stdout, 'isTTY', { value: tty, configurable: true, writable: true }) + Object.defineProperty(process.stdout, 'columns', { value: columns, configurable: true, writable: true }) +} + +let written: string[] +const envBackup = { NO_COLOR: process.env.NO_COLOR, TERM: process.env.TERM } + +function firstWritten(): string { + const chunk = written[0] + if (chunk === undefined) throw new Error('banner did not write a string to stdout') + return chunk +} + +beforeEach(() => { + written = [] + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { + if (typeof chunk === 'string') written.push(chunk) + return true + }) +}) + +afterEach(() => { + vi.restoreAllMocks() + if (envBackup.NO_COLOR === undefined) delete process.env.NO_COLOR + else process.env.NO_COLOR = envBackup.NO_COLOR + if (envBackup.TERM === undefined) delete process.env.TERM + else process.env.TERM = envBackup.TERM +}) + +describe('showTitleBanner', () => { + it('prints nothing when stdout is not a TTY', async () => { + setStdout(false, undefined) + await showTitleBanner() + expect(written).toHaveLength(0) + }) + + it('prints the colored block banner on a TTY', async () => { + setStdout(true, 100) + await showTitleBanner() + const out = firstWritten() + expect(out).toContain(BLOCK) + expect(out.includes(ESC)).toBe(true) + }) + + it('prints a monochrome banner when NO_COLOR is set', async () => { + process.env.NO_COLOR = '' + setStdout(true, 100) + await showTitleBanner() + const out = firstWritten() + expect(out).toContain(BLOCK) + expect(out.includes(ESC)).toBe(false) + }) + + it('prints a monochrome banner when TERM=dumb', async () => { + process.env.TERM = 'dumb' + setStdout(true, 100) + await showTitleBanner() + const out = firstWritten() + expect(out).toContain(BLOCK) + expect(out.includes(ESC)).toBe(false) + }) +}) From 08860e0a29e0e2046507b5269e037300ff18f82e Mon Sep 17 00:00:00 2001 From: Nort <118631044+Nort1346@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:58:36 +0200 Subject: [PATCH 3/8] refactor(setup): drop restating comments from cli source --- cli/src/core/banner.ts | 3 --- cli/src/core/env.ts | 1 - cli/src/core/platform.ts | 1 - cli/src/types.ts | 10 ---------- 4 files changed, 15 deletions(-) diff --git a/cli/src/core/banner.ts b/cli/src/core/banner.ts index ec19b91..caca9b6 100644 --- a/cli/src/core/banner.ts +++ b/cli/src/core/banner.ts @@ -1,4 +1,3 @@ -// Large "Lode" title, shown at startup and again at the end of the setup. const GRADIENT = ['#fbbf24', '#f59e0b'] as const function colorDisabled(): boolean { @@ -6,12 +5,10 @@ function colorDisabled(): boolean { } export async function showTitleBanner(): Promise { - // Non-TTY (CI, pipes) - skip the banner entirely. if (process.stdout.isTTY !== true) return // cfonts bundles every font it ships; defer the cost until it is actually needed. const { render } = await import('cfonts') const width = process.stdout.columns ?? 80 - // NO_COLOR / TERM=dumb: same art, no colour codes. // transitionGradient blends the two stops directly - without it cfonts invents a // full hue-wheel "impressive" gradient, which reads as a rainbow. const settings: object = colorDisabled() diff --git a/cli/src/core/env.ts b/cli/src/core/env.ts index 9d16f5e..7fd99cb 100644 --- a/cli/src/core/env.ts +++ b/cli/src/core/env.ts @@ -25,7 +25,6 @@ export function envValueMeetsMinLength(dir: string, file: string, key: string, m return value.length >= min } -// Replace an active KEY= line, else uncomment a "# KEY=" line, else append. // String-based (not sed-style) so values containing & or $ survive intact. export function updateEnvFile(content: string, key: string, value: string): string { const lines = content.split('\n') diff --git a/cli/src/core/platform.ts b/cli/src/core/platform.ts index 55d7746..6ba6f4b 100644 --- a/cli/src/core/platform.ts +++ b/cli/src/core/platform.ts @@ -4,7 +4,6 @@ import { run } from './exec' // Parents that close their console window when the child exits (double-click, // Start Menu, RDP, VS Code integrated terminal). const TRANSIENT_PARENTS = new Set(['explorer.exe', 'conhost.exe', 'openconsole.exe', 'windowsterminal.exe', 'code.exe']) -// Intermediaries to walk through without concluding. const PASSTHROUGH_PARENTS = new Set(['cmd.exe', 'conhost.exe', 'openconsole.exe']) export function isTty(): boolean { diff --git a/cli/src/types.ts b/cli/src/types.ts index 5b4d926..23d0ab7 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -1,19 +1,13 @@ -/** Database backend for Lode. */ export type DbDriver = 'sqlite' | 'postgres' -/** Lode image tag. */ export type LodeTag = 'latest' | 'nightly' -/** Whether a service runs as a local container or an external instance. */ export type ServiceMode = 'local' | 'external' -/** Media server backend for library detection. */ export type MediaProvider = 'jellyfin' | 'none' -/** Deployment mode for the media server. */ export type MediaMode = 'local' | 'external' | 'none' -/** Compose files shipped with the repo (base + per-service overlays). */ export type ComposeFileName = | 'docker-compose.yml' | 'docker-compose.postgres.yml' @@ -42,7 +36,6 @@ export interface ExternalUrls { jellyfin: string } -/** Mutable context threaded through every setup step. */ export interface StepContext { /** True when a .lode-setup state file existed before this run. */ stateFound: boolean @@ -57,21 +50,18 @@ export interface StepContext { adminPass: string } -/** An option for select-style prompts. */ export interface PromptOption { value: T label: string hint?: string } -/** Result of an external process run. */ export interface CommandResult { code: number stdout: string stderr: string } -/** A row in the final services summary table. */ export interface SummaryRow { label: string value: string From c01f99b8742f65597a899f8d2b25d5a3d6ae6b0b Mon Sep 17 00:00:00 2001 From: Nort <118631044+Nort1346@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:30:44 +0200 Subject: [PATCH 4/8] feat(setup): live progress spinners, warning callouts, and banner spacing --- cli/.gitignore | 2 + cli/src/core/banner.ts | 4 +- cli/src/core/docker.ts | 11 ++- cli/src/core/exec.ts | 22 ++++- cli/src/core/prompt.ts | 24 +++--- cli/src/index.ts | 5 +- cli/src/steps/compose-files.ts | 9 +- cli/src/steps/env-file.ts | 11 +-- cli/src/steps/prerequisites.ts | 34 +++++--- cli/src/steps/services.ts | 149 ++++++++++++++++++++++----------- cli/src/steps/start-lode.ts | 26 +++--- cli/src/steps/summary.ts | 46 +++++----- cli/src/types.ts | 18 +++- 13 files changed, 236 insertions(+), 125 deletions(-) create mode 100644 cli/.gitignore diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..99f6d2f --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,2 @@ +docker-compose* +.env* diff --git a/cli/src/core/banner.ts b/cli/src/core/banner.ts index caca9b6..5e96df0 100644 --- a/cli/src/core/banner.ts +++ b/cli/src/core/banner.ts @@ -4,7 +4,7 @@ function colorDisabled(): boolean { return process.env.NO_COLOR !== undefined || process.env.TERM === 'dumb' } -export async function showTitleBanner(): Promise { +export async function showTitleBanner(leadingBreak = false, trailingBreak = false): Promise { if (process.stdout.isTTY !== true) return // cfonts bundles every font it ships; defer the cost until it is actually needed. const { render } = await import('cfonts') @@ -15,5 +15,5 @@ export async function showTitleBanner(): Promise { ? { font: 'block', align: 'left', spaceless: true } : { font: 'block', gradient: [...GRADIENT], transitionGradient: true, align: 'left', spaceless: true } const out = render('Lode', settings, false, 0, { width, height: 30 }) - if (out) process.stdout.write(out.string + '\n') + if (out) process.stdout.write((leadingBreak ? '\n' : '') + out.string + '\n' + (trailingBreak ? '\n' : '')) } diff --git a/cli/src/core/docker.ts b/cli/src/core/docker.ts index 42d52fb..3485baf 100644 --- a/cli/src/core/docker.ts +++ b/cli/src/core/docker.ts @@ -1,5 +1,5 @@ import { LODE_IMAGE } from '../constants' -import type { ComposeFileName } from '../types' +import type { CommandResult, ComposeFileName } from '../types' import { run } from './exec' export function composeFileArgs(files: readonly ComposeFileName[]): string[] { @@ -13,6 +13,15 @@ export function compose(files: readonly ComposeFileName[], ...args: string[]) { return run('docker', ['compose', ...composeFileArgs(files), ...args]) } +// Runs compose and reports each output line to onLine; raw output stays captured. +export function composeOnLine( + files: readonly ComposeFileName[], + args: readonly string[], + onLine: (line: string) => void +): Promise { + return run('docker', ['compose', ...composeFileArgs(files), ...args], { onLine }) +} + // Copy-pasteable prefix for manual-command hints. export function dcCmdPrefix(files: readonly ComposeFileName[]): string { return `docker compose ${composeFileArgs(files).join(' ')}` diff --git a/cli/src/core/exec.ts b/cli/src/core/exec.ts index 9ed45be..d0e6da6 100644 --- a/cli/src/core/exec.ts +++ b/cli/src/core/exec.ts @@ -3,6 +3,8 @@ import type { CommandResult } from '../types' interface RunOptions { cwd?: string + /** Called for each complete line of stdout/stderr as it arrives (raw output stays captured). */ + onLine?: (line: string) => void } // Never throws: spawn failures (ENOENT, etc.) resolve with code 127 so callers @@ -12,12 +14,24 @@ export function run(command: string, args: readonly string[] = [], options: RunO let stdout = '' let stderr = '' let settled = false + const outPending = { text: '' } + const errPending = { text: '' } + const feed = (pending: { text: string }, text: string) => { + pending.text += text + const parts = pending.text.split(/[\r\n]+/) + pending.text = parts.pop() ?? '' + if (options.onLine) for (const line of parts) options.onLine(line) + } const child = spawn(command, args, { cwd: options.cwd }) child.stdout.on('data', (chunk: Buffer) => { - stdout += chunk.toString() + const text = chunk.toString() + stdout += text + feed(outPending, text) }) child.stderr.on('data', (chunk: Buffer) => { - stderr += chunk.toString() + const text = chunk.toString() + stderr += text + feed(errPending, text) }) child.on('error', (error: NodeJS.ErrnoException) => { if (settled) return @@ -27,6 +41,10 @@ export function run(command: string, args: readonly string[] = [], options: RunO child.on('close', (code) => { if (settled) return settled = true + if (options.onLine) { + if (outPending.text) options.onLine(outPending.text) + if (errPending.text) options.onLine(errPending.text) + } resolve({ code: code ?? 1, stdout, stderr }) }) }) diff --git a/cli/src/core/prompt.ts b/cli/src/core/prompt.ts index 124adb3..857601d 100644 --- a/cli/src/core/prompt.ts +++ b/cli/src/core/prompt.ts @@ -1,5 +1,7 @@ +import { styleText } from 'node:util' import { CANCEL_SYMBOL, + box, cancel, confirm, isCancel, @@ -7,11 +9,10 @@ import { multiselect, note, password, + S_WARN, select, - spinner, text } from '@clack/prompts' -import type { SpinnerResult } from '@clack/prompts' import { TOTAL_STEPS } from '../constants' import type { PromptOption } from '../types' import { clipboardAvailable, readClipboard } from './clipboard' @@ -26,6 +27,13 @@ export function instructions(title: string, lines: readonly string[]): void { note(lines.join('\n'), title) } +// Bordered warning block: yellow border + warning icon. note() has no warning +// variant, and box() wraps long lines so the border stays aligned. +export function warningBox(message: string, title: string): void { + const yellow = (s: string) => styleText('yellow', s) + box(message, yellow(`${S_WARN} ${title}`), { width: 'auto', formatBorder: yellow }) +} + // Esc at any prompt aborts the whole setup (parity with the old scripts: print and exit 0). function guard(value: T | typeof CANCEL_SYMBOL): T { if (isCancel(value)) { @@ -99,15 +107,3 @@ export async function askSecret(name: string): Promise { } } -export async function withSpinner( - message: string, - task: (update: (message: string) => void) => Promise -): Promise { - const active: SpinnerResult = spinner() - active.start(message) - try { - return await task((next) => active.message(next)) - } finally { - active.stop('') - } -} diff --git a/cli/src/index.ts b/cli/src/index.ts index 36e7669..6fd87ba 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -28,7 +28,7 @@ async function main(): Promise { console.error('Run it from a terminal - `curl | bash` and `irm | iex` are handled by the setup scripts.') process.exit(1) } - await showTitleBanner() + await showTitleBanner(true, true) const ctx: StepContext = { stateFound: false, previousSelection: null, @@ -54,9 +54,8 @@ async function main(): Promise { await tmdbApiKey() await discordWebhook() await startLode(ctx) - showSummary(ctx) + await showSummary(ctx) outro('Setup complete - enjoy!') - await showTitleBanner() await pauseBeforeExit() } diff --git a/cli/src/steps/compose-files.ts b/cli/src/steps/compose-files.ts index 4106e80..2a411d4 100644 --- a/cli/src/steps/compose-files.ts +++ b/cli/src/steps/compose-files.ts @@ -3,7 +3,7 @@ import { REPO_RAW } from '../constants' import { maskLodeImageTag } from '../core/compose' import { downloadFile } from '../core/download' import { SetupFailure } from '../core/errors' -import { askConfirm, log, stepHeader, withSpinner } from '../core/prompt' +import { askConfirm, log, spinner, stepHeader } from '../core/prompt' import type { StepContext } from '../types' export async function downloadComposeFiles(ctx: StepContext): Promise { @@ -12,14 +12,17 @@ export async function downloadComposeFiles(ctx: StepContext): Promise { const missing = ctx.composeFiles.filter((file) => !existsSync(file)) for (const file of missing) { + const dl = spinner() + dl.start(`Downloading ${file}...`) try { - await withSpinner(`Downloading ${file}...`, () => downloadFile(`${REPO_RAW}/${file}`, file)) + await downloadFile(`${REPO_RAW}/${file}`, file) } catch { + dl.clear() throw new SetupFailure(`Failed to download ${file} from GitHub.`, [ ' Check your internet connection and try again.' ]) } - log.success(`${file} downloaded`) + dl.stop(`${file} downloaded`) } if (existing.length === 0) return diff --git a/cli/src/steps/env-file.ts b/cli/src/steps/env-file.ts index a30d253..3f2559f 100644 --- a/cli/src/steps/env-file.ts +++ b/cli/src/steps/env-file.ts @@ -2,7 +2,7 @@ import { copyFileSync, existsSync, mkdirSync, renameSync } from 'node:fs' import { ENV_EXAMPLE_FILE, ENV_FILE, MEDIA_DIRS, REPO_RAW } from '../constants' import { downloadFile } from '../core/download' import { SetupFailure } from '../core/errors' -import { log, stepHeader, withSpinner } from '../core/prompt' +import { log, spinner, stepHeader } from '../core/prompt' export async function setupEnvFile(): Promise { stepHeader(2, 'Setting up .env file') @@ -10,16 +10,17 @@ export async function setupEnvFile(): Promise { log.warn('.env exists - keeping existing config') } else { if (!existsSync(ENV_EXAMPLE_FILE)) { - log.info('Downloading .env.example from GitHub...') + const dl = spinner() + dl.start('Downloading .env.example...') try { - await withSpinner('Downloading .env.example...', () => - downloadFile(`${REPO_RAW}/${ENV_EXAMPLE_FILE}`, ENV_EXAMPLE_FILE) - ) + await downloadFile(`${REPO_RAW}/${ENV_EXAMPLE_FILE}`, ENV_EXAMPLE_FILE) } catch { + dl.clear() throw new SetupFailure('Failed to download .env.example from GitHub.', [ ' Check your internet connection and try again.' ]) } + dl.stop('.env.example downloaded') } copyFileSync(ENV_EXAMPLE_FILE, `${ENV_FILE}.tmp`) renameSync(`${ENV_FILE}.tmp`, ENV_FILE) diff --git a/cli/src/steps/prerequisites.ts b/cli/src/steps/prerequisites.ts index e62ca50..5d98be5 100644 --- a/cli/src/steps/prerequisites.ts +++ b/cli/src/steps/prerequisites.ts @@ -3,7 +3,7 @@ import { checkDaemon, composeVersion, dockerVersion } from '../core/docker' import { SetupFailure } from '../core/errors' import { run } from '../core/exec' import { hyperlink } from '../core/hyperlink' -import { log, stepHeader, withSpinner } from '../core/prompt' +import { note, spinner, stepHeader } from '../core/prompt' function dockerInstallHints(): string[] { switch (process.platform) { @@ -47,21 +47,27 @@ async function daemonHints(permissionDenied: boolean): Promise { export async function checkPrerequisites(): Promise { stepHeader(1, 'Checking prerequisites') - const version = await withSpinner('Checking Docker...', dockerVersion) - if (version === null) throw new SetupFailure('Docker is not installed.', dockerInstallHints()) - log.success(`Docker ${version}`) - - const daemon = await withSpinner('Checking Docker daemon...', checkDaemon) + // One spinner covers all three checks; results are reported in a single box. + const s = spinner() + s.start('Checking Docker...') + const version = await dockerVersion() + if (version === null) { + s.clear() + throw new SetupFailure('Docker is not installed.', dockerInstallHints()) + } + s.message('Checking Docker daemon...') + const daemon = await checkDaemon() if (!daemon.reachable) { + s.clear() throw new SetupFailure('Docker daemon is not running or not reachable from this shell.', [ ...daemon.errorLines.map((line) => ` ${line}`), ...(await daemonHints(daemon.permissionDenied)) ]) } - log.success('Docker daemon running') - - const compose = await withSpinner('Checking Docker Compose...', composeVersion) + s.message('Checking Docker Compose...') + const compose = await composeVersion() if (compose === null) { + s.clear() throw new SetupFailure('Docker Compose plugin is not installed.', [ ' macOS/Windows: install or update Docker Desktop', ` ${hyperlink(DOCS_LINKS.dockerGeneric)}`, @@ -69,5 +75,13 @@ export async function checkPrerequisites(): Promise { ` ${hyperlink(DOCS_LINKS.composeLinux)}` ]) } - log.success(`Docker Compose ${compose}`) + s.clear() + note( + [ + `Docker: ${version}`, + 'Docker daemon: running', + `Docker Compose: ${compose}` + ].join('\n'), + 'Prerequisites' + ) } diff --git a/cli/src/steps/services.ts b/cli/src/steps/services.ts index ad39b65..1ee94b6 100644 --- a/cli/src/steps/services.ts +++ b/cli/src/steps/services.ts @@ -1,5 +1,6 @@ import { compose, + composeOnLine, containerExists, dcCmdPrefix, imageExists, @@ -9,10 +10,10 @@ import { stopAndRemoveContainer } from '../core/docker' import { SetupFailure } from '../core/errors' -import { askConfirm, log, stepHeader, withSpinner } from '../core/prompt' +import { askConfirm, log, spinner, stepHeader } from '../core/prompt' import { sleep, waitForPort } from '../core/ports' import { infraServicesFor, LODE_IMAGE, PORTS, PORT_TIMEOUTS } from '../constants' -import type { SetupSelection, StepContext } from '../types' +import type { ComposeProgress, DeselectionCheck, StepContext, WaitPhase } from '../types' const QBIT_TEMP_PASSWORD = /A temporary password is provided for this session:\s*(\S+)/g @@ -22,10 +23,36 @@ export function extractQbitTempPassword(logs: string): string { return last } -interface DeselectionCheck { - service: string - stateKey: keyof SetupSelection - activeValue: string +const LAYER_STAGE = + /^([0-9a-f]{6,64}): (Pulling fs layer|Waiting|Downloading|Download complete|Verifying Checksum|Extracting|Pull complete|Mounted from cache)/ +const IMAGE_STATUS = /^(\S+) (Pulling|Pulled)\b/ +const IMAGE_PULLING = /^Pulling (\S+)/ + +const STAGE_LABELS: Record = { + 'Pulling fs layer': 'pulling', + Waiting: 'waiting', + 'Download complete': 'downloaded', + Downloading: 'downloading', + 'Verifying Checksum': 'verifying', + Extracting: 'extracting', + 'Pull complete': 'ready', + 'Mounted from cache': 'cached' +} + +// Maps a raw docker compose pull line to a clean { image, stage } for the spinner. +function parseComposeProgress(line: string): ComposeProgress | null { + const trimmed = line.trim() + if (!trimmed) return null + const layer = LAYER_STAGE.exec(trimmed) + const rawStage = layer?.[2] + if (rawStage) return { stage: STAGE_LABELS[rawStage] ?? rawStage.toLowerCase() } + const status = IMAGE_STATUS.exec(trimmed) + if (status?.[1] && status[2]) { + return { image: status[1], stage: status[2] === 'Pulled' ? 'pulled' : 'pulling' } + } + const pulling = IMAGE_PULLING.exec(trimmed) + if (pulling?.[1]) return { image: pulling[1], stage: 'pulling' } + return null } const DESELECTION_CHECKS: readonly DeselectionCheck[] = [ @@ -58,18 +85,20 @@ async function removeDeselectedServices(ctx: StepContext): Promise { } } -async function waitReady(label: string, port: number, timeoutMs: number): Promise { - log.info(`Waiting for ${label}...`) - await withSpinner(`Waiting for ${label} on port ${port}...`, (update) => - waitForPort('localhost', port, timeoutMs, { - onTick: (attempt, maxAttempts) => update(`Waiting for ${label}... (${attempt}/${maxAttempts})`) - }) - ) +function portWaitPhase(label: string, port: number, timeoutMs: number): WaitPhase { + return { + label: `Waiting for ${label}...`, + run: (update) => + waitForPort('localhost', port, timeoutMs, { + onTick: (attempt, maxAttempts) => update(`Waiting for ${label}... (${attempt}/${maxAttempts})`) + }).then(() => undefined) + } } export async function startServices(ctx: StepContext): Promise { stepHeader(9, 'Starting selected services') const files = ctx.composeFiles + const { selection } = ctx const config = await compose(files, 'config', '-q') if (config.code !== 0) { @@ -78,17 +107,29 @@ export async function startServices(ctx: StepContext): Promise { if (ctx.stateFound) await removeDeselectedServices(ctx) - const services = infraServicesFor(ctx.selection) - - await withSpinner('Pulling images...', () => compose(files, 'pull')) - if (!(await imageExists(ctx.selection.imageTag))) { + const services = infraServicesFor(selection) + + // One spinner covers pull + up; docker's raw layer output is parsed into a clean + // "image: stage" message instead of being dumped to the terminal. + const pull = spinner() + pull.start('Pulling images...') + let image = '' + await composeOnLine(files, ['pull'], (line) => { + const progress = parseComposeProgress(line) + if (!progress) return + if (progress.image) image = progress.image + if (progress.stage) pull.message(`${image ? `${image}: ` : ''}${progress.stage}`) + }) + if (!(await imageExists(selection.imageTag))) { + pull.clear() throw new SetupFailure( - `Failed to pull the Lode image (${LODE_IMAGE}:${ctx.selection.imageTag}). Check your network and try again.`, + `Failed to pull the Lode image (${LODE_IMAGE}:${selection.imageTag}). Check your network and try again.`, [`You can also try manually: ${dcCmdPrefix(files)} pull lode`] ) } - - await withSpinner('Starting selected services...', () => compose(files, 'up', '-d', ...services)) + pull.message('Starting selected services...') + await compose(files, 'up', '-d', ...services) + pull.stop('Services started') const failed: string[] = [] for (const service of services) { @@ -100,47 +141,59 @@ export async function startServices(ctx: StepContext): Promise { if (failed.includes('redis')) { throw new SetupFailure('Redis failed to start. Cannot continue.', [`Check logs: ${dcCmdPrefix(files)} logs redis`]) } - if (ctx.selection.dbDriver === 'postgres' && failed.includes('postgres')) { + if (selection.dbDriver === 'postgres' && failed.includes('postgres')) { throw new SetupFailure('PostgreSQL failed to start. Cannot continue.', [ `Check logs: ${dcCmdPrefix(files)} logs postgres` ]) } - if (ctx.selection.qbittorrent === 'local' && failed.includes('qbittorrent')) { + if (selection.qbittorrent === 'local' && failed.includes('qbittorrent')) { throw new SetupFailure('qBittorrent failed to start. Cannot continue.', [ `Check logs: ${dcCmdPrefix(files)} logs qbittorrent` ]) } - await waitReady('Redis', PORTS.redis, PORT_TIMEOUTS.redis) - - if (ctx.selection.qbittorrent === 'local') - await waitReady('qBittorrent', PORTS.qbittorrent, PORT_TIMEOUTS.qbittorrent) - - await sleep(3000) - if (ctx.selection.qbittorrent === 'local') { - ctx.qbitTempPass = extractQbitTempPassword(await logsTail(files, 'qbittorrent', 100_000)) + // Non-fatal services: warn once now and skip their wait phase. + if (selection.prowlarr === 'local' && failed.includes('prowlarr')) { + log.warn('Prowlarr not running - you can configure it later (step 12)') } - - if (ctx.selection.prowlarr === 'local') { - if (failed.includes('prowlarr')) { - log.warn('Prowlarr not running - you can configure it later (step 12)') - } else { - await waitReady('Prowlarr', PORTS.prowlarr, PORT_TIMEOUTS.prowlarr) - } + if (selection.mediaMode === 'local' && failed.includes('jellyfin')) { + log.warn('Jellyfin not running - you can configure it later (step 10)') } - if (ctx.selection.mediaMode === 'local') { - if (failed.includes('jellyfin')) { - log.warn('Jellyfin not running - you can configure it later (step 10)') + // One spinner carries the whole readiness wait; its message moves with each phase. + const phases: WaitPhase[] = [portWaitPhase('Redis', PORTS.redis, PORT_TIMEOUTS.redis)] + if (selection.qbittorrent === 'local') { + phases.push(portWaitPhase('qBittorrent', PORTS.qbittorrent, PORT_TIMEOUTS.qbittorrent)) + phases.push({ + // The temp password only lands in the logs once qBittorrent has been up briefly. + label: 'Collecting qBittorrent password...', + run: () => + sleep(3000).then(async () => { + ctx.qbitTempPass = extractQbitTempPassword(await logsTail(files, 'qbittorrent', 100_000)) + }) + }) + } + if (selection.prowlarr === 'local' && !failed.includes('prowlarr')) { + phases.push(portWaitPhase('Prowlarr', PORTS.prowlarr, PORT_TIMEOUTS.prowlarr)) + } + if (selection.mediaMode === 'local' && !failed.includes('jellyfin')) { + phases.push(portWaitPhase('Jellyfin', PORTS.jellyfin, PORT_TIMEOUTS.jellyfin)) + } + if (selection.dbDriver === 'postgres') { + phases.push(portWaitPhase('PostgreSQL', PORTS.postgres, PORT_TIMEOUTS.postgres)) + } + phases.push({ label: 'Settling services...', run: () => sleep(10_000) }) + + const waitSpinner = spinner() + let first = true + for (const phase of phases) { + if (first) { + waitSpinner.start(phase.label) + first = false } else { - await waitReady('Jellyfin', PORTS.jellyfin, PORT_TIMEOUTS.jellyfin) + waitSpinner.message(phase.label) } + await phase.run((msg) => waitSpinner.message(msg)) } - - log.success('Selected services are running') - - if (ctx.selection.dbDriver === 'postgres') await waitReady('PostgreSQL', PORTS.postgres, PORT_TIMEOUTS.postgres) - - log.info('Waiting 10s for services to fully initialize...') - await withSpinner('Settling services...', () => sleep(10_000)) + waitSpinner.stop('Selected services are running') } diff --git a/cli/src/steps/start-lode.ts b/cli/src/steps/start-lode.ts index 7bbaf5b..2e63473 100644 --- a/cli/src/steps/start-lode.ts +++ b/cli/src/steps/start-lode.ts @@ -3,7 +3,7 @@ import { compose, dcCmdPrefix, logsTail, serviceRunning } from '../core/docker' import { SetupFailure } from '../core/errors' import { readEnvValue, updateEnv } from '../core/env' import { hyperlink } from '../core/hyperlink' -import { log, stepHeader, withSpinner } from '../core/prompt' +import { spinner, stepHeader } from '../core/prompt' import { sleep, waitForPort } from '../core/ports' import type { StepContext } from '../types' @@ -49,26 +49,30 @@ export async function startLode(ctx: StepContext): Promise { updateEnv(dir, ENV_FILE, ENV_KEYS.databaseUrl, `postgresql://lode:${pgPass}@postgres:5432/lode`) } - log.info('Starting Lode...') - await withSpinner('Starting Lode container...', () => compose(files, 'up', '-d', 'lode')) + // One spinner carries start + wait; it stops on the single completion line. + const s = spinner() + s.start('Starting Lode container...') + await compose(files, 'up', '-d', 'lode') if (!(await serviceRunning(files, 'lode'))) { + s.clear() throw new SetupFailure('Lode container failed to start.', [ `Check logs: ${dcCmdPrefix(files)} logs lode`, `To retry: ${dcCmdPrefix(files)} up -d lode` ]) } + s.message('Waiting for Lode to start (first start may take 1-2 minutes)...') + await waitForPort('localhost', PORTS.lode, PORT_TIMEOUTS.lode, { + onTick: (attempt, maxAttempts) => s.message(`Waiting for Lode... (${attempt}/${maxAttempts})`) + }) + s.stop(`Lode is running at ${hyperlink('http://localhost:5757')}`) - log.info('Waiting for Lode to start (first start may take 1-2 minutes)...') - await withSpinner('Waiting for Lode on port 5757...', (update) => - waitForPort('localhost', PORTS.lode, PORT_TIMEOUTS.lode, { - onTick: (attempt, maxAttempts) => update(`Waiting for Lode... (${attempt}/${maxAttempts})`) - }) - ) - log.success(`Lode is running at ${hyperlink('http://localhost:5757')}`) - + // The admin password is printed once on first start; poll the logs until it appears. + const credentials = spinner() + credentials.start('Retrieving admin credentials...') for (let retry = 0; retry < 5; retry++) { ctx.adminPass = extractAdminPassword(await logsTail(files, 'lode', 200)) if (ctx.adminPass) break await sleep(2000) } + credentials.stop(ctx.adminPass ? 'Admin credentials ready' : 'Admin password not in logs yet') } diff --git a/cli/src/steps/summary.ts b/cli/src/steps/summary.ts index 66a3575..aee6661 100644 --- a/cli/src/steps/summary.ts +++ b/cli/src/steps/summary.ts @@ -1,60 +1,58 @@ import { copyToClipboardOsc52 } from '../core/clipboard' +import { showTitleBanner } from '../core/banner' import { dcCmdPrefix } from '../core/docker' -import { hyperlink } from '../core/hyperlink' -import { log, note } from '../core/prompt' +import { note, warningBox } from '../core/prompt' import type { StepContext, SummaryRow } from '../types' function buildRows(ctx: StepContext): SummaryRow[] { const { selection, urls } = ctx - const rows: SummaryRow[] = [{ label: 'Lode', value: 'http://localhost:5757', url: 'http://localhost:5757' }] + const rows: SummaryRow[] = [{ label: 'Lode', value: 'http://localhost:5757' }] const qbit = selection.qbittorrent === 'local' ? 'http://localhost:8080' : urls.qbittorrent - rows.push({ label: 'qBittorrent', value: qbit, url: /^https?:\/\//.test(qbit) ? qbit : undefined }) + rows.push({ label: 'qBittorrent', value: qbit }) const prowlarr = selection.prowlarr === 'local' ? 'http://localhost:9900' : urls.prowlarr - rows.push({ label: 'Prowlarr', value: prowlarr, url: /^https?:\/\//.test(prowlarr) ? prowlarr : undefined }) + rows.push({ label: 'Prowlarr', value: prowlarr }) if (selection.mediaMode === 'local') { - rows.push({ label: 'Jellyfin', value: 'http://localhost:8096', url: 'http://localhost:8096' }) + rows.push({ label: 'Jellyfin', value: 'http://localhost:8096' }) } else if (selection.mediaMode === 'external') { - rows.push({ - label: 'Jellyfin', - value: urls.jellyfin, - url: /^https?:\/\//.test(urls.jellyfin) ? urls.jellyfin : undefined - }) + rows.push({ label: 'Jellyfin', value: urls.jellyfin }) } else { rows.push({ label: 'Jellyfin', value: '(disabled)' }) } - if (selection.flaresolverr) - rows.push({ label: 'FlareSolverr', value: 'http://localhost:8191', url: 'http://localhost:8191' }) + if (selection.flaresolverr) rows.push({ label: 'FlareSolverr', value: 'http://localhost:8191' }) rows.push({ label: 'Database', value: selection.dbDriver }) if (selection.dbDriver === 'postgres') rows.push({ label: 'PostgreSQL', value: 'localhost:5432 / lode' }) - if (selection.dozzle) rows.push({ label: 'Dozzle', value: 'http://localhost:8082', url: 'http://localhost:8082' }) + if (selection.dozzle) rows.push({ label: 'Dozzle', value: 'http://localhost:8082' }) return rows } -export function showSummary(ctx: StepContext): void { - log.success('Lode is ready!') - const rows = buildRows(ctx).map((row) => ` ${row.label.padEnd(18)} ${row.url ? hyperlink(row.value) : row.value}`) +export async function showSummary(ctx: StepContext): Promise { + await showTitleBanner(true) + + // Plain values: hyperlink() would break note()'s width calc and misalign the border. + const rows = buildRows(ctx).map((row) => ` ${row.label.padEnd(18)} ${row.value}`) note(rows.join('\n'), 'Services') - log.message('Username: admin') + const cred: string[] = [' Username: admin'] if (ctx.adminPass) { copyToClipboardOsc52(ctx.adminPass) - log.message(`Password: ${ctx.adminPass}`) - log.message('Copied to clipboard (this replaces your previous clipboard contents)') + cred.push(` Password: ${ctx.adminPass}`) + cred.push(' (copied to clipboard - this replaces your previous contents)') } else { - log.message(`Password: check '${dcCmdPrefix(ctx.composeFiles)} logs lode'`) + cred.push(` Password: check '${dcCmdPrefix(ctx.composeFiles)} logs lode'`) } - log.message('Change this password after first login.') + note(cred.join('\n'), 'Login') + warningBox('Change this password after your first login.', 'Password') const { selection } = ctx const required = selection.prowlarr === 'local' ? [ 'Prowlarr has no indexers yet - Lode cannot find torrents until you add them.', - `Open ${hyperlink('http://localhost:9900')} and add at least one indexer (e.g. YTS).`, + 'Open http://localhost:9900 and add at least one indexer (e.g. YTS).', ...(selection.flaresolverr ? ['For private trackers: Settings > Indexers > Add > FlareSolverr, URL: http://flaresolverr:8191'] : []) ] : ['Your external Prowlarr needs at least one indexer before Lode can find torrents.'] - note(required.map((line) => ` ${line}`).join('\n'), 'Required before first use') + warningBox(required.join('\n'), 'Required before first use') } diff --git a/cli/src/types.ts b/cli/src/types.ts index 23d0ab7..e598e72 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -65,6 +65,20 @@ export interface CommandResult { export interface SummaryRow { label: string value: string - /** http(s) values are rendered as OSC 8 hyperlinks. */ - url?: string +} + +export interface DeselectionCheck { + service: string + stateKey: keyof SetupSelection + activeValue: string +} + +export interface WaitPhase { + label: string + run: (update: (msg: string) => void) => Promise +} + +export interface ComposeProgress { + image?: string + stage?: string } From ad6faf7ac1741412a372f8b25946f5ce408a4687 Mon Sep 17 00:00:00 2001 From: Nort <118631044+Nort1346@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:06:39 +0200 Subject: [PATCH 5/8] fix(setup): use BEL terminator so hyperlink boxes keep aligned borders --- cli/src/core/hyperlink.ts | 4 +++- cli/src/steps/summary.ts | 23 ++++++++++++++--------- cli/src/types.ts | 2 ++ 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/cli/src/core/hyperlink.ts b/cli/src/core/hyperlink.ts index bcfbed7..2ba654e 100644 --- a/cli/src/core/hyperlink.ts +++ b/cli/src/core/hyperlink.ts @@ -1,9 +1,11 @@ // OSC 8 hyperlink: emitted unconditionally for http(s) URLs, deliberately // independent of NO_COLOR (ECMA-48 terminals ignore unknown OSC sequences). +// Uses the BEL terminator (\u0007), not ST (\u001b\\): fast-string-width (clack's +// width math) strips BEL-terminated OSC 8 but not ST, so BEL keeps box/note borders aligned. export function hyperlink(url: string, label?: string): string { const text = label && label !== url ? label : url if (/^https?:\/\//.test(url)) { - return `\u001b]8;;${url}\u001b\\${text}\u001b]8;;\u001b\\` + return `\u001b]8;;${url}\u0007${text}\u001b]8;;\u0007` } return label && label !== url ? `${label}: ${url}` : url } diff --git a/cli/src/steps/summary.ts b/cli/src/steps/summary.ts index aee6661..20b3205 100644 --- a/cli/src/steps/summary.ts +++ b/cli/src/steps/summary.ts @@ -1,35 +1,40 @@ import { copyToClipboardOsc52 } from '../core/clipboard' import { showTitleBanner } from '../core/banner' import { dcCmdPrefix } from '../core/docker' +import { hyperlink } from '../core/hyperlink' import { note, warningBox } from '../core/prompt' import type { StepContext, SummaryRow } from '../types' function buildRows(ctx: StepContext): SummaryRow[] { const { selection, urls } = ctx - const rows: SummaryRow[] = [{ label: 'Lode', value: 'http://localhost:5757' }] + const rows: SummaryRow[] = [{ label: 'Lode', value: 'http://localhost:5757', url: 'http://localhost:5757' }] const qbit = selection.qbittorrent === 'local' ? 'http://localhost:8080' : urls.qbittorrent - rows.push({ label: 'qBittorrent', value: qbit }) + rows.push({ label: 'qBittorrent', value: qbit, url: /^https?:\/\//.test(qbit) ? qbit : undefined }) const prowlarr = selection.prowlarr === 'local' ? 'http://localhost:9900' : urls.prowlarr - rows.push({ label: 'Prowlarr', value: prowlarr }) + rows.push({ label: 'Prowlarr', value: prowlarr, url: /^https?:\/\//.test(prowlarr) ? prowlarr : undefined }) if (selection.mediaMode === 'local') { - rows.push({ label: 'Jellyfin', value: 'http://localhost:8096' }) + rows.push({ label: 'Jellyfin', value: 'http://localhost:8096', url: 'http://localhost:8096' }) } else if (selection.mediaMode === 'external') { - rows.push({ label: 'Jellyfin', value: urls.jellyfin }) + rows.push({ + label: 'Jellyfin', + value: urls.jellyfin, + url: /^https?:\/\//.test(urls.jellyfin) ? urls.jellyfin : undefined + }) } else { rows.push({ label: 'Jellyfin', value: '(disabled)' }) } - if (selection.flaresolverr) rows.push({ label: 'FlareSolverr', value: 'http://localhost:8191' }) + if (selection.flaresolverr) rows.push({ label: 'FlareSolverr', value: 'http://localhost:8191', url: 'http://localhost:8191' }) rows.push({ label: 'Database', value: selection.dbDriver }) if (selection.dbDriver === 'postgres') rows.push({ label: 'PostgreSQL', value: 'localhost:5432 / lode' }) - if (selection.dozzle) rows.push({ label: 'Dozzle', value: 'http://localhost:8082' }) + if (selection.dozzle) rows.push({ label: 'Dozzle', value: 'http://localhost:8082', url: 'http://localhost:8082' }) return rows } export async function showSummary(ctx: StepContext): Promise { await showTitleBanner(true) - // Plain values: hyperlink() would break note()'s width calc and misalign the border. - const rows = buildRows(ctx).map((row) => ` ${row.label.padEnd(18)} ${row.value}`) + // BEL-terminated hyperlinks measure at their visible width, so the note border stays aligned. + const rows = buildRows(ctx).map((row) => ` ${row.label.padEnd(18)} ${row.url ? hyperlink(row.value) : row.value}`) note(rows.join('\n'), 'Services') const cred: string[] = [' Username: admin'] diff --git a/cli/src/types.ts b/cli/src/types.ts index e598e72..624cc54 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -65,6 +65,8 @@ export interface CommandResult { export interface SummaryRow { label: string value: string + /** http(s) values are rendered as OSC 8 hyperlinks. */ + url?: string } export interface DeselectionCheck { From 344cee7820da98c6e7f434d18bc53ae643c4d424 Mon Sep 17 00:00:00 2001 From: Nort <118631044+Nort1346@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:25:07 +0200 Subject: [PATCH 6/8] fix(setup): embed banner art and simplify password hint --- cli/bun.lock | 27 +------------------- cli/package.json | 3 +-- cli/src/core/banner.ts | 48 +++++++++++++++++++++++++++--------- cli/src/index.ts | 4 +-- cli/src/steps/summary.ts | 7 +++--- cli/test/core/banner.test.ts | 16 ++++++------ 6 files changed, 52 insertions(+), 53 deletions(-) diff --git a/cli/bun.lock b/cli/bun.lock index 0ebc219..b895bf1 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -6,7 +6,6 @@ "name": "lode-setup-cli", "dependencies": { "@clack/prompts": "^1.2.0", - "cfonts": "^3.3.1", }, "devDependencies": { "@eslint/js": "^9.25.0", @@ -152,8 +151,6 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - "cfonts": ["cfonts@3.3.1", "", { "dependencies": { "supports-color": "^8", "window-size": "^1" }, "bin": { "cfonts": "bin/index.js" } }, "sha512-ZGEmN3W9mViWEDjsuPo4nK4h39sfh6YtoneFYp9WLPI/rw8BaSSrfQC6jkrGW3JMvV3ZnExJB/AEqXc/nHYxkw=="], - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "4.3.0", "supports-color": "7.2.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -172,8 +169,6 @@ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], - "define-property": ["define-property@1.0.0", "", { "dependencies": { "is-descriptor": "^1.0.0" } }, "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA=="], - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], @@ -224,36 +219,22 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], - "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "1.0.1", "resolve-from": "4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - "is-accessor-descriptor": ["is-accessor-descriptor@1.0.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-AIbwAcazqP3R65dGvqk1V+a+vE5Fg1yu/ZKMOiBWSUIXXiwQkYmXQcVa2O0nh0tSDKDFKxG2mY7dB1Sr4hEP1g=="], - - "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="], - - "is-data-descriptor": ["is-data-descriptor@1.0.1", "", { "dependencies": { "hasown": "^2.0.0" } }, "sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw=="], - - "is-descriptor": ["is-descriptor@1.0.4", "", { "dependencies": { "is-accessor-descriptor": "^1.0.2", "is-data-descriptor": "^1.0.1" } }, "sha512-bv5z95W0dDtLfKwDfkTNxaRxmISBD3eQBKJeVxv2AQ7MjuUnDNG7cIQqvFtMOUYhsILWHhMayWdoGqNqYYYjww=="], - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-number": ["is-number@3.0.0", "", { "dependencies": { "kind-of": "^3.0.2" } }, "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], @@ -266,8 +247,6 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - "kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], - "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "1.2.1", "type-check": "0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], @@ -356,7 +335,7 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -386,8 +365,6 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "window-size": ["window-size@1.1.1", "", { "dependencies": { "define-property": "^1.0.0", "is-number": "^3.0.0" }, "bin": { "window-size": "cli.js" } }, "sha512-5D/9vujkmVQ7pSmc0SCBmHXbkv6eaHwXEx65MywhmUMsI8sGqJ972APq1lotfcwMKPFLuCFfL8xGHLIp7jaBmA=="], - "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -402,8 +379,6 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "4.0.4" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], diff --git a/cli/package.json b/cli/package.json index dfa6f5b..b108cc6 100644 --- a/cli/package.json +++ b/cli/package.json @@ -12,8 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@clack/prompts": "^1.2.0", - "cfonts": "^3.3.1" + "@clack/prompts": "^1.2.0" }, "devDependencies": { "@eslint/js": "^9.25.0", diff --git a/cli/src/core/banner.ts b/cli/src/core/banner.ts index 5e96df0..5b1a30e 100644 --- a/cli/src/core/banner.ts +++ b/cli/src/core/banner.ts @@ -1,19 +1,45 @@ +// "Lode" pre-rendered in the block font. cfonts reads its .fnt data from disk at +// runtime, which is absent from the single-file `bun build --compile` binary, so +// the art is embedded and the gradient is applied by hand (colorize). +const BANNER_ART = + ' ██╗ ██████╗ ██████╗ ███████╗\n ██║ ██╔═══██╗ ██╔══██╗ ██╔════╝\n ██║ ██║ ██║ ██║ ██║ █████╗\n ██║ ██║ ██║ ██║ ██║ ██╔══╝\n ███████╗ ╚██████╔╝ ██████╔╝ ███████╗\n ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝' const GRADIENT = ['#fbbf24', '#f59e0b'] as const function colorDisabled(): boolean { return process.env.NO_COLOR !== undefined || process.env.TERM === 'dumb' } -export async function showTitleBanner(leadingBreak = false, trailingBreak = false): Promise { +function hexToRgb(hex: string): [number, number, number] { + const value = parseInt(hex.slice(1), 16) + return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff] +} + +// Left-to-right fade across the widest line, colored per non-space glyph. +function colorize(art: string): string { + const lines = art.split('\n') + const width = Math.max(1, ...lines.map((line) => line.length)) + const from = hexToRgb(GRADIENT[0]) + const to = hexToRgb(GRADIENT[1]) + return ( + lines + .map((line) => + Array.from(line) + .map((ch, i) => { + if (ch === ' ') return ch + const t = width <= 1 ? 0 : i / (width - 1) + const r = Math.round(from[0] + (to[0] - from[0]) * t) + const g = Math.round(from[1] + (to[1] - from[1]) * t) + const b = Math.round(from[2] + (to[2] - from[2]) * t) + return `\u001b[38;2;${r};${g};${b}m${ch}` + }) + .join('') + ) + .join('\n') + '\u001b[0m' + ) +} + +export function showTitleBanner(leadingBreak = false, trailingBreak = false): void { if (process.stdout.isTTY !== true) return - // cfonts bundles every font it ships; defer the cost until it is actually needed. - const { render } = await import('cfonts') - const width = process.stdout.columns ?? 80 - // transitionGradient blends the two stops directly - without it cfonts invents a - // full hue-wheel "impressive" gradient, which reads as a rainbow. - const settings: object = colorDisabled() - ? { font: 'block', align: 'left', spaceless: true } - : { font: 'block', gradient: [...GRADIENT], transitionGradient: true, align: 'left', spaceless: true } - const out = render('Lode', settings, false, 0, { width, height: 30 }) - if (out) process.stdout.write((leadingBreak ? '\n' : '') + out.string + '\n' + (trailingBreak ? '\n' : '')) + const art = colorDisabled() ? BANNER_ART : colorize(BANNER_ART) + process.stdout.write((leadingBreak ? '\n' : '') + art + '\n' + (trailingBreak ? '\n' : '')) } diff --git a/cli/src/index.ts b/cli/src/index.ts index 6fd87ba..8243380 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -28,7 +28,7 @@ async function main(): Promise { console.error('Run it from a terminal - `curl | bash` and `irm | iex` are handled by the setup scripts.') process.exit(1) } - await showTitleBanner(true, true) + showTitleBanner(true, true) const ctx: StepContext = { stateFound: false, previousSelection: null, @@ -54,7 +54,7 @@ async function main(): Promise { await tmdbApiKey() await discordWebhook() await startLode(ctx) - await showSummary(ctx) + showSummary(ctx) outro('Setup complete - enjoy!') await pauseBeforeExit() } diff --git a/cli/src/steps/summary.ts b/cli/src/steps/summary.ts index 20b3205..6efb1c7 100644 --- a/cli/src/steps/summary.ts +++ b/cli/src/steps/summary.ts @@ -1,6 +1,5 @@ import { copyToClipboardOsc52 } from '../core/clipboard' import { showTitleBanner } from '../core/banner' -import { dcCmdPrefix } from '../core/docker' import { hyperlink } from '../core/hyperlink' import { note, warningBox } from '../core/prompt' import type { StepContext, SummaryRow } from '../types' @@ -30,8 +29,8 @@ function buildRows(ctx: StepContext): SummaryRow[] { return rows } -export async function showSummary(ctx: StepContext): Promise { - await showTitleBanner(true) +export function showSummary(ctx: StepContext): void { + showTitleBanner(true) // BEL-terminated hyperlinks measure at their visible width, so the note border stays aligned. const rows = buildRows(ctx).map((row) => ` ${row.label.padEnd(18)} ${row.url ? hyperlink(row.value) : row.value}`) @@ -43,7 +42,7 @@ export async function showSummary(ctx: StepContext): Promise { cred.push(` Password: ${ctx.adminPass}`) cred.push(' (copied to clipboard - this replaces your previous contents)') } else { - cred.push(` Password: check '${dcCmdPrefix(ctx.composeFiles)} logs lode'`) + cred.push(` Password: check 'docker compose logs lode'`) } note(cred.join('\n'), 'Login') warningBox('Change this password after your first login.', 'Password') diff --git a/cli/test/core/banner.test.ts b/cli/test/core/banner.test.ts index d9c976b..9f130f0 100644 --- a/cli/test/core/banner.test.ts +++ b/cli/test/core/banner.test.ts @@ -36,33 +36,33 @@ afterEach(() => { }) describe('showTitleBanner', () => { - it('prints nothing when stdout is not a TTY', async () => { + it('prints nothing when stdout is not a TTY', () => { setStdout(false, undefined) - await showTitleBanner() + showTitleBanner() expect(written).toHaveLength(0) }) - it('prints the colored block banner on a TTY', async () => { + it('prints the colored block banner on a TTY', () => { setStdout(true, 100) - await showTitleBanner() + showTitleBanner() const out = firstWritten() expect(out).toContain(BLOCK) expect(out.includes(ESC)).toBe(true) }) - it('prints a monochrome banner when NO_COLOR is set', async () => { + it('prints a monochrome banner when NO_COLOR is set', () => { process.env.NO_COLOR = '' setStdout(true, 100) - await showTitleBanner() + showTitleBanner() const out = firstWritten() expect(out).toContain(BLOCK) expect(out.includes(ESC)).toBe(false) }) - it('prints a monochrome banner when TERM=dumb', async () => { + it('prints a monochrome banner when TERM=dumb', () => { process.env.TERM = 'dumb' setStdout(true, 100) - await showTitleBanner() + showTitleBanner() const out = firstWritten() expect(out).toContain(BLOCK) expect(out.includes(ESC)).toBe(false) From 9c00d492596e8873056875b1365020d91272dc54 Mon Sep 17 00:00:00 2001 From: Nort <118631044+Nort1346@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:09:50 +0200 Subject: [PATCH 7/8] refactor(setup): move inline types into src/types.ts --- cli/src/core/clipboard.ts | 6 +----- cli/src/core/docker.ts | 8 +------- cli/src/core/exec.ts | 8 +------- cli/src/core/ports.ts | 6 +----- cli/src/types.ts | 22 ++++++++++++++++++++++ 5 files changed, 26 insertions(+), 24 deletions(-) diff --git a/cli/src/core/clipboard.ts b/cli/src/core/clipboard.ts index d35df94..8b682e8 100644 --- a/cli/src/core/clipboard.ts +++ b/cli/src/core/clipboard.ts @@ -1,3 +1,4 @@ +import type { ClipboardReader } from '../types' import { commandExists, run } from './exec' const ESC = '\u001b' @@ -10,11 +11,6 @@ export function copyToClipboardOsc52(text: string): void { process.stdout.write(`${ESC}]52;c;${encoded}${ESC}\\`) } -interface ClipboardReader { - command: string - args: string[] -} - async function linuxReaders(): Promise { const readers: ClipboardReader[] = [] if (process.env.WAYLAND_DISPLAY) { diff --git a/cli/src/core/docker.ts b/cli/src/core/docker.ts index 3485baf..8f2f742 100644 --- a/cli/src/core/docker.ts +++ b/cli/src/core/docker.ts @@ -1,5 +1,5 @@ import { LODE_IMAGE } from '../constants' -import type { CommandResult, ComposeFileName } from '../types' +import type { CommandResult, ComposeFileName, DaemonCheck } from '../types' import { run } from './exec' export function composeFileArgs(files: readonly ComposeFileName[]): string[] { @@ -42,12 +42,6 @@ export async function composeVersion(): Promise { const DAEMON_ERROR_PATTERN = /failed to connect|cannot connect|permission denied|connection refused|cannot find|no such file/i -export interface DaemonCheck { - reachable: boolean - errorLines: string[] - permissionDenied: boolean -} - export async function checkDaemon(): Promise { const result = await run('docker', ['info']) if (result.code === 0) return { reachable: true, errorLines: [], permissionDenied: false } diff --git a/cli/src/core/exec.ts b/cli/src/core/exec.ts index d0e6da6..3798ffe 100644 --- a/cli/src/core/exec.ts +++ b/cli/src/core/exec.ts @@ -1,11 +1,5 @@ import { spawn } from 'node:child_process' -import type { CommandResult } from '../types' - -interface RunOptions { - cwd?: string - /** Called for each complete line of stdout/stderr as it arrives (raw output stays captured). */ - onLine?: (line: string) => void -} +import type { CommandResult, RunOptions } from '../types' // Never throws: spawn failures (ENOENT, etc.) resolve with code 127 so callers // can treat "command missing" uniformly with a non-zero exit. diff --git a/cli/src/core/ports.ts b/cli/src/core/ports.ts index 72c3dde..1eaddd0 100644 --- a/cli/src/core/ports.ts +++ b/cli/src/core/ports.ts @@ -1,4 +1,5 @@ import net from 'node:net' +import type { PortWaitOptions } from '../types' export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) @@ -26,11 +27,6 @@ function checkPort(host: string, port: number, timeoutMs: number): Promise void -} - export async function waitForPort( host: string, port: number, diff --git a/cli/src/types.ts b/cli/src/types.ts index 624cc54..d243ffc 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -84,3 +84,25 @@ export interface ComposeProgress { image?: string stage?: string } + +export interface RunOptions { + cwd?: string + /** Called for each complete line of stdout/stderr as it arrives (raw output stays captured). */ + onLine?: (line: string) => void +} + +export interface PortWaitOptions { + intervalMs?: number + onTick?: (attempt: number, maxAttempts: number) => void +} + +export interface DaemonCheck { + reachable: boolean + errorLines: string[] + permissionDenied: boolean +} + +export interface ClipboardReader { + command: string + args: string[] +} From 107f011ce6089abc35137993bcdc70d17281a4d4 Mon Sep 17 00:00:00 2001 From: Nort <118631044+Nort1346@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:18:22 +0200 Subject: [PATCH 8/8] style: format code --- cli/src/core/prompt.ts | 1 - cli/src/steps/prerequisites.ts | 9 +-------- cli/src/steps/summary.ts | 3 ++- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/cli/src/core/prompt.ts b/cli/src/core/prompt.ts index 857601d..2c9aad9 100644 --- a/cli/src/core/prompt.ts +++ b/cli/src/core/prompt.ts @@ -106,4 +106,3 @@ export async function askSecret(name: string): Promise { return askPassword(`Paste your ${name} (Enter to skip)`) } } - diff --git a/cli/src/steps/prerequisites.ts b/cli/src/steps/prerequisites.ts index 5d98be5..d5956a0 100644 --- a/cli/src/steps/prerequisites.ts +++ b/cli/src/steps/prerequisites.ts @@ -76,12 +76,5 @@ export async function checkPrerequisites(): Promise { ]) } s.clear() - note( - [ - `Docker: ${version}`, - 'Docker daemon: running', - `Docker Compose: ${compose}` - ].join('\n'), - 'Prerequisites' - ) + note([`Docker: ${version}`, 'Docker daemon: running', `Docker Compose: ${compose}`].join('\n'), 'Prerequisites') } diff --git a/cli/src/steps/summary.ts b/cli/src/steps/summary.ts index 6efb1c7..d9fa75a 100644 --- a/cli/src/steps/summary.ts +++ b/cli/src/steps/summary.ts @@ -22,7 +22,8 @@ function buildRows(ctx: StepContext): SummaryRow[] { } else { rows.push({ label: 'Jellyfin', value: '(disabled)' }) } - if (selection.flaresolverr) rows.push({ label: 'FlareSolverr', value: 'http://localhost:8191', url: 'http://localhost:8191' }) + if (selection.flaresolverr) + rows.push({ label: 'FlareSolverr', value: 'http://localhost:8191', url: 'http://localhost:8191' }) rows.push({ label: 'Database', value: selection.dbDriver }) if (selection.dbDriver === 'postgres') rows.push({ label: 'PostgreSQL', value: 'localhost:5432 / lode' }) if (selection.dozzle) rows.push({ label: 'Dozzle', value: 'http://localhost:8082', url: 'http://localhost:8082' })