feat: add startup.completeStartupWizard config option
Adds support for completing the Jellyfin startup wizard via config. When startup.completeStartupWizard is true, calls POST /Startup/Complete. Closes #23
This commit is contained in:
15
README.md
15
README.md
@@ -290,6 +290,19 @@ users:
|
||||
}
|
||||
```
|
||||
|
||||
### Startup Configuration
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
base_url: "http://localhost:8096"
|
||||
system: {}
|
||||
startup:
|
||||
completeStartupWizard: true # Mark startup wizard as complete
|
||||
```
|
||||
|
||||
Useful for automated deployments where you want to skip the interactive startup
|
||||
wizard.
|
||||
|
||||
---
|
||||
|
||||
## Secret Management
|
||||
@@ -385,6 +398,8 @@ users:
|
||||
policy:
|
||||
isAdministrator: true
|
||||
loginAttemptsBeforeLockout: 3
|
||||
startup:
|
||||
completeStartupWizard: true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -47,3 +47,5 @@ users:
|
||||
loginAttemptsBeforeLockout: 3
|
||||
- name: "test-jellarr-3"
|
||||
password: "plainpass"
|
||||
startup:
|
||||
completeStartupWizard: true
|
||||
|
||||
@@ -30,6 +30,7 @@ export type PostBrandingConfigurationResponse = ApiResponse<void>;
|
||||
export type GetUsersResponse = ApiResponse<UserDtoSchema[]>;
|
||||
export type PostNewUserResponse = ApiResponse<UserDtoSchema>;
|
||||
export type PostUserPolicyResponse = ApiResponse<void>;
|
||||
export type PostStartupCompleteResponse = ApiResponse<void>;
|
||||
|
||||
export interface JellyfinClient {
|
||||
getSystemConfiguration(): Promise<ServerConfigurationSchema>;
|
||||
@@ -53,4 +54,5 @@ export interface JellyfinClient {
|
||||
getUsers(): Promise<UserDtoSchema[]>;
|
||||
createUser(body: CreateUserByNameSchema): Promise<void>;
|
||||
updateUserPolicy(userId: string, body: UserPolicySchema): Promise<void>;
|
||||
completeStartupWizard(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
GetUsersResponse,
|
||||
PostNewUserResponse,
|
||||
PostUserPolicyResponse,
|
||||
PostStartupCompleteResponse,
|
||||
} from "./jellyfin.types";
|
||||
import { makeClient } from "./client";
|
||||
import type { paths } from "../../generated/schema";
|
||||
@@ -224,5 +225,16 @@ export function createJellyfinClient(
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async completeStartupWizard(): Promise<void> {
|
||||
const res: PostStartupCompleteResponse =
|
||||
await client.POST("/Startup/Complete");
|
||||
|
||||
if (res.error) {
|
||||
throw new Error(
|
||||
`POST /Startup/Complete failed: ${res.response.status.toString()}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,4 +141,10 @@ export async function runPipeline(path: string): Promise<void> {
|
||||
console.log("✓ user policies already up to date");
|
||||
}
|
||||
}
|
||||
|
||||
if (cfg.startup?.completeStartupWizard) {
|
||||
console.log("→ marking startup wizard as complete");
|
||||
await jellyfinClient.completeStartupWizard();
|
||||
console.log("✓ marked startup wizard as complete");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { EncodingOptionsConfigType } from "./encoding-options";
|
||||
import { LibraryConfigType } from "./library";
|
||||
import { BrandingOptionsConfigType } from "./branding-options";
|
||||
import { UserConfigListType } from "./users";
|
||||
import { StartupConfigType } from "./startup";
|
||||
|
||||
export const RootConfigType: z.ZodObject<{
|
||||
version: z.ZodNumber;
|
||||
@@ -13,6 +14,7 @@ export const RootConfigType: z.ZodObject<{
|
||||
library: z.ZodOptional<typeof LibraryConfigType>;
|
||||
branding: z.ZodOptional<typeof BrandingOptionsConfigType>;
|
||||
users: z.ZodOptional<typeof UserConfigListType>;
|
||||
startup: z.ZodOptional<typeof StartupConfigType>;
|
||||
}> = z
|
||||
.object({
|
||||
version: z.number().int().positive("Version must be a positive integer"),
|
||||
@@ -22,6 +24,7 @@ export const RootConfigType: z.ZodObject<{
|
||||
library: LibraryConfigType.optional(),
|
||||
branding: BrandingOptionsConfigType.optional(),
|
||||
users: UserConfigListType.optional(),
|
||||
startup: StartupConfigType.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
11
src/types/config/startup.ts
Normal file
11
src/types/config/startup.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const StartupConfigType: z.ZodObject<{
|
||||
completeStartupWizard: z.ZodOptional<z.ZodBoolean>;
|
||||
}> = z
|
||||
.object({
|
||||
completeStartupWizard: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type StartupConfig = z.infer<typeof StartupConfigType>;
|
||||
@@ -682,3 +682,50 @@ describe("api/jf Users façade", () => {
|
||||
).rejects.toThrow(/POST \/Users\/\{userId\}\/Policy failed/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("api/jf Startup façade", () => {
|
||||
beforeEach((): void => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach((): void => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("when POST /Startup/Complete succeeds then it resolves", async (): Promise<void> => {
|
||||
// Arrange
|
||||
mockFetchNoContent(204);
|
||||
|
||||
// Act
|
||||
const jellyfinClient: JellyfinClient = createJellyfinClient(
|
||||
baseUrl,
|
||||
apiKey,
|
||||
);
|
||||
await jellyfinClient.completeStartupWizard();
|
||||
|
||||
// Assert
|
||||
const req: Request = getLastRequest();
|
||||
expect(req.method).toBe("POST");
|
||||
expect(req.url).toMatch(/\/Startup\/Complete$/);
|
||||
expect(req.headers.get("X-Emby-Token")).toBe(apiKey);
|
||||
});
|
||||
|
||||
it("when POST /Startup/Complete fails then it throws an error with status", async (): Promise<void> => {
|
||||
// Arrange
|
||||
fetchMock = vi
|
||||
.spyOn(global, "fetch")
|
||||
.mockResolvedValue(new Response("boom", { status: 500 }));
|
||||
|
||||
// Act
|
||||
const jellyfinClient: JellyfinClient = createJellyfinClient(
|
||||
baseUrl,
|
||||
apiKey,
|
||||
);
|
||||
|
||||
// Assert
|
||||
await expect(jellyfinClient.completeStartupWizard()).rejects.toThrow(
|
||||
/POST \/Startup\/Complete failed/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -567,4 +567,90 @@ describe("RootConfig", () => {
|
||||
expect(strictError?.code).toBe("unrecognized_keys");
|
||||
}
|
||||
});
|
||||
|
||||
it("should validate root config with startup section", () => {
|
||||
// Arrange
|
||||
const validConfig: z.input<typeof RootConfigType> = {
|
||||
version: 1,
|
||||
base_url: "http://10.0.0.76:8096",
|
||||
system: {},
|
||||
startup: {
|
||||
completeStartupWizard: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result: ZodSafeParseResult<RootConfig> =
|
||||
RootConfigType.safeParse(validConfig);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.startup).toBeDefined();
|
||||
expect(result.data.startup?.completeStartupWizard).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should validate root config with empty startup section", () => {
|
||||
// Arrange
|
||||
const validConfig: z.input<typeof RootConfigType> = {
|
||||
version: 1,
|
||||
base_url: "http://10.0.0.76:8096",
|
||||
system: {},
|
||||
startup: {},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result: ZodSafeParseResult<RootConfig> =
|
||||
RootConfigType.safeParse(validConfig);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.startup).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("should validate root config without startup section", () => {
|
||||
// Arrange
|
||||
const validConfig: z.input<typeof RootConfigType> = {
|
||||
version: 1,
|
||||
base_url: "http://10.0.0.76:8096",
|
||||
system: {},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result: ZodSafeParseResult<RootConfig> =
|
||||
RootConfigType.safeParse(validConfig);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.startup).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject invalid startup configuration", () => {
|
||||
// Arrange
|
||||
const invalidConfig: z.input<typeof RootConfigType> = {
|
||||
version: 1,
|
||||
base_url: "http://10.0.0.76:8096",
|
||||
system: {},
|
||||
startup: {
|
||||
// @ts-expect-error intentional invalid field for test
|
||||
invalidField: "not allowed",
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result: ZodSafeParseResult<RootConfig> =
|
||||
RootConfigType.safeParse(invalidConfig);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues).toBeDefined();
|
||||
expect(result.error.issues.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
101
tests/types/config/startup.spec.ts
Normal file
101
tests/types/config/startup.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { ZodSafeParseResult } from "zod";
|
||||
import { type z } from "zod";
|
||||
import {
|
||||
StartupConfigType,
|
||||
type StartupConfig,
|
||||
} from "../../../src/types/config/startup";
|
||||
|
||||
describe("StartupConfig", () => {
|
||||
it("should validate startup config with completeStartupWizard true", () => {
|
||||
// Arrange
|
||||
const validConfig: z.input<typeof StartupConfigType> = {
|
||||
completeStartupWizard: true,
|
||||
};
|
||||
|
||||
// Act
|
||||
const result: ZodSafeParseResult<StartupConfig> =
|
||||
StartupConfigType.safeParse(validConfig);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.completeStartupWizard).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should validate startup config with completeStartupWizard false", () => {
|
||||
// Arrange
|
||||
const validConfig: z.input<typeof StartupConfigType> = {
|
||||
completeStartupWizard: false,
|
||||
};
|
||||
|
||||
// Act
|
||||
const result: ZodSafeParseResult<StartupConfig> =
|
||||
StartupConfigType.safeParse(validConfig);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.completeStartupWizard).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject invalid completeStartupWizard value", () => {
|
||||
// Arrange
|
||||
const invalidConfig: z.input<typeof StartupConfigType> = {
|
||||
// @ts-expect-error intentional invalid value for test
|
||||
completeStartupWizard: "invalid",
|
||||
};
|
||||
|
||||
// Act
|
||||
const result: ZodSafeParseResult<StartupConfig> =
|
||||
StartupConfigType.safeParse(invalidConfig);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues).toBeDefined();
|
||||
expect(result.error.issues.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should validate empty startup config", () => {
|
||||
// Arrange
|
||||
const validConfig: z.input<typeof StartupConfigType> = {};
|
||||
|
||||
// Act
|
||||
const result: ZodSafeParseResult<StartupConfig> =
|
||||
StartupConfigType.safeParse(validConfig);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.completeStartupWizard).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject extra fields due to strict mode", () => {
|
||||
// Arrange
|
||||
const invalidConfig: z.input<typeof StartupConfigType> = {
|
||||
completeStartupWizard: true,
|
||||
// @ts-expect-error intentional extra field for test
|
||||
extraField: "not allowed",
|
||||
};
|
||||
|
||||
// Act
|
||||
const result: ZodSafeParseResult<StartupConfig> =
|
||||
StartupConfigType.safeParse(invalidConfig);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues).toBeDefined();
|
||||
const strictError: z.core.$ZodIssue | undefined =
|
||||
result.error.issues.find(
|
||||
(err: z.core.$ZodIssue) => err.code === "unrecognized_keys",
|
||||
);
|
||||
expect(strictError?.code).toBe("unrecognized_keys");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user