commit d99711563e83da0e5a9713c8240d428dce4bbbeb Author: josh Date: Sat Sep 12 00:23:51 2026 -0400 Initial commit — SvelteKit fleet manager for T5 Plutonium servers Ports push_scripts.sh (1497 lines of bash) to a SvelteKit + SQLite web app that manages a fleet of T5 Plutonium servers running on Komodo. Same push and beta workflow as the shell script, with a live SSE pipeline view, a !rt-token-gated auth modal that hard-pauses deployments on stale tokens, per-file audit diffs, and a Komodo-container deployment target. - Push/beta engines port the exact filter, held-by-beta, conflict, and stale-backup rules from the shell script - SQLite (better-sqlite3) replaces script_metadata.json; a one-shot importer moves legacy state over on first boot - 16 vitest tests cover push add/update/skip, beta open→rollback, open→promote, conflict guards, sweep frozen/manual branches, and IW4M login/status parsing - docker/ + komodo/stack.toml deploy as a managed container on the same host as the game servers, /root/zombies bind-mounted at the same path the shell script used diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4fa244e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.svelte-kit +build +.git +.env +.env.* +!.env.example +tests +archive +*.log +test-fixtures +data diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9dc9b90 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Filesystem paths — inside the Komodo container, these mirror the host layout +SHARED_SCRIPTS=/root/zombies/shared/scripts +SERVERS_ROOT=/root/zombies/t5 +REL_DEST=runtime/plutonium/storage/t5/raw/scripts/sp + +# SQLite location +DB_PATH=/data/cherry.db + +# Retention (days) for deployment rows and blob diffs +DEPLOY_KEEP_DAYS=7 + +# Komodo +KOMODO_ADDRESS=https://komodo.example.internal +KOMODO_API_KEY= +KOMODO_API_SECRET= + +# IW4MAdmin +IW4M_ADDRESS=http://iw4m.example.internal +IW4M_CLIENT_ID= +# no password here — !rt token is provided at runtime through the modal + +# HTTP +HTTP_TIMEOUT_MS=10000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dcb1cb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +node_modules +.svelte-kit +build +.claude/ +.env +.env.* +!.env.example +*.db +*.db-journal +*.db-wal +*.db-shm +data/ +test-fixtures/*/zombies/ +!test-fixtures/*/zombies/.gitkeep diff --git a/README.md b/README.md new file mode 100644 index 0000000..c0a1e9e --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# electric-cherry + +Web app that manages a fleet of T5 Plutonium servers running under Komodo. +Successor to `push_scripts.sh` — same job (push shared GSC scripts, run beta +tests, restart empty servers, track deployments) with a live UI, real audit +diffs, and a hard pause when the IW4MAdmin `!rt` token goes stale. + +## Stack + +- SvelteKit (adapter-node) + TypeScript +- SQLite via better-sqlite3 (replaces `script_metadata.json` as the source of + truth) +- Server-sent events for live pipeline / auth-state / server-status streams +- Runs as a single Komodo-managed container on the same host as the game + servers, with `/root/zombies` bind-mounted in + +## Layout + +- `src/lib/server/db/` — schema, migrations, better-sqlite3 handle +- `src/lib/server/fleet/` — file walking, push/beta engines +- `src/lib/server/integrations/` — Komodo, IW4MAdmin, `!rt` token state +- `src/lib/server/pipeline/` — sweep loop, event bus, worker singleton +- `src/routes/api/` — REST + SSE endpoints +- `src/routes/` — UI pages (dashboard, deployments, betas, per-server) +- `docker/` — Dockerfile, entrypoint, docker-compose +- `komodo/` — Komodo stack definition +- `scripts/` — legacy JSON importer, fixture seeder +- `tests/` — vitest unit tests + +## Local dev + +Windows or macOS — no `/root/zombies` on disk, so seed a fixture tree first. + +```bash +npm install +npm run seed:fixture + +# Point env at the fixture (PowerShell) +$env:SHARED_SCRIPTS = "$PWD/test-fixtures/zombies/shared/scripts" +$env:SERVERS_ROOT = "$PWD/test-fixtures/zombies/t5" +$env:DB_PATH = "$PWD/data/cherry.db" + +npm run dev +# open http://localhost:3000 +``` + +The `!rt` modal will block deployments until you paste a valid token. In +local dev you can bypass it for exploration by leaving `IW4M_ADDRESS` unset +and calling `sweep()` in a test that force-transitions the auth state (see +`tests/unit/sweep.test.ts`). + +## Tests + +```bash +npm test +``` + +Covers push (add/update/skip), beta lifecycle (open → rollback, open → +promote, conflict blocks), sweep (frozen / manual branches), and IW4M +login/status parsing (fetch mocked). + +## Deploy to the Komodo host + +1. Build the image once locally or on the host: + + ```bash + docker compose -f docker/docker-compose.yml build + ``` + +2. Import the shape of the stack into Komodo using `komodo/stack.toml` (or + copy the compose file into a Komodo stack). + +3. First boot triggers the legacy import: if + `/root/zombies/script_metadata.json` exists and `/data/cherry.db` is + empty, `docker/entrypoint.sh` runs `scripts/import-legacy.ts` before + starting the server. The source JSON gets renamed to `.migrated`. + +4. Load the app on the LAN or over tailscale (no web-app auth by design). + Paste an `!rt` token to unfreeze the pipeline. + +## What still lives in the shell script + +Nothing that's needed at runtime. Move `push_scripts.sh` to `archive/` once +you've run a side-by-side parity check between the shell's dry-run and the +app's `POST /api/push { dryRun: true }`. + +## Non-goals + +- **Editing GSC files in-browser.** Do that in your editor of choice; the + web app only cares about what's on disk in `SHARED_SCRIPTS`. +- **Multi-user auth.** LAN/tailscale scoping is the security model. +- **Two-way sync with `script_metadata.json`.** The importer runs once. If + the shell script is ever used again after that, it'll write to the JSON + file and the web app won't see it. + +## References into the old script + +Every port carries a `// Ports push_scripts.sh:NN` comment pointing back to +the original for the parts that had non-obvious behavior. Grep those if a +behavior seems wrong — the shell script is the reference implementation +until the parity check passes. diff --git a/archive/README.md b/archive/README.md new file mode 100644 index 0000000..352fe7d --- /dev/null +++ b/archive/README.md @@ -0,0 +1,24 @@ +# archive/ + +Holds retired code that's kept only for reference. Nothing here is executed +by the app. + +## push_scripts.sh + +The original shell CLI that this web app replaces. Move it here from the +repo root once you've verified electric-cherry is doing the same job: + +```bash +git mv ../push_scripts.sh ./push_scripts.sh.retired +``` + +The web app's `docker/entrypoint.sh` will call `scripts/import-legacy.ts` on +first boot to move any existing `script_metadata.json` into SQLite, so +retiring the shell script does not lose deployment history or open betas. + +Parity check (run before archiving): + +1. On the Komodo host, run `./push_scripts.sh -n` and capture the plan output. +2. Hit `POST /api/push { "dryRun": true }` against the running web app. +3. Diff the two plans — the set of `added` / `updated` / `removed` files per + server must match exactly. Same for open beta listings vs `-lb`. diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..db5418e --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1.7 +FROM node:22-alpine AS build + +# native builds for better-sqlite3 +RUN apk add --no-cache python3 make g++ sqlite + +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN --mount=type=cache,target=/root/.npm \ + npm install --include=dev + +COPY . . +RUN npm run build \ + && npm prune --omit=dev + +# ---- runtime ---- +FROM node:22-alpine + +RUN apk add --no-cache sqlite tini rsync + +WORKDIR /app +COPY --from=build /app/build ./build +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/package.json ./package.json +COPY --from=build /app/src/lib/server/db/schema.sql ./build/schema.sql +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOST=0.0.0.0 +EXPOSE 3000 + +ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..014a288 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,26 @@ +services: + electric-cherry: + build: + context: .. + dockerfile: docker/Dockerfile + image: electric-cherry:latest + container_name: electric-cherry + restart: unless-stopped + ports: + - "3000:3000" + volumes: + # in prod (Komodo host), the game server tree is at /root/zombies + - /root/zombies:/root/zombies:rw + - /root/zombies/.electric-cherry:/data:rw + environment: + SHARED_SCRIPTS: /root/zombies/shared/scripts + SERVERS_ROOT: /root/zombies/t5 + REL_DEST: runtime/plutonium/storage/t5/raw/scripts/sp + DB_PATH: /data/cherry.db + LEGACY_METADATA: /root/zombies/script_metadata.json + DEPLOY_KEEP_DAYS: "7" + KOMODO_ADDRESS: ${KOMODO_ADDRESS} + KOMODO_API_KEY: ${KOMODO_API_KEY} + KOMODO_API_SECRET: ${KOMODO_API_SECRET} + IW4M_ADDRESS: ${IW4M_ADDRESS} + IW4M_CLIENT_ID: ${IW4M_CLIENT_ID} diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..1ccd734 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +# Legacy import: if /data/cherry.db is empty and script_metadata.json exists +# in the mounted zombies tree, run a one-shot import before starting. +if [ -n "${LEGACY_METADATA:-}" ] && [ -f "$LEGACY_METADATA" ]; then + if [ ! -f "${DB_PATH:-/data/cherry.db}" ] || [ ! -s "${DB_PATH:-/data/cherry.db}" ]; then + echo "[entrypoint] running legacy import from $LEGACY_METADATA" + node build/scripts/import-legacy.js || echo "[entrypoint] legacy import failed — continuing" + fi +fi + +echo "[entrypoint] starting node server on ${HOST:-0.0.0.0}:${PORT:-3000}" +exec node build/index.js diff --git a/komodo/stack.toml b/komodo/stack.toml new file mode 100644 index 0000000..3eec290 --- /dev/null +++ b/komodo/stack.toml @@ -0,0 +1,32 @@ +# Komodo stack definition. Deploys electric-cherry as a managed container on +# the same host running the T5 Plutonium servers. Mounts /root/zombies so the +# app can push scripts to the same paths push_scripts.sh used, and dedicates +# a small subdir for its SQLite state. + +[[stack]] +name = "electric-cherry" + +[stack.services.web] +image = "electric-cherry:latest" +restart = "unless-stopped" +ports = ["3000:3000"] + +volumes = [ + "/root/zombies:/root/zombies:rw", + "/root/zombies/.electric-cherry:/data:rw" +] + +[stack.services.web.env] +SHARED_SCRIPTS = "/root/zombies/shared/scripts" +SERVERS_ROOT = "/root/zombies/t5" +REL_DEST = "runtime/plutonium/storage/t5/raw/scripts/sp" +DB_PATH = "/data/cherry.db" +LEGACY_METADATA = "/root/zombies/script_metadata.json" +DEPLOY_KEEP_DAYS = "7" + +# secrets: bind via Komodo's env-file or secret store +KOMODO_ADDRESS = "https://komodo.example.internal" +KOMODO_API_KEY = "" +KOMODO_API_SECRET = "" +IW4M_ADDRESS = "http://iw4m.example.internal" +IW4M_CLIENT_ID = "" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9946754 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3244 @@ +{ + "name": "electric-cherry", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "electric-cherry", + "version": "0.1.0", + "dependencies": { + "better-sqlite3": "^11.3.0" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.2.9", + "@sveltejs/kit": "^2.8.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@types/better-sqlite3": "^7.6.11", + "@types/node": "^22.7.0", + "svelte": "^5.1.0", + "svelte-check": "^4.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vite": "^5.4.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.3.tgz", + "integrity": "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.7.tgz", + "integrity": "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "@rollup/plugin-replace": "^6.0.3", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.70.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", + "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz", + "integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0", + "debug": "^4.3.7", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.12", + "vitefu": "^1.0.3" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz", + "integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0", + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz", + "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.7.tgz", + "integrity": "sha512-n2nf7fZR3c9yXf0BPEuHuXqT+KW0SJVj4cN5FMEkpCZ3scLjOQWpiccyCxVzCC2q1wubTghuEGzngJY/7Ah0Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.96.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz", + "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.57.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.0.tgz", + "integrity": "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/svelte/node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b23c7ec --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "electric-cherry", + "version": "0.1.0", + "description": "T5 Plutonium fleet manager — replaces push_scripts.sh with a live web UI", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run", + "test:watch": "vitest", + "import-legacy": "tsx scripts/import-legacy.ts", + "seed:fixture": "tsx scripts/seed-fixture.ts" + }, + "dependencies": { + "better-sqlite3": "^11.3.0" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.2.9", + "@sveltejs/kit": "^2.8.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@types/better-sqlite3": "^7.6.11", + "@types/node": "^22.7.0", + "svelte": "^5.1.0", + "svelte-check": "^4.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vite": "^5.4.0", + "vitest": "^2.1.0" + } +} diff --git a/push_scripts.sh b/push_scripts.sh new file mode 100644 index 0000000..68af72c --- /dev/null +++ b/push_scripts.sh @@ -0,0 +1,1496 @@ +#!/usr/bin/env bash +# +# push_scripts.sh - push shared GSC scripts out to every T5 server +# +# source: /root/zombies/shared/scripts +# staging: /root/zombies/shared/scripts/betas <- drop scripts to test here +# open: /root/zombies/shared/scripts/betas/ <- one folder per open beta +# dest: /root/zombies/t5//runtime/plutonium/storage/t5/raw/scripts/sp +# metadata: script_metadata.json next to this script <- source of truth for betas +# +# The betas folder is never pushed to a server. +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +SRC="${SRC:-/root/zombies/shared/scripts}" +SERVERS_ROOT="${SERVERS_ROOT:-/root/zombies/t5}" +REL_DEST="runtime/plutonium/storage/t5/raw/scripts/sp" +METADATA="${METADATA:-$SCRIPT_DIR/script_metadata.json}" +LEGACY_STATE_DIR="${STATE_DIR:-/root/zombies/.betas}" +BETA_SRC="$SRC/betas" + +# integrations: read from env, or from push_scripts.env next to this script +ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/push_scripts.env}" +if [[ -r "$ENV_FILE" ]]; then + # shellcheck disable=SC1090 + set -a; source "$ENV_FILE"; set +a +fi + +KOMODO_ADDRESS="${KOMODO_ADDRESS:-}" +KOMODO_API_KEY="${KOMODO_API_KEY:-}" +KOMODO_API_SECRET="${KOMODO_API_SECRET:-}" +IW4M_ADDRESS="${IW4M_ADDRESS:-}" +IW4M_CLIENT_ID="${IW4M_CLIENT_ID:-}" +IW4M_PASSWORD="${IW4M_PASSWORD:-}" +HTTP_TIMEOUT="${HTTP_TIMEOUT:-10}" +DEPLOY_KEEP_DAYS="${DEPLOY_KEEP_DAYS:-7}" + +DEPLOY_ACTION="push" +IW4M_STATUS_CACHE="" +IW4M_JAR="" +IW4M_ERR="" +CHANGED_SERVERS=() + +MODE=push +DRY_RUN=0 +DELETE=0 +CREATE=0 +BETA_NAME="" + +# ------------------------------------------------------------------ style --- + +if [[ -t 1 && "${NO_COLOR:-}" == "" ]]; then + B=$'\e[1m'; D=$'\e[2m'; R=$'\e[0m' + GN=$'\e[32m'; YL=$'\e[33m' +else + B=""; D=""; R=""; GN=""; YL="" +fi +WIDTH=66 + +rule() { printf '%s%s%s\n' "$D" "$(printf '─%.0s' $(seq 1 $WIDTH))" "$R"; } + +hdr() { + local title="$1" note="${2:-}" pad + pad=$(( WIDTH - ${#title} - ${#note} - 1 )) + (( pad < 1 )) && pad=1 + printf ' %s%s%s%*s%s%s%s\n' "$B" "$title" "$R" "$pad" "" "$D" "$note" "$R" +} + +row() { + local col="$1" name="$2" note="${3:-}" pad + pad=$(( WIDTH - ${#name} - ${#note} - 3 )) + (( pad < 1 )) && pad=1 + printf ' %s●%s %s%*s%s%s%s\n' "$col" "$R" "$name" "$pad" "" "$D" "$note" "$R" +} + +field() { printf ' %s%-9s%s %s\n' "$D" "$1" "$R" "$2"; } +detail(){ printf ' %s%-8s%s %s\n' "$D" "$1" "$R" "$2"; } +warn() { printf ' %s! %s%s\n' "$YL" "$1" "$R"; } +note() { printf ' %s%s%s\n' "$D" "$1" "$R"; } + +field_wrap() { + local label="$1" text="$2" + printf '%s\n' "$text" | fold -s -w $(( WIDTH - 12 )) \ + | awk -v l="$label" -v d="$D" -v r="$R" \ + 'NR==1 {printf " %s%-9s%s %s\n", d, l, r, $0; next} + {printf " %s%-9s%s %s\n", d, "", r, $0}' +} + +ago() { + local when now diff + when="$(date -d "$1" +%s 2>/dev/null || printf '0')" + [[ "$when" == "0" ]] && { printf '%s' "$1"; return; } + now="$(date +%s)"; diff=$(( now - when )) + if (( diff < 60 )); then printf 'just now' + elif (( diff < 3600 )); then printf '%dm ago' $(( diff / 60 )) + elif (( diff < 86400 )); then printf '%dh ago' $(( diff / 3600 )) + else printf '%dd ago' $(( diff / 86400 )); fi +} + +when_str() { + local pretty + pretty="$(date -d "$1" '+%b %-d, %H:%M' 2>/dev/null || printf '%s' "$1")" + printf '%s (%s)' "$pretty" "$(ago "$1")" +} + +plural() { [[ "$1" == "1" ]] && printf '%s' "$2" || printf '%ss' "$2"; } +now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +# ------------------------------------------------------------------- args --- + +usage() { + cat </$REL_DEST + metadata: $METADATA + +Usage: push_scripts.sh [options] [server ...] + + -n, --dry-run show what would change, change nothing + -d, --delete delete files at the dest that no longer exist in source + -c, --create create the sp directory on servers that don't have one + -h, --help this + +Betas: + + Drop the scripts you want to test straight into $BETA_SRC, + then open a beta. Only those staged files go into it - the rest of the shared + folder keeps pushing to everyone as normal, and betas/ itself is never pushed. + + -b, --beta NAME [server ...] + open a beta. Each staged script lands on the named servers as + script-beta.gsc, the live copy is parked as script.OLD, and the staged + files move to betas// so the staging folder is free for the next one. + + -ub, --update-beta NAME + push a new take on scripts already in an open beta. Stage the new file in + betas/, run this, and it replaces the copy in betas// and on every + server in that beta. Backups and beta membership are left alone. + + -r, --rollback NAME undo a beta: delete its scripts from the servers, restore + every .OLD, and delete betas// + -pb, --promote-beta NAME + keep a beta: clear the beta files and .OLD backups off the servers, move the + scripts back into the shared folder under their real names, delete + betas//, then push to every server + + -lb, --list-betas show what is staged and every open beta + +Restarts and deployments: + + After anything lands on a server, that server is checked against IW4MAdmin. + Empty servers are restarted through Komodo straight away; servers with players + on them are left alone and queued, so they can be picked up later. + + -ad, --active-deployments + re-check everything still queued, restart whatever is empty now, then + list what is still waiting. Good as a cron job. + -ld, --list-deployments show every deployment from the last $DEPLOY_KEEP_DAYS days + + Credentials come from the environment or from $ENV_FILE: + KOMODO_ADDRESS, KOMODO_API_KEY, KOMODO_API_SECRET + IW4M_ADDRESS, and IW4M_CLIENT_ID + IW4M_PASSWORD if your webfront + requires a login to read /api/status + Without them the pushes still work, they just aren't checked or restarted. + + Only one beta may own a given script on a given server at a time. + Open betas are tracked in $METADATA. + +Server args match folder names; globs and comma lists both work: + + push_scripts.sh # push to every server + push_scripts.sh -n # dry run + push_scripts.sh 't5_cotd_*' # one group + push_scripts.sh -b "New HUD Test" "t5_kino_1, t5_ascension_1" + push_scripts.sh -b "Plugin Test" # no servers = every server + push_scripts.sh -ub "New HUD Test" + push_scripts.sh -lb + push_scripts.sh -r "New HUD Test" + push_scripts.sh -pb "New HUD Test" + push_scripts.sh -ad + push_scripts.sh -ld +EOF + exit "${1:-0}" +} + +need_name() { + if [[ -z "${1:-}" || "${1:0:1}" == "-" ]]; then + echo "$2 needs a beta name, e.g. $2 \"New HUD Test\"" >&2 + exit 1 + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -n|--dry-run) DRY_RUN=1 ;; + -d|--delete) DELETE=1 ;; + -c|--create) CREATE=1 ;; + -b|--beta) need_name "${2:-}" "$1"; MODE=beta; BETA_NAME="$2"; shift ;; + -ub|-up|--update-beta) need_name "${2:-}" "$1"; MODE=update; BETA_NAME="$2"; shift ;; + -r|--rollback) need_name "${2:-}" "$1"; MODE=rollback; BETA_NAME="$2"; shift ;; + -pb|--promote-beta) need_name "${2:-}" "$1"; MODE=promote; BETA_NAME="$2"; shift ;; + -lb|--list-betas) MODE=list ;; + -ad|--active-deployments) MODE=active ;; + -ld|--list-deployments) MODE=deployments ;; + -h|--help) usage 0 ;; + -*) echo "unknown option: $1" >&2; usage 1 ;; + *) break ;; + esac + shift +done + +PATTERNS=() +for arg in "$@"; do + IFS=',' read -r -a _parts <<<"$arg" + for p in "${_parts[@]}"; do + p="$(printf '%s' "$p" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" + if [[ -n "$p" ]]; then PATTERNS+=("$p"); fi + done +done +if [[ ${#PATTERNS[@]} -eq 0 ]]; then PATTERNS=("*"); fi + +if [[ "$MODE" != list ]]; then + [[ -d "$SERVERS_ROOT" ]] || { echo "servers dir not found: $SERVERS_ROOT" >&2; exit 1; } +fi +[[ -d "$SRC" ]] || { echo "source dir not found: $SRC" >&2; exit 1; } + +# --------------------------------------------------------------- metadata --- + +require_jq() { + command -v jq >/dev/null 2>&1 && return 0 + echo "jq is needed to read $METADATA" >&2 + echo "install it with: apt-get install -y jq" >&2 + exit 1 +} + +meta_exists() { [[ -f "$METADATA" ]]; } + +meta_q() { require_jq; jq -r "$@" "$METADATA"; } + +meta_apply() { + require_jq + local tmp + tmp="$(mktemp "${METADATA}.XXXXXX")" + if jq "$@" "$METADATA" >"$tmp"; then + mv -f "$tmp" "$METADATA" + else + rm -f "$tmp" + echo "could not update $METADATA" >&2 + exit 1 + fi +} + +meta_touch() { + meta_apply --arg now "$(now_iso)" --arg src "$SRC" --arg stg "$BETA_SRC" \ + --arg root "$SERVERS_ROOT" --arg rel "$REL_DEST" \ + '.updated = $now + | .paths = {shared_scripts:$src, staging:$stg, + servers_root:$root, server_script_path:$rel}' +} + +# build the file, importing any legacy .beta manifests on first run +meta_init() { + if meta_exists; then + if ! meta_q 'has("servers") and has("deployments")' | grep -qx true; then + meta_apply '.servers //= {} | .deployments //= []' + fi + return 0 + fi + require_jq + jq -n --arg now "$(now_iso)" --arg src "$SRC" --arg stg "$BETA_SRC" \ + --arg root "$SERVERS_ROOT" --arg rel "$REL_DEST" \ + '{schema:1, generated_by:"push_scripts.sh", updated:$now, + paths:{shared_scripts:$src, staging:$stg, + servers_root:$root, server_script_path:$rel}, + betas:{}, servers:{}, deployments:[]}' >"$METADATA" + migrate_legacy +} + +migrate_legacy() { + local f slug name created base rel sha server had n=0 + [[ -d "$LEGACY_STATE_DIR" ]] || return 0 + for f in "$LEGACY_STATE_DIR"/*.beta; do + [[ -e "$f" ]] || continue + slug="$(basename "$f" .beta)" + name="$(awk -F'\t' '$1=="name"{print $2; exit}' "$f")" + created="$(awk -F'\t' '$1=="created"{print $2; exit}' "$f")" + beta_create "$slug" "$name" "$created" + while IFS=$'\t' read -r _ base rel sha; do + beta_add_script "$slug" "$base" "$rel" "$sha" + done < <(awk -F'\t' '$1=="staged"' "$f") + while IFS=$'\t' read -r _ server rel had; do + beta_add_deploy "$slug" "$rel" "$server" "$had" + done < <(awk -F'\t' '$1=="file"' "$f") + beta_sync_servers "$slug" + mv "$f" "$f.migrated" + n=$((n + 1)) + done + if [[ $n -gt 0 ]]; then + echo + note "imported $n open $(plural "$n" beta) from $LEGACY_STATE_DIR into $METADATA" + note "the old manifests were renamed to *.beta.migrated" + fi +} + +beta_create() { + meta_apply --arg slug "$1" --arg name "$2" --arg opened "$3" --arg folder "betas/$1" \ + '.betas[$slug] = {name:$name, folder:$folder, opened:$opened, + updated:$opened, revision:1, servers:[], scripts:[]}' +} + +beta_add_script() { # slug staged_as target sha + meta_apply --arg slug "$1" --arg staged "$2" --arg target "$3" --arg sha "$4" \ + --arg bfile "$(beta_file "$3")" --arg backup "$(old_file "$3")" \ + '.betas[$slug].scripts += [{staged_as:$staged, target:$target, + beta_file:$bfile, backup:$backup, + sha256:$sha, revision:1, deployed:[]}]' +} + +beta_add_deploy() { # slug target server had_original(0|1) + local had=false + [[ "$4" == "1" || "$4" == "true" ]] && had=true + meta_apply --arg slug "$1" --arg target "$2" --arg srv "$3" --argjson had "$had" \ + '(.betas[$slug].scripts[] | select(.target==$target) | .deployed) + += [{server:$srv, had_original:$had}]' +} + +beta_sync_servers() { + meta_apply --arg slug "$1" \ + '.betas[$slug].servers = ([.betas[$slug].scripts[].deployed[].server] | unique)' +} + +beta_bump_script() { # slug target sha + meta_apply --arg slug "$1" --arg target "$2" --arg sha "$3" --arg now "$(now_iso)" \ + '(.betas[$slug].scripts[] | select(.target==$target) | .sha256) = $sha + | (.betas[$slug].scripts[] | select(.target==$target) | .revision) += 1 + | .betas[$slug].revision += 1 + | .betas[$slug].updated = $now' +} + +beta_drop() { meta_apply --arg slug "$1" 'del(.betas[$slug])'; } + +beta_slugs() { meta_exists || return 0; meta_q '.betas | keys[]'; } +beta_has() { meta_exists || return 1; meta_q --arg s "$1" '.betas | has($s)' | grep -qx true; } +beta_field() { meta_q --arg s "$1" --arg k "$2" '.betas[$s][$k] // ""'; } +beta_servers() { meta_q --arg s "$1" '.betas[$s].servers[]?'; } + +# staged_as target beta_file backup revision +beta_scripts() { + meta_q --arg s "$1" \ + '.betas[$s].scripts[]? | [.staged_as,.target,.beta_file,.backup,(.revision|tostring)] | @tsv' +} + +# server target beta_file backup had_original +beta_deploys() { + meta_q --arg s "$1" \ + '.betas[$s].scripts[]? as $x | $x.deployed[]? + | [.server,$x.target,$x.beta_file,$x.backup,(.had_original|tostring)] | @tsv' +} + +owner_of() { # server target -> slug of the beta holding it + local out + meta_exists || return 1 + out="$(meta_q --arg srv "$1" --arg t "$2" \ + '[.betas | to_entries[] + | select(any(.value.scripts[]?; + .target==$t and any(.deployed[]?; .server==$srv))) + | .key][0] // empty')" + [[ -n "$out" ]] || return 1 + printf '%s\n' "$out" +} + +held_by_beta() { # server -> target slug + meta_exists || return 0 + meta_q --arg srv "$1" \ + '.betas | to_entries[] as $b | $b.value.scripts[]? + | select(any(.deployed[]?; .server==$srv)) + | "\(.target)\t\($b.key)"' +} + +resolve_beta() { + local slug + slug="$(slugify "$BETA_NAME")" + if ! beta_has "$slug"; then + echo "no open beta called '$BETA_NAME' in $METADATA" >&2 + echo "run 'push_scripts.sh -lb' to see what's open" >&2 + exit 1 + fi + printf '%s\n' "$slug" +} + +# ---------------------------------------------------------------- helpers --- + +slugify() { + printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-//; s/-$//' +} + +dest_of() { printf '%s/%s/%s\n' "$SERVERS_ROOT" "$1" "$REL_DEST"; } +join_list() { sort -u | awk 'NR==1{printf "%s",$0;next}{printf ", %s",$0} END{if(NR)print ""}'; } + +sha_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + printf '%s\n' '-' + fi +} + +list_source_files() { + ( cd "$SRC" && find . -path './betas' -prune -o -type f \ + ! -path './.git/*' ! -name '*.swp' ! -name '*~' \ + ! -name '*.OLD' ! -name '*-beta.*' -printf '%P\n' | sort ) +} + +staged_files() { + [[ -d "$BETA_SRC" ]] || return 0 + ( cd "$BETA_SRC" && find . -maxdepth 1 -type f \ + ! -name '*.swp' ! -name '*~' ! -name '.*' -printf '%P\n' | sort ) +} + +find_targets() { + ( cd "$SRC" && find . -path './betas' -prune -o -type f -name "$1" -printf '%P\n' | sort ) +} + +all_servers() { + local dir + for dir in "$SERVERS_ROOT"/*/; do + [[ -d "$dir" ]] || continue + basename "$dir" + done +} + +match_servers() { + local dir server p + for dir in "$SERVERS_ROOT"/*/; do + [[ -d "$dir" ]] || continue + server="$(basename "$dir")" + for p in "${PATTERNS[@]}"; do + # shellcheck disable=SC2053 + if [[ "$server" == $p ]]; then printf '%s\n' "$server"; break; fi + done + done +} + +beta_file() { + local rel="$1" dir base stem ext="" + dir="$(dirname "$rel")"; base="$(basename "$rel")" + if [[ "$base" == *.* ]]; then stem="${base%.*}"; ext=".${base##*.}"; else stem="$base"; fi + if [[ "$dir" == "." ]]; then printf '%s-beta%s\n' "$stem" "$ext" + else printf '%s/%s-beta%s\n' "$dir" "$stem" "$ext"; fi +} + +old_file() { + local rel="$1" dir base stem + dir="$(dirname "$rel")"; base="$(basename "$rel")" + if [[ "$base" == *.* ]]; then stem="${base%.*}"; else stem="$base"; fi + if [[ "$dir" == "." ]]; then printf '%s.OLD\n' "$stem" + else printf '%s/%s.OLD\n' "$dir" "$stem"; fi +} + +ensure_dest() { + local server="$1" dest + dest="$(dest_of "$server")" + if [[ ! -d "$dest" ]]; then + if [[ $CREATE -eq 1 && $DRY_RUN -eq 0 ]]; then + mkdir -p "$dest" + else + row "$YL" "$server" "no sp folder - use -c" + return 1 + fi + fi + return 0 +} + +# ----------------------------------------------------------- integrations --- + +have_komodo() { [[ -n "$KOMODO_ADDRESS" && -n "$KOMODO_API_KEY" && -n "$KOMODO_API_SECRET" ]]; } +have_iw4m() { [[ -n "$IW4M_ADDRESS" ]]; } +integrations_on() { have_komodo && have_iw4m; } + +# body, then the http status code on the last line +komodo_call() { # path json-body + curl -sS -m "$HTTP_TIMEOUT" -X POST "${KOMODO_ADDRESS%/}/$1" \ + -H 'Content-Type: application/json' \ + -H "X-Api-Key: $KOMODO_API_KEY" \ + -H "X-Api-Secret: $KOMODO_API_SECRET" \ + -w '\n%{http_code}' \ + -d "$2" 2>/dev/null +} + +# POST /api/client//login, keep the session cookie for the rest of the run +iw4m_login() { + local jar code + if [[ -z "$IW4M_CLIENT_ID" || -z "$IW4M_PASSWORD" ]]; then + IW4M_ERR="iw4madmin needs a login" + return 1 + fi + [[ -n "$IW4M_JAR" ]] && return 0 + + jar="$(mktemp -t push_scripts_iw4m.XXXXXX)" || return 1 + chmod 600 "$jar" + code="$(curl -sS -m "$HTTP_TIMEOUT" -o /dev/null -w '%{http_code}' \ + -c "$jar" -X POST \ + -H 'Content-Type: application/json' \ + --data "$(jq -nc --arg p "$IW4M_PASSWORD" '{password:$p}')" \ + "${IW4M_ADDRESS%/}/api/client/${IW4M_CLIENT_ID}/login" 2>/dev/null)" || { + rm -f "$jar"; IW4M_ERR="iw4madmin unreachable"; return 1; } + + if [[ "$code" != 2* ]]; then + rm -f "$jar" + if [[ "$code" == "401" ]]; then + IW4M_ERR="iw4madmin login rejected" + else + IW4M_ERR="iw4madmin login failed ($code)" + fi + return 1 + fi + IW4M_JAR="$jar" + return 0 +} + +iw4m_logout() { + [[ -n "$IW4M_JAR" ]] || return 0 + curl -sS -m "$HTTP_TIMEOUT" -o /dev/null -b "$IW4M_JAR" -X POST \ + "${IW4M_ADDRESS%/}/api/client/${IW4M_CLIENT_ID}/logout" >/dev/null 2>&1 || true + rm -f "$IW4M_JAR" + IW4M_JAR="" +} +trap iw4m_logout EXIT + +iw4m_fetch_status() { + local out + if [[ -n "$IW4M_JAR" ]]; then + out="$(curl -sS -m "$HTTP_TIMEOUT" -b "$IW4M_JAR" \ + "${IW4M_ADDRESS%/}/api/status" 2>/dev/null)" || return 1 + else + out="$(curl -sS -m "$HTTP_TIMEOUT" "${IW4M_ADDRESS%/}/api/status" 2>/dev/null)" || return 1 + fi + jq -e 'type=="array"' >/dev/null 2>&1 <<<"$out" || return 1 + IW4M_STATUS_CACHE="$out" + return 0 +} + +# pull /api/status once per run and keep it in memory. Most webfronts serve it +# without a login, so only sign in if the anonymous read doesn't work - that +# also avoids burning a one-shot !rt token when it isn't needed. +iw4m_load() { + have_iw4m || return 1 + [[ -n "$IW4M_STATUS_CACHE" ]] && return 0 + IW4M_ERR="" + if iw4m_fetch_status; then return 0; fi + IW4M_ERR="iw4madmin unreachable" + iw4m_login || return 1 + IW4M_ERR="" + iw4m_fetch_status || { IW4M_ERR="iw4madmin unreachable"; return 1; } + return 0 +} + +server_reg_seed() { + local server + while read -r server; do + [[ -n "$server" ]] || continue + if meta_q --arg s "$server" '.servers | has($s)' | grep -qx true; then continue; fi + meta_apply --arg s "$server" --arg k "$(printf '%s' "$server" | tr '_' '-')" \ + '.servers[$s] = {komodo_type:"deployment", komodo_name:$k, + komodo_server:"", iw4m_match:"", iw4m_value:""}' + done < <(all_servers) +} + +server_reg() { meta_q --arg s "$1" --arg k "$2" '.servers[$s][$k] // ""'; } +server_reg_set() { meta_apply --arg s "$1" --arg k "$2" --arg v "$3" '.servers[$s][$k] = $v'; } + +# how many players are on this server right now, blank if we can't tell +iw4m_players() { + local server="$1" match value out + [[ -n "$IW4M_STATUS_CACHE" ]] || return 1 + match="$(server_reg "$server" iw4m_match)" + value="$(server_reg "$server" iw4m_value)" + if [[ -z "$match" || -z "$value" ]]; then + iw4m_autolink "$server" >/dev/null || return 1 + match="$(server_reg "$server" iw4m_match)" + value="$(server_reg "$server" iw4m_value)" + fi + out="$(jq -r --arg m "$match" --arg v "$value" ' + def norm: ascii_downcase | gsub("[^a-z0-9]";""); + [ .[] | {id:(.id // .Id), port:(.port // .Port), + name:((.name // .Name) // ""), + players:(.currentPlayers // .CurrentPlayers)} ] + | map(select( + if $m=="port" then ((.port|tostring)==$v) + elif $m=="id" then ((.id|tostring)==$v) + else ((.name|norm)==($v|norm)) end)) + | if length==1 then (.[0].players|tostring) else empty end + ' <<<"$IW4M_STATUS_CACHE" 2>/dev/null)" || return 1 + [[ -n "$out" && "$out" != "null" ]] || return 1 + printf '%s\n' "$out" +} + +# first run: try to work out which IW4MAdmin server a folder is, and remember it +iw4m_autolink() { + local server="$1" token hit + token="$(printf '%s' "$server" | tr -cs 'a-zA-Z0-9' ' ' \ + | awk '{for(i=1;i<=NF;i++) if ($i !~ /^(t4|t5|t6|iw5|iw4|[0-9]+)$/) print $i}' \ + | head -1 | tr 'A-Z' 'a-z')" + [[ -n "$token" ]] || return 1 + hit="$(jq -r --arg t "$token" ' + def norm: ascii_downcase | gsub("[^a-z0-9]";""); + [ .[] | select((((.name // .Name) // "") | norm) | contains($t)) | (.port // .Port) ] + | if length==1 then (.[0]|tostring) else empty end' \ + <<<"$IW4M_STATUS_CACHE" 2>/dev/null)" || return 1 + [[ -n "$hit" ]] || return 1 + server_reg_set "$server" iw4m_match port + server_reg_set "$server" iw4m_value "$hit" + printf '%s\n' "$hit" +} + +komodo_restart() { + local server="$1" ktype name ksrv path body resp code + ktype="$(server_reg "$server" komodo_type)"; [[ -n "$ktype" ]] || ktype=deployment + name="$(server_reg "$server" komodo_name)" + [[ -n "$name" ]] || return 2 + case "$ktype" in + stack) + path="execute/RestartStack" + body="$(jq -nc --arg n "$name" '{stack:$n}')" ;; + container) + ksrv="$(server_reg "$server" komodo_server)" + [[ -n "$ksrv" ]] || return 2 + path="execute/RestartContainer" + body="$(jq -nc --arg n "$name" --arg s "$ksrv" '{server:$s, container:$n}')" ;; + *) + path="execute/RestartDeployment" + body="$(jq -nc --arg n "$name" '{deployment:$n}')" ;; + esac + resp="$(komodo_call "$path" "$body")" || return 1 + [[ -n "$resp" ]] || return 1 + code="$(tail -n1 <<<"$resp")" + resp="$(sed '$d' <<<"$resp")" + [[ "$code" == 2* ]] || return 1 + # note: jq's // treats false as empty, so test for the key explicitly + jq -e 'if type=="object" and has("success") then .success == true else true end' \ + >/dev/null 2>&1 <<<"$resp" || return 1 + return 0 +} + +# ----------------------------------------------------------- deployments --- + +deploy_open() { # action (reads CHANGED_SERVERS), prints the new id + local action="$1" id server files state="pending" + id="dep-$(date -u +%Y%m%dT%H%M%SZ)-$$" + integrations_on || state="manual" + meta_apply --arg id "$id" --arg a "$action" --arg now "$(now_iso)" \ + '.deployments += [{id:$id, action:$a, started:$now, servers:[]}]' + for entry in "${CHANGED_SERVERS[@]}"; do + IFS=$'\t' read -r server files <<<"$entry" + [[ -n "$server" ]] || continue + meta_apply --arg id "$id" --arg s "$server" --argjson f "${files:-0}" --arg st "$state" \ + '(.deployments[] | select(.id==$id) | .servers) += [{ + server:$s, files:$f, restart:$st, players:null, + checked:null, restarted:null, attempts:0, error:null}]' + done + printf '%s\n' "$id" +} + +deploy_set() { # id server restart players error + meta_apply --arg id "$1" --arg s "$2" --arg r "$3" --arg p "$4" --arg e "$5" \ + --arg now "$(now_iso)" \ + '(.deployments[] | select(.id==$id) | .servers[] | select(.server==$s)) |= ( + .restart = $r + | .checked = $now + | .players = (if $p=="" then null else ($p|tonumber) end) + | .error = (if $e=="" then null else $e end) + | .attempts = (.attempts + 1) + | (if $r=="done" then .restarted = $now else . end))' +} + +deploy_prune() { + local cutoff + cutoff="$(date -u -d "$DEPLOY_KEEP_DAYS days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || printf '')" + [[ -n "$cutoff" ]] || return 0 + meta_apply --arg cutoff "$cutoff" \ + '.deployments = [ .deployments[] + | select((.started >= $cutoff) + or any(.servers[]; .restart=="pending" or .restart=="failed")) ]' +} + +# id server for every server still waiting on a restart +deploy_pending() { + local filter="${1:-}" + meta_exists || return 0 + meta_q --arg id "$filter" \ + '.deployments[] + | select($id=="" or .id==$id) as $d + | $d.servers[] + | select(.restart=="pending" or .restart=="failed") + | "\($d.id)\t\(.server)"' +} + +# check each waiting server, restart the empty ones +deploy_sweep() { + local filter="${1:-}" line id server players rc + local checked=0 restarted=0 held=0 failed=0 + local -a pending=() unmatched=() + declare -A SEEN_PLAYERS SEEN_RESULT + + mapfile -t pending < <(deploy_pending "$filter") + [[ ${#pending[@]} -gt 0 ]] || return 0 + + if ! integrations_on; then + echo + hdr "RESTARTS" "not configured" + rule + note "set KOMODO_ADDRESS / KOMODO_API_KEY / KOMODO_API_SECRET / IW4M_ADDRESS" + note "in $ENV_FILE to let this restart containers for you" + rule + echo + return 0 + fi + + iw4m_load || true + + echo + hdr "RESTARTS" "$(printf '%s\n' "${pending[@]}" | cut -f2 | sort -u | grep -c .) $(plural "$(printf '%s\n' "${pending[@]}" | cut -f2 | sort -u | grep -c .)" server)" + rule + + for line in "${pending[@]}"; do + IFS=$'\t' read -r id server <<<"$line" + [[ -n "$server" ]] || continue + + if [[ -n "${SEEN_RESULT[$server]:-}" ]]; then + # already handled this server in another deployment this run + deploy_set "$id" "$server" "${SEEN_RESULT[$server]}" "${SEEN_PLAYERS[$server]:-}" "" + continue + fi + checked=$((checked + 1)) + + if [[ -z "$IW4M_STATUS_CACHE" ]]; then + deploy_set "$id" "$server" "pending" "" "${IW4M_ERR:-iw4madmin unreachable}" + SEEN_RESULT[$server]="pending" + row "$YL" "$server" "${IW4M_ERR:-iw4madmin unreachable} - held" + held=$((held + 1)); continue + fi + + if ! players="$(iw4m_players "$server")"; then + deploy_set "$id" "$server" "pending" "" "no iw4madmin match" + SEEN_RESULT[$server]="pending" + unmatched+=("$server") + row "$YL" "$server" "no iw4madmin match - held" + held=$((held + 1)); continue + fi + + SEEN_PLAYERS[$server]="$players" + if [[ "$players" -gt 0 ]]; then + deploy_set "$id" "$server" "pending" "$players" "" + SEEN_RESULT[$server]="pending" + row "$D" "$server" "$players $(plural "$players" player) on - held" + held=$((held + 1)); continue + fi + + rc=0; komodo_restart "$server" || rc=$? + case "$rc" in + 0) deploy_set "$id" "$server" "done" "0" "" + SEEN_RESULT[$server]="done" + row "$GN" "$server" "empty - restarted" + restarted=$((restarted + 1)) ;; + 2) deploy_set "$id" "$server" "failed" "0" "no komodo name" + SEEN_RESULT[$server]="failed" + row "$YL" "$server" "no komodo name - skipped" + failed=$((failed + 1)) ;; + *) deploy_set "$id" "$server" "failed" "0" "komodo restart failed" + SEEN_RESULT[$server]="failed" + row "$YL" "$server" "komodo restart failed" + failed=$((failed + 1)) ;; + esac + done + + rule + note "$restarted restarted · $held held · $failed failed" + [[ $((held + failed)) -gt 0 ]] && note "check again later with: push_scripts.sh -ad" + + if [[ "$IW4M_ERR" == *login* ]]; then + echo + note "set IW4M_CLIENT_ID and IW4M_PASSWORD in $ENV_FILE" + note "an in-game !rt token works but dies after 2 minutes and only once -" + note "use !setpassword in game for something that keeps working" + fi + + if [[ ${#unmatched[@]} -gt 0 && -n "$IW4M_STATUS_CACHE" ]]; then + echo + note "couldn't work out which IW4MAdmin server these are:" + note " $(printf '%s\n' "${unmatched[@]}" | sort -u | join_list)" + note "set iw4m_match/iw4m_value for them in $METADATA using:" + jq -r '.[] | " port \((.port // .Port)) \((.name // .Name))"' \ + <<<"$IW4M_STATUS_CACHE" 2>/dev/null | while IFS= read -r l; do + printf ' %s%s%s\n' "$D" "$l" "$R" + done + fi + echo +} + +deploy_finish() { # action + local id + [[ $DRY_RUN -eq 0 ]] || return 0 + [[ ${#CHANGED_SERVERS[@]} -gt 0 ]] || return 0 + meta_init + server_reg_seed + id="$(deploy_open "$1")" + deploy_prune + if integrations_on; then + # sweep everything queued, not just this deployment: one restart makes + # every change waiting on that server live + deploy_sweep "" + else + note "restart checks are off - no KOMODO_ADDRESS / IW4M_ADDRESS in $ENV_FILE" + echo + fi +} + +deploy_rows() { # all deployments, newest first + meta_exists || return 0 + meta_q '[.deployments[]] | sort_by(.started) | reverse | .[] + | [ .id, .action, .started, + ([.servers[] | select(.restart=="pending" or .restart=="failed")] | length), + (.servers | length) ] | @tsv' +} + +deploy_servers_by_state() { # id state + meta_q --arg id "$1" --arg st "$2" \ + '.deployments[] | select(.id==$id) | .servers[] | select(.restart==$st) + | if .players != null and .players > 0 + then "\(.server) (\(.players)p)" else .server end' +} + +do_list_deployments() { + local mode="${1:-all}" id action started npend ntotal line shown=0 active=0 + meta_init + deploy_prune + + local total + total="$(deploy_rows | grep -c . || true)" + active="$(deploy_rows | awk -F'\t' '$4>0' | grep -c . || true)" + + echo + if [[ "$mode" == "active" ]]; then + hdr "ACTIVE DEPLOYMENTS" "$active of $total open" + else + hdr "DEPLOYMENTS" "last $DEPLOY_KEEP_DAYS days · $active active" + fi + rule + + while IFS=$'\t' read -r id action started npend ntotal; do + [[ -n "$id" ]] || continue + if [[ "$mode" == "active" && "$npend" -eq 0 ]]; then continue; fi + shown=$((shown + 1)) + if [[ "$npend" -gt 0 ]]; then + row "$YL" "$action" "$(when_str "$started")" + else + row "$GN" "$action" "$(when_str "$started")" + fi + line="$(deploy_servers_by_state "$id" done | join_list)" + [[ -n "$line" ]] && field_wrap "restarted" "$line" + line="$(deploy_servers_by_state "$id" pending | join_list)" + [[ -n "$line" ]] && field_wrap "waiting" "$line" + line="$(deploy_servers_by_state "$id" failed | join_list)" + [[ -n "$line" ]] && field_wrap "failed" "$line" + line="$(deploy_servers_by_state "$id" manual | join_list)" + [[ -n "$line" ]] && field_wrap "no check" "$line" + printf ' %s%-9s%s %s\n' "$D" "id" "$R" "${D}$id${R}" + echo + done < <(deploy_rows) + + if [[ $shown -eq 0 ]]; then + if [[ "$mode" == "active" ]]; then note "nothing waiting on a restart" + else note "no deployments recorded yet"; fi + rule + echo + return 0 + fi + rule + note "restart sweep: push_scripts.sh -ad" + echo +} + +do_active_deployments() { + meta_init + if [[ $DRY_RUN -eq 0 ]]; then deploy_sweep ""; fi + do_list_deployments active +} + +# ------------------------------------------------------------------- push --- + +do_push() { + local total_servers=0 total_changed=0 total_files=0 + local server dest out changes rel line slug verb path + local -a held excl rs_opts + declare -A HELD_SET + + echo + if [[ $DRY_RUN -eq 1 ]]; then hdr "PUSH (dry run)" "$SRC"; else hdr "PUSH" "$SRC"; fi + rule + + while read -r server; do + ensure_dest "$server" || continue + dest="$(dest_of "$server")" + total_servers=$((total_servers + 1)) + + held=(); excl=(); HELD_SET=() + mapfile -t held < <(held_by_beta "$server") + for line in ${held[@]+"${held[@]}"}; do + [[ -n "$line" ]] || continue + IFS=$'\t' read -r rel slug <<<"$line" + HELD_SET["$rel"]="$slug" + excl+=("--exclude=/$rel") + done + + changes=0 + if command -v rsync >/dev/null 2>&1; then + rs_opts=(-a --checksum --itemize-changes + --exclude '/betas/' --exclude '.git' --exclude '*.swp' + --exclude '*~' --exclude '*.OLD' --exclude '*-beta.*') + if [[ $DRY_RUN -eq 1 ]]; then rs_opts+=(--dry-run); fi + if [[ $DELETE -eq 1 ]]; then rs_opts+=(--delete); fi + out="$(rsync "${rs_opts[@]}" ${excl[@]+"${excl[@]}"} "$SRC"/ "$dest"/)" + out="$(printf '%s\n' "$out" | awk ' + /^\*deleting/ { p=$0; sub(/^[^ ]+[[:space:]]+/,"",p); print "removed\t" p; next } + /^>f/ { p=$0; sub(/^[^ ]+[[:space:]]+/,"",p) + if ($1 ~ /\+\+\+\+\+\+\+/) print "added\t" p; else print "updated\t" p } + ')" + changes="$(printf '%s' "$out" | grep -c . || true)" + else + out="" + while read -r rel; do + [[ -n "${HELD_SET[$rel]:-}" ]] && continue + if [[ ! -f "$dest/$rel" ]]; then verb=added + elif ! cmp -s "$SRC/$rel" "$dest/$rel"; then verb=updated + else continue; fi + if [[ $DRY_RUN -eq 0 ]]; then + mkdir -p "$dest/$(dirname "$rel")" + cp -p "$SRC/$rel" "$dest/$rel" + fi + out+="$verb"$'\t'"$rel"$'\n' + done < <(list_source_files) + changes="$(printf '%s' "$out" | grep -c . || true)" + fi + + if [[ "$changes" -gt 0 ]]; then + CHANGED_SERVERS+=("$server"$'\t'"$changes") + row "$GN" "$server" "$changes $(plural "$changes" file)" + while IFS=$'\t' read -r verb path; do + [[ -n "$verb" ]] || continue + detail "$verb" "$path" + done <<<"$out" + total_changed=$((total_changed + 1)) + total_files=$((total_files + changes)) + else + row "$D" "$server" "up to date" + fi + + if [[ ${#HELD_SET[@]} -gt 0 ]]; then + for rel in "${!HELD_SET[@]}"; do + detail "held" "$(printf '%-28s' "$rel")${D}beta: ${HELD_SET[$rel]}${R}" + done + fi + done < <(match_servers) + + rule + if [[ $DRY_RUN -eq 1 ]]; then + note "would change $total_changed of $total_servers $(plural "$total_servers" server) · $total_files $(plural "$total_files" file)" + else + note "$total_changed of $total_servers $(plural "$total_servers" server) updated · $total_files $(plural "$total_files" file)" + fi + echo + deploy_finish "$DEPLOY_ACTION" +} + +# ------------------------------------------------------------------- beta --- + +do_beta() { + local slug beta_dir server dest base rel had owner bfile ofile entry + local -a staged=() hits=() targets=() servers=() conflicts=() blocked=() ambiguous=() + + slug="$(slugify "$BETA_NAME")" + [[ -n "$slug" ]] || { echo "beta name needs at least one letter or number" >&2; exit 1; } + beta_dir="$BETA_SRC/$slug" + + meta_init + if beta_has "$slug"; then + echo "a beta called '$(beta_field "$slug" name)' is already open" >&2 + echo "update it (-ub), roll it back (-r) or promote it (-pb) first" >&2 + exit 1 + fi + if [[ -e "$beta_dir" ]]; then + echo "$beta_dir already exists but no beta is open for it" >&2 + echo "move or delete that folder first" >&2 + exit 1 + fi + if [[ ! -d "$BETA_SRC" ]]; then + echo "no staging folder at $BETA_SRC" >&2 + echo "create it and drop the scripts you want to test inside" >&2 + exit 1 + fi + + mapfile -t staged < <(staged_files) + if [[ ${#staged[@]} -eq 0 ]]; then + echo "nothing staged in $BETA_SRC - drop the scripts you want to test in there first" >&2 + exit 1 + fi + + for base in "${staged[@]}"; do + mapfile -t hits < <(find_targets "$base") + case ${#hits[@]} in + 0) targets+=("$base"$'\t'"$base") ;; + 1) targets+=("$base"$'\t'"${hits[0]}") ;; + *) ambiguous+=("$base"$'\t'"$(printf '%s\n' "${hits[@]}" | join_list)") ;; + esac + done + + if [[ ${#ambiguous[@]} -gt 0 ]]; then + echo "more than one script in the shared folder has these names, so I can't tell" >&2 + echo "which one the staged copy is meant to replace:" >&2 + for entry in "${ambiguous[@]}"; do + IFS=$'\t' read -r base rel <<<"$entry" + printf ' %-26s %s\n' "$base" "$rel" >&2 + done + echo "nothing was changed" >&2 + exit 1 + fi + + mapfile -t servers < <(match_servers) + if [[ ${#servers[@]} -eq 0 ]]; then + echo "no servers matched" >&2 + exit 1 + fi + + local -a plan=() + for server in "${servers[@]}"; do + ensure_dest "$server" || continue + dest="$(dest_of "$server")" + for entry in "${targets[@]}"; do + IFS=$'\t' read -r base rel <<<"$entry" + if owner="$(owner_of "$server" "$rel")"; then + conflicts+=("$server"$'\t'"$rel"$'\t'"$owner") + continue + fi + had=0 + if [[ -f "$dest/$rel" ]]; then had=1; fi + ofile="$(old_file "$rel")" + if [[ $had -eq 1 && -e "$dest/$ofile" ]]; then + blocked+=("$server"$'\t'"$ofile") + continue + fi + plan+=("$server"$'\t'"$base"$'\t'"$rel"$'\t'"$had") + done + done + + if [[ ${#conflicts[@]} -gt 0 ]]; then + echo "conflict - these scripts already belong to another beta:" >&2 + for entry in "${conflicts[@]}"; do + IFS=$'\t' read -r server rel owner <<<"$entry" + printf ' %-16s %-26s beta: %s\n' "$server" "$rel" "$owner" >&2 + done + echo "nothing was changed" >&2 + exit 1 + fi + + if [[ ${#blocked[@]} -gt 0 ]]; then + echo "stale backups in the way - no beta owns these, so they were never cleaned up:" >&2 + for entry in "${blocked[@]}"; do + IFS=$'\t' read -r server ofile <<<"$entry" + printf ' %-16s %s\n' "$server" "$ofile" >&2 + done + echo "move or delete them, then reopen the beta. nothing was changed" >&2 + exit 1 + fi + + if [[ ${#plan[@]} -eq 0 ]]; then + echo "no usable servers - nothing was changed" >&2 + exit 1 + fi + + local srv_list scr_list n_srv n_scr + srv_list="$(printf '%s\n' "${plan[@]}" | cut -f1 | join_list)" + scr_list="$(printf '%s\n' "${targets[@]}" | cut -f2 | join_list)" + n_srv="$(printf '%s\n' "${plan[@]}" | cut -f1 | sort -u | grep -c .)" + n_scr="${#targets[@]}" + + echo + if [[ $DRY_RUN -eq 1 ]]; then + hdr "OPEN BETA $BETA_NAME (dry run)" "$n_scr $(plural "$n_scr" script) · $n_srv $(plural "$n_srv" server)" + rule + field_wrap "servers" "$srv_list" + field_wrap "scripts" "$scr_list" + field "deploy" "each lands as -beta.gsc, live copy parked as .OLD" + field "move to" "betas/$slug/" + rule + note "nothing was changed" + echo + return 0 + fi + + beta_create "$slug" "$BETA_NAME" "$(now_iso)" + for entry in "${targets[@]}"; do + IFS=$'\t' read -r base rel <<<"$entry" + beta_add_script "$slug" "$base" "$rel" "$(sha_of "$BETA_SRC/$base")" + done + + for entry in "${plan[@]}"; do + IFS=$'\t' read -r server base rel had <<<"$entry" + dest="$(dest_of "$server")" + bfile="$(beta_file "$rel")" + ofile="$(old_file "$rel")" + mkdir -p "$dest/$(dirname "$rel")" + if [[ $had -eq 1 ]]; then mv "$dest/$rel" "$dest/$ofile"; fi + cp -p "$BETA_SRC/$base" "$dest/$bfile" + beta_add_deploy "$slug" "$rel" "$server" "$had" + done + beta_sync_servers "$slug" + meta_touch + + mkdir -p "$beta_dir" + for entry in "${targets[@]}"; do + IFS=$'\t' read -r base rel <<<"$entry" + mv "$BETA_SRC/$base" "$beta_dir/$(basename "$(beta_file "$rel")")" + done + + hdr "BETA OPEN $BETA_NAME" "$n_scr $(plural "$n_scr" script) · $n_srv $(plural "$n_srv" server)" + rule + field_wrap "servers" "$srv_list" + field_wrap "scripts" "$scr_list" + field "deployed" "as -beta.gsc, live copies parked as .OLD" + field "folder" "betas/$slug/" + rule + note "staging folder is clear for the next beta" + note "update: push_scripts.sh -ub \"$BETA_NAME\"" + note "roll back: push_scripts.sh -r \"$BETA_NAME\"" + echo + + CHANGED_SERVERS=() + while read -r server; do + [[ -n "$server" ]] || continue + CHANGED_SERVERS+=("$server"$'\t'"$(printf '%s\n' "${plan[@]}" | awk -F'\t' -v s="$server" '$1==s' | grep -c .)") + done < <(printf '%s\n' "${plan[@]}" | cut -f1 | sort -u) + deploy_finish "beta:$slug" +} + +# ------------------------------------------------------------ update beta --- + +do_update_beta() { + local slug beta_dir base staged_as target bfile backup rev server line entry + local touched=0 + local -a staged=() matched=() unmatched=() missing=() known=() + + meta_init + slug="$(resolve_beta)" + beta_dir="$BETA_SRC/$slug" + + mapfile -t staged < <(staged_files) + if [[ ${#staged[@]} -eq 0 ]]; then + echo "nothing staged in $BETA_SRC - drop the new version in there first" >&2 + exit 1 + fi + + # a staged file belongs to this beta if it matches how the script was staged + # originally, or the filename of the script it replaces + mapfile -t known < <(beta_scripts "$slug") + for base in "${staged[@]}"; do + entry="" + for line in ${known[@]+"${known[@]}"}; do + IFS=$'\t' read -r staged_as target bfile backup rev <<<"$line" + if [[ "$base" == "$staged_as" || "$base" == "$(basename "$target")" ]]; then + entry="$base"$'\t'"$target"$'\t'"$bfile"$'\t'"$rev" + break + fi + done + if [[ -n "$entry" ]]; then matched+=("$entry"); else unmatched+=("$base"); fi + done + + if [[ ${#matched[@]} -eq 0 ]]; then + echo "none of the staged files belong to '$BETA_NAME':" >&2 + printf '%s\n' "${staged[@]}" | sed 's/^/ /' >&2 + echo "that beta covers: $(beta_scripts "$slug" | cut -f2 | join_list)" >&2 + echo "nothing was changed" >&2 + exit 1 + fi + + local scr_list srv_list n_scr n_srv + scr_list="$(printf '%s\n' "${matched[@]}" | cut -f2 | join_list)" + srv_list="$(beta_servers "$slug" | join_list)" + n_scr="${#matched[@]}" + n_srv="$(beta_servers "$slug" | grep -c . || true)" + + echo + if [[ $DRY_RUN -eq 1 ]]; then + hdr "UPDATE BETA $BETA_NAME (dry run)" "$n_scr $(plural "$n_scr" script) · $n_srv $(plural "$n_srv" server)" + rule + field_wrap "replace" "$scr_list" + field_wrap "servers" "$srv_list" + field "keeps" "backups, membership and revision history" + [[ ${#unmatched[@]} -gt 0 ]] && field_wrap "skipped" "$(printf '%s\n' "${unmatched[@]}" | join_list)" + rule + note "nothing was changed" + echo + return 0 + fi + + for entry in "${matched[@]}"; do + IFS=$'\t' read -r base target bfile rev <<<"$entry" + + while IFS=$'\t' read -r server _ _ _ _; do + [[ -n "$server" ]] || continue + dest="$(dest_of "$server")" + if [[ ! -d "$dest" ]]; then + missing+=("$server: no sp folder") + continue + fi + mkdir -p "$dest/$(dirname "$target")" + cp -p "$BETA_SRC/$base" "$dest/$bfile" + done < <(beta_deploys "$slug" | awk -F'\t' -v t="$target" '$2==t') + + mv -f "$BETA_SRC/$base" "$beta_dir/$(basename "$bfile")" + beta_bump_script "$slug" "$target" "$(sha_of "$beta_dir/$(basename "$bfile")")" + touched=$((touched + 1)) + done + meta_touch + + hdr "BETA UPDATED $BETA_NAME" "rev $(beta_field "$slug" revision)" + rule + field_wrap "replaced" "$scr_list" + field_wrap "servers" "$srv_list" + field "folder" "betas/$slug/" + [[ ${#unmatched[@]} -gt 0 ]] && field_wrap "skipped" "$(printf '%s\n' "${unmatched[@]}" | join_list)" + for line in ${missing[@]+"${missing[@]}"}; do warn "$line"; done + rule + note "backups and beta membership unchanged" + [[ ${#unmatched[@]} -gt 0 ]] && note "anything left staged is not part of this beta" + echo + + CHANGED_SERVERS=() + while read -r server; do + [[ -n "$server" ]] || continue + CHANGED_SERVERS+=("$server"$'\t'"$n_scr") + done < <(beta_servers "$slug") + deploy_finish "update:$slug" +} + +# --------------------------------------------------------------- rollback --- + +do_rollback() { + local slug beta_dir server target bfile backup had dest line + local restored=0 removed=0 missing=0 dropped=0 + local -a restored_list=() dropped_list=() warn_list=() touched_servers=() + + meta_init + slug="$(resolve_beta)" + beta_dir="$BETA_SRC/$slug" + + while IFS=$'\t' read -r server target bfile backup had; do + [[ -n "$server" ]] || continue + dest="$(dest_of "$server")" + + if [[ $DRY_RUN -eq 1 ]]; then + if [[ "$had" == "true" ]]; then restored_list+=("$target"); else dropped_list+=("$target"); fi + removed=$((removed + 1)) + continue + fi + + touched_servers+=("$server") + if [[ -e "$dest/$bfile" ]]; then rm -f "$dest/$bfile"; removed=$((removed + 1)); fi + if [[ "$had" == "true" ]]; then + if [[ -e "$dest/$backup" ]]; then + mv "$dest/$backup" "$dest/$target" + restored=$((restored + 1)); restored_list+=("$target") + else + missing=$((missing + 1)); warn_list+=("$server: no $backup to restore") + fi + else + dropped_list+=("$target") + fi + done < <(beta_deploys "$slug") + + if [[ -d "$beta_dir" ]]; then dropped="$(find "$beta_dir" -type f | grep -c . || true)"; fi + + echo + if [[ $DRY_RUN -eq 1 ]]; then + hdr "ROLLBACK $BETA_NAME (dry run)" "$removed beta $(plural "$removed" file)" + rule + [[ ${#restored_list[@]} -gt 0 ]] && field_wrap "restore" "$(printf '%s\n' "${restored_list[@]}" | join_list)" + [[ ${#dropped_list[@]} -gt 0 ]] && field_wrap "drop" "$(printf '%s\n' "${dropped_list[@]}" | join_list)" + field "delete" "betas/$slug/ ($dropped $(plural "$dropped" script))" + rule + note "nothing was changed" + echo + return 0 + fi + + rm -rf "$beta_dir" + beta_drop "$slug" + meta_touch + + hdr "ROLLED BACK $BETA_NAME" "$removed beta $(plural "$removed" file) removed" + rule + [[ ${#restored_list[@]} -gt 0 ]] && field_wrap "restored" "$(printf '%s\n' "${restored_list[@]}" | join_list)" + [[ ${#dropped_list[@]} -gt 0 ]] && field_wrap "dropped" "$(printf '%s\n' "${dropped_list[@]}" | join_list)" + field "deleted" "betas/$slug/ ($dropped $(plural "$dropped" script))" + for line in ${warn_list[@]+"${warn_list[@]}"}; do warn "$line"; done + rule + note "servers are back on the shared version" + echo + + CHANGED_SERVERS=() + while read -r server; do + [[ -n "$server" ]] || continue + CHANGED_SERVERS+=("$server"$'\t'"1") + done < <(printf '%s\n' ${touched_servers[@]+"${touched_servers[@]}"} | sort -u) + deploy_finish "rollback:$slug" +} + +# ---------------------------------------------------------------- promote --- + +do_promote() { + local slug beta_dir server target bfile backup had dest staged_as rev + local cleaned=0 landed=0 + local -a lost=() live_list=() + + meta_init + slug="$(resolve_beta)" + beta_dir="$BETA_SRC/$slug" + + while IFS=$'\t' read -r staged_as target bfile backup rev; do + [[ -n "$target" ]] || continue + [[ -f "$beta_dir/$(basename "$bfile")" ]] || lost+=("$(basename "$bfile")") + done < <(beta_scripts "$slug") + + if [[ ${#lost[@]} -gt 0 ]]; then + echo "can't promote '$BETA_NAME' - these are missing from betas/$slug/:" >&2 + printf '%s\n' "${lost[@]}" | sed 's/^/ /' >&2 + echo "put them back or roll the beta back instead. nothing was changed" >&2 + exit 1 + fi + + while IFS=$'\t' read -r server target bfile backup had; do + [[ -n "$server" ]] || continue + dest="$(dest_of "$server")" + if [[ $DRY_RUN -eq 0 ]]; then + rm -f "$dest/$bfile" + [[ "$had" == "true" ]] && rm -f "$dest/$backup" + fi + cleaned=$((cleaned + 1)) + done < <(beta_deploys "$slug") + + while IFS=$'\t' read -r staged_as target bfile backup rev; do + [[ -n "$target" ]] || continue + live_list+=("$target") + if [[ $DRY_RUN -eq 0 ]]; then + mkdir -p "$SRC/$(dirname "$target")" + mv -f "$beta_dir/$(basename "$bfile")" "$SRC/$target" + fi + landed=$((landed + 1)) + done < <(beta_scripts "$slug") + + echo + if [[ $DRY_RUN -eq 1 ]]; then + hdr "PROMOTE $BETA_NAME (dry run)" "$landed $(plural "$landed" script)" + rule + field_wrap "go live" "$(printf '%s\n' "${live_list[@]}" | join_list)" + field "clear" "$cleaned beta $(plural "$cleaned" file) and backups off the servers" + field "delete" "betas/$slug/" + field "then" "push to every server" + rule + note "nothing was changed" + echo + return 0 + fi + + rm -rf "$beta_dir" + beta_drop "$slug" + meta_touch + + hdr "PROMOTED $BETA_NAME" "$landed $(plural "$landed" script)" + rule + field_wrap "now live" "$(printf '%s\n' "${live_list[@]}" | join_list)" + field "cleared" "$cleaned beta $(plural "$cleaned" file) and backups" + field "deleted" "betas/$slug/" + rule + + PATTERNS=("*") + DEPLOY_ACTION="promote:$slug" + do_push +} + +# ------------------------------------------------------------------- list --- + +do_list() { + local slug name opened updated revision server target bfile backup had dest base + local open=0 nstaged=0 + local -a staged=() hits=() slugs=() trouble=() + + meta_init + mapfile -t staged < <(staged_files) + mapfile -t slugs < <(beta_slugs) + nstaged=${#staged[@]} + open=${#slugs[@]} + + echo + hdr "BETAS" "$open open · $nstaged staged" + rule + + if [[ $nstaged -gt 0 ]]; then + printf ' %sSTAGING%s %sready for the next beta you open%s\n' "$B" "$R" "$D" "$R" + for base in "${staged[@]}"; do + mapfile -t hits < <(find_targets "$base") + case ${#hits[@]} in + 0) detail "new" "$base" ;; + 1) if [[ "${hits[0]}" == "$base" ]]; then + detail "replaces" "$base" + else + detail "replaces" "${hits[0]} ${D}(staged as $base)${R}" + fi ;; + *) detail "ambiguous" "$base ${YL}matches ${hits[*]}${R}" ;; + esac + done + echo + fi + + if [[ $open -eq 0 ]]; then + if [[ $nstaged -eq 0 ]]; then + note "nothing staged, nothing open" + note "drop scripts in betas/, then: push_scripts.sh -b \"Name\" " + else + note "open one with: push_scripts.sh -b \"Name\" " + fi + rule + echo + return 0 + fi + + for slug in "${slugs[@]}"; do + name="$(beta_field "$slug" name)" + opened="$(beta_field "$slug" opened)" + updated="$(beta_field "$slug" updated)" + revision="$(beta_field "$slug" revision)" + trouble=() + + while IFS=$'\t' read -r server target bfile backup had; do + [[ -n "$server" ]] || continue + dest="$(dest_of "$server")" + [[ -e "$dest/$bfile" ]] || trouble+=("$server: $bfile missing on disk") + if [[ "$had" == "true" && ! -e "$dest/$backup" ]]; then + trouble+=("$server: backup $backup missing") + fi + done < <(beta_deploys "$slug") + + if [[ ! -d "$BETA_SRC/$slug" ]] || [[ "$(find "$BETA_SRC/$slug" -type f | grep -c . || true)" -eq 0 ]]; then + trouble+=("betas/$slug/ is empty - promote would fail") + fi + + if [[ ${#trouble[@]} -gt 0 ]]; then + row "$YL" "$name" "$(when_str "$opened")" + else + row "$GN" "$name" "$(when_str "$opened")" + fi + + field_wrap "maps" "$(beta_deploys "$slug" | cut -f1 | join_list)" + field_wrap "scripts" "$(beta_scripts "$slug" | cut -f2 | join_list)" + field "folder" "betas/$slug/" + if [[ "$revision" != "1" ]]; then + field "revision" "$revision ${D}updated $(ago "$updated")${R}" + fi + for base in ${trouble[@]+"${trouble[@]}"}; do warn "$base"; done + echo + done + + rule + note "update: push_scripts.sh -ub \"\"" + note "promote: push_scripts.sh -pb \"\"" + note "roll back: push_scripts.sh -r \"\"" + echo +} + +# ------------------------------------------------------------------- main --- + +case "$MODE" in + push) do_push ;; + beta) do_beta ;; + update) do_update_beta ;; + rollback) do_rollback ;; + promote) do_promote ;; + list) do_list ;; + active) do_active_deployments ;; + deployments) do_list_deployments all ;; +esac diff --git a/scripts/import-legacy.ts b/scripts/import-legacy.ts new file mode 100644 index 0000000..b0d143d --- /dev/null +++ b/scripts/import-legacy.ts @@ -0,0 +1,199 @@ +#!/usr/bin/env node +/** + * One-shot importer: reads script_metadata.json (the format push_scripts.sh + * writes) and populates the electric-cherry SQLite DB with the equivalent + * rows. Idempotent — safe to re-run, will INSERT OR IGNORE on collisions. + * + * Trigger: docker/entrypoint.sh calls this when LEGACY_METADATA is set and + * DB_PATH is empty. + */ + +import { readFile, rename, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { env, exit } from 'node:process'; +import { db } from '../src/lib/server/db/index.js'; + +interface LegacyServer { + komodo_type?: string; + komodo_name?: string; + komodo_server?: string; + iw4m_match?: string; + iw4m_value?: string; +} +interface LegacyBetaScript { + staged_as: string; + target: string; + beta_file: string; + backup: string; + sha256: string; + revision?: number; + deployed?: Array<{ server: string; had_original: boolean }>; +} +interface LegacyBeta { + name: string; + folder: string; + opened: string; + updated?: string; + revision?: number; + servers?: string[]; + scripts?: LegacyBetaScript[]; +} +interface LegacyDeployServer { + server: string; + files: number; + restart: string; + players: number | null; + checked: string | null; + restarted: string | null; + attempts: number; + error: string | null; +} +interface LegacyDeployment { + id: string; + action: string; + started: string; + servers?: LegacyDeployServer[]; +} +interface LegacyRoot { + servers?: Record; + betas?: Record; + deployments?: LegacyDeployment[]; +} + +async function main(): Promise { + const src = env.LEGACY_METADATA; + if (!src) { + console.error('LEGACY_METADATA env var not set'); + exit(1); + } + if (!existsSync(src)) { + console.log(`no legacy file at ${src} — nothing to import`); + return; + } + const stats = await stat(src); + if (stats.size === 0) { + console.log(`legacy file at ${src} is empty — nothing to import`); + return; + } + + const raw = await readFile(src, 'utf8'); + const data = JSON.parse(raw) as LegacyRoot; + const handle = db(); + + let servers = 0; + let betas = 0; + let betaScripts = 0; + let betaDeploys = 0; + let deployments = 0; + let deploymentServers = 0; + + const tx = handle.transaction(() => { + // servers + const insServer = handle.prepare( + `INSERT OR IGNORE INTO servers + (name, komodo_type, komodo_name, komodo_server, iw4m_match, iw4m_value) + VALUES (?, ?, ?, ?, ?, ?)` + ); + for (const [name, s] of Object.entries(data.servers ?? {})) { + const r = insServer.run( + name, + s.komodo_type ?? 'deployment', + s.komodo_name ?? name.replace(/_/g, '-'), + s.komodo_server ?? null, + s.iw4m_match ?? null, + s.iw4m_value ?? null + ); + if (r.changes > 0) servers++; + } + + // betas + const insBeta = handle.prepare( + `INSERT OR IGNORE INTO betas (slug, name, folder, opened_at, updated_at, revision) + VALUES (?, ?, ?, ?, ?, ?)` + ); + const insScript = handle.prepare( + `INSERT INTO beta_scripts (beta_slug, staged_as, target, beta_file, backup, sha256, revision) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ); + const insDeploy = handle.prepare( + `INSERT OR IGNORE INTO beta_deploys (beta_script_id, server, had_original) + VALUES (?, ?, ?)` + ); + + for (const [slug, b] of Object.entries(data.betas ?? {})) { + const br = insBeta.run(slug, b.name, b.folder, b.opened, b.updated ?? b.opened, b.revision ?? 1); + if (br.changes > 0) betas++; + for (const s of b.scripts ?? []) { + const sr = insScript.run( + slug, + s.staged_as, + s.target, + s.beta_file, + s.backup, + s.sha256, + s.revision ?? 1 + ); + betaScripts++; + const scriptId = Number(sr.lastInsertRowid); + for (const d of s.deployed ?? []) { + // Skip deploys whose server isn't tracked — matches the + // FK constraint. Log so we don't silently lose rows. + const exists = handle.prepare('SELECT 1 FROM servers WHERE name = ?').get(d.server); + if (!exists) { + console.warn(`skipping beta_deploy for unknown server ${d.server}`); + continue; + } + const dr = insDeploy.run(scriptId, d.server, d.had_original ? 1 : 0); + if (dr.changes > 0) betaDeploys++; + } + } + } + + // deployments + const insDep = handle.prepare( + `INSERT OR IGNORE INTO deployments (id, action, started_at, triggered_by) + VALUES (?, ?, ?, 'legacy')` + ); + const insDepSrv = handle.prepare( + `INSERT OR IGNORE INTO deployment_servers + (deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + for (const d of data.deployments ?? []) { + const r = insDep.run(d.id, d.action, d.started); + if (r.changes > 0) deployments++; + for (const s of d.servers ?? []) { + const exists = handle.prepare('SELECT 1 FROM servers WHERE name = ?').get(s.server); + if (!exists) continue; + const sr = insDepSrv.run( + d.id, + s.server, + s.files ?? 0, + s.restart ?? 'manual', + s.players ?? null, + s.checked ?? null, + s.restarted ?? null, + s.attempts ?? 0, + s.error ?? null + ); + if (sr.changes > 0) deploymentServers++; + } + } + }); + tx(); + + console.log( + `imported: ${servers} servers, ${betas} betas (${betaScripts} scripts, ${betaDeploys} deploys), ` + + `${deployments} deployments (${deploymentServers} server rows)` + ); + + // Move the source out of the way so we don't re-import on future boots. + const moved = `${src}.migrated`; + await rename(src, moved); + console.log(`renamed ${src} → ${moved}`); +} + +main().catch((err) => { + console.error('legacy import failed:', err); + exit(2); +}); diff --git a/scripts/seed-fixture.ts b/scripts/seed-fixture.ts new file mode 100644 index 0000000..d250985 --- /dev/null +++ b/scripts/seed-fixture.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * Seed a test-fixtures/zombies/ tree so the app has something to point at + * during local dev on Windows/macOS (where /root/zombies doesn't exist). + * + * Usage: + * npm run seed:fixture + * SHARED_SCRIPTS=./test-fixtures/zombies/shared/scripts \ + * SERVERS_ROOT=./test-fixtures/zombies/t5 \ + * npm run dev + */ + +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const root = process.env.FIXTURE_ROOT ?? './test-fixtures/zombies'; + +async function main(): Promise { + const shared = join(root, 'shared/scripts'); + const staging = join(shared, 'betas'); + const t5 = join(root, 't5'); + await mkdir(shared, { recursive: true }); + await mkdir(staging, { recursive: true }); + + // A handful of representative GSC files + await writeFile( + join(shared, 'hud.gsc'), + '#include maps\\_utility;\n\ninit() { println("hud v1"); }\n' + ); + await writeFile( + join(shared, 'zom.gsc'), + '#include maps\\_zombiemode;\n\ninit() { println("zom v1"); }\n' + ); + await writeFile( + join(shared, 'perks.gsc'), + '#include maps\\_zombiemode_perks;\n\ninit() { println("perks v1"); }\n' + ); + + // Three servers, one already up-to-date, one empty, one with old copies + const rel = 'runtime/plutonium/storage/t5/raw/scripts/sp'; + for (const name of ['t5_kino_1', 't5_ascension_1', 't5_cotd_1']) { + const dest = join(t5, name, rel); + await mkdir(dest, { recursive: true }); + } + // t5_kino_1 is already up-to-date + await writeFile( + join(t5, 't5_kino_1', rel, 'hud.gsc'), + '#include maps\\_utility;\n\ninit() { println("hud v1"); }\n' + ); + // t5_cotd_1 has an older version of zom + await writeFile( + join(t5, 't5_cotd_1', rel, 'zom.gsc'), + '#include maps\\_zombiemode;\n\ninit() { println("zom v0"); }\n' + ); + + console.log(`seeded ${root}`); + console.log(` set SHARED_SCRIPTS=${shared}`); + console.log(` set SERVERS_ROOT=${t5}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..9213ba0 --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,5 @@ +declare global { + namespace App {} +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..17653cb --- /dev/null +++ b/src/app.html @@ -0,0 +1,14 @@ + + + + + + + + Electric Cherry + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..59e4371 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,19 @@ +import type { Handle } from '@sveltejs/kit'; +import { migrate } from '$lib/server/db/migrate.js'; +import { startWorker } from '$lib/server/pipeline/worker.js'; + +let booted = false; +function boot(): void { + if (booted) return; + booted = true; + migrate(); + startWorker(); +} + +// SvelteKit calls handle() for every request; using the first request as the +// boot trigger keeps the whole app single-process without needing an +// out-of-band init hook. +export const handle: Handle = async ({ event, resolve }) => { + boot(); + return resolve(event); +}; diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts new file mode 100644 index 0000000..687ec04 --- /dev/null +++ b/src/lib/server/config.ts @@ -0,0 +1,44 @@ +import { env } from 'node:process'; + +function req(name: string, fallback?: string): string { + const v = env[name] ?? fallback; + if (v === undefined) throw new Error(`missing env: ${name}`); + return v; +} + +function num(name: string, fallback: number): number { + const raw = env[name]; + if (!raw) return fallback; + const n = Number(raw); + if (!Number.isFinite(n)) throw new Error(`env ${name}=${raw} is not a number`); + return n; +} + +export const config = { + sharedScripts: req('SHARED_SCRIPTS', '/root/zombies/shared/scripts'), + serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'), + relDest: req('REL_DEST', 'runtime/plutonium/storage/t5/raw/scripts/sp'), + dbPath: req('DB_PATH', './data/cherry.db'), + deployKeepDays: num('DEPLOY_KEEP_DAYS', 7), + httpTimeoutMs: num('HTTP_TIMEOUT_MS', 10_000), + komodo: { + address: env.KOMODO_ADDRESS ?? '', + apiKey: env.KOMODO_API_KEY ?? '', + apiSecret: env.KOMODO_API_SECRET ?? '' + }, + iw4m: { + address: env.IW4M_ADDRESS ?? '', + clientId: env.IW4M_CLIENT_ID ?? '' + } +} as const; + +export function komodoConfigured(): boolean { + return !!(config.komodo.address && config.komodo.apiKey && config.komodo.apiSecret); +} + +export function iw4mConfigured(): boolean { + return !!(config.iw4m.address && config.iw4m.clientId); +} + +export const betaSrc = () => `${config.sharedScripts}/betas`; +export const destOf = (server: string) => `${config.serversRoot}/${server}/${config.relDest}`; diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts new file mode 100644 index 0000000..752bddc --- /dev/null +++ b/src/lib/server/db/index.ts @@ -0,0 +1,26 @@ +import Database from 'better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { config } from '../config.js'; +import schemaSql from './schema.sql?raw'; + +let _db: Database.Database | null = null; + +export function db(): Database.Database { + if (_db) return _db; + + mkdirSync(dirname(resolve(config.dbPath)), { recursive: true }); + _db = new Database(config.dbPath); + _db.pragma('journal_mode = WAL'); + _db.pragma('foreign_keys = ON'); + _db.exec(schemaSql); + + return _db; +} + +export function closeDb(): void { + if (_db) { + _db.close(); + _db = null; + } +} diff --git a/src/lib/server/db/migrate.ts b/src/lib/server/db/migrate.ts new file mode 100644 index 0000000..dba739f --- /dev/null +++ b/src/lib/server/db/migrate.ts @@ -0,0 +1,9 @@ +import { db } from './index.js'; + +// The heavy lifting is idempotent DDL inside schema.sql, which db() runs on +// first handle. This module exists as an explicit hook for future migrations +// (numbered .sql files applied via a schema_version row) and to be called +// from hooks.server.ts so boot-time failures surface early. +export function migrate(): void { + db().prepare('SELECT 1').get(); +} diff --git a/src/lib/server/db/schema-sql.d.ts b/src/lib/server/db/schema-sql.d.ts new file mode 100644 index 0000000..203afd5 --- /dev/null +++ b/src/lib/server/db/schema-sql.d.ts @@ -0,0 +1,4 @@ +declare module '*.sql?raw' { + const content: string; + export default content; +} diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql new file mode 100644 index 0000000..a3b05cb --- /dev/null +++ b/src/lib/server/db/schema.sql @@ -0,0 +1,93 @@ +-- Electric Cherry schema. Idempotent — safe to run at every boot via migrate.ts. +-- Mirrors the shapes tracked in the legacy script_metadata.json but in real SQL. + +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS servers ( + name TEXT PRIMARY KEY, + komodo_type TEXT NOT NULL DEFAULT 'deployment', -- deployment|stack|container + komodo_name TEXT NOT NULL, + komodo_server TEXT, + iw4m_match TEXT, -- port|id|name + iw4m_value TEXT, + last_players INTEGER, + last_checked_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +CREATE TABLE IF NOT EXISTS betas ( + slug TEXT PRIMARY KEY, + name TEXT NOT NULL, + folder TEXT NOT NULL, + opened_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS beta_scripts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + beta_slug TEXT NOT NULL REFERENCES betas(slug) ON DELETE CASCADE, + staged_as TEXT NOT NULL, + target TEXT NOT NULL, + beta_file TEXT NOT NULL, + backup TEXT NOT NULL, + sha256 TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1 +); + +CREATE INDEX IF NOT EXISTS idx_beta_scripts_slug ON beta_scripts(beta_slug); + +CREATE TABLE IF NOT EXISTS beta_deploys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + beta_script_id INTEGER NOT NULL REFERENCES beta_scripts(id) ON DELETE CASCADE, + server TEXT NOT NULL REFERENCES servers(name), + had_original INTEGER NOT NULL, + UNIQUE(beta_script_id, server) +); + +CREATE INDEX IF NOT EXISTS idx_beta_deploys_server ON beta_deploys(server); + +CREATE TABLE IF NOT EXISTS deployments ( + id TEXT PRIMARY KEY, + action TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + triggered_by TEXT +); + +CREATE INDEX IF NOT EXISTS idx_deployments_started ON deployments(started_at DESC); + +CREATE TABLE IF NOT EXISTS deployment_servers ( + deployment_id TEXT NOT NULL REFERENCES deployments(id) ON DELETE CASCADE, + server TEXT NOT NULL REFERENCES servers(name), + files_changed INTEGER NOT NULL, + restart_state TEXT NOT NULL, -- pending | done | failed | manual | held + players INTEGER, + checked_at TEXT, + restarted_at TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + error TEXT, + PRIMARY KEY (deployment_id, server) +); + +CREATE INDEX IF NOT EXISTS idx_deployment_servers_state ON deployment_servers(restart_state); + +CREATE TABLE IF NOT EXISTS deployment_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + deployment_id TEXT NOT NULL REFERENCES deployments(id) ON DELETE CASCADE, + server TEXT NOT NULL, + verb TEXT NOT NULL, -- added | updated | removed | held + rel_path TEXT NOT NULL, + sha256_before TEXT, + sha256_after TEXT, + content_before BLOB, + content_after BLOB +); + +CREATE INDEX IF NOT EXISTS idx_deployment_files_dep ON deployment_files(deployment_id); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); diff --git a/src/lib/server/fleet/beta.ts b/src/lib/server/fleet/beta.ts new file mode 100644 index 0000000..905c326 --- /dev/null +++ b/src/lib/server/fleet/beta.ts @@ -0,0 +1,455 @@ +import { copyFile, mkdir, readdir, readFile, rename, rm, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { dirname, join, posix } from 'node:path'; +import { betaSrc, config, destOf } from '../config.js'; +import { db } from '../db/index.js'; +import { + betaFile, + findTargets, + listSourceFiles, + oldFile, + sha256, + slugify, + stagedFiles +} from './files.js'; +import { allServers, ensureDest, matchServers, ownerOf } from './servers.js'; +import { doPush } from './push.js'; +import { emit } from '../pipeline/bus.js'; + +// -- shared row shapes -- + +interface BetaRow { + slug: string; + name: string; + folder: string; + opened_at: string; + updated_at: string; + revision: number; +} + +interface BetaScriptRow { + id: number; + beta_slug: string; + staged_as: string; + target: string; + beta_file: string; + backup: string; + sha256: string; + revision: number; +} + +interface BetaDeployRow { + beta_script_id: number; + server: string; + had_original: number; + target: string; + beta_file: string; + backup: string; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function getBeta(slug: string): BetaRow | undefined { + return db().prepare('SELECT * FROM betas WHERE slug = ?').get(slug) as BetaRow | undefined; +} + +export function listBetas(): BetaRow[] { + return db().prepare('SELECT * FROM betas ORDER BY opened_at').all() as BetaRow[]; +} + +export function listBetaScripts(slug: string): BetaScriptRow[] { + return db() + .prepare('SELECT * FROM beta_scripts WHERE beta_slug = ? ORDER BY id') + .all(slug) as BetaScriptRow[]; +} + +export function listBetaDeploys(slug: string): BetaDeployRow[] { + return db() + .prepare( + `SELECT bd.beta_script_id, bd.server, bd.had_original, + bs.target, bs.beta_file, bs.backup + FROM beta_deploys bd + JOIN beta_scripts bs ON bs.id = bd.beta_script_id + WHERE bs.beta_slug = ?` + ) + .all(slug) as BetaDeployRow[]; +} + +export function listBetaServers(slug: string): string[] { + return ( + db() + .prepare( + `SELECT DISTINCT bd.server AS server + FROM beta_deploys bd + JOIN beta_scripts bs ON bs.id = bd.beta_script_id + WHERE bs.beta_slug = ? + ORDER BY bd.server` + ) + .all(slug) as Array<{ server: string }> + ).map((r) => r.server); +} + +// -- open a beta (do_beta, push_scripts.sh:983) -- + +export interface OpenBetaOptions { + name: string; + patterns?: string[]; + dryRun?: boolean; + create?: boolean; +} + +export interface OpenBetaResult { + slug: string; + name: string; + servers: string[]; + scripts: Array<{ stagedAs: string; target: string }>; + dryRun: boolean; +} + +export class BetaError extends Error { + constructor( + message: string, + public readonly kind: + | 'name-empty' + | 'already-open' + | 'stale-beta-dir' + | 'no-staging-dir' + | 'nothing-staged' + | 'ambiguous' + | 'no-servers' + | 'conflict' + | 'blocked-backup' + | 'empty-plan' + | 'missing-staged' + | 'no-such-beta' + | 'no-match', + public readonly details?: unknown + ) { + super(message); + } +} + +export async function openBeta(opts: OpenBetaOptions): Promise { + const { name, patterns = ['*'], dryRun = false, create = false } = opts; + const slug = slugify(name); + if (!slug) throw new BetaError('beta name needs at least one letter or number', 'name-empty'); + + const betaDir = `${betaSrc()}/${slug}`; + + if (getBeta(slug)) throw new BetaError(`a beta called '${name}' is already open`, 'already-open'); + if (existsSync(betaDir)) { + throw new BetaError( + `${betaDir} already exists but no beta is open for it — move or delete it first`, + 'stale-beta-dir' + ); + } + if (!existsSync(betaSrc())) { + throw new BetaError( + `no staging folder at ${betaSrc()} — create it and drop the scripts you want to test`, + 'no-staging-dir' + ); + } + + const staged = await stagedFiles(); + if (staged.length === 0) { + throw new BetaError(`nothing staged in ${betaSrc()}`, 'nothing-staged'); + } + + const targets: Array<{ base: string; target: string }> = []; + const ambiguous: Array<{ base: string; hits: string[] }> = []; + for (const base of staged) { + const hits = await findTargets(base); + if (hits.length === 0) targets.push({ base, target: base }); + else if (hits.length === 1) targets.push({ base, target: hits[0] }); + else ambiguous.push({ base, hits }); + } + if (ambiguous.length > 0) { + throw new BetaError('more than one script matches — cannot tell which to replace', 'ambiguous', ambiguous); + } + + const servers = matchServers(await allServers(), patterns); + if (servers.length === 0) throw new BetaError('no servers matched', 'no-servers'); + + // build the plan (server × target) while checking conflicts and stale backups + const plan: Array<{ server: string; base: string; target: string; had: boolean }> = []; + const conflicts: Array<{ server: string; target: string; owner: string }> = []; + const blockedBackups: Array<{ server: string; ofile: string }> = []; + + for (const server of servers) { + const dest = await ensureDest(server, create); + if (!dest.ok) continue; + for (const { base, target } of targets) { + const owner = ownerOf(server, target); + if (owner) { + conflicts.push({ server, target, owner }); + continue; + } + const had = existsSync(join(dest.path, target)); + const ofile = oldFile(target); + if (had && existsSync(join(dest.path, ofile))) { + blockedBackups.push({ server, ofile }); + continue; + } + plan.push({ server, base, target, had }); + } + } + + if (conflicts.length > 0) + throw new BetaError('script already belongs to another beta', 'conflict', conflicts); + if (blockedBackups.length > 0) + throw new BetaError('stale .OLD backups in the way', 'blocked-backup', blockedBackups); + if (plan.length === 0) throw new BetaError('no usable servers — nothing was changed', 'empty-plan'); + + if (dryRun) { + return { + slug, + name, + servers: [...new Set(plan.map((p) => p.server))], + scripts: targets.map((t) => ({ stagedAs: t.base, target: t.target })), + dryRun: true + }; + } + + // ---- commit ---- + + const opened = nowIso(); + const insertBeta = db().prepare( + `INSERT INTO betas (slug, name, folder, opened_at, updated_at, revision) + VALUES (?, ?, ?, ?, ?, 1)` + ); + const insertScript = db().prepare( + `INSERT INTO beta_scripts (beta_slug, staged_as, target, beta_file, backup, sha256, revision) + VALUES (?, ?, ?, ?, ?, ?, 1)` + ); + const insertDeploy = db().prepare( + `INSERT INTO beta_deploys (beta_script_id, server, had_original) VALUES (?, ?, ?)` + ); + + const scriptIdByTarget = new Map(); + const tx = db().transaction(() => { + insertBeta.run(slug, name, `betas/${slug}`, opened, opened); + for (const { base, target } of targets) { + const bfile = betaFile(target); + const backup = oldFile(target); + const sha = 'pending'; // filled after we compute below + const res = insertScript.run(slug, base, target, bfile, backup, sha); + scriptIdByTarget.set(target, Number(res.lastInsertRowid)); + } + for (const { server, target, had } of plan) { + insertDeploy.run(scriptIdByTarget.get(target)!, server, had ? 1 : 0); + } + }); + tx(); + + // Compute shas outside tx (I/O), then patch + for (const { base, target } of targets) { + const sha = await sha256(`${betaSrc()}/${base}`); + db().prepare('UPDATE beta_scripts SET sha256 = ? WHERE beta_slug = ? AND target = ?') + .run(sha, slug, target); + } + + // Move files on disk + for (const { server, base, target, had } of plan) { + const dest = destOf(server); + const bfile = betaFile(target); + const ofile = oldFile(target); + await mkdir(join(dest, dirname(target)), { recursive: true }); + if (had) { + await rename(join(dest, target), join(dest, ofile)); + } + await copyFile(join(betaSrc(), base), join(dest, bfile)); + } + + // Move staged files into betas// under their beta_file name + await mkdir(betaDir, { recursive: true }); + for (const { base, target } of targets) { + const bfile = betaFile(target); + await rename(join(betaSrc(), base), join(betaDir, posix.basename(bfile))); + } + + emit({ + type: 'deployment-opened', + deploymentId: `beta:${slug}`, + action: `beta:${slug}`, + servers: [...new Set(plan.map((p) => p.server))] + }); + + return { + slug, + name, + servers: [...new Set(plan.map((p) => p.server))], + scripts: targets.map((t) => ({ stagedAs: t.base, target: t.target })), + dryRun: false + }; +} + +// -- update an open beta (do_update_beta, push_scripts.sh:1153) -- + +export interface UpdateBetaResult { + slug: string; + replaced: string[]; + skipped: string[]; + revision: number; + dryRun: boolean; +} + +export async function updateBeta(name: string, dryRun = false): Promise { + const slug = slugify(name); + const beta = getBeta(slug); + if (!beta) throw new BetaError(`no open beta called '${name}'`, 'no-such-beta'); + + const staged = await stagedFiles(); + if (staged.length === 0) throw new BetaError('nothing staged', 'nothing-staged'); + + const known = listBetaScripts(slug); + const matched: Array<{ base: string; target: string; betaFile: string }> = []; + const skipped: string[] = []; + for (const base of staged) { + const hit = known.find((k) => k.staged_as === base || posix.basename(k.target) === base); + if (hit) matched.push({ base, target: hit.target, betaFile: hit.beta_file }); + else skipped.push(base); + } + + if (matched.length === 0) throw new BetaError('none of the staged files belong to this beta', 'no-match'); + + if (dryRun) { + return { slug, replaced: matched.map((m) => m.target), skipped, revision: beta.revision, dryRun: true }; + } + + const betaDir = `${betaSrc()}/${slug}`; + const now = nowIso(); + + // For each matched target, copy staged file to every deployed server, + // move staged file into betas//, bump revision. + for (const { base, target, betaFile: bfile } of matched) { + const deploys = listBetaDeploys(slug).filter((d) => d.target === target); + for (const d of deploys) { + const dest = destOf(d.server); + if (!existsSync(dest)) continue; + await mkdir(join(dest, dirname(target)), { recursive: true }); + await copyFile(join(betaSrc(), base), join(dest, bfile)); + } + await rename(join(betaSrc(), base), join(betaDir, posix.basename(bfile))); + const sha = await sha256(join(betaDir, posix.basename(bfile))); + db() + .prepare( + `UPDATE beta_scripts + SET sha256 = ?, revision = revision + 1 + WHERE beta_slug = ? AND target = ?` + ) + .run(sha, slug, target); + } + db() + .prepare('UPDATE betas SET revision = revision + 1, updated_at = ? WHERE slug = ?') + .run(now, slug); + + const updated = getBeta(slug)!; + return { slug, replaced: matched.map((m) => m.target), skipped, revision: updated.revision, dryRun: false }; +} + +// -- rollback (do_rollback, push_scripts.sh:1253) -- + +export interface RollbackResult { + slug: string; + restored: string[]; + dropped: string[]; + warnings: string[]; + dryRun: boolean; +} + +export async function rollbackBeta(name: string, dryRun = false): Promise { + const slug = slugify(name); + if (!getBeta(slug)) throw new BetaError(`no open beta called '${name}'`, 'no-such-beta'); + + const betaDir = `${betaSrc()}/${slug}`; + const deploys = listBetaDeploys(slug); + const restored: string[] = []; + const dropped: string[] = []; + const warnings: string[] = []; + + if (dryRun) { + for (const d of deploys) { + if (d.had_original) restored.push(d.target); + else dropped.push(d.target); + } + return { slug, restored, dropped, warnings, dryRun: true }; + } + + for (const d of deploys) { + const dest = destOf(d.server); + const bpath = join(dest, d.beta_file); + const opath = join(dest, d.backup); + const tpath = join(dest, d.target); + if (existsSync(bpath)) await rm(bpath, { force: true }); + if (d.had_original) { + if (existsSync(opath)) { + await rename(opath, tpath); + restored.push(d.target); + } else { + warnings.push(`${d.server}: no ${d.backup} to restore`); + } + } else { + dropped.push(d.target); + } + } + + if (existsSync(betaDir)) await rm(betaDir, { recursive: true, force: true }); + db().prepare('DELETE FROM betas WHERE slug = ?').run(slug); // cascades to scripts/deploys + + return { slug, restored, dropped, warnings, dryRun: false }; +} + +// -- promote (do_promote, push_scripts.sh:1325) -- + +export interface PromoteResult { + slug: string; + landed: string[]; + dryRun: boolean; +} + +export async function promoteBeta(name: string, dryRun = false): Promise { + const slug = slugify(name); + const beta = getBeta(slug); + if (!beta) throw new BetaError(`no open beta called '${name}'`, 'no-such-beta'); + + const betaDir = `${betaSrc()}/${slug}`; + const scripts = listBetaScripts(slug); + + // Sanity: every beta_file must exist in the beta dir on disk + const lost: string[] = []; + for (const s of scripts) { + const p = join(betaDir, posix.basename(s.beta_file)); + if (!existsSync(p)) lost.push(posix.basename(s.beta_file)); + } + if (lost.length > 0) + throw new BetaError('scripts missing from betas dir — cannot promote', 'missing-staged', lost); + + const deploys = listBetaDeploys(slug); + + if (dryRun) { + return { slug, landed: scripts.map((s) => s.target), dryRun: true }; + } + + // Clear beta files + backups off every server + for (const d of deploys) { + const dest = destOf(d.server); + await rm(join(dest, d.beta_file), { force: true }); + if (d.had_original) await rm(join(dest, d.backup), { force: true }); + } + + // Move staged betas//*.gsc back into shared/scripts under real name + for (const s of scripts) { + await mkdir(join(config.sharedScripts, dirname(s.target)), { recursive: true }); + await rename(join(betaDir, posix.basename(s.beta_file)), join(config.sharedScripts, s.target)); + } + + if (existsSync(betaDir)) await rm(betaDir, { recursive: true, force: true }); + db().prepare('DELETE FROM betas WHERE slug = ?').run(slug); + + // Then push to every server — same behavior as the shell script's promote (line 1391) + await doPush({ patterns: ['*'], action: `promote:${slug}`, create: false }); + + return { slug, landed: scripts.map((s) => s.target), dryRun: false }; +} diff --git a/src/lib/server/fleet/files.ts b/src/lib/server/fleet/files.ts new file mode 100644 index 0000000..4d512d3 --- /dev/null +++ b/src/lib/server/fleet/files.ts @@ -0,0 +1,138 @@ +import { createHash } from 'node:crypto'; +import { readdir, readFile, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, posix, relative, sep } from 'node:path'; +import { betaSrc, config } from '../config.js'; + +// Ported from push_scripts.sh:414-464. These are the exact filter rules — +// change one and the parity check against the shell script will fail. + +const EXCLUDE_NAMES_EXACT = new Set(['.git']); +const EXCLUDE_SUFFIXES = ['.swp', '.OLD']; +const EXCLUDE_TRAILING = ['~']; + +function isExcluded(name: string): boolean { + if (EXCLUDE_NAMES_EXACT.has(name)) return true; + if (name.startsWith('.')) return false; // dotfiles allowed except .git + for (const s of EXCLUDE_SUFFIXES) if (name.endsWith(s)) return true; + for (const t of EXCLUDE_TRAILING) if (name.endsWith(t)) return true; + // *-beta.* — script line 417 excludes any file with "-beta." in its name + if (/-beta\./.test(name)) return true; + return false; +} + +function toPosix(p: string): string { + return p.split(sep).join(posix.sep); +} + +async function walk(root: string, prune: (rel: string) => boolean): Promise { + const out: string[] = []; + async function recur(dir: string): Promise { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const abs = join(dir, e.name); + const rel = toPosix(relative(root, abs)); + if (prune(rel)) continue; + if (e.isDirectory()) { + await recur(abs); + } else if (e.isFile()) { + if (isExcluded(e.name)) continue; + out.push(rel); + } + } + } + await recur(root); + return out.sort(); +} + +/** + * Every file under SHARED_SCRIPTS except the betas/ tree. + * Mirrors `list_source_files` in push_scripts.sh:414. + */ +export async function listSourceFiles(): Promise { + return walk(config.sharedScripts, (rel) => rel === 'betas' || rel.startsWith('betas/')); +} + +/** + * Every file directly in betas/ (one level deep, ignore subfolders — those + * are open betas). Mirrors `staged_files` in push_scripts.sh:420. + */ +export async function stagedFiles(): Promise { + if (!existsSync(betaSrc())) return []; + let entries; + try { + entries = await readdir(betaSrc(), { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((e) => e.isFile() && !isExcluded(e.name) && !e.name.startsWith('.')) + .map((e) => e.name) + .sort(); +} + +/** + * Find every source file whose basename matches `base`. + * Mirrors `find_targets` in push_scripts.sh:426. + */ +export async function findTargets(base: string): Promise { + const all = await listSourceFiles(); + return all.filter((rel) => posix.basename(rel) === base); +} + +/** + * `foo/bar.gsc` -> `foo/bar-beta.gsc` + * Mirrors `beta_file` in push_scripts.sh:450. + */ +export function betaFile(rel: string): string { + const dir = posix.dirname(rel); + const base = posix.basename(rel); + const dot = base.lastIndexOf('.'); + const stem = dot === -1 ? base : base.slice(0, dot); + const ext = dot === -1 ? '' : base.slice(dot); + const named = `${stem}-beta${ext}`; + return dir === '.' ? named : `${dir}/${named}`; +} + +/** + * `foo/bar.gsc` -> `foo/bar.OLD` + * Mirrors `old_file` in push_scripts.sh:458. + */ +export function oldFile(rel: string): string { + const dir = posix.dirname(rel); + const base = posix.basename(rel); + const dot = base.lastIndexOf('.'); + const stem = dot === -1 ? base : base.slice(0, dot); + const named = `${stem}.OLD`; + return dir === '.' ? named : `${dir}/${named}`; +} + +/** + * Slugify a beta name the same way the shell script does. + * Mirrors `slugify` in push_scripts.sh:399. + */ +export function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export async function sha256(path: string): Promise { + const buf = await readFile(path); + return createHash('sha256').update(buf).digest('hex'); +} + +export async function sha256OrNull(path: string): Promise { + try { + await stat(path); + } catch { + return null; + } + return sha256(path); +} diff --git a/src/lib/server/fleet/push.ts b/src/lib/server/fleet/push.ts new file mode 100644 index 0000000..ca24a7f --- /dev/null +++ b/src/lib/server/fleet/push.ts @@ -0,0 +1,239 @@ +import { copyFile, mkdir, readdir, readFile, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { config, destOf } from '../config.js'; +import { db } from '../db/index.js'; +import { listSourceFiles, sha256, sha256OrNull } from './files.js'; +import { allServers, ensureDest, heldByBeta, matchServers } from './servers.js'; +import { emit } from '../pipeline/bus.js'; + +export type FileVerb = 'added' | 'updated' | 'removed' | 'held'; + +export interface FileChange { + verb: FileVerb; + relPath: string; + sha256Before: string | null; + sha256After: string | null; + // held rows carry the beta slug in `error` for the audit log + heldBy?: string; +} + +export interface ServerPlan { + server: string; + dest: string; + changes: FileChange[]; + heldTargets: string[]; + skipped?: string; +} + +export interface PushOptions { + patterns?: string[]; // server-name globs; default ['*'] + dryRun?: boolean; + delete?: boolean; // remove dest files not in source + create?: boolean; // create sp/ if missing + triggeredBy?: 'ui' | 'sweep' | 'cron'; + action?: string; // 'push' | 'beta:' | ... +} + +export interface PushResult { + deploymentId: string | null; // null when dryRun or nothing changed + plans: ServerPlan[]; + totalServers: number; + totalChangedServers: number; + totalFiles: number; +} + +function nowIso(): string { + return new Date().toISOString().replace(/\.\d+Z$/, (m) => m.replace(/\d{3}Z$/, 'Z')); +} + +let _depSeq = 0; +function deploymentId(): string { + const now = new Date(); + const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z'); + // Seconds-precision timestamp + monotonic counter — two pushes in the + // same second (e.g. a beta open followed by its post-promote push) must + // still get distinct IDs. Same shape as `dep--` in the plan. + return `dep-${stamp}-${(++_depSeq).toString(36)}`; +} + +/** + * Compute the change set for a single server without touching the disk. + * Splitting plan-from-apply is what lets dry-run share code with commit. + */ +async function planServer( + server: string, + sourceFiles: string[], + opts: { deleteExtras: boolean; create: boolean } +): Promise { + const dest = destOf(server); + const ok = await ensureDest(server, opts.create); + if (!ok.ok) return { server, dest, changes: [], heldTargets: [], skipped: ok.reason }; + + const held = heldByBeta(server); + const heldTargets = [...held.keys()]; + const sourceSet = new Set(sourceFiles); + const changes: FileChange[] = []; + + for (const rel of sourceFiles) { + if (held.has(rel)) continue; + const src = join(config.sharedScripts, rel); + const dst = join(dest, rel); + const dstSha = await sha256OrNull(dst); + const srcSha = await sha256(src); + if (dstSha === null) { + changes.push({ verb: 'added', relPath: rel, sha256Before: null, sha256After: srcSha }); + } else if (dstSha !== srcSha) { + changes.push({ verb: 'updated', relPath: rel, sha256Before: dstSha, sha256After: srcSha }); + } + // matching sha → skip + } + + if (opts.deleteExtras) { + // walk dest and remove files that aren't in the source set (and + // aren't held-by-beta artifacts, which include -beta.gsc and .OLD) + const destFiles = await walkDest(dest); + for (const rel of destFiles) { + if (rel.endsWith('.OLD')) continue; + if (/-beta\./.test(rel)) continue; + if (sourceSet.has(rel)) continue; + if (held.has(rel)) continue; + const before = await sha256OrNull(join(dest, rel)); + changes.push({ verb: 'removed', relPath: rel, sha256Before: before, sha256After: null }); + } + } + + return { server, dest, changes, heldTargets }; +} + +async function walkDest(root: string): Promise { + const out: string[] = []; + async function recur(dir: string, prefix: string): Promise { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const rel = prefix ? `${prefix}/${e.name}` : e.name; + if (e.isDirectory()) await recur(join(dir, e.name), rel); + else if (e.isFile()) out.push(rel); + } + } + await recur(root, ''); + return out.sort(); +} + +/** + * Apply a plan: write files, capture content blobs for the audit log. + */ +async function applyPlan(plan: ServerPlan): Promise> { + const applied: Array = []; + for (const change of plan.changes) { + const dst = join(plan.dest, change.relPath); + if (change.verb === 'added' || change.verb === 'updated') { + const src = join(config.sharedScripts, change.relPath); + const contentBefore = change.verb === 'updated' ? await readFile(dst) : null; + const contentAfter = await readFile(src); + await mkdir(dirname(dst), { recursive: true }); + await copyFile(src, dst); + applied.push({ ...change, contentBefore, contentAfter }); + } else if (change.verb === 'removed') { + const contentBefore = existsSync(dst) ? await readFile(dst) : null; + await rm(dst, { force: true }); + applied.push({ ...change, contentBefore, contentAfter: null }); + } + } + return applied; +} + +/** + * The main push entry point. Ports `do_push` in push_scripts.sh:897. + * Uses node:fs (not shelling out to rsync) but matches the same + * added/updated/removed classification and held-by-beta exclusion rules. + */ +export async function doPush(opts: PushOptions = {}): Promise { + const { patterns = ['*'], dryRun = false, delete: deleteExtras = false, create = false } = opts; + + const sourceFiles = await listSourceFiles(); + const servers = matchServers(await allServers(), patterns); + + const plans: ServerPlan[] = []; + let totalServers = 0; + let totalChangedServers = 0; + let totalFiles = 0; + + for (const server of servers) { + const plan = await planServer(server, sourceFiles, { deleteExtras, create }); + if (!plan) continue; + totalServers++; + if (!plan.skipped && plan.changes.length > 0) { + totalChangedServers++; + totalFiles += plan.changes.length; + } + plans.push(plan); + } + + if (dryRun) { + return { deploymentId: null, plans, totalServers, totalChangedServers, totalFiles }; + } + + // No changes → no deployment row. Same behavior as `deploy_finish` line 806. + const changedPlans = plans.filter((p) => !p.skipped && p.changes.length > 0); + if (changedPlans.length === 0) { + return { deploymentId: null, plans, totalServers, totalChangedServers, totalFiles }; + } + + const depId = deploymentId(); + const action = opts.action ?? 'push'; + const started = nowIso(); + + db().prepare('INSERT INTO deployments (id, action, started_at, triggered_by) VALUES (?, ?, ?, ?)') + .run(depId, action, started, opts.triggeredBy ?? 'ui'); + + const insServer = db().prepare( + `INSERT INTO deployment_servers + (deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error) + VALUES (?, ?, ?, 'pending', NULL, NULL, NULL, 0, NULL)` + ); + const insFile = db().prepare( + `INSERT INTO deployment_files + (deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ); + + for (const plan of changedPlans) { + const applied = await applyPlan(plan); + const tx = db().transaction(() => { + insServer.run(depId, plan.server, plan.changes.length); + for (const a of applied) { + insFile.run( + depId, + plan.server, + a.verb, + a.relPath, + a.sha256Before, + a.sha256After, + a.contentBefore, + a.contentAfter + ); + } + for (const heldRel of plan.heldTargets) { + insFile.run(depId, plan.server, 'held', heldRel, null, null, null, null); + } + }); + tx(); + emit({ type: 'server-changed', deploymentId: depId, server: plan.server, files: plan.changes.length }); + } + + emit({ type: 'deployment-opened', deploymentId: depId, action, servers: changedPlans.map((p) => p.server) }); + + return { + deploymentId: depId, + plans, + totalServers, + totalChangedServers, + totalFiles + }; +} diff --git a/src/lib/server/fleet/servers.ts b/src/lib/server/fleet/servers.ts new file mode 100644 index 0000000..adf7a67 --- /dev/null +++ b/src/lib/server/fleet/servers.ts @@ -0,0 +1,159 @@ +import { readdir, mkdir, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { config, destOf } from '../config.js'; +import { db } from '../db/index.js'; + +/** + * All server folders under SERVERS_ROOT. Mirrors `all_servers` in + * push_scripts.sh:430. + */ +export async function allServers(): Promise { + let entries; + try { + entries = await readdir(config.serversRoot, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort(); +} + +/** + * Match a server name against a list of glob-like patterns (supports `*` and + * `?` and comma-separated lists). Ports `match_servers` in + * push_scripts.sh:438. + */ +export function matchServers(all: string[], patterns: string[]): string[] { + if (patterns.length === 0 || (patterns.length === 1 && patterns[0] === '*')) { + return [...all]; + } + const regexes = patterns + .flatMap((p) => p.split(',').map((s) => s.trim()).filter(Boolean)) + .map((p) => new RegExp('^' + p.replace(/[.+^$()|{}\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$')); + return all.filter((name) => regexes.some((r) => r.test(name))); +} + +/** + * Ensure a server has an sp/ destination. Mirrors `ensure_dest` in + * push_scripts.sh:466. Returns `{ ok: true }` when the dir exists (or was + * just created with `create: true`), or `{ ok: false, reason }` when it can't + * be used. + */ +export async function ensureDest( + server: string, + create: boolean +): Promise<{ ok: true; path: string } | { ok: false; reason: string }> { + const dest = destOf(server); + if (existsSync(dest)) { + try { + const s = await stat(dest); + if (!s.isDirectory()) return { ok: false, reason: 'dest is not a directory' }; + } catch (err) { + return { ok: false, reason: `stat failed: ${(err as Error).message}` }; + } + return { ok: true, path: dest }; + } + if (!create) return { ok: false, reason: 'no sp folder — use create=true' }; + await mkdir(dest, { recursive: true }); + return { ok: true, path: dest }; +} + +// -- server registry (was .servers map in script_metadata.json) -- + +export interface ServerRow { + name: string; + komodo_type: 'deployment' | 'stack' | 'container'; + komodo_name: string; + komodo_server: string | null; + iw4m_match: 'port' | 'id' | 'name' | null; + iw4m_value: string | null; + last_players: number | null; + last_checked_at: string | null; + created_at: string; +} + +/** + * Seed the servers table with rows for any folder under SERVERS_ROOT that + * isn't yet tracked. Uses folder-name-with-dashes as the default komodo_name, + * mirroring `server_reg_seed` in push_scripts.sh:564. + */ +export async function seedServerRegistry(): Promise { + const rows = await allServers(); + const insert = db().prepare( + 'INSERT OR IGNORE INTO servers (name, komodo_type, komodo_name) VALUES (?, ?, ?)' + ); + let n = 0; + const tx = db().transaction((names: string[]) => { + for (const name of names) { + const res = insert.run(name, 'deployment', name.replace(/_/g, '-')); + if (res.changes > 0) n++; + } + }); + tx(rows); + return n; +} + +export function getServer(name: string): ServerRow | undefined { + return db().prepare('SELECT * FROM servers WHERE name = ?').get(name) as ServerRow | undefined; +} + +export function listServers(): ServerRow[] { + return db().prepare('SELECT * FROM servers ORDER BY name').all() as ServerRow[]; +} + +export function setServerField( + name: string, + field: keyof ServerRow, + value: string | number | null +): void { + // whitelist columns — never interpolate arbitrary strings into SQL + const allowed: Array = [ + 'komodo_type', + 'komodo_name', + 'komodo_server', + 'iw4m_match', + 'iw4m_value', + 'last_players', + 'last_checked_at' + ]; + if (!allowed.includes(field)) throw new Error(`refusing to set ${field}`); + db().prepare(`UPDATE servers SET ${field} = ? WHERE name = ?`).run(value, name); +} + +/** + * Which relative-path targets are currently held by an open beta on this + * server? Returns `Map`. Mirrors `held_by_beta` in + * push_scripts.sh:378. + */ +export function heldByBeta(server: string): Map { + const rows = db() + .prepare( + `SELECT bs.target AS target, bs.beta_slug AS slug + FROM beta_deploys bd + JOIN beta_scripts bs ON bs.id = bd.beta_script_id + WHERE bd.server = ?` + ) + .all(server) as Array<{ target: string; slug: string }>; + const map = new Map(); + for (const r of rows) map.set(r.target, r.slug); + return map; +} + +/** + * Which beta (if any) owns this target on this server? + * Mirrors `owner_of` in push_scripts.sh:366. + */ +export function ownerOf(server: string, target: string): string | null { + const row = db() + .prepare( + `SELECT bs.beta_slug AS slug + FROM beta_deploys bd + JOIN beta_scripts bs ON bs.id = bd.beta_script_id + WHERE bd.server = ? AND bs.target = ? + LIMIT 1` + ) + .get(server, target) as { slug: string } | undefined; + return row?.slug ?? null; +} diff --git a/src/lib/server/integrations/auth-state.ts b/src/lib/server/integrations/auth-state.ts new file mode 100644 index 0000000..195a14d --- /dev/null +++ b/src/lib/server/integrations/auth-state.ts @@ -0,0 +1,54 @@ +import { emit } from '../pipeline/bus.js'; + +export type AuthState = + | { kind: 'ready'; token: string; cookie: string; expiresAt: number } + | { kind: 'stale'; reason: 'expired' | 'rejected' | 'unreachable' } + | { kind: 'missing' }; + +// Two minutes minus 5s slack — the !rt token really is single-use and dies +// fast. Better to preemptively mark stale than to fire a call with a dead +// session cookie mid-sweep. +export const TOKEN_TTL_MS = 115_000; +// How long before expiry the UI should re-prompt so the user has time to +// generate a new one in-game. +export const TOKEN_WARN_MS = 20_000; + +let state: AuthState = { kind: 'missing' }; + +export function getAuthState(): AuthState { + // Auto-transition from ready → stale when we cross the expiry boundary + if (state.kind === 'ready' && Date.now() >= state.expiresAt) { + state = { kind: 'stale', reason: 'expired' }; + emit({ type: 'auth-state', state }); + } + return state; +} + +export function setAuthState(next: AuthState): void { + state = next; + const payload: Parameters[0] = + next.kind === 'ready' + ? { type: 'auth-state', state: next, msRemaining: Math.max(0, next.expiresAt - Date.now()) } + : { type: 'auth-state', state: next }; + emit(payload); +} + +/** + * Web-app-side check used by the pipeline worker's tick loop. Returns true + * only when the token is ready AND has more than a couple seconds left. Any + * other state hard-freezes the pipeline. + */ +export function pipelineReady(): boolean { + const s = getAuthState(); + if (s.kind !== 'ready') return false; + return s.expiresAt - Date.now() > 3000; +} + +/** + * The UI shows a warning modal once we're within TOKEN_WARN_MS of expiry. + */ +export function tokenNearExpiry(): boolean { + const s = getAuthState(); + if (s.kind !== 'ready') return false; + return s.expiresAt - Date.now() <= TOKEN_WARN_MS; +} diff --git a/src/lib/server/integrations/iw4m.ts b/src/lib/server/integrations/iw4m.ts new file mode 100644 index 0000000..8cc7530 --- /dev/null +++ b/src/lib/server/integrations/iw4m.ts @@ -0,0 +1,176 @@ +import { config, iw4mConfigured } from '../config.js'; +import { getServer, setServerField } from '../fleet/servers.js'; +import { getAuthState, setAuthState, TOKEN_TTL_MS } from './auth-state.js'; + +// Ports iw4m_login/fetch_status/players/autolink from push_scripts.sh:497-620. +// The one big difference: the script keeps a temp cookie-jar file, this holds +// the session cookie in memory (we're a single Node process, not a shell). + +export interface StatusEntry { + id?: number; + port?: number; + name?: string; + currentPlayers?: number; +} + +let statusCache: { entries: StatusEntry[]; at: number } | null = null; +const STATUS_TTL_MS = 5_000; + +function abortableFetch(url: string, init: RequestInit = {}): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), config.httpTimeoutMs); + return fetch(url, { ...init, signal: controller.signal }).finally(() => clearTimeout(timer)); +} + +/** + * Exchange the 4-char !rt token for a session cookie. Called by the + * `/api/auth/token` route when the user pastes the modal input. + */ +export async function loginWithToken( + token: string +): Promise<{ ok: true; cookie: string; expiresAt: number } | { ok: false; reason: 'rejected' | 'unreachable' | 'unconfigured'; message: string }> { + if (!iw4mConfigured()) return { ok: false, reason: 'unconfigured', message: 'iw4m not configured' }; + + const url = `${config.iw4m.address.replace(/\/+$/, '')}/api/client/${config.iw4m.clientId}/login`; + let res: Response; + try { + res = await abortableFetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: token }) + }); + } catch (err) { + return { ok: false, reason: 'unreachable', message: (err as Error).message }; + } + + if (res.status === 401 || res.status === 403) return { ok: false, reason: 'rejected', message: 'iw4m rejected the token' }; + if (!res.ok) return { ok: false, reason: 'unreachable', message: `HTTP ${res.status}` }; + + // Grab every Set-Cookie the server handed us and re-send them intact. + // `getSetCookie` is Node 20+; on older Node use `res.headers.raw()['set-cookie']`. + let setCookies: string[] = []; + if (typeof (res.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie === 'function') { + setCookies = (res.headers as unknown as { getSetCookie: () => string[] }).getSetCookie(); + } else { + const single = res.headers.get('set-cookie'); + if (single) setCookies = [single]; + } + const cookie = setCookies + .map((c) => c.split(';')[0]) + .filter(Boolean) + .join('; '); + if (!cookie) return { ok: false, reason: 'rejected', message: 'iw4m returned no session cookie' }; + + return { ok: true, cookie, expiresAt: Date.now() + TOKEN_TTL_MS }; +} + +/** + * Fetch /api/status, using the current session cookie if we have one. Falls + * back to anonymous read — that's what the script does at line 542 — but the + * pipeline worker gates on the token being present anyway, so this is used + * mainly for the dashboard's non-blocking status refresh. + */ +export async function fetchStatus(): Promise<{ ok: true; entries: StatusEntry[] } | { ok: false; message: string }> { + if (!iw4mConfigured()) return { ok: false, message: 'iw4m not configured' }; + + if (statusCache && Date.now() - statusCache.at < STATUS_TTL_MS) { + return { ok: true, entries: statusCache.entries }; + } + + const url = `${config.iw4m.address.replace(/\/+$/, '')}/api/status`; + const s = getAuthState(); + const headers: Record = {}; + if (s.kind === 'ready') headers.cookie = s.cookie; + + let res: Response; + try { + res = await abortableFetch(url, { headers }); + } catch (err) { + return { ok: false, message: (err as Error).message }; + } + if (!res.ok) { + if ((res.status === 401 || res.status === 403) && s.kind === 'ready') { + // Cookie went sour — force a re-prompt. + setAuthState({ kind: 'stale', reason: 'rejected' }); + } + return { ok: false, message: `HTTP ${res.status}` }; + } + let json: unknown; + try { + json = await res.json(); + } catch (err) { + return { ok: false, message: `bad JSON: ${(err as Error).message}` }; + } + if (!Array.isArray(json)) return { ok: false, message: 'status was not an array' }; + + // Normalize keys — the script does the same case-insensitive dance + // (line 589) to work across IW4MAdmin versions. + const entries: StatusEntry[] = json.map((e) => { + const o = e as Record; + return { + id: (o.id ?? o.Id) as number | undefined, + port: (o.port ?? o.Port) as number | undefined, + name: (o.name ?? o.Name ?? '') as string, + currentPlayers: (o.currentPlayers ?? o.CurrentPlayers) as number | undefined + }; + }); + statusCache = { entries, at: Date.now() }; + return { ok: true, entries }; +} + +function normalizeName(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +/** + * How many players are on this server right now, or null if we can't tell. + * Ports `iw4m_players` in push_scripts.sh:579. + */ +export async function playersOn(server: string): Promise { + const row = getServer(server); + if (!row) return null; + const status = await fetchStatus(); + if (!status.ok) return null; + + let match = row.iw4m_match; + let value = row.iw4m_value; + if (!match || !value) { + const auto = autolink(server, status.entries); + if (!auto) return null; + setServerField(server, 'iw4m_match', auto.match); + setServerField(server, 'iw4m_value', auto.value); + match = auto.match; + value = auto.value; + } + + const hits = status.entries.filter((e) => { + if (match === 'port') return String(e.port ?? '') === value; + if (match === 'id') return String(e.id ?? '') === value; + return normalizeName(e.name ?? '') === normalizeName(value!); + }); + if (hits.length !== 1) return null; + const players = hits[0].currentPlayers; + return typeof players === 'number' ? players : null; +} + +/** + * First-run guess: pick the first non-generic token in the server folder + * name and look for a matching IW4M server by name. Ports `iw4m_autolink` + * in push_scripts.sh:605. + */ +function autolink(server: string, entries: StatusEntry[]): { match: 'port'; value: string } | null { + const skip = new Set(['t4', 't5', 't6', 'iw5', 'iw4']); + const parts = server + .replace(/[^a-zA-Z0-9]+/g, ' ') + .trim() + .split(' ') + .filter((p) => p && !skip.has(p.toLowerCase()) && !/^\d+$/.test(p)); + if (parts.length === 0) return null; + const token = parts[0].toLowerCase(); + + const hits = entries.filter((e) => normalizeName(e.name ?? '').includes(token)); + if (hits.length !== 1) return null; + const port = hits[0].port; + if (typeof port !== 'number') return null; + return { match: 'port', value: String(port) }; +} diff --git a/src/lib/server/integrations/komodo.ts b/src/lib/server/integrations/komodo.ts new file mode 100644 index 0000000..0431783 --- /dev/null +++ b/src/lib/server/integrations/komodo.ts @@ -0,0 +1,81 @@ +import { config, komodoConfigured } from '../config.js'; +import { getServer } from '../fleet/servers.js'; + +// Ports `komodo_restart` in push_scripts.sh:622. Three shapes depending on +// the tracked komodo_type: deployment | container | stack. + +export type KomodoRestartResult = + | { ok: true } + | { ok: false; kind: 'unconfigured' | 'no-name' | 'http-error' | 'unreachable'; message: string }; + +async function komodoCall(path: string, body: unknown): Promise { + const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), config.httpTimeoutMs); + try { + return await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Api-Key': config.komodo.apiKey, + 'X-Api-Secret': config.komodo.apiSecret + }, + body: JSON.stringify(body), + signal: controller.signal + }); + } finally { + clearTimeout(timer); + } +} + +export async function komodoRestart(serverName: string): Promise { + if (!komodoConfigured()) { + return { ok: false, kind: 'unconfigured', message: 'komodo not configured' }; + } + const row = getServer(serverName); + if (!row?.komodo_name) return { ok: false, kind: 'no-name', message: 'no komodo_name set for server' }; + + const type = row.komodo_type ?? 'deployment'; + let path: string; + let body: Record; + + switch (type) { + case 'stack': + path = 'execute/RestartStack'; + body = { stack: row.komodo_name }; + break; + case 'container': + if (!row.komodo_server) return { ok: false, kind: 'no-name', message: 'container type needs komodo_server' }; + path = 'execute/RestartContainer'; + body = { server: row.komodo_server, container: row.komodo_name }; + break; + case 'deployment': + default: + path = 'execute/RestartDeployment'; + body = { deployment: row.komodo_name }; + break; + } + + let res: Response; + try { + res = await komodoCall(path, body); + } catch (err) { + return { ok: false, kind: 'unreachable', message: (err as Error).message }; + } + if (!res.ok) { + return { ok: false, kind: 'http-error', message: `HTTP ${res.status}` }; + } + + // The script also checks the response body: if it's an object with + // .success === false, treat as failure. If .success is absent, trust the + // HTTP status (line 646). + try { + const json = await res.json(); + if (json && typeof json === 'object' && 'success' in json && json.success === false) { + return { ok: false, kind: 'http-error', message: 'komodo returned success=false' }; + } + } catch { + // Non-JSON body is fine — HTTP 2xx already indicates success. + } + return { ok: true }; +} diff --git a/src/lib/server/pipeline/bus.ts b/src/lib/server/pipeline/bus.ts new file mode 100644 index 0000000..0d98258 --- /dev/null +++ b/src/lib/server/pipeline/bus.ts @@ -0,0 +1,39 @@ +import { EventEmitter } from 'node:events'; + +// One in-process event bus, multiplexed by all three SSE endpoints. Kept +// separate from any specific subscriber so push/beta/sweep can emit without +// pulling the whole worker module in. + +export type PipelineEvent = + | { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] } + | { type: 'server-changed'; deploymentId: string; server: string; files: number } + | { + type: 'server-state'; + deploymentId: string; + server: string; + state: 'pending' | 'done' | 'failed' | 'held' | 'manual'; + players: number | null; + error: string | null; + } + | { type: 'sweep-tick'; frozen: boolean; reason?: string } + | { type: 'auth-state'; state: import('../integrations/auth-state.js').AuthState; msRemaining?: number } + | { type: 'server-status'; entries: Array<{ server: string; players: number | null; name?: string }> }; + +class Bus extends EventEmitter { + constructor() { + super(); + // Deployments and sweep can produce a lot of events during a big push. + this.setMaxListeners(64); + } +} + +const bus = new Bus(); + +export function emit(event: PipelineEvent): void { + bus.emit('event', event); +} + +export function subscribe(listener: (event: PipelineEvent) => void): () => void { + bus.on('event', listener); + return () => bus.off('event', listener); +} diff --git a/src/lib/server/pipeline/sweep.ts b/src/lib/server/pipeline/sweep.ts new file mode 100644 index 0000000..e8b2408 --- /dev/null +++ b/src/lib/server/pipeline/sweep.ts @@ -0,0 +1,163 @@ +import { config, iw4mConfigured, komodoConfigured } from '../config.js'; +import { db } from '../db/index.js'; +import { emit } from './bus.js'; +import { fetchStatus, playersOn } from '../integrations/iw4m.js'; +import { komodoRestart } from '../integrations/komodo.js'; +import { pipelineReady } from '../integrations/auth-state.js'; + +interface PendingRow { + deployment_id: string; + server: string; + attempts: number; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function integrationsOn(): boolean { + return komodoConfigured() && iw4mConfigured(); +} + +/** + * Re-check every pending/failed deployment_server row, restart the ones with + * zero players, mark the busy ones held. Same-server dedup is per-sweep, so + * one restart resolves every pending row for that server. Ports `deploy_sweep` + * in push_scripts.sh:705. + */ +export async function sweep(): Promise<{ checked: number; restarted: number; held: number; failed: number; frozen: boolean }> { + if (!pipelineReady()) { + emit({ type: 'sweep-tick', frozen: true, reason: 'iw4m token stale' }); + return { checked: 0, restarted: 0, held: 0, failed: 0, frozen: true }; + } + + if (!integrationsOn()) { + // Same behavior as line 714 — mark rows manual so they don't hang + // forever if integrations were never configured. + db() + .prepare( + `UPDATE deployment_servers + SET restart_state = 'manual', checked_at = ?, error = 'integrations not configured' + WHERE restart_state IN ('pending','failed')` + ) + .run(nowIso()); + return { checked: 0, restarted: 0, held: 0, failed: 0, frozen: false }; + } + + const pending = db() + .prepare( + `SELECT deployment_id, server, attempts + FROM deployment_servers + WHERE restart_state IN ('pending','failed')` + ) + .all() as PendingRow[]; + if (pending.length === 0) return { checked: 0, restarted: 0, held: 0, failed: 0, frozen: false }; + + // Warm the /api/status cache so per-server calls just hit memory + const status = await fetchStatus(); + if (!status.ok) { + // Same as line 742 — mark held with the iw4m error + const stmt = db().prepare( + `UPDATE deployment_servers + SET checked_at = ?, error = ?, attempts = attempts + 1 + WHERE restart_state IN ('pending','failed')` + ); + stmt.run(nowIso(), `iw4m: ${status.message}`); + return { checked: 0, restarted: 0, held: pending.length, failed: 0, frozen: false }; + } + + // Dedup across deployments: if we resolved server X once this tick, + // reuse the outcome for every other pending row on X. + type Outcome = { state: 'done' | 'failed' | 'pending'; players: number | null; error: string | null }; + const seen = new Map(); + + let checked = 0; + let restarted = 0; + let held = 0; + let failed = 0; + + const applyOutcome = db().prepare( + `UPDATE deployment_servers + SET restart_state = ?, checked_at = ?, players = ?, error = ?, attempts = attempts + 1, + restarted_at = CASE WHEN ? = 'done' THEN ? ELSE restarted_at END + WHERE deployment_id = ? AND server = ?` + ); + + for (const row of pending) { + let outcome = seen.get(row.server); + if (!outcome) { + checked++; + const players = await playersOn(row.server); + if (players === null) { + outcome = { state: 'pending', players: null, error: 'no iw4m match' }; + held++; + } else if (players > 0) { + outcome = { state: 'pending', players, error: null }; + held++; + } else { + const r = await komodoRestart(row.server); + if (r.ok) { + outcome = { state: 'done', players: 0, error: null }; + restarted++; + } else if (r.kind === 'no-name') { + outcome = { state: 'failed', players: 0, error: r.message }; + failed++; + } else { + outcome = { state: 'failed', players: 0, error: `komodo: ${r.message}` }; + failed++; + } + } + seen.set(row.server, outcome); + } + const now = nowIso(); + applyOutcome.run( + outcome.state, + now, + outcome.players, + outcome.error, + outcome.state, + now, + row.deployment_id, + row.server + ); + emit({ + type: 'server-state', + deploymentId: row.deployment_id, + server: row.server, + state: outcome.state, + players: outcome.players, + error: outcome.error + }); + } + + // Finish deployments where every server is now non-pending + db() + .prepare( + `UPDATE deployments + SET finished_at = ? + WHERE finished_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM deployment_servers + WHERE deployment_id = deployments.id + AND restart_state IN ('pending','failed') + )` + ) + .run(nowIso()); + + // Prune rows older than DEPLOY_KEEP_DAYS that have no pending/failed servers + const cutoff = new Date(Date.now() - config.deployKeepDays * 24 * 60 * 60 * 1000).toISOString(); + db() + .prepare( + `DELETE FROM deployments + WHERE started_at < ? + AND NOT EXISTS ( + SELECT 1 FROM deployment_servers + WHERE deployment_id = deployments.id + AND restart_state IN ('pending','failed') + )` + ) + .run(cutoff); + + emit({ type: 'sweep-tick', frozen: false }); + return { checked, restarted, held, failed, frozen: false }; +} diff --git a/src/lib/server/pipeline/worker.ts b/src/lib/server/pipeline/worker.ts new file mode 100644 index 0000000..ac74f3a --- /dev/null +++ b/src/lib/server/pipeline/worker.ts @@ -0,0 +1,78 @@ +import { sweep } from './sweep.js'; +import { emit } from './bus.js'; +import { fetchStatus } from '../integrations/iw4m.js'; +import { listServers, seedServerRegistry } from '../fleet/servers.js'; +import { getAuthState } from '../integrations/auth-state.js'; + +const TICK_MS = 15_000; +const STATUS_REFRESH_MS = 30_000; + +let started = false; +let tickTimer: NodeJS.Timeout | null = null; +let statusTimer: NodeJS.Timeout | null = null; + +async function tick(): Promise { + try { + await sweep(); + } catch (err) { + console.error('[worker] sweep error:', err); + } +} + +async function pushServerStatus(): Promise { + const st = await fetchStatus(); + if (!st.ok) return; + const rows = listServers(); + const byName = new Map(rows.map((r) => [r.name, r])); + // For the dashboard: emit a lightweight snapshot. Actual per-server + // resolution is handled by playersOn() during a sweep, which caches + // iw4m_match/value into the servers table. + emit({ + type: 'server-status', + entries: st.entries.map((e) => ({ + server: e.name ?? String(e.port ?? ''), + players: e.currentPlayers ?? null, + name: e.name + })) + }); + // Also fold current auth ttl into the auth channel so subscribers get a + // heartbeat and a countdown without waiting for a transition. + const auth = getAuthState(); + emit({ + type: 'auth-state', + state: auth, + msRemaining: auth.kind === 'ready' ? Math.max(0, auth.expiresAt - Date.now()) : undefined + }); + void byName; // reserved: future join between /api/status and server registry +} + +export function startWorker(): void { + if (started) return; + started = true; + + // Seed the servers table from the fs so first-boot has rows to reference. + seedServerRegistry().catch((err) => console.error('[worker] seed error:', err)); + + void tick(); + void pushServerStatus(); + tickTimer = setInterval(tick, TICK_MS); + statusTimer = setInterval(pushServerStatus, STATUS_REFRESH_MS); + + console.log('[worker] started — sweep every', TICK_MS, 'ms, status every', STATUS_REFRESH_MS, 'ms'); +} + +export function stopWorker(): void { + if (tickTimer) clearInterval(tickTimer); + if (statusTimer) clearInterval(statusTimer); + tickTimer = null; + statusTimer = null; + started = false; +} + +/** + * External trigger — called from `/api/push` and beta endpoints so a sweep + * fires immediately after a state change instead of waiting for the next tick. + */ +export async function kick(): Promise { + await tick(); +} diff --git a/src/lib/ui/components/BetaPanel.svelte b/src/lib/ui/components/BetaPanel.svelte new file mode 100644 index 0000000..3f2a7f8 --- /dev/null +++ b/src/lib/ui/components/BetaPanel.svelte @@ -0,0 +1,201 @@ + + +
+

Staged

+ {#if staged.length === 0} +

nothing staged

+ {:else} +
    + {#each staged as s (s.base)} +
  • + {s.base} + {#if s.hits.length === 0} + new + {:else if s.hits.length === 1} + replaces {s.hits[0]} + {:else} + ambiguous ({s.hits.length} hits) + {/if} +
  • + {/each} +
+ +
{ e.preventDefault(); openNewBeta(); }}> + + + +
+ {/if} + +

Open betas

+ {#if betas.length === 0} +

no open betas

+ {:else} + {#each betas as b (b.slug)} +
+
+ {b.name} + rev {b.revision} +
+
scripts {b.scripts.map((s) => s.target).join(', ')}
+
servers {b.servers.join(', ')}
+
+ + + +
+
+ {/each} + {/if} + + {#if flash} +

{flash}

+ {/if} +
+ + diff --git a/src/lib/ui/components/DiffViewer.svelte b/src/lib/ui/components/DiffViewer.svelte new file mode 100644 index 0000000..c0888b2 --- /dev/null +++ b/src/lib/ui/components/DiffViewer.svelte @@ -0,0 +1,95 @@ + + +
+
+ {verb} + {relPath} +
+
+{#each rows as r, idx (idx)}{r.kind === 'add' ? '+' : r.kind === 'del' ? '-' : ' '} {r.text}
+{/each}
+
+ + diff --git a/src/lib/ui/components/PipelineTimeline.svelte b/src/lib/ui/components/PipelineTimeline.svelte new file mode 100644 index 0000000..04693f1 --- /dev/null +++ b/src/lib/ui/components/PipelineTimeline.svelte @@ -0,0 +1,108 @@ + + +
+
+

Pipeline

+ {#if $frozen} + frozen — {$frozenReason ?? 'no reason'} + {/if} +
+ {#if rows.length === 0} +

no active deployments

+ {:else} + + {/if} +
+ + diff --git a/src/lib/ui/components/ServerCard.svelte b/src/lib/ui/components/ServerCard.svelte new file mode 100644 index 0000000..535899b --- /dev/null +++ b/src/lib/ui/components/ServerCard.svelte @@ -0,0 +1,95 @@ + + + +
+ {name} + 0}> + {players ?? '—'} + +
+
+ restart: {ago(lastRestartedAt)} +
+ {#if held.length > 0} +
+ {#each held as h (h.target)} + β {h.target} + {/each} +
+ {/if} + {#if restarting} +
+ {/if} +
+ + diff --git a/src/lib/ui/components/TokenModal.svelte b/src/lib/ui/components/TokenModal.svelte new file mode 100644 index 0000000..72ae7c9 --- /dev/null +++ b/src/lib/ui/components/TokenModal.svelte @@ -0,0 +1,161 @@ + + +{#if showModal} + +{/if} + + diff --git a/src/lib/ui/stores/auth.ts b/src/lib/ui/stores/auth.ts new file mode 100644 index 0000000..a8e7a74 --- /dev/null +++ b/src/lib/ui/stores/auth.ts @@ -0,0 +1,51 @@ +import { writable, derived } from 'svelte/store'; +import { browser } from '$app/environment'; + +export type AuthState = + | { kind: 'ready'; token: string; expiresAt: number } + | { kind: 'stale'; reason: 'expired' | 'rejected' | 'unreachable' } + | { kind: 'missing' }; + +interface AuthPayload { + type: 'auth-state'; + state: AuthState; + msRemaining?: number; +} + +export const authState = writable({ kind: 'missing' }); +export const msRemaining = writable(0); + +export const isReady = derived(authState, ($s) => $s.kind === 'ready'); +export const shouldBlock = derived(authState, ($s) => $s.kind !== 'ready'); + +let source: EventSource | null = null; + +export function startAuthStream(): void { + if (!browser || source) return; + source = new EventSource('/api/events/auth'); + source.addEventListener('auth-state', (evt) => { + try { + const payload = JSON.parse((evt as MessageEvent).data) as AuthPayload; + authState.set(payload.state); + if (payload.msRemaining !== undefined) msRemaining.set(payload.msRemaining); + } catch { + /* ignore malformed */ + } + }); + source.addEventListener('error', () => { + // Browser will auto-reconnect. If the stream stays down long enough, + // the token will expire on the server anyway and the next reconnect + // will surface the transition. + }); +} + +export async function submitToken(token: string): Promise<{ ok: true } | { ok: false; error: string }> { + const res = await fetch('/api/auth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }) + }); + const body = (await res.json()) as { ok: boolean; error?: string }; + if (!res.ok || !body.ok) return { ok: false, error: body.error ?? `HTTP ${res.status}` }; + return { ok: true }; +} diff --git a/src/lib/ui/stores/pipeline.ts b/src/lib/ui/stores/pipeline.ts new file mode 100644 index 0000000..f041f8d --- /dev/null +++ b/src/lib/ui/stores/pipeline.ts @@ -0,0 +1,60 @@ +import { writable } from 'svelte/store'; +import { browser } from '$app/environment'; + +export type ServerState = 'pending' | 'done' | 'failed' | 'held' | 'manual'; + +export interface PipelineDeployment { + id: string; + action: string; + servers: Map; + openedAt: number; +} + +export const deployments = writable>(new Map()); +export const frozen = writable(false); +export const frozenReason = writable(null); + +let source: EventSource | null = null; + +function updateDeployment(id: string, mutate: (d: PipelineDeployment) => void): void { + deployments.update((m) => { + const existing = m.get(id) ?? { id, action: '', servers: new Map(), openedAt: Date.now() }; + mutate(existing); + m.set(id, existing); + return new Map(m); + }); +} + +export function startPipelineStream(): void { + if (!browser || source) return; + source = new EventSource('/api/events/pipeline'); + + source.addEventListener('deployment-opened', (evt) => { + const data = JSON.parse((evt as MessageEvent).data) as { deploymentId: string; action: string; servers: string[] }; + updateDeployment(data.deploymentId, (d) => { + d.action = data.action; + for (const s of data.servers) { + if (!d.servers.has(s)) d.servers.set(s, { state: 'pending', players: null, error: null }); + } + }); + }); + + source.addEventListener('server-state', (evt) => { + const data = JSON.parse((evt as MessageEvent).data) as { + deploymentId: string; + server: string; + state: ServerState; + players: number | null; + error: string | null; + }; + updateDeployment(data.deploymentId, (d) => { + d.servers.set(data.server, { state: data.state, players: data.players, error: data.error }); + }); + }); + + source.addEventListener('sweep-tick', (evt) => { + const data = JSON.parse((evt as MessageEvent).data) as { frozen: boolean; reason?: string }; + frozen.set(data.frozen); + frozenReason.set(data.reason ?? null); + }); +} diff --git a/src/lib/ui/stores/servers.ts b/src/lib/ui/stores/servers.ts new file mode 100644 index 0000000..fd88c19 --- /dev/null +++ b/src/lib/ui/stores/servers.ts @@ -0,0 +1,25 @@ +import { writable } from 'svelte/store'; +import { browser } from '$app/environment'; + +export interface ServerStatus { + server: string; + players: number | null; + name?: string; +} + +export const statusByServer = writable>(new Map()); + +let source: EventSource | null = null; + +export function startServerStream(): void { + if (!browser || source) return; + source = new EventSource('/api/events/servers'); + source.addEventListener('server-status', (evt) => { + const data = JSON.parse((evt as MessageEvent).data) as { entries: ServerStatus[] }; + statusByServer.update(() => { + const m = new Map(); + for (const e of data.entries) m.set(e.server, e); + return m; + }); + }); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..bb27fdb --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,68 @@ + + +
+ +
+ {@render children()} +
+
+ + + + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..bf619f8 --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,123 @@ + + +
+
+
+

Fleet

+
+ + +
+
+ {#if flash}

{flash}

{/if} + {#if servers.length === 0} +

no servers registered yet — check SERVERS_ROOT

+ {:else} +
+ {#each servers as s (s.name)} + + {/each} +
+ {/if} +
+ + +
+ + diff --git a/src/routes/api/auth/token/+server.ts b/src/routes/api/auth/token/+server.ts new file mode 100644 index 0000000..d4b998c --- /dev/null +++ b/src/routes/api/auth/token/+server.ts @@ -0,0 +1,30 @@ +import { json } from '@sveltejs/kit'; +import { loginWithToken } from '$lib/server/integrations/iw4m.js'; +import { getAuthState, setAuthState } from '$lib/server/integrations/auth-state.js'; +import { kick } from '$lib/server/pipeline/worker.js'; + +export async function GET() { + return json(getAuthState()); +} + +export async function POST({ request }: { request: Request }) { + const body = (await request.json().catch(() => ({}))) as { token?: string }; + const token = (body.token ?? '').trim(); + if (!/^[a-zA-Z0-9]{4}$/.test(token)) { + return json({ ok: false, error: 'token must be exactly 4 alphanumeric characters' }, { status: 400 }); + } + const res = await loginWithToken(token); + if (!res.ok) { + setAuthState({ kind: 'stale', reason: res.reason === 'rejected' ? 'rejected' : 'unreachable' }); + return json({ ok: false, error: res.message }, { status: res.reason === 'rejected' ? 401 : 502 }); + } + setAuthState({ kind: 'ready', token, cookie: res.cookie, expiresAt: res.expiresAt }); + // Fresh cookie → immediately drain any queued restarts. + void kick(); + return json({ ok: true, expiresAt: res.expiresAt }); +} + +export async function DELETE() { + setAuthState({ kind: 'missing' }); + return json({ ok: true }); +} diff --git a/src/routes/api/betas/+server.ts b/src/routes/api/betas/+server.ts new file mode 100644 index 0000000..a365ece --- /dev/null +++ b/src/routes/api/betas/+server.ts @@ -0,0 +1,59 @@ +import { json } from '@sveltejs/kit'; +import { openBeta, updateBeta, listBetas, listBetaScripts, listBetaServers, BetaError } from '$lib/server/fleet/beta.js'; +import { stagedFiles, findTargets } from '$lib/server/fleet/files.js'; +import { kick } from '$lib/server/pipeline/worker.js'; + +export async function GET() { + const staged = await stagedFiles(); + const withHints = await Promise.all( + staged.map(async (base) => ({ + base, + hits: await findTargets(base) + })) + ); + const betas = listBetas().map((b) => ({ + ...b, + servers: listBetaServers(b.slug), + scripts: listBetaScripts(b.slug) + })); + return json({ staged: withHints, betas }); +} + +export async function POST({ request }: { request: Request }) { + const body = (await request.json().catch(() => ({}))) as { + name?: string; + patterns?: string[]; + dryRun?: boolean; + create?: boolean; + }; + if (!body.name) return json({ ok: false, error: 'name is required' }, { status: 400 }); + try { + const res = await openBeta({ + name: body.name, + patterns: body.patterns ?? ['*'], + dryRun: body.dryRun ?? false, + create: body.create ?? false + }); + if (!res.dryRun) void kick(); + return json({ ok: true, result: res }); + } catch (err) { + if (err instanceof BetaError) { + return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 }); + } + throw err; + } +} + +export async function PATCH({ request }: { request: Request }) { + const body = (await request.json().catch(() => ({}))) as { name?: string; dryRun?: boolean }; + if (!body.name) return json({ ok: false, error: 'name is required' }, { status: 400 }); + try { + const res = await updateBeta(body.name, body.dryRun ?? false); + return json({ ok: true, result: res }); + } catch (err) { + if (err instanceof BetaError) { + return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 }); + } + throw err; + } +} diff --git a/src/routes/api/betas/[slug]/promote/+server.ts b/src/routes/api/betas/[slug]/promote/+server.ts new file mode 100644 index 0000000..af0951a --- /dev/null +++ b/src/routes/api/betas/[slug]/promote/+server.ts @@ -0,0 +1,17 @@ +import { json } from '@sveltejs/kit'; +import { promoteBeta, BetaError } from '$lib/server/fleet/beta.js'; +import { kick } from '$lib/server/pipeline/worker.js'; + +export async function POST({ params, request }: { params: { slug: string }; request: Request }) { + const body = (await request.json().catch(() => ({}))) as { dryRun?: boolean }; + try { + const res = await promoteBeta(params.slug, body.dryRun ?? false); + if (!res.dryRun) void kick(); + return json({ ok: true, result: res }); + } catch (err) { + if (err instanceof BetaError) { + return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 }); + } + throw err; + } +} diff --git a/src/routes/api/betas/[slug]/rollback/+server.ts b/src/routes/api/betas/[slug]/rollback/+server.ts new file mode 100644 index 0000000..c92a963 --- /dev/null +++ b/src/routes/api/betas/[slug]/rollback/+server.ts @@ -0,0 +1,18 @@ +import { json } from '@sveltejs/kit'; +import { rollbackBeta, BetaError } from '$lib/server/fleet/beta.js'; +import { kick } from '$lib/server/pipeline/worker.js'; + +export async function POST({ params, request }: { params: { slug: string }; request: Request }) { + const body = (await request.json().catch(() => ({}))) as { dryRun?: boolean }; + try { + // callers pass name; slug in URL is the slugified name — either works + const res = await rollbackBeta(params.slug, body.dryRun ?? false); + if (!res.dryRun) void kick(); + return json({ ok: true, result: res }); + } catch (err) { + if (err instanceof BetaError) { + return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 }); + } + throw err; + } +} diff --git a/src/routes/api/deployments/+server.ts b/src/routes/api/deployments/+server.ts new file mode 100644 index 0000000..911dac4 --- /dev/null +++ b/src/routes/api/deployments/+server.ts @@ -0,0 +1,51 @@ +import { json } from '@sveltejs/kit'; +import { db } from '$lib/server/db/index.js'; + +export async function GET({ url }: { url: URL }) { + const activeOnly = url.searchParams.get('active') === 'true'; + + const deployments = db() + .prepare( + `SELECT id, action, started_at, finished_at, triggered_by + FROM deployments + ORDER BY started_at DESC + LIMIT 500` + ) + .all() as Array<{ + id: string; + action: string; + started_at: string; + finished_at: string | null; + triggered_by: string | null; + }>; + + const serversByDep = db() + .prepare( + `SELECT deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error + FROM deployment_servers` + ) + .all() as Array<{ + deployment_id: string; + server: string; + files_changed: number; + restart_state: string; + players: number | null; + checked_at: string | null; + restarted_at: string | null; + attempts: number; + error: string | null; + }>; + + const grouped = new Map(); + for (const s of serversByDep) { + const arr = grouped.get(s.deployment_id) ?? []; + arr.push(s); + grouped.set(s.deployment_id, arr); + } + + const rows = deployments + .map((d) => ({ ...d, servers: grouped.get(d.id) ?? [] })) + .filter((d) => (activeOnly ? d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed') : true)); + + return json({ deployments: rows }); +} diff --git a/src/routes/api/deployments/[id]/+server.ts b/src/routes/api/deployments/[id]/+server.ts new file mode 100644 index 0000000..754883d --- /dev/null +++ b/src/routes/api/deployments/[id]/+server.ts @@ -0,0 +1,28 @@ +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db/index.js'; + +export async function GET({ params }: { params: { id: string } }) { + const dep = db() + .prepare('SELECT * FROM deployments WHERE id = ?') + .get(params.id) as Record | undefined; + if (!dep) throw error(404, 'deployment not found'); + + const servers = db() + .prepare('SELECT * FROM deployment_servers WHERE deployment_id = ? ORDER BY server') + .all(params.id); + + // Don't ship blobs in the summary — the client asks for a single file when + // it opens the diff viewer. + const files = db() + .prepare( + `SELECT id, server, verb, rel_path, sha256_before, sha256_after, + LENGTH(content_before) AS size_before, + LENGTH(content_after) AS size_after + FROM deployment_files + WHERE deployment_id = ? + ORDER BY server, rel_path` + ) + .all(params.id); + + return json({ deployment: dep, servers, files }); +} diff --git a/src/routes/api/deployments/[id]/file/[fileId]/+server.ts b/src/routes/api/deployments/[id]/file/[fileId]/+server.ts new file mode 100644 index 0000000..b2e86cf --- /dev/null +++ b/src/routes/api/deployments/[id]/file/[fileId]/+server.ts @@ -0,0 +1,34 @@ +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db/index.js'; + +// Streams a single deployment_files row's before/after blobs as UTF-8 text +// so the DiffViewer can render a unified diff without a second query. + +export async function GET({ params }: { params: { id: string; fileId: string } }) { + const row = db() + .prepare( + `SELECT verb, rel_path, sha256_before, sha256_after, content_before, content_after + FROM deployment_files + WHERE deployment_id = ? AND id = ?` + ) + .get(params.id, Number(params.fileId)) as + | { + verb: string; + rel_path: string; + sha256_before: string | null; + sha256_after: string | null; + content_before: Buffer | null; + content_after: Buffer | null; + } + | undefined; + if (!row) throw error(404, 'file not found'); + + return json({ + verb: row.verb, + relPath: row.rel_path, + sha256Before: row.sha256_before, + sha256After: row.sha256_after, + before: row.content_before ? row.content_before.toString('utf8') : null, + after: row.content_after ? row.content_after.toString('utf8') : null + }); +} diff --git a/src/routes/api/events/auth/+server.ts b/src/routes/api/events/auth/+server.ts new file mode 100644 index 0000000..d4387f4 --- /dev/null +++ b/src/routes/api/events/auth/+server.ts @@ -0,0 +1,47 @@ +import { subscribe } from '$lib/server/pipeline/bus.js'; +import { getAuthState } from '$lib/server/integrations/auth-state.js'; + +const encoder = new TextEncoder(); +const HEARTBEAT_MS = 5_000; + +export async function GET() { + let heartbeat: NodeJS.Timeout | null = null; + let unsubscribe: (() => void) | null = null; + + const stream = new ReadableStream({ + start(controller) { + const push = () => { + const s = getAuthState(); + const payload = { + type: 'auth-state', + state: s, + msRemaining: s.kind === 'ready' ? Math.max(0, s.expiresAt - Date.now()) : undefined + }; + controller.enqueue(encoder.encode(`event: auth-state\ndata: ${JSON.stringify(payload)}\n\n`)); + }; + + unsubscribe = subscribe((event) => { + if (event.type === 'auth-state') { + controller.enqueue(encoder.encode(`event: auth-state\ndata: ${JSON.stringify(event)}\n\n`)); + } + }); + push(); + // The auth stream double-serves as the token-countdown feed; the + // frontend uses the msRemaining to render a per-second ticker, + // so we push every 5s even without a state change. + heartbeat = setInterval(push, HEARTBEAT_MS); + }, + cancel() { + if (heartbeat) clearInterval(heartbeat); + if (unsubscribe) unsubscribe(); + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + } + }); +} diff --git a/src/routes/api/events/pipeline/+server.ts b/src/routes/api/events/pipeline/+server.ts new file mode 100644 index 0000000..6d7e2ad --- /dev/null +++ b/src/routes/api/events/pipeline/+server.ts @@ -0,0 +1,45 @@ +import { subscribe, type PipelineEvent } from '$lib/server/pipeline/bus.js'; + +// Multiplexed SSE. Every event with a type field goes out. The client filters +// by type (server-state | deployment-opened | sweep-tick | server-changed). + +const encoder = new TextEncoder(); +const HEARTBEAT_MS = 20_000; + +function send(controller: ReadableStreamDefaultController, event: PipelineEvent): void { + controller.enqueue(encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)); +} + +export async function GET() { + let heartbeat: NodeJS.Timeout | null = null; + let unsubscribe: (() => void) | null = null; + + const stream = new ReadableStream({ + start(controller) { + unsubscribe = subscribe((event) => { + if ( + event.type === 'server-state' || + event.type === 'deployment-opened' || + event.type === 'server-changed' || + event.type === 'sweep-tick' + ) { + send(controller, event); + } + }); + controller.enqueue(encoder.encode(': hello\n\n')); + heartbeat = setInterval(() => controller.enqueue(encoder.encode(': ping\n\n')), HEARTBEAT_MS); + }, + cancel() { + if (heartbeat) clearInterval(heartbeat); + if (unsubscribe) unsubscribe(); + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + } + }); +} diff --git a/src/routes/api/events/servers/+server.ts b/src/routes/api/events/servers/+server.ts new file mode 100644 index 0000000..a673cd6 --- /dev/null +++ b/src/routes/api/events/servers/+server.ts @@ -0,0 +1,33 @@ +import { subscribe } from '$lib/server/pipeline/bus.js'; + +const encoder = new TextEncoder(); +const HEARTBEAT_MS = 20_000; + +export async function GET() { + let heartbeat: NodeJS.Timeout | null = null; + let unsubscribe: (() => void) | null = null; + + const stream = new ReadableStream({ + start(controller) { + unsubscribe = subscribe((event) => { + if (event.type === 'server-status') { + controller.enqueue(encoder.encode(`event: server-status\ndata: ${JSON.stringify(event)}\n\n`)); + } + }); + controller.enqueue(encoder.encode(': hello\n\n')); + heartbeat = setInterval(() => controller.enqueue(encoder.encode(': ping\n\n')), HEARTBEAT_MS); + }, + cancel() { + if (heartbeat) clearInterval(heartbeat); + if (unsubscribe) unsubscribe(); + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + } + }); +} diff --git a/src/routes/api/push/+server.ts b/src/routes/api/push/+server.ts new file mode 100644 index 0000000..cc36792 --- /dev/null +++ b/src/routes/api/push/+server.ts @@ -0,0 +1,21 @@ +import { json } from '@sveltejs/kit'; +import { doPush } from '$lib/server/fleet/push.js'; +import { kick } from '$lib/server/pipeline/worker.js'; + +export async function POST({ request }: { request: Request }) { + const body = (await request.json().catch(() => ({}))) as { + patterns?: string[]; + dryRun?: boolean; + delete?: boolean; + create?: boolean; + }; + const result = await doPush({ + patterns: body.patterns ?? ['*'], + dryRun: body.dryRun ?? false, + delete: body.delete ?? false, + create: body.create ?? false, + triggeredBy: 'ui' + }); + if (result.deploymentId) void kick(); + return json(result); +} diff --git a/src/routes/api/servers/+server.ts b/src/routes/api/servers/+server.ts new file mode 100644 index 0000000..7ff464c --- /dev/null +++ b/src/routes/api/servers/+server.ts @@ -0,0 +1,10 @@ +import { json } from '@sveltejs/kit'; +import { listServers, heldByBeta } from '$lib/server/fleet/servers.js'; + +export async function GET() { + const servers = listServers().map((s) => ({ + ...s, + held: [...heldByBeta(s.name).entries()].map(([target, slug]) => ({ target, slug })) + })); + return json({ servers }); +} diff --git a/src/routes/betas/+page.svelte b/src/routes/betas/+page.svelte new file mode 100644 index 0000000..64a3678 --- /dev/null +++ b/src/routes/betas/+page.svelte @@ -0,0 +1,32 @@ + + +

Betas

+ +{#if loading} +

loading…

+{:else} + +{/if} + + diff --git a/src/routes/deployments/+page.svelte b/src/routes/deployments/+page.svelte new file mode 100644 index 0000000..f260397 --- /dev/null +++ b/src/routes/deployments/+page.svelte @@ -0,0 +1,134 @@ + + +

Deployments

+ +{#if loading} +

loading…

+{:else} +
+

Active

+ {#if active.length === 0} +

nothing active

+ {:else} + {#each active as d (d.id)} + {@render deployRow(d)} + {/each} + {/if} +
+ +
+

History (7 days)

+ {#if history.length === 0} +

no completed deployments in the retention window

+ {:else} + {#each history as d (d.id)} + {@render deployRow(d)} + {/each} + {/if} +
+{/if} + +{#snippet deployRow(d: Deployment)} + +
+ {d.action} + {ago(d.started_at)} +
+
+ {#each d.servers as s (s.server)} + {@const c = chipColor(s.restart_state)} + + {s.server}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''} + + {/each} +
+
{d.id}
+
+{/snippet} + + diff --git a/src/routes/deployments/[id]/+page.svelte b/src/routes/deployments/[id]/+page.svelte new file mode 100644 index 0000000..7c9e8fe --- /dev/null +++ b/src/routes/deployments/[id]/+page.svelte @@ -0,0 +1,203 @@ + + +{#if detail} +

{detail.deployment.action}

+

+ started {detail.deployment.started_at} + {#if detail.deployment.finished_at} · finished {detail.deployment.finished_at}{/if} + · {detail.deployment.id} +

+ +

Servers

+
+ {#each detail.servers as s (s.server)} +
+
{s.server}
+
state: {s.restart_state}
+ {#if s.players !== null}
players: {s.players}
{/if} + {#if s.error}
{s.error}
{/if} + {#if s.restarted_at}
restarted {s.restarted_at}
{/if} +
attempts: {s.attempts}
+
+ {/each} +
+ +

Files

+ {#each [...byServer().entries()] as [srv, files] (srv)} +
+

{srv}

+ {#each files as f (f.id)} + + {/each} +
+ {/each} + + {#if openFile} +
(openFile = null)} onkeydown={(e) => { if (e.key === 'Escape') openFile = null; }} role="button" tabindex="-1"> +
e.stopPropagation()} role="none"> + + +
+
+ {/if} +{:else} +

loading…

+{/if} + + diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte new file mode 100644 index 0000000..635815e --- /dev/null +++ b/src/routes/servers/[name]/+page.svelte @@ -0,0 +1,125 @@ + + +{#if server} +

{server.name}

+
+
+
players
+
{live ?? server.last_players ?? '—'}
+
+
+
komodo
+
{server.komodo_type} · {server.komodo_name}
+
+
+
iw4m
+
{server.iw4m_match ?? '—'} · {server.iw4m_value ?? '—'}
+
+
+
held by beta
+
{server.held.length === 0 ? '—' : server.held.map((h) => h.target).join(', ')}
+
+
+ +

Deployment history

+ {#if history.length === 0} +

no deployments recorded on this server

+ {:else} + {#each history as d (d.id)} + {@const mine = d.servers.find((s) => s.server === name)!} + + {d.action} + {mine.restart_state}{mine.players !== null && mine.players > 0 ? ` (${mine.players}p)` : ''} + {mine.files_changed} file{mine.files_changed === 1 ? '' : 's'} + {d.started_at} + {#if mine.error}{mine.error}{/if} + + {/each} + {/if} +{:else} +

loading…

+{/if} + + diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..e31dff0 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,16 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ out: 'build' }), + alias: { + $ui: 'src/lib/ui', + '$ui/*': 'src/lib/ui/*' + } + } +}; + +export default config; diff --git a/tests/helpers/fixture.ts b/tests/helpers/fixture.ts new file mode 100644 index 0000000..1f8cbb4 --- /dev/null +++ b/tests/helpers/fixture.ts @@ -0,0 +1,74 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export interface Fixture { + root: string; + shared: string; + staging: string; + t5: string; + dbPath: string; + cleanup: () => Promise; +} + +/** + * Build a zombies/ tree in a temp dir and point env at it BEFORE the code + * under test is imported. Returns paths + a cleanup fn. + */ +export async function newFixture(files: Record = {}, servers: string[] = ['t5_kino_1']): Promise { + const root = await mkdtemp(join(tmpdir(), 'cherry-')); + const shared = join(root, 'shared/scripts'); + const staging = join(shared, 'betas'); + const t5 = join(root, 't5'); + const dbPath = join(root, 'cherry.db'); + await mkdir(staging, { recursive: true }); + await mkdir(t5, { recursive: true }); + + for (const [rel, body] of Object.entries(files)) { + const p = join(shared, rel); + await mkdir(join(p, '..'), { recursive: true }); + await writeFile(p, body); + } + for (const s of servers) { + await mkdir(join(t5, s, 'runtime/plutonium/storage/t5/raw/scripts/sp'), { recursive: true }); + } + + process.env.SHARED_SCRIPTS = shared; + process.env.SERVERS_ROOT = t5; + process.env.REL_DEST = 'runtime/plutonium/storage/t5/raw/scripts/sp'; + process.env.DB_PATH = dbPath; + process.env.DEPLOY_KEEP_DAYS = '7'; + process.env.HTTP_TIMEOUT_MS = '2000'; + + return { + root, + shared, + staging, + t5, + dbPath, + cleanup: async () => { + // On Windows the SQLite handle keeps a lock on cherry.db-shm/wal + // and rmdir refuses to remove the temp tree. Close the singleton + // (if this test imported it) before removing the dir. Fresh + // dynamic import so we grab whatever handle the test opened. + try { + const mod = (await import('../../src/lib/server/db/index.js')) as { + closeDb: () => void; + }; + mod.closeDb(); + } catch { + /* db module was never loaded — nothing to close */ + } + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }; +} + +/** + * Fresh module-level state. Vitest caches modules per file — this forces a + * re-import so the tests can rebuild the singleton DB handle against a new + * fixture. + */ +export async function importFresh(mod: string): Promise { + return (await import(`${mod}?t=${Date.now()}${Math.random()}`)) as T; +} diff --git a/tests/unit/beta.test.ts b/tests/unit/beta.test.ts new file mode 100644 index 0000000..fbd7e16 --- /dev/null +++ b/tests/unit/beta.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFile, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +let fixture: Fixture; + +beforeEach(async () => { + vi.resetModules(); + fixture = await newFixture( + { 'hud.gsc': 'v1', 'zom.gsc': 'v1' }, + ['t5_kino_1', 't5_ascension_1'] + ); +}); + +afterEach(async () => { + await fixture.cleanup(); +}); + +describe('beta lifecycle', () => { + it('open → rollback restores original state', async () => { + const { doPush } = await import('../../src/lib/server/fleet/push.js'); + const { openBeta, rollbackBeta } = await import('../../src/lib/server/fleet/beta.js'); + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + await seedServerRegistry(); + + // Populate servers with v1 first + await doPush({ patterns: ['*'] }); + + // Stage a new hud + await writeFile(join(fixture.staging, 'hud.gsc'), 'v2'); + + // Open beta on t5_kino_1 only + const opened = await openBeta({ name: 'test1', patterns: ['t5_kino_1'] }); + expect(opened.slug).toBe('test1'); + expect(opened.servers).toEqual(['t5_kino_1']); + + // On kino: hud-beta.gsc = v2, hud.OLD = v1, hud.gsc gone + const kinoSp = join(fixture.t5, 't5_kino_1', 'runtime/plutonium/storage/t5/raw/scripts/sp'); + expect(await readFile(join(kinoSp, 'hud-beta.gsc'), 'utf8')).toBe('v2'); + expect(await readFile(join(kinoSp, 'hud.OLD'), 'utf8')).toBe('v1'); + expect(existsSync(join(kinoSp, 'hud.gsc'))).toBe(false); + + // ascension untouched + const ascSp = join(fixture.t5, 't5_ascension_1', 'runtime/plutonium/storage/t5/raw/scripts/sp'); + expect(await readFile(join(ascSp, 'hud.gsc'), 'utf8')).toBe('v1'); + + // Rollback + const back = await rollbackBeta('test1'); + expect(back.restored).toContain('hud.gsc'); + expect(await readFile(join(kinoSp, 'hud.gsc'), 'utf8')).toBe('v1'); + expect(existsSync(join(kinoSp, 'hud-beta.gsc'))).toBe(false); + expect(existsSync(join(kinoSp, 'hud.OLD'))).toBe(false); + }); + + it('open → promote lands the beta on shared/scripts and clears beta files', async () => { + const { doPush } = await import('../../src/lib/server/fleet/push.js'); + const { openBeta, promoteBeta } = await import('../../src/lib/server/fleet/beta.js'); + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + await seedServerRegistry(); + + await doPush({ patterns: ['*'] }); + await writeFile(join(fixture.staging, 'hud.gsc'), 'v2'); + await openBeta({ name: 'promote-me', patterns: ['t5_kino_1'] }); + + const kinoSp = join(fixture.t5, 't5_kino_1', 'runtime/plutonium/storage/t5/raw/scripts/sp'); + expect(existsSync(join(kinoSp, 'hud-beta.gsc'))).toBe(true); + + await promoteBeta('promote-me'); + + // shared has v2 now + expect(await readFile(join(fixture.shared, 'hud.gsc'), 'utf8')).toBe('v2'); + // kino has plain hud.gsc = v2, no OLD, no -beta + expect(await readFile(join(kinoSp, 'hud.gsc'), 'utf8')).toBe('v2'); + expect(existsSync(join(kinoSp, 'hud-beta.gsc'))).toBe(false); + expect(existsSync(join(kinoSp, 'hud.OLD'))).toBe(false); + }); + + it('refuses to open a beta whose staged file matches an already-owned target', async () => { + const { doPush } = await import('../../src/lib/server/fleet/push.js'); + const { openBeta, BetaError } = await import('../../src/lib/server/fleet/beta.js'); + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + await seedServerRegistry(); + await doPush({ patterns: ['*'] }); + + // First beta claims hud on kino + await writeFile(join(fixture.staging, 'hud.gsc'), 'v2'); + await openBeta({ name: 'first', patterns: ['t5_kino_1'] }); + + // Second attempt on kino should conflict + await writeFile(join(fixture.staging, 'hud.gsc'), 'v3'); + await expect(openBeta({ name: 'second', patterns: ['t5_kino_1'] })).rejects.toThrow(BetaError); + }); +}); diff --git a/tests/unit/files.test.ts b/tests/unit/files.test.ts new file mode 100644 index 0000000..22809db --- /dev/null +++ b/tests/unit/files.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { betaFile, oldFile, slugify } from '../../src/lib/server/fleet/files.js'; + +describe('slugify', () => { + it('matches the shell script behavior', () => { + expect(slugify('New HUD Test')).toBe('new-hud-test'); + expect(slugify(' --Weird!!! ')).toBe('weird'); + expect(slugify('t5_kino_1')).toBe('t5-kino-1'); + }); +}); + +describe('betaFile', () => { + it('inserts -beta before the extension', () => { + expect(betaFile('hud.gsc')).toBe('hud-beta.gsc'); + expect(betaFile('perks/zom.gsc')).toBe('perks/zom-beta.gsc'); + }); + it('handles files without extension', () => { + expect(betaFile('README')).toBe('README-beta'); + }); +}); + +describe('oldFile', () => { + it('replaces extension with .OLD', () => { + expect(oldFile('hud.gsc')).toBe('hud.OLD'); + expect(oldFile('perks/zom.gsc')).toBe('perks/zom.OLD'); + }); +}); diff --git a/tests/unit/iw4m.test.ts b/tests/unit/iw4m.test.ts new file mode 100644 index 0000000..1b6fe86 --- /dev/null +++ b/tests/unit/iw4m.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +let fixture: Fixture; + +beforeEach(async () => { + vi.resetModules(); + fixture = await newFixture(); + process.env.IW4M_ADDRESS = 'http://iw4m.test'; + process.env.IW4M_CLIENT_ID = '1'; + vi.stubGlobal('fetch', vi.fn()); +}); + +afterEach(async () => { + await fixture.cleanup(); + vi.unstubAllGlobals(); +}); + +describe('loginWithToken', () => { + it('returns cookie + expiry on 2xx', async () => { + (globalThis.fetch as ReturnType).mockResolvedValueOnce( + new Response('', { + status: 200, + headers: { 'Set-Cookie': '.AspNetCore.Cookies=abc; path=/; httponly' } + }) + ); + const { loginWithToken } = await import('../../src/lib/server/integrations/iw4m.js'); + const res = await loginWithToken('abcd'); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.cookie).toContain('.AspNetCore.Cookies=abc'); + expect(res.expiresAt).toBeGreaterThan(Date.now()); + } + }); + + it('returns rejected on 401', async () => { + (globalThis.fetch as ReturnType).mockResolvedValueOnce(new Response('', { status: 401 })); + const { loginWithToken } = await import('../../src/lib/server/integrations/iw4m.js'); + const res = await loginWithToken('abcd'); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.reason).toBe('rejected'); + }); + + it('returns unreachable on network error', async () => { + (globalThis.fetch as ReturnType).mockRejectedValueOnce(new Error('ECONNREFUSED')); + const { loginWithToken } = await import('../../src/lib/server/integrations/iw4m.js'); + const res = await loginWithToken('abcd'); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.reason).toBe('unreachable'); + }); +}); + +describe('fetchStatus', () => { + it('normalizes camelCase / PascalCase entries', async () => { + (globalThis.fetch as ReturnType).mockResolvedValueOnce( + new Response( + JSON.stringify([ + { id: 1, port: 3072, name: 'kino der toten', currentPlayers: 3 }, + { Id: 2, Port: 3073, Name: 'ascension', CurrentPlayers: 0 } + ]), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + const { fetchStatus } = await import('../../src/lib/server/integrations/iw4m.js'); + const res = await fetchStatus(); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.entries[0].currentPlayers).toBe(3); + expect(res.entries[1].name).toBe('ascension'); + } + }); +}); diff --git a/tests/unit/push.test.ts b/tests/unit/push.test.ts new file mode 100644 index 0000000..62fb56d --- /dev/null +++ b/tests/unit/push.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +let fixture: Fixture; + +beforeEach(async () => { + vi.resetModules(); + fixture = await newFixture( + { + 'hud.gsc': 'v1', + 'zom.gsc': 'v1', + 'perks/perk1.gsc': 'p1' + }, + ['t5_kino_1', 't5_ascension_1'] + ); +}); + +afterEach(async () => { + await fixture.cleanup(); +}); + +describe('doPush', () => { + it('adds every source file on a fresh server', async () => { + const { doPush } = await import('../../src/lib/server/fleet/push.js'); + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + await seedServerRegistry(); + + const res = await doPush({ patterns: ['*'], dryRun: false, create: false }); + expect(res.totalChangedServers).toBe(2); + expect(res.totalFiles).toBe(3 * 2); + expect(res.deploymentId).toBeTruthy(); + + const written = await readFile(join(fixture.t5, 't5_kino_1', 'runtime/plutonium/storage/t5/raw/scripts/sp/hud.gsc'), 'utf8'); + expect(written).toBe('v1'); + }); + + it('dry-run makes no changes and creates no deployment row', async () => { + const { doPush } = await import('../../src/lib/server/fleet/push.js'); + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + await seedServerRegistry(); + + const res = await doPush({ patterns: ['*'], dryRun: true, create: false }); + expect(res.deploymentId).toBeNull(); + expect(res.totalChangedServers).toBe(2); + + // no file created + await expect(readFile(join(fixture.t5, 't5_kino_1', 'runtime/plutonium/storage/t5/raw/scripts/sp/hud.gsc'))).rejects.toThrow(); + }); + + it('skips servers that are already up to date', async () => { + const { doPush } = await import('../../src/lib/server/fleet/push.js'); + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + await seedServerRegistry(); + await doPush({ patterns: ['*'], dryRun: false }); + + // second run: everything matches → no deployment + const res = await doPush({ patterns: ['*'], dryRun: false }); + expect(res.deploymentId).toBeNull(); + expect(res.totalChangedServers).toBe(0); + }); +}); diff --git a/tests/unit/sweep.test.ts b/tests/unit/sweep.test.ts new file mode 100644 index 0000000..7a61ba0 --- /dev/null +++ b/tests/unit/sweep.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +let fixture: Fixture; + +beforeEach(async () => { + vi.resetModules(); + fixture = await newFixture({ 'hud.gsc': 'v1' }, ['t5_kino_1', 't5_ascension_1']); + // No integrations → sweep marks rows manual, exercising that branch. + delete process.env.KOMODO_ADDRESS; + delete process.env.IW4M_ADDRESS; +}); + +afterEach(async () => { + await fixture.cleanup(); +}); + +describe('sweep', () => { + it('marks all pending rows manual when integrations are unset', async () => { + const { doPush } = await import('../../src/lib/server/fleet/push.js'); + const { sweep } = await import('../../src/lib/server/pipeline/sweep.js'); + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + const { setAuthState } = await import('../../src/lib/server/integrations/auth-state.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + await seedServerRegistry(); + + // Force ready so pipelineReady() lets sweep run (even though + // integrations are off — that's the branch we want to test). + setAuthState({ kind: 'ready', token: 'abcd', cookie: 'x=y', expiresAt: Date.now() + 100_000 }); + + const push = await doPush({ patterns: ['*'] }); + expect(push.deploymentId).not.toBeNull(); + + const result = await sweep(); + expect(result.frozen).toBe(false); + + const rows = db() + .prepare("SELECT restart_state FROM deployment_servers WHERE deployment_id = ?") + .all(push.deploymentId!) as Array<{ restart_state: string }>; + for (const r of rows) expect(r.restart_state).toBe('manual'); + }); + + it('freezes when auth is not ready', async () => { + const { doPush } = await import('../../src/lib/server/fleet/push.js'); + const { sweep } = await import('../../src/lib/server/pipeline/sweep.js'); + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + await seedServerRegistry(); + + await doPush({ patterns: ['*'] }); + const result = await sweep(); + expect(result.frozen).toBe(true); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c56f861 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler", + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"] + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..9904858 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,12 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + test: { + include: ['tests/**/*.{test,spec}.{js,ts}'] + }, + server: { + port: 3000 + } +});