Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<install-dir>/config/`; `state.json`, `admin.txt`, and media still survive |

Global flags: `--verbose`, `--non-interactive`.
`install` flags: `--fresh`, `--resume`, `--install-dir <path>`.
`install` flags: `--fresh`, `--resume`, `--yes`, `--install-dir <path>`.
`--yes` finishes an existing install without the wizard and requires `--resume`.

## Configuration reference

Expand Down
4 changes: 4 additions & 0 deletions docs/guide/01-installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/09-updating.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/troubleshooting/common-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions docs/troubleshooting/reinstall-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "arrstack-installer",
"version": "1.2.1",
"version": "1.2.2",
"type": "module",
"bin": {
"arrstack": "src/cli.ts"
Expand Down
23 changes: 23 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <path>", "installation directory", `${process.env.HOME}/arrstack`)
.action(async (opts) => {
const installDir = opts.installDir ?? `${process.env.HOME}/arrstack`;
Expand All @@ -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);
Comment on lines +73 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate --yes before purging a fresh install

When an existing user runs arrstack install --fresh --yes without --resume, the --fresh block has already stopped the stack and deleted state.json, admin.txt, .env, and the entire service config/ tree before this newly added validation exits with an error. Since --yes is rejected in this combination, validate it before performing any --fresh side effects (and likewise reject --fresh --resume --yes before purging) to avoid destructive data loss from an invalid invocation.

Useful? React with 👍 / 👎.

}
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();
Expand Down
7 changes: 5 additions & 2 deletions src/lib/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
Expand Down
10 changes: 9 additions & 1 deletion src/renderer/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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/<pid>/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 }));
Comment on lines +329 to +331

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve mandatory dependencies for partial selections

When the independently toggleable Jellyseerr service remains selected but Jellyfin is deselected, this blanket filter removes Jellyseerr's declared jellyfin dependency and lets Compose start the partial project, but runInstall still unconditionally calls linkJellyseerr, whose bootstrap connects to hostname jellyfin; the install therefore fails after deploying containers. Required dependencies need to be auto-included or rejected during selection rather than being discarded alongside optional ordering edges.

Useful? React with 👍 / 👎.

if (vpnNetwork) {
dependsOn.push({ service: "gluetun", condition: "service_healthy" });
}
Expand Down
52 changes: 52 additions & 0 deletions src/usecase/resume.ts
Original file line number Diff line number Diff line change
@@ -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<InstallResult> {
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,
);
}
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const VERSION = "1.2.1";
export const VERSION = "1.2.2";
47 changes: 35 additions & 12 deletions src/wiring/jellyfin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,24 @@ async function readBody(res: Response): Promise<string> {
// 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<void> {
// 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<boolean> {
Expand All @@ -46,13 +55,20 @@ async function authenticate(
adminUser: string,
adminPass: string,
): Promise<AuthResult> {
// 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(() =>
fetch(`${base}/Users/AuthenticateByName`, {
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 }),
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not treat the startup user's name as configured

On every fresh Jellyfin installation, GET /Startup/User initializes and returns the placeholder first user with a nonempty Name; it does not indicate that the user has already been configured. Consequently this condition skips the required POST /Startup/User, leaves the placeholder credentials unchanged, and the subsequent authentication with the requested admin username/password fails, aborting the default install. Detect prior configuration by authenticating or attempting the update and handling the specific already-configured response instead.

Useful? React with 👍 / 👎.

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);
Expand Down
Loading
Loading