diff --git a/etc/vortex.api.md b/etc/vortex.api.md index 1df4ad3ef1..d27df88a40 100644 --- a/etc/vortex.api.md +++ b/etc/vortex.api.md @@ -48,6 +48,7 @@ import type { IModFileContentSearchFilter } from '@nexusmods/nexus-api'; import type { IModInfo } from 'modmeta-db'; import type { IModInfo as IModInfo_2 } from '@nexusmods/nexus-api'; import type { IModRequirements } from '@nexusmods/nexus-api'; +import type { IncomingHttpHeaders } from 'http'; import type { IncomingMessage } from 'http'; import type { IPreference } from '@nexusmods/nexus-api'; import type { IPreferenceQuery } from '@nexusmods/nexus-api'; @@ -4067,6 +4068,16 @@ interface IUnavailableReason { solution?: (t: TFunction) => string; } +// @public (undocumented) +interface IUploadResult { + // (undocumented) + body: Buffer; + // (undocumented) + headers: IncomingHttpHeaders; + // (undocumented) + statusCode: number; +} + // @public interface IUser { // (undocumented) @@ -6032,6 +6043,9 @@ type UpdateType = "drag-n-drop" | "props-update" | "refresh"; // @public (undocumented) function upload(targetUrl: string, dataStream: Readable, dataSize: number): Promise; +// @public (undocumented) +function uploadWithHeaders(targetUrl: string, dataStream: Readable, dataSize: number, extraHeaders?: Record): Promise; + // Warning: (ae-forgotten-export) The symbol "IUsageProps" needs to be exported by the entry point api.d.ts // // @public (undocumented) @@ -6205,8 +6219,10 @@ declare namespace util { jsonRequest, request, upload, + uploadWithHeaders, IRequestOptions, - Method + Method, + IUploadResult } } export { util } diff --git a/extensions/collections/package.json b/extensions/collections/package.json index 3982853ed7..4c839c3e0c 100644 --- a/extensions/collections/package.json +++ b/extensions/collections/package.json @@ -14,6 +14,7 @@ "7z-bin": "catalog:", "@hot-updater/bsdiff": "catalog:", "@nexusmods/nexus-api": "catalog:", + "@vortex/nexus-api-v3": "workspace:*", "@types/lodash": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", diff --git a/extensions/collections/src/collectionExport.ts b/extensions/collections/src/collectionExport.ts index 80ac39cb65..625f516c4c 100644 --- a/extensions/collections/src/collectionExport.ts +++ b/extensions/collections/src/collectionExport.ts @@ -1,6 +1,7 @@ import * as path from "path"; import { ICreateCollectionResult, IGraphErrorDetail } from "@nexusmods/nexus-api"; +import { V3ApiError } from "@vortex/nexus-api-v3"; import Bluebird from "bluebird"; import * as _ from "lodash"; import Zip from "node-7z"; @@ -246,7 +247,9 @@ export async function doExportToAPI( const state: types.IState = api.store.getState(); const mod = state.persistent.mods[gameId][modId]; - const { progress, progressEnd } = makeProgressFunction(api); + const { progress, progressEnd, signal } = makeProgressFunction(api, { + cancellable: true, + }); const errors: Array<{ message: string; replace: any }> = []; @@ -292,17 +295,17 @@ export async function doExportToAPI( collectionId = undefined; } const result: ICreateCollectionResult = await util.toPromise((cb) => - api.events.emit("submit-collection", filterInfo(info), filePath, collectionId, cb), + api.events.emit("submit-collection", filterInfo(info), filePath, collectionId, signal, cb), ); collectionId = result.collection.id; - collectionSlug = result.collection.slug; + // V3 revision endpoint omits slug (it never changes), so fall back to + // the previously stored slug on the mod. + collectionSlug = result.collection.slug ?? mod.attributes?.collectionSlug; api.store.dispatch(actions.setModAttribute(gameId, modId, "collectionId", collectionId)); - api.store.dispatch( - actions.setModAttribute(gameId, modId, "collectionSlug", result.collection.slug), - ); + api.store.dispatch(actions.setModAttribute(gameId, modId, "collectionSlug", collectionSlug)); api.store.dispatch(actions.setModAttribute(gameId, modId, "source", "nexus")); - const revisionId = result.revision?.id ?? result["revisionId"]; - revisionNumber = result.revision?.revisionNumber ?? result["revisionNumber"]; + const revisionId = result.revision?.id; + revisionNumber = result.revision?.revisionNumber; api.store.dispatch(actions.setModAttribute(gameId, modId, "revisionId", revisionId)); api.store.dispatch(actions.setModAttribute(gameId, modId, "revisionNumber", revisionNumber)); api.store.dispatch( @@ -326,6 +329,9 @@ export async function doExportToAPI( progressEnd(); } catch (err) { progressEnd(); + if (err instanceof util.UserCanceled) { + throw err; + } if (err.name === "ModFileNotFound") { const file = info.mods.find((iter) => iter.source.fileId === err.fileId); api.sendNotification({ @@ -343,6 +349,32 @@ export async function doExportToAPI( message: err.message || "", }); throw new util.ProcessCanceled("collection rejected"); + } else if (err instanceof V3ApiError) { + const message = err.detail || err.message; + const validationErrors = err.validationErrors ?? []; + api.sendNotification({ + type: "error", + message: "The server rejected this collection", + actions: [ + { + title: "More", + action: () => { + api.showDialog( + "error", + "The server rejected this collection", + { + text: + validationErrors.length === 0 + ? message + : validationErrors.map((ve) => `${ve.pointer}: ${ve.detail}`).join("\n"), + }, + [{ label: "Close" }], + ); + }, + }, + ], + }); + throw new util.ProcessCanceled("collection rejected"); } else if (err.constructor.name === "GraphError") { const message: string = err.message; const details: IGraphErrorDetail[] = err["details"] ?? []; diff --git a/extensions/collections/src/util/util.ts b/extensions/collections/src/util/util.ts index 82b22149f2..a27aa1ce45 100644 --- a/extensions/collections/src/util/util.ts +++ b/extensions/collections/src/util/util.ts @@ -22,12 +22,30 @@ export function hasEditPermissions(permissions: ICollectionPermission[]): boolea return allPermissions.includes("collection:edit"); } -export function makeProgressFunction(api: types.IExtensionApi) { +interface IProgressOptions { + cancellable?: boolean; +} + +export function makeProgressFunction(api: types.IExtensionApi, options: IProgressOptions = {}) { + const controller = new AbortController(); + + const cancelAction = options.cancellable + ? [ + { + title: "Cancel", + action: () => { + controller.abort(new util.UserCanceled()); + }, + }, + ] + : undefined; + const notificationId = api.sendNotification({ type: "activity", title: "Building Collection", message: "", progress: 0, + actions: cancelAction, }); let notiPerc = 0; @@ -69,6 +87,7 @@ export function makeProgressFunction(api: types.IExtensionApi) { title: "Building Collection", progress: notiPerc, message: notiText, + actions: cancelAction, }); } }; @@ -77,7 +96,7 @@ export function makeProgressFunction(api: types.IExtensionApi) { api.dismissNotification(notificationId); }; - return { progress, progressEnd }; + return { progress, progressEnd, signal: controller.signal }; } export function bbProm(func: (...args: any[]) => Promise): (...args: any[]) => Bluebird { diff --git a/packages/nexus-api-v3/.gitignore b/packages/nexus-api-v3/.gitignore new file mode 100644 index 0000000000..1ea6743a06 --- /dev/null +++ b/packages/nexus-api-v3/.gitignore @@ -0,0 +1,2 @@ +dist/ +src/generated/ diff --git a/packages/nexus-api-v3/package.json b/packages/nexus-api-v3/package.json new file mode 100644 index 0000000000..9e3edec898 --- /dev/null +++ b/packages/nexus-api-v3/package.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "name": "@vortex/nexus-api-v3", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "codegen": "openapi-typescript https://api.nexusmods.com/openapi.yaml -o src/generated/nexus-api-v3.d.ts", + "build": "pnpm codegen && pnpm tsdown", + "dist": "pnpm tsdown", + "test": "vitest run", + "typecheck": "pnpm tsc --noEmit -p tsconfig.json", + "postinstall": "pnpm codegen" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "dependencies": { + "openapi-fetch": "catalog:" + }, + "devDependencies": { + "openapi-typescript": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/nexus-api-v3/src/client.test.ts b/packages/nexus-api-v3/src/client.test.ts new file mode 100644 index 0000000000..2c900debc7 --- /dev/null +++ b/packages/nexus-api-v3/src/client.test.ts @@ -0,0 +1,298 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { createNexusV3Client } from "./client"; +import { V3ApiError } from "./errors"; + +// Mock openapi-fetch's createClient +vi.mock("openapi-fetch", () => ({ + default: vi.fn(() => ({ + GET: vi.fn(), + POST: vi.fn(), + PUT: vi.fn(), + PATCH: vi.fn(), + })), +})); + +import createClient from "openapi-fetch"; + +const mockCreateClient = vi.mocked(createClient); + +describe("createNexusV3Client", () => { + let mockClient: { + GET: ReturnType; + POST: ReturnType; + PATCH: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = { + GET: vi.fn(), + POST: vi.fn(), + PATCH: vi.fn(), + }; + mockCreateClient.mockReturnValue(mockClient as any); + }); + + describe("auth headers", () => { + it("sets apikey header when apiKey is provided", () => { + createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "my-api-key", + }); + + expect(mockCreateClient).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + apikey: "my-api-key", + }), + }), + ); + }); + + it("sets Authorization header when bearerToken is provided", () => { + createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + bearerToken: "my-jwt-token", + }); + + expect(mockCreateClient).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer my-jwt-token", + }), + }), + ); + }); + + it("prefers bearerToken over apiKey", () => { + createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "my-api-key", + bearerToken: "my-jwt-token", + }); + + const headers = mockCreateClient.mock.calls[0][0].headers as Record; + expect(headers["Authorization"]).toBe("Bearer my-jwt-token"); + expect(headers["apikey"]).toBeUndefined(); + }); + }); + + describe("createUpload", () => { + it("calls POST /uploads and returns data", async () => { + mockClient.POST.mockResolvedValue({ + data: { data: { id: "upload-1", presigned_url: "https://s3/upload" } }, + error: undefined, + response: {}, + }); + + const client = createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "key", + }); + const result = await client.createUpload(1024, "file.zip"); + + expect(mockClient.POST).toHaveBeenCalledWith("/uploads", { + body: { size_bytes: 1024, filename: "file.zip" }, + }); + expect(result.id).toBe("upload-1"); + expect(result.presigned_url).toBe("https://s3/upload"); + }); + + it("throws V3ApiError on error response", async () => { + mockClient.POST.mockResolvedValue({ + data: undefined, + error: { + type: "about:blank", + title: "Bad Request", + status: 400, + detail: "Invalid size", + instance: "/uploads", + }, + response: { status: 400, url: "https://api.nexusmods.com/v3/uploads" }, + }); + + const client = createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "key", + }); + + await expect(client.createUpload(-1, "file.zip")).rejects.toThrow(V3ApiError); + }); + }); + + describe("finaliseUpload", () => { + it("calls POST /uploads/{id}/finalise", async () => { + mockClient.POST.mockResolvedValue({ + data: { data: { id: "upload-1", state: "created" } }, + error: undefined, + response: {}, + }); + + const client = createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "key", + }); + await client.finaliseUpload("upload-1"); + + expect(mockClient.POST).toHaveBeenCalledWith("/uploads/{id}/finalise", { + params: { path: { id: "upload-1" } }, + }); + }); + }); + + describe("getUpload", () => { + it("calls GET /uploads/{id} and returns state", async () => { + mockClient.GET.mockResolvedValue({ + data: { data: { id: "upload-1", state: "available" } }, + error: undefined, + response: {}, + }); + + const client = createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "key", + }); + const result = await client.getUpload("upload-1"); + + expect(mockClient.GET).toHaveBeenCalledWith("/uploads/{id}", { + params: { path: { id: "upload-1" } }, + }); + expect(result.state).toBe("available"); + }); + }); + + describe("createCollection", () => { + it("calls POST /collections with upload_id and payload", async () => { + mockClient.POST.mockResolvedValue({ + data: { data: { id: "col-1", revision_id: "rev-1" } }, + error: undefined, + response: {}, + }); + + const client = createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "key", + }); + const payload = { + adult_content: false, + collection_manifest: { info: {}, mods: [] }, + collection_schema_id: 1, + }; + const result = await client.createCollection("upload-1", payload as any); + + expect(mockClient.POST).toHaveBeenCalledWith("/collections", { + body: { upload_id: "upload-1", collection_data: payload }, + }); + expect(result.id).toBe("col-1"); + expect(result.revision_id).toBe("rev-1"); + }); + + it("throws V3ApiError with validation errors on 422", async () => { + mockClient.POST.mockResolvedValue({ + data: undefined, + error: { + type: "about:blank", + title: "Unprocessable Entity", + status: 422, + detail: "Validation failed", + instance: "/collections", + errors: [ + { + detail: "mod_id is required", + pointer: "/collection_data/collection_manifest/mods/0/source/mod_id", + }, + ], + }, + response: { + status: 422, + url: "https://api.nexusmods.com/v3/collections", + }, + }); + + const client = createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "key", + }); + + try { + await client.createCollection("upload-1", {} as any); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(V3ApiError); + const v3err = err as V3ApiError; + expect(v3err.status).toBe(422); + expect(v3err.validationErrors).toHaveLength(1); + expect(v3err.validationErrors![0].pointer).toContain("mod_id"); + } + }); + }); + + describe("createCollectionRevision", () => { + it("calls POST /collections/{id}/revisions", async () => { + mockClient.POST.mockResolvedValue({ + data: { data: { id: "rev-2", collection_id: "col-1" } }, + error: undefined, + response: {}, + }); + + const client = createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "key", + }); + const result = await client.createCollectionRevision("col-1", "upload-1", {} as any); + + expect(mockClient.POST).toHaveBeenCalledWith("/collections/{id}/revisions", { + params: { path: { id: "col-1" } }, + body: { upload_id: "upload-1", collection_data: {} }, + }); + expect(result.id).toBe("rev-2"); + expect(result.collection_id).toBe("col-1"); + }); + }); + + describe("editCollection", () => { + it("calls PATCH /collections/{id} with the patch body", async () => { + mockClient.PATCH.mockResolvedValue({ + data: undefined, + error: undefined, + response: { status: 204 }, + }); + + const client = createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "key", + }); + await client.editCollection(42, { name: "Renamed" }); + + expect(mockClient.PATCH).toHaveBeenCalledWith("/collections/{id}", { + params: { path: { id: 42 } }, + body: { name: "Renamed" }, + }); + }); + + it("throws V3ApiError on error response", async () => { + mockClient.PATCH.mockResolvedValue({ + data: undefined, + error: { + type: "about:blank", + title: "Forbidden", + status: 403, + detail: "Not the owner", + instance: "/collections/42", + }, + response: { + status: 403, + url: "https://api.nexusmods.com/v3/collections/42", + }, + }); + + const client = createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "key", + }); + + await expect(client.editCollection(42, { name: "Renamed" })).rejects.toThrow(V3ApiError); + }); + }); +}); diff --git a/packages/nexus-api-v3/src/client.ts b/packages/nexus-api-v3/src/client.ts new file mode 100644 index 0000000000..c9c273dbde --- /dev/null +++ b/packages/nexus-api-v3/src/client.ts @@ -0,0 +1,133 @@ +import createClient, { type Middleware } from "openapi-fetch"; + +import { V3ApiError } from "./errors"; +import type { paths } from "./generated/nexus-api-v3"; + +export interface NexusV3ClientOptions { + baseUrl: string; + apiKey?: string; + bearerToken?: string; + middleware?: Middleware[]; +} + +export type NexusV3Client = ReturnType; + +export function createNexusV3Client(options: NexusV3ClientOptions) { + const headers: Record = { + "User-Agent": "Vortex", + }; + + if (options.bearerToken) { + headers["Authorization"] = `Bearer ${options.bearerToken}`; + } else if (options.apiKey) { + headers["apikey"] = options.apiKey; + } + + const client = createClient({ + baseUrl: options.baseUrl, + headers, + }); + + for (const mw of options.middleware ?? []) { + client.use(mw); + } + + // Wrap the client methods to throw V3ApiError on error responses + return { + ...client, + + async createUpload(sizeBytes: number, filename: string) { + const { data, error, response } = await client.POST("/uploads", { + body: { size_bytes: sizeBytes, filename }, + }); + if (error) throw toV3Error(error, response); + return data.data; + }, + + async createMultipartUpload(sizeBytes: number, filename: string) { + const { data, error, response } = await client.POST("/uploads/multipart", { + body: { size_bytes: sizeBytes, filename }, + }); + if (error) throw toV3Error(error, response); + return data.data; + }, + + async finaliseUpload(uploadId: string) { + const { data, error, response } = await client.POST("/uploads/{id}/finalise", { + params: { path: { id: uploadId } }, + }); + if (error) throw toV3Error(error, response); + return data.data; + }, + + async getUpload(uploadId: string) { + const { data, error, response } = await client.GET("/uploads/{id}", { + params: { path: { id: uploadId } }, + }); + if (error) throw toV3Error(error, response); + return data.data; + }, + + async createCollection( + uploadId: string, + collectionData: paths["/collections"]["post"]["requestBody"]["content"]["application/json"]["collection_data"], + ) { + const { data, error, response } = await client.POST("/collections", { + body: { upload_id: uploadId, collection_data: collectionData }, + }); + if (error) throw toV3Error(error, response); + return data.data; + }, + + async createCollectionRevision( + collectionId: string, + uploadId: string, + collectionData: paths["/collections/{id}/revisions"]["post"]["requestBody"]["content"]["application/json"]["collection_data"], + ) { + const { data, error, response } = await client.POST("/collections/{id}/revisions", { + params: { path: { id: collectionId } }, + body: { upload_id: uploadId, collection_data: collectionData }, + }); + if (error) throw toV3Error(error, response); + return data.data; + }, + + async editCollection( + collectionId: number, + patch: paths["/collections/{id}"]["patch"]["requestBody"]["content"]["application/json"], + ) { + const { error, response } = await client.PATCH("/collections/{id}", { + params: { path: { id: collectionId } }, + body: patch, + }); + if (error) throw toV3Error(error, response); + }, + }; +} + +function toV3Error(error: unknown, response: Response): V3ApiError { + // openapi-fetch returns the parsed error body. For problem+json responses, + // this will be a ProblemDetails or ValidationProblem object — but proxies, + // 502 pages, and transport errors can all produce other shapes, so we fall + // back to the HTTP response whenever a field is missing. + const problem = error && typeof error === "object" ? (error as Record) : {}; + + return new V3ApiError({ + type: typeof problem.type === "string" ? problem.type : "about:blank", + title: + typeof problem.title === "string" && problem.title.length > 0 + ? problem.title + : `HTTP ${response.status}`, + status: typeof problem.status === "number" ? problem.status : response.status, + detail: + typeof problem.detail === "string" + ? problem.detail + : error instanceof Error + ? error.message + : "", + instance: typeof problem.instance === "string" ? problem.instance : response.url, + errors: Array.isArray(problem.errors) + ? (problem.errors as V3ApiError["validationErrors"]) + : undefined, + }); +} diff --git a/packages/nexus-api-v3/src/errors.ts b/packages/nexus-api-v3/src/errors.ts new file mode 100644 index 0000000000..f9c0f08c3f --- /dev/null +++ b/packages/nexus-api-v3/src/errors.ts @@ -0,0 +1,22 @@ +import type { components } from "./generated/nexus-api-v3"; + +type ProblemDetails = components["schemas"]["ProblemDetails"]; +type ValidationProblemItem = components["schemas"]["ValidationProblemItem"]; + +export class V3ApiError extends Error { + public readonly status: number; + public readonly problemType: string; + public readonly detail: string; + public readonly instance: string; + public readonly validationErrors?: ValidationProblemItem[]; + + constructor(problem: Partial & { errors?: ValidationProblemItem[] }) { + super(problem.title || "Request failed"); + this.name = "V3ApiError"; + this.status = problem.status ?? 0; + this.problemType = problem.type || "about:blank"; + this.detail = problem.detail || ""; + this.instance = problem.instance || ""; + this.validationErrors = problem.errors; + } +} diff --git a/packages/nexus-api-v3/src/index.ts b/packages/nexus-api-v3/src/index.ts new file mode 100644 index 0000000000..da07f0e555 --- /dev/null +++ b/packages/nexus-api-v3/src/index.ts @@ -0,0 +1,4 @@ +export type { paths, components, operations } from "./generated/nexus-api-v3"; +export { createNexusV3Client, type NexusV3Client, type NexusV3ClientOptions } from "./client"; +export { V3ApiError } from "./errors"; +export type { Middleware as NexusV3Middleware } from "openapi-fetch"; diff --git a/packages/nexus-api-v3/tsconfig.json b/packages/nexus-api-v3/tsconfig.json new file mode 100644 index 0000000000..92150e6908 --- /dev/null +++ b/packages/nexus-api-v3/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://www.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.base.json", + "include": ["src/**/*"], + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "allowJs": false, + "composite": true, + "noEmit": true, + "types": ["node"], + "lib": ["ESNext"] + } +} diff --git a/packages/nexus-api-v3/tsdown.config.ts b/packages/nexus-api-v3/tsdown.config.ts new file mode 100644 index 0000000000..be11a350bb --- /dev/null +++ b/packages/nexus-api-v3/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: { + index: "./src/index.ts", + }, + format: ["esm", "commonjs"], + dts: { + sourcemap: true, + }, + exports: true, + platform: "neutral", +}); diff --git a/packages/nexus-api-v3/vitest.config.ts b/packages/nexus-api-v3/vitest.config.ts new file mode 100644 index 0000000000..c1433e6ef3 --- /dev/null +++ b/packages/nexus-api-v3/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69d0872431..5b439a50cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,7 +48,7 @@ catalogs: version: 1.7.19 '@hot-updater/bsdiff': specifier: ^0.30.6 - version: 0.30.6 + version: 0.30.11 '@iarna/toml': specifier: ^2.2.5 version: 2.2.5 @@ -78,7 +78,7 @@ catalogs: version: 2.6.1 '@opentelemetry/context-zone': specifier: ^2.5.1 - version: 2.7.0 + version: 2.7.1 '@opentelemetry/exporter-trace-otlp-http': specifier: ^0.57.0 version: 0.57.2 @@ -183,7 +183,7 @@ catalogs: version: 1.2.5 '@types/node': specifier: ^24 - version: 24.12.2 + version: 24.12.4 '@types/node-7z': specifier: ~2.1.11 version: 2.1.11 @@ -532,6 +532,12 @@ catalogs: nx: specifier: ^22.7.1 version: 22.7.1 + openapi-fetch: + specifier: ^0.14.0 + version: 0.14.1 + openapi-typescript: + specifier: ^7.8.0 + version: 7.13.0 oxfmt: specifier: ^0.41.0 version: 0.41.0 @@ -786,7 +792,7 @@ importers: version: 9.39.1 '@microsoft/api-extractor': specifier: 'catalog:' - version: 7.57.7(@types/node@24.12.2) + version: 7.57.7(@types/node@24.12.4) '@stylistic/eslint-plugin': specifier: 'catalog:' version: 5.10.0(eslint@9.39.1) @@ -801,7 +807,7 @@ importers: version: 1.2.5 '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 '@types/webpack-node-externals': specifier: 'catalog:' version: 3.0.4(webpack-cli@5.1.4) @@ -906,10 +912,10 @@ importers: version: 8.47.0(eslint@9.39.1)(typescript@5.9.3) vite: specifier: 'catalog:' - version: 8.0.3(@types/node@24.12.2)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.1)(tsx@4.22.0)(yaml@2.8.4) + version: 8.0.3(@types/node@24.12.4)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.1)(tsx@4.22.0)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/ui@4.1.0)(happy-dom@20.8.8)(vite@8.0.3) + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/ui@4.1.0)(happy-dom@20.8.8)(vite@8.0.3) webpack: specifier: 'catalog:' version: 5.105.4(webpack-cli@5.1.4) @@ -978,7 +984,7 @@ importers: version: https://codeload.github.com/Nexus-Mods/7z-bin/tar.gz/3298c42e69e3220dc39694bc2f610c077c3e213a '@hot-updater/bsdiff': specifier: 'catalog:' - version: 0.30.6 + version: 0.30.11 '@nexusmods/nexus-api': specifier: 'catalog:' version: https://codeload.github.com/Nexus-Mods/node-nexus-api/tar.gz/2d92fd2bdc4aa6b9813a1e7043d412e13f4aa1d7 @@ -987,7 +993,7 @@ importers: version: 4.17.24 '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 '@types/react': specifier: '16' version: 16.14.69 @@ -1003,6 +1009,9 @@ importers: '@types/redux': specifier: 'catalog:' version: 3.6.0 + '@vortex/nexus-api-v3': + specifier: workspace:* + version: link:../../packages/nexus-api-v3 bluebird: specifier: 'catalog:' version: 3.7.2 @@ -1086,7 +1095,7 @@ importers: devDependencies: '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 '@types/react': specifier: '16' version: 16.14.69 @@ -1161,7 +1170,7 @@ importers: devDependencies: '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 '@types/react': specifier: '16' version: 16.14.69 @@ -1266,7 +1275,7 @@ importers: version: 3.5.20 '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 '@types/react': specifier: '16' version: 16.14.69 @@ -1341,7 +1350,7 @@ importers: version: 12.1.0 '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 '@types/redux': specifier: 'catalog:' version: 3.6.0 @@ -1464,7 +1473,7 @@ importers: version: 12.1.0 '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 '@types/rimraf': specifier: 'catalog:' version: 2.0.5 @@ -2133,7 +2142,7 @@ importers: version: 3.5.20 '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 '@types/react': specifier: '16' version: 16.14.69 @@ -2163,7 +2172,7 @@ importers: version: 16.14.0 react-dnd: specifier: 'catalog:' - version: 14.0.5(@types/hoist-non-react-statics@3.3.7)(@types/node@24.12.2)(@types/react@16.14.69)(react@16.14.0) + version: 14.0.5(@types/hoist-non-react-statics@3.3.7)(@types/node@24.12.4)(@types/react@16.14.69)(react@16.14.0) react-i18next: specifier: 'catalog:' version: 11.18.6(i18next@19.9.2)(react-dom@16.14.0)(react@16.14.0) @@ -2296,7 +2305,7 @@ importers: version: https://codeload.github.com/Nexus-Mods/node-nexus-api/tar.gz/2d92fd2bdc4aa6b9813a1e7043d412e13f4aa1d7 '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 '@types/react': specifier: '16' version: 16.14.69 @@ -2326,7 +2335,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/ui@4.1.0)(happy-dom@20.8.8)(vite@8.0.3) + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/ui@4.1.0)(happy-dom@20.8.8)(vite@8.0.3) vortex-api: specifier: workspace:* version: link:../../../packages/vortex-api @@ -2464,7 +2473,7 @@ importers: version: 3.5.20 '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 bluebird: specifier: 'catalog:' version: 3.7.2 @@ -2518,7 +2527,7 @@ importers: version: 3.5.20 '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 bluebird: specifier: 'catalog:' version: 3.7.2 @@ -2542,7 +2551,7 @@ importers: version: 3.5.20 '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 '@types/redux': specifier: 'catalog:' version: 3.6.0 @@ -2582,7 +2591,7 @@ importers: devDependencies: '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 '@types/react': specifier: '16' version: 16.14.69 @@ -2624,7 +2633,7 @@ importers: version: 3.5.20 '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 '@types/react': specifier: '16' version: 16.14.69 @@ -2717,7 +2726,7 @@ importers: devDependencies: '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 '@types/react': specifier: '16' version: 16.14.69 @@ -2846,7 +2855,7 @@ importers: version: 12.1.0 '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 '@types/react': specifier: '16' version: 16.14.69 @@ -3047,7 +3056,7 @@ importers: devDependencies: '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 bs58: specifier: 'catalog:' version: 4.0.1 @@ -3346,7 +3355,7 @@ importers: version: 3.5.20 '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 bluebird: specifier: 'catalog:' version: 3.7.2 @@ -3370,7 +3379,7 @@ importers: devDependencies: '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 '@types/react': specifier: '16' version: 16.14.69 @@ -3520,7 +3529,7 @@ importers: version: 12.1.0 '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 '@types/react': specifier: '16' version: 16.14.69 @@ -3572,7 +3581,7 @@ importers: version: 4.17.24 '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 bluebird: specifier: 'catalog:' version: 3.7.2 @@ -3671,7 +3680,7 @@ importers: version: 12.1.0 '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 bluebird: specifier: 'catalog:' version: 3.7.2 @@ -3770,7 +3779,7 @@ importers: version: https://codeload.github.com/Nexus-Mods/7z-bin/tar.gz/3298c42e69e3220dc39694bc2f610c077c3e213a '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 '@types/react': specifier: '16' version: 16.14.69 @@ -3806,7 +3815,7 @@ importers: devDependencies: '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 rolldown: specifier: 'catalog:' version: 1.0.0-rc.9 @@ -3846,6 +3855,25 @@ importers: packages/exe-version: {} + packages/nexus-api-v3: + dependencies: + openapi-fetch: + specifier: 'catalog:' + version: 0.14.1 + devDependencies: + openapi-typescript: + specifier: 'catalog:' + version: 7.13.0(typescript@5.9.3) + tsdown: + specifier: 'catalog:' + version: 0.21.0-beta.1(synckit@0.11.12)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(@vitest/ui@4.1.0)(happy-dom@20.8.8)(vite@8.0.3) + packages/vortex-api: dependencies: '@types/bluebird': @@ -3977,7 +4005,7 @@ importers: version: 2.6.1(@opentelemetry/api@1.9.1) '@opentelemetry/context-zone': specifier: 'catalog:' - version: 2.7.0(@opentelemetry/api@1.9.1) + version: 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/exporter-trace-otlp-http': specifier: 'catalog:' version: 0.57.2(@opentelemetry/api@1.9.1) @@ -4169,7 +4197,7 @@ importers: version: 3.8.0(react-dom@16.14.0)(react@16.14.0) react-dnd: specifier: 'catalog:' - version: 14.0.5(@types/hoist-non-react-statics@3.3.7)(@types/node@24.12.2)(@types/react@16.14.69)(react@16.14.0) + version: 14.0.5(@types/hoist-non-react-statics@3.3.7)(@types/node@24.12.4)(@types/react@16.14.69)(react@16.14.0) react-dnd-html5-backend: specifier: 'catalog:' version: 14.1.0 @@ -4332,7 +4360,7 @@ importers: version: 4.17.24 '@types/node': specifier: 'catalog:' - version: 24.12.2 + version: 24.12.4 '@types/node-7z': specifier: 'catalog:' version: 2.1.11 @@ -4475,7 +4503,7 @@ importers: version: 1.9.1 '@opentelemetry/context-zone': specifier: 'catalog:' - version: 2.7.0(@opentelemetry/api@1.9.1) + version: 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/resources': specifier: 'catalog:' version: 1.30.1(@opentelemetry/api@1.9.1) @@ -4623,6 +4651,9 @@ importers: '@vitejs/plugin-react': specifier: 'catalog:' version: 6.0.1(vite@8.0.3) + '@vortex/nexus-api-v3': + specifier: workspace:* + version: link:../../packages/nexus-api-v3 '@vortex/shared': specifier: workspace:* version: link:../shared @@ -4947,7 +4978,7 @@ packages: hasBin: true 7z-bin@https://codeload.github.com/Nexus-Mods/7z-bin/tar.gz/3298c42e69e3220dc39694bc2f610c077c3e213a: - resolution: {gitHosted: true, tarball: https://codeload.github.com/Nexus-Mods/7z-bin/tar.gz/3298c42e69e3220dc39694bc2f610c077c3e213a} + resolution: {gitHosted: true, integrity: sha512-5CSzuhfDSqMUAz2sHpwgD8sUjg+VLqp30xeSyPnLQvWX9gLTxG5Sk6CkF0ckuDGnbE1zHduU2DvSCAl7CxQZZw==, tarball: https://codeload.github.com/Nexus-Mods/7z-bin/tar.gz/3298c42e69e3220dc39694bc2f610c077c3e213a} version: 24.0.1 hasBin: true @@ -5905,8 +5936,8 @@ packages: react: ^16 || ^17 || ^18 react-dom: ^16 || ^17 || ^18 - '@hot-updater/bsdiff@0.30.6': - resolution: {integrity: sha512-Ux8WyYsYXlU13TahGCHHa3EQpfDQukGm0REFQEz7IxRXYFJf0+oOB0k8e+j+V9j53WXbUFdoRq+FIB+gMC2iLQ==} + '@hot-updater/bsdiff@0.30.11': + resolution: {integrity: sha512-CfJUs2eM2NEL8UoscBTAR4ohKE9eSUPRuOWSvoh3U/Ms9DKzl6FpdkVaipn04//4nVmbrNbYk8skCNglJvJ9Nw==} '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -6053,7 +6084,7 @@ packages: engines: {node: '>=22'} '@nexusmods/nexus-api@https://codeload.github.com/Nexus-Mods/node-nexus-api/tar.gz/2d92fd2bdc4aa6b9813a1e7043d412e13f4aa1d7': - resolution: {gitHosted: true, tarball: https://codeload.github.com/Nexus-Mods/node-nexus-api/tar.gz/2d92fd2bdc4aa6b9813a1e7043d412e13f4aa1d7} + resolution: {gitHosted: true, integrity: sha512-CtyiIxCnPh3XOAxvdfyXef9JWRpq3Pf5o3upK11nuYcQejVixfuCuFut/qzAgboGvWj5rsAp0Czdcc/7PP+DGw==, tarball: https://codeload.github.com/Nexus-Mods/node-nexus-api/tar.gz/2d92fd2bdc4aa6b9813a1e7043d412e13f4aa1d7} version: 1.6.1 '@nodelib/fs.scandir@2.1.5': @@ -6136,15 +6167,15 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/context-zone-peer-dep@2.7.0': - resolution: {integrity: sha512-DNcbXhV3G7ySz1HbHP0CdJpxE1WaTlzzY20Yzg7xFkitjxAagnYkpD2NfJ3+GBhNa91TuO92vu7jK1S4p+Yckw==} + '@opentelemetry/context-zone-peer-dep@2.7.1': + resolution: {integrity: sha512-QPLvl82Ds+W9Tjz0s4b8UDUK9YkCb3pvaur4JQdgHe+eph6Ii20NbiC+wsdnBtG17DTPhmZcFvWMcQXZFBgeVw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' zone.js: ^0.10.2 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0 - '@opentelemetry/context-zone@2.7.0': - resolution: {integrity: sha512-rPgJHtBIPX8Vcuo4wVmrwokt61nAGSs+i/ffDsE8Tpm4i5LI8LRMgm2lZ+gADuEdiHK77rbCvARuCBw6ZbHgUg==} + '@opentelemetry/context-zone@2.7.1': + resolution: {integrity: sha512-B42kO3zIMVbJ+wj5nlSkDvLF8cJY+7wDKLomHp10GL00nvUnhY67UQ/soZQgKR4dvPf8zTKbcONDsOiJLyRuXw==} engines: {node: ^18.19.0 || >=20.6.0} '@opentelemetry/core@1.30.1': @@ -6480,6 +6511,16 @@ packages: '@react-dnd/shallowequal@2.0.0': resolution: {integrity: sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==} + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.11': + resolution: {integrity: sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rolldown/binding-android-arm64@1.0.0-rc.11': resolution: {integrity: sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7315,8 +7356,8 @@ packages: '@types/node@16.18.126': resolution: {integrity: sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==} - '@types/node@24.12.2': - resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} @@ -7867,6 +7908,10 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: @@ -8133,7 +8178,7 @@ packages: hasBin: true bbcode-to-react@https://codeload.github.com/TanninOne/bbcode-to-react/tar.gz/c67356006470e5066ea447e04a3968dca367339d: - resolution: {gitHosted: true, tarball: https://codeload.github.com/TanninOne/bbcode-to-react/tar.gz/c67356006470e5066ea447e04a3968dca367339d} + resolution: {gitHosted: true, integrity: sha512-JCnCmMGZ0KRA5ZNnt0/fSA925S+tEiod6UwS+h5G2veMJHWhNMRv26udPcQ4Yj6EBG593dE+jbbfm22cN9K06Q==, tarball: https://codeload.github.com/TanninOne/bbcode-to-react/tar.gz/c67356006470e5066ea447e04a3968dca367339d} version: 0.2.11 peerDependencies: react: ^0.14.0 || ^15.0.0 || ^16.0.0 @@ -8196,7 +8241,7 @@ packages: resolution: {integrity: sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==} bsatk@https://codeload.github.com/Nexus-Mods/node-bsatk/tar.gz/5a3d15fae2177bfb0a42b794d3afb21eda563c59: - resolution: {gitHosted: true, tarball: https://codeload.github.com/Nexus-Mods/node-bsatk/tar.gz/5a3d15fae2177bfb0a42b794d3afb21eda563c59} + resolution: {gitHosted: true, integrity: sha512-x0ygf1FRt9qc22ffqKoSpwZwct/dNw8CVh3R9Djiyi3OzmGf1soOdWwFXwysHC3JEkSoeXKV0EXzPeGdOG8Q4Q==, tarball: https://codeload.github.com/Nexus-Mods/node-bsatk/tar.gz/5a3d15fae2177bfb0a42b794d3afb21eda563c59} version: 2.1.0 os: [win32, linux] @@ -8285,6 +8330,9 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + character-entities-legacy@1.1.4: resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==} @@ -8390,6 +8438,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -8849,7 +8900,7 @@ packages: resolution: {integrity: sha512-HHHLPEPZqRXIDQDFRFdK7RONZausNlJ4WkA73ST7Z6O2HPWttxFHVwHo8nccuDLzXWwiVKRVuc6fTkW+CQA++A==} drivelist@https://codeload.github.com/TanninOne/drivelist/tar.gz/720d1890db11482ec05fc0f6aa176cfa6e6844dd: - resolution: {gitHosted: true, tarball: https://codeload.github.com/TanninOne/drivelist/tar.gz/720d1890db11482ec05fc0f6aa176cfa6e6844dd} + resolution: {gitHosted: true, integrity: sha512-hhu4lTOqu9NsxCdlmBU6IiG5q4CmfTIEaREKilE53AxTdAqaJeV8TFn/gds8i0MCw4KXD66y/scwlIRu2L/yAQ==, tarball: https://codeload.github.com/TanninOne/drivelist/tar.gz/720d1890db11482ec05fc0f6aa176cfa6e6844dd} version: 10.0.2 engines: {node: '>=10'} @@ -8913,7 +8964,7 @@ packages: resolution: {integrity: sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==} electron-redux@https://codeload.github.com/TanninOne/electron-redux/tar.gz/66bbd9d389579806e8c4ebd87bd513a668cc64a8: - resolution: {gitHosted: true, tarball: https://codeload.github.com/TanninOne/electron-redux/tar.gz/66bbd9d389579806e8c4ebd87bd513a668cc64a8} + resolution: {gitHosted: true, integrity: sha512-3JEjTg+Sj6ABicP0k7xhE0xNyC2Dc6oTdhPWrHxSrCY7PXGHFBfubaaD4c0u+GfKxhsSxDBOT+GqywBXjVzc9w==, tarball: https://codeload.github.com/TanninOne/electron-redux/tar.gz/66bbd9d389579806e8c4ebd87bd513a668cc64a8} version: 1.4.9-sync electron-to-chromium@1.5.325: @@ -9507,8 +9558,8 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.5.0: - resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} get-intrinsic@1.3.0: @@ -9716,6 +9767,10 @@ packages: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -9796,6 +9851,10 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -10099,6 +10158,10 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -10139,7 +10202,7 @@ packages: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} json-socket@https://codeload.github.com/foi/node-json-socket/tar.gz/d56c8e2938fa4284c4001b815d9b6e4a92b5c07b: - resolution: {gitHosted: true, tarball: https://codeload.github.com/foi/node-json-socket/tar.gz/d56c8e2938fa4284c4001b815d9b6e4a92b5c07b} + resolution: {gitHosted: true, integrity: sha512-UQ1HXQfzmW4YvvKaMVnZa0eTtr9y0fkvNBLFSQT7ulOm+U2231U+7Pw+kM7k3AzB6lBT7s9nzK1avEOFqctxUQ==, tarball: https://codeload.github.com/foi/node-json-socket/tar.gz/d56c8e2938fa4284c4001b815d9b6e4a92b5c07b} version: 0.3.0 json-stable-stringify-without-jsonify@1.0.1: @@ -10453,7 +10516,7 @@ packages: hasBin: true loot@https://codeload.github.com/Nexus-Mods/node-loot/tar.gz/7b6028fb2caeb3a2af6a0c3d68630c033cc01e8d: - resolution: {gitHosted: true, tarball: https://codeload.github.com/Nexus-Mods/node-loot/tar.gz/7b6028fb2caeb3a2af6a0c3d68630c033cc01e8d} + resolution: {gitHosted: true, integrity: sha512-/bqsW+y5n7JJNUiclZpvIFxT4DWVa7PXnQLHpiWQZvQ4FB1zW1XbC8brJTh6SAebh9E8MyUEnst/jQZhJ1hwtA==, tarball: https://codeload.github.com/Nexus-Mods/node-loot/tar.gz/7b6028fb2caeb3a2af6a0c3d68630c033cc01e8d} version: 6.2.2 lowercase-keys@3.0.0: @@ -10674,7 +10737,7 @@ packages: engines: {node: '>=0.10.0'} modmeta-db@https://codeload.github.com/Nexus-Mods/modmeta-db/tar.gz/daa8935b6e38e255ec192c908adfce35d47c0336: - resolution: {gitHosted: true, tarball: https://codeload.github.com/Nexus-Mods/modmeta-db/tar.gz/daa8935b6e38e255ec192c908adfce35d47c0336} + resolution: {gitHosted: true, integrity: sha512-TCtHcETebXwNlKz4h4sSbod4aUCycvS8J9mlknKJFhCxJZoU3VmZUzKYi7GHe6ERTMM5dYZc8FBePy72Jgu/Vw==, tarball: https://codeload.github.com/Nexus-Mods/modmeta-db/tar.gz/daa8935b6e38e255ec192c908adfce35d47c0336} version: 0.9.3 peerDependencies: commander: ^6.1.0 @@ -10734,7 +10797,7 @@ packages: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} node-7z@https://codeload.github.com/Nexus-Mods/node-7z/tar.gz/b75def8d0d7d81a03f4526c52b8ada9a34a06479: - resolution: {gitHosted: true, tarball: https://codeload.github.com/Nexus-Mods/node-7z/tar.gz/b75def8d0d7d81a03f4526c52b8ada9a34a06479} + resolution: {gitHosted: true, integrity: sha512-hFgO6i7T1ZGj3QwgI4OhpbevPJRQI6DBJq1h2VFxPnyO6Nx9NLTu76EmokUDqUMo0ei7IqMOwwD1soEC3IqWUQ==, tarball: https://codeload.github.com/Nexus-Mods/node-7z/tar.gz/b75def8d0d7d81a03f4526c52b8ada9a34a06479} version: 0.8.1 node-abi@3.89.0: @@ -11017,6 +11080,18 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + openapi-fetch@0.14.1: + resolution: {integrity: sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==} + + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -11097,6 +11172,10 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -11153,7 +11232,7 @@ packages: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} permissions@https://codeload.github.com/Nexus-Mods/node-permissions/tar.gz/7c1b6f1d6437f2238be51316de823b0fbd63e4c0: - resolution: {gitHosted: true, tarball: https://codeload.github.com/Nexus-Mods/node-permissions/tar.gz/7c1b6f1d6437f2238be51316de823b0fbd63e4c0} + resolution: {gitHosted: true, integrity: sha512-fhaMTn3oR2R31Xpp2Prb849j4tVh2Skav4IskygXGCQquP1ZgqrwrkvT9krDywxY85gRi5QGYdKN/9uysrgTCg==, tarball: https://codeload.github.com/Nexus-Mods/node-permissions/tar.gz/7c1b6f1d6437f2238be51316de823b0fbd63e4c0} version: 2.1.0 picocolors@1.1.1: @@ -11202,6 +11281,10 @@ packages: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + popper.js@1.16.1: resolution: {integrity: sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==} deprecated: You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1 @@ -11744,7 +11827,7 @@ packages: hasBin: true rimraf@https://codeload.github.com/TanninOne/rimraf/tar.gz/7b8b70d4e8783cd233fca3283cf1f930af4e39c2: - resolution: {gitHosted: true, tarball: https://codeload.github.com/TanninOne/rimraf/tar.gz/7b8b70d4e8783cd233fca3283cf1f930af4e39c2} + resolution: {gitHosted: true, integrity: sha512-bUluJfpyNMy+q3xCiB5KjLVxPl+MHfS2qJj0eZupLvo9+PlLgoDnO37hkj+wnofxEhstXq+d10XbGIkyyjHmKA==, tarball: https://codeload.github.com/TanninOne/rimraf/tar.gz/7b8b70d4e8783cd233fca3283cf1f930af4e39c2} version: 2.6.2 hasBin: true @@ -11953,7 +12036,7 @@ packages: engines: {node: '>=10'} simple-vdf@https://codeload.github.com/Nexus-Mods/vdf-parser/tar.gz/df279ff89cb480597544d3029e12f90cb8c79464: - resolution: {gitHosted: true, tarball: https://codeload.github.com/Nexus-Mods/vdf-parser/tar.gz/df279ff89cb480597544d3029e12f90cb8c79464} + resolution: {gitHosted: true, integrity: sha512-H52FVQ84mJle6bqHMDCRpQMFjzgdJMdJznliEG41ZZvhsGZTK1RaJPYWrkx+ce7+zRqNjvCUMWo3b7vEVTZ1PA==, tarball: https://codeload.github.com/Nexus-Mods/vdf-parser/tar.gz/df279ff89cb480597544d3029e12f90cb8c79464} version: 1.1.3 sirv@3.0.2: @@ -12141,6 +12224,10 @@ packages: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -12383,7 +12470,7 @@ packages: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} turbowalk@https://codeload.github.com/Nexus-Mods/node-turbowalk/tar.gz/3502f6ffc3f9eb55fe1c9c097b4e4772edce0c0f: - resolution: {gitHosted: true, tarball: https://codeload.github.com/Nexus-Mods/node-turbowalk/tar.gz/3502f6ffc3f9eb55fe1c9c097b4e4772edce0c0f} + resolution: {gitHosted: true, integrity: sha512-GPt3rDAkgrD72xxVLWKVXuLhcTP0Wg6Pt3S8t97WZ9S9O1ifZ2QOoMhLocKStHTkLlAEkisnB81QidQ/PxsdZA==, tarball: https://codeload.github.com/Nexus-Mods/node-turbowalk/tar.gz/3502f6ffc3f9eb55fe1c9c097b4e4772edce0c0f} version: 3.1.1 tweetnacl@0.14.5: @@ -12393,6 +12480,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-fest@5.5.0: resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} engines: {node: '>=20'} @@ -12582,6 +12673,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -12716,7 +12810,7 @@ packages: engines: {node: '>=0.10.0'} vortex-parse-ini@https://codeload.github.com/Nexus-Mods/vortex-parse-ini/tar.gz/2425af99d1cff2331ccf3aacfa892c314e99e18d: - resolution: {gitHosted: true, tarball: https://codeload.github.com/Nexus-Mods/vortex-parse-ini/tar.gz/2425af99d1cff2331ccf3aacfa892c314e99e18d} + resolution: {gitHosted: true, integrity: sha512-iB8Btg2VWED+nYlOqyt0xFoGbZIbQkiAny3J1ftI9IcbKwQweEfQ0uTYgXZZba9p6O8EaTCLXBvRe/m/uMUCqg==, tarball: https://codeload.github.com/Nexus-Mods/vortex-parse-ini/tar.gz/2425af99d1cff2331ccf3aacfa892c314e99e18d} version: 0.4.0 warning@3.0.0: @@ -12812,7 +12906,7 @@ packages: hasBin: true wholocks@https://codeload.github.com/Nexus-Mods/node-wholocks/tar.gz/28da3bcf312312e577d7c636799a59011998b4af: - resolution: {gitHosted: true, tarball: https://codeload.github.com/Nexus-Mods/node-wholocks/tar.gz/28da3bcf312312e577d7c636799a59011998b4af} + resolution: {gitHosted: true, integrity: sha512-lWy1M2tsoySZShiQZrfFKJi02k3y2Cx1XahGB5Tz5AI2P166F0o4d3lpUM81/AQsTV8NfRdhHNrNsjYst9H3Ww==, tarball: https://codeload.github.com/Nexus-Mods/node-wholocks/tar.gz/28da3bcf312312e577d7c636799a59011998b4af} version: 1.1.0 why-is-node-running@2.3.0: @@ -12827,7 +12921,7 @@ packages: resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} winapi-bindings@https://codeload.github.com/Nexus-Mods/node-winapi-bindings/tar.gz/faa92afe3320731e98abc15b3f5f19c60896d7c1: - resolution: {gitHosted: true, tarball: https://codeload.github.com/Nexus-Mods/node-winapi-bindings/tar.gz/faa92afe3320731e98abc15b3f5f19c60896d7c1} + resolution: {gitHosted: true, integrity: sha512-6EWxHP64L7nc/VBb/szJflKBvG2VnZ5cSd//R8ocau7UgrtMINm7MMHxcw8+EX9Z4BU+L0dF4rz6yQpwt5/Alw==, tarball: https://codeload.github.com/Nexus-Mods/node-winapi-bindings/tar.gz/faa92afe3320731e98abc15b3f5f19c60896d7c1} version: 2.7.3 winston@2.4.7: @@ -12917,13 +13011,16 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + yaml@2.8.0: resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} engines: {node: '>= 14.6'} hasBin: true - yaml@2.8.4: - resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true @@ -12961,8 +13058,8 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - zone.js@0.15.1: - resolution: {integrity: sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==} + zone.js@0.16.2: + resolution: {integrity: sha512-Eky7p2Z1Ig3NnbfodSPoARCjKBSTFMnE/ACsP1L/XJEfY4SdOFce19BsUCWVwL6K5ABZFy5J3bjcMWffX+YM3Q==} snapshots: @@ -12995,7 +13092,7 @@ snapshots: '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -13056,7 +13153,7 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) lodash.debounce: 4.0.8 resolve: 1.22.11 transitivePeerDependencies: @@ -13725,7 +13822,7 @@ snapshots: '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -13795,7 +13892,7 @@ snapshots: '@electron/get@5.0.0': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) env-paths: 3.0.0 graceful-fs: 4.2.11 progress: 2.0.3 @@ -13808,7 +13905,7 @@ snapshots: '@electron/notarize@2.2.1': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 9.1.0 promise-retry: 2.0.1 transitivePeerDependencies: @@ -13817,7 +13914,7 @@ snapshots: '@electron/osx-sign@1.0.5': dependencies: compare-version: 0.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 10.1.0 isbinaryfile: 4.0.10 minimist: 1.2.8 @@ -13828,7 +13925,7 @@ snapshots: '@electron/rebuild@4.0.4': dependencies: '@malept/cross-spawn-promise': 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) node-abi: 4.31.0 node-api-version: 0.2.1 node-gyp: 12.3.0 @@ -13844,7 +13941,7 @@ snapshots: dependencies: '@electron/asar': 3.4.1 '@malept/cross-spawn-promise': 1.1.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) dir-compare: 3.3.0 fs-extra: 9.1.0 minimatch: 3.1.5 @@ -14041,7 +14138,7 @@ snapshots: '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -14062,7 +14159,7 @@ snapshots: '@eslint/eslintrc@3.3.5': dependencies: ajv: 6.14.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -14089,7 +14186,7 @@ snapshots: react: 16.14.0 react-dom: 16.14.0(react@16.14.0) - '@hot-updater/bsdiff@0.30.6': {} + '@hot-updater/bsdiff@0.30.11': {} '@humanfs/core@0.19.1': {} @@ -14160,7 +14257,7 @@ snapshots: '@kwsites/file-exists@1.1.1': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -14176,7 +14273,7 @@ snapshots: '@malept/flatpak-bundler@0.4.0': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 9.1.0 lodash: 4.17.23 tmp-promise: 3.0.3 @@ -14185,11 +14282,11 @@ snapshots: '@mdi/js@7.4.47': {} - '@microsoft/api-extractor-model@7.33.4(@types/node@24.12.2)': + '@microsoft/api-extractor-model@7.33.4(@types/node@24.12.4)': dependencies: '@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc-config': 0.18.1 - '@rushstack/node-core-library': 5.20.3(@types/node@24.12.2) + '@rushstack/node-core-library': 5.20.3(@types/node@24.12.4) transitivePeerDependencies: - '@types/node' @@ -14201,15 +14298,15 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@microsoft/api-extractor@7.57.7(@types/node@24.12.2)': + '@microsoft/api-extractor@7.57.7(@types/node@24.12.4)': dependencies: - '@microsoft/api-extractor-model': 7.33.4(@types/node@24.12.2) + '@microsoft/api-extractor-model': 7.33.4(@types/node@24.12.4) '@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc-config': 0.18.1 - '@rushstack/node-core-library': 5.20.3(@types/node@24.12.2) + '@rushstack/node-core-library': 5.20.3(@types/node@24.12.4) '@rushstack/rig-package': 0.7.2 - '@rushstack/terminal': 0.22.3(@types/node@24.12.2) - '@rushstack/ts-command-line': 5.3.3(@types/node@24.12.2) + '@rushstack/terminal': 0.22.3(@types/node@24.12.4) + '@rushstack/ts-command-line': 5.3.3(@types/node@24.12.4) diff: 8.0.4 lodash: 4.17.23 minimatch: 10.2.3 @@ -14365,15 +14462,15 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/context-zone-peer-dep@2.7.0(@opentelemetry/api@1.9.1)(zone.js@0.15.1)': + '@opentelemetry/context-zone-peer-dep@2.7.1(@opentelemetry/api@1.9.1)(zone.js@0.16.2)': dependencies: '@opentelemetry/api': 1.9.1 - zone.js: 0.15.1 + zone.js: 0.16.2 - '@opentelemetry/context-zone@2.7.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/context-zone@2.7.1(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/context-zone-peer-dep': 2.7.0(@opentelemetry/api@1.9.1)(zone.js@0.15.1) - zone.js: 0.15.1 + '@opentelemetry/context-zone-peer-dep': 2.7.1(@opentelemetry/api@1.9.1)(zone.js@0.16.2) + zone.js: 0.16.2 transitivePeerDependencies: - '@opentelemetry/api' @@ -14605,6 +14702,29 @@ snapshots: '@react-dnd/shallowequal@2.0.0': {} + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.11(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.1.1 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + '@rolldown/binding-android-arm64@1.0.0-rc.11': optional: true @@ -14799,7 +14919,7 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@rushstack/node-core-library@5.20.3(@types/node@24.12.2)': + '@rushstack/node-core-library@5.20.3(@types/node@24.12.4)': dependencies: ajv: 8.18.0 ajv-draft-04: 1.0.0(ajv@8.18.0) @@ -14810,7 +14930,7 @@ snapshots: resolve: 1.22.11 semver: 7.5.4 optionalDependencies: - '@types/node': 24.12.2 + '@types/node': 24.12.4 '@rushstack/node-core-library@5.20.3(@types/node@25.5.0)': dependencies: @@ -14825,9 +14945,9 @@ snapshots: optionalDependencies: '@types/node': 25.5.0 - '@rushstack/problem-matcher@0.2.1(@types/node@24.12.2)': + '@rushstack/problem-matcher@0.2.1(@types/node@24.12.4)': optionalDependencies: - '@types/node': 24.12.2 + '@types/node': 24.12.4 '@rushstack/problem-matcher@0.2.1(@types/node@25.5.0)': optionalDependencies: @@ -14838,13 +14958,13 @@ snapshots: resolve: 1.22.11 strip-json-comments: 3.1.1 - '@rushstack/terminal@0.22.3(@types/node@24.12.2)': + '@rushstack/terminal@0.22.3(@types/node@24.12.4)': dependencies: - '@rushstack/node-core-library': 5.20.3(@types/node@24.12.2) - '@rushstack/problem-matcher': 0.2.1(@types/node@24.12.2) + '@rushstack/node-core-library': 5.20.3(@types/node@24.12.4) + '@rushstack/problem-matcher': 0.2.1(@types/node@24.12.4) supports-color: 8.1.1 optionalDependencies: - '@types/node': 24.12.2 + '@types/node': 24.12.4 '@rushstack/terminal@0.22.3(@types/node@25.5.0)': dependencies: @@ -14854,9 +14974,9 @@ snapshots: optionalDependencies: '@types/node': 25.5.0 - '@rushstack/ts-command-line@5.3.3(@types/node@24.12.2)': + '@rushstack/ts-command-line@5.3.3(@types/node@24.12.4)': dependencies: - '@rushstack/terminal': 0.22.3(@types/node@24.12.2) + '@rushstack/terminal': 0.22.3(@types/node@24.12.4) '@types/argparse': 1.0.38 argparse: 1.0.10 string-argv: 0.3.2 @@ -15315,7 +15435,7 @@ snapshots: '@types/node@16.18.126': {} - '@types/node@24.12.2': + '@types/node@24.12.4': dependencies: undici-types: 7.16.0 @@ -15513,7 +15633,7 @@ snapshots: '@typescript-eslint/types': 8.47.0 '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.47.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -15523,7 +15643,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) '@typescript-eslint/types': 8.57.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -15532,7 +15652,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) '@typescript-eslint/types': 8.57.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -15560,7 +15680,7 @@ snapshots: '@typescript-eslint/types': 8.47.0 '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) '@typescript-eslint/utils': 8.47.0(eslint@9.39.1)(typescript@5.9.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -15572,7 +15692,7 @@ snapshots: '@typescript-eslint/types': 8.57.2 '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) '@typescript-eslint/utils': 8.57.2(eslint@9.39.1)(typescript@5.9.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -15589,7 +15709,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.47.0(typescript@5.9.3) '@typescript-eslint/types': 8.47.0 '@typescript-eslint/visitor-keys': 8.47.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.9 @@ -15605,7 +15725,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) '@typescript-eslint/types': 8.57.2 '@typescript-eslint/visitor-keys': 8.57.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 @@ -15712,7 +15832,7 @@ snapshots: '@vitejs/plugin-react@6.0.1(vite@8.0.3)': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.3(@types/node@25.5.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.1)(tsx@4.22.0)(yaml@2.8.4) + vite: 8.0.3(@types/node@25.5.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.1)(tsx@4.22.0)(yaml@2.9.0) '@vitest/coverage-v8@4.1.2(vitest@4.1.2)': dependencies: @@ -15726,7 +15846,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/ui@4.1.0)(happy-dom@20.8.8)(vite@8.0.3) + vitest: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/ui@4.1.0)(happy-dom@20.8.8)(vite@8.0.3) '@vitest/expect@4.1.2': dependencies: @@ -15743,7 +15863,7 @@ snapshots: estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.3(@types/node@24.12.2)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.1)(tsx@4.22.0)(yaml@2.8.4) + vite: 8.0.3(@types/node@24.12.4)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.1)(tsx@4.22.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.0': dependencies: @@ -15776,7 +15896,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vitest: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/ui@4.1.0)(happy-dom@20.8.8)(vite@8.0.3) + vitest: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/ui@4.1.0)(happy-dom@20.8.8)(vite@8.0.3) '@vitest/utils@4.1.0': dependencies: @@ -15937,10 +16057,12 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color + agent-base@7.1.4: {} + ajv-draft-04@1.0.0(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -16017,7 +16139,7 @@ snapshots: builder-util: 24.13.1 builder-util-runtime: 9.2.4 chromium-pickle-js: 0.2.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3) ejs: 3.1.10 electron-builder-squirrel-windows: 24.13.3(dmg-builder@24.13.3) @@ -16359,14 +16481,14 @@ snapshots: builder-util-runtime@8.9.2: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) sax: 1.6.0 transitivePeerDependencies: - supports-color builder-util-runtime@9.2.4: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) sax: 1.6.0 transitivePeerDependencies: - supports-color @@ -16380,7 +16502,7 @@ snapshots: builder-util-runtime: 9.2.4 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 10.1.0 http-proxy-agent: 5.0.0 https-proxy-agent: 5.0.1 @@ -16448,6 +16570,8 @@ snapshots: chalk@5.6.2: {} + change-case@5.4.4: {} + character-entities-legacy@1.1.4: {} character-entities@1.2.4: {} @@ -16538,6 +16662,8 @@ snapshots: color-name@1.1.4: {} + colorette@1.4.0: {} + colorette@2.0.20: {} colors@1.0.3: {} @@ -16877,9 +17003,11 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 decimal.js-light@2.5.1: {} @@ -17184,7 +17312,7 @@ snapshots: electron@42.0.0: dependencies: '@electron/get': 5.0.0 - '@types/node': 24.12.2 + '@types/node': 24.12.4 extract-zip: 2.0.1 transitivePeerDependencies: - supports-color @@ -17395,7 +17523,7 @@ snapshots: eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.1(jiti@2.6.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) get-tsconfig: 4.13.7 @@ -17599,7 +17727,7 @@ snapshots: ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -17732,7 +17860,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -18001,7 +18129,7 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.5.0: {} + get-east-asian-width@1.6.0: {} get-intrinsic@1.3.0: dependencies: @@ -18220,7 +18348,7 @@ snapshots: dependencies: '@tootallnate/once': 2.0.0 agent-base: 6.0.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -18238,7 +18366,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -18302,6 +18437,8 @@ snapshots: indent-string@4.0.0: {} + index-to-position@1.2.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -18422,7 +18559,7 @@ snapshots: is-fullwidth-code-point@5.1.0: dependencies: - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 is-generator-function@1.1.2: dependencies: @@ -18579,6 +18716,8 @@ snapshots: jju@1.4.0: {} + js-levenshtein@1.1.6: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -18801,14 +18940,14 @@ snapshots: dependencies: chalk: 5.6.2 commander: 13.1.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.3 micromatch: 4.0.8 pidtree: 0.6.0 string-argv: 0.3.2 - yaml: 2.8.4 + yaml: 2.9.0 transitivePeerDependencies: - supports-color @@ -19006,7 +19145,7 @@ snapshots: micromark@2.11.4: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) parse-entities: 2.0.0 transitivePeerDependencies: - supports-color @@ -19437,6 +19576,22 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openapi-fetch@0.14.1: + dependencies: + openapi-typescript-helpers: 0.0.15 + + openapi-typescript-helpers@0.0.15: {} + + openapi-typescript@7.13.0(typescript@5.9.3): + dependencies: + '@redocly/openapi-core': 1.34.11(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -19557,6 +19712,12 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.0 + index-to-position: 1.2.0 + type-fest: 4.41.0 + parseurl@1.3.3: {} path-exists@3.0.0: {} @@ -19631,6 +19792,8 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + pluralize@8.0.0: {} + popper.js@1.16.1: {} possible-typed-array-names@1.1.0: {} @@ -19866,7 +20029,7 @@ snapshots: transitivePeerDependencies: - '@types/react' - react-dnd@14.0.5(@types/hoist-non-react-statics@3.3.7)(@types/node@24.12.2)(@types/react@16.14.69)(react@16.14.0): + react-dnd@14.0.5(@types/hoist-non-react-statics@3.3.7)(@types/node@24.12.4)(@types/react@16.14.69)(react@16.14.0): dependencies: '@react-dnd/invariant': 2.0.0 '@react-dnd/shallowequal': 2.0.0 @@ -19876,7 +20039,7 @@ snapshots: react: 16.14.0 optionalDependencies: '@types/hoist-non-react-statics': 3.3.7(@types/react@16.14.69) - '@types/node': 24.12.2 + '@types/node': 24.12.4 '@types/react': 16.14.69 react-dnd@14.0.5(@types/hoist-non-react-statics@3.3.7)(@types/node@25.5.0)(@types/react@16.14.69)(react@16.14.0): @@ -20106,7 +20269,7 @@ snapshots: read-binary-file-arch@1.0.6: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -20656,7 +20819,7 @@ snapshots: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -20778,7 +20941,7 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 string.fromcodepoint@0.2.1: {} @@ -20850,10 +21013,12 @@ snapshots: sumchecker@3.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color + supports-color@10.2.2: {} + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -21109,6 +21274,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@4.41.0: {} + type-fest@5.5.0: dependencies: tagged-tag: 1.0.0 @@ -21271,7 +21438,7 @@ snapshots: universal-analytics@0.4.23: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) request: 2.88.2 uuid: 3.4.0 transitivePeerDependencies: @@ -21332,6 +21499,8 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js-replace@1.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -21378,10 +21547,10 @@ snapshots: vite-plugin-doctest@2.0.0(typescript@5.9.3)(vite@8.0.3)(vitest@4.1.2): dependencies: typescript: 5.9.3 - vite: 8.0.3(@types/node@24.12.2)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.1)(tsx@4.22.0)(yaml@2.8.4) - vitest: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/ui@4.1.0)(happy-dom@20.8.8)(vite@8.0.3) + vite: 8.0.3(@types/node@24.12.4)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.1)(tsx@4.22.0)(yaml@2.9.0) + vitest: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/ui@4.1.0)(happy-dom@20.8.8)(vite@8.0.3) - vite@8.0.3(@types/node@24.12.2)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.1)(tsx@4.22.0)(yaml@2.8.4): + vite@8.0.3(@types/node@24.12.4)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.1)(tsx@4.22.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -21389,15 +21558,15 @@ snapshots: rolldown: 1.0.0-rc.12 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.12.2 + '@types/node': 24.12.4 fsevents: 2.3.3 jiti: 2.6.1 sass: 1.97.3 terser: 5.46.1 tsx: 4.22.0 - yaml: 2.8.4 + yaml: 2.9.0 - vite@8.0.3(@types/node@25.5.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.1)(tsx@4.22.0)(yaml@2.8.4): + vite@8.0.3(@types/node@25.5.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.1)(tsx@4.22.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -21411,9 +21580,9 @@ snapshots: sass: 1.97.3 terser: 5.46.1 tsx: 4.22.0 - yaml: 2.8.4 + yaml: 2.9.0 - vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/ui@4.1.0)(happy-dom@20.8.8)(vite@8.0.3): + vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/ui@4.1.0)(happy-dom@20.8.8)(vite@8.0.3): dependencies: '@vitest/expect': 4.1.2 '@vitest/mocker': 4.1.2(vite@8.0.3) @@ -21433,11 +21602,11 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 8.0.3(@types/node@24.12.2)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.1)(tsx@4.22.0)(yaml@2.8.4) + vite: 8.0.3(@types/node@24.12.4)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.1)(tsx@4.22.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 - '@types/node': 24.12.2 + '@types/node': 24.12.4 '@vitest/ui': 4.1.0(vitest@4.1.2) happy-dom: 20.8.8 transitivePeerDependencies: @@ -21463,7 +21632,7 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 8.0.3(@types/node@25.5.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.1)(tsx@4.22.0)(yaml@2.8.4) + vite: 8.0.3(@types/node@25.5.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.1)(tsx@4.22.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -21709,9 +21878,11 @@ snapshots: yallist@5.0.0: {} + yaml-ast-parser@0.0.43: {} + yaml@2.8.0: {} - yaml@2.8.4: {} + yaml@2.9.0: {} yargs-parser@20.2.9: {} @@ -21754,4 +21925,4 @@ snapshots: zod@4.3.6: {} - zone.js@0.15.1: {} + zone.js@0.16.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index aa61c44368..c6c7b1e184 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -284,6 +284,8 @@ catalog: xml2js: ^0.5.0 xxhash-addon: ^2.0.3 zod: ^4.3.5 + openapi-fetch: ^0.14.0 + openapi-typescript: ^7.8.0 blockExoticSubdeps: false catalogMode: strict diff --git a/src/renderer/package.json b/src/renderer/package.json index 0a25d5e678..7f56be2d8c 100644 --- a/src/renderer/package.json +++ b/src/renderer/package.json @@ -51,6 +51,7 @@ "@nexusmods/fomod-installer-ipc": "catalog:", "@nexusmods/fomod-installer-native": "catalog:", "@nexusmods/nexus-api": "catalog:", + "@vortex/nexus-api-v3": "workspace:*", "@opentelemetry/api": "catalog:", "@opentelemetry/context-zone": "catalog:", "@opentelemetry/resources": "catalog:", diff --git a/src/renderer/src/extensions/nexus_integration/constants.ts b/src/renderer/src/extensions/nexus_integration/constants.ts index 02b1c58cb0..6cab9f1f0b 100644 --- a/src/renderer/src/extensions/nexus_integration/constants.ts +++ b/src/renderer/src/extensions/nexus_integration/constants.ts @@ -8,6 +8,10 @@ export const NEXUS_USERS_SUBDOMAIN = process.env["USERS_SUBDOMAIN"] || "users"; export const NEXUS_BASE_URL = process.env["NEXUS_BASE_URL"] || `https://${NEXUS_FLAMEWORK_SUBDOMAIN}.${NEXUS_DOMAIN}`; export const NEXUS_GAMES_URL = process.env["NEXUS_GAMES_URL"] || `https://${NEXUS_DOMAIN}/games`; +export const NEXUS_V3_BASE_URL = + process.env["NEXUS_V3_BASE_URL"] || `https://${NEXUS_API_SUBDOMAIN}.${NEXUS_DOMAIN}/v3`; +export const MULTIPART_THRESHOLD = 100 * 1024 * 1024; // 100 MiB + export const NEXUS_PROTOCOL = "https:"; export const PREMIUM_PATH = ["account", "billing", "premium"]; export const FALLBACK_AVATAR = "assets/images/noavatar.png"; diff --git a/src/renderer/src/extensions/nexus_integration/eventHandlers.ts b/src/renderer/src/extensions/nexus_integration/eventHandlers.ts index ea478160d9..6cc8db0f04 100644 --- a/src/renderer/src/extensions/nexus_integration/eventHandlers.ts +++ b/src/renderer/src/extensions/nexus_integration/eventHandlers.ts @@ -83,6 +83,7 @@ import { import type { ITokenReply } from "./util/oauth"; import submitFeedback from "./util/submitFeedback"; import { makeModUID } from "./util/UIDs"; +import { submitCollectionV3 } from "./util_v3/submitCollectionV3"; export function onChangeDownloads(api: IExtensionApi, nexus: Nexus) { const state: IState = api.store.getState(); @@ -1183,52 +1184,21 @@ export function onSubmitFeedback(nexus: Nexus) { }; } -function sendCollection( - nexus: Nexus, - collectionInfo: ICollectionManifest, - collectionId: number, - uuid: string, -) { - if (collectionId === undefined) { - return nexus.createCollection( - { - adultContent: false, - collectionManifest: collectionInfo, - collectionSchemaId: 1, - }, - uuid, - ); - } else { - return nexus.editCollection(collectionId as any, collectionInfo.info.name).then(() => - nexus.createOrUpdateRevision( - { - adultContent: false, - collectionManifest: collectionInfo, - collectionSchemaId: 1, - }, - uuid, - collectionId, - ), - ); - } -} - -export function onSubmitCollection(nexus: Nexus) { +export function onSubmitCollection(api: IExtensionApi) { return ( collectionInfo: ICollectionManifest, assetFilePath: string, collectionId: number, + signal: AbortSignal | undefined, callback: (err: Error, response?: any) => void, ) => { - nexus - .getRevisionUploadUrl() - .then(({ url, uuid }) => { - return fs - .statAsync(assetFilePath) - .then((stat) => upload(url, fs.createReadStream(assetFilePath), stat.size)) - .then(() => uuid); - }) - .then((uuid: string) => sendCollection(nexus, collectionInfo, collectionId, uuid)) + submitCollectionV3( + api.getState(), + collectionInfo, + assetFilePath, + collectionId || undefined, + signal, + ) .then((response) => callback(null, response)) .catch((err) => callback(unknownToError(err))); }; diff --git a/src/renderer/src/extensions/nexus_integration/index.tsx b/src/renderer/src/extensions/nexus_integration/index.tsx index 62a0a883f3..1141eba781 100644 --- a/src/renderer/src/extensions/nexus_integration/index.tsx +++ b/src/renderer/src/extensions/nexus_integration/index.tsx @@ -1045,7 +1045,7 @@ function extendAPI(api: IExtensionApi, nexus: NexusT): INexusAPIExtension { nexusGetTrendingMods: eh.onGetTrendingMods(api, nexus), nexusEndorseMod: eh.onEndorseMod(api, nexus), nexusSubmitFeedback: eh.onSubmitFeedback(nexus), - nexusSubmitCollection: eh.onSubmitCollection(nexus), + nexusSubmitCollection: eh.onSubmitCollection(api), nexusModUpdate: eh.onModUpdate(api, nexus), nexusOpenCollectionPage: eh.onOpenCollectionPage(api), nexusOpenModPage: eh.onOpenModPage(api), @@ -1210,7 +1210,7 @@ function once(api: IExtensionApi, callbacks: Array<(nexus: NexusT) => void>) { api.events.on("refresh-user-info", eh.onRefreshUserInfo(nexus, api)); api.events.on("endorse-mod", eh.onEndorseMod(api, nexus)); api.events.on("submit-feedback", eh.onSubmitFeedback(nexus)); - api.events.on("submit-collection", eh.onSubmitCollection(nexus)); + api.events.on("submit-collection", eh.onSubmitCollection(api)); api.events.on("mods-update", eh.onModsUpdate(api, nexus)); api.events.on("mod-update", eh.onModUpdate(api, nexus)); api.events.on("open-collection-page", eh.onOpenCollectionPage(api)); diff --git a/src/renderer/src/extensions/nexus_integration/types/INexusAPIExtension.ts b/src/renderer/src/extensions/nexus_integration/types/INexusAPIExtension.ts index 9a0ad3186b..59b5b47531 100644 --- a/src/renderer/src/extensions/nexus_integration/types/INexusAPIExtension.ts +++ b/src/renderer/src/extensions/nexus_integration/types/INexusAPIExtension.ts @@ -71,6 +71,7 @@ export interface INexusAPIExtension { collectionInfo: ICollectionManifest, assetFilePath: string, collectionId: number, + signal: AbortSignal | undefined, callback: (err: Error, response?: any) => void, ) => void; nexusModUpdate?: (gameId: string, modId: number, fileId: number, source: string) => void; diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.test.ts b/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.test.ts new file mode 100644 index 0000000000..22d73b96d6 --- /dev/null +++ b/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.test.ts @@ -0,0 +1,207 @@ +import type { ICollectionManifest } from "@nexusmods/nexus-api"; +import { describe, it, expect } from "vitest"; + +import { toV3CollectionPayload } from "./manifestMapping"; + +const makeManifest = (overrides: Partial = {}): ICollectionManifest => ({ + info: overrides.info ?? { + author: "TestAuthor", + authorUrl: "https://nexusmods.com/users/123", + name: "Test Collection", + description: "A test collection", + summary: "Test summary", + domainName: "skyrimspecialedition", + gameVersions: ["1.6.640"], + }, + mods: overrides.mods ?? [ + { + name: "Test Mod", + version: "1.0.0", + optional: false, + domainName: "skyrimspecialedition", + source: { + type: "nexus", + modId: 12345, + fileId: 67890, + md5: "abc123", + fileSize: 1024, + updatePolicy: "exact", + logicalFilename: "testmod", + fileExpression: "testmod-{version}.zip", + }, + author: "ModAuthor", + }, + ], +}); + +describe("toV3CollectionPayload", () => { + it("converts info fields from camelCase to snake_case", () => { + const manifest = makeManifest(); + const payload = toV3CollectionPayload(manifest); + + expect(payload.collection_manifest.info).toEqual({ + author: "TestAuthor", + author_url: "https://nexusmods.com/users/123", + name: "Test Collection", + description: "A test collection", + summary: "Test summary", + domain_name: "skyrimspecialedition", + game_versions: ["1.6.640"], + }); + }); + + it("converts mod fields from camelCase to snake_case", () => { + const manifest = makeManifest(); + const payload = toV3CollectionPayload(manifest); + + expect(payload.collection_manifest.mods).toHaveLength(1); + expect(payload.collection_manifest.mods[0]).toEqual({ + name: "Test Mod", + version: "1.0.0", + optional: false, + domain_name: "skyrimspecialedition", + source: { + type: "nexus", + mod_id: "12345", + file_id: "67890", + md5: "abc123", + file_size: 1024, + update_policy: "exact", + logical_filename: "testmod", + file_expression: "testmod-{version}.zip", + url: null, + adult_content: null, + }, + author: "ModAuthor", + }); + }); + + it("converts mod_id and file_id to strings", () => { + const manifest = makeManifest(); + const payload = toV3CollectionPayload(manifest); + const source = payload.collection_manifest.mods[0].source; + + expect(typeof source.mod_id).toBe("string"); + expect(typeof source.file_id).toBe("string"); + }); + + it("defaults adult_content to false", () => { + const manifest = makeManifest(); + expect(toV3CollectionPayload(manifest).adult_content).toBe(false); + }); + + it("passes through adult_content when specified", () => { + const manifest = makeManifest(); + expect(toV3CollectionPayload(manifest, true).adult_content).toBe(true); + expect(toV3CollectionPayload(manifest, false).adult_content).toBe(false); + }); + + it("sets collection_schema_id to 1", () => { + const manifest = makeManifest(); + const payload = toV3CollectionPayload(manifest); + + expect(payload.collection_schema_id).toBe(1); + }); + + it("handles missing optional fields with null", () => { + const manifest = makeManifest({ + info: { + author: "Author", + name: "Name", + domainName: "skyrim", + }, + mods: [ + { + name: "Mod", + version: "1.0", + optional: true, + domainName: "skyrim", + source: { + type: "direct", + url: "https://example.com/mod.zip", + }, + }, + ], + }); + + const payload = toV3CollectionPayload(manifest); + const info = payload.collection_manifest.info; + + expect(info.author_url).toBeNull(); + expect(info.description).toBeNull(); + expect(info.summary).toBeNull(); + expect(info.game_versions).toBeNull(); + + const source = payload.collection_manifest.mods[0].source; + // mod_id / file_id fall back to empty strings — V3 backend rejects + // both null and missing fields, requires a String + expect(source.mod_id).toBe(""); + expect(source.file_id).toBe(""); + expect(source.md5).toBeNull(); + expect(source.file_size).toBeNull(); + }); + + it("handles all source types", () => { + const sourceTypes = ["nexus", "direct", "browse", "manual"] as const; + + for (const type of sourceTypes) { + const manifest = makeManifest({ + mods: [ + { + name: "Mod", + version: "1.0", + optional: false, + domainName: "skyrim", + source: { type }, + }, + ], + }); + const payload = toV3CollectionPayload(manifest); + expect(payload.collection_manifest.mods[0].source.type).toBe(type); + } + }); + + it("handles multiple mods", () => { + const manifest = makeManifest({ + mods: [ + { + name: "Mod A", + version: "1.0", + optional: false, + domainName: "skyrim", + source: { type: "nexus", modId: 1, fileId: 2 }, + }, + { + name: "Mod B", + version: "2.0", + optional: true, + domainName: "fallout4", + source: { type: "direct", url: "https://example.com" }, + }, + ], + }); + + const payload = toV3CollectionPayload(manifest); + expect(payload.collection_manifest.mods).toHaveLength(2); + expect(payload.collection_manifest.mods[0].name).toBe("Mod A"); + expect(payload.collection_manifest.mods[1].name).toBe("Mod B"); + expect(payload.collection_manifest.mods[1].domain_name).toBe("fallout4"); + }); + + it("handles mod with no author", () => { + const manifest = makeManifest({ + mods: [ + { + name: "Mod", + version: "1.0", + optional: false, + domainName: "skyrim", + source: { type: "nexus" }, + }, + ], + }); + + const payload = toV3CollectionPayload(manifest); + expect(payload.collection_manifest.mods[0].author).toBeNull(); + }); +}); diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts b/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts new file mode 100644 index 0000000000..f1ccfac084 --- /dev/null +++ b/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts @@ -0,0 +1,73 @@ +import type { ICollectionManifest } from "@nexusmods/nexus-api"; +import type { components } from "@vortex/nexus-api-v3"; + +type V3CollectionPayload = components["schemas"]["CollectionPayload"]; +type V3CollectionManifest = components["schemas"]["CollectionManifest"]; +type V3CollectionManifestMod = components["schemas"]["CollectionManifestMod"]; +type V3CollectionManifestModSource = components["schemas"]["CollectionManifestModSource"]; + +function toV3ManifestModSource( + source: ICollectionManifest["mods"][number]["source"], +): V3CollectionManifestModSource { + // NOTE: The V3 OpenAPI spec marks mod_id/file_id as optional (mod_id?: string), + // but the backend's Sorbet runtime treats them as required non-nilable strings. + // Omitting the field raises "Can't set mod_id to nil - need a String" and + // sending null raises "does not allow null values". Send empty strings as a + // workaround for non-nexus sources until the backend spec is fixed. + return { + type: source.type satisfies V3CollectionManifestModSource["type"], + mod_id: source.modId?.toString() ?? "", + file_id: source.fileId?.toString() ?? "", + md5: source.md5 ?? null, + file_size: source.fileSize ?? null, + update_policy: source.updatePolicy satisfies V3CollectionManifestModSource["update_policy"], + logical_filename: source.logicalFilename ?? null, + file_expression: source.fileExpression ?? null, + url: source.url ?? null, + adult_content: source.adultContent ?? null, + }; +} + +function toV3ManifestMod(mod: ICollectionManifest["mods"][number]): V3CollectionManifestMod { + return { + name: mod.name, + version: mod.version, + optional: mod.optional, + domain_name: mod.domainName, + source: toV3ManifestModSource(mod.source), + author: mod.author ?? null, + }; +} + +function emptyToNull(value: string | undefined | null): string | null { + if (value === undefined || value === null || value === "") { + return null; + } + return value; +} + +function toV3Manifest(manifest: ICollectionManifest): V3CollectionManifest { + return { + info: { + author: manifest.info.author, + author_url: emptyToNull(manifest.info.authorUrl), + name: manifest.info.name, + description: emptyToNull(manifest.info.description), + summary: emptyToNull(manifest.info.summary), + domain_name: manifest.info.domainName, + game_versions: manifest.info.gameVersions ?? null, + }, + mods: manifest.mods.map(toV3ManifestMod), + }; +} + +export function toV3CollectionPayload( + manifest: ICollectionManifest, + adultContent: boolean = false, +): V3CollectionPayload { + return { + adult_content: adultContent, + collection_manifest: toV3Manifest(manifest), + collection_schema_id: 1, + }; +} diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts new file mode 100644 index 0000000000..a8bc207174 --- /dev/null +++ b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts @@ -0,0 +1,234 @@ +import type { ICollectionManifest } from "@nexusmods/nexus-api"; +import type { NexusV3Client } from "@vortex/nexus-api-v3"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../../logging", () => ({ + log: vi.fn(), +})); + +vi.mock("@vortex/nexus-api-v3", () => ({ + createNexusV3Client: vi.fn(), +})); + +vi.mock("fs-extra", () => ({ + default: { stat: vi.fn() }, + stat: vi.fn(), +})); + +vi.mock("./uploadV3", () => ({ + pollUploadAvailable: vi.fn().mockResolvedValue(undefined), + uploadSinglePart: vi.fn().mockResolvedValue(undefined), + uploadMultipart: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("./manifestMapping", () => ({ + toV3CollectionPayload: vi.fn(() => ({ + adult_content: false, + collection_manifest: {}, + collection_schema_id: 1, + })), +})); + +vi.mock("../selectors", () => ({ + apiKey: vi.fn(() => "test-api-key"), + isLoggedIn: vi.fn(() => true), +})); + +import { createNexusV3Client } from "@vortex/nexus-api-v3"; +import { stat } from "fs-extra"; + +import type { IState } from "../../../types/IState"; +import { isLoggedIn } from "../selectors"; +import { submitCollectionV3 } from "./submitCollectionV3"; +import { uploadMultipart, uploadSinglePart } from "./uploadV3"; + +const mockCreateClient = vi.mocked(createNexusV3Client); +const mockStat = stat as unknown as ReturnType; + +const SMALL_FILE_SIZE = 1024; // < 100 MiB +const LARGE_FILE_SIZE = 200 * 1024 * 1024; // > 100 MiB + +function makeState(): IState { + return { + confidential: { + account: { + nexus: { + APIKey: "test-key", + OAuthCredentials: { token: "test-token" }, + }, + }, + }, + } as unknown as IState; +} + +function makeManifest(): ICollectionManifest { + return { + info: { + author: "Author", + name: "Test", + domainName: "skyrim", + }, + mods: [], + }; +} + +function makeMockClient(): Pick< + NexusV3Client, + | "createUpload" + | "createMultipartUpload" + | "finaliseUpload" + | "getUpload" + | "createCollection" + | "createCollectionRevision" + | "editCollection" +> { + return { + createUpload: vi.fn().mockResolvedValue({ + id: "upload-123", + presigned_url: "https://s3.example.com/upload", + user: { id: "1" }, + state: "created", + }), + createMultipartUpload: vi.fn().mockResolvedValue({ + id: "upload-456", + part_size_bytes: 100 * 1024 * 1024, + part_presigned_urls: ["https://s3.example.com/part1", "https://s3.example.com/part2"], + complete_presigned_url: "https://s3.example.com/complete", + user: { id: "1" }, + state: "created", + }), + finaliseUpload: vi + .fn() + .mockResolvedValue({ id: "upload-123", user: { id: "1" }, state: "created" }), + getUpload: vi.fn().mockResolvedValue({ + id: "upload-123", + user: { id: "1" }, + state: "available", + }), + createCollection: vi.fn().mockResolvedValue({ + id: "999", + slug: "test-slug", + revision_id: "1", + revision_number: 1, + revision_status: "draft", + }), + createCollectionRevision: vi.fn().mockResolvedValue({ + id: "42", + collection_id: "888", + revision_number: 2, + revision_status: "draft", + }), + editCollection: vi.fn().mockResolvedValue(undefined), + }; +} + +describe("submitCollectionV3", () => { + let mockClient: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = makeMockClient(); + mockCreateClient.mockReturnValue(mockClient as NexusV3Client); + mockStat.mockResolvedValue({ size: SMALL_FILE_SIZE }); + }); + + describe("upload path selection", () => { + it("uses single-part upload for small files", async () => { + mockStat.mockResolvedValue({ size: SMALL_FILE_SIZE }); + + await submitCollectionV3(makeState(), makeManifest(), "/tmp/small.zip", undefined); + + expect(mockClient.createUpload).toHaveBeenCalledWith(SMALL_FILE_SIZE, "small.zip"); + expect(vi.mocked(uploadSinglePart)).toHaveBeenCalled(); + expect(mockClient.createMultipartUpload).not.toHaveBeenCalled(); + expect(vi.mocked(uploadMultipart)).not.toHaveBeenCalled(); + }); + + it("uses multipart upload for large files", async () => { + mockStat.mockResolvedValue({ size: LARGE_FILE_SIZE }); + + await submitCollectionV3(makeState(), makeManifest(), "/tmp/large.zip", undefined); + + expect(mockClient.createMultipartUpload).toHaveBeenCalledWith(LARGE_FILE_SIZE, "large.zip"); + expect(vi.mocked(uploadMultipart)).toHaveBeenCalled(); + expect(mockClient.createUpload).not.toHaveBeenCalled(); + expect(vi.mocked(uploadSinglePart)).not.toHaveBeenCalled(); + }); + }); + + describe("new collection", () => { + it("returns id, slug, revisionNumber, and revisionStatus from V3 response", async () => { + const result = await submitCollectionV3( + makeState(), + makeManifest(), + "/tmp/file.zip", + undefined, + ); + + expect(mockClient.createCollection).toHaveBeenCalledWith("upload-123", expect.any(Object)); + expect(mockClient.createCollectionRevision).not.toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(result.collection?.id).toBe(999); + expect(result.collection?.slug).toBe("test-slug"); + expect(result.revision?.id).toBe(1); + expect(result.revision?.revisionNumber).toBe(1); + expect(result.revision?.revisionStatus).toBe("draft"); + }); + }); + + describe("revision update", () => { + it("returns id, revisionNumber, and revisionStatus from V3 response (slug unchanged)", async () => { + const result = await submitCollectionV3(makeState(), makeManifest(), "/tmp/file.zip", 888); + + expect(mockClient.createCollectionRevision).toHaveBeenCalledWith( + "888", + "upload-123", + expect.any(Object), + ); + expect(mockClient.createCollection).not.toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(result.collection?.id).toBe(888); + // slug intentionally not returned — caller keeps previously stored value + expect(result.collection?.slug).toBeUndefined(); + expect(result.revision?.id).toBe(42); + expect(result.revision?.revisionNumber).toBe(2); + expect(result.revision?.revisionStatus).toBe("draft"); + }); + + it("patches the collection name via V3 before the revision upload", async () => { + await submitCollectionV3(makeState(), makeManifest(), "/tmp/file.zip", 888); + + expect(mockClient.editCollection).toHaveBeenCalledWith(888, { + name: "Test", + }); + const editOrder = vi.mocked(mockClient.editCollection).mock.invocationCallOrder[0]; + const revisionOrder = vi.mocked(mockClient.createCollectionRevision).mock + .invocationCallOrder[0]; + expect(editOrder).toBeLessThan(revisionOrder); + }); + + it("does not patch the collection when creating a new one", async () => { + await submitCollectionV3(makeState(), makeManifest(), "/tmp/file.zip", undefined); + + expect(mockClient.editCollection).not.toHaveBeenCalled(); + }); + }); + + describe("upload lifecycle", () => { + it("calls finalise and poll after upload", async () => { + await submitCollectionV3(makeState(), makeManifest(), "/tmp/file.zip", undefined); + + expect(mockClient.finaliseUpload).toHaveBeenCalledWith("upload-123"); + }); + }); + + describe("auth", () => { + it("throws when not logged in", async () => { + vi.mocked(isLoggedIn).mockReturnValueOnce(false); + + await expect( + submitCollectionV3(makeState(), makeManifest(), "/tmp/file.zip", undefined), + ).rejects.toThrow("Not logged in"); + }); + }); +}); diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts new file mode 100644 index 0000000000..f67ed90710 --- /dev/null +++ b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts @@ -0,0 +1,103 @@ +import type { + ICollectionManifest, + ICreateCollectionResult, + IOAuthCredentials, +} from "@nexusmods/nexus-api"; +import { createNexusV3Client } from "@vortex/nexus-api-v3"; +import * as fs from "fs-extra"; + +import { log } from "../../../logging"; +import type { IState } from "../../../types/IState"; +import { MULTIPART_THRESHOLD, NEXUS_V3_BASE_URL } from "../constants"; +import { apiKey as apiKeySelector, isLoggedIn } from "../selectors"; +import { toV3CollectionPayload } from "./manifestMapping"; +import { pollUploadAvailable, uploadMultipart, uploadSinglePart } from "./uploadV3"; + +function createClientFromState(state: IState) { + if (!isLoggedIn(state)) { + throw new Error("Not logged in to Nexus Mods"); + } + + const apiKey = apiKeySelector(state); + const oauthCred: IOAuthCredentials = state.confidential.account?.["nexus"]?.["OAuthCredentials"]; + const oauthToken = oauthCred?.token; + + return createNexusV3Client({ + baseUrl: NEXUS_V3_BASE_URL, + apiKey, + bearerToken: oauthToken, + }); +} + +export async function submitCollectionV3( + state: IState, + collectionInfo: ICollectionManifest, + assetFilePath: string, + collectionId: number | undefined, + signal?: AbortSignal, +): Promise { + const client = createClientFromState(state); + const stat = await fs.stat(assetFilePath); + const fileSize = stat.size; + const filename = assetFilePath.split(/[\\/]/).pop() ?? "collection.zip"; + + log("info", "submitting collection via V3 API", { + fileSize, + isMultipart: fileSize > MULTIPART_THRESHOLD, + isNewCollection: collectionId === undefined, + }); + + // Step 1: Create upload session + let uploadId: string; + + if (fileSize <= MULTIPART_THRESHOLD) { + const upload = await client.createUpload(fileSize, filename); + uploadId = upload.id; + await uploadSinglePart(upload.presigned_url, assetFilePath, fileSize, signal); + } else { + const multipart = await client.createMultipartUpload(fileSize, filename); + uploadId = multipart.id; + await uploadMultipart(multipart, assetFilePath, fileSize, signal); + } + + // Step 2: Finalise and wait for availability + await client.finaliseUpload(uploadId); + await pollUploadAvailable(client, uploadId, signal); + + // Step 3: Create collection or revision + const payload = toV3CollectionPayload(collectionInfo); + + if (collectionId === undefined) { + const result = await client.createCollection(uploadId, payload); + return { + collection: { id: Number(result.id), slug: result.slug }, + revision: { + id: Number(result.revision_id), + revisionNumber: result.revision_number, + revisionStatus: result.revision_status, + }, + success: true, + }; + } + + // V3 revision creation doesn't propagate collection-level metadata (name) + // to the parent collection, so patch the name first — mirrors the pre-v3 + // flow which ran editCollection unconditionally before every revision upload. + await client.editCollection(collectionId, { name: collectionInfo.info.name }); + + const revisionResult = await client.createCollectionRevision( + String(collectionId), + uploadId, + payload, + ); + // Slug is unchanged from the previous upload (caller already has it stored). + return { + collection: { id: collectionId }, + revision: { + id: Number(revisionResult.id), + revisionNumber: revisionResult.revision_number, + revisionStatus: revisionResult.revision_status, + }, + success: true, + }; +} diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts new file mode 100644 index 0000000000..3c0c647512 --- /dev/null +++ b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts @@ -0,0 +1,276 @@ +import { Readable } from "stream"; + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../../logging", () => ({ + log: vi.fn(), +})); + +vi.mock("../../../util/network", () => ({ + uploadWithHeaders: vi.fn(), +})); + +vi.mock("fs", () => ({ + default: { createReadStream: vi.fn(() => Readable.from(Buffer.alloc(0))) }, + createReadStream: vi.fn(() => Readable.from(Buffer.alloc(0))), +})); + +import type { NexusV3Client } from "@vortex/nexus-api-v3"; + +import { uploadWithHeaders } from "../../../util/network"; +import { uploadMultipart, uploadSinglePart, pollUploadAvailable } from "./uploadV3"; + +const mockUploadWithHeaders = vi.mocked(uploadWithHeaders); + +function makeClient(overrides: Partial = {}): NexusV3Client { + return { + getUpload: vi.fn(), + ...overrides, + } as unknown as NexusV3Client; +} + +describe("pollUploadAvailable", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + it("resolves immediately when state is available", async () => { + const getUpload = vi.fn().mockResolvedValue({ state: "available" }); + const client = makeClient({ getUpload }); + + await pollUploadAvailable(client, "upload-123"); + + expect(getUpload).toHaveBeenCalledOnce(); + expect(getUpload).toHaveBeenCalledWith("upload-123"); + }); + + it("polls until state becomes available", async () => { + const getUpload = vi + .fn() + .mockResolvedValueOnce({ state: "created" }) + .mockResolvedValueOnce({ state: "created" }) + .mockResolvedValueOnce({ state: "available" }); + + const client = makeClient({ getUpload }); + + const promise = pollUploadAvailable(client, "upload-123"); + + await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(2000); + + await promise; + + expect(getUpload).toHaveBeenCalledTimes(3); + }); + + it("bails after tolerating unknown states a few times", async () => { + const getUpload = vi.fn().mockResolvedValue({ state: "failed" }); + const client = makeClient({ getUpload }); + + const promise = pollUploadAvailable(client, "upload-123"); + const settled = promise.catch((err: unknown) => err); + await vi.runAllTimersAsync(); + const err = await settled; + + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/unknown state/); + // One initial observation + tolerance-1 more before bailing (3 total). + expect(getUpload).toHaveBeenCalledTimes(3); + }); + + it("keeps polling through a single unknown-state blip", async () => { + const getUpload = vi + .fn() + .mockResolvedValueOnce({ state: "created" }) + .mockResolvedValueOnce({ state: "mystery-new-state" }) + .mockResolvedValueOnce({ state: "created" }) + .mockResolvedValueOnce({ state: "available" }); + const client = makeClient({ getUpload }); + + const promise = pollUploadAvailable(client, "upload-123"); + await vi.runAllTimersAsync(); + await promise; + + expect(getUpload).toHaveBeenCalledTimes(4); + }); + + it("throws promptly when signal is already aborted", async () => { + const getUpload = vi.fn().mockResolvedValue({ state: "created" }); + const client = makeClient({ getUpload }); + const controller = new AbortController(); + controller.abort(); + + await expect(pollUploadAvailable(client, "upload-123", controller.signal)).rejects.toThrow(); + expect(getUpload).not.toHaveBeenCalled(); + }); + + it("aborts between polls when signal fires", async () => { + const getUpload = vi.fn().mockResolvedValue({ state: "created" }); + const client = makeClient({ getUpload }); + const controller = new AbortController(); + + const promise = pollUploadAvailable(client, "upload-123", controller.signal); + const settled = promise.catch((err: unknown) => err); + // Let the first poll run and enter the sleep. + await vi.advanceTimersByTimeAsync(0); + controller.abort(); + await vi.runAllTimersAsync(); + + const err = await settled; + expect(err).toBeInstanceOf(Error); + expect(getUpload).toHaveBeenCalledOnce(); + }); +}); + +describe("uploadSinglePart", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls uploadWithHeaders with correct URL and size", async () => { + mockUploadWithHeaders.mockResolvedValue({ + body: Buffer.alloc(0), + headers: {}, + statusCode: 200, + }); + + await uploadSinglePart("https://s3.example.com/upload", "/tmp/file.zip", 1024); + + expect(mockUploadWithHeaders).toHaveBeenCalledOnce(); + const [url, , size] = mockUploadWithHeaders.mock.calls[0]; + expect(url).toBe("https://s3.example.com/upload"); + expect(size).toBe(1024); + }); +}); + +describe("uploadMultipart", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it("uploads all parts and completes with correct XML", async () => { + mockUploadWithHeaders + .mockResolvedValueOnce({ + body: Buffer.alloc(0), + headers: { etag: '"etag-part-1"' }, + statusCode: 200, + }) + .mockResolvedValueOnce({ + body: Buffer.alloc(0), + headers: { etag: '"etag-part-2"' }, + statusCode: 200, + }); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue(""), + } as unknown as Response); + + await uploadMultipart( + { + part_size_bytes: 100, + part_presigned_urls: ["https://s3.example.com/part1", "https://s3.example.com/part2"], + complete_presigned_url: "https://s3.example.com/complete", + }, + "/tmp/bigfile.zip", + 180, + ); + + // Verify both parts were uploaded + expect(mockUploadWithHeaders).toHaveBeenCalledTimes(2); + + const [url1, , size1] = mockUploadWithHeaders.mock.calls[0]; + expect(url1).toBe("https://s3.example.com/part1"); + expect(size1).toBe(100); + + const [url2, , size2] = mockUploadWithHeaders.mock.calls[1]; + expect(url2).toBe("https://s3.example.com/part2"); + expect(size2).toBe(80); // last part is smaller + + // Verify completion POST with correct XML + expect(fetchSpy).toHaveBeenCalledOnce(); + const [completeUrl, options] = fetchSpy.mock.calls[0]; + expect(completeUrl).toBe("https://s3.example.com/complete"); + expect(options.method).toBe("POST"); + expect(options.headers).toEqual({ "Content-Type": "application/xml" }); + + const xml = options.body as string; + expect(xml).toContain(""); + expect(xml).toContain("1"); + expect(xml).toContain('"etag-part-1"'); + expect(xml).toContain("2"); + expect(xml).toContain('"etag-part-2"'); + }); + + it("throws if a part upload has no ETag", async () => { + mockUploadWithHeaders.mockResolvedValueOnce({ + body: Buffer.alloc(0), + headers: {}, // no etag + statusCode: 200, + }); + + await expect( + uploadMultipart( + { + part_size_bytes: 100, + part_presigned_urls: ["https://s3.example.com/part1"], + complete_presigned_url: "https://s3.example.com/complete", + }, + "/tmp/file.zip", + 50, + ), + ).rejects.toThrow("ETag"); + }); + + it("retries and then throws if multipart completion keeps failing", async () => { + mockUploadWithHeaders.mockResolvedValueOnce({ + body: Buffer.alloc(0), + headers: { etag: '"etag-1"' }, + statusCode: 200, + }); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + status: 500, + text: vi.fn().mockResolvedValue("Internal Server Error"), + } as unknown as Response); + + vi.useFakeTimers(); + const promise = uploadMultipart( + { + part_size_bytes: 100, + part_presigned_urls: ["https://s3.example.com/part1"], + complete_presigned_url: "https://s3.example.com/complete", + }, + "/tmp/file.zip", + 50, + ); + // Swallow the eventual rejection so the assertion below owns it. + const settled = promise.catch((err: unknown) => err); + await vi.runAllTimersAsync(); + vi.useRealTimers(); + + const err = await settled; + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/Failed to complete multipart upload/); + // Completion POST is retried up to RETRY_ATTEMPTS (3) times. + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it("throws with mismatched part layout", async () => { + await expect( + uploadMultipart( + { + part_size_bytes: 100, + // One URL provided, but 250 bytes at 100 bytes/part needs 3 parts. + part_presigned_urls: ["https://s3.example.com/part1"], + complete_presigned_url: "https://s3.example.com/complete", + }, + "/tmp/file.zip", + 250, + ), + ).rejects.toThrow(/Multipart layout mismatch/); + }); +}); diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts new file mode 100644 index 0000000000..6fc5c0dbe6 --- /dev/null +++ b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts @@ -0,0 +1,278 @@ +import { createReadStream } from "fs"; + +import type { NexusV3Client } from "@vortex/nexus-api-v3"; + +import { log } from "../../../logging"; +import { uploadWithHeaders, type IUploadResult } from "../../../util/network"; + +function describeError(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === "string") return err; + try { + return JSON.stringify(err); + } catch { + return ""; + } +} + +function abortError(signal: AbortSignal | undefined): Error { + const reason: unknown = signal?.reason; + if (reason instanceof Error) return reason; + if (typeof reason === "string") return new Error(reason); + return new Error("Aborted"); +} + +const POLL_INTERVAL_MS = 2000; +const POLL_MAX_ATTEMPTS = 150; // 5 minutes + +const RETRY_ATTEMPTS = 3; +const RETRY_BASE_DELAY_MS = 1000; + +// States declared by the OpenAPI schema. A successful upload transitions +// created → available; anything else is an unknown state we did not opt into. +const KNOWN_INPROGRESS_STATES = new Set(["created"]); +const SUCCESS_STATE = "available"; +// How many consecutive unknown-state observations we tolerate before bailing. +// One transient observation can be legitimate if the server introduces a new +// intermediate state before the schema catches up; three in a row is not. +const UNKNOWN_STATE_TOLERANCE = 3; + +function statusCodeOf(err: unknown): number | undefined { + if (err !== null && typeof err === "object" && "statusCode" in err) { + const sc = (err as { statusCode?: unknown }).statusCode; + if (typeof sc === "number") return sc; + } + if (err !== null && typeof err === "object" && "status" in err) { + const s = (err as { status?: unknown }).status; + if (typeof s === "number") return s; + } + return undefined; +} + +function isRetryableError(err: unknown): boolean { + const sc = statusCodeOf(err); + if (sc === undefined) { + // No status — treat as transport error, retry. + return true; + } + // 4xx are client errors and generally not worth retrying. 408 (timeout) + // and 429 (rate-limit) are the conventional exceptions. + if (sc >= 400 && sc < 500 && sc !== 408 && sc !== 429) { + return false; + } + return true; +} + +function abortableSleep(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.reject(abortError(signal)); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(abortError(signal)); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +interface IRetryOptions { + attempts?: number; + signal?: AbortSignal; +} + +async function withRetry( + fn: () => Promise, + label: string, + options: IRetryOptions = {}, +): Promise { + const { attempts = RETRY_ATTEMPTS, signal } = options; + let lastErr: unknown; + for (let attempt = 1; attempt <= attempts; attempt++) { + if (signal?.aborted) throw abortError(signal); + try { + return await fn(); + } catch (err) { + lastErr = err; + if (attempt === attempts || !isRetryableError(err)) { + if (!isRetryableError(err)) { + log("debug", "upload attempt failed, not retrying", { + label, + statusCode: statusCodeOf(err), + error: describeError(err), + }); + } + break; + } + const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1); + log("warn", "upload attempt failed, retrying", { + label, + attempt, + attempts, + delayMs: delay, + error: describeError(err), + }); + await abortableSleep(delay, signal); + } + } + throw lastErr; +} + +export async function pollUploadAvailable( + client: NexusV3Client, + uploadId: string, + signal?: AbortSignal, +): Promise { + let unknownStreak = 0; + for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) { + if (signal?.aborted) throw abortError(signal); + + const upload = await client.getUpload(uploadId); + const state = upload.state as string; + if (state === SUCCESS_STATE) { + return; + } + if (KNOWN_INPROGRESS_STATES.has(state)) { + unknownStreak = 0; + log("debug", "polling upload status", { + uploadId, + state, + attempt, + }); + } else { + unknownStreak += 1; + log("warn", "upload reported unknown state", { + uploadId, + state, + attempt, + unknownStreak, + tolerance: UNKNOWN_STATE_TOLERANCE, + }); + if (unknownStreak >= UNKNOWN_STATE_TOLERANCE) { + throw new Error( + `Upload ${uploadId} reported unknown state "${state}" ${unknownStreak} times; bailing`, + ); + } + } + await abortableSleep(POLL_INTERVAL_MS, signal); + } + throw new Error( + `Upload ${uploadId} did not become available within ${(POLL_INTERVAL_MS * POLL_MAX_ATTEMPTS) / 1000}s`, + ); +} + +export async function uploadSinglePart( + presignedUrl: string, + filePath: string, + fileSize: number, + signal?: AbortSignal, +): Promise { + await withRetry( + () => uploadWithHeaders(presignedUrl, createReadStream(filePath), fileSize, undefined, signal), + "single-part upload", + { signal }, + ); +} + +function buildCompleteMultipartXml(etags: Array<{ partNumber: number; etag: string }>): string { + const parts = etags + .map( + ({ partNumber, etag }) => + ` \n ${partNumber}\n ${etag}\n `, + ) + .join("\n"); + return `\n${parts}\n`; +} + +async function uploadPart( + url: string, + filePath: string, + start: number, + end: number, + partNumber: number, + totalParts: number, + signal?: AbortSignal, +): Promise<{ partNumber: number; etag: string }> { + const chunkSize = end - start; + const result = await withRetry( + () => { + const stream = createReadStream(filePath, { start, end: end - 1 }); + return uploadWithHeaders(url, stream, chunkSize, undefined, signal); + }, + `multipart part ${partNumber}/${totalParts}`, + { signal }, + ); + + const rawEtag = result.headers["etag"]; + const etag = Array.isArray(rawEtag) ? rawEtag[0] : rawEtag; + if (!etag) { + throw new Error(`S3 did not return an ETag for part ${partNumber} of multipart upload`); + } + + log("debug", "multipart part uploaded", { + part: partNumber, + total: totalParts, + etag, + }); + return { partNumber, etag }; +} + +export async function uploadMultipart( + multipart: { + part_size_bytes: number; + part_presigned_urls: string[]; + complete_presigned_url: string; + }, + filePath: string, + fileSize: number, + signal?: AbortSignal, +): Promise { + const { part_size_bytes, part_presigned_urls, complete_presigned_url } = multipart; + const totalParts = part_presigned_urls.length; + const expectedParts = Math.ceil(fileSize / part_size_bytes); + if (expectedParts !== totalParts) { + throw new Error( + `Multipart layout mismatch: server returned ${totalParts} presigned URLs ` + + `but ${fileSize} bytes at ${part_size_bytes} bytes/part needs ${expectedParts}`, + ); + } + const etags: Array<{ partNumber: number; etag: string }> = []; + for (let i = 0; i < totalParts; i++) { + if (signal?.aborted) throw abortError(signal); + const start = i * part_size_bytes; + const end = Math.min(start + part_size_bytes, fileSize); + etags.push( + await uploadPart(part_presigned_urls[i], filePath, start, end, i + 1, totalParts, signal), + ); + } + + // Complete the multipart upload by POSTing the ETags XML to S3. + const xml = buildCompleteMultipartXml(etags); + await withRetry( + async () => { + const response = await fetch(complete_presigned_url, { + method: "POST", + headers: { "Content-Type": "application/xml" }, + body: xml, + signal, + }); + if (!response.ok) { + // Throw inside withRetry so transient 5xx responses are retried. + // Status is attached so isRetryableError can skip 4xx responses. + const body = await response.text(); + const err = new Error( + `Failed to complete multipart upload: ${response.status} ${body}`, + ) as Error & { statusCode: number }; + err.statusCode = response.status; + throw err; + } + return response; + }, + "multipart completion", + { signal }, + ); +} diff --git a/src/renderer/src/util/network.ts b/src/renderer/src/util/network.ts index 9d33689907..5099315a6e 100644 --- a/src/renderer/src/util/network.ts +++ b/src/renderer/src/util/network.ts @@ -1,4 +1,4 @@ -import type { IncomingMessage, ClientRequest } from "http"; +import type { IncomingMessage, IncomingHttpHeaders, ClientRequest } from "http"; import { get as getHTTP, request as requestHTTP } from "http"; import { get as getHTTPS, request as requestHTTPS } from "https"; import type { Readable } from "stream"; @@ -160,3 +160,99 @@ export function upload(targetUrl: string, dataStream: Readable, dataSize: number }); }); } + +export interface IUploadResult { + body: Buffer; + headers: IncomingHttpHeaders; + statusCode: number; +} + +export class HttpUploadError extends Error { + public readonly statusCode: number; + constructor(message: string, statusCode: number) { + super(message); + this.name = "HttpUploadError"; + this.statusCode = statusCode; + } +} + +function toAbortError(signal: AbortSignal | undefined): Error { + const reason: unknown = signal?.reason; + if (reason instanceof Error) return reason; + if (typeof reason === "string") return new Error(reason); + return new Error("Upload aborted"); +} + +export function uploadWithHeaders( + targetUrl: string, + dataStream: Readable, + dataSize: number, + extraHeaders?: Record, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + log("debug", "uploading file (with headers)", { targetUrl, dataSize }); + const started = Date.now(); + + if (signal?.aborted) { + return reject(toAbortError(signal)); + } + + const req = request( + "PUT", + targetUrl, + { + "Content-Type": "application/octet-stream", + "Content-Length": dataSize.toString(), + ...extraHeaders, + }, + (res) => { + const { statusCode } = res; + log("debug", "upload complete", { + targetUrl, + dataSize, + statusCode, + elapsed: Date.now() - started, + }); + + let rawData: Buffer = Buffer.alloc(0); + res + .on("data", (chunk) => { + rawData = Buffer.concat([rawData, chunk]); + }) + .on("end", () => { + if (statusCode !== 200) { + return reject( + new HttpUploadError(`Upload failed. Status Code: ${statusCode}`, statusCode ?? 0), + ); + } + resolve({ + body: rawData, + headers: res.headers, + statusCode, + }); + }) + .on("error", (reqErr: Error) => { + return reject(reqErr); + }); + }, + ); + req.on("error", (err) => { + return reject(err); + }); + + const onAbort = () => { + const reason = toAbortError(signal); + req.destroy(reason); + dataStream.destroy(reason); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + req.on("close", () => { + signal?.removeEventListener("abort", onAbort); + }); + + dataStream.pipe(req, { + end: true, + }); + }); +} diff --git a/src/renderer/webpack.config.cjs b/src/renderer/webpack.config.cjs index 08f5a6a38f..2017ec1e44 100644 --- a/src/renderer/webpack.config.cjs +++ b/src/renderer/webpack.config.cjs @@ -76,7 +76,7 @@ const config = { devtool: "source-map", externals: [ nodeExternals({ - allowlist: [/@vortex\/shared/], + allowlist: [/@vortex\/shared/, /@vortex\/nexus-api-v3/], }), ], };