diff --git a/README.md b/README.md index a46c19a..b69e047 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,8 @@ Add a drive later by mounting it on the host, re-running `arrstack`, and enterin | `arrstack uninstall` | `docker compose down` only. Preserves `config/`, `state.json`, `admin.txt`, and media. Add `--purge` to also delete `/config/`; `state.json`, `admin.txt`, and media still survive | Global flags: `--verbose`, `--non-interactive`. -`install` flags: `--fresh`, `--resume`, `--install-dir `. +`install` flags: `--fresh`, `--resume`, `--yes`, `--install-dir `. +`--yes` finishes an existing install without the wizard and requires `--resume`. ## Configuration reference diff --git a/docs/guide/01-installation.md b/docs/guide/01-installation.md index 0f0f103..e6850fe 100644 --- a/docs/guide/01-installation.md +++ b/docs/guide/01-installation.md @@ -13,6 +13,9 @@ arrstack install # Resume a partially finished run arrstack install --resume + +# Finish it without the wizard (CI, remote shell, scripted recovery) +arrstack install --resume --yes ``` ## Supported systems @@ -70,6 +73,7 @@ If you prefer not to pipe curl to bash, the script URL is plain text. Read it, t arrstack install Run the TUI wizard arrstack install --fresh Wipe state and start over arrstack install --resume Continue from last saved step +arrstack install --resume --yes Finish an existing install without the wizard arrstack doctor Health check existing install arrstack update Pull latest images and restart arrstack show-password Print admin credentials diff --git a/docs/guide/09-updating.md b/docs/guide/09-updating.md index 117939e..6aed509 100644 --- a/docs/guide/09-updating.md +++ b/docs/guide/09-updating.md @@ -26,7 +26,7 @@ arrstack doctor Duration: 30 seconds to 5 minutes depending on image sizes and download speed. To update a single service, use `docker compose` directly. -`arrstack update` does not re-run the Jellyfin or Jellyseerr bootstrap. If those were never configured because an install failed early, use `arrstack install --resume`. +`arrstack update` does not re-run the Jellyfin or Jellyseerr bootstrap. If those were never configured because an install failed early, use `arrstack install --resume`, or `arrstack install --resume --yes` to finish without the wizard. ## Image tag policy diff --git a/docs/troubleshooting/common-errors.md b/docs/troubleshooting/common-errors.md index 1a7b4c4..6925833 100644 --- a/docs/troubleshooting/common-errors.md +++ b/docs/troubleshooting/common-errors.md @@ -40,6 +40,8 @@ sudo -E arrstack install --install-dir /opt/arrstack # or run elevated **Fix.** Finish the install so the wiring steps run: ```bash arrstack install --resume +# or, without the wizard: +arrstack install --resume --yes ``` On arrstack 1.2.1 and later, `arrstack update` also seeds a missing Sonarr/Radarr/Prowlarr admin user, but it does not run the Jellyfin or Jellyseerr setup. Use `--resume` when Jellyfin login is also broken. diff --git a/docs/troubleshooting/reinstall-recovery.md b/docs/troubleshooting/reinstall-recovery.md index 20bd1e1..2da091c 100644 --- a/docs/troubleshooting/reinstall-recovery.md +++ b/docs/troubleshooting/reinstall-recovery.md @@ -37,6 +37,14 @@ arrstack install --resume arrstack install --resume --install-dir /opt/arrstack ``` +For a script, remote shell, or any non-interactive run, add `--yes`. It skips +the wizard, reads `state.json` and `admin.txt`, and reruns the full pipeline +without rotating the password: + +```bash +arrstack install --resume --yes +``` + ### arrstack install --fresh Ignores any existing `state.json` and walks the wizard from scratch. Your previous config directory and media are still on disk, but the new install will generate a new `state.json` and may pick different API keys and ports. diff --git a/package.json b/package.json index 96a88c7..91bde06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "arrstack-installer", - "version": "1.2.1", + "version": "1.2.2", "type": "module", "bin": { "arrstack": "src/cli.ts" diff --git a/src/cli.ts b/src/cli.ts index e7f1ac2..02e9127 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { App } from "./ui/App.js"; import { readState, adminTxtGuardMessage } from "./state/store.js"; import { runDoctor } from "./usecase/doctor.js"; import { runUpdate } from "./usecase/update.js"; +import { runResume } from "./usecase/resume.js"; import { showPassword } from "./usecase/show-password.js"; import { runUninstall } from "./usecase/uninstall.js"; import { tailLogs } from "./usecase/logs.js"; @@ -44,6 +45,7 @@ program .description("Install the arr media stack") .option("--fresh", "perform a fresh installation") .option("--resume", "resume a previously interrupted installation") + .option("--yes", "skip the wizard and finish an existing install non-interactively (requires --resume)") .option("--install-dir ", "installation directory", `${process.env.HOME}/arrstack`) .action(async (opts) => { const installDir = opts.installDir ?? `${process.env.HOME}/arrstack`; @@ -67,6 +69,27 @@ program // --fresh purges the dir (removing admin.txt) just above, so only guard a // resume/reconfigure over an existing install. if (!opts.fresh) guardAdminTxtReadable(installDir); + + if (opts.yes) { + if (!opts.resume) { + console.error("\n--yes requires --resume. Use 'arrstack install --resume --yes' to finish an existing install.\n"); + process.exit(1); + } + try { + const result = await runResume(installDir); + console.log("\nInstall complete."); + for (const url of result.urls) { + console.log(` ${url.name}: ${url.url}`); + } + console.log(` Admin user: ${result.adminUser} (password in ${join(installDir, "admin.txt")})`); + console.log(` Verify: arrstack doctor --install-dir ${installDir}`); + } catch (err: any) { + console.error(`\nInstall failed: ${err.message ?? String(err)}`); + process.exit(1); + } + return; + } + const existing = opts.fresh ? null : readState(installDir); const { waitUntilExit } = render(React.createElement(App, { existingState: existing })); await waitUntilExit(); diff --git a/src/lib/retry.ts b/src/lib/retry.ts index 03fbae5..5811a48 100644 --- a/src/lib/retry.ts +++ b/src/lib/retry.ts @@ -10,9 +10,12 @@ export interface RetryOpts { } function defaultShouldRetry(err: unknown): boolean { - // Undici throws TypeError/AggregateError on connection refused / socket hang up. + // Undici/Bun throw TypeError/AggregateError on connection refused, socket + // hang up, and sockets closed while a request is in flight. Jellyfin's + // Startup/Configuration restarts internal services and can close the socket + // mid-request, which is transient even though it is not ECONNREFUSED. const msg = err instanceof Error ? err.message : String(err); - return /ECONN|socket hang up|fetch failed|network|UND_ERR/i.test(msg); + return /ECONN|socket hang up|socket connection was closed|connection closed unexpectedly|fetch failed|network|UND_ERR/i.test(msg); } export async function withRetry( diff --git a/src/renderer/compose.ts b/src/renderer/compose.ts index 18979b7..09b187e 100644 --- a/src/renderer/compose.ts +++ b/src/renderer/compose.ts @@ -296,6 +296,12 @@ export function buildComposeContext(services: Service[], opts: ComposeOptions): ? services.filter((svc) => isVpnRouted(svc, opts.vpn)).flatMap((svc) => svc.ports) : []; + // Catalog dependsOn edges describe the full stack, but the wizard lets users + // deselect services. A dangling edge (Sonarr -> Prowlarr when Prowlarr is + // unchecked) makes `docker compose` reject the whole project with + // "depends on undefined service". Drop edges to services not being rendered. + const enabledIds = new Set(services.map((svc) => svc.id)); + const serviceContexts: ServiceContext[] = services.map((svc) => { const vpnNetwork = isVpnRouted(svc, opts.vpn); const apiKeyEnv = svc.apiKeyEnv; @@ -320,7 +326,9 @@ export function buildComposeContext(services: Service[], opts: ComposeOptions): // before they attach to its netns, otherwise a gluetun restart mid-boot // leaves a stale namespace path and `docker compose up` aborts with the // cryptic `lstat /proc//ns/net: no such file or directory`. - const dependsOn: DependsOnEntry[] = svc.dependsOn.map((service) => ({ service })); + const dependsOn: DependsOnEntry[] = svc.dependsOn + .filter((service) => enabledIds.has(service)) + .map((service) => ({ service })); if (vpnNetwork) { dependsOn.push({ service: "gluetun", condition: "service_healthy" }); } diff --git a/src/usecase/resume.ts b/src/usecase/resume.ts new file mode 100644 index 0000000..48fb230 --- /dev/null +++ b/src/usecase/resume.ts @@ -0,0 +1,52 @@ +import { readState, readAdminCredentials } from "../state/store.js"; +import { runInstall, type InstallResult, type StepUpdate } from "./install.js"; +import { VERSION } from "../version.js"; + +export type RunInstallFn = typeof runInstall; + +export interface RunResumeOptions { + onStep?: (update: StepUpdate) => void; + // Injectable for tests. Production uses runInstall. + runInstallFn?: RunInstallFn; +} + +function printStep(update: StepUpdate): void { + if (update.status === "running") { + process.stdout.write(` ${update.step}... `); + } else if (update.status === "done") { + const seconds = ((update.durationMs ?? 0) / 1000).toFixed(1); + console.log(`ok (${seconds}s)`); + } else if (update.status === "failed") { + console.log(`FAILED\n ${update.message ?? "unknown error"}`); + } +} + +// Non-interactive `arrstack install --resume --yes`. Reads the saved state and +// the existing admin password, then reruns the full install pipeline: render, +// pull, up, health gate, and every wiring step (arr logins, Jellyfin, +// Jellyseerr, qBittorrent, etc). This is the scriptable recovery path for an +// install that aborted before wiring, and it never rotates the admin password. +export async function runResume( + installDir: string, + opts: RunResumeOptions = {}, +): Promise { + const state = readState(installDir); + if (!state) { + throw new Error(`No state.json found in ${installDir}. Run 'arrstack install' first.`); + } + + const creds = readAdminCredentials(installDir); + if (!creds) { + throw new Error( + `No readable admin.txt found in ${installDir}. ` + + `A previous install may have failed before writing it. Run 'arrstack install --fresh'.`, + ); + } + + const install = opts.runInstallFn ?? runInstall; + return install( + { ...state, install_dir: installDir, installer_version: VERSION }, + creds.password, + opts.onStep ?? printStep, + ); +} diff --git a/src/version.ts b/src/version.ts index f92f80d..498d5d1 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.2.1"; +export const VERSION = "1.2.2"; diff --git a/src/wiring/jellyfin.ts b/src/wiring/jellyfin.ts index 88b0de6..574dd06 100644 --- a/src/wiring/jellyfin.ts +++ b/src/wiring/jellyfin.ts @@ -12,15 +12,24 @@ async function readBody(res: Response): Promise { // Jellyfin's /health returns 200 before the default user record is created // internally, so the first POST /Startup/User can 500. Poll /Startup/User (GET) // until it returns 200, which is Jellyfin's signal that the startup API is ready. -async function waitForJellyfinStartupReady(base: string, timeoutMs = 60_000): Promise { +// Returns the current user record so a resumed install can detect a user that +// was created by an earlier attempt and skip the POST (Jellyfin answers 403 +// once the first user exists). +async function waitForJellyfinStartupReady( + base: string, + timeoutMs = 60_000, +): Promise<{ Name?: string } | null> { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { const r = await fetch(`${base}/Startup/User`); - if (r.ok) return; + if (r.ok) { + return (await r.json().catch(() => null)) as { Name?: string } | null; + } } catch { /* retry */ } await new Promise((r) => setTimeout(r, 1500)); } + return null; } async function isStartupComplete(base: string): Promise { @@ -46,6 +55,10 @@ async function authenticate( adminUser: string, adminPass: string, ): Promise { + // Jellyfin 10.11+ parses the standard Authorization header. 12.x disables + // the legacy X-Emby-Authorization fallback by default, and without Client / + // Device / DeviceId / Version in the header AuthenticateByName returns 400 + // ("Value cannot be null. Parameter 'request.App'"). const authHeader = 'MediaBrowser Client="arrstack", Device="installer", DeviceId="arrstack", Version="1.0.0"'; const authRes = await withRetry(() => @@ -53,6 +66,9 @@ async function authenticate( method: "POST", headers: { "Content-Type": "application/json", + Authorization: authHeader, + // Older Jellyfin builds only read the legacy header. 10.11+ prefer + // Authorization, and 12.x ignores this when legacy auth is disabled. "X-Emby-Authorization": authHeader, }, body: JSON.stringify({ Username: adminUser, Pw: adminPass }), @@ -120,23 +136,30 @@ export async function setupJellyfin( throw new Error(`Startup/Configuration failed: HTTP ${configRes.status}\n${await readBody(configRes)}`); } - await waitForJellyfinStartupReady(base); - - const userRes = await fetchWithRetry(`${base}/Startup/User`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ Name: adminUser, Password: adminPass }), - }); - if (!userRes.ok) { - throw new Error(`Startup/User failed: HTTP ${userRes.status}\n${await readBody(userRes)}`); + const startupUser = await waitForJellyfinStartupReady(base); + + // A previous attempt may have created the first user before failing later + // (or the user answered part of the startup wizard). POSTing /Startup/User + // again returns 403, so only create the user when none exists. + if (!startupUser?.Name) { + const userRes = await fetchWithRetry(`${base}/Startup/User`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ Name: adminUser, Password: adminPass }), + }); + if (!userRes.ok) { + throw new Error(`Startup/User failed: HTTP ${userRes.status}\n${await readBody(userRes)}`); + } } } // Authenticate with the (now-existing) admin user const auth = await authenticate(base, adminUser, adminPass); + const tokenHeader = `${auth.authHeader}, Token="${auth.token}"`; const authedHeaders = { "Content-Type": "application/json", - "X-Emby-Authorization": `${auth.authHeader}, Token="${auth.token}"`, + Authorization: tokenHeader, + "X-Emby-Authorization": tokenHeader, }; await ensureAdmin(base, auth, authedHeaders); diff --git a/src/wiring/jellyseerr.ts b/src/wiring/jellyseerr.ts index abf35be..aed27f8 100644 --- a/src/wiring/jellyseerr.ts +++ b/src/wiring/jellyseerr.ts @@ -29,55 +29,59 @@ export async function linkJellyseerr( ): Promise { await waitForJellyseerrReady(base); - // 1. Bootstrap: create the admin + store the Jellyfin connection. - const bootstrap = await withRetry(() => - fetch(`${base}/api/v1/auth/jellyfin`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: jellyfinUser, - password: jellyfinPass, - hostname: "jellyfin", - port: 8096, - useSsl: false, - urlBase: "", - // MediaServerType enum (PLEX=1, JELLYFIN=2, EMBY=3). Without this the - // endpoint returns HTTP 500 {"message":"NO_ADMIN_USER"}. - serverType: 2, - email: jellyfinUser, + // 1. Bootstrap: create the admin + store the Jellyfin connection. On a + // resumed install the hostname is already configured and Seerr answers 500 + // "Jellyfin hostname already configured", so retry without the hostname + // fields to log in to the existing admin instead. + const login = (withHostname: boolean) => + withRetry(() => + fetch(`${base}/api/v1/auth/jellyfin`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: jellyfinUser, + password: jellyfinPass, + email: jellyfinUser, + // MediaServerType enum (PLEX=1, JELLYFIN=2, EMBY=3). Without this the + // endpoint returns HTTP 500 {"message":"NO_ADMIN_USER"}. + serverType: 2, + ...(withHostname + ? { hostname: "jellyfin", port: 8096, useSsl: false, urlBase: "" } + : {}), + }), }), - }) - ); + ); + + const bootstrap = await login(true); + let sessionRes = bootstrap; if (!bootstrap.ok) { - throw new Error(`Jellyseerr auth failed: HTTP ${bootstrap.status}\n${await readBody(bootstrap)}`); + const existing = await login(false); + if (!existing.ok) { + throw new Error( + `Jellyseerr auth failed: HTTP ${bootstrap.status} (bootstrap), ` + + `${existing.status} (existing login)\n${await readBody(existing)}`, + ); + } + sessionRes = existing; + } else { + // The session cookie that bootstrap returns is racy: Jellyseerr creates + // the admin user, saves settings, and fires startJobs() before the + // response flushes, but subsequent GETs with that cookie intermittently + // return 403 ("You do not have permission to access this endpoint") + // because the session/user join hasn't materialised yet. Re-logging in + // with the same credentials (no hostname — Jellyfin is configured now) + // returns a cookie that's reliably bound to the admin user. + const relogin = await login(false); + if (!relogin.ok) { + throw new Error(`Jellyseerr re-login failed: HTTP ${relogin.status}\n${await readBody(relogin)}`); + } + sessionRes = relogin; } - // The session cookie that bootstrap returns is racy: Jellyseerr creates the - // admin user, saves settings, and fires startJobs() before the response - // flushes, but subsequent GETs with that cookie intermittently return 403 - // ("You do not have permission to access this endpoint") because the - // session/user join hasn't materialised yet. Re-logging in with the same - // credentials (no hostname — Jellyfin is already configured now) returns a - // cookie that's reliably bound to the admin user. - const relogin = await withRetry(() => - fetch(`${base}/api/v1/auth/jellyfin`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: jellyfinUser, - password: jellyfinPass, - email: jellyfinUser, - serverType: 2, - }), - }) - ); - if (!relogin.ok) { - throw new Error(`Jellyseerr re-login failed: HTTP ${relogin.status}\n${await readBody(relogin)}`); - } const cookie = - (relogin.headers.get("set-cookie") ?? "").match(/connect\.sid=[^;]+/)?.[0] ?? ""; + (sessionRes.headers.get("set-cookie") ?? "").match(/connect\.sid=[^;]+/)?.[0] ?? ""; if (!cookie) { - throw new Error("Jellyseerr re-login did not return a session cookie"); + throw new Error("Jellyseerr login did not return a session cookie"); } const authedHeaders = { "Content-Type": "application/json", Cookie: cookie }; @@ -94,34 +98,72 @@ export async function linkJellyseerr( await new Promise((r) => setTimeout(r, 500)); } - // 2. Sync the Jellyfin library list so Jellyseerr knows what exists. + // 2. Sync the Jellyfin library list so Jellyseerr knows what exists. Seerr + // 3.x replaced the old `?sync=true` shortcut with POST /library/sync; keep + // the legacy call as a fallback for older Jellyseerr builds. + let libs: Library[] = []; + let seerrLibraryApi = false; const syncRes = await withRetry(() => - fetch(`${base}/api/v1/settings/jellyfin/library?sync=true`, { headers: authedHeaders }), + fetch(`${base}/api/v1/settings/jellyfin/library/sync`, { + method: "POST", + headers: authedHeaders, + }), ); - if (!syncRes.ok) { + if (syncRes.ok) { + libs = (await syncRes.json()) as Library[]; + seerrLibraryApi = true; + } else if (syncRes.status === 404 || syncRes.status === 400) { + const legacyRes = await withRetry(() => + fetch(`${base}/api/v1/settings/jellyfin/library?sync=true`, { headers: authedHeaders }), + ); + if (!legacyRes.ok) { + throw new Error( + `Jellyseerr library sync failed: HTTP ${legacyRes.status}\n${await readBody(legacyRes)}`, + ); + } + libs = (await legacyRes.json()) as Library[]; + } else { throw new Error(`Jellyseerr library sync failed: HTTP ${syncRes.status}\n${await readBody(syncRes)}`); } - const libs = (await syncRes.json()) as Library[]; // 3. Enable every library (Movies, TV Shows, Music). Jellyseerr's library // flag is what the /setup wizard toggles at step 3 — without it, requests // can't be matched against media. if (libs.length > 0) { - const enableIds = libs.map((l) => l.id).join(","); - const enableRes = await withRetry(() => - fetch(`${base}/api/v1/settings/jellyfin/library?enable=${enableIds}`, { - headers: authedHeaders, - }), - ); - if (!enableRes.ok) { - throw new Error(`Jellyseerr enable libraries failed: HTTP ${enableRes.status}\n${await readBody(enableRes)}`); + if (seerrLibraryApi) { + // Seerr 3.x: PUT /library/:id { enabled: true } + for (const lib of libs) { + const enableRes = await withRetry(() => + fetch(`${base}/api/v1/settings/jellyfin/library/${encodeURIComponent(lib.id)}`, { + method: "PUT", + headers: authedHeaders, + body: JSON.stringify({ enabled: true }), + }), + ); + if (!enableRes.ok) { + throw new Error( + `Jellyseerr enable library "${lib.name}" failed: HTTP ${enableRes.status}\n${await readBody(enableRes)}`, + ); + } + } + } else { + const enableIds = libs.map((l) => l.id).join(","); + const enableRes = await withRetry(() => + fetch(`${base}/api/v1/settings/jellyfin/library?enable=${enableIds}`, { + headers: authedHeaders, + }), + ); + if (!enableRes.ok) { + throw new Error(`Jellyseerr enable libraries failed: HTTP ${enableRes.status}\n${await readBody(enableRes)}`); + } } - // Kick off the one-time full media scan in the background. + // Kick off the one-time full media scan in the background. Seerr expects + // { start: true }; older Overseerr-derived builds also accept it. await fetch(`${base}/api/v1/settings/jellyfin/sync`, { method: "POST", headers: authedHeaders, - body: "{}", + body: JSON.stringify({ start: true }), }).catch(() => { /* scan is best-effort */ }); } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 4d9d51d..d69745b 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -2,6 +2,8 @@ import { test, expect } from "bun:test"; import { spawnSync } from "bun"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = join(repoRoot, "src", "cli.ts"); @@ -27,6 +29,17 @@ test("--help includes arrstack", () => { expect(stdout).toContain("arrstack"); }); +test("install --yes without --resume fails with a clear error", () => { + const dir = mkdtempSync(join(tmpdir(), "arrstack-cli-test-")); + try { + const { stderr, exitCode } = runCli("install", "--yes", "--install-dir", dir); + expect(exitCode).toBe(1); + expect(stderr).toContain("--yes requires --resume"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("--help lists all subcommands", () => { const { stdout } = runCli("--help"); expect(stdout).toContain("install"); diff --git a/tests/lib/retry.test.ts b/tests/lib/retry.test.ts index a1d9a8c..539e365 100644 --- a/tests/lib/retry.test.ts +++ b/tests/lib/retry.test.ts @@ -20,6 +20,22 @@ test("withRetry retries on ECONNREFUSED-style errors", async () => { expect(calls).toBe(3); }); +test("withRetry retries when the socket closes mid-request (Jellyfin startup)", async () => { + let calls = 0; + const result = await withRetry( + async () => { + calls++; + if (calls < 3) { + throw new Error("The socket connection was closed unexpectedly. For more information, pass `verbose: true`"); + } + return "ok"; + }, + { baseMs: 1, attempts: 5 }, + ); + expect(result).toBe("ok"); + expect(calls).toBe(3); +}); + test("withRetry does not retry on non-transient errors", async () => { let calls = 0; await expect( diff --git a/tests/renderer/compose.test.ts b/tests/renderer/compose.test.ts index 885db14..707eda7 100644 --- a/tests/renderer/compose.test.ts +++ b/tests/renderer/compose.test.ts @@ -31,6 +31,26 @@ describe("renderCompose", () => { expect(output).toContain("8989:8989"); }); + test("drops depends_on edges to services that are not rendered", () => { + // Catalog says Sonarr dependsOn Prowlarr, but the wizard lets users deselect + // Prowlarr. A dangling edge makes `docker compose` reject the project with + // "depends on undefined service", so it must not be emitted. + const services = getServices(["sonarr"]); + const ctx = buildComposeContext(services, baseOpts); + const sonarr = ctx.services.find((s) => s.id === "sonarr")!; + expect(sonarr.dependsOn.map((d) => d.service)).not.toContain("prowlarr"); + + const output = renderCompose(services, baseOpts); + expect(output).not.toContain("- prowlarr"); + }); + + test("keeps depends_on edges when both services are rendered", () => { + const services = getServices(["sonarr", "prowlarr"]); + const ctx = buildComposeContext(services, baseOpts); + const sonarr = ctx.services.find((s) => s.id === "sonarr")!; + expect(sonarr.dependsOn.map((d) => d.service)).toContain("prowlarr"); + }); + test("logging block is present", () => { const services = getServices(["sonarr"]); const output = renderCompose(services, baseOpts); diff --git a/tests/usecase/resume.test.ts b/tests/usecase/resume.test.ts new file mode 100644 index 0000000..7047479 --- /dev/null +++ b/tests/usecase/resume.test.ts @@ -0,0 +1,86 @@ +import { test, expect, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { runResume } from "../../src/usecase/resume.js"; +import { writeState } from "../../src/state/store.js"; +import { VERSION } from "../../src/version.js"; +import type { State } from "../../src/state/schema.js"; +import type { InstallResult } from "../../src/usecase/install.js"; + +const STATE: State = { + schema_version: 1, + installer_version: "1.0.0", + install_dir: "/old/path", + storage_root: "/data", + extra_paths: [], + admin: { username: "admin" }, + services_enabled: ["sonarr"], + gpu: { vendor: "none" }, + remote_access: { mode: "none" }, + local_dns: { enabled: false, tld: "local", install_dnsmasq: false }, + vpn: { enabled: false }, + timezone: "Europe/London", + puid: 1000, + pgid: 1000, + subtitle_languages: ["en"], + api_keys: { sonarr: "key" }, + secrets: {}, +}; + +const RESULT: InstallResult = { + urls: [{ name: "Sonarr", url: "http://localhost:8989", description: "Sonarr" }], + password: "from-state", + adminUser: "admin", +}; + +let created: string[] = []; + +function makeDir(): string { + const dir = mkdtempSync(join(tmpdir(), "arrstack-resume-test-")); + created.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of created) rmSync(dir, { recursive: true, force: true }); + created = []; +}); + +test("runResume passes existing state and admin.txt password to runInstall", async () => { + const dir = makeDir(); + writeState(dir, { ...STATE, install_dir: dir }); + writeFileSync(join(dir, "admin.txt"), "username: admin\npassword: s3cret-pw\ngenerated: now\n"); + + let receivedState: State | null = null; + let receivedPassword = ""; + const result = await runResume(dir, { + onStep: () => {}, + runInstallFn: async (state, password) => { + receivedState = state; + receivedPassword = password; + return RESULT; + }, + }); + + expect(result).toEqual(RESULT); + expect(receivedPassword).toBe("s3cret-pw"); + expect(receivedState?.install_dir).toBe(dir); + expect(receivedState?.installer_version).toBe(VERSION); + expect(receivedState?.services_enabled).toEqual(["sonarr"]); +}); + +test("runResume rejects when state.json is missing", async () => { + const dir = makeDir(); + await expect(runResume(dir, { runInstallFn: async () => RESULT })).rejects.toThrow( + /No state\.json found/, + ); +}); + +test("runResume rejects when admin.txt is missing", async () => { + const dir = makeDir(); + writeState(dir, { ...STATE, install_dir: dir }); + await expect(runResume(dir, { runInstallFn: async () => RESULT })).rejects.toThrow( + /No readable admin\.txt found/, + ); +}); diff --git a/tests/wiring/jellyfin.test.ts b/tests/wiring/jellyfin.test.ts index 1d47ad8..da47bc8 100644 --- a/tests/wiring/jellyfin.test.ts +++ b/tests/wiring/jellyfin.test.ts @@ -9,3 +9,95 @@ test("setupJellyfin is exported and is async", () => { // Suppress unhandled rejection result.catch(() => {}); }); + +test("setupJellyfin skips Startup/User when a previous attempt created the user", async () => { + const origFetch = globalThis.fetch; + const calls: string[] = []; + globalThis.fetch = (async (url: any, init: any) => { + const u = String(url); + const method = init?.method ?? "GET"; + calls.push(`${method} ${u}`); + if (u.endsWith("/System/Info/Public")) { + return new Response(JSON.stringify({ StartupWizardCompleted: false }), { status: 200 }); + } + if (u.endsWith("/Startup/User") && method === "GET") { + return new Response(JSON.stringify({ Name: "admin" }), { status: 200 }); + } + if (u.endsWith("/Startup/User") && method === "POST") { + return new Response("first user exists", { status: 403 }); + } + if (u.endsWith("/Users/AuthenticateByName")) { + return new Response( + JSON.stringify({ + AccessToken: "tok-123", + User: { Id: "user-1", Policy: { IsAdministrator: true } }, + }), + { status: 200 }, + ); + } + if (u.endsWith("/Library/VirtualFolders") && method === "GET") { + return new Response("[]", { status: 200 }); + } + return new Response("{}", { status: 200 }); + }) as any; + + try { + await setupJellyfin("admin", "pw", [], "http://jellyfin.test"); + } finally { + globalThis.fetch = origFetch; + } + + expect(calls).not.toContain("POST http://jellyfin.test/Startup/User"); + expect(calls).toContain("POST http://jellyfin.test/Startup/Complete"); +}); + +// Jellyfin 12.x disables legacy X-Emby-Authorization by default. The standard +// Authorization header carries Client/Device/DeviceId/Version, which +// AuthenticateByName requires for request.App. +test("setupJellyfin uses the standard Authorization header", async () => { + const origFetch = globalThis.fetch; + const authHeaders: Record[] = []; + const libraryHeaders: Record[] = []; + globalThis.fetch = (async (url: any, init: any) => { + const u = String(url); + const headers = (init?.headers ?? {}) as Record; + if (u.endsWith("/System/Info/Public")) { + return new Response(JSON.stringify({ StartupWizardCompleted: true }), { status: 200 }); + } + if (u.endsWith("/Users/AuthenticateByName")) { + authHeaders.push(headers); + return new Response( + JSON.stringify({ + AccessToken: "tok-123", + User: { Id: "user-1", Policy: { IsAdministrator: true } }, + }), + { status: 200 }, + ); + } + if (u.includes("/Library/VirtualFolders")) { + libraryHeaders.push(headers); + return new Response("[]", { status: 200 }); + } + return new Response("{}", { status: 200 }); + }) as any; + + try { + await setupJellyfin( + "admin", + "pw", + [{ name: "Movies", type: "movies", paths: ["/data/media/movies"] }], + "http://jellyfin.test", + ); + } finally { + globalThis.fetch = origFetch; + } + + expect(authHeaders).toHaveLength(1); + expect(authHeaders[0].Authorization).toStartWith("MediaBrowser "); + expect(authHeaders[0].Authorization).toContain('Client="arrstack"'); + expect(authHeaders[0]["X-Emby-Authorization"]).toBe(authHeaders[0].Authorization); + + expect(libraryHeaders.length).toBeGreaterThan(0); + expect(libraryHeaders[0].Authorization).toContain('Token="tok-123"'); + expect(libraryHeaders[0]["X-Emby-Authorization"]).toBe(libraryHeaders[0].Authorization); +}); diff --git a/tests/wiring/jellyseerr.test.ts b/tests/wiring/jellyseerr.test.ts index c1a7a86..d7dab4c 100644 --- a/tests/wiring/jellyseerr.test.ts +++ b/tests/wiring/jellyseerr.test.ts @@ -7,3 +7,117 @@ test("linkJellyseerr is exported and is async", () => { expect(result instanceof Promise).toBe(true); result.catch(() => {}); }); + +interface Call { + method: string; + url: string; +} + +function mockSeerr( + librarySyncStatus: number, + hostnameAlreadyConfigured = false, +): { calls: Call[]; authBodies: any[]; restore: () => void } { + const origFetch = globalThis.fetch; + const calls: Call[] = []; + const authBodies: any[] = []; + globalThis.fetch = (async (url: any, init: any) => { + const u = String(url); + const method = init?.method ?? "GET"; + calls.push({ method, url: u }); + + if (u.endsWith("/api/v1/settings/public")) { + return new Response("{}", { status: 200 }); + } + if (u.endsWith("/api/v1/auth/jellyfin")) { + const body = init?.body ? JSON.parse(init.body) : {}; + authBodies.push(body); + if (hostnameAlreadyConfigured && body.hostname) { + return new Response(JSON.stringify({ error: "Jellyfin hostname already configured" }), { + status: 500, + }); + } + return new Response("{}", { + status: 200, + headers: { "set-cookie": "connect.sid=test-session; Path=/; HttpOnly" }, + }); + } + if (u.endsWith("/api/v1/settings/main")) { + return new Response("{}", { status: 200 }); + } + if (u.endsWith("/api/v1/settings/jellyfin/library/sync")) { + if (librarySyncStatus !== 200) { + return new Response("not found", { status: librarySyncStatus }); + } + return new Response( + JSON.stringify([ + { id: "movies", name: "Movies", enabled: false, type: "movies" }, + { id: "tv", name: "TV Shows", enabled: false, type: "tvshows" }, + ]), + { status: 200 }, + ); + } + if (u.includes("/api/v1/settings/jellyfin/library?sync=true")) { + return new Response( + JSON.stringify([{ id: "movies", name: "Movies", enabled: false, type: "movies" }]), + { status: 200 }, + ); + } + if (u.includes("/api/v1/settings/jellyfin/library?enable=")) { + return new Response("{}", { status: 200 }); + } + if (u.includes("/api/v1/settings/jellyfin/library/")) { + return new Response("{}", { status: 200 }); + } + if (u.endsWith("/api/v1/settings/jellyfin/sync")) { + return new Response("{}", { status: 200 }); + } + if (u.endsWith("/api/v1/settings/initialize")) { + return new Response("{}", { status: 200 }); + } + return new Response("{}", { status: 200 }); + }) as any; + return { calls, authBodies, restore: () => { globalThis.fetch = origFetch; } }; +} + +test("linkJellyseerr uses the Seerr 3.x library sync and per-library enable API", async () => { + const mock = mockSeerr(200); + try { + await linkJellyseerr("admin", "pw", "http://seerr.test"); + } finally { + mock.restore(); + } + + const urls = mock.calls.map((c) => `${c.method} ${c.url}`); + expect(urls).toContain("POST http://seerr.test/api/v1/settings/jellyfin/library/sync"); + expect(urls).toContain("PUT http://seerr.test/api/v1/settings/jellyfin/library/movies"); + expect(urls).toContain("PUT http://seerr.test/api/v1/settings/jellyfin/library/tv"); + expect(urls.some((u) => u.includes("?sync=true"))).toBe(false); + expect(urls.some((u) => u.includes("?enable="))).toBe(false); + expect(urls.some((u) => u.startsWith("POST ") && u.endsWith("/settings/initialize"))).toBe(true); +}); + +test("linkJellyseerr logs in to an existing install when the hostname is already configured", async () => { + const mock = mockSeerr(200, true); + try { + await linkJellyseerr("admin", "pw", "http://seerr.test"); + } finally { + mock.restore(); + } + + expect(mock.authBodies.length).toBeGreaterThanOrEqual(2); + expect(mock.authBodies[0].hostname).toBe("jellyfin"); + expect(mock.authBodies[1].hostname).toBeUndefined(); +}); + +test("linkJellyseerr falls back to the legacy library API on 404", async () => { + const mock = mockSeerr(404); + try { + await linkJellyseerr("admin", "pw", "http://seerr.test"); + } finally { + mock.restore(); + } + + const urls = mock.calls.map((c) => `${c.method} ${c.url}`); + expect(urls).toContain("GET http://seerr.test/api/v1/settings/jellyfin/library?sync=true"); + expect(urls.some((u) => u.includes("library?enable="))).toBe(true); +});