diff --git a/CHANGELOG.md b/CHANGELOG.md index dc9ff08..30e2562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] ### Added +- **Markdown flattening** (`--markdown` / `-m`; on in `--email` / `--plain`): + drops headings, `**bold**`/`*italic*`/`~~strike~~`, inline `` `code` `` and list + markers, rewrites `[text](url)` → `text (url)`, and unwraps autolinks/images. + Fenced and inline code are kept **verbatim** — shielded from inline stripping + and typography via a sentinel pass. New `flattenMarkdown` transform. +- **HTML → text** (`--html`): strip tags, drop script/style, decode entities. +- **PowerShell cleanup** (`--powershell` / `--ps`): drop `~~~~` underlines and the + `+ ` continuation gutter from pasted error records (block-anchored so diffs are + safe). New `cleanPowerShell` transform. +- **Shell-prompt stripping** (`--prompts`): `$ `, `PS C:\>`, `>>>`, `user@host:…$`. +- **Paragraph reflow** (`--reflow`): rejoin hard-wrapped prose for proportional + fonts (conservative; under-joins rather than over-joins). +- **`--agent` preset**: denoise for feeding output *into* a model — strip + ANSI/box/glyph noise but keep Markdown structure and Unicode. +- **UTF-16 / BOM input decoding**: Windows files and clipboard exports no longer + mojibake. New `decodeInput` helper. +- **MCP server** (`socb-mcp`): exposes `sanitize_text` to Claude Desktop / Claude + Code / Codex. `@modelcontextprotocol/sdk` is an optional dependency. +- **Experimental PreToolUse hook** (`hooks/socb-clean.mjs`) that pipes noisy Bash + output through `socb --agent` before it reaches the model's context. +- Markdown pipe tables with GitHub alignment delimiters (`| :--: |`) are now + recognized by the table engine. +- Strip Markdown code fences (```` ``` ````/`~~~`) and PowerShell `~~~~~` error + underlines so pasted output is fence-free (on by default; `--no-fences` to + keep them). New `stripFences` option / exported transform. - Initial release: `sickofcodeblocks` / `socb` CLI. - Core pipeline: OSC 8 hyperlink rewrite, carriage-return/spinner collapse, augmented ANSI/OSC/DCS/APC escape stripping, Nerd Font/Powerline glyph diff --git a/README.md b/README.md index 5c4e42f..64219b6 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![license: MIT](https://img.shields.io/npm/l/sickofcodeblocks.svg)](./LICENSE) [![node](https://img.shields.io/node/v/sickofcodeblocks.svg)](https://nodejs.org) -> Paste terminal output into email, Slack, and docs as clean text — no screenshots, no triple-backtick code blocks. +> Paste terminal output — and Markdown / AI answers — into email, Slack, and docs as clean text. No screenshots, no triple-backtick code blocks. Raw terminal output is full of stuff that turns to garbage the moment it leaves the terminal: ANSI color codes, progress bars that redrew themselves 200 times, spinner frames, Nerd Font icons that show up as `□`, and box-drawing tables that fall apart in a proportional font. `socb` cleans all of it and gives you plain, readable text. @@ -13,11 +13,14 @@ Stripping colors is the easy 10%. `socb` also does the parts other tools skip: - **Collapses progress bars & spinners** to their final state (resolves carriage-return / backspace / erase-line redraws). - **Flattens multi-line redraws** (docker / cargo / pip) with an optional headless-terminal mode (`--emulate`). -- **Removes Nerd Font / Powerline glyphs** (Private Use Area) that render as tofu. -- **Rebuilds box-drawing tables** into clean aligned columns. +- **Flattens Markdown to readable prose** — drops `#` headings, `**bold**`, inline `` `code` `` and list markers, and rewrites `[text](url)` to `text (url)`; fenced code is kept **verbatim**. (On with `--email` / `--plain`, or `-m`.) +- **Converts HTML to text** — strips tags and decodes entities (`&` → `&`). +- **Tidies PowerShell errors** (`~~~~` underlines + the `+` gutter) and **strips shell prompts** (`$`, `PS C:\>`, `>>>`). +- **Reflows hard-wrapped paragraphs** so prose flows in a proportional-font email instead of breaking mid-line. +- **Removes Nerd Font / Powerline glyphs** (Private Use Area) that render as tofu, and **rebuilds box-drawing tables** (incl. Markdown pipe tables) into clean aligned columns. - **Strips embedded escape strings** that `strip-ansi` leaks — OSC, plus DCS/APC (sixel, Kitty graphics). - **Rewrites hyperlinks** (`OSC 8`) to `text (url)` instead of dropping the URL. -- **Normalizes** smart quotes/dashes/ellipses to ASCII and tidies whitespace. +- **Normalizes** smart quotes/dashes/ellipses to ASCII, **decodes UTF-16 / BOM input** (no Windows mojibake), and tidies whitespace. - **Optionally redacts** secrets/PII before you share output up the chain (`--redact`). Zero runtime dependencies on the default path. Works on Windows, macOS, and Linux. @@ -117,6 +120,13 @@ Input priority: [file] argument > --clip > piped stdin | `--no-links` | | Don't rewrite OSC 8 hyperlinks | | `--strip-emoji` | | Remove emoji (+ ZWJ / variation selectors / skin tones) | | `--no-glyphs` | | Keep Nerd Font / Private-Use glyphs | +| `--no-fences` | | Keep Markdown code fences + PowerShell `~` underlines (default strips them) | +| `--markdown` | `-m` | Flatten Markdown to readable text (code kept verbatim); on in `--email` / `--plain` | +| `--no-markdown` | | Keep Markdown markup literal (the default) | +| `--html` | | Strip HTML tags and decode entities (`&` → `&`) | +| `--prompts` | | Strip leading shell prompts (`$`, `PS C:\>`, `>>>`) | +| `--reflow` | | Rejoin hard-wrapped prose into flowing paragraphs | +| `--powershell` / `--ps` | | Tidy pasted PowerShell errors (`~` underlines + `+` gutter) | | `--no-typographic` | | Keep smart quotes / em-dashes / ellipsis (don't convert to ASCII) | | `--arrows` | | Also convert arrows (`→` becomes `->`) | | `--expand-tabs` / `--tab-width ` | | Convert tabs to spaces (default width 4) | @@ -125,18 +135,47 @@ Input priority: [file] argument > --clip > piped stdin | `--redact` | `-r` | Mask API keys, JWTs, emails, IPs, and home-dir paths | | `--watch` | `-w` | Keep cleaning the clipboard in place (Ctrl+C to stop) | | `--interval ` | | `--watch` poll interval (default 800) | -| `--slack` / `--email` / `--plain` | | Presets (below) | +| `--slack` / `--email` / `--plain` / `--agent` | | Presets (below) | | `--help` / `--version` | `-h` / `-v` | | ### Presets -Presets set a bundle of defaults; any explicit flag still overrides them. +Presets set a bundle of defaults; any explicit flag still overrides them (e.g. `--email --no-markdown`). -| Preset | Tables | Emoji | Typography | Notes | -|---|---|---|---|---| -| `--slack` | reconstruct | keep | → ASCII | Slack renders Unicode; aligned tables read well | -| `--email` | strip | strip | → ASCII | Proportional fonts can't align columns | -| `--plain` | strip | strip | → ASCII | Maximum compatibility; arrows + tabs→spaces too | +| Preset | Tables | Markdown / HTML | Notes | +|---|---|---|---| +| `--slack` | reconstruct | left as-is | Slack renders Unicode; aligned tables read well | +| `--email` | strip | **flattened** | + reflow wraps, strip prompts, tidy PowerShell. For proportional fonts (email / Docs / Word) | +| `--plain` | strip | **flattened** | The `--email` bundle + arrows + tabs→spaces. Maximum compatibility | +| `--agent` | strip | kept (structure) | Denoise for feeding output **into** a model: strips ANSI/box/glyph noise but keeps Markdown + Unicode (no flattening, no ASCII folding) | + +## Markdown & rich paste + +Pasting a Claude / ChatGPT answer (or a README) into email drags along `#`, `**`, backticks, and ` ``` ` fences. `--markdown` (`-m`, and on by default in `--email` / `--plain`) flattens it to plain prose while keeping fenced code **verbatim**: + +Input: + +````md +## Deploy +1. Run `npm ci`, then **build**. +2. See the [runbook](https://wiki/runbook). + +```bash +export TOKEN=abc # keep me exactly +``` +```` + +`socb --email`: + +``` +Deploy +1. Run npm ci, then build. +2. See the runbook (https://wiki/runbook). + +export TOKEN=abc # keep me exactly +``` + +It's **opt-in**, so a bare `socb` never mangles raw terminal output (where `*`, `_`, `#`, backticks are literal). `--html` does the same for HTML copied from a browser, Slack, or Teams. ## Tables @@ -159,15 +198,57 @@ socb --clip (`cmd.exe` and POSIX shells pipe bytes faithfully, so `some-command | socb` is fine there.) +## Use with AI agents (MCP + hook) + +`socb` ships an MCP server so Claude Desktop, Claude Code, and Codex can clean text on demand, plus an experimental hook that denoises shell output before it reaches the model. + +### MCP server + +`socb-mcp` is a stdio MCP server exposing one tool, `sanitize_text(text, preset?, options?)`. + +Claude Code / Claude Desktop — add to `.mcp.json` (project) or your user config: + +```json +{ "mcpServers": { "socb": { "command": "socb-mcp" } } } +``` + +Codex — add to `~/.codex/config.toml`: + +```toml +[mcp_servers.socb] +command = "socb-mcp" +``` + +(If `socb-mcp` isn't on `PATH`, use `npx -y sickofcodeblocks socb-mcp`.) The server needs the optional `@modelcontextprotocol/sdk` dependency, which installs by default with the package — the plain CLI path stays dependency-free. + +### Auto-clean hook (experimental) + +`hooks/socb-clean.mjs` is a Claude Code **PreToolUse** hook that rewrites noisy Bash commands (`npm`, `pip`, `cargo`, `docker`, `pytest`, …) to pipe their output through `socb --agent`, cutting ANSI / progress-bar tokens before they hit the model's context. It preserves the original exit code and falls back to raw output if `socb` is missing. Enable it in `.claude/settings.json`: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "node /abs/path/to/hooks/socb-clean.mjs" }] + } + ] + } +} +``` + +It is conservative (only known-noisy tools; never multi-line / heredoc commands) and **experimental** — the MCP `sanitize_text` tool is the dependable path. On Windows it relies on the Bash tool running through git-bash (byte-faithful pipes); native PowerShell piping is not, so don't wire it to a PowerShell runner. + ## Use as a library ```ts import { sanitize } from "sickofcodeblocks"; -const clean = await sanitize(rawTerminalOutput, { tableMode: "strip", redact: true }); +const clean = await sanitize(rawTerminalOutput, { tableMode: "strip", markdown: true, redact: true }); ``` -Individual transforms (`stripEscapes`, `resolveOverwrites`, `transformTables`, `redact`, …) are exported too. +Individual transforms (`stripEscapes`, `resolveOverwrites`, `transformTables`, `flattenMarkdown`, `htmlToText`, `cleanPowerShell`, `stripPrompts`, `reflowParagraphs`, `redact`, `decodeInput`, …) are exported too. ## License diff --git a/hooks/socb-clean.mjs b/hooks/socb-clean.mjs new file mode 100644 index 0000000..0bd83be --- /dev/null +++ b/hooks/socb-clean.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +// EXPERIMENTAL, opt-in PreToolUse hook for Claude Code. +// +// Goal: denoise the output of noisy Bash commands through `socb --agent` BEFORE +// it enters the model's context, to cut tokens and strip ANSI / progress-bar / +// box-drawing / glyph noise. +// +// Why PreToolUse and not PostToolUse: a PostToolUse hook fires AFTER the tool +// output is already committed to context and cannot rewrite it. A PreToolUse hook +// CAN rewrite the command (hookSpecificOutput.updatedInput), so we wrap it to +// capture combined stdout+stderr to a temp file, print the cleaned version, and +// preserve the original exit code. +// +// Conservative by design: only wraps a known set of noisy build/test/infra tools, +// and never a command that is multi-line, already pipes through socb, or reads a +// heredoc. Everything else passes through byte-for-byte. Requires `socb` on PATH +// (`npm i -g sickofcodeblocks`); if socb is missing at run time the wrapper falls +// back to the raw output so nothing is lost. +// +// Caveats: the Claude Code Bash tool runs commands through bash (git-bash on +// Windows, where pipes are byte-faithful — note that native PowerShell piping is +// not, so do NOT wire this to a PowerShell-based runner without the temp-file +// form below). Wrapping changes how stdout/stderr interleave (both go to one +// stream). Verify behavior with your Claude Code version before relying on it. +// +// Install in .claude/settings.json: +// { +// "hooks": { +// "PreToolUse": [ +// { +// "matcher": "Bash", +// "hooks": [ +// { "type": "command", "command": "node /ABS/PATH/hooks/socb-clean.mjs" } +// ] +// } +// ] +// } +// } + +import { readFileSync } from "node:fs"; + +const NOISY = new Set([ + "npm", "pnpm", "yarn", "npx", "bun", + "pip", "pip3", "poetry", "uv", + "cargo", "go", "mvn", "gradle", "make", + "docker", "docker-compose", "kubectl", "helm", "terraform", + "pytest", "tox", "jest", "vitest", "mocha", "vite", "webpack", "tsc", "eslint", + "apt", "apt-get", "brew", "dnf", "yum", +]); + +/** Emit a no-op result (leave the command untouched) and exit. */ +function passthrough() { + process.stdout.write("{}"); + process.exit(0); +} + +let event; +try { + event = JSON.parse(readFileSync(0, "utf8")); +} catch { + passthrough(); +} + +if (!event || event.tool_name !== "Bash") passthrough(); + +const command = event.tool_input && event.tool_input.command; +if (typeof command !== "string" || command.trim() === "") passthrough(); + +// Skip anything we shouldn't touch. +if (command.includes("\n") || /\bsocb\b/.test(command) || command.includes("<<")) { + passthrough(); +} + +// First meaningful token (skip leading VAR=val assignments and "sudo"). +const tokens = command.trim().split(/\s+/); +let i = 0; +while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++; +if (tokens[i] === "sudo") i++; +const tool = (tokens[i] || "").split("/").pop(); +if (!NOISY.has(tool)) passthrough(); + +// Run the command, capture combined output to a temp file, clean it, and keep +// the original exit code. (bash syntax — the Bash tool runs through bash.) +const inner = command.trim().replace(/;+$/, ""); +const wrapped = + '__socb_t="$(mktemp)"; { ' + + inner + + '; } >"$__socb_t" 2>&1; __socb_rc=$?; ' + + 'socb --agent <"$__socb_t" 2>/dev/null || cat "$__socb_t"; ' + + 'rm -f "$__socb_t"; exit $__socb_rc'; + +process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + updatedInput: { command: wrapped }, + }, + }), +); diff --git a/package-lock.json b/package-lock.json index 15f3c17..e3e5164 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.1", "license": "MIT", "dependencies": { - "@xterm/headless": "*" + "@modelcontextprotocol/sdk": "*" }, "bin": { "sickofcodeblocks": "dist/cli.js", @@ -25,6 +25,7 @@ "node": ">=20" }, "optionalDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "@xterm/headless": "^6.0.0" } }, @@ -504,6 +505,19 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -543,6 +557,47 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "optional": true, + "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.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -1362,6 +1417,20 @@ "addons/*" ] }, + "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", + "optional": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1375,6 +1444,41 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "optional": true, + "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", + "optional": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1392,6 +1496,31 @@ "node": ">=12" } }, + "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", + "optional": true, + "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/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1408,6 +1537,16 @@ "esbuild": ">=0.18" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1418,6 +1557,37 @@ "node": ">=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", + "optional": true, + "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", + "optional": true, + "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/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1471,6 +1641,30 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "optional": true, + "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", + "optional": true, + "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", @@ -1478,11 +1672,64 @@ "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", + "optional": true, + "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", + "optional": true, + "engines": { + "node": ">=6.6.0" + } + }, + "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", + "optional": true, + "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", + "optional": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1496,6 +1743,16 @@ } } }, + "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", + "optional": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1506,6 +1763,58 @@ "node": ">=8" } }, + "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", + "optional": true, + "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", + "optional": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.8" + } + }, + "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", + "optional": true, + "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", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -1513,6 +1822,19 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -1555,6 +1877,13 @@ "@esbuild/win32-x64": "0.27.7" } }, + "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", + "optional": true + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1565,6 +1894,39 @@ "@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", + "optional": true, + "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", + "optional": true, + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "optional": true, + "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", @@ -1575,6 +1937,93 @@ "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", + "optional": true, + "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.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ip-address": "^10.2.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", + "optional": true + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1593,6 +2042,28 @@ } } }, + "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", + "optional": true, + "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/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -1605,6 +2076,26 @@ "rollup": "^4.34.8" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "optional": true, + "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", + "optional": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1620,8 +2111,195 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/joycon": { - "version": "3.1.1", + "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", + "optional": true, + "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", + "optional": true, + "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", + "optional": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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", + "optional": true, + "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", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "optional": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16.9.0" + } + }, + "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", + "optional": true, + "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", + "optional": true, + "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", + "optional": true + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "optional": true, + "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", + "optional": true, + "engines": { + "node": ">= 0.10" + } + }, + "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", + "optional": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "optional": true + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/joycon": { + "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", "dev": true, @@ -1630,6 +2308,20 @@ "node": ">=10" } }, + "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", + "optional": true + }, + "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", + "optional": true + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1931,6 +2623,66 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "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", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "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", + "optional": true, + "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", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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", + "optional": true, + "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", + "optional": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -1948,7 +2700,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/mz": { @@ -1982,16 +2734,39 @@ "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", + "optional": true, + "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==", - "dev": true, + "devOptional": true, "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", + "optional": true, + "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", @@ -2003,6 +2778,60 @@ ], "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", + "optional": true, + "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", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "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", + "optional": true, + "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", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "optional": true, + "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", @@ -2040,6 +2869,16 @@ "node": ">= 6" } }, + "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", + "optional": true, + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -2124,6 +2963,62 @@ } } }, + "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", + "optional": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "optional": true, + "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", + "optional": true, + "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", + "optional": true, + "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/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2138,6 +3033,16 @@ "url": "https://paulmillr.com/funding/" } }, + "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", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -2227,6 +3132,183 @@ "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", + "optional": true, + "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", + "optional": 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", + "optional": true, + "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", + "optional": true, + "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", + "optional": true + }, + "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", + "optional": true, + "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", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "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", + "optional": true, + "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.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "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", + "optional": true, + "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", + "optional": true, + "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", @@ -2261,6 +3343,16 @@ "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", + "optional": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", @@ -2355,6 +3447,16 @@ "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", + "optional": true, + "engines": { + "node": ">=0.6" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -2433,6 +3535,39 @@ } } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "optional": true, + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -2461,6 +3596,26 @@ "dev": true, "license": "MIT" }, + "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", + "optional": true, + "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", + "optional": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", @@ -2639,6 +3794,22 @@ "node": ">=18" } }, + "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", + "optional": true, + "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", @@ -2655,6 +3826,33 @@ "engines": { "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", + "optional": true + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "optional": true, + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 79493de..e0e9d65 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,12 @@ }, "bin": { "sickofcodeblocks": "dist/cli.js", - "socb": "dist/cli.js" + "socb": "dist/cli.js", + "socb-mcp": "dist/mcp.js" }, "files": [ "dist", + "hooks", "README.md", "LICENSE", "CHANGELOG.md" @@ -55,6 +57,7 @@ "vitest": "^4.1.8" }, "optionalDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "@xterm/headless": "^6.0.0" } } diff --git a/src/cli.ts b/src/cli.ts index 80a8e51..da99a60 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,7 @@ import { type ParsedCli, } from "./options.js"; import { isPipedStdin, readStdin } from "./io/stdin.js"; +import { decodeInput } from "./io/decode.js"; import { readClipboard, writeClipboard, ClipboardError } from "./io/clipboard.js"; import { sanitize, shouldSuggestEmulate } from "./pipeline.js"; import { EmulateUnavailableError } from "./transforms/emulate.js"; @@ -23,7 +24,7 @@ function fail(message: string): void { async function loadInput(cli: ParsedCli): Promise { if (cli.source.kind === "file") { - return readFileSync(cli.source.path, "utf8"); + return decodeInput(readFileSync(cli.source.path)); } if (cli.source.kind === "clip") { return readClipboard(); diff --git a/src/config.ts b/src/config.ts index 27112c4..15cd30e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -52,6 +52,12 @@ export function validate(obj: unknown): Partial { const bools: Array = [ "redact", "stripGlyphs", + "stripFences", + "powershell", + "prompts", + "markdown", + "html", + "reflow", "stripEmoji", "typographic", "arrows", diff --git a/src/index.ts b/src/index.ts index 4d6ebc1..9f10903 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,10 +13,22 @@ export { rewriteHyperlinks } from "./transforms/hyperlinks.js"; export { resolveOverwrites } from "./transforms/overwrite.js"; export { stripEscapes, stripLooseControls } from "./transforms/escapes.js"; export { stripGlyphs } from "./transforms/glyphs.js"; +export { stripFences } from "./transforms/fences.js"; +export { cleanPowerShell } from "./transforms/powershell.js"; +export { stripPrompts } from "./transforms/prompts.js"; +export { + flattenMarkdown, + flattenMarkdownText, + protectCode, + restoreCodes, +} from "./transforms/markdown.js"; +export { htmlToText } from "./transforms/html.js"; export { transformTables } from "./transforms/tables.js"; export { stripEmoji } from "./transforms/emoji.js"; export { normalizeTypography } from "./transforms/typography.js"; +export { reflowParagraphs } from "./transforms/reflow.js"; export { normalizeWhitespace } from "./transforms/whitespace.js"; +export { decodeInput } from "./io/decode.js"; export { redact } from "./transforms/redact.js"; export { emulate, EmulateUnavailableError } from "./transforms/emulate.js"; export { presetPatch, type Preset } from "./presets.js"; diff --git a/src/io/decode.ts b/src/io/decode.ts new file mode 100644 index 0000000..ac75062 --- /dev/null +++ b/src/io/decode.ts @@ -0,0 +1,29 @@ +// Decode raw input bytes to a string, honoring a byte-order mark. Windows files +// and clipboard exports are frequently UTF-16 (Notepad, PowerShell redirection, +// "Save As Unicode"); decoding those as UTF-8 yields mojibake. We sniff the BOM +// and fall back to UTF-8 (the overwhelmingly common case for pipes). + +/** Decode a Buffer to text, detecting UTF-16 LE/BE BOMs; default UTF-8. */ +export function decodeInput(buf: Buffer): string { + if (buf.length >= 2) { + // UTF-16 LE BOM: FF FE + if (buf[0] === 0xff && buf[1] === 0xfe) { + return stripBom(buf.toString("utf16le")); + } + // UTF-16 BE BOM: FE FF. Node has no "utf16be" decoder, so swap byte pairs. + if (buf[0] === 0xfe && buf[1] === 0xff) { + const swapped = Buffer.allocUnsafe(buf.length); + for (let i = 0; i + 1 < buf.length; i += 2) { + swapped[i] = buf[i + 1] as number; + swapped[i + 1] = buf[i] as number; + } + if (buf.length % 2 === 1) swapped[buf.length - 1] = buf[buf.length - 1] as number; + return stripBom(swapped.toString("utf16le")); + } + } + return stripBom(buf.toString("utf8")); // includes a UTF-8 BOM (EF BB BF) -> U+FEFF +} + +function stripBom(s: string): string { + return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s; +} diff --git a/src/io/stdin.ts b/src/io/stdin.ts index 39b1e56..a3a1054 100644 --- a/src/io/stdin.ts +++ b/src/io/stdin.ts @@ -1,4 +1,6 @@ -// Read piped stdin as UTF-8. +// Read piped stdin as raw bytes, then decode (UTF-8 / UTF-16 by BOM). + +import { decodeInput } from "./decode.js"; /** True when stdin is NOT an interactive terminal (i.e. data is piped/redirected). */ export function isPipedStdin(): boolean { @@ -7,8 +9,10 @@ export function isPipedStdin(): boolean { /** Read all of stdin to a string. Handles chunking/backpressure via async iteration. */ export async function readStdin(): Promise { - process.stdin.setEncoding("utf8"); - let data = ""; - for await (const chunk of process.stdin) data += chunk; - return data; + const chunks: Buffer[] = []; + // No setEncoding: collect raw Buffers so decodeInput can detect a UTF-16 BOM. + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer)); + } + return decodeInput(Buffer.concat(chunks)); } diff --git a/src/mcp.ts b/src/mcp.ts new file mode 100644 index 0000000..081e9d9 --- /dev/null +++ b/src/mcp.ts @@ -0,0 +1,130 @@ +// MCP server: exposes socb's sanitizer as a tool any MCP client (Claude Desktop, +// Claude Code, Codex, ...) can call. Runs over stdio. The @modelcontextprotocol +// /sdk is an OPTIONAL dependency, so the default CLI path stays dependency-free; +// this entry only loads it when you actually run `socb-mcp`. +// +// tsup prepends the #!/usr/bin/env node shebang. The server is started only when +// this file is run directly (see the bottom guard), so importing sanitizeForTool +// in tests does not block on stdio. + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { sanitize, type SanitizeOptions } from "./pipeline.js"; +import { presetPatch, type Preset } from "./presets.js"; +import { getVersion } from "./options.js"; + +const PRESETS: readonly Preset[] = ["slack", "email", "plain", "agent"]; + +export interface SanitizeToolArgs { + text: string; + preset?: Preset; + options?: Partial; +} + +/** Core of the sanitize_text tool: resolve preset + overrides, then sanitize. */ +export async function sanitizeForTool(args: SanitizeToolArgs): Promise { + let opts: Partial = {}; + if (args.preset && PRESETS.includes(args.preset)) opts = presetPatch(args.preset); + if (args.options && typeof args.options === "object") { + opts = { ...opts, ...args.options }; + } + return sanitize(args.text, opts); +} + +const TOOL = { + name: "sanitize_text", + description: + "Clean messy terminal / Markdown / HTML / PowerShell output into plain, " + + "human-readable text. Use it before quoting command output back to a user, " + + "or when asked to make output paste-ready for email, Slack, or docs. It " + + "strips ANSI codes, collapses progress bars/spinners, and removes box-drawing " + + "and Nerd-Font glyph noise. With preset 'email' or 'plain' it also flattens " + + "Markdown and HTML to prose; with 'agent' it denoises but keeps Markdown " + + "structure (useful for re-ingesting tool output without burning tokens).", + inputSchema: { + type: "object", + properties: { + text: { type: "string", description: "The raw text to sanitize." }, + preset: { + type: "string", + enum: PRESETS as readonly string[], + description: + "Optional bundle. 'email'/'plain': flatten Markdown+HTML for a human " + + "reader. 'slack': keep Unicode/tables. 'agent': denoise but keep " + + "Markdown + Unicode for a model.", + }, + options: { + type: "object", + description: + 'Optional per-field overrides of SanitizeOptions, e.g. {"redact": true} ' + + 'or {"tableMode": "ascii"}. Applied on top of the preset.', + }, + }, + required: ["text"], + additionalProperties: false, + }, +} as const; + +function buildServer(): Server { + const server = new Server( + { name: "socb", version: getVersion() }, + { capabilities: { tools: {} } }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [TOOL] })); + + server.setRequestHandler(CallToolRequestSchema, async (req) => { + if (req.params.name !== TOOL.name) { + return { + isError: true, + content: [{ type: "text", text: `unknown tool: ${req.params.name}` }], + }; + } + const a = (req.params.arguments ?? {}) as Record; + if (typeof a.text !== "string") { + return { + isError: true, + content: [{ type: "text", text: "missing required string argument: text" }], + }; + } + const args: SanitizeToolArgs = { text: a.text }; + if (typeof a.preset === "string") args.preset = a.preset as Preset; + if (a.options && typeof a.options === "object") { + args.options = a.options as Partial; + } + const clean = await sanitizeForTool(args); + return { content: [{ type: "text", text: clean }] }; + }); + + return server; +} + +async function main(): Promise { + const server = buildServer(); + await server.connect(new StdioServerTransport()); +} + +// Start the server only when run directly (not when imported by a test). +function isDirectRun(): boolean { + try { + return ( + typeof process.argv[1] === "string" && + realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +} + +if (isDirectRun()) { + main().catch((e: unknown) => { + process.stderr.write(`socb-mcp error: ${e instanceof Error ? e.message : String(e)}\n`); + process.exitCode = 1; + }); +} diff --git a/src/options.ts b/src/options.ts index 43266af..7b00262 100644 --- a/src/options.ts +++ b/src/options.ts @@ -70,6 +70,19 @@ export function parseCli(argv: string[]): ParsedCli { "strip-emoji": { type: "boolean" }, "no-glyphs": { type: "boolean" }, "keep-glyphs": { type: "boolean" }, + "no-fences": { type: "boolean" }, + "keep-fences": { type: "boolean" }, + markdown: { type: "boolean", short: "m" }, + "no-markdown": { type: "boolean" }, + html: { type: "boolean" }, + "no-html": { type: "boolean" }, + prompts: { type: "boolean" }, + "no-prompts": { type: "boolean" }, + reflow: { type: "boolean" }, + "no-reflow": { type: "boolean" }, + powershell: { type: "boolean" }, + ps: { type: "boolean" }, + "no-powershell": { type: "boolean" }, "no-typographic": { type: "boolean" }, arrows: { type: "boolean" }, "expand-tabs": { type: "boolean" }, @@ -82,6 +95,7 @@ export function parseCli(argv: string[]): ParsedCli { slack: { type: "boolean" }, email: { type: "boolean" }, plain: { type: "boolean" }, + agent: { type: "boolean" }, help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" }, }, @@ -104,7 +118,9 @@ export function parseCli(argv: string[]): ParsedCli { ? "email" : v.slack ? "slack" - : null; + : v.agent + ? "agent" + : null; if (preset) o = { ...o, ...presetPatch(preset) }; if (v.table !== undefined) { @@ -121,6 +137,17 @@ export function parseCli(argv: string[]): ParsedCli { if (v["no-links"]) o.hyperlinks = false; if (v["strip-emoji"]) o.stripEmoji = true; if (v["no-glyphs"] || v["keep-glyphs"]) o.stripGlyphs = false; + if (v["no-fences"] || v["keep-fences"]) o.stripFences = false; + if (v.markdown) o.markdown = true; + if (v["no-markdown"]) o.markdown = false; + if (v.html) o.html = true; + if (v["no-html"]) o.html = false; + if (v.prompts) o.prompts = true; + if (v["no-prompts"]) o.prompts = false; + if (v.reflow) o.reflow = true; + if (v["no-reflow"]) o.reflow = false; + if (v.powershell || v.ps) o.powershell = true; + if (v["no-powershell"]) o.powershell = false; if (v["no-typographic"]) o.typographic = false; if (v.arrows) o.arrows = true; if (v["no-collapse-blanks"]) o.collapseBlankLines = false; @@ -191,6 +218,15 @@ OPTIONS --no-links do not rewrite OSC 8 hyperlinks to "text (url)" --strip-emoji remove emoji, ZWJ sequences, variation selectors, skin tones --no-glyphs keep Nerd Font / Private-Use glyphs (default strips them) + --no-fences keep Markdown code fences + PowerShell ~ underlines + (default strips these marker lines) + -m, --markdown flatten Markdown to readable text: headings, lists, + **bold**, [links](url) -> text (url); code kept verbatim + --no-markdown keep Markdown markup literal (the default) + --html strip HTML tags and decode entities (& -> &) + --prompts strip leading shell prompts ($, PS C:\>, >>>) + --reflow rejoin hard-wrapped prose into flowing paragraphs + --powershell, --ps tidy pasted PowerShell errors (~ underlines + "+" gutter) --no-typographic keep smart quotes / em-dashes / ellipsis (default -> ASCII) --arrows also convert arrows (-> for the right arrow, etc.) --expand-tabs convert tabs to spaces (4 wide) @@ -205,8 +241,10 @@ OPTIONS PRESETS (apply a bundle; individual flags still override) --slack tables reconstructed, emoji kept, typographic on - --email tables stripped, emoji stripped, typographic on - --plain tables stripped, emoji+glyphs stripped, arrows + tabs->spaces + --email + flatten Markdown/HTML, strip prompts, reflow wraps + --plain email preset + arrows + tabs->spaces (max compatibility) + --agent denoise for feeding output INTO a model: strip ANSI/box/ + glyph noise, KEEP Markdown structure + Unicode (no folding) CONFIG (set persistent defaults so you don't repeat flags) ~/.socbrc.json or ./.socbrc.json , e.g. { "redact": true, "tableMode": "strip" } diff --git a/src/pipeline.ts b/src/pipeline.ts index 98d928c..4f21d27 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -5,9 +5,15 @@ import { rewriteHyperlinks } from "./transforms/hyperlinks.js"; import { resolveOverwrites } from "./transforms/overwrite.js"; import { stripEscapes, stripLooseControls } from "./transforms/escapes.js"; import { stripGlyphs } from "./transforms/glyphs.js"; +import { stripFences } from "./transforms/fences.js"; +import { cleanPowerShell } from "./transforms/powershell.js"; +import { stripPrompts } from "./transforms/prompts.js"; +import { protectCode, flattenMarkdownText, restoreCodes } from "./transforms/markdown.js"; +import { htmlToText } from "./transforms/html.js"; import { transformTables } from "./transforms/tables.js"; import { stripEmoji } from "./transforms/emoji.js"; import { normalizeTypography } from "./transforms/typography.js"; +import { reflowParagraphs } from "./transforms/reflow.js"; import { redact } from "./transforms/redact.js"; import { normalizeWhitespace } from "./transforms/whitespace.js"; import { emulate } from "./transforms/emulate.js"; @@ -26,11 +32,23 @@ export interface SanitizeOptions { tableMode: TableMode; /** Step 6. */ stripGlyphs: boolean; + /** Step 6b: drop Markdown code fences + PowerShell tilde underlines. */ + stripFences: boolean; + /** Step 5b: tidy pasted PowerShell errors (~ underlines + "+ " gutter). */ + powershell: boolean; + /** Step 5c: strip leading shell prompts ($, PS>, >>>). */ + prompts: boolean; + /** Step 6c: flatten Markdown to readable text (code kept verbatim). */ + markdown: boolean; + /** Step 6b': strip HTML tags + decode entities. */ + html: boolean; /** Step 8. */ stripEmoji: boolean; /** Step 9. */ typographic: boolean; arrows: boolean; + /** Step 9a: rejoin hard-wrapped prose into flowing paragraphs. */ + reflow: boolean; /** Step 10. */ redact: boolean; /** Step 11. */ @@ -48,9 +66,15 @@ export const DEFAULTS: SanitizeOptions = { hyperlinks: true, tableMode: "reconstruct", stripGlyphs: true, + stripFences: true, + powershell: false, + prompts: false, + markdown: false, + html: false, stripEmoji: false, typographic: true, arrows: false, + reflow: false, redact: false, expandTabs: false, collapseBlankLines: true, @@ -87,10 +111,27 @@ export async function sanitize( s = stripLooseControls(s); // step 5 } + if (o.powershell) s = cleanPowerShell(s); // step 5b + if (o.prompts) s = stripPrompts(s); // step 5c if (o.stripGlyphs) s = stripGlyphs(s); // step 6 + + // Markdown owns fence handling and protects code from later passes. When it is + // off we fall back to the line-level fence/underline strip (step 6b). + let codes: string[] = []; + if (o.markdown) { + const protectedCode = protectCode(s); // step 6a + s = protectedCode.text; + codes = protectedCode.codes; + } + if (o.html) s = htmlToText(s); // step 6b' (inside the code bracket) + if (o.markdown) s = flattenMarkdownText(s); // step 6c + else if (o.stripFences) s = stripFences(s); // step 6b + s = transformTables(s, o.tableMode); // step 7 if (o.stripEmoji) s = stripEmoji(s); // step 8 if (o.typographic) s = normalizeTypography(s, o.arrows); // step 9 + if (o.reflow) s = reflowParagraphs(s); // step 9a + if (o.markdown) s = restoreCodes(s, codes); // step 9b: code back, verbatim if (o.redact) s = redact(s, o.maxLineLength); // step 10 s = normalizeWhitespace(s, { diff --git a/src/presets.ts b/src/presets.ts index 46f749b..04d42dd 100644 --- a/src/presets.ts +++ b/src/presets.ts @@ -3,7 +3,7 @@ import type { SanitizeOptions } from "./pipeline.js"; -export type Preset = "slack" | "email" | "plain"; +export type Preset = "slack" | "email" | "plain" | "agent"; export function presetPatch(preset: Preset): Partial { switch (preset) { @@ -18,11 +18,17 @@ export function presetPatch(preset: Preset): Partial { }; case "email": // Email is often a proportional font -> aligned tables break; strip borders. + // Flatten Markdown/HTML and reflow wraps so a paste reads as plain prose. return { tableMode: "strip", stripEmoji: true, stripGlyphs: true, typographic: true, + markdown: true, + html: true, + prompts: true, + powershell: true, + reflow: true, }; case "plain": // Maximum ASCII safety for any destination. @@ -31,8 +37,26 @@ export function presetPatch(preset: Preset): Partial { stripEmoji: true, stripGlyphs: true, typographic: true, + markdown: true, + html: true, + prompts: true, + powershell: true, + reflow: true, arrows: true, expandTabs: 4, }; + case "agent": + // Tuned for feeding output INTO a model: kill ANSI/progress/glyph/box noise + // to cut tokens, but KEEP Markdown structure and Unicode (models read them + // fine, so flattening/ASCII-folding would just lose signal). + return { + tableMode: "strip", + stripGlyphs: true, + stripEmoji: false, + typographic: false, + markdown: false, + html: false, + reflow: false, + }; } } diff --git a/src/transforms/fences.ts b/src/transforms/fences.ts new file mode 100644 index 0000000..fae14f6 --- /dev/null +++ b/src/transforms/fences.ts @@ -0,0 +1,31 @@ +// Strip code-block delimiter lines so a paste lands as plain text with no +// fences. Two kinds of marker lines are removed (the wrapped code/text between +// them is kept untouched): +// +// * Markdown code fences — a line of 3+ backticks or 3+ tildes (the CommonMark +// fence), optionally indented up to 3 spaces and carrying an info string +// (e.g. ```bash). Both the opening and the closing fence are removed. +// * PowerShell error underlines — the run of "~" PowerShell prints under the +// offending token in a parse/binding error, usually behind the "+" gutter +// (e.g. " + ~~~~~~~~"). +// +// Both render as noise once the text leaves a code block, which is exactly what +// makes a paste look like a screenshot-of-a-terminal. We drop the whole marker +// line (and its newline) rather than blanking it, so no stray empty line is left. + +// Up to 3 spaces of indent, then >=3 backticks or >=3 tildes, then any info string. +const MD_FENCE = /^ {0,3}(?:`{3,}|~{3,}).*$/; +// PowerShell underline: optional indent, then either the "+" gutter followed by +// a tilde run, or a bare run of 2+ tildes. Requiring the gutter-or-2 guards a +// lone literal "~" on its own line from being swallowed. +const PS_UNDERLINE = /^\s*(?:\+[ \t]*~[~ \t]*|~~[~ \t]*)$/; + +/** Remove Markdown code-fence lines and PowerShell tilde-underline lines. */ +export function stripFences(input: string): string { + // Fast path: no fence/underline characters anywhere. + if (input.indexOf("`") === -1 && input.indexOf("~") === -1) return input; + return input + .split("\n") + .filter((line) => !MD_FENCE.test(line) && !PS_UNDERLINE.test(line)) + .join("\n"); +} diff --git a/src/transforms/html.ts b/src/transforms/html.ts new file mode 100644 index 0000000..c6b4a9a --- /dev/null +++ b/src/transforms/html.ts @@ -0,0 +1,71 @@ +// HTML -> plain text. Clipboards frequently hold HTML (copying from a browser, +// Slack, Teams, rendered docs), which otherwise pastes as raw markup. We drop +// script/style bodies and comments, turn
and block-level tags into line +// breaks, strip the remaining tags, and decode character references LAST (so a +// decoded "<" is never re-read as a tag). +// +// This is opt-in (presets --email/--plain, or --html) because terminal output +// legitimately contains "<" / ">" (redirections like 2>&1, C++ ). In the +// Markdown pipeline this runs after code has been pulled out to sentinels, so +// angle brackets inside code are safe. + +const SCRIPT_STYLE = /<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi; +const COMMENT = //g; +const BR = //gi; +// Block-level elements become a newline so structure survives as line breaks. +const BLOCK = + /<\/?(?:p|div|li|ul|ol|tr|table|thead|tbody|tfoot|h[1-6]|section|article|header|footer|nav|aside|main|blockquote|pre|hr|figure|figcaption|form|dl|dt|dd)\b[^>]*>/gi; +// A remaining tag: "<" / "". +// Requiring a letter right after "<" leaves "a < b" and "2 > 1" untouched. +const TAG = /<\/?[a-z][a-z0-9-]*(?:\s[^<>]*?)?\/?>/gi; + +const NAMED: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: " ", + mdash: "--", + ndash: "-", + hellip: "...", + copy: "(C)", + reg: "(R)", + trade: "(TM)", + ldquo: '"', + rdquo: '"', + lsquo: "'", + rsquo: "'", +}; + +const ENTITY = /&(#x?[0-9a-f]+|[a-z][a-z0-9]*);/gi; + +function decodeEntities(input: string): string { + return input.replace(ENTITY, (m, body: string) => { + if (body[0] === "#") { + const hex = body[1] === "x" || body[1] === "X"; + const cp = parseInt(body.slice(hex ? 2 : 1), hex ? 16 : 10); + if (Number.isFinite(cp) && cp > 0 && cp <= 0x10ffff) { + try { + return String.fromCodePoint(cp); + } catch { + return m; + } + } + return m; + } + const named = NAMED[body.toLowerCase()]; + return named ?? m; + }); +} + +/** Convert HTML markup to readable plain text. */ +export function htmlToText(input: string): string { + if (input.indexOf("<") === -1 && input.indexOf("&") === -1) return input; // fast path + let s = input.replace(SCRIPT_STYLE, ""); + s = s.replace(COMMENT, ""); + s = s.replace(BR, "\n"); + s = s.replace(BLOCK, "\n"); + s = s.replace(TAG, ""); + return decodeEntities(s); +} diff --git a/src/transforms/markdown.ts b/src/transforms/markdown.ts new file mode 100644 index 0000000..89e421a --- /dev/null +++ b/src/transforms/markdown.ts @@ -0,0 +1,204 @@ +// Markdown -> readable plain text. The goal is a paste that reads as prose, not +// as marked-up source: no fences, no inline backticks, no `**` / `_` / `#`, and +// links written as "text (url)" (same convention as the OSC 8 rewriter). +// +// The hard requirement is that CODE survives verbatim — both fenced blocks and +// inline `code` spans must NOT be touched by the inline flattener OR by the later +// typography pass (which would turn `->` into a smart arrow, `--` into a dash, +// etc. inside code). We solve this by EXTRACTING code to sentinel tokens up front +// (protectCode), running every text transform over the sentinel'd string, then +// restoring the verbatim code at the end (restoreCodes). The pipeline interleaves +// HTML stripping and typography between the two; flattenMarkdown() is the +// all-in-one convenience used by the library and unit tests. +// +// Sentinels use U+0000 as a delimiter: it is a C0 control already removed by +// stripLooseControls (pipeline step 5, which runs before this), so it cannot +// collide with real input, and no later step (typography, whitespace) rewrites +// it. We build it via String.fromCharCode so this source file stays pure ASCII. + +const NUL = String.fromCharCode(0); +const FENCE_OPEN = /^( {0,3})(`{3,}|~{3,})(.*)$/; +// Inline code: a run of N backticks, content (single line, runs of < N backticks +// allowed), then a matching run of exactly N backticks. +const INLINE_CODE = /(`+)([^\n]*?)\1(?!`)/g; +const SENTINEL = new RegExp(NUL + "(\\d+)" + NUL, "g"); +const SENTINEL_LINE = new RegExp("^" + NUL + "\\d+" + NUL + "$"); +const ESCAPE_TOKEN = new RegExp(NUL + "e(\\d+)" + NUL, "g"); + +function sentinel(i: number): string { + return NUL + i + NUL; +} + +/** + * Pull fenced code blocks and inline code spans out into `codes`, replacing each + * with a sentinel token so downstream text transforms leave the code alone. + */ +export function protectCode(input: string): { text: string; codes: string[] } { + const codes: string[] = []; + const lines = input.split("\n"); + const out: string[] = []; + + let i = 0; + while (i < lines.length) { + const m = FENCE_OPEN.exec(lines[i] as string); + if (m) { + const indent = (m[1] as string).length; + const fenceChar = (m[2] as string)[0] as string; // "`" or "~" + const fenceLen = (m[2] as string).length; + const closeRe = new RegExp("^ {0,3}" + fenceChar + "{" + fenceLen + ",}[ \\t]*$"); + const dedent = new RegExp("^ {0," + indent + "}"); + const body: string[] = []; + let j = i + 1; + let closed = false; + while (j < lines.length) { + if (closeRe.test(lines[j] as string)) { + closed = true; + break; + } + body.push((lines[j] as string).replace(dedent, "")); + j += 1; + } + if (closed) { + codes.push(body.join("\n")); + out.push(sentinel(codes.length - 1)); + i = j + 1; // skip the closing fence + } else { + // No closing fence: treat the opener as an ordinary line rather than + // swallowing the rest of the document (safer for stray "~~~" underlines). + out.push(lines[i] as string); + i += 1; + } + continue; + } + out.push(lines[i] as string); + i += 1; + } + + const text = out.join("\n").replace(INLINE_CODE, (_match, _ticks: string, content: string) => { + codes.push(content); + return sentinel(codes.length - 1); + }); + return { text, codes }; +} + +/** Restore code sentinels produced by protectCode to their verbatim text. */ +export function restoreCodes(text: string, codes: string[]): string { + if (codes.length === 0) return text; + return text.replace(SENTINEL, (_m, i: string) => codes[Number(i)] ?? ""); +} + +const ESCAPED = /\\([!-/:-@[-`{-~])/g; // backslash-escaped ASCII punctuation +const IMAGE = /!\[([^\]]*)\]\(\s*]+)>?(?:\s+"[^"]*"|\s+'[^']*')?\s*\)/g; +const LINK = /\[([^\]]+)\]\(\s*]+)>?(?:\s+"[^"]*"|\s+'[^']*')?\s*\)/g; +const AUTOLINK = /<((?:https?|ftp|mailto):[^>\s]+)>/g; +const STRIKE = /~~(?=\S)([^~]+?)~~/g; +const BOLD_STAR = /\*\*(?=\S)([^*]+?)\*\*/g; +const BOLD_UNDER = /__(?=\S)([^_]+?)__/g; +const ITALIC_STAR = /\*(?=\S)([^*\n]+?)\*/g; +// Underscore emphasis only at word boundaries, so identifiers like my_var_name +// (and the sentinel tokens) are left intact. +const ITALIC_UNDER = /(^|[^A-Za-z0-9_])_(?=\S)([^_\n]+?)_(?![A-Za-z0-9_])/g; + +/** Strip inline Markdown markup from a single line; code sentinels pass through. */ +function flattenInline(input: string): string { + const escapes: string[] = []; + let s = input.replace(ESCAPED, (_m, ch: string) => { + escapes.push(ch); + return NUL + "e" + (escapes.length - 1) + NUL; + }); + + s = s.replace(IMAGE, (_m, alt: string, url: string) => (alt ? `${alt} (${url})` : url)); + s = s.replace(LINK, (_m, text: string, url: string) => (text === url ? text : `${text} (${url})`)); + s = s.replace(AUTOLINK, "$1"); + s = s.replace(STRIKE, "$1"); + s = s.replace(BOLD_STAR, "$1").replace(BOLD_UNDER, "$1"); + s = s.replace(ITALIC_STAR, "$1").replace(ITALIC_UNDER, "$1$2"); + + return s.replace(ESCAPE_TOKEN, (_m, i: string) => escapes[Number(i)] ?? ""); +} + +const BLOCKQUOTE = /^(\s*)(?:> ?)+/; +const HR = /^ {0,3}([-*_])( *\1){2,} *$/; +const ATX = /^ {0,3}(#{1,6})\s+(.*?)(?:\s+#+)?\s*$/; +const SETEXT_UNDERLINE = /^ {0,3}=+\s*$/; +const TASK = /^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$/; +const BULLET = /^(\s*)[-*+]\s+(.*)$/; +const ORDERED = /^(\s*)(\d{1,9})[.)]\s+(.*)$/; + +/** + * Flatten Markdown block + inline structure to readable text. Assumes code has + * already been replaced with sentinels by protectCode. + */ +export function flattenMarkdownText(text: string): string { + const lines = text.split("\n"); + const out: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i] as string; + if (SENTINEL_LINE.test(raw)) { + out.push(raw); + continue; + } + + // Strip blockquote markers (any nesting), keeping leading indent. + const line = raw.replace(BLOCKQUOTE, "$1"); + + // Setext heading: text on this line, "===" underline on the next. + const next = lines[i + 1]; + if ( + /\S/.test(line) && + !/^\s*#/.test(line) && + !HR.test(line) && + next !== undefined && + SETEXT_UNDERLINE.test(next) + ) { + out.push(flattenInline(line.trim())); + i += 1; // consume the underline + continue; + } + + if (HR.test(line)) { + out.push(""); // horizontal rule -> blank separator + continue; + } + + const atx = ATX.exec(line); + if (atx) { + out.push(flattenInline(atx[2] as string)); + continue; + } + + let m = TASK.exec(line); + if (m) { + const mark = (m[2] as string).toLowerCase() === "x" ? "x" : " "; + out.push(`${m[1]}- [${mark}] ${flattenInline(m[3] as string)}`); + continue; + } + m = BULLET.exec(line); + if (m) { + out.push(`${m[1]}- ${flattenInline(m[2] as string)}`); + continue; + } + m = ORDERED.exec(line); + if (m) { + out.push(`${m[1]}${m[2]}. ${flattenInline(m[3] as string)}`); + continue; + } + + out.push(flattenInline(line)); + } + + return out.join("\n"); +} + +// Trigger the full pass on any inline marker, a list line, or a rule/setext +// underline line (all dashes or all equals). "*"/"_" rules already match the +// inline class, so only the all-dash / all-equals cases need adding here. +const MD_TRIGGER = /[`*_#>[\]~<]|^[ \t]*(?:[-+]\s|\d{1,9}[.)]\s|[-=][-= \t]*$)/m; + +/** Flatten Markdown to readable plain text, keeping code verbatim. */ +export function flattenMarkdown(input: string): string { + if (!MD_TRIGGER.test(input)) return input; // fast path: no markup at all + const { text, codes } = protectCode(input); + return restoreCodes(flattenMarkdownText(text), codes); +} diff --git a/src/transforms/powershell.ts b/src/transforms/powershell.ts new file mode 100644 index 0000000..47f9d23 --- /dev/null +++ b/src/transforms/powershell.ts @@ -0,0 +1,40 @@ +// Tidy pasted PowerShell error / transcript output. A PS error record looks like: +// +// Get-Item : Cannot find path 'C:\nope' because it does not exist. +// At line:1 char:1 +// + Get-Item C:\nope +// + ~~~~~~~~~~~~~~~~ +// + CategoryInfo : ObjectNotFound: (C:\nope:String) [Get-Item], ... +// + FullyQualifiedErrorId : PathNotFound,Microsoft.PowerShell.Commands.GetItemCommand +// +// We remove the "~~~~" underline lines and strip the leading "+ " continuation +// gutter, keeping the readable text (and the useful "At line:N char:M"). To avoid +// mangling unrelated input (e.g. a unified-diff "+added" line), this only acts +// when the text actually looks like PowerShell output, and the gutter form "+ " +// (plus a space) never matches a column-0 diff marker like "+added". + +const AT_LINE = /^\s*At line:\d+ char:\d+\s*$/; +// Underline run, with or without the "+" gutter (matches fences.ts PS_UNDERLINE). +const PS_UNDERLINE = /^\s*(?:\+[ \t]*~[~ \t]*|~~[~ \t]*)$/; +// Leading "+ " continuation gutter (indent allowed), followed by real content. +const GUTTER = /^\s*\+ (?=\S)(.*)$/; + +/** True when the text carries PowerShell error-record markers. */ +function looksLikePowerShell(lines: string[]): boolean { + return lines.some((l) => AT_LINE.test(l) || PS_UNDERLINE.test(l)); +} + +/** Remove PowerShell underline lines and the "+ " gutter from error output. */ +export function cleanPowerShell(input: string): string { + if (input.indexOf("~") === -1 && !/At line:\d+/.test(input)) return input; // fast path + const lines = input.split("\n"); + if (!looksLikePowerShell(lines)) return input; + + const out: string[] = []; + for (const line of lines) { + if (PS_UNDERLINE.test(line)) continue; // drop the caret/tilde underline + const m = GUTTER.exec(line); + out.push(m ? (m[1] as string) : line); + } + return out.join("\n"); +} diff --git a/src/transforms/prompts.ts b/src/transforms/prompts.ts new file mode 100644 index 0000000..8204601 --- /dev/null +++ b/src/transforms/prompts.ts @@ -0,0 +1,34 @@ +// Strip leading shell prompts so a copied terminal session pastes as just the +// commands and their output. Conservative on purpose: we only remove prompts +// that are unambiguous. In particular we do NOT touch a bare ">" (Markdown +// blockquote / redirection) or a bare "#" (heading / comment). +// +// $ npm test -> npm test +// PS C:\Users\me> dir -> dir +// >>> print("hi") -> print("hi") +// me@host:~/p$ ls -> ls + +const PROMPTS: readonly RegExp[] = [ + /^\s*\$ (?=\S)/, // POSIX shell "$ " (space required, so "$var"/"$5" are safe) + /^\s*PS [^>\n]*> ?/, // PowerShell "PS C:\path> " + /^\s*>>> ?/, // Python REPL primary prompt + /^\s*[\w.-]+@[\w.-]+:[^\s$#]*[$#] (?=\S)/, // user@host:path$ / user@host:path# +]; + +// Fast path: only worth scanning if a prompt-ish marker could be present. +const HAS_PROMPT = /^\s*(?:\$ |PS [^>\n]*>|>>>|[\w.-]+@[\w.-]+:)/m; + +/** Remove conservative leading shell prompts from each line. */ +export function stripPrompts(input: string): string { + if (!HAS_PROMPT.test(input)) return input; + return input + .split("\n") + .map((line) => { + for (const re of PROMPTS) { + const m = re.exec(line); + if (m) return line.slice(m[0].length); + } + return line; + }) + .join("\n"); +} diff --git a/src/transforms/reflow.ts b/src/transforms/reflow.ts new file mode 100644 index 0000000..cae7be9 --- /dev/null +++ b/src/transforms/reflow.ts @@ -0,0 +1,45 @@ +// Rejoin hard-wrapped prose so it flows in a proportional-font destination +// (email, Docs) instead of breaking at the original ~72-80 column wrap. +// +// Deliberately conservative — under-joining is far better than gluing unrelated +// lines together. We only join a line onto the previous one when the previous +// line looks like a *filled* wrap line: long, plain prose, not ending in +// sentence/clause punctuation. That leaves list items, code (sentinel lines from +// the Markdown pass), blockquotes/indented blocks, table rows, and paragraph +// breaks (blank lines) untouched. + +const MIN_WRAP_LEN = 60; // shorter "prev" lines are likely headings / standalone +const NUL = String.fromCharCode(0); // protected-code sentinel delimiter (markdown.ts) + +function isStructured(line: string): boolean { + if (line.trim() === "") return true; // blank = paragraph break + if (/^\s/.test(line)) return true; // indented: code / quote / nested list + if (line.indexOf("|") !== -1) return true; // table row + if (line.indexOf(NUL) !== -1) return true; // protected code sentinel + if (/^(?:-|\d+\.)\s/.test(line)) return true; // list item (post-normalization) + return false; +} + +function canJoin(prev: string, cur: string): boolean { + if (isStructured(prev) || isStructured(cur)) return false; + const p = prev.replace(/\s+$/, ""); + if (p.length < MIN_WRAP_LEN) return false; // short => not a wrapped continuation + if (/[.!?:;,)\]]$/.test(p)) return false; // clause/sentence end => keep the break + return true; +} + +/** Join hard-wrapped prose lines into flowing paragraphs (conservative). */ +export function reflowParagraphs(input: string): string { + if (input.indexOf("\n") === -1) return input; + const lines = input.split("\n"); + const out: string[] = []; + for (const line of lines) { + const prev = out[out.length - 1]; + if (prev !== undefined && canJoin(prev, line)) { + out[out.length - 1] = prev.replace(/\s+$/, "") + " " + line.replace(/^\s+/, ""); + } else { + out.push(line); + } + } + return out.join("\n"); +} diff --git a/src/transforms/tables.ts b/src/transforms/tables.ts index c385478..214f572 100644 --- a/src/transforms/tables.ts +++ b/src/transforms/tables.ts @@ -52,8 +52,9 @@ export function isBorderRow(line: string): boolean { hasBorder = true; } else if (ch === "+" || ch === "-" || ch === "=") { hasBorder = true; - } else if (ch === "|" || ch === " ") { - // separators / padding are allowed in a border row but don't qualify it + } else if (ch === "|" || ch === " " || ch === ":") { + // separators / padding / Markdown alignment colons are allowed in a border + // row but don't by themselves qualify it (":" only appears in "| :--: |") } else { return false; } diff --git a/tests/integration/cli.test.ts b/tests/integration/cli.test.ts index a3ff5ac..3e49bcd 100644 --- a/tests/integration/cli.test.ts +++ b/tests/integration/cli.test.ts @@ -55,6 +55,42 @@ describe("cli integration (spawns the built binary)", () => { expect(r.stdout).toBe("Name Age\nBob 30\n"); }); + it("flattens Markdown with the --email preset", () => { + const r = run(["--email"], "# Title\n\n- **bold** item\n"); + expect(r.status).toBe(0); + expect(r.stdout).toBe("Title\n\n- bold item\n"); + }); + + it("leaves Markdown literal by default (opt-in only)", () => { + const r = run([], "# not a heading by default"); + expect(r.stdout).toBe("# not a heading by default\n"); + }); + + it("lets --no-markdown override the --email preset", () => { + const r = run(["--email", "--no-markdown"], "# Hi"); + expect(r.stdout).toBe("# Hi\n"); + }); + + it("keeps fenced code verbatim (protected from flattening) under --email", () => { + const r = run(["--email"], "```py\ncall(**kwargs) # see [docs](url)\n```"); + expect(r.stdout).toBe("call(**kwargs) # see [docs](url)\n"); + }); + + it("--agent strips ANSI noise but keeps Markdown markup", () => { + const r = run(["--agent"], "**bold** \x1b[31mred\x1b[0m"); + expect(r.stdout).toBe("**bold** red\n"); + }); + + it("--ps tidies a PowerShell error block", () => { + const r = run(["--ps"], "At line:1 char:1\n+ Get-Item foo\n+ ~~~~~~~~"); + expect(r.stdout).toBe("At line:1 char:1\nGet-Item foo\n"); + }); + + it("--html strips tags and decodes entities", () => { + const r = run(["--html"], "

a & b

"); + expect(r.stdout).toBe("a & b\n"); + }); + it("exits 2 on an unknown flag", () => { const r = run(["--definitely-not-a-flag"]); expect(r.status).toBe(2); diff --git a/tests/integration/hook.test.ts b/tests/integration/hook.test.ts new file mode 100644 index 0000000..0fdd77b --- /dev/null +++ b/tests/integration/hook.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const HOOK = resolve(here, "../../hooks/socb-clean.mjs"); + +/** Run the hook with a PreToolUse event, return parsed stdout JSON. */ +function runHook(event: unknown): any { + const r = spawnSync(process.execPath, [HOOK], { + input: JSON.stringify(event), + encoding: "utf8", + }); + expect(r.status).toBe(0); + return JSON.parse(r.stdout); +} + +const bash = (command: string) => ({ tool_name: "Bash", tool_input: { command } }); + +describe("socb-clean PreToolUse hook", () => { + it("wraps a noisy command through 'socb --agent', preserving it", () => { + const out = runHook(bash("npm test")); + const cmd = out.hookSpecificOutput.updatedInput.command; + expect(out.hookSpecificOutput.hookEventName).toBe("PreToolUse"); + expect(cmd).toContain("npm test"); + expect(cmd).toContain("socb --agent"); + expect(cmd).toContain("exit $__socb_rc"); // original exit code preserved + }); + + it("looks past leading env assignments to find the tool", () => { + const out = runHook(bash("CI=1 NODE_ENV=test pytest -q")); + expect(out.hookSpecificOutput.updatedInput.command).toContain("pytest -q"); + }); + + it("leaves a non-noisy command untouched", () => { + expect(runHook(bash("ls -la"))).toEqual({}); + expect(runHook(bash("cat file.txt"))).toEqual({}); + }); + + it("does not double-wrap a command that already uses socb", () => { + expect(runHook(bash("npm test | socb"))).toEqual({}); + }); + + it("skips multi-line scripts and heredocs", () => { + expect(runHook(bash("npm test\nnpm run build"))).toEqual({}); + expect(runHook(bash("cat < { + expect(runHook({ tool_name: "Read", tool_input: { file_path: "x" } })).toEqual({}); + }); +}); diff --git a/tests/integration/mcp.test.ts b/tests/integration/mcp.test.ts new file mode 100644 index 0000000..9ee3859 --- /dev/null +++ b/tests/integration/mcp.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { existsSync } from "node:fs"; + +// @modelcontextprotocol/sdk is an optional dependency; skip gracefully if absent. +let available = true; +let Client: any; +let StdioClientTransport: any; +try { + ({ Client } = await import("@modelcontextprotocol/sdk/client/index.js")); + ({ StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js")); +} catch { + available = false; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const MCP = resolve(here, "../../dist/mcp.js"); + +async function withClient(fn: (client: any) => Promise): Promise { + const transport = new StdioClientTransport({ command: process.execPath, args: [MCP] }); + const client = new Client({ name: "socb-test", version: "0.0.0" }, { capabilities: {} }); + await client.connect(transport); + try { + await fn(client); + } finally { + await client.close(); + } +} + +const textOf = (res: any): string => + (res.content as Array<{ text?: string }>).map((c) => c.text ?? "").join(""); + +describe.skipIf(!available)("mcp server (spawns dist/mcp.js)", () => { + beforeAll(() => { + if (!existsSync(MCP)) { + throw new Error("dist/mcp.js missing — run `npm run build` (npm test does this)."); + } + }); + + it("advertises the sanitize_text tool", async () => { + await withClient(async (client) => { + const { tools } = await client.listTools(); + expect(tools.map((t: any) => t.name)).toContain("sanitize_text"); + }); + }); + + it("flattens Markdown via the email preset", async () => { + await withClient(async (client) => { + const res = await client.callTool({ + name: "sanitize_text", + arguments: { text: "# Hi\n\n**bold**", preset: "email" }, + }); + expect(textOf(res)).toBe("Hi\n\nbold"); + }); + }); + + it("applies per-field option overrides (redact)", async () => { + await withClient(async (client) => { + const res = await client.callTool({ + name: "sanitize_text", + arguments: { text: "key AKIAIOSFODNN7EXAMPLE end", options: { redact: true } }, + }); + expect(textOf(res)).toContain("[REDACTED:aws-key]"); + }); + }); +}); diff --git a/tests/unit/decode.test.ts b/tests/unit/decode.test.ts new file mode 100644 index 0000000..cc2d310 --- /dev/null +++ b/tests/unit/decode.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { decodeInput } from "../../src/io/decode.js"; + +const rocket = String.fromCodePoint(0x1f680); + +function utf16be(s: string): Buffer { + const le = Buffer.from(s, "utf16le"); + const be = Buffer.alloc(le.length); + for (let i = 0; i + 1 < le.length; i += 2) { + be[i] = le[i + 1] as number; + be[i + 1] = le[i] as number; + } + return be; +} + +describe("decodeInput", () => { + it("decodes UTF-8 (the default) faithfully", () => { + expect(decodeInput(Buffer.from("café " + rocket, "utf8"))).toBe("café " + rocket); + }); + + it("strips a UTF-8 BOM", () => { + const buf = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from("hi", "utf8")]); + expect(decodeInput(buf)).toBe("hi"); + }); + + it("decodes UTF-16 LE with a BOM", () => { + const buf = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from("héllo", "utf16le")]); + expect(decodeInput(buf)).toBe("héllo"); + }); + + it("decodes UTF-16 BE with a BOM", () => { + const buf = Buffer.concat([Buffer.from([0xfe, 0xff]), utf16be("héllo")]); + expect(decodeInput(buf)).toBe("héllo"); + }); + + it("handles empty input", () => { + expect(decodeInput(Buffer.alloc(0))).toBe(""); + }); +}); diff --git a/tests/unit/fences.test.ts b/tests/unit/fences.test.ts new file mode 100644 index 0000000..51b60e5 --- /dev/null +++ b/tests/unit/fences.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { stripFences } from "../../src/transforms/fences.js"; + +describe("stripFences", () => { + it("removes opening and closing backtick fences, keeps the code", () => { + const input = "```bash\nnpm install\n```"; + expect(stripFences(input)).toBe("npm install"); + }); + + it("removes tilde fences (CommonMark ~~~)", () => { + expect(stripFences("~~~\ncode\n~~~")).toBe("code"); + }); + + it("removes indented fences (up to 3 spaces) with an info string", () => { + expect(stripFences(" ```js\nx\n ```")).toBe("x"); + }); + + it("removes PowerShell tilde underlines with the + gutter", () => { + const input = "At line:1 char:1\n+ Get-Foo\n+ ~~~~~~~"; + expect(stripFences(input)).toBe("At line:1 char:1\n+ Get-Foo"); + }); + + it("removes indented PowerShell underlines", () => { + expect(stripFences(" + ~~~~~~~~")).toBe(""); + }); + + it("removes a bare run of tildes", () => { + expect(stripFences("token\n~~~~~~")).toBe("token"); + }); + + it("keeps a lone literal tilde line", () => { + expect(stripFences("~")).toBe("~"); + }); + + it("keeps inline backticks and tildes in prose", () => { + expect(stripFences("use `npm` in ~/projects")).toBe("use `npm` in ~/projects"); + }); + + it("leaves fence-free text untouched (fast path)", () => { + expect(stripFences("hello world")).toBe("hello world"); + }); +}); diff --git a/tests/unit/html.test.ts b/tests/unit/html.test.ts new file mode 100644 index 0000000..5e92986 --- /dev/null +++ b/tests/unit/html.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { htmlToText } from "../../src/transforms/html.js"; + +describe("htmlToText", () => { + it("strips inline tags, keeping text", () => { + expect(htmlToText("bold and italic text")).toBe("bold and italic text"); + }); + + it("decodes named and numeric entities", () => { + expect(htmlToText("a & b <c>")).toBe("a & b "); + expect(htmlToText("'A")).toBe("'A"); + expect(htmlToText("x y")).toBe("x y"); + }); + + it("turns
into a newline", () => { + expect(htmlToText("line
break")).toBe("line\nbreak"); + }); + + it("breaks block-level elements onto their own lines", () => { + expect(htmlToText("

one

two

").trim()).toBe("one\n\ntwo".trim()); + }); + + it("drops script and style bodies", () => { + expect(htmlToText("keep")).toBe("keep"); + expect(htmlToText("text")).toBe("text"); + }); + + it("decodes entities LAST so '<' is not re-read as a tag", () => { + expect(htmlToText("<b>not a tag</b>")).toBe("not a tag"); + }); + + it("leaves comparison operators alone (no letter after '<')", () => { + expect(htmlToText("2 < 3 and a > b")).toBe("2 < 3 and a > b"); + }); + + it("returns markup-free text unchanged (fast path)", () => { + expect(htmlToText("no markup here")).toBe("no markup here"); + }); +}); diff --git a/tests/unit/markdown.test.ts b/tests/unit/markdown.test.ts new file mode 100644 index 0000000..48b58f3 --- /dev/null +++ b/tests/unit/markdown.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { flattenMarkdown } from "../../src/transforms/markdown.js"; + +describe("flattenMarkdown - block structure", () => { + it("strips ATX headings", () => { + expect(flattenMarkdown("# Title")).toBe("Title"); + expect(flattenMarkdown("### H3 ###")).toBe("H3"); + }); + + it("handles setext === headings (not ---)", () => { + expect(flattenMarkdown("Title\n====")).toBe("Title"); + }); + + it("strips blockquote markers, including nesting", () => { + expect(flattenMarkdown("> quote")).toBe("quote"); + expect(flattenMarkdown(">> deep")).toBe("deep"); + }); + + it("turns horizontal rules into a blank line", () => { + expect(flattenMarkdown("---")).toBe(""); + expect(flattenMarkdown("***")).toBe(""); + expect(flattenMarkdown("___")).toBe(""); + }); + + it("normalizes bullet markers to '-'", () => { + expect(flattenMarkdown("* a\n+ b\n- c")).toBe("- a\n- b\n- c"); + }); + + it("normalizes ordered list punctuation to '.'", () => { + expect(flattenMarkdown("1. a\n2) b")).toBe("1. a\n2. b"); + }); + + it("keeps task list checkboxes", () => { + expect(flattenMarkdown("- [ ] todo\n- [x] done")).toBe("- [ ] todo\n- [x] done"); + }); + + it("preserves nested-list indentation", () => { + expect(flattenMarkdown("- a\n - b")).toBe("- a\n - b"); + }); +}); + +describe("flattenMarkdown - inline", () => { + it("strips bold, italic, and strikethrough", () => { + expect(flattenMarkdown("**b** and *i* and ~~s~~")).toBe("b and i and s"); + expect(flattenMarkdown("__b__")).toBe("b"); + }); + + it("keeps inline code content verbatim (no backticks)", () => { + expect(flattenMarkdown("a `b*c` d")).toBe("a b*c d"); + expect(flattenMarkdown("use `npm test` now")).toBe("use npm test now"); + }); + + it("rewrites links to 'text (url)' and collapses when equal", () => { + expect(flattenMarkdown("[Anthropic](https://x.com)")).toBe("Anthropic (https://x.com)"); + expect(flattenMarkdown("[https://x.com](https://x.com)")).toBe("https://x.com"); + }); + + it("unwraps autolinks and images", () => { + expect(flattenMarkdown("")).toBe("https://x.com"); + expect(flattenMarkdown("![alt](u)")).toBe("alt (u)"); + }); + + it("unescapes backslash-escaped punctuation without re-interpreting it", () => { + expect(flattenMarkdown("\\*not bold\\*")).toBe("*not bold*"); + }); +}); + +describe("flattenMarkdown - fenced code", () => { + it("removes fences and keeps the body verbatim", () => { + expect(flattenMarkdown("```bash\nls *.py\n```")).toBe("ls *.py"); + }); + + it("keeps emphasis-looking characters inside code intact", () => { + expect(flattenMarkdown("```\nconst a_b = x->y;\n```")).toBe("const a_b = x->y;"); + }); + + it("does not let a stray unclosed tilde fence swallow the document", () => { + // A bare tilde underline must not be read as a fence opener. + expect(flattenMarkdown("text\n~~~~~~\nmore")).toContain("more"); + }); +}); + +describe("flattenMarkdown - false-positive guards", () => { + it("leaves identifiers with underscores intact", () => { + expect(flattenMarkdown("my_var_name")).toBe("my_var_name"); + }); + + it("leaves globs and multiplication intact", () => { + expect(flattenMarkdown("ls *.py")).toBe("ls *.py"); + expect(flattenMarkdown("area = 2 * 3")).toBe("area = 2 * 3"); + }); + + it("does not treat code-span content as markup", () => { + expect(flattenMarkdown("`**x**`")).toBe("**x**"); + expect(flattenMarkdown("`a_b_c`")).toBe("a_b_c"); + }); + + it("does not treat '#' without a space as a heading", () => { + expect(flattenMarkdown("#nospace")).toBe("#nospace"); + expect(flattenMarkdown("#!/bin/sh")).toBe("#!/bin/sh"); + }); + + it("does not eat a Markdown pipe-table delimiter as a rule", () => { + expect(flattenMarkdown("|---|---|")).toBe("|---|---|"); + }); + + it("returns plain prose unchanged (fast path)", () => { + expect(flattenMarkdown("just a normal sentence.")).toBe("just a normal sentence."); + }); +}); + +describe("flattenMarkdown - idempotence", () => { + const samples = [ + "# Title", + "**bold** and *italic*", + "- a\n- b", + "[x](https://y.com)", + "a `code` span", + "```\ncode -> here\n```", + "> quote", + ]; + for (const s of samples) { + it(`is idempotent for ${JSON.stringify(s)}`, () => { + const once = flattenMarkdown(s); + expect(flattenMarkdown(once)).toBe(once); + }); + } +}); diff --git a/tests/unit/powershell.test.ts b/tests/unit/powershell.test.ts new file mode 100644 index 0000000..686b08e --- /dev/null +++ b/tests/unit/powershell.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { cleanPowerShell } from "../../src/transforms/powershell.js"; + +describe("cleanPowerShell", () => { + it("removes the ~ underline and the '+ ' gutter, keeping the text", () => { + const input = + "Get-Item : Cannot find path.\nAt line:1 char:1\n+ Get-Item foo\n+ ~~~~~~~~"; + expect(cleanPowerShell(input)).toBe( + "Get-Item : Cannot find path.\nAt line:1 char:1\nGet-Item foo", + ); + }); + + it("keeps the 'At line:N char:M' location", () => { + expect(cleanPowerShell("At line:5 char:3\n+ ~~~")).toBe("At line:5 char:3"); + }); + + it("de-gutters indented CategoryInfo trailers", () => { + const input = "At line:1 char:1\n + CategoryInfo : ObjectNotFound"; + expect(cleanPowerShell(input)).toBe("At line:1 char:1\nCategoryInfo : ObjectNotFound"); + }); + + it("leaves a unified diff untouched (no PowerShell markers)", () => { + const diff = "+ added line\n- removed line\n+ another"; + expect(cleanPowerShell(diff)).toBe(diff); + }); + + it("returns marker-free text unchanged (fast path)", () => { + expect(cleanPowerShell("ordinary output\nsecond line")).toBe("ordinary output\nsecond line"); + }); +}); diff --git a/tests/unit/prompts.test.ts b/tests/unit/prompts.test.ts new file mode 100644 index 0000000..ba0390b --- /dev/null +++ b/tests/unit/prompts.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { stripPrompts } from "../../src/transforms/prompts.js"; + +describe("stripPrompts", () => { + it("strips a POSIX '$ ' prompt", () => { + expect(stripPrompts("$ npm test")).toBe("npm test"); + }); + + it("strips a PowerShell 'PS path>' prompt", () => { + expect(stripPrompts("PS C:\\Users\\me> dir")).toBe("dir"); + }); + + it("strips a Python REPL '>>>' prompt", () => { + expect(stripPrompts(">>> print(1)")).toBe("print(1)"); + }); + + it("strips a user@host:path$ prompt", () => { + expect(stripPrompts("me@host:~/proj$ ls -la")).toBe("ls -la"); + }); + + it("does not strip a bare '$' with no following space (variables)", () => { + expect(stripPrompts("$var = 5")).toBe("$var = 5"); + }); + + it("does not strip a bare '>' (blockquote / redirection)", () => { + expect(stripPrompts("> quoted")).toBe("> quoted"); + }); + + it("handles multiple lines, only touching the prompt", () => { + expect(stripPrompts("$ echo hi\nhi")).toBe("echo hi\nhi"); + }); + + it("returns prompt-free text unchanged (fast path)", () => { + expect(stripPrompts("plain line\nanother")).toBe("plain line\nanother"); + }); +}); diff --git a/tests/unit/reflow.test.ts b/tests/unit/reflow.test.ts new file mode 100644 index 0000000..69ea90a --- /dev/null +++ b/tests/unit/reflow.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { reflowParagraphs } from "../../src/transforms/reflow.js"; + +const long = "This is a sufficiently long line of ordinary prose that wraps over"; // > 60 chars + +describe("reflowParagraphs", () => { + it("joins a long, unpunctuated wrap line with its continuation", () => { + expect(reflowParagraphs(long + "\nhere onto the next line.")).toBe( + long + " here onto the next line.", + ); + }); + + it("does not join a short standalone line (e.g. a heading)", () => { + expect(reflowParagraphs("Title\nbody text follows")).toBe("Title\nbody text follows"); + }); + + it("does not join across a blank line (paragraph break)", () => { + expect(reflowParagraphs(long + "\n\nnext paragraph")).toBe(long + "\n\nnext paragraph"); + }); + + it("does not join when the previous line ends a sentence", () => { + const sentence = long + "."; + expect(reflowParagraphs(sentence + "\nnext")).toBe(sentence + "\nnext"); + }); + + it("does not join into a following list item", () => { + expect(reflowParagraphs(long + "\n- a list item")).toBe(long + "\n- a list item"); + }); + + it("does not join an indented (code/quote) continuation", () => { + expect(reflowParagraphs(long + "\n indented")).toBe(long + "\n indented"); + }); + + it("returns single-line input unchanged", () => { + expect(reflowParagraphs("one line only")).toBe("one line only"); + }); +}); diff --git a/tests/unit/tables.test.ts b/tests/unit/tables.test.ts index 8ef8eed..a584eab 100644 --- a/tests/unit/tables.test.ts +++ b/tests/unit/tables.test.ts @@ -39,6 +39,10 @@ describe("table detection helpers", () => { expect(isBorderRow("|------|-----|")).toBe(true); expect(isBorderRow("| data | row |")).toBe(false); }); + it("recognizes GitHub alignment delimiter rows (colons)", () => { + expect(isBorderRow("| :--- | ---: |")).toBe(true); + expect(isBorderRow("| :--: |")).toBe(true); + }); it("detects a contiguous table block", () => { expect(detectTableBlocks(boxTable.split("\n"))).toEqual([ { start: 0, end: 4 }, @@ -53,6 +57,11 @@ describe("transformTables", () => { it("reconstructs a markdown/ASCII pipe table", () => { expect(transformTables(mdTable, "reconstruct")).toBe("Name Age\nBob 30"); }); + it("reconstructs a GitHub-aligned pipe table (colon delimiters)", () => { + expect(transformTables("| A | B |\n| :-- | --: |\n| 1 | 2 |", "reconstruct")).toBe( + "A B\n1 2", + ); + }); it("converts box glyphs to ASCII borders", () => { expect(transformTables(TL + H(4) + TR, "ascii")).toBe("+----+"); }); diff --git a/tsup.config.ts b/tsup.config.ts index 5b6414c..d727430 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -30,4 +30,19 @@ export default defineConfig([ dts: true, external: ["@xterm/headless"], }, + { + // MCP server bin. @modelcontextprotocol/sdk is an optional dep, kept external + // so it is loaded from node_modules at runtime (only when socb-mcp is used). + entry: { mcp: "src/mcp.ts" }, + format: ["esm"], + target: "node20", + platform: "node", + bundle: true, + clean: false, + splitting: false, + sourcemap: false, + dts: false, + external: ["@xterm/headless", "@modelcontextprotocol/sdk"], + banner: { js: "#!/usr/bin/env node" }, + }, ]);