From 978fc52a48b79f884165887b9e405e3806e5d7c0 Mon Sep 17 00:00:00 2001 From: 0xsline Date: Wed, 25 Mar 2026 02:53:55 +0800 Subject: [PATCH 01/12] feat(channel): add type definitions for events, config, and event sources --- src/channel/types.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/channel/types.ts diff --git a/src/channel/types.ts b/src/channel/types.ts new file mode 100644 index 00000000..8b8213c8 --- /dev/null +++ b/src/channel/types.ts @@ -0,0 +1,60 @@ +/** + * Channel module types: events, config, and event source interface. + */ + +/** A platform event to be pushed to the AI session. */ +export interface ChannelEvent { + /** Dedup key: "twitter/notifications:1234567" */ + id: string + /** Source command: "twitter/notifications" */ + source: string + /** Platform name: "twitter" */ + platform: string + /** Event type: "new_mention", "new_post", "new_dm", etc. */ + eventType: string + /** Human-readable summary of the event */ + content: string + /** Original data from the command result */ + raw?: unknown + /** Epoch ms when the event was detected */ + timestamp: number +} + +/** Configuration for a single polling source. */ +export interface PollingSourceConfig { + type: 'polling' + /** opencli command full name, e.g. "twitter/notifications" */ + command: string + /** Poll interval in seconds (minimum 30) */ + interval: number + /** Whether this source is active */ + enabled: boolean + /** Override field name for dedup key derivation */ + dedupField?: string +} + +/** Configuration for the webhook receiver. */ +export interface WebhookConfig { + enabled: boolean + /** HTTP port (default 8788, localhost only) */ + port: number + /** Bearer token for auth. Empty string = no auth. Supports $ENV_VAR syntax. */ + token: string +} + +/** Top-level channel configuration (channel.yaml). */ +export interface ChannelConfig { + sources: PollingSourceConfig[] + webhook: WebhookConfig +} + +/** Handler called when an event source produces a new event. */ +export type EventHandler = (event: ChannelEvent) => void + +/** Pluggable event source interface (strategy pattern). */ +export interface EventSource { + readonly type: string + start(): Promise + stop(): Promise + onEvent(handler: EventHandler): void +} From a52b37d70a285d39c4da03aeeda8a9e8b0498bd3 Mon Sep 17 00:00:00 2001 From: 0xsline Date: Wed, 25 Mar 2026 02:55:23 +0800 Subject: [PATCH 02/12] feat(channel): add event queue with dedup and bounded size --- src/channel/queue.test.ts | 69 +++++++++++++++++++++++++++++++++++++++ src/channel/queue.ts | 64 ++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 src/channel/queue.test.ts create mode 100644 src/channel/queue.ts diff --git a/src/channel/queue.test.ts b/src/channel/queue.test.ts new file mode 100644 index 00000000..9686a677 --- /dev/null +++ b/src/channel/queue.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { EventQueue } from './queue.js' +import type { ChannelEvent } from './types.js' + +function makeEvent(id: string, content = 'test'): ChannelEvent { + return { + id, + source: 'test/source', + platform: 'test', + eventType: 'new_item', + content, + timestamp: Date.now(), + } +} + +describe('EventQueue', () => { + let queue: EventQueue + + beforeEach(() => { + queue = new EventQueue({ maxSize: 5, dedupWindowSize: 10 }) + }) + + it('pushes and drains events', () => { + queue.push(makeEvent('a')) + queue.push(makeEvent('b')) + const events = queue.drain() + expect(events).toHaveLength(2) + expect(events[0].id).toBe('a') + expect(queue.drain()).toHaveLength(0) + }) + + it('deduplicates by event id', () => { + queue.push(makeEvent('a')) + queue.push(makeEvent('a')) + expect(queue.drain()).toHaveLength(1) + }) + + it('deduplicates across drains (dedup window)', () => { + queue.push(makeEvent('a')) + queue.drain() + queue.push(makeEvent('a')) + expect(queue.drain()).toHaveLength(0) + }) + + it('discards oldest when max size exceeded', () => { + for (let i = 0; i < 7; i++) queue.push(makeEvent(`e${i}`)) + const events = queue.drain() + expect(events).toHaveLength(5) + expect(events[0].id).toBe('e2') + }) + + it('prunes dedup window when exceeded', () => { + const q = new EventQueue({ maxSize: 100, dedupWindowSize: 3 }) + q.push(makeEvent('a')) + q.push(makeEvent('b')) + q.push(makeEvent('c')) + q.drain() + q.push(makeEvent('d')) + // 'a' should be pruned from dedup window, so re-pushing it works + q.push(makeEvent('a')) + expect(q.drain()).toHaveLength(2) + }) + + it('reports pending count', () => { + queue.push(makeEvent('a')) + queue.push(makeEvent('b')) + expect(queue.pending).toBe(2) + }) +}) diff --git a/src/channel/queue.ts b/src/channel/queue.ts new file mode 100644 index 00000000..16f51e00 --- /dev/null +++ b/src/channel/queue.ts @@ -0,0 +1,64 @@ +/** + * Bounded event queue with dedup window. + * + * - Deduplicates events by ID (same event won't be pushed twice) + * - Bounded size: discards oldest when full (silent) + * - Dedup window: remembers recently seen IDs even after drain + */ + +import type { ChannelEvent } from './types.js' + +export interface QueueOptions { + /** Max events buffered before oldest are discarded. Default: 200 */ + maxSize?: number + /** Number of recent event IDs kept for dedup after drain. Default: 500 */ + dedupWindowSize?: number +} + +export class EventQueue { + private readonly events: ChannelEvent[] = [] + private readonly seenIds: string[] = [] + private readonly seenSet = new Set() + private readonly maxSize: number + private readonly dedupWindowSize: number + + constructor(opts: QueueOptions = {}) { + this.maxSize = opts.maxSize ?? 200 + this.dedupWindowSize = opts.dedupWindowSize ?? 500 + } + + /** Push an event. Returns false if it was a duplicate. */ + push(event: ChannelEvent): boolean { + if (this.seenSet.has(event.id)) return false + + this.trackId(event.id) + this.events.push(event) + + // Discard oldest if over capacity + while (this.events.length > this.maxSize) { + this.events.shift() + } + + return true + } + + /** Drain all pending events (removes them from the queue). */ + drain(): ChannelEvent[] { + return this.events.splice(0) + } + + /** Number of events waiting to be drained. */ + get pending(): number { + return this.events.length + } + + private trackId(id: string): void { + this.seenIds.push(id) + this.seenSet.add(id) + + while (this.seenIds.length > this.dedupWindowSize) { + const old = this.seenIds.shift()! + this.seenSet.delete(old) + } + } +} From a1e8ad731b4e102417c6e9002242a4b0727371ce Mon Sep 17 00:00:00 2001 From: 0xsline Date: Wed, 25 Mar 2026 02:55:18 +0800 Subject: [PATCH 03/12] feat(channel): add cross-process browser lock --- src/channel/browser-lock.ts | 55 +++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/channel/browser-lock.ts diff --git a/src/channel/browser-lock.ts b/src/channel/browser-lock.ts new file mode 100644 index 00000000..7597cf4d --- /dev/null +++ b/src/channel/browser-lock.ts @@ -0,0 +1,55 @@ +/** + * Cross-process browser lock for coordinating browser access between + * the channel watcher and the main opencli CLI process. + * + * Lock file: ~/.opencli/browser.lock + * If the lock is held, watcher skips the poll cycle and retries next interval. + */ + +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' + +const LOCK_PATH = path.join(os.homedir(), '.opencli', 'browser.lock') +const STALE_TIMEOUT = 120_000 // 2 minutes — if lock older than this, treat as stale + +interface BrowserLockData { + pid: number + acquiredAt: number +} + +/** Try to acquire the browser lock. Returns true if acquired, false if held. */ +export function acquireBrowserLock(): boolean { + try { + const raw = fs.readFileSync(LOCK_PATH, 'utf-8') + const data: BrowserLockData = JSON.parse(raw) + // Check if holder is alive and lock is not stale + try { + process.kill(data.pid, 0) + if (Date.now() - data.acquiredAt < STALE_TIMEOUT) { + return false // Lock is actively held + } + } catch { + // Holder process is dead — stale lock + } + } catch { + // No lock file — proceed + } + + fs.mkdirSync(path.dirname(LOCK_PATH), { recursive: true }) + fs.writeFileSync(LOCK_PATH, JSON.stringify({ pid: process.pid, acquiredAt: Date.now() })) + return true +} + +/** Release the browser lock (only if we own it). */ +export function releaseBrowserLock(): void { + try { + const raw = fs.readFileSync(LOCK_PATH, 'utf-8') + const data: BrowserLockData = JSON.parse(raw) + if (data.pid === process.pid) { + fs.unlinkSync(LOCK_PATH) + } + } catch { + // Already gone — no-op + } +} From 95ab91d7012a8526e9e47acb662f21c016a94bff Mon Sep 17 00:00:00 2001 From: 0xsline Date: Wed, 25 Mar 2026 02:55:34 +0800 Subject: [PATCH 04/12] feat(channel): add single-instance PID lock --- src/channel/lock.ts | 70 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/channel/lock.ts diff --git a/src/channel/lock.ts b/src/channel/lock.ts new file mode 100644 index 00000000..dd35187d --- /dev/null +++ b/src/channel/lock.ts @@ -0,0 +1,70 @@ +/** + * Single-instance lock for the channel server. + * + * Lock file: ~/.opencli/channel.lock + * Format: JSON { pid: number, startedAt: number } + * Stale detection: check if PID is alive via process.kill(pid, 0) + */ + +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' + +const LOCK_PATH = path.join(os.homedir(), '.opencli', 'channel.lock') + +interface LockData { + pid: number + startedAt: number +} + +/** Attempt to acquire the lock. Returns true if acquired. */ +export function acquireLock(): boolean { + // Check for existing lock + try { + const raw = fs.readFileSync(LOCK_PATH, 'utf-8') + const data: LockData = JSON.parse(raw) + // Check if process is still alive + try { + process.kill(data.pid, 0) + return false // Process is alive, lock is held + } catch { + // Process is dead, stale lock — overwrite + } + } catch { + // No lock file or invalid — proceed + } + + // Write lock + fs.mkdirSync(path.dirname(LOCK_PATH), { recursive: true }) + fs.writeFileSync(LOCK_PATH, JSON.stringify({ pid: process.pid, startedAt: Date.now() })) + return true +} + +/** Release the lock (only if we own it). */ +export function releaseLock(): void { + try { + const raw = fs.readFileSync(LOCK_PATH, 'utf-8') + const data: LockData = JSON.parse(raw) + if (data.pid === process.pid) { + fs.unlinkSync(LOCK_PATH) + } + } catch { + // Lock file already gone or unreadable — no-op + } +} + +/** Read lock info (for `channel status`). Returns null if no active lock. */ +export function readLockInfo(): LockData | null { + try { + const raw = fs.readFileSync(LOCK_PATH, 'utf-8') + const data: LockData = JSON.parse(raw) + try { + process.kill(data.pid, 0) + return data + } catch { + return null // Stale + } + } catch { + return null + } +} From 023dd4314749036b9dffa3b33ee852730b0f58c4 Mon Sep 17 00:00:00 2001 From: 0xsline Date: Wed, 25 Mar 2026 02:57:53 +0800 Subject: [PATCH 05/12] feat(channel): add polling event source with diff detection --- src/channel/sources/polling.test.ts | 86 +++++++++++++++++ src/channel/sources/polling.ts | 141 ++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 src/channel/sources/polling.test.ts create mode 100644 src/channel/sources/polling.ts diff --git a/src/channel/sources/polling.test.ts b/src/channel/sources/polling.test.ts new file mode 100644 index 00000000..eec165f0 --- /dev/null +++ b/src/channel/sources/polling.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { PollingSource } from './polling.js' +import type { ChannelEvent, PollingSourceConfig } from '../types.js' + +const config: PollingSourceConfig = { + type: 'polling', + command: 'test/items', + interval: 1, + enabled: true, +} + +describe('PollingSource', () => { + let source: PollingSource + let executeFn: ReturnType + let events: ChannelEvent[] + + beforeEach(() => { + events = [] + executeFn = vi.fn() + source = new PollingSource(config, executeFn) + source.onEvent((e) => events.push(e)) + }) + + afterEach(async () => { + await source.stop() + }) + + it('has correct type', () => { + expect(source.type).toBe('polling') + }) + + it('detects new items on first poll', async () => { + executeFn.mockResolvedValue([ + { id: '1', title: 'Hello' }, + { id: '2', title: 'World' }, + ]) + await source.pollOnce() + expect(events).toHaveLength(2) + expect(events[0].id).toBe('test/items:1') + }) + + it('only emits new items on subsequent polls', async () => { + executeFn.mockResolvedValue([{ id: '1', title: 'Hello' }]) + await source.pollOnce() + events.length = 0 + + executeFn.mockResolvedValue([ + { id: '1', title: 'Hello' }, + { id: '2', title: 'New' }, + ]) + await source.pollOnce() + expect(events).toHaveLength(1) + expect(events[0].id).toBe('test/items:2') + }) + + it('handles non-array results gracefully', async () => { + executeFn.mockResolvedValue('not an array') + await source.pollOnce() + expect(events).toHaveLength(0) + }) + + it('handles execution errors without throwing', async () => { + executeFn.mockRejectedValue(new Error('network error')) + await expect(source.pollOnce()).resolves.not.toThrow() + }) + + it('uses custom dedupField when configured', async () => { + const customSource = new PollingSource( + { ...config, dedupField: 'bvid' }, + executeFn, + ) + customSource.onEvent((e) => events.push(e)) + + executeFn.mockResolvedValue([{ bvid: 'BV123', title: 'Video' }]) + await customSource.pollOnce() + expect(events[0].id).toBe('test/items:BV123') + }) + + it('derives dedup key with priority: id > url > title > hash', async () => { + executeFn.mockResolvedValue([ + { url: 'https://example.com', title: 'Page' }, + ]) + await source.pollOnce() + expect(events[0].id).toBe('test/items:https://example.com') + }) +}) diff --git a/src/channel/sources/polling.ts b/src/channel/sources/polling.ts new file mode 100644 index 00000000..ffc77936 --- /dev/null +++ b/src/channel/sources/polling.ts @@ -0,0 +1,141 @@ +/** + * Polling event source: wraps an opencli command as a periodic event source. + * Uses setTimeout recursion for dynamic backoff intervals. + */ + +import { createHash } from 'node:crypto' +import type { ChannelEvent, EventHandler, EventSource, PollingSourceConfig } from '../types.js' + +/** Execute function provided by the watcher (closure over CliCommand). */ +export type PollExecuteFn = () => Promise + +const MAX_SNAPSHOT_KEYS = 100 +const MIN_INTERVAL = 30 + +export class PollingSource implements EventSource { + readonly type = 'polling' + + private readonly config: PollingSourceConfig + private readonly execute: PollExecuteFn + private readonly handlers: EventHandler[] = [] + private previousKeys = new Set() + private timer: ReturnType | null = null + private backoffMultiplier = 1 + private consecutiveErrors = 0 + private stopped = false + + constructor(config: PollingSourceConfig, execute: PollExecuteFn) { + this.config = config + this.execute = execute + } + + onEvent(handler: EventHandler): void { + this.handlers.push(handler) + } + + async start(): Promise { + this.stopped = false + await this.pollOnce() + this.scheduleNext() + } + + async stop(): Promise { + this.stopped = true + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + } + + private scheduleNext(): void { + if (this.stopped) return + const delayMs = Math.max(this.config.interval, MIN_INTERVAL) * 1000 * this.backoffMultiplier + this.timer = setTimeout(async () => { + await this.pollOnce() + this.scheduleNext() + }, delayMs) + } + + /** Execute a single poll cycle. Exposed for testing. */ + async pollOnce(): Promise { + let result: unknown + try { + result = await this.execute() + } catch (err) { + this.consecutiveErrors++ + if (this.consecutiveErrors <= 3) { + this.backoffMultiplier = Math.min(this.backoffMultiplier * 2, 10) + } + if (this.consecutiveErrors === 1) { + this.emit({ + id: `${this.config.command}:error:${Date.now()}`, + source: this.config.command, + platform: this.config.command.split('/')[0], + eventType: 'error', + content: `Polling error for ${this.config.command}: ${err instanceof Error ? err.message : String(err)}`, + timestamp: Date.now(), + }) + } + return + } + + this.consecutiveErrors = 0 + this.backoffMultiplier = 1 + + if (!Array.isArray(result)) return + + const currentKeys = new Set() + const newItems: ChannelEvent[] = [] + + for (const item of result) { + if (typeof item !== 'object' || item === null) continue + const key = this.deriveKey(item as Record) + currentKeys.add(key) + + if (!this.previousKeys.has(key)) { + newItems.push({ + id: `${this.config.command}:${key}`, + source: this.config.command, + platform: this.config.command.split('/')[0], + eventType: 'new_item', + content: this.formatItem(item as Record), + raw: item, + timestamp: Date.now(), + }) + } + } + + this.previousKeys = currentKeys.size <= MAX_SNAPSHOT_KEYS + ? currentKeys + : new Set([...currentKeys].slice(0, MAX_SNAPSHOT_KEYS)) + + for (const event of newItems) { + this.emit(event) + } + } + + private deriveKey(item: Record): string { + if (this.config.dedupField && item[this.config.dedupField] != null) { + return String(item[this.config.dedupField]) + } + if (item.id != null) return String(item.id) + if (item.url != null) return String(item.url) + if (item.title != null) return String(item.title) + return createHash('sha256').update(JSON.stringify(item)).digest('hex').slice(0, 16) + } + + private formatItem(item: Record): string { + const parts: string[] = [] + if (item.title) parts.push(String(item.title)) + if (item.description) parts.push(String(item.description)) + if (item.url) parts.push(String(item.url)) + if (item.author || item.user) parts.push(`by ${item.author ?? item.user}`) + return parts.join('\n') || JSON.stringify(item).slice(0, 200) + } + + private emit(event: ChannelEvent): void { + for (const handler of this.handlers) { + handler(event) + } + } +} From 48d24e174076f4d63ebb793aabcc155dc3183132 Mon Sep 17 00:00:00 2001 From: 0xsline Date: Wed, 25 Mar 2026 02:58:07 +0800 Subject: [PATCH 06/12] feat(channel): add webhook event source with token auth --- src/channel/sources/webhook.test.ts | 85 +++++++++++++++++++ src/channel/sources/webhook.ts | 125 ++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 src/channel/sources/webhook.test.ts create mode 100644 src/channel/sources/webhook.ts diff --git a/src/channel/sources/webhook.test.ts b/src/channel/sources/webhook.test.ts new file mode 100644 index 00000000..ed58a594 --- /dev/null +++ b/src/channel/sources/webhook.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { WebhookSource } from './webhook.js' +import type { ChannelEvent, WebhookConfig } from '../types.js' + +const config: WebhookConfig = { + enabled: true, + port: 0, // random port for tests + token: '', +} + +describe('WebhookSource', () => { + let source: WebhookSource + let events: ChannelEvent[] + + beforeEach(async () => { + events = [] + source = new WebhookSource(config) + source.onEvent((e) => events.push(e)) + await source.start() + }) + + afterEach(async () => { + await source.stop() + }) + + it('has correct type', () => { + expect(source.type).toBe('webhook') + }) + + it('accepts valid POST and emits event', async () => { + const port = source.getPort() + const res = await fetch(`http://127.0.0.1:${port}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source: 'github', event: 'push', data: { ref: 'main' } }), + }) + expect(res.status).toBe(200) + expect(events).toHaveLength(1) + expect(events[0].platform).toBe('github') + expect(events[0].eventType).toBe('push') + }) + + it('rejects non-POST methods', async () => { + const port = source.getPort() + const res = await fetch(`http://127.0.0.1:${port}/events`) + expect(res.status).toBe(405) + }) + + it('rejects invalid JSON', async () => { + const port = source.getPort() + const res = await fetch(`http://127.0.0.1:${port}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'not json', + }) + expect(res.status).toBe(400) + }) + + it('enforces token auth when configured', async () => { + await source.stop() + source = new WebhookSource({ ...config, token: 'secret123' }) + source.onEvent((e) => events.push(e)) + await source.start() + const port = source.getPort() + + // Without token + const res1 = await fetch(`http://127.0.0.1:${port}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source: 'test', event: 'ping' }), + }) + expect(res1.status).toBe(401) + + // With token + const res2 = await fetch(`http://127.0.0.1:${port}/events`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer secret123', + }, + body: JSON.stringify({ source: 'test', event: 'ping' }), + }) + expect(res2.status).toBe(200) + }) +}) diff --git a/src/channel/sources/webhook.ts b/src/channel/sources/webhook.ts new file mode 100644 index 00000000..f880e412 --- /dev/null +++ b/src/channel/sources/webhook.ts @@ -0,0 +1,125 @@ +/** + * Webhook event source: HTTP server that receives external POST events. + * Listens on localhost only. Optional Bearer token auth. + */ + +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import type { ChannelEvent, EventHandler, EventSource, WebhookConfig } from '../types.js' + +const MAX_BODY = 64 * 1024 // 64 KB + +export class WebhookSource implements EventSource { + readonly type = 'webhook' + + private readonly config: WebhookConfig + private readonly handlers: EventHandler[] = [] + private server: ReturnType | null = null + private resolvedToken: string + private assignedPort = 0 + + constructor(config: WebhookConfig) { + this.config = config + this.resolvedToken = config.token.startsWith('$') + ? process.env[config.token.slice(1)] ?? '' + : config.token + } + + onEvent(handler: EventHandler): void { + this.handlers.push(handler) + } + + async start(): Promise { + this.server = createServer((req, res) => { + this.handleRequest(req, res).catch(() => { + res.writeHead(500) + res.end() + }) + }) + + return new Promise((resolve) => { + this.server!.listen(this.config.port, '127.0.0.1', () => { + const addr = this.server!.address() + this.assignedPort = typeof addr === 'object' && addr ? addr.port : this.config.port + resolve() + }) + }) + } + + async stop(): Promise { + if (this.server) { + return new Promise((resolve) => { + this.server!.close(() => resolve()) + }) + } + } + + getPort(): number { + return this.assignedPort + } + + private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + if (req.method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Method not allowed' })) + return + } + + if (this.resolvedToken) { + const auth = req.headers['authorization'] ?? '' + if (auth !== `Bearer ${this.resolvedToken}`) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Unauthorized' })) + return + } + } + + let body: string + try { + body = await this.readBody(req) + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Body too large' })) + return + } + + let payload: { source?: string; event?: string; data?: unknown; message?: string } + try { + payload = JSON.parse(body) + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Invalid JSON' })) + return + } + + const event: ChannelEvent = { + id: `webhook:${payload.source ?? 'unknown'}:${Date.now()}`, + source: `webhook/${payload.source ?? 'unknown'}`, + platform: payload.source ?? 'webhook', + eventType: payload.event ?? 'notification', + content: payload.message ?? JSON.stringify(payload.data ?? payload).slice(0, 500), + raw: payload, + timestamp: Date.now(), + } + + for (const handler of this.handlers) { + handler(event) + } + + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ ok: true })) + } + + private readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let size = 0 + req.on('data', (c: Buffer) => { + size += c.length + if (size > MAX_BODY) { req.destroy(); reject(new Error('Body too large')); return } + chunks.push(c) + }) + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))) + req.on('error', reject) + }) + } +} From df9cb2f817f78bbce641315a4d21571a4ee3a03e Mon Sep 17 00:00:00 2001 From: 0xsline Date: Wed, 25 Mar 2026 03:00:03 +0800 Subject: [PATCH 07/12] feat(channel): add watcher engine to orchestrate event sources --- src/channel/watcher.ts | 121 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/channel/watcher.ts diff --git a/src/channel/watcher.ts b/src/channel/watcher.ts new file mode 100644 index 00000000..dfdead27 --- /dev/null +++ b/src/channel/watcher.ts @@ -0,0 +1,121 @@ +/** + * Watcher engine: manages EventSource lifecycle and routes events to the queue. + * + * - Instantiates sources from config + * - Routes events: source → queue + * - Provides drain() for the MCP server to push events + * - Acquires browser lock for COOKIE/UI strategy commands + */ + +import type { ChannelConfig, ChannelEvent, EventSource } from './types.js' +import { EventQueue } from './queue.js' +import { PollingSource } from './sources/polling.js' +import { WebhookSource } from './sources/webhook.js' +import { getRegistry, Strategy } from '../registry.js' +import { executeCommand } from '../execution.js' +import { acquireBrowserLock, releaseBrowserLock } from './browser-lock.js' + +export class Watcher { + private readonly sources: EventSource[] = [] + private readonly queue = new EventQueue() + private readonly eventLog: Array<{ source: string; lastPoll: number; errors: number }> = [] + + constructor(private readonly config: ChannelConfig) {} + + /** Initialize and start all configured sources. */ + async start(): Promise { + const registry = getRegistry() + + // Polling sources + for (const src of this.config.sources) { + if (!src.enabled) continue + + // Validate command exists in registry + const cmd = registry.get(src.command) + if (!cmd) { + console.error(`[channel] Warning: command "${src.command}" not found, skipping`) + continue + } + + // Enforce minimum interval + if (src.interval < 30) { + console.error(`[channel] Warning: interval for "${src.command}" clamped to 30s (was ${src.interval}s)`) + src.interval = 30 + } + + // Determine if this command needs browser (for lock coordination) + const needsBrowser = cmd.browser !== false && ( + cmd.strategy === Strategy.COOKIE || + cmd.strategy === Strategy.UI || + cmd.strategy === Strategy.HEADER + ) + + // Create execute closure that captures the resolved cmd + const executeFn = async (): Promise => { + if (needsBrowser) { + if (!acquireBrowserLock()) { + console.error(`[channel] Browser busy, skipping poll for "${src.command}"`) + return [] // Skip this cycle — return empty to avoid false diff + } + try { + return await executeCommand(cmd, {}) + } finally { + releaseBrowserLock() + } + } + return executeCommand(cmd, {}) + } + + const polling = new PollingSource(src, executeFn) + this.wireSource(polling, src.command) + this.sources.push(polling) + } + + // Webhook source + if (this.config.webhook.enabled) { + const webhook = new WebhookSource(this.config.webhook) + this.wireSource(webhook, 'webhook') + this.sources.push(webhook) + } + + // Start all + for (const source of this.sources) { + await source.start() + } + } + + /** Stop all sources. */ + async stop(): Promise { + for (const source of this.sources) { + await source.stop() + } + } + + /** Drain queued events (called by MCP server to push). */ + drain(): ChannelEvent[] { + return this.queue.drain() + } + + /** Number of events waiting. */ + get pendingCount(): number { + return this.queue.pending + } + + /** Get source stats for status command. */ + getStats(): Array<{ source: string; lastPoll: number; errors: number }> { + return [...this.eventLog] + } + + private wireSource(source: EventSource, name: string): void { + const logEntry = { source: name, lastPoll: 0, errors: 0 } + this.eventLog.push(logEntry) + + source.onEvent((event: ChannelEvent) => { + logEntry.lastPoll = Date.now() + if (event.eventType === 'error') { + logEntry.errors++ + } + this.queue.push(event) + }) + } +} From a0c4b7168355a9dcd9040b5aace2a04734aa45fb Mon Sep 17 00:00:00 2001 From: 0xsline Date: Wed, 25 Mar 2026 03:03:51 +0800 Subject: [PATCH 08/12] feat(channel): add MCP server entry point with stdio transport --- src/channel/server.ts | 137 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 src/channel/server.ts diff --git a/src/channel/server.ts b/src/channel/server.ts new file mode 100644 index 00000000..fb668a9c --- /dev/null +++ b/src/channel/server.ts @@ -0,0 +1,137 @@ +/** + * Channel MCP Server: declares claude/channel capability, pushes platform + * events into the active Claude Code session via stdio transport. + * + * Entry point: `opencli channel start` + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' +import yaml from 'js-yaml' +import { acquireLock, releaseLock } from './lock.js' +import { Watcher } from './watcher.js' +import type { ChannelConfig } from './types.js' + +const CONFIG_PATH = path.join(os.homedir(), '.opencli', 'channel.yaml') +const STATE_PATH = path.join(os.homedir(), '.opencli', 'channel-state.json') +const PUSH_INTERVAL = 1000 // Check queue every 1s +const STATE_INTERVAL = 10000 // Write state file every 10s + +// Redirect console.log → stderr (stdout is reserved for MCP stdio protocol) +console.log = (...args: unknown[]) => console.error(...args) + +function loadConfig(): ChannelConfig { + const defaults: ChannelConfig = { + sources: [], + webhook: { enabled: false, port: 8788, token: '' }, + } + + try { + const raw = fs.readFileSync(CONFIG_PATH, 'utf-8') + const parsed = yaml.load(raw) as Partial + return { + sources: parsed.sources ?? defaults.sources, + webhook: { ...defaults.webhook, ...parsed.webhook }, + } + } catch { + console.error(`[channel] No config found at ${CONFIG_PATH}, using defaults`) + return defaults + } +} + +export async function startChannelServer(): Promise { + // Single-instance check + if (!acquireLock()) { + console.error('[channel] Another channel server is already running. Exiting.') + process.exit(1) + } + + const config = loadConfig() + + if (config.sources.filter(s => s.enabled).length === 0 && !config.webhook.enabled) { + console.error(`[channel] No sources configured. Edit ${CONFIG_PATH} to add sources.`) + } + + // Initialize MCP server + const mcp = new Server( + { name: 'opencli-channel', version: '0.1.0' }, + { + capabilities: { + experimental: { 'claude/channel': {} }, + }, + instructions: `Platform events arrive as . +Inform the user about new events. If the user wants to take action, use opencli commands directly (e.g. opencli twitter reply).`, + }, + ) + + // Initialize watcher + const watcher = new Watcher(config) + await watcher.start() + + // Push events from queue → MCP notifications + const pushTimer = setInterval(async () => { + const events = watcher.drain() + for (const event of events) { + try { + // Cast to any: notifications/claude/channel is an experimental method + // not in the SDK's ServerNotification union type + await (mcp as unknown as { + notification(n: { method: string; params: unknown }): Promise + }).notification({ + method: 'notifications/claude/channel', + params: { + content: event.content, + meta: { + platform: event.platform, + event_type: event.eventType, + source: event.source, + event_id: event.id, + }, + }, + }) + } catch (err) { + console.error(`[channel] Failed to push notification: ${err instanceof Error ? err.message : err}`) + } + } + }, PUSH_INTERVAL) + + // Write state file periodically (for `channel status`) + const stateTimer = setInterval(() => { + try { + const state = { + pid: process.pid, + uptime: process.uptime(), + sources: watcher.getStats(), + pendingEvents: watcher.pendingCount, + updatedAt: Date.now(), + } + fs.mkdirSync(path.dirname(STATE_PATH), { recursive: true }) + fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)) + } catch { + // Non-critical, ignore + } + }, STATE_INTERVAL) + + // Graceful shutdown + async function shutdown(): Promise { + clearInterval(pushTimer) + clearInterval(stateTimer) + await watcher.stop() + releaseLock() + try { fs.unlinkSync(STATE_PATH) } catch { /* ignore */ } + process.exit(0) + } + + process.on('SIGTERM', shutdown) + process.on('SIGINT', shutdown) + process.stdin.on('end', shutdown) // Claude Code disconnected + + // Connect MCP over stdio + const transport = new StdioServerTransport() + await mcp.connect(transport) + + console.error('[channel] Server started, waiting for events...') +} From 5765fe2de42bf4b0d4436c37f0b9f0fda0f37da7 Mon Sep 17 00:00:00 2001 From: 0xsline Date: Wed, 25 Mar 2026 03:05:02 +0800 Subject: [PATCH 09/12] feat(channel): register channel subcommands (start, status, stop) --- src/cli.ts | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index 6d005800..d89d5864 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -444,6 +444,85 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { await startServe({ port: parseInt(opts.port) }); }); + // ── Built-in: channel ───────────────────────────────────────────────────── + const channelCmd = program.command('channel').description('Platform event notification channel'); + + channelCmd + .command('start') + .description('Start the MCP channel server (launched by Claude Code)') + .action(async () => { + const { startChannelServer } = await import('./channel/server.js'); + await startChannelServer(); + }); + + channelCmd + .command('status') + .description('Show channel server status') + .action(async () => { + const { readLockInfo } = await import('./channel/lock.js'); + const fs = await import('node:fs'); + const path = await import('node:path'); + const os = await import('node:os'); + + const lock = readLockInfo(); + if (!lock) { + console.log('Channel: not running'); + return; + } + + const statePath = path.join(os.homedir(), '.opencli', 'channel-state.json'); + try { + const state = JSON.parse(fs.readFileSync(statePath, 'utf-8')); + const uptime = Math.floor(state.uptime / 60); + console.log(`Channel Status:`); + console.log(` Lock: active (PID ${lock.pid}, uptime: ${uptime}m)`); + console.log(` Sources:`); + for (const s of state.sources ?? []) { + const ago = s.lastPoll ? `${Math.floor((Date.now() - s.lastPoll) / 1000)}s ago` : 'never'; + console.log(` ${s.source} last: ${ago} errors: ${s.errors}`); + } + console.log(` Queue: ${state.pendingEvents} events pending`); + } catch { + console.log(`Channel: running (PID ${lock.pid}) but no state available`); + } + }); + + channelCmd + .command('stop') + .description('Stop the running channel server') + .action(async () => { + const { readLockInfo } = await import('./channel/lock.js'); + const fs = await import('node:fs'); + const path = await import('node:path'); + const os = await import('node:os'); + + const lock = readLockInfo(); + if (!lock) { + console.log('Channel: not running'); + return; + } + try { + process.kill(lock.pid, 'SIGTERM'); + console.log(`Sent SIGTERM to channel server (PID ${lock.pid})`); + + // Wait up to 3 seconds for graceful shutdown + const deadline = Date.now() + 3000; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 200)); + try { process.kill(lock.pid, 0); } catch { + console.log('Channel server stopped.'); + // Clean up lock file if still present + const lockPath = path.join(os.homedir(), '.opencli', 'channel.lock'); + try { fs.unlinkSync(lockPath); } catch { /* ignore */ } + return; + } + } + console.log('Channel server did not exit within 3s. You may need to kill it manually.'); + } catch (err) { + console.error(`Failed to stop: ${err instanceof Error ? err.message : err}`); + } + }); + // ── Dynamic adapter commands ────────────────────────────────────────────── const siteGroups = new Map(); From c79c720301dc1ded5f43ee04927f95effa649f2c Mon Sep 17 00:00:00 2001 From: 0xsline Date: Wed, 25 Mar 2026 03:08:08 +0800 Subject: [PATCH 10/12] test(channel): add e2e test for channel status --- tests/e2e/channel.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/e2e/channel.test.ts diff --git a/tests/e2e/channel.test.ts b/tests/e2e/channel.test.ts new file mode 100644 index 00000000..da0bf5a3 --- /dev/null +++ b/tests/e2e/channel.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, afterEach } from 'vitest' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' +import { runCli } from './helpers.js' + +describe('channel e2e', () => { + const LOCK_PATH = path.join(os.homedir(), '.opencli', 'channel.lock') + + afterEach(() => { + // Cleanup lock if test left it + try { fs.unlinkSync(LOCK_PATH) } catch { /* ignore */ } + }) + + it('channel status shows not running when no server', async () => { + // Ensure no lock file + try { fs.unlinkSync(LOCK_PATH) } catch { /* ignore */ } + + const { stdout, code } = await runCli(['channel', 'status']) + expect(code).toBe(0) + expect(stdout).toContain('not running') + }, 15_000) +}) From 8308fe713eab056b6fc666c2913ecaf9ce196ee3 Mon Sep 17 00:00:00 2001 From: 0xsline Date: Wed, 25 Mar 2026 03:35:52 +0800 Subject: [PATCH 11/12] fix(channel): add MCP SDK dependency and fix polling test types --- package-lock.json | 1130 ++++++++++++++++++++++++++- package.json | 1 + src/channel/sources/polling.test.ts | 2 +- 3 files changed, 1129 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 042fcc3a..a5901f2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", "chalk": "^5.3.0", "cli-table3": "^0.6.5", "commander": "^14.0.3", @@ -879,6 +880,18 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@iconify-json/simple-icons": { "version": "1.2.74", "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.74.tgz", @@ -909,6 +922,46 @@ "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", "license": "BSD-2-Clause" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -2156,6 +2209,52 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/algoliasearch": { "version": "5.49.2", "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.49.2.tgz", @@ -2217,6 +2316,68 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -2307,6 +2468,28 @@ "node": ">=20" } }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2314,6 +2497,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/copy-anything": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", @@ -2330,6 +2531,37 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2337,6 +2569,32 @@ "dev": true, "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2371,6 +2629,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2384,6 +2662,15 @@ "dev": true, "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -2397,6 +2684,24 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -2404,6 +2709,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -2446,6 +2763,12 @@ "@esbuild/win32-x64": "0.27.4" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2456,6 +2779,36 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2466,6 +2819,89 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2484,6 +2920,27 @@ } } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/focus-trap": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", @@ -2494,6 +2951,24 @@ "tabbable": "^6.4.0" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2509,6 +2984,52 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", @@ -2522,6 +3043,42 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hast-util-to-html": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", @@ -2560,6 +3117,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hono": { + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hookable": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", @@ -2573,9 +3139,69 @@ "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" } }, "node_modules/is-fullwidth-code-point": { @@ -2587,6 +3213,12 @@ "node": ">=8" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-what": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", @@ -2600,6 +3232,21 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -2612,6 +3259,18 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2890,6 +3549,15 @@ "dev": true, "license": "MIT" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdast-util-to-hast": { "version": "13.2.1", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", @@ -2912,6 +3580,27 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/micromark-util-character": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", @@ -3006,6 +3695,31 @@ ], "license": "MIT" }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/minisearch": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", @@ -3020,6 +3734,12 @@ "dev": true, "license": "MIT" }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -3039,6 +3759,36 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -3050,6 +3800,27 @@ ], "license": "MIT" }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/oniguruma-to-es": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", @@ -3062,6 +3833,34 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3096,6 +3895,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -3147,6 +3955,58 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", @@ -3174,6 +4034,15 @@ "dev": true, "license": "MIT" }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -3270,6 +4139,28 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/search-insights": { "version": "2.17.3", "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", @@ -3278,6 +4169,78 @@ "license": "MIT", "peer": true }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/shiki": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", @@ -3295,6 +4258,78 @@ "@types/hast": "^3.0.4" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3340,6 +4375,15 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", @@ -3452,6 +4496,15 @@ "node": ">=14.0.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -3500,6 +4553,20 @@ "@mixmark-io/domino": "^2.2.0" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typescript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", @@ -3594,6 +4661,24 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -4352,6 +5437,21 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4369,6 +5469,12 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", @@ -4390,6 +5496,24 @@ } } }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 88a92c0d..1989038a 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "url": "git+https://github.com/jackwener/opencli.git" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", "chalk": "^5.3.0", "cli-table3": "^0.6.5", "commander": "^14.0.3", diff --git a/src/channel/sources/polling.test.ts b/src/channel/sources/polling.test.ts index eec165f0..31c3c576 100644 --- a/src/channel/sources/polling.test.ts +++ b/src/channel/sources/polling.test.ts @@ -11,7 +11,7 @@ const config: PollingSourceConfig = { describe('PollingSource', () => { let source: PollingSource - let executeFn: ReturnType + let executeFn: ReturnType Promise>> let events: ChannelEvent[] beforeEach(() => { From e24ebd0a6eb6ce0ebc25bf52966d41813a903b9d Mon Sep 17 00:00:00 2001 From: 0xsline Date: Wed, 25 Mar 2026 03:36:42 +0800 Subject: [PATCH 12/12] docs: add channel event notification documentation --- docs/advanced/channel.md | 161 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/advanced/channel.md diff --git a/docs/advanced/channel.md b/docs/advanced/channel.md new file mode 100644 index 00000000..8ddd7c2a --- /dev/null +++ b/docs/advanced/channel.md @@ -0,0 +1,161 @@ +# Channel — Platform Event Notifications + +Push real-time platform events into your active AI coding session. When something happens on a platform (new tweet, new email, new post), opencli notifies your current Claude Code session automatically. + +## How It Works + +``` +Platform (Twitter, V2EX, GitHub...) + ↓ polling / webhook +opencli channel server (MCP stdio) + ↓ notifications/claude/channel +Claude Code session ← you see the notification here +``` + +The channel server runs as an MCP server with the `claude/channel` experimental capability. It monitors configured platforms and pushes events into your active session via the MCP protocol. + +## Quick Start + +### 1. Configure sources + +Create `~/.opencli/channel.yaml`: + +```yaml +sources: + # Poll V2EX hot posts every 60 seconds + - command: v2ex/hot + type: polling + interval: 60 + enabled: true + + # Poll Twitter notifications every 90 seconds + - command: twitter/notifications + type: polling + interval: 90 + enabled: true + +webhook: + enabled: true + port: 8788 + token: "" # optional auth token, supports $ENV_VAR +``` + +### 2. Register MCP server + +Add to `~/.claude.json` under `mcpServers`: + +```json +{ + "opencli-channel": { + "command": "npx", + "args": ["tsx", "/path/to/opencli/src/main.ts", "channel", "start"], + "type": "stdio" + } +} +``` + +Or if opencli is installed globally: + +```json +{ + "opencli-channel": { + "command": "opencli", + "args": ["channel", "start"], + "type": "stdio" + } +} +``` + +### 3. Launch Claude Code with channel + +```bash +claude --dangerously-load-development-channels server:opencli-channel +``` + +That's it! Platform events will now push into your session automatically. + +## CLI Commands + +```bash +opencli channel start # Start MCP stdio server (called by Claude Code) +opencli channel status # Show running channel server status +opencli channel stop # Stop the running channel server +``` + +## Configuration Reference + +### `~/.opencli/channel.yaml` + +```yaml +sources: + - command: # opencli command to poll (e.g. twitter/timeline) + type: polling # event source type + interval: 60 # seconds between polls (minimum: 30) + enabled: true # toggle source on/off + dedupField: id # optional: override dedup key field + +webhook: + enabled: true # enable webhook HTTP receiver + port: 8788 # HTTP port (localhost only) + token: "" # Bearer token auth (empty = no auth) + # supports $ENV_VAR syntax +``` + +### Polling Sources + +Any opencli command that returns a list can be used as a polling source. The channel server runs the command periodically, compares results with the previous snapshot, and pushes new items as notifications. + +**Dedup key priority:** `id` > `url` > `title` > SHA-256 hash. Override with `dedupField` for platforms with custom ID fields (e.g. `dedupField: bvid` for Bilibili). + +**Example sources:** + +| Command | What it monitors | +|---------|-----------------| +| `v2ex/hot` | V2EX hot posts | +| `twitter/notifications` | Twitter mentions & replies | +| `twitter/timeline` | New tweets from follows | +| `bilibili/dynamic` | Bilibili followee updates | +| `reddit/hot` | Reddit hot posts | +| `jike/notifications` | Jike notifications | +| `bloomberg/feeds` | Bloomberg news | + +### Webhook Source + +The webhook source listens for HTTP POST requests on localhost. External services (CI, monitoring, custom scripts) can push events: + +```bash +curl -X POST http://127.0.0.1:8788/events \ + -H "Content-Type: application/json" \ + -d '{"source": "github", "event": "push", "message": "New push to main branch"}' +``` + +**Payload format:** + +| Field | Type | Description | +|-------|------|-------------| +| `source` | string | Platform name (e.g. "github") | +| `event` | string | Event type (e.g. "push", "new_email") | +| `message` | string | Human-readable event summary | +| `data` | object | Optional raw event data | + +## Error Handling + +| Scenario | Behavior | +|----------|----------| +| Poll fails | Exponential backoff (×2, ×4, max 5min), one notification to user | +| Cookie/auth expired | Pauses source, notifies user | +| Queue overflow (>200) | Discards oldest events silently | +| Claude Code disconnects | Graceful shutdown, releases lock | + +## Architecture + +- **Read-only / push-only** — The channel only monitors and notifies. All platform actions (reply, like, etc.) go through normal `opencli` commands. +- **Pluggable event sources** — `EventSource` interface supports polling, webhook, and future extension-based monitoring. +- **Single instance** — Lock file prevents duplicate channel servers. +- **Browser lock** — Cross-process coordination prevents conflicts between channel polling and interactive opencli usage. + +## Requirements + +- Claude Code v2.1.80+ (Channels is a research preview feature) +- `claude.ai` login (API key auth not supported for Channels) +- Team/Enterprise orgs must enable Channels in admin settings