@ashdev/codex-plugin-release-mangaupdates 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1127 -0
- package/dist/index.js.map +7 -0
- package/package.json +51 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../sdk-typescript/src/types/rpc.ts", "../../sdk-typescript/src/errors.ts", "../../sdk-typescript/src/request-context.ts", "../../sdk-typescript/src/host-rpc.ts", "../../sdk-typescript/src/logger.ts", "../../sdk-typescript/src/server.ts", "../../sdk-typescript/src/storage.ts", "../../sdk-typescript/src/types/releases.ts", "../src/fetcher.ts", "../src/parser.ts", "../src/filter.ts", "../package.json", "../src/manifest.ts", "../src/index.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * JSON-RPC 2.0 types for plugin communication\n */\n\nexport interface JsonRpcRequest {\n jsonrpc: \"2.0\";\n id: string | number | null;\n method: string;\n params?: unknown;\n /**\n * Reverse-RPC only: id of the forward call this plugin is currently\n * servicing. Tells the host to route the reverse-RPC back to the\n * originating caller's task so emitted events land in that caller's\n * recording broadcaster (and replay correctly in distributed\n * deployments). The SDK stamps this automatically via\n * `AsyncLocalStorage` \u2014 plugin authors don't set it.\n */\n parentRequestId?: string | number | null;\n}\n\nexport interface JsonRpcSuccessResponse {\n jsonrpc: \"2.0\";\n id: string | number | null;\n result: unknown;\n}\n\nexport interface JsonRpcErrorResponse {\n jsonrpc: \"2.0\";\n id: string | number | null;\n error: JsonRpcError;\n}\n\nexport interface JsonRpcError {\n code: number;\n message: string;\n data?: unknown;\n}\n\nexport type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse;\n\n/**\n * Standard JSON-RPC error codes\n */\nexport const JSON_RPC_ERROR_CODES = {\n /** Invalid JSON was received */\n PARSE_ERROR: -32700,\n /** The JSON sent is not a valid Request object */\n INVALID_REQUEST: -32600,\n /** The method does not exist / is not available */\n METHOD_NOT_FOUND: -32601,\n /** Invalid method parameter(s) */\n INVALID_PARAMS: -32602,\n /** Internal JSON-RPC error */\n INTERNAL_ERROR: -32603,\n} as const;\n\n/**\n * Plugin-specific error codes (in the -32000 to -32099 range)\n */\nexport const PLUGIN_ERROR_CODES = {\n /** Rate limited by external API */\n RATE_LIMITED: -32001,\n /** Resource not found (e.g., series ID doesn't exist) */\n NOT_FOUND: -32002,\n /** Authentication failed (invalid credentials) */\n AUTH_FAILED: -32003,\n /** External API error */\n API_ERROR: -32004,\n /** Plugin configuration error */\n CONFIG_ERROR: -32005,\n} as const;\n", "/**\n * Plugin error classes for structured error handling\n */\n\nimport { type JsonRpcError, PLUGIN_ERROR_CODES } from \"./types/rpc.js\";\n\n/**\n * Base class for plugin errors that map to JSON-RPC errors\n */\nexport abstract class PluginError extends Error {\n abstract readonly code: number;\n readonly data?: unknown;\n\n constructor(message: string, data?: unknown) {\n super(message);\n this.name = this.constructor.name;\n this.data = data;\n }\n\n /**\n * Convert to JSON-RPC error format\n */\n toJsonRpcError(): JsonRpcError {\n return {\n code: this.code,\n message: this.message,\n data: this.data,\n };\n }\n}\n\n/**\n * Thrown when rate limited by an external API\n */\nexport class RateLimitError extends PluginError {\n readonly code = PLUGIN_ERROR_CODES.RATE_LIMITED;\n /** Seconds to wait before retrying */\n readonly retryAfterSeconds: number;\n\n constructor(retryAfterSeconds: number, message?: string) {\n super(message ?? `Rate limited, retry after ${retryAfterSeconds}s`, {\n retryAfterSeconds,\n });\n this.retryAfterSeconds = retryAfterSeconds;\n }\n}\n\n/**\n * Thrown when a requested resource is not found\n */\nexport class NotFoundError extends PluginError {\n readonly code = PLUGIN_ERROR_CODES.NOT_FOUND;\n}\n\n/**\n * Thrown when authentication fails (invalid credentials)\n */\nexport class AuthError extends PluginError {\n readonly code = PLUGIN_ERROR_CODES.AUTH_FAILED;\n\n constructor(message?: string) {\n super(message ?? \"Authentication failed\");\n }\n}\n\n/**\n * Thrown when an external API returns an error\n */\nexport class ApiError extends PluginError {\n readonly code = PLUGIN_ERROR_CODES.API_ERROR;\n readonly statusCode: number | undefined;\n\n constructor(message: string, statusCode?: number) {\n super(message, statusCode !== undefined ? { statusCode } : undefined);\n this.statusCode = statusCode;\n }\n}\n\n/**\n * Thrown when the plugin is misconfigured\n */\nexport class ConfigError extends PluginError {\n readonly code = PLUGIN_ERROR_CODES.CONFIG_ERROR;\n}\n", "/**\n * Async-local context for the currently-handled forward request.\n *\n * When the SDK dispatches a forward call (e.g. `releases/poll`), it stores\n * the call's `id` in this context for the duration of the handler. Any\n * reverse-RPC the plugin makes while servicing that call (e.g.\n * `releases/record` via `HostRpcClient.call`) reads the id and stamps it as\n * `parentRequestId` on the outgoing request.\n *\n * The host uses `parentRequestId` to route the reverse-RPC back to the\n * originating caller's tokio task, so emitted events land in the recording\n * broadcaster scoped to that task and replay correctly in distributed\n * deployments. Without this stamping, plugins that emit events via\n * reverse-RPC would silently lose them on the worker.\n *\n * Plugin authors don't interact with this directly. The SDK's request\n * dispatch (`server.ts`) sets it; `HostRpcClient.call` reads it.\n */\n\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\nconst store = new AsyncLocalStorage<string | number | null>();\n\n/**\n * Run `fn` with `forwardRequestId` as the current parent. Calls to\n * `currentParentRequestId()` made inside `fn` (or anything it awaits) will\n * see this value.\n */\nexport function runWithParentRequestId<T>(\n forwardRequestId: string | number | null,\n fn: () => Promise<T>,\n): Promise<T> {\n return store.run(forwardRequestId, fn);\n}\n\n/**\n * Snapshot the current forward request id, or `undefined` if no forward\n * request is on the call stack (e.g. background timers in the plugin that\n * fire reverse-RPCs outside a forward-call context \u2014 those won't be replay-\n * eligible, by design, since they don't belong to any task).\n */\nexport function currentParentRequestId(): string | number | null | undefined {\n return store.getStore();\n}\n", "/**\n * Generic host reverse-RPC client.\n *\n * Plugins use this to call host methods outside the storage namespace \u2014\n * notably `releases/list_tracked`, `releases/record`,\n * `releases/source_state/get`, and `releases/source_state/set`. The class is\n * intentionally generic so future reverse-RPC namespaces can reuse it\n * without a per-namespace client.\n *\n * Wire-format and lifecycle mirror `PluginStorage`: send a JSON-RPC request\n * over stdout with a unique id, and resolve when the host's response with\n * the matching id arrives on stdin. The plugin server's main loop calls\n * `handleResponse(line)` on every incoming response; whichever client owns\n * the id resolves it (others no-op silently).\n *\n * The id counter starts at a high value (`1_000_000_000`) so it can never\n * collide with `PluginStorage`'s sequence (`1, 2, 3, ...`). This means the\n * dispatch in the server doesn't need to know which client a response\n * belongs to \u2014 it can fan out to both, and at most one will match.\n */\n\nimport { currentParentRequestId } from \"./request-context.js\";\nimport type { JsonRpcError, JsonRpcRequest } from \"./types/rpc.js\";\n\n/** Write function signature for sending JSON-RPC requests. */\ntype WriteFn = (line: string) => void;\n\n/**\n * Error thrown when a reverse-RPC call fails (host returned a JSON-RPC error,\n * or the client was canceled).\n */\nexport class HostRpcError extends Error {\n constructor(\n message: string,\n public readonly code: number,\n public readonly data?: unknown,\n ) {\n super(message);\n this.name = \"HostRpcError\";\n }\n}\n\n/**\n * Generic reverse-RPC client. Construct one per plugin instance and pass it\n * around via `InitializeParams`.\n */\nexport class HostRpcClient {\n // Start the counter high so it can't collide with PluginStorage's id space.\n // `Number.MAX_SAFE_INTEGER` is far above this, so we have plenty of room\n // before wrapping (and we never expect a single plugin lifetime to issue\n // more than ~9 quintillion calls).\n private nextId = 1_000_000_000;\n private pendingRequests = new Map<\n number,\n {\n resolve: (value: unknown) => void;\n reject: (error: Error) => void;\n }\n >();\n private writeFn: WriteFn;\n\n /**\n * @param writeFn - Optional custom write function (defaults to\n * `process.stdout.write`). Useful for testing.\n */\n constructor(writeFn?: WriteFn) {\n this.writeFn =\n writeFn ??\n ((line: string) => {\n process.stdout.write(line);\n });\n }\n\n /**\n * Send a JSON-RPC request to the host and resolve with the result.\n *\n * @param method - JSON-RPC method name (e.g. `\"releases/list_tracked\"`).\n * @param params - Method-specific params. Pass `undefined` when the method\n * takes no params.\n */\n async call<T = unknown>(method: string, params?: unknown): Promise<T> {\n const id = this.nextId++;\n // Stamp the forward call we're inside so the host can route this\n // reverse-RPC back to the originating caller's task. Lifted from the\n // `request-context` async-local storage that `server.ts` sets around\n // every forward-request handler.\n const parent = currentParentRequestId();\n const request: JsonRpcRequest = {\n jsonrpc: \"2.0\",\n id,\n method,\n params,\n ...(parent !== undefined ? { parentRequestId: parent } : {}),\n };\n\n return new Promise<T>((resolve, reject) => {\n this.pendingRequests.set(id, {\n resolve: (v) => resolve(v as T),\n reject,\n });\n try {\n this.writeFn(`${JSON.stringify(request)}\\n`);\n } catch (err) {\n this.pendingRequests.delete(id);\n const message = err instanceof Error ? err.message : \"Unknown write error\";\n reject(new HostRpcError(`Failed to send request: ${message}`, -1));\n }\n });\n }\n\n /**\n * Process an incoming JSON-RPC response line. Returns `true` if this\n * client owned the response id and resolved it, `false` otherwise (so\n * other clients can try).\n *\n * Called by the plugin server's main loop on every response.\n */\n handleResponse(line: string): boolean {\n const trimmed = line.trim();\n if (!trimmed) return false;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(trimmed);\n } catch {\n return false;\n }\n\n const obj = parsed as Record<string, unknown>;\n if (obj.method !== undefined) return false; // not a response\n const rawId = obj.id;\n if (typeof rawId !== \"number\") return false;\n if (!this.pendingRequests.has(rawId)) return false;\n\n const pending = this.pendingRequests.get(rawId);\n if (!pending) return false;\n this.pendingRequests.delete(rawId);\n\n if (\"error\" in obj && obj.error) {\n const err = obj.error as JsonRpcError;\n pending.reject(new HostRpcError(err.message, err.code, err.data));\n } else {\n pending.resolve(obj.result);\n }\n return true;\n }\n\n /** Reject all pending requests (e.g. on shutdown). */\n cancelAll(): void {\n for (const [, pending] of this.pendingRequests) {\n pending.reject(new HostRpcError(\"Host RPC client stopped\", -1));\n }\n this.pendingRequests.clear();\n }\n}\n", "/**\n * Logging utilities for plugins\n *\n * IMPORTANT: Plugins must ONLY write to stderr for logging.\n * stdout is reserved for JSON-RPC communication.\n */\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nconst LOG_LEVELS: Record<LogLevel, number> = {\n debug: 0,\n info: 1,\n warn: 2,\n error: 3,\n};\n\nexport interface LoggerOptions {\n /** Plugin name to prefix log messages */\n name: string;\n /** Minimum log level (default: \"info\") */\n level?: LogLevel;\n /** Whether to include timestamps (default: true) */\n timestamps?: boolean;\n}\n\n/**\n * Logger that writes to stderr (safe for plugins)\n */\nexport class Logger {\n private readonly name: string;\n private readonly minLevel: number;\n private readonly timestamps: boolean;\n\n constructor(options: LoggerOptions) {\n this.name = options.name;\n this.minLevel = LOG_LEVELS[options.level ?? \"info\"];\n this.timestamps = options.timestamps ?? true;\n }\n\n private shouldLog(level: LogLevel): boolean {\n return LOG_LEVELS[level] >= this.minLevel;\n }\n\n private format(level: LogLevel, message: string, data?: unknown): string {\n const parts: string[] = [];\n\n if (this.timestamps) {\n parts.push(new Date().toISOString());\n }\n\n parts.push(`[${level.toUpperCase()}]`);\n parts.push(`[${this.name}]`);\n parts.push(message);\n\n if (data !== undefined) {\n if (data instanceof Error) {\n parts.push(`- ${data.message}`);\n if (data.stack) {\n parts.push(`\\n${data.stack}`);\n }\n } else if (typeof data === \"object\") {\n parts.push(`- ${JSON.stringify(data)}`);\n } else {\n parts.push(`- ${String(data)}`);\n }\n }\n\n return parts.join(\" \");\n }\n\n private log(level: LogLevel, message: string, data?: unknown): void {\n if (this.shouldLog(level)) {\n // Write to stderr (not stdout!) - stdout is for JSON-RPC only\n process.stderr.write(`${this.format(level, message, data)}\\n`);\n }\n }\n\n debug(message: string, data?: unknown): void {\n this.log(\"debug\", message, data);\n }\n\n info(message: string, data?: unknown): void {\n this.log(\"info\", message, data);\n }\n\n warn(message: string, data?: unknown): void {\n this.log(\"warn\", message, data);\n }\n\n error(message: string, data?: unknown): void {\n this.log(\"error\", message, data);\n }\n}\n\n/**\n * Create a logger for a plugin\n */\nexport function createLogger(options: LoggerOptions): Logger {\n return new Logger(options);\n}\n", "/**\n * Plugin server - handles JSON-RPC communication over stdio\n *\n * Provides factory functions for creating different plugin types.\n * All plugin types share a common base server that handles:\n * - stdin readline parsing\n * - JSON-RPC error handling\n * - initialize/ping/shutdown lifecycle methods\n *\n * Each plugin type adds its own method routing on top.\n */\n\nimport { createInterface } from \"node:readline\";\nimport { PluginError } from \"./errors.js\";\nimport { HostRpcClient } from \"./host-rpc.js\";\nimport { createLogger, type Logger } from \"./logger.js\";\nimport { runWithParentRequestId } from \"./request-context.js\";\nimport { PluginStorage } from \"./storage.js\";\nimport type {\n BookMetadataProvider,\n MetadataContentType,\n MetadataProvider,\n RecommendationProvider,\n ReleaseSourceProvider,\n SyncProvider,\n} from \"./types/capabilities.js\";\nimport type { PluginManifest, ReleaseSourceCapability } from \"./types/manifest.js\";\nimport type {\n BookMatchParams,\n BookSearchParams,\n MetadataGetParams,\n MetadataMatchParams,\n MetadataSearchParams,\n} from \"./types/protocol.js\";\nimport type {\n ProfileUpdateRequest,\n RecommendationDismissRequest,\n RecommendationRequest,\n} from \"./types/recommendations.js\";\nimport type { ReleasePollRequest } from \"./types/releases.js\";\nimport { JSON_RPC_ERROR_CODES, type JsonRpcRequest, type JsonRpcResponse } from \"./types/rpc.js\";\nimport type { SyncPullRequest, SyncPushRequest } from \"./types/sync.js\";\n\n// =============================================================================\n// Parameter Validation\n// =============================================================================\n\ninterface ValidationError {\n field: string;\n message: string;\n}\n\n/**\n * Validate that the required string fields are present and non-empty\n */\nfunction validateStringFields(params: unknown, fields: string[]): ValidationError | null {\n if (params === null || params === undefined) {\n return { field: \"params\", message: \"params is required\" };\n }\n if (typeof params !== \"object\") {\n return { field: \"params\", message: \"params must be an object\" };\n }\n\n const obj = params as Record<string, unknown>;\n for (const field of fields) {\n const value = obj[field];\n if (value === undefined || value === null) {\n return { field, message: `${field} is required` };\n }\n if (typeof value !== \"string\") {\n return { field, message: `${field} must be a string` };\n }\n if (value.trim() === \"\") {\n return { field, message: `${field} cannot be empty` };\n }\n }\n\n return null;\n}\n\n/**\n * Validate MetadataSearchParams\n */\nfunction validateSearchParams(params: unknown): ValidationError | null {\n return validateStringFields(params, [\"query\"]);\n}\n\n/**\n * Validate MetadataGetParams\n */\nfunction validateGetParams(params: unknown): ValidationError | null {\n return validateStringFields(params, [\"externalId\"]);\n}\n\n/**\n * Validate MetadataMatchParams\n */\nfunction validateMatchParams(params: unknown): ValidationError | null {\n return validateStringFields(params, [\"title\"]);\n}\n\n/**\n * Validate BookSearchParams - requires either isbn or query\n */\nfunction validateBookSearchParams(params: unknown): ValidationError | null {\n if (params === null || params === undefined) {\n return { field: \"params\", message: \"params is required\" };\n }\n if (typeof params !== \"object\") {\n return { field: \"params\", message: \"params must be an object\" };\n }\n\n const obj = params as Record<string, unknown>;\n const hasIsbn = obj.isbn !== undefined && obj.isbn !== null && obj.isbn !== \"\";\n const hasQuery = obj.query !== undefined && obj.query !== null && obj.query !== \"\";\n\n if (!hasIsbn && !hasQuery) {\n return { field: \"isbn/query\", message: \"either isbn or query is required\" };\n }\n\n return null;\n}\n\n/**\n * Validate BookMatchParams\n */\nfunction validateBookMatchParams(params: unknown): ValidationError | null {\n return validateStringFields(params, [\"title\"]);\n}\n\n/**\n * Create an INVALID_PARAMS error response\n */\nfunction invalidParamsError(id: string | number | null, error: ValidationError): JsonRpcResponse {\n return {\n jsonrpc: \"2.0\",\n id,\n error: {\n code: JSON_RPC_ERROR_CODES.INVALID_PARAMS,\n message: `Invalid params: ${error.message}`,\n data: { field: error.field },\n },\n };\n}\n\n// =============================================================================\n// Shared Types\n// =============================================================================\n\n/**\n * Initialize parameters received from Codex\n */\nexport interface InitializeParams {\n /** Admin-level plugin configuration (from plugin settings) */\n adminConfig?: Record<string, unknown>;\n /** Per-user plugin configuration (from user plugin settings) */\n userConfig?: Record<string, unknown>;\n /** Plugin credentials (API keys, tokens, etc.) */\n credentials?: Record<string, string>;\n /**\n * Per-user key-value storage client.\n *\n * Use this to persist data across plugin restarts (e.g., dismissed IDs,\n * cached profiles, user preferences). Storage is scoped per user-plugin\n * instance \u2014 the host resolves the user context automatically.\n */\n storage: PluginStorage;\n /**\n * Generic host reverse-RPC client.\n *\n * Use this to call host methods outside the storage namespace, notably\n * the `releases/*` methods (`releases/list_tracked`, `releases/record`,\n * `releases/source_state/get`, `releases/source_state/set`) for plugins\n * declaring the `releaseSource` capability.\n */\n hostRpc: HostRpcClient;\n}\n\n/**\n * A method router handles capability-specific JSON-RPC methods.\n * Returns a response for known methods, or null to indicate \"not my method\".\n */\ntype MethodRouter = (\n method: string,\n params: unknown,\n id: string | number | null,\n) => Promise<JsonRpcResponse | null>;\n\n// =============================================================================\n// Shared Plugin Server\n// =============================================================================\n\ninterface PluginServerOptions {\n manifest: PluginManifest;\n onInitialize?: ((params: InitializeParams) => void | Promise<void>) | undefined;\n logLevel?: \"debug\" | \"info\" | \"warn\" | \"error\" | undefined;\n label?: string | undefined;\n router: MethodRouter;\n}\n\n/**\n * Shared plugin server that handles JSON-RPC communication over stdio.\n *\n * Handles the common lifecycle methods (initialize, ping, shutdown) and\n * delegates capability-specific methods to the provided router.\n */\nfunction createPluginServer(options: PluginServerOptions): void {\n const { manifest, onInitialize, logLevel = \"info\", label, router } = options;\n const logger = createLogger({ name: manifest.name, level: logLevel });\n const prefix = label ? `${label} plugin` : \"plugin\";\n const storage = new PluginStorage();\n const hostRpc = new HostRpcClient();\n\n logger.info(`Starting ${prefix}: ${manifest.displayName} v${manifest.version}`);\n\n const rl = createInterface({\n input: process.stdin,\n terminal: false,\n });\n\n rl.on(\"line\", (line) => {\n void handleLine(line, manifest, onInitialize, router, logger, storage, hostRpc);\n });\n\n rl.on(\"close\", () => {\n logger.info(\"stdin closed, shutting down\");\n storage.cancelAll();\n hostRpc.cancelAll();\n process.exit(0);\n });\n\n process.on(\"uncaughtException\", (error) => {\n logger.error(\"Uncaught exception\", error);\n process.exit(1);\n });\n\n process.on(\"unhandledRejection\", (reason) => {\n logger.error(\"Unhandled rejection\", reason);\n });\n}\n\n/**\n * Detect whether a parsed JSON object is a JSON-RPC response (not a request).\n *\n * A response has `id` and either `result` or `error`, but no `method`.\n * A request always has `method`.\n */\nfunction isJsonRpcResponse(obj: Record<string, unknown>): boolean {\n if (obj.method !== undefined) return false;\n if (obj.id === undefined || obj.id === null) return false;\n return \"result\" in obj || \"error\" in obj;\n}\n\nasync function handleLine(\n line: string,\n manifest: PluginManifest,\n onInitialize: ((params: InitializeParams) => void | Promise<void>) | undefined,\n router: MethodRouter,\n logger: Logger,\n storage: PluginStorage,\n hostRpc: HostRpcClient,\n): Promise<void> {\n const trimmed = line.trim();\n if (!trimmed) return;\n\n // Try to detect responses (storage or host-rpc) before full request handling.\n // Both come from the host on stdin \u2014 they have id + (result|error) but no\n // method field. The two clients use disjoint id ranges so each can claim\n // ownership without coordination; whichever owns the id resolves it.\n let parsed: Record<string, unknown> | undefined;\n try {\n parsed = JSON.parse(trimmed) as Record<string, unknown>;\n } catch {\n // Will be handled as a parse error below\n }\n\n if (parsed && isJsonRpcResponse(parsed)) {\n logger.debug(\"Routing reverse-RPC response\", { id: parsed.id });\n if (!hostRpc.handleResponse(trimmed)) {\n storage.handleResponse(trimmed);\n }\n return;\n }\n\n let id: string | number | null = null;\n\n try {\n const request = (parsed ?? JSON.parse(trimmed)) as JsonRpcRequest;\n id = request.id;\n\n logger.debug(`Received request: ${request.method}`, { id: request.id });\n\n // Run the request handler inside the parent-request async-local context.\n // Reverse-RPCs the handler issues via `HostRpcClient.call` will read this\n // and stamp `parentRequestId` so the host can route the call back to the\n // originating task. See `request-context.ts`.\n const response = await runWithParentRequestId(request.id, () =>\n handleRequest(request, manifest, onInitialize, router, logger, storage, hostRpc),\n );\n if (response !== null) {\n writeResponse(response);\n }\n } catch (error) {\n if (error instanceof SyntaxError) {\n writeResponse({\n jsonrpc: \"2.0\",\n id: null,\n error: {\n code: JSON_RPC_ERROR_CODES.PARSE_ERROR,\n message: \"Parse error: invalid JSON\",\n },\n });\n } else if (error instanceof PluginError) {\n writeResponse({\n jsonrpc: \"2.0\",\n id,\n error: error.toJsonRpcError(),\n });\n } else {\n const message = error instanceof Error ? error.message : \"Unknown error\";\n logger.error(\"Request failed\", error);\n writeResponse({\n jsonrpc: \"2.0\",\n id,\n error: {\n code: JSON_RPC_ERROR_CODES.INTERNAL_ERROR,\n message,\n },\n });\n }\n }\n}\n\nasync function handleRequest(\n request: JsonRpcRequest,\n manifest: PluginManifest,\n onInitialize: ((params: InitializeParams) => void | Promise<void>) | undefined,\n router: MethodRouter,\n logger: Logger,\n storage: PluginStorage,\n hostRpc: HostRpcClient,\n): Promise<JsonRpcResponse | null> {\n const { method, params, id } = request;\n\n // Common lifecycle methods\n switch (method) {\n case \"initialize\": {\n const initParams = (params ?? {}) as InitializeParams;\n // Inject the reverse-RPC clients so plugins can persist data and\n // call host-side methods (e.g. releases/list_tracked).\n initParams.storage = storage;\n initParams.hostRpc = hostRpc;\n if (onInitialize) {\n await onInitialize(initParams);\n }\n return { jsonrpc: \"2.0\", id, result: manifest };\n }\n\n case \"ping\":\n return { jsonrpc: \"2.0\", id, result: \"pong\" };\n\n case \"shutdown\": {\n logger.info(\"Shutdown requested\");\n storage.cancelAll();\n hostRpc.cancelAll();\n const response: JsonRpcResponse = { jsonrpc: \"2.0\", id, result: null };\n process.stdout.write(`${JSON.stringify(response)}\\n`, () => {\n process.exit(0);\n });\n // Response already written above; return null so handleLine skips the write\n return null;\n }\n }\n\n // Delegate to capability-specific router\n const response = await router(method, params, id);\n if (response !== null) {\n return response;\n }\n\n // Unknown method\n return {\n jsonrpc: \"2.0\",\n id,\n error: {\n code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,\n message: `Method not found: ${method}`,\n },\n };\n}\n\nfunction writeResponse(response: JsonRpcResponse): void {\n process.stdout.write(`${JSON.stringify(response)}\\n`);\n}\n\n// =============================================================================\n// Response Helpers\n// =============================================================================\n\nfunction methodNotFound(id: string | number | null, message: string): JsonRpcResponse {\n return {\n jsonrpc: \"2.0\",\n id,\n error: {\n code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,\n message,\n },\n };\n}\n\nfunction success(id: string | number | null, result: unknown): JsonRpcResponse {\n return { jsonrpc: \"2.0\", id, result };\n}\n\n// =============================================================================\n// Metadata Plugin\n// =============================================================================\n\n/**\n * Options for creating a metadata plugin\n */\nexport interface MetadataPluginOptions {\n /** Plugin manifest - must have capabilities.metadataProvider with content types */\n manifest: PluginManifest & {\n capabilities: { metadataProvider: MetadataContentType[] };\n };\n /** Series MetadataProvider implementation (required if \"series\" in metadataProvider) */\n provider?: MetadataProvider;\n /** Book MetadataProvider implementation (required if \"book\" in metadataProvider) */\n bookProvider?: BookMetadataProvider;\n /** Called when plugin receives initialize with credentials/config */\n onInitialize?: (params: InitializeParams) => void | Promise<void>;\n /** Log level (default: \"info\") */\n logLevel?: \"debug\" | \"info\" | \"warn\" | \"error\";\n}\n\n/**\n * Create and run a metadata provider plugin\n *\n * Creates a plugin server that handles JSON-RPC communication over stdio.\n * The TypeScript compiler will ensure you implement all required methods.\n *\n * @example\n * ```typescript\n * import { createMetadataPlugin, type MetadataProvider } from \"@ashdev/codex-plugin-sdk\";\n *\n * const provider: MetadataProvider = {\n * async search(params) {\n * return {\n * results: [{\n * externalId: \"123\",\n * title: \"Example\",\n * alternateTitles: [],\n * relevanceScore: 0.95,\n * }],\n * };\n * },\n * async get(params) {\n * return {\n * externalId: params.externalId,\n * externalUrl: \"https://example.com/123\",\n * alternateTitles: [],\n * genres: [],\n * tags: [],\n * authors: [],\n * artists: [],\n * externalLinks: [],\n * };\n * },\n * };\n *\n * createMetadataPlugin({\n * manifest: {\n * name: \"my-plugin\",\n * displayName: \"My Plugin\",\n * version: \"1.0.0\",\n * description: \"Example plugin\",\n * author: \"Me\",\n * protocolVersion: \"1.0\",\n * capabilities: { metadataProvider: [\"series\"] },\n * },\n * provider,\n * });\n * ```\n */\nexport function createMetadataPlugin(options: MetadataPluginOptions): void {\n const { manifest, provider, bookProvider, onInitialize, logLevel } = options;\n\n // Validate that required providers are present based on manifest\n const contentTypes = manifest.capabilities.metadataProvider;\n if (contentTypes.includes(\"series\") && !provider) {\n throw new Error(\n \"Series metadata provider is required when 'series' is in metadataProvider capabilities\",\n );\n }\n if (contentTypes.includes(\"book\") && !bookProvider) {\n throw new Error(\n \"Book metadata provider is required when 'book' is in metadataProvider capabilities\",\n );\n }\n\n const router: MethodRouter = async (method, params, id) => {\n switch (method) {\n // Series metadata methods\n case \"metadata/series/search\": {\n if (!provider) return methodNotFound(id, \"This plugin does not support series metadata\");\n const err = validateSearchParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await provider.search(params as MetadataSearchParams));\n }\n case \"metadata/series/get\": {\n if (!provider) return methodNotFound(id, \"This plugin does not support series metadata\");\n const err = validateGetParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await provider.get(params as MetadataGetParams));\n }\n case \"metadata/series/match\": {\n if (!provider) return methodNotFound(id, \"This plugin does not support series metadata\");\n if (!provider.match) return methodNotFound(id, \"This plugin does not support series match\");\n const err = validateMatchParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await provider.match(params as MetadataMatchParams));\n }\n\n // Book metadata methods\n case \"metadata/book/search\": {\n if (!bookProvider) return methodNotFound(id, \"This plugin does not support book metadata\");\n const err = validateBookSearchParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await bookProvider.search(params as BookSearchParams));\n }\n case \"metadata/book/get\": {\n if (!bookProvider) return methodNotFound(id, \"This plugin does not support book metadata\");\n const err = validateGetParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await bookProvider.get(params as MetadataGetParams));\n }\n case \"metadata/book/match\": {\n if (!bookProvider) return methodNotFound(id, \"This plugin does not support book metadata\");\n if (!bookProvider.match)\n return methodNotFound(id, \"This plugin does not support book match\");\n const err = validateBookMatchParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await bookProvider.match(params as BookMatchParams));\n }\n\n default:\n return null;\n }\n };\n\n createPluginServer({ manifest, onInitialize, logLevel, router });\n}\n\n// =============================================================================\n// Sync Plugin\n// =============================================================================\n\n/**\n * Options for creating a sync provider plugin\n */\nexport interface SyncPluginOptions {\n /** Plugin manifest - must have capabilities.userReadSync: true */\n manifest: PluginManifest & {\n capabilities: { userReadSync: true };\n };\n /** SyncProvider implementation */\n provider: SyncProvider;\n /** Called when plugin receives initialize with credentials/config */\n onInitialize?: (params: InitializeParams) => void | Promise<void>;\n /** Log level (default: \"info\") */\n logLevel?: \"debug\" | \"info\" | \"warn\" | \"error\";\n}\n\n/**\n * Create and run a sync provider plugin\n *\n * Creates a plugin server that handles JSON-RPC communication over stdio\n * for sync operations (push/pull reading progress with external services).\n *\n * @example\n * ```typescript\n * import { createSyncPlugin, type SyncProvider } from \"@ashdev/codex-plugin-sdk\";\n *\n * const provider: SyncProvider = {\n * async getUserInfo() {\n * return { externalId: \"123\", username: \"user\" };\n * },\n * async pushProgress(params) {\n * return { success: [], failed: [] };\n * },\n * async pullProgress(params) {\n * return { entries: [], hasMore: false };\n * },\n * };\n *\n * createSyncPlugin({\n * manifest: {\n * name: \"my-sync-plugin\",\n * displayName: \"My Sync Plugin\",\n * version: \"1.0.0\",\n * description: \"Syncs reading progress\",\n * author: \"Me\",\n * protocolVersion: \"1.0\",\n * capabilities: { userReadSync: true },\n * },\n * provider,\n * });\n * ```\n */\nexport function createSyncPlugin(options: SyncPluginOptions): void {\n const { manifest, provider, onInitialize, logLevel } = options;\n\n const router: MethodRouter = async (method, params, id) => {\n switch (method) {\n case \"sync/getUserInfo\":\n return success(id, await provider.getUserInfo());\n case \"sync/pushProgress\":\n return success(id, await provider.pushProgress(params as SyncPushRequest));\n case \"sync/pullProgress\":\n return success(id, await provider.pullProgress(params as SyncPullRequest));\n case \"sync/status\": {\n if (!provider.status) return methodNotFound(id, \"This plugin does not support sync/status\");\n return success(id, await provider.status());\n }\n default:\n return null;\n }\n };\n\n createPluginServer({ manifest, onInitialize, logLevel, label: \"sync\", router });\n}\n\n// =============================================================================\n// Recommendation Plugin\n// =============================================================================\n\n/**\n * Options for creating a recommendation provider plugin\n */\nexport interface RecommendationPluginOptions {\n /** Plugin manifest - must have capabilities.userRecommendationProvider: true */\n manifest: PluginManifest & {\n capabilities: { userRecommendationProvider: true };\n };\n /** RecommendationProvider implementation */\n provider: RecommendationProvider;\n /** Called when plugin receives initialize with credentials/config */\n onInitialize?: (params: InitializeParams) => void | Promise<void>;\n /** Log level (default: \"info\") */\n logLevel?: \"debug\" | \"info\" | \"warn\" | \"error\";\n}\n\n/**\n * Create and run a recommendation provider plugin\n *\n * Creates a plugin server that handles JSON-RPC communication over stdio\n * for recommendation operations (get recommendations, update profile, dismiss).\n */\nexport function createRecommendationPlugin(options: RecommendationPluginOptions): void {\n const { manifest, provider, onInitialize, logLevel } = options;\n\n const router: MethodRouter = async (method, params, id) => {\n switch (method) {\n case \"recommendations/get\":\n return success(id, await provider.get(params as RecommendationRequest));\n case \"recommendations/updateProfile\": {\n if (!provider.updateProfile)\n return methodNotFound(id, \"This plugin does not support recommendations/updateProfile\");\n return success(id, await provider.updateProfile(params as ProfileUpdateRequest));\n }\n case \"recommendations/clear\": {\n if (!provider.clear)\n return methodNotFound(id, \"This plugin does not support recommendations/clear\");\n return success(id, await provider.clear());\n }\n case \"recommendations/dismiss\": {\n if (!provider.dismiss)\n return methodNotFound(id, \"This plugin does not support recommendations/dismiss\");\n const err = validateStringFields(params, [\"externalId\"]);\n if (err) return invalidParamsError(id, err);\n return success(id, await provider.dismiss(params as RecommendationDismissRequest));\n }\n default:\n return null;\n }\n };\n\n createPluginServer({ manifest, onInitialize, logLevel, label: \"recommendation\", router });\n}\n\n// =============================================================================\n// Release Source Plugin\n// =============================================================================\n\n/**\n * Validate `releases/poll` parameters. Requires a non-empty `sourceId` string;\n * `etag` is optional.\n */\nfunction validateReleasePollParams(params: unknown): ValidationError | null {\n return validateStringFields(params, [\"sourceId\"]);\n}\n\n/**\n * Options for creating a release-source plugin.\n */\nexport interface ReleaseSourcePluginOptions {\n /** Plugin manifest. Must declare `capabilities.releaseSource`. */\n manifest: PluginManifest & {\n capabilities: { releaseSource: ReleaseSourceCapability };\n };\n /** ReleaseSourceProvider implementation. */\n provider: ReleaseSourceProvider;\n /** Called when plugin receives initialize with credentials/config. */\n onInitialize?: (params: InitializeParams) => void | Promise<void>;\n /** Log level (default: \"info\"). */\n logLevel?: \"debug\" | \"info\" | \"warn\" | \"error\";\n}\n\n/**\n * Create and run a release-source plugin.\n *\n * The host calls `releases/poll` on a schedule (per `release_sources` row).\n * The plugin returns candidates either inline (in the poll response) or by\n * streaming `releases/record` reverse-RPC calls during the poll. Both styles\n * are supported by the host.\n *\n * Plugins typically:\n * 1. Fetch tracked series via `releases/list_tracked`.\n * 2. For each series, GET the upstream feed (with `If-None-Match` from the\n * previous ETag).\n * 3. Parse + filter (language, group blocklist, etc.).\n * 4. Either return all candidates in the poll response or call\n * `releases/record` for each.\n * 5. Persist the new ETag via `releases/source_state/set` (or include it on\n * the poll response).\n *\n * @example\n * ```typescript\n * import { createReleaseSourcePlugin, type ReleaseSourceProvider } from \"@ashdev/codex-plugin-sdk\";\n *\n * const provider: ReleaseSourceProvider = {\n * async poll({ sourceId, etag }) {\n * // ...fetch + parse...\n * return { candidates: [...], etag: \"new-etag\" };\n * },\n * };\n *\n * createReleaseSourcePlugin({ manifest, provider });\n * ```\n */\nexport function createReleaseSourcePlugin(options: ReleaseSourcePluginOptions): void {\n const { manifest, provider, onInitialize, logLevel } = options;\n\n if (!manifest.capabilities.releaseSource) {\n throw new Error(\n \"manifest.capabilities.releaseSource is required for createReleaseSourcePlugin\",\n );\n }\n\n const router: MethodRouter = async (method, params, id) => {\n switch (method) {\n case \"releases/poll\": {\n const err = validateReleasePollParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await provider.poll(params as ReleasePollRequest));\n }\n default:\n return null;\n }\n };\n\n createPluginServer({ manifest, onInitialize, logLevel, label: \"release-source\", router });\n}\n", "/**\n * Plugin Storage - key-value storage for per-user plugin data\n *\n * Storage is scoped per user-plugin instance. Plugins only specify a key;\n * the host resolves the user context from the connection.\n *\n * Plugins send storage requests as JSON-RPC calls to the host over stdout\n * and receive responses on stdin. This is the reverse of the normal\n * host-to-plugin request flow.\n *\n * @example\n * ```typescript\n * import { PluginStorage } from \"@ashdev/codex-plugin-sdk\";\n *\n * const storage = new PluginStorage();\n *\n * // Store data\n * await storage.set(\"taste_profile\", { genres: [\"action\", \"drama\"] });\n *\n * // Retrieve data\n * const data = await storage.get(\"taste_profile\");\n *\n * // Store with TTL (expires in 24 hours)\n * const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();\n * await storage.set(\"cache\", { items: [1, 2, 3] }, expires);\n *\n * // List all keys\n * const keys = await storage.list();\n *\n * // Delete a key\n * await storage.delete(\"cache\");\n *\n * // Clear all data\n * await storage.clear();\n * ```\n */\n\nimport type { JsonRpcError, JsonRpcRequest } from \"./types/rpc.js\";\n\n// =============================================================================\n// Storage Types\n// =============================================================================\n\n/** Response from storage/get */\nexport interface StorageGetResponse {\n /** The stored data, or null if key doesn't exist */\n data: unknown | null;\n /** Expiration timestamp (ISO 8601) if TTL was set */\n expiresAt?: string;\n}\n\n/** Response from storage/set */\nexport interface StorageSetResponse {\n /** Always true on success */\n success: boolean;\n}\n\n/** Response from storage/delete */\nexport interface StorageDeleteResponse {\n /** Whether the key existed and was deleted */\n deleted: boolean;\n}\n\n/** Individual key entry from storage/list */\nexport interface StorageKeyEntry {\n /** Storage key name */\n key: string;\n /** Expiration timestamp (ISO 8601) if TTL was set */\n expiresAt?: string;\n /** Last update timestamp (ISO 8601) */\n updatedAt: string;\n}\n\n/** Response from storage/list */\nexport interface StorageListResponse {\n /** All keys for this plugin instance (excluding expired) */\n keys: StorageKeyEntry[];\n}\n\n/** Response from storage/clear */\nexport interface StorageClearResponse {\n /** Number of entries deleted */\n deletedCount: number;\n}\n\n// =============================================================================\n// Storage Error\n// =============================================================================\n\n/** Error from a storage operation */\nexport class StorageError extends Error {\n constructor(\n message: string,\n public readonly code: number,\n public readonly data?: unknown,\n ) {\n super(message);\n this.name = \"StorageError\";\n }\n}\n\n// =============================================================================\n// Plugin Storage Client\n// =============================================================================\n\n/** Write function signature for sending JSON-RPC requests */\ntype WriteFn = (line: string) => void;\n\n/**\n * Client for plugin key-value storage.\n *\n * Sends JSON-RPC requests to the host process over stdout and reads\n * responses on stdin. Each request gets a unique ID so responses can\n * be correlated even if they arrive out of order.\n */\nexport class PluginStorage {\n private nextId = 1;\n private pendingRequests = new Map<\n string | number,\n {\n resolve: (value: unknown) => void;\n reject: (error: Error) => void;\n }\n >();\n private writeFn: WriteFn;\n\n /**\n * Create a new storage client.\n *\n * @param writeFn - Optional custom write function (defaults to process.stdout.write).\n * Useful for testing or custom transport layers.\n */\n constructor(writeFn?: WriteFn) {\n this.writeFn =\n writeFn ??\n ((line: string) => {\n process.stdout.write(line);\n });\n }\n\n /**\n * Get a value by key\n *\n * @param key - Storage key to retrieve\n * @returns The stored data and optional expiration, or null data if key doesn't exist\n */\n async get(key: string): Promise<StorageGetResponse> {\n return (await this.sendRequest(\"storage/get\", { key })) as StorageGetResponse;\n }\n\n /**\n * Set a value by key (upsert - creates or updates)\n *\n * @param key - Storage key\n * @param data - JSON-serializable data to store\n * @param expiresAt - Optional expiration timestamp (ISO 8601)\n * @returns Success indicator\n */\n async set(key: string, data: unknown, expiresAt?: string): Promise<StorageSetResponse> {\n const params: Record<string, unknown> = { key, data };\n if (expiresAt !== undefined) {\n params.expiresAt = expiresAt;\n }\n return (await this.sendRequest(\"storage/set\", params)) as StorageSetResponse;\n }\n\n /**\n * Delete a value by key\n *\n * @param key - Storage key to delete\n * @returns Whether the key existed and was deleted\n */\n async delete(key: string): Promise<StorageDeleteResponse> {\n return (await this.sendRequest(\"storage/delete\", { key })) as StorageDeleteResponse;\n }\n\n /**\n * List all keys for this plugin instance (excluding expired)\n *\n * @returns List of key entries with metadata\n */\n async list(): Promise<StorageListResponse> {\n return (await this.sendRequest(\"storage/list\", {})) as StorageListResponse;\n }\n\n /**\n * Clear all data for this plugin instance\n *\n * @returns Number of entries deleted\n */\n async clear(): Promise<StorageClearResponse> {\n return (await this.sendRequest(\"storage/clear\", {})) as StorageClearResponse;\n }\n\n /**\n * Handle an incoming JSON-RPC response line from the host.\n *\n * Call this method from your readline handler to deliver responses\n * back to pending storage requests.\n */\n handleResponse(line: string): void {\n const trimmed = line.trim();\n if (!trimmed) return;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(trimmed);\n } catch {\n // Not JSON - ignore\n return;\n }\n\n const obj = parsed as Record<string, unknown>;\n\n // Only handle responses (have \"result\" or \"error\", no \"method\")\n if (obj.method !== undefined) {\n // This is a host-to-plugin request, not a storage response - ignore\n return;\n }\n\n const id = obj.id;\n if (id === undefined || id === null) return;\n\n const pending = this.pendingRequests.get(id as string | number);\n if (!pending) return;\n\n this.pendingRequests.delete(id as string | number);\n\n if (\"error\" in obj && obj.error) {\n const err = obj.error as JsonRpcError;\n pending.reject(new StorageError(err.message, err.code, err.data));\n } else {\n pending.resolve(obj.result);\n }\n }\n\n /**\n * Cancel all pending requests (e.g. on shutdown).\n */\n cancelAll(): void {\n for (const [, pending] of this.pendingRequests) {\n pending.reject(new StorageError(\"Storage client stopped\", -1));\n }\n this.pendingRequests.clear();\n }\n\n // ===========================================================================\n // Internal\n // ===========================================================================\n\n private sendRequest(method: string, params: unknown): Promise<unknown> {\n const id = this.nextId++;\n\n const request: JsonRpcRequest = {\n jsonrpc: \"2.0\",\n id,\n method,\n params,\n };\n\n return new Promise((resolve, reject) => {\n this.pendingRequests.set(id, { resolve, reject });\n\n try {\n this.writeFn(`${JSON.stringify(request)}\\n`);\n } catch (err) {\n this.pendingRequests.delete(id);\n const message = err instanceof Error ? err.message : \"Unknown write error\";\n reject(new StorageError(`Failed to send request: ${message}`, -1));\n }\n });\n }\n}\n", "/**\n * Release-source protocol types - MUST match the Rust protocol exactly.\n *\n * Plugins implementing the `release_source` capability poll external sources\n * for new chapter/volume releases and emit `ReleaseCandidate` rows. The host\n * threshold-gates and dedups them through the `release_ledger` table.\n *\n * @see src/services/plugin/protocol.rs (`ReleasePollRequest`, `ReleasePollResponse`)\n * @see src/services/release/candidate.rs (`ReleaseCandidate`, `SeriesMatch`)\n * @see src/services/plugin/releases_handler.rs (reverse-RPC handlers)\n */\n\n// =============================================================================\n// Reverse-RPC method names (plugin -> host)\n// =============================================================================\n\n/**\n * Method names for the `releases/*` reverse-RPC namespace. Plugins call these\n * over the open RPC channel during `releases/poll` (or any other time).\n */\nexport const RELEASES_METHODS = {\n /** List tracked series, scoped to what the plugin's manifest declared. */\n LIST_TRACKED: \"releases/list_tracked\",\n /** Submit a candidate to the host's release ledger. */\n RECORD: \"releases/record\",\n /** Get persisted per-source state (etag, last_polled_at, last_error). */\n SOURCE_STATE_GET: \"releases/source_state/get\",\n /** Set persisted per-source state (etag only \u2014 other fields are host-owned). */\n SOURCE_STATE_SET: \"releases/source_state/set\",\n /**\n * Replace the set of `release_sources` rows owned by this plugin.\n *\n * Plugins call this from `onInitialize` (and after any config change, which\n * triggers a process restart that re-runs `onInitialize`). Each call carries\n * the plugin's full desired-state list; the host upserts every entry on\n * `(plugin_id, source_key)` and prunes rows whose `source_key` is not in\n * the request. User-managed fields (`enabled`, `pollIntervalS`) are\n * preserved across re-registrations so an admin's overrides aren't\n * trampled by a plugin restart.\n */\n REGISTER_SOURCES: \"releases/register_sources\",\n} as const;\n\n// =============================================================================\n// ReleaseCandidate (the wire shape plugins emit)\n// =============================================================================\n\n/**\n * Per-series match metadata attached to every candidate.\n *\n * - `codexSeriesId` is the host's UUID for the series. Plugins resolve this\n * from `releases/list_tracked` (don't invent series IDs).\n * - `confidence` (0.0..=1.0) tells the host how sure the plugin is about the\n * match. The host drops below-threshold candidates (default 0.7).\n * - `reason` is a short opaque string used for debugging/UI, e.g.\n * `\"mangaupdates_id\"`, `\"alias-exact\"`, `\"alias-fuzzy\"`.\n */\nexport interface SeriesMatch {\n codexSeriesId: string;\n confidence: number;\n reason: string;\n}\n\n/**\n * Release candidate emitted by a plugin.\n *\n * **Field semantics:**\n * - `externalReleaseId`: Stable per-source ID. The first dedup key.\n * `(sourceId, externalReleaseId)` is `UNIQUE` in `release_ledger`.\n * - `chapter` / `volume`: At least one should be set; both is fine for a\n * \"vol 15 covers ch 126-142\" case (the volume axis advances; the chapter\n * axis advances to the volume's last chapter only if the candidate\n * carries it). Decimals supported on `chapter` (e.g. 47.5).\n * - `language`: ISO 639-1 code, lowercase. Must be non-empty. The host's\n * `latest_known_*` advance gate uses this against the per-series\n * effective language list.\n * - `groupOrUploader`: Scanlation group (MangaUpdates) or torrent uploader\n * handle (Nyaa). Optional but strongly recommended.\n * - `payloadUrl`: The link the user follows to actually consume / acquire\n * the release. Must be non-empty. Conventionally a human-readable landing\n * page (Nyaa view page, MangaUpdates release page).\n * - `mediaUrl` / `mediaUrlKind`: Optional second URL describing how to\n * actually fetch the bits \u2014 a `.torrent` file, a magnet link, or a direct\n * download. Set both together; leave both unset for sources that only\n * surface a landing page. The UI renders a kind-specific icon next to\n * the standard external-link icon when these are present.\n * - `infoHash`: Torrent info_hash if applicable. Cross-source dedup key.\n * - `metadata` / `formatHints`: Free-form JSON for plugin-specific data\n * (Nyaa size in bytes, MangaUpdates \"is volume bundle\" flag, etc.).\n * - `observedAt`: When the plugin saw this entry. Used for ordering;\n * bounded by `MAX_FUTURE_SKEW_S` (1h) on the host side.\n */\nexport interface ReleaseCandidate {\n seriesMatch: SeriesMatch;\n externalReleaseId: string;\n chapter?: number | null;\n volume?: number | null;\n language: string;\n formatHints?: Record<string, unknown> | null;\n groupOrUploader?: string | null;\n payloadUrl: string;\n mediaUrl?: string | null;\n mediaUrlKind?: MediaUrlKind | null;\n infoHash?: string | null;\n metadata?: Record<string, unknown> | null;\n /** ISO-8601 timestamp. */\n observedAt: string;\n}\n\n/**\n * Classifies what `mediaUrl` points at so the UI can pick an appropriate\n * icon and the host can label it consistently across sources.\n *\n * - `torrent`: HTTP(S) URL to a `.torrent` file.\n * - `magnet`: `magnet:` URI.\n * - `direct`: HTTP(S) URL to the file itself (DDL host, CDN, etc.).\n * - `other`: anything else; render a generic download icon.\n */\nexport type MediaUrlKind = \"torrent\" | \"magnet\" | \"direct\" | \"other\";\n\n// =============================================================================\n// releases/list_tracked\n// =============================================================================\n\nexport interface ListTrackedRequest {\n sourceId: string;\n limit?: number;\n offset?: number;\n}\n\n/**\n * One tracked-series row scoped to what the plugin's manifest asked for.\n * Aliases are present only when `requiresAliases: true`; external IDs are\n * present only for sources the plugin listed in `requiresExternalIds`.\n */\nexport interface TrackedSeriesEntry {\n seriesId: string;\n aliases?: string[];\n /** Map keyed by external-ID source name (e.g. `{ mangaupdates: \"12345\" }`). */\n externalIds?: Record<string, string>;\n latestKnownChapter?: number | null;\n latestKnownVolume?: number | null;\n}\n\nexport interface ListTrackedResponse {\n tracked: TrackedSeriesEntry[];\n nextOffset?: number;\n}\n\n// =============================================================================\n// releases/record\n// =============================================================================\n\nexport interface RecordRequest {\n sourceId: string;\n candidate: ReleaseCandidate;\n}\n\nexport interface RecordResponse {\n ledgerId: string;\n /** True if the row deduped onto an existing ledger entry. */\n deduped: boolean;\n}\n\n// =============================================================================\n// releases/source_state\n// =============================================================================\n\nexport interface SourceStateGetRequest {\n sourceId: string;\n}\n\nexport interface SourceStateView {\n etag?: string;\n lastPolledAt?: string;\n lastError?: string;\n lastErrorAt?: string;\n}\n\nexport interface SourceStateSetRequest {\n sourceId: string;\n /** Only `etag` is plugin-writable; other fields are host-owned. */\n etag?: string;\n}\n\n// =============================================================================\n// releases/register_sources\n// =============================================================================\n\n/**\n * One source the plugin wants the host to materialize as a `release_sources`\n * row. The plugin owns the `sourceKey` namespace; the host treats it as an\n * opaque string for dedup keyed on `(pluginId, sourceKey)`.\n */\nexport interface RegisteredSourceInput {\n /**\n * Stable per-plugin identifier. Reuse the same key across calls so user\n * overrides (enabled, pollIntervalS) survive plugin restarts.\n */\n sourceKey: string;\n /** Human-readable label shown in the Release tracking settings UI. */\n displayName: string;\n /**\n * Must be one of the kinds the plugin declared in its\n * `releaseSource.kinds` capability \u2014 the host rejects anything else.\n */\n kind: \"rss-uploader\" | \"rss-series\" | \"api-feed\" | \"metadata-feed\";\n /**\n * Optional opaque per-source config snapshot persisted on the row. The\n * host doesn't interpret it; the plugin reads its own admin config\n * directly. Useful for surfacing \"what did this source originate from?\"\n * in the UI / logs.\n */\n config?: Record<string, unknown> | null;\n}\n\nexport interface RegisterSourcesRequest {\n sources: RegisteredSourceInput[];\n}\n\nexport interface RegisterSourcesResponse {\n /** Number of sources upserted (created or refreshed). */\n registered: number;\n /** Number of sources removed because they were not in the request. */\n pruned: number;\n}\n\n// =============================================================================\n// releases/poll (host -> plugin)\n// =============================================================================\n\n/**\n * Parameters for the host's call into a release-source plugin's\n * `releases/poll` handler. Carries the source row to poll plus any ETag the\n * plugin recorded on its previous poll, plus the plugin-defined source key\n * and per-source config snapshot so the plugin can dispatch directly without\n * a reverse-RPC roundtrip.\n */\nexport interface ReleasePollRequest {\n sourceId: string;\n /**\n * The same `sourceKey` the plugin passed to `releases/register_sources`.\n * Useful when one plugin process owns multiple source rows (e.g., one per\n * Nyaa uploader) and needs to know which one to poll.\n */\n sourceKey?: string;\n /**\n * Snapshot of `release_sources.config` for this row. Plugins that stash\n * per-source config on register can read it back here.\n */\n config?: Record<string, unknown> | null;\n etag?: string;\n}\n\n/**\n * Response from a `releases/poll` call.\n *\n * Plugins may also stream candidates over `releases/record` mid-poll; the\n * host treats both styles identically. Use `candidates` for plugins that\n * prefer to return everything at once.\n *\n * Plugins that stream via `releases/record` should also populate the\n * counter fields (`parsed`, `matched`, `recorded`, `deduped`). Without\n * them, the host can only see what came back in `candidates` and the\n * source's status badge will read \"Fetched 0 items\" no matter what\n * actually happened.\n */\nexport interface ReleasePollResponse {\n /** Optional batch of candidates the host should evaluate and ledger. */\n candidates?: ReleaseCandidate[];\n /** New ETag observed (e.g., from the upstream feed's `ETag` header). */\n etag?: string;\n /** Whether the upstream returned `304 Not Modified` (or equivalent). */\n notModified?: boolean;\n /** HTTP status code observed (used by host's per-host backoff). */\n upstreamStatus?: number;\n /**\n * Items the plugin parsed from the upstream feed before any matching\n * or threshold filtering. Streaming plugins should set this so the\n * host's `last_summary` reflects upstream activity, not just the shape\n * of the response payload.\n */\n parsed?: number;\n /**\n * Of those parsed, the count that matched a tracked-series alias (i.e.\n * became candidates the plugin then evaluated/streamed).\n */\n matched?: number;\n /**\n * Of those matched, the count actually inserted into the ledger\n * (excludes dedupes). For plugins that stream via `releases/record`,\n * this is the count of non-deduped record outcomes.\n */\n recorded?: number;\n /**\n * Of those matched, the count the host deduped onto an existing ledger\n * row. Optional; when omitted the host infers `matched - recorded`.\n */\n deduped?: number;\n}\n", "/**\n * MangaUpdates per-series RSS fetcher.\n *\n * Wraps `fetch` with conditional GET (`If-None-Match` from a stored ETag) and\n * a hard timeout. Returns a discriminated result so the caller can:\n * - act on `200`: parse the body, persist the new ETag.\n * - skip parse on `304`: nothing changed since last poll.\n * - report `429` / `5xx` upstream-status codes back to the host so the\n * per-host backoff layer can react.\n *\n * Network is the only side effect; nothing in here touches storage, the host,\n * or process state. That keeps it trivially testable: pass a mocked `fetch`\n * implementation and assert.\n */\n\n/** Discriminated fetch result. */\nexport type FetchResult =\n | { kind: \"ok\"; body: string; etag: string | null; status: 200 }\n | { kind: \"notModified\"; status: 304 }\n | { kind: \"error\"; status: number; message: string };\n\nexport interface FetcherOptions {\n /** Custom `fetch` impl (for testing). Defaults to global `fetch`. */\n fetchImpl?: typeof fetch;\n /** Per-request timeout. Defaults to 10s. */\n timeoutMs?: number;\n}\n\n/** Public base URL for MangaUpdates' v1 RSS API. */\nexport const MANGAUPDATES_RSS_BASE = \"https://api.mangaupdates.com/v1/series\";\n\n/**\n * Normalize a MangaUpdates series ID to its numeric form for API calls.\n *\n * MangaUpdates uses two interchangeable representations of the same ID:\n *\n * - **Numeric** (e.g. `15180124327`) \u2014 the internal primary key. Every\n * `/v1/series/...` API endpoint requires this form.\n * - **Base36 slug** (e.g. `6z1uqw7`) \u2014 a base36 encoding of the numeric\n * ID, used in public URLs only (`mangaupdates.com/series/6z1uqw7/...`).\n * The API rejects this form with a 405.\n *\n * Metadata sources (MangaBaka, etc.) typically scrape the public URL and\n * store the slug, so the value we receive on `entry.externalIds.mangaupdates`\n * is whatever the source happened to grab. Decode here so callers don't\n * have to know.\n *\n * Returns the input unchanged when it's already an all-digit string;\n * `null` when the input contains characters outside the base36 alphabet\n * (caller should surface as a configuration error).\n */\nexport function normalizeMangaUpdatesId(raw: string): string | null {\n const trimmed = raw.trim();\n if (trimmed.length === 0) return null;\n if (/^\\d+$/.test(trimmed)) return trimmed;\n if (!/^[0-9a-z]+$/i.test(trimmed)) return null;\n // parseInt('6z1uqw7', 36) = 15180124327. JS numbers are precise for\n // integers up to 2^53; MangaUpdates IDs sit well below that.\n const decoded = Number.parseInt(trimmed, 36);\n if (!Number.isFinite(decoded) || decoded <= 0) return null;\n return String(decoded);\n}\n\n/**\n * Build the per-series RSS URL. Accepts either the numeric ID or the\n * base36 slug \u2014 see `normalizeMangaUpdatesId` for the rationale.\n */\nexport function feedUrl(mangaUpdatesId: string): string {\n const normalized = normalizeMangaUpdatesId(mangaUpdatesId) ?? mangaUpdatesId;\n return `${MANGAUPDATES_RSS_BASE}/${normalized}/rss`;\n}\n\n/**\n * Conditional GET against a per-series RSS feed.\n *\n * @param mangaUpdatesId - The MangaUpdates series ID.\n * @param previousEtag - The ETag from the previous successful poll (if any).\n * @param opts - Fetcher options (custom fetch, timeout).\n */\nexport async function fetchSeriesFeed(\n mangaUpdatesId: string,\n previousEtag: string | null,\n opts: FetcherOptions = {},\n): Promise<FetchResult> {\n const fetchImpl = opts.fetchImpl ?? globalThis.fetch;\n const timeoutMs = opts.timeoutMs ?? 10_000;\n\n const url = feedUrl(mangaUpdatesId);\n const headers: Record<string, string> = {\n Accept: \"application/rss+xml, application/xml;q=0.9, */*;q=0.5\",\n \"User-Agent\": \"Codex-ReleaseTracker/1.0 (+https://github.com/AshDevFr/codex)\",\n };\n if (previousEtag) {\n headers[\"If-None-Match\"] = previousEtag;\n }\n\n // AbortSignal.timeout is the cleanest path. Falling back to a manual\n // controller would add complexity without value (we already require Node\n // 22+).\n const signal = AbortSignal.timeout(timeoutMs);\n\n let resp: Response;\n try {\n resp = await fetchImpl(url, { method: \"GET\", headers, signal });\n } catch (err) {\n const msg = err instanceof Error ? err.message : \"Unknown fetch error\";\n // Treat aborts and other transport-level failures as 0/unavailable so\n // the host's per-host backoff layer can detect \"this domain is sad\n // right now\" without us having to invent a fake HTTP status.\n return { kind: \"error\", status: 0, message: msg };\n }\n\n if (resp.status === 304) {\n return { kind: \"notModified\", status: 304 };\n }\n\n if (resp.status === 200) {\n const body = await resp.text();\n const etag = resp.headers.get(\"etag\");\n return { kind: \"ok\", body, etag, status: 200 };\n }\n\n // Pass through 429 / 5xx so the host's backoff layer sees the real status.\n return {\n kind: \"error\",\n status: resp.status,\n message: `upstream returned ${resp.status} ${resp.statusText}`,\n };\n}\n", "/**\n * RSS parser for MangaUpdates per-series feeds.\n *\n * Per-series feed: `https://api.mangaupdates.com/v1/series/{series_id}/rss`\n *\n * Each `<item>` is one scanlation release. The plugin extracts:\n * - chapter / volume from the title\n * - scanlation group from the title\n * - language tag (parenthesized two-letter code) from the title\n * - link (the MangaUpdates release page) used as `payloadUrl`\n * - pubDate as `observedAt`\n *\n * Implementation note: we do NOT pull in a heavy XML parser. The MangaUpdates\n * RSS format is simple, well-formed, and stable. A small targeted regex\n * pipeline avoids a 100kb dependency and CVE surface for marginal benefit.\n */\n\n/** Parsed item, pre-`ReleaseCandidate`. */\nexport interface ParsedRssItem {\n /** Stable per-source ID. Derived from the release URL or guid. */\n externalReleaseId: string;\n /** Original title string. Useful for debugging / fallback. */\n title: string;\n /** Chapter number (decimals supported, e.g. \"47.5\"). */\n chapter: number | null;\n /** Volume number. */\n volume: number | null;\n /**\n * Language tag (lowercased ISO 639-1). Defaults to `\"en\"` when the title\n * doesn't carry an explicit `(xx)` code, since the MangaUpdates v1 RSS\n * endpoint serves the English release stream. The legacy\n * `UNKNOWN_LANGUAGE` sentinel is still exported for callers that want\n * to surface \"no tag detected\" explicitly, but the parser no longer\n * produces it on its own.\n */\n language: string;\n /** Scanlation group name (best-effort; nullable). */\n group: string | null;\n /** Release page URL on MangaUpdates. Used as `payloadUrl`. */\n link: string;\n /** ISO-8601 string. Falls back to \"now\" when pubDate is missing/invalid. */\n observedAt: string;\n}\n\n/** Sentinel returned when the language tag can't be detected. */\nexport const UNKNOWN_LANGUAGE = \"unknown\" as const;\n\n// -----------------------------------------------------------------------------\n// XML helpers\n// -----------------------------------------------------------------------------\n\n/** Strip CDATA wrapper if present, unescape `&` `<` `>` `"`. */\nfunction decodeXmlText(raw: string): string {\n let s = raw.trim();\n const cdataMatch = s.match(/^<!\\[CDATA\\[([\\s\\S]*?)]]>$/);\n if (cdataMatch?.[1] !== undefined) {\n s = cdataMatch[1];\n }\n return s\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n .replace(/'/g, \"'\");\n}\n\n/** Pull the first `<tag>` text content from an XML fragment, or null. */\nfunction extractTagText(xml: string, tag: string): string | null {\n const re = new RegExp(`<${tag}[^>]*>([\\\\s\\\\S]*?)</${tag}>`, \"i\");\n const m = xml.match(re);\n if (!m?.[1]) return null;\n return decodeXmlText(m[1]);\n}\n\n/** Pull all `<item>...</item>` blocks from a feed. */\nfunction splitItems(xml: string): string[] {\n const out: string[] = [];\n const re = /<item\\b[^>]*>([\\s\\S]*?)<\\/item>/gi;\n for (;;) {\n const match = re.exec(xml);\n if (match === null) break;\n if (match[1] !== undefined) out.push(match[1]);\n }\n return out;\n}\n\n// -----------------------------------------------------------------------------\n// Title parsing\n// -----------------------------------------------------------------------------\n\n/**\n * Extract chapter/volume/group/language from a MangaUpdates RSS title.\n *\n * Observed shapes:\n * \"Vol.2 c.14 by GroupName (en)\"\n * \"v.2 c.14.5 by GroupName (es)\"\n * \"c.143 by GroupName\" (language missing)\n * \"Vol.15 by GroupName (en)\" (volume-only bundle)\n * \"c.143 (en)\" (no group)\n *\n * Volume tokens: `v.N`, `vol.N`, `Vol.N` (case-insensitive).\n * Chapter tokens: `c.N`, `ch.N`, `Ch.N` (decimals allowed).\n * Group: text between `by ` and the next `(` or end-of-string.\n * Language: trailing `(xx)` two-letter code, lowercased.\n */\nexport function parseTitle(title: string): {\n chapter: number | null;\n volume: number | null;\n group: string | null;\n language: string;\n} {\n const trimmed = title.trim();\n\n // Chapter: c.N or ch.N (allow decimals).\n let chapter: number | null = null;\n const chMatch = trimmed.match(/\\bc(?:h)?\\.?\\s*([0-9]+(?:\\.[0-9]+)?)\\b/i);\n if (chMatch?.[1]) {\n const n = Number.parseFloat(chMatch[1]);\n if (Number.isFinite(n)) chapter = n;\n }\n\n // Volume: v.N or vol.N.\n let volume: number | null = null;\n const volMatch = trimmed.match(/\\bv(?:ol)?\\.?\\s*([0-9]+)\\b/i);\n if (volMatch?.[1]) {\n const n = Number.parseInt(volMatch[1], 10);\n if (Number.isFinite(n)) volume = n;\n }\n\n // Group: \"by <Group>\" up to \"(\" or end.\n let group: string | null = null;\n const groupMatch = trimmed.match(/\\bby\\s+(.+?)(?:\\s*\\([a-z]{2,3}\\)\\s*)?$/i);\n if (groupMatch?.[1]) {\n const candidate = groupMatch[1].trim();\n if (candidate.length > 0) group = candidate;\n }\n\n // Language: trailing parenthesized 2-3 letter code (e.g. (en), (es), (id), (por)).\n //\n // The current MangaUpdates v1 RSS endpoint (`/v1/series/{id}/rss`) ships\n // titles without a language tag \u2014 it's the English-localized release\n // stream by design. Default to `\"en\"` so items aren't dropped by the\n // client-side language gate; an explicit `(es)` / `(id)` / etc. still\n // wins when present, and the host's per-series language list remains\n // the authoritative gate downstream. The legacy `UNKNOWN_LANGUAGE`\n // sentinel is kept exported for backwards compatibility but no longer\n // produced by this parser.\n let language = \"en\";\n const langMatch = trimmed.match(/\\(([a-z]{2,3})\\)\\s*$/i);\n if (langMatch?.[1]) {\n language = langMatch[1].toLowerCase();\n }\n\n return { chapter, volume, group, language };\n}\n\n// -----------------------------------------------------------------------------\n// Item parsing\n// -----------------------------------------------------------------------------\n\n/**\n * Best-effort `pubDate` -> ISO-8601 conversion. MangaUpdates uses RFC-2822\n * style dates (`Mon, 04 May 2026 02:31:00 GMT`). Falls back to \"now\" on\n * invalid input \u2014 never throws, since one bad pubDate shouldn't drop the\n * whole feed.\n */\nfunction pubDateToIso(raw: string | null): string {\n if (raw) {\n const d = new Date(raw);\n if (!Number.isNaN(d.getTime())) return d.toISOString();\n }\n return new Date().toISOString();\n}\n\n/**\n * Derive a stable external_release_id. Prefer `<guid>`, then the link URL,\n * otherwise fall back to a deterministic hash of `(title + pubDate)`.\n *\n * Stability is what matters: re-polling the same item must produce the same\n * ID so the host's `(source_id, external_release_id)` dedup catches it.\n */\nfunction deriveExternalReleaseId(\n guid: string | null,\n link: string | null,\n title: string,\n pubDate: string | null,\n): string {\n if (guid && guid.trim().length > 0) return guid.trim();\n if (link && link.trim().length > 0) return link.trim();\n // Deterministic fallback for feeds that omit both. djb2-ish hash keeps the\n // ID short while staying stable across polls.\n const fallback = `${title}|${pubDate ?? \"\"}`;\n let h = 5381;\n for (let i = 0; i < fallback.length; i++) {\n h = ((h << 5) + h + fallback.charCodeAt(i)) | 0;\n }\n return `t:${(h >>> 0).toString(36)}`;\n}\n\n/**\n * Parse a single MangaUpdates `<item>` block into a `ParsedRssItem`. Returns\n * null if the title is missing entirely (truly malformed item).\n */\nexport function parseItem(itemXml: string): ParsedRssItem | null {\n const title = extractTagText(itemXml, \"title\");\n if (!title) return null;\n\n const link = extractTagText(itemXml, \"link\");\n const guid = extractTagText(itemXml, \"guid\");\n const pubDate = extractTagText(itemXml, \"pubDate\");\n\n const { chapter, volume, group, language } = parseTitle(title);\n\n return {\n externalReleaseId: deriveExternalReleaseId(guid, link, title, pubDate),\n title,\n chapter,\n volume,\n group,\n language,\n link: link ?? \"\",\n observedAt: pubDateToIso(pubDate),\n };\n}\n\n/**\n * Parse a full MangaUpdates per-series RSS feed body into items. Bad items\n * (missing title) are dropped silently \u2014 the feed should be best-effort\n * tolerant.\n */\nexport function parseFeed(xml: string): ParsedRssItem[] {\n return splitItems(xml)\n .map(parseItem)\n .filter((i): i is ParsedRssItem => i !== null);\n}\n", "/**\n * Filtering: language allowlist + group blocklist.\n *\n * Filters are applied client-side in the plugin (before recording) for two\n * reasons:\n * 1. Keeps the ledger small. Out-of-language items would be dropped by the\n * host anyway via the latest_known_* gate, but writing them to the\n * ledger pollutes the inbox and wastes write IO.\n * 2. Keeps the inbox clean. Users who configure `[\"en\"]` don't want to see\n * Spanish entries hidden behind a state flag \u2014 they want them gone.\n */\n\nimport { type ParsedRssItem, UNKNOWN_LANGUAGE } from \"./parser.js\";\n\n/**\n * Resolved, normalized filter inputs for a single series. Both lists are\n * lowercased + trimmed. Empty `languages` is interpreted as \"no filter\"\n * (everything passes), but the caller is expected to pass at least the\n * server-wide default to avoid that footgun.\n */\nexport interface ResolvedFilters {\n /** Lowercased ISO 639-1 codes; empty = no filter. */\n languages: string[];\n /** Lowercased group names; case-insensitive exact match against `group`. */\n blockedGroups: Set<string>;\n /**\n * Whether to include items whose language couldn't be detected\n * (`UNKNOWN_LANGUAGE` sentinel). Default false \u2014 be conservative.\n */\n includeUnknownLanguage: boolean;\n}\n\n/**\n * Build resolved filters from raw config strings + lists. Centralizes the\n * normalization so the poll handler doesn't have to care about casing or\n * whitespace.\n */\nexport function resolveFilters(input: {\n languages: string[];\n blockedGroups: string[];\n includeUnknownLanguage?: boolean;\n}): ResolvedFilters {\n const languages = dedupePreserveOrder(\n input.languages.map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0),\n );\n const blockedGroups = new Set(\n input.blockedGroups.map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0),\n );\n return {\n languages,\n blockedGroups,\n includeUnknownLanguage: input.includeUnknownLanguage ?? false,\n };\n}\n\n/**\n * Parse a comma-separated string into a clean list (trim, drop empties).\n * Helper for `blockedGroups` which is admin-config typed as a single string.\n */\nexport function parseCommaList(raw: unknown): string[] {\n if (typeof raw !== \"string\") return [];\n return raw\n .split(\",\")\n .map((s) => s.trim())\n .filter((s) => s.length > 0);\n}\n\n/**\n * Returns true if the item should be kept.\n *\n * Language filter:\n * - If `languages` is empty \u2192 pass.\n * - Otherwise, item.language must be in the list (case-insensitive).\n * - `unknown` language is rejected unless `includeUnknownLanguage` is true.\n *\n * Group filter:\n * - If `group` is null \u2192 pass (we have nothing to match against).\n * - Otherwise, group must NOT be in `blockedGroups`.\n */\nexport function passesFilters(item: ParsedRssItem, filters: ResolvedFilters): boolean {\n // Language gate.\n if (item.language === UNKNOWN_LANGUAGE) {\n if (!filters.includeUnknownLanguage) return false;\n } else if (filters.languages.length > 0) {\n if (!filters.languages.includes(item.language.toLowerCase())) return false;\n }\n\n // Group blocklist.\n if (item.group !== null && filters.blockedGroups.size > 0) {\n if (filters.blockedGroups.has(item.group.trim().toLowerCase())) return false;\n }\n\n return true;\n}\n\nfunction dedupePreserveOrder(xs: string[]): string[] {\n const seen = new Set<string>();\n const out: string[] = [];\n for (const x of xs) {\n if (!seen.has(x)) {\n seen.add(x);\n out.push(x);\n }\n }\n return out;\n}\n", "{\n \"name\": \"@ashdev/codex-plugin-release-mangaupdates\",\n \"version\": \"1.18.0\",\n \"description\": \"MangaUpdates RSS release-source plugin for Codex - announces new chapter releases for tracked series in user-configured languages\",\n \"main\": \"dist/index.js\",\n \"bin\": \"dist/index.js\",\n \"type\": \"module\",\n \"files\": [\n \"dist\",\n \"README.md\"\n ],\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/AshDevFr/codex.git\",\n \"directory\": \"plugins/release-mangaupdates\"\n },\n \"scripts\": {\n \"build\": \"esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'\",\n \"dev\": \"npm run build -- --watch\",\n \"clean\": \"rm -rf dist\",\n \"start\": \"node dist/index.js\",\n \"lint\": \"biome check .\",\n \"lint:fix\": \"biome check --write .\",\n \"typecheck\": \"tsc --noEmit\",\n \"test\": \"vitest run --passWithNoTests\",\n \"test:watch\": \"vitest\",\n \"prepublishOnly\": \"npm run lint && npm run build\"\n },\n \"keywords\": [\n \"codex\",\n \"plugin\",\n \"mangaupdates\",\n \"release-source\",\n \"manga\"\n ],\n \"author\": \"Codex\",\n \"license\": \"MIT\",\n \"engines\": {\n \"node\": \">=22.0.0\"\n },\n \"dependencies\": {\n \"@ashdev/codex-plugin-sdk\": \"file:../sdk-typescript\"\n },\n \"devDependencies\": {\n \"@biomejs/biome\": \"^2.4.4\",\n \"@types/node\": \"^22.0.0\",\n \"esbuild\": \"^0.27.3\",\n \"typescript\": \"^5.9.3\",\n \"vitest\": \"^4.0.18\"\n }\n}\n", "import type { PluginManifest } from \"@ashdev/codex-plugin-sdk\";\nimport packageJson from \"../package.json\" with { type: \"json\" };\n\n/**\n * External-ID source name for MangaUpdates.\n *\n * MangaUpdates IDs are populated by metadata-provider plugins (e.g.\n * MangaBaka cross-references) or pasted manually by the user via the series\n * tracking panel. The release plugin needs the bare source name (no\n * `plugin:` prefix) here to match the host's external-ID filter.\n */\nexport const EXTERNAL_ID_SOURCE_MANGAUPDATES = \"mangaupdates\" as const;\n\nexport const manifest = {\n name: \"release-mangaupdates\",\n displayName: \"MangaUpdates Releases\",\n version: packageJson.version,\n description:\n \"Announces new chapter releases for tracked series via MangaUpdates per-series RSS feeds. Filters by user-configured languages.\",\n author: \"Codex\",\n homepage: \"https://github.com/AshDevFr/codex\",\n protocolVersion: \"1.1\",\n capabilities: {\n releaseSource: {\n kinds: [\"rss-series\"],\n requiresAliases: false,\n requiresExternalIds: [EXTERNAL_ID_SOURCE_MANGAUPDATES],\n canAnnounceChapters: true,\n canAnnounceVolumes: true,\n },\n },\n configSchema: {\n description:\n \"MangaUpdates plugin configuration. Per-series language preferences live on each series' tracking config; the values here are server-wide defaults applied when a series doesn't override them.\",\n fields: [\n {\n key: \"blockedGroups\",\n label: \"Blocked Scanlation Groups\",\n description:\n \"Comma-separated list of scanlation group names to exclude from announcements (case-insensitive, exact match). Per-series overrides may further extend this list.\",\n type: \"string\" as const,\n required: false,\n default: \"\",\n example: \"LowQualityScans,MTL Group\",\n },\n {\n key: \"requestTimeoutMs\",\n label: \"Request Timeout (ms)\",\n description:\n \"How long to wait for a single RSS fetch before giving up. Defaults to 10000 (10 seconds).\",\n type: \"number\" as const,\n required: false,\n default: 10_000,\n },\n ],\n },\n userDescription:\n \"Announces new chapters for series you've tracked, using their MangaUpdates IDs. Filters releases to languages you can read. Notification-only \u2014 Codex does not download anything.\",\n adminSetupInstructions:\n \"1. No config is required to get started \u2014 saving the plugin is enough. The plugin auto-registers a single source row (`MangaUpdates Releases`) in **Settings \u2192 Release tracking** on first start, where you can disable it, change the poll interval, or hit *Poll now*. 2. To get announcements for a series, edit its tracking panel and either paste a `mangaupdates` external ID or let the metadata-refresh path populate it from MangaBaka cross-references. 3. Optional: set `blockedGroups` (CSV, case-insensitive) to filter noisy scanlators server-wide; per-series language preferences live on each series' tracking config and override the server default (`release_tracking.default_languages`). No credentials are needed; MangaUpdates RSS feeds are public.\",\n} as const satisfies PluginManifest & {\n capabilities: { releaseSource: { kinds: [\"rss-series\"] } };\n};\n", "/**\n * MangaUpdates RSS Release-Source Plugin for Codex.\n *\n * Polls per-series RSS feeds at MangaUpdates and announces new chapter /\n * volume releases for tracked series. The plugin is the first writer of\n * `release_ledger` rows in production \u2014 earlier phases build the\n * infrastructure, this one delivers the first real notification feed.\n *\n * Flow per `releases/poll`:\n * 1. Pull tracked-series scope from the host (`releases/list_tracked`).\n * Filtered server-side to series with a `mangaupdates` external ID.\n * 2. For each series, conditional GET the RSS feed.\n * 3. Parse the response into items, then filter by:\n * - per-series language list (admin / per-series config)\n * - admin-configured group blocklist\n * 4. Build `ReleaseCandidate` rows and stream them via\n * `releases/record`. The host's matcher applies the threshold and\n * ledger dedup.\n * 5. Pass the new ETag back via the poll response so the host updates\n * the source row.\n *\n * **Concurrency note:** The plugin host already serializes RPCs per plugin\n * process, so we don't need to throttle internally beyond an in-poll loop\n * that walks tracked series sequentially.\n */\n\nimport {\n createLogger,\n createReleaseSourcePlugin,\n type HostRpcClient,\n HostRpcError,\n type InitializeParams,\n RELEASES_METHODS,\n type ReleaseCandidate,\n type ReleasePollRequest,\n type ReleasePollResponse,\n type TrackedSeriesEntry,\n} from \"@ashdev/codex-plugin-sdk\";\nimport { fetchSeriesFeed } from \"./fetcher.js\";\nimport { parseCommaList, passesFilters, resolveFilters } from \"./filter.js\";\nimport { EXTERNAL_ID_SOURCE_MANGAUPDATES, manifest } from \"./manifest.js\";\nimport { type ParsedRssItem, parseFeed } from \"./parser.js\";\n\nconst logger = createLogger({ name: manifest.name, level: \"info\" });\n\n// =============================================================================\n// Plugin-level state (set during initialize)\n// =============================================================================\n\ninterface PluginState {\n hostRpc: HostRpcClient | null;\n /** Admin-configured group blocklist (lowercased exact match). */\n blockedGroupsCsv: string;\n /** Hard timeout for upstream fetches. */\n requestTimeoutMs: number;\n}\n\nconst state: PluginState = {\n hostRpc: null,\n blockedGroupsCsv: \"\",\n requestTimeoutMs: 10_000,\n};\n\n/** Reset state. Exported for tests; not part of the plugin contract. */\nexport function _resetState(): void {\n state.hostRpc = null;\n state.blockedGroupsCsv = \"\";\n state.requestTimeoutMs = 10_000;\n}\n\n// =============================================================================\n// Reverse-RPC wrappers (typed shorthands so the poll code reads cleanly)\n// =============================================================================\n\ninterface ListTrackedResponse {\n tracked: TrackedSeriesEntry[];\n nextOffset?: number;\n}\n\ninterface RecordResponse {\n ledgerId: string;\n deduped: boolean;\n}\n\nasync function listTracked(\n rpc: HostRpcClient,\n sourceId: string,\n offset: number,\n limit: number,\n): Promise<ListTrackedResponse> {\n return rpc.call<ListTrackedResponse>(RELEASES_METHODS.LIST_TRACKED, {\n sourceId,\n offset,\n limit,\n });\n}\n\nasync function recordCandidate(\n rpc: HostRpcClient,\n sourceId: string,\n candidate: ReleaseCandidate,\n): Promise<RecordResponse | null> {\n try {\n return await rpc.call<RecordResponse>(RELEASES_METHODS.RECORD, {\n sourceId,\n candidate,\n });\n } catch (err) {\n if (err instanceof HostRpcError) {\n // Threshold rejection / validation error / unknown source. Log and\n // skip; the next poll will retry the still-eligible candidates.\n logger.warn(\n `record failed for ${candidate.externalReleaseId}: ${err.message} (code ${err.code})`,\n );\n } else {\n const msg = err instanceof Error ? err.message : \"unknown error\";\n logger.warn(`record failed for ${candidate.externalReleaseId}: ${msg}`);\n }\n return null;\n }\n}\n\n// =============================================================================\n// Iteration helpers\n// =============================================================================\n\n/**\n * Lazily walk all tracked-series pages from the host. Yields entries one\n * series at a time so the caller can interleave per-series fetches without\n * buffering the whole list (relevant for users tracking hundreds of series).\n */\nasync function* iterateTrackedSeries(\n rpc: HostRpcClient,\n sourceId: string,\n): AsyncGenerator<TrackedSeriesEntry> {\n const pageSize = 200;\n let offset = 0;\n while (true) {\n const page = await listTracked(rpc, sourceId, offset, pageSize);\n for (const entry of page.tracked) {\n yield entry;\n }\n if (page.nextOffset === undefined || page.tracked.length === 0) return;\n offset = page.nextOffset;\n }\n}\n\n/**\n * Per-series effective language list. We use the host's `latestKnown*`\n * exposure plus the `externalIds` map to scope the fetch, but the\n * languages config is owned by the host (set on `series_tracking.languages`\n * with fallback to the server-wide default).\n *\n * However, the current `releases/list_tracked` response shape doesn't\n * expose per-series `languages` \u2014 see plan doc for this design choice.\n * For Phase 6 the plugin reads its admin-level group blocklist and emits\n * candidates with the language tag from the parsed entry; the host's\n * `latest_known_*` advance gate enforces the per-series language list\n * authoritatively (see `services/release/languages.rs`).\n *\n * We *also* want to drop out-of-language candidates client-side to keep the\n * ledger small and the inbox clean. Without per-series languages on the\n * tracked-series payload, the client-side filter degrades to a no-op\n * pass-everything for known languages \u2014 leaving it to the host's gate. The\n * group blocklist still applies.\n *\n * If a future protocol revision exposes `effectiveLanguages` on the\n * tracked-series entry, swap this stub for the real list and the existing\n * `passesFilters` will do the right thing.\n */\nfunction effectiveLanguagesForSeries(_entry: TrackedSeriesEntry): string[] {\n return []; // empty = no client-side language gate; host gate is authoritative\n}\n\n/**\n * Map a `ParsedRssItem` to a `ReleaseCandidate`. Confidence is 1.0 because\n * the match is keyed by external ID \u2014 there's no fuzzy matching.\n */\nfunction toCandidate(entry: TrackedSeriesEntry, item: ParsedRssItem): ReleaseCandidate {\n const candidate: ReleaseCandidate = {\n seriesMatch: {\n codexSeriesId: entry.seriesId,\n confidence: 1.0,\n reason: `mangaupdates_id:${entry.externalIds?.[EXTERNAL_ID_SOURCE_MANGAUPDATES] ?? \"\"}`,\n },\n externalReleaseId: item.externalReleaseId,\n chapter: item.chapter,\n volume: item.volume,\n language: item.language,\n groupOrUploader: item.group,\n payloadUrl: item.link.length > 0 ? item.link : `urn:mu:${item.externalReleaseId}`,\n observedAt: item.observedAt,\n };\n return candidate;\n}\n\n// =============================================================================\n// Per-series poll\n// =============================================================================\n\n/** Outcome of a single per-series fetch+record cycle. */\nexport interface SeriesPollOutcome {\n seriesId: string;\n fetched: boolean;\n notModified: boolean;\n parsed: number;\n /** Of those parsed, how many passed client-side filters and were sent to record. */\n matched: number;\n recorded: number;\n /** Of those sent to record, how many the host deduped onto an existing row. */\n deduped: number;\n upstreamStatus: number;\n /** New ETag returned by upstream (only set when fetched=true). */\n etag: string | null;\n /** Error string if the per-series fetch failed; empty otherwise. */\n error: string;\n}\n\n/**\n * Poll a single series. Internal \u2014 exposed for testing.\n *\n * Aggregates the worst (highest) upstream status across the per-series\n * fetches at the call site so the host's per-host backoff layer sees real\n * 429/5xx signals.\n */\nexport async function pollSeries(\n rpc: HostRpcClient,\n sourceId: string,\n entry: TrackedSeriesEntry,\n options: {\n blockedGroups: string[];\n timeoutMs: number;\n fetchImpl?: typeof fetch;\n },\n): Promise<SeriesPollOutcome> {\n const muId = entry.externalIds?.[EXTERNAL_ID_SOURCE_MANGAUPDATES];\n if (!muId) {\n return {\n seriesId: entry.seriesId,\n fetched: false,\n notModified: false,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n upstreamStatus: 0,\n etag: null,\n error: \"missing mangaupdates external ID\",\n };\n }\n\n // We don't have per-series ETag here \u2014 that lives on the source row, not\n // the series. For a per-source feed (rss-uploader) ETags align cleanly;\n // for per-series feeds (this plugin) we'd need per-(source, series) state\n // to do conditional GETs per series. That's a future optimization; for\n // now we always do an unconditional GET. Daily polls + small per-series\n // bodies keep the bandwidth cost negligible.\n const result = await fetchSeriesFeed(muId, null, {\n fetchImpl: options.fetchImpl,\n timeoutMs: options.timeoutMs,\n });\n\n if (result.kind === \"notModified\") {\n return {\n seriesId: entry.seriesId,\n fetched: true,\n notModified: true,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n upstreamStatus: 304,\n etag: null,\n error: \"\",\n };\n }\n\n if (result.kind === \"error\") {\n return {\n seriesId: entry.seriesId,\n fetched: false,\n notModified: false,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n upstreamStatus: result.status,\n etag: null,\n error: result.message,\n };\n }\n\n // result.kind === \"ok\"\n const items = parseFeed(result.body);\n const filters = resolveFilters({\n languages: effectiveLanguagesForSeries(entry),\n blockedGroups: options.blockedGroups,\n });\n let matched = 0;\n let recorded = 0;\n let deduped = 0;\n for (const item of items) {\n if (!passesFilters(item, filters)) continue;\n matched++;\n const candidate = toCandidate(entry, item);\n const outcome = await recordCandidate(rpc, sourceId, candidate);\n if (!outcome) continue;\n if (outcome.deduped) {\n deduped++;\n } else {\n recorded++;\n }\n }\n return {\n seriesId: entry.seriesId,\n fetched: true,\n notModified: false,\n parsed: items.length,\n matched,\n recorded,\n deduped,\n upstreamStatus: 200,\n etag: result.etag,\n error: \"\",\n };\n}\n\n// =============================================================================\n// Top-level poll handler\n// =============================================================================\n\nasync function poll(params: ReleasePollRequest, rpc: HostRpcClient): Promise<ReleasePollResponse> {\n const sourceId = params.sourceId;\n const blockedGroups = parseCommaList(state.blockedGroupsCsv);\n\n let parsed = 0;\n let matched = 0;\n let recorded = 0;\n let deduped = 0;\n let worstStatus = 200;\n let lastEtag: string | null = null;\n let seenSeries = 0;\n // Series the host returned that lack a MangaUpdates external ID. A high\n // count here is the most common cause of an \"empty\" poll: the plugin\n // can't fetch a feed without an MU ID, so the user needs to populate\n // those (manual paste or metadata refresh from MangaBaka).\n let skippedNoMuId = 0;\n\n for await (const entry of iterateTrackedSeries(rpc, sourceId)) {\n seenSeries++;\n const outcome = await pollSeries(rpc, sourceId, entry, {\n blockedGroups,\n timeoutMs: state.requestTimeoutMs,\n });\n parsed += outcome.parsed;\n matched += outcome.matched;\n recorded += outcome.recorded;\n deduped += outcome.deduped;\n if (outcome.upstreamStatus > worstStatus) {\n worstStatus = outcome.upstreamStatus;\n }\n if (outcome.etag) lastEtag = outcome.etag;\n\n if (outcome.error === \"missing mangaupdates external ID\") {\n skippedNoMuId++;\n } else if (outcome.error) {\n logger.warn(`series ${entry.seriesId}: ${outcome.error} (status ${outcome.upstreamStatus})`);\n }\n }\n\n if (skippedNoMuId > 0) {\n logger.info(\n `skipped ${skippedNoMuId} of ${seenSeries} tracked series for source=${sourceId}: no mangaupdates external ID. Add one in the Tracking panel or run a metadata refresh.`,\n );\n }\n\n logger.info(\n `poll complete: source=${sourceId} series=${seenSeries} skipped=${skippedNoMuId} parsed=${parsed} matched=${matched} recorded=${recorded} deduped=${deduped} worst_status=${worstStatus}`,\n );\n\n // Report counters back to the host so the source's `last_summary` is\n // accurate. Without these the host only sees the (empty) `candidates`\n // payload \u2014 we record via reverse-RPC mid-poll \u2014 and the badge reads\n // \"Fetched 0 items\" no matter what actually happened.\n // Per-series ETags don't align with the per-source state slot, so we\n // intentionally leave `etag` undefined unless we actually saw one\n // (which today we won't, since we don't pass If-None-Match per series).\n return {\n notModified: false,\n upstreamStatus: worstStatus,\n parsed,\n matched,\n recorded,\n deduped,\n ...(lastEtag !== null ? { etag: lastEtag } : {}),\n };\n}\n\n// =============================================================================\n// Plugin Initialization\n// =============================================================================\n\n/**\n * Register a single static source row representing the MangaUpdates batch\n * feed. Unlike Nyaa (one row per uploader), MangaUpdates polls all tracked\n * series under one logical feed, so we always declare exactly one row keyed\n * `default`. Retries on `METHOD_NOT_FOUND` to handle the brief race where\n * the host has not yet installed the releases reverse-RPC handler.\n */\nexport async function registerSources(\n rpc: HostRpcClient,\n): Promise<{ registered: number; pruned: number } | null> {\n const sources = [\n {\n sourceKey: \"default\",\n displayName: \"MangaUpdates Releases\",\n kind: \"rss-series\" as const,\n config: null,\n },\n ];\n const maxAttempts = 5;\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n return await rpc.call<{ registered: number; pruned: number }>(\n RELEASES_METHODS.REGISTER_SOURCES,\n { sources },\n );\n } catch (err) {\n const isMethodNotFound = err instanceof HostRpcError && err.code === -32601;\n if (isMethodNotFound && attempt < maxAttempts) {\n await new Promise((r) => setTimeout(r, 50 * attempt));\n continue;\n }\n const reason = err instanceof Error ? err.message : String(err);\n logger.error(`register_sources failed: ${reason}`);\n return null;\n }\n }\n return null;\n}\n\ncreateReleaseSourcePlugin({\n manifest,\n provider: {\n async poll(params: ReleasePollRequest): Promise<ReleasePollResponse> {\n if (!state.hostRpc) {\n throw new Error(\"Plugin not initialized: hostRpc client missing\");\n }\n return poll(params, state.hostRpc);\n },\n },\n logLevel: \"info\",\n async onInitialize(params: InitializeParams) {\n state.hostRpc = params.hostRpc;\n const ac = params.adminConfig ?? {};\n if (typeof ac.blockedGroups === \"string\") {\n state.blockedGroupsCsv = ac.blockedGroups;\n }\n if (typeof ac.requestTimeoutMs === \"number\" && Number.isFinite(ac.requestTimeoutMs)) {\n state.requestTimeoutMs = Math.max(1_000, Math.min(ac.requestTimeoutMs, 60_000));\n }\n logger.info(\n `initialized: blockedGroups=${state.blockedGroupsCsv ? \"set\" : \"empty\"} timeoutMs=${state.requestTimeoutMs}`,\n );\n\n // Materialize the single static source row. Deferred to a microtask so\n // we run *after* the host installs the releases reverse-RPC handler.\n queueMicrotask(() => {\n void registerSources(params.hostRpc).then((result) => {\n if (result) {\n logger.info(`register_sources: registered=${result.registered} pruned=${result.pruned}`);\n }\n });\n });\n },\n});\n\nlogger.info(\"MangaUpdates release-source plugin started\");\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["manifest", "logger", "response", "manifest"]
|
|
7
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ashdev/codex-plugin-release-mangaupdates",
|
|
3
|
+
"version": "1.18.0",
|
|
4
|
+
"description": "MangaUpdates RSS release-source plugin for Codex - announces new chapter releases for tracked series in user-configured languages",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": "dist/index.js",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/AshDevFr/codex.git",
|
|
15
|
+
"directory": "plugins/release-mangaupdates"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'",
|
|
19
|
+
"dev": "npm run build -- --watch",
|
|
20
|
+
"clean": "rm -rf dist",
|
|
21
|
+
"start": "node dist/index.js",
|
|
22
|
+
"lint": "biome check .",
|
|
23
|
+
"lint:fix": "biome check --write .",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "vitest run --passWithNoTests",
|
|
26
|
+
"test:watch": "vitest",
|
|
27
|
+
"prepublishOnly": "npm run lint && npm run build"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"codex",
|
|
31
|
+
"plugin",
|
|
32
|
+
"mangaupdates",
|
|
33
|
+
"release-source",
|
|
34
|
+
"manga"
|
|
35
|
+
],
|
|
36
|
+
"author": "Codex",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22.0.0"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@ashdev/codex-plugin-sdk": "file:../sdk-typescript"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@biomejs/biome": "^2.4.4",
|
|
46
|
+
"@types/node": "^22.0.0",
|
|
47
|
+
"esbuild": "^0.27.3",
|
|
48
|
+
"typescript": "^5.9.3",
|
|
49
|
+
"vitest": "^4.0.18"
|
|
50
|
+
}
|
|
51
|
+
}
|