diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..9a42434 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,51 @@ +{ + "name": "skillmanager", + "version": "0.4.1", + "description": "Inspect, organize, and safely converge local Agent Skills from Codex.", + "author": { + "name": "Backtthefuture", + "url": "https://github.com/Backtthefuture" + }, + "homepage": "https://backtthefuture.github.io/skillmanager-plugin/", + "repository": "https://github.com/Backtthefuture/skillmanager-plugin", + "license": "MIT", + "keywords": [ + "skills", + "codex", + "agents", + "diagnostics", + "local-first", + "skill-manager", + "symlinks", + "rollback" + ], + "skills": "./skills/", + "interface": { + "displayName": "SkillManager", + "shortDescription": "Organize local Agent Skills safely", + "longDescription": "Use SkillManager to inventory local Agent Skills, distinguish installed Agents from compatible targets, find duplicate or weak trigger configurations, choose one canonical Skill version, preview shared-link convergence, and apply or roll back recoverable edits from a session-protected local Dashboard.", + "developerName": "Backtthefuture", + "category": "Productivity", + "capabilities": [ + "Interactive", + "Read", + "Write" + ], + "websiteURL": "https://backtthefuture.github.io/skillmanager-plugin/", + "privacyPolicyURL": "https://backtthefuture.github.io/skillmanager-plugin/privacy.html", + "termsOfServiceURL": "https://backtthefuture.github.io/skillmanager-plugin/terms.html", + "brandColor": "#FACC15", + "composerIcon": "./assets/icon.png", + "logo": "./assets/logo.png", + "logoDark": "./assets/logo-dark.png", + "screenshots": [ + "./assets/screenshots/dashboard-convergence.png" + ], + "defaultPrompt": [ + "\u6253\u5f00 SkillManager\uff0c\u68c0\u67e5\u5f53\u524d\u9879\u76ee\u7684 Skill \u914d\u7f6e", + "\u68c0\u67e5\u6211\u7684 Skills \u662f\u5426\u91cd\u590d\u3001\u51b2\u7a81\u6216\u9002\u5408\u5408\u5e76", + "\u8bca\u65ad\u8fd9\u4e2a Skill \u4e3a\u4ec0\u4e48\u6ca1\u6709\u89e6\u53d1" + ] + }, + "mcpServers": "./.mcp.json" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aca4228 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: ci + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + verify: + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest, windows-latest] + node: [20, 22] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm run test + - run: npm run test:integration + - run: npm run build + - run: npm run pack:verify + - run: npm run plugin:pack:verify + - run: npm run audit:release diff --git a/.gitignore b/.gitignore index d5706ae..adb122b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ node_modules/ dist/ +release/*.tgz +release/codex-marketplace/ +coverage/ .DS_Store *.log .env diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..e6e3d9d --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "skillmanager": { + "command": "./bin/skillmanager-mcp.js", + "cwd": "." + } + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..db8700f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Backtthefuture + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..6ce91ba --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,11 @@ +# SkillManager Privacy + +SkillManager is local-first. Its Dashboard binds to `127.0.0.1`, and its MCP tools inspect Skill metadata on the user's machine. + +- Skill bodies and supporting-file contents are not returned by default through MCP. +- Absolute home paths, local control credentials, session cookies, and GitHub tokens are excluded from MCP and diagnostic output. +- A GitHub token entered in the local Dashboard is stored in the operating system credential store; the local config keeps only a credential reference. +- Git synchronization is optional and runs only after an explicit browser preview and confirmation. +- Skill edits, replacements, and deletions use server-bound plans with stale-state checks and recovery snapshots or trash entries. + +SkillManager does not operate a hosted data-collection service. Network access occurs only for features the user invokes, such as optional GitHub synchronization. diff --git a/README.md b/README.md index 9dcdbff..9358ddc 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,117 @@ -# Skill Hub +# SkillManager -> 黄叔的 Claude Skill 可视化管理器 — 一键扫描并管理你机器上所有 Claude Agent Skills。 +> 本地优先的 Agent Skill 管理与诊断工作台。 -## 它解决了什么问题 +SkillManager 扫描本机和当前项目中的 Agent Skills,集中展示安装位置、作用域、冲突、相似项、健康诊断、版本与回收站。Web 服务只监听 `127.0.0.1`;Skill 内容默认留在本机。 -当你装了几十个 Skill 后,你会遇到: +`0.4.0` 已统一产品名和主命令为 SkillManager,并提供可独立运行的 Codex Public Git Marketplace。不要继续使用历史 `huangshu/tools/skill-hub` tarball。 -- 同名 Skill 散落在不同目录(`~/.claude/skills/`、插件、各个项目的 `.claude/skills/`) -- 想改个 Skill 得手动翻目录 -- 不知道哪些是全局、哪些是项目私有、哪些来自插件、哪些重复了 -- 改坏了想回滚,没有版本历史 +## 安装 Codex Plugin 发布版 -Skill Hub 是一个本地 Web UI:扫描全盘、聚合展示、可视化编辑、自动版本快照。 - -## 快速开始(一行命令) +公开发布仓库安装: ```bash -npm install -g https://github.com/Backtthefuture/huangshu/raw/main/tools/skill-hub/release/claude-skill-hub.tgz && skill-hub +codex plugin marketplace add Backtthefuture/skillmanager-plugin +codex plugin add skillmanager@backtthefuture ``` -首次运行会自动: -1. 下载预构建的 tarball 并全局安装 -2. 安装运行期依赖 -3. 启动服务并打开浏览器到 `http://localhost:3456` +重新新建一个 Codex 对话,输入“打开 SkillManager”。插件会先查询运行状态,再启动或复用只监听 `127.0.0.1` 的本地服务,并返回 60 秒内有效、只能使用一次的可点击安全入口。 -**之后每次启动只要敲 `skill-hub` 就行**,不用再打这串长命令。 +同一个发布物包含 `@napi-rs/keyring` 锁定的 12 个官方原生目标,并在运行时按操作系统、CPU 和 Linux libc 自动选择。源码 CI 在 macOS、Ubuntu、Windows 的 Node.js 20/22 上分别执行隔离运行验收;FreeBSD、Linux ARM/RISC-V 和 musl 目标只完成锁定制品与哈希静态校验,不表述为真实运行通过。 -要求:Node.js ≥ 20。 +开发者也可以在执行 `npm run plugin:pack:verify` 后,从本地 `release/codex-marketplace/` 添加 Marketplace。 -> **为什么用 tarball URL 而不是 `github:user/repo`**:npm 11 + node 24 在 macOS 上通过 `npm install -g github:...` 全局安装时,会把包软链到 `~/.npm/_cacache/tmp/` 里的临时克隆目录,随后临时目录被清理、留下悬空符号链接导致 `skill-hub` 无法运行。直接装预构建 tarball 走的是真正的文件拷贝路径,完全绕开这个 bug。 -> -> **更新到最新版**:再跑一次同样的命令即可。 -> -> **卸载**:`npm uninstall -g claude-skill-hub`。 +## 从源码运行 -## 扫描覆盖的位置 +要求:Node.js 20 或 22、Git。 -- `~/.claude/skills/` — 全局 skill -- `~/.claude/plugins/**/skills/` — Claude Code 插件附带的 skill(递归扫描) -- `~/.claude/projects/*` 里注册过的项目 `.claude/skills/` -- 常见开发目录:`~/Documents`、`~/Projects`、`~/Developer`、`~/Code`、`~/code`、`~/workspace`、`~/dev`、`~/work`、`~/repos`、`~/src` 下一层的项目 -- 当前工作目录及其向上 3 级目录的 `.claude/skills/` -- 环境变量 `SKILL_HUB_EXTRA_PATHS=/path/a:/path/b` 指定的额外路径 +```bash +git clone https://github.com/Backtthefuture/skillmanager.git +cd skillmanager +npm ci +npm run build +npm install -g . +skillmanager +``` -## 排查问题 +兼容命令 `skill-hub` 和 `claude-skill-hub` 仍会保留至少一个大版本。 -如果发现扫到的 skill 数量不对、或者页面打开是白屏,访问: +卸载: +```bash +npm uninstall -g skillmanager ``` -http://localhost:3456/api/debug + +## 当前能力 + +- 扫描多种 Agent 的全局、项目与已启用插件 Skill。 +- 搜索、筛选、分类、同名冲突与语义相似检测。 +- 本地编辑、自动快照、版本对比与回滚。 +- 回收站删除与恢复。 +- 可选的 GitHub 私有仓库备份和恢复。 +- React Web UI、Fastify 本地服务与 WebSocket 实时刷新。 + +Codex 用户应优先把个人共享 Skill 放在 `~/.agents/skills/`,项目共享 Skill 放在 `/.agents/skills/`。`~/.codex/skills/` 只作为兼容/历史位置展示。 + +## 从 Codex 打开 + +在 Codex 新对话中输入“打开当前项目的 SkillManager”,或使用: + +```bash +skillmanager open --project <项目目录> ``` -返回 JSON 包含:node 版本、cwd、homedir、所有被扫的路径及每个路径的命中数、耗时、错误。报 bug 时发这份 JSON 即可快速定位。 +不要直接打开 `web/index.html`、`dist/web/index.html` 或 Codex 开发记录中自动生成的“网站预览”。它们不会启动本地服务,也没有一次性会话。正确入口会返回实际端口的 `/launch/` 链接,设置本地会话后再进入 Dashboard。 ## 本地开发 ```bash -git clone https://github.com/Backtthefuture/huangshu.git -cd huangshu/tools/skill-hub - -npm install -npm run dev # 开发模式:前端 5173 + 后端 3456 +npm ci +npm run dev ``` -生产模式: +发布前门禁: ```bash +npm run lint +npm run typecheck +npm run test +npm run test:integration npm run build -npm start +npm run pack:verify +npm run plugin:pack:verify +npm run audit:release ``` -## 可选环境变量 +`pack:verify` 会连续构建两次,生成带 commit/source hash 的 manifest,再把 tarball 安装到全新的临时目录并启动包内服务。测试只允许使用 `SKILLMANAGER_FIXTURE_ROOT` 与 `SKILLMANAGER_DATA_DIR`,不得读写真实用户 Skill。 + +`plugin:pack:verify` 会生成 `release/codex-marketplace/`,下载并校验 lockfile 中的 12 个官方钥匙串二进制,再验证 Marketplace/Plugin manifest、MCP 初始化与工具清单、状态查询、服务启动/复用、可点击启动入口、单次跳转、健康检查、当前平台原生绑定和停止流程。同时审计软链、开发依赖、绝对源码路径与常见凭据模式。 + +## 开发环境变量 + +- `PORT`:当前前台服务的起始端口,默认 `3456`。 +- `SKILLMANAGER_NO_OPEN=1`:启动时不自动打开系统浏览器。 +- `SKILLMANAGER_EXTRA_PATHS`:额外扫描目录;兼容旧名 `SKILL_HUB_EXTRA_PATHS`。 +- `SKILLMANAGER_FIXTURE_ROOT`:测试专用的虚拟用户目录,必须是绝对路径。 +- `SKILLMANAGER_DATA_DIR`:显式数据目录,必须是绝对路径。 +- `SKILLMANAGER_PROJECT_ROOT`:显式当前项目目录,必须是绝对路径。 + +不要把服务暴露到 `0.0.0.0`,也不要提交 GitHub token、Skill 私有内容或本地数据目录。 + +## 构建与发布物 -- `PORT` — 自定义起始端口(默认 3456;占用时自动向上尝试到 3460) -- `SKILL_HUB_NO_OPEN=1` — 启动时不自动打开浏览器 -- `SKILL_HUB_EXTRA_PATHS` — 额外的扫描路径,冒号或逗号分隔 +`dist/`、`release/*.tgz` 和 `release/codex-marketplace/` 都是本地生成物,不提交到源码仓库。`npm pack` 会先执行严格构建,并在 `dist/build-manifest.json` 写入包版本、Git commit、源码树哈希与每个产物哈希,避免源码、构建物和安装包漂移。Codex Plugin 构建还会写入通用目标矩阵、各原生依赖的 registry integrity 与 SHA-256、安全约束和全目录内容哈希;生成目录会单独发布到 `Backtthefuture/skillmanager-plugin`。 -## 目录结构 +## 目录 -- `server/` — Fastify 后端(API + WebSocket + 文件监听 + 扫描器) -- `web/` — React + Vite + Tailwind 前端(含 ErrorBoundary) -- `bin/` — CLI 入口与首次安装构建脚本 +- `server/`:扫描、诊断、版本、回收站、同步与本地 API。 +- `web/`:React + Vite + Tailwind 工作台。 +- `bin/`:CLI 入口。 +- `scripts/`:测试、构建 manifest、依赖审计和打包验证。 +- `tests/`:单元与隔离文件系统集成测试。 +- `branding/`:六版 Logo 方案、定稿源文件与展示页。 +- `public-marketplace/`:公开安装页、隐私、条款、支持与安全说明源文件。 -## 许可 +## License -MIT +[MIT](LICENSE) diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..dbdc226 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/logo-dark.png b/assets/logo-dark.png new file mode 100644 index 0000000..0773668 Binary files /dev/null and b/assets/logo-dark.png differ diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..38953d1 Binary files /dev/null and b/assets/logo.png differ diff --git a/assets/screenshots/dashboard-convergence.png b/assets/screenshots/dashboard-convergence.png new file mode 100644 index 0000000..354e837 Binary files /dev/null and b/assets/screenshots/dashboard-convergence.png differ diff --git a/bin/cli.js b/bin/cli.js index f085578..4d44665 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -1,29 +1,682 @@ #!/usr/bin/env node -import { spawn } from 'child_process' -import { fileURLToPath } from 'url' +import { spawn, spawnSync } from 'child_process' +import fsSync from 'fs' +import fs from 'fs/promises' +import { fileURLToPath, pathToFileURL } from 'url' import path from 'path' -import fs from 'fs' +import os from 'os' +import crypto from 'crypto' +import net from 'net' -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const pkgRoot = path.resolve(__dirname, '..') -const serverEntry = path.join(pkgRoot, 'dist', 'server', 'index.js') +const cliFile = fileURLToPath(import.meta.url) +const binDirectory = path.dirname(cliFile) +const packageRoot = path.resolve(binDirectory, '..') +const packagedServerEntry = path.join(packageRoot, 'dist', 'server', 'index.js') +const serverEntry = process.env.SKILLMANAGER_SERVER_ENTRY + ? path.resolve(process.env.SKILLMANAGER_SERVER_ENTRY) + : packagedServerEntry +const serverNodeArgs = process.env.SKILLMANAGER_SERVER_NODE_ARGS + ? JSON.parse(process.env.SKILLMANAGER_SERVER_NODE_ARGS) + : [] +if (!Array.isArray(serverNodeArgs) || serverNodeArgs.some((value) => typeof value !== 'string')) { + throw new Error('SKILLMANAGER_SERVER_NODE_ARGS must be a JSON string array') +} +const packageJson = JSON.parse(fsSync.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')) +const version = packageJson.version +const capabilities = ['scan', 'diagnose', 'versions', 'trash', 'sync'] + +function optionalAbsolutePath(name) { + const value = process.env[name]?.trim() + if (!value) return null + if (!path.isAbsolute(value)) throw new CliError('INVALID_ENV_PATH', `${name} must be absolute`) + return path.resolve(value) +} + +const fixtureRoot = optionalAbsolutePath('SKILLMANAGER_FIXTURE_ROOT') +const userHome = fixtureRoot || os.homedir() + +function defaultDataDirectory() { + if (fixtureRoot) return path.join(fixtureRoot, 'data') + if (process.platform === 'darwin') { + return path.join(userHome, 'Library', 'Application Support', 'SkillManager') + } + if (process.platform === 'win32') { + const appData = process.env.APPDATA?.trim() + return path.join(appData || path.join(userHome, 'AppData', 'Roaming'), 'SkillManager') + } + const xdgDataHome = process.env.XDG_DATA_HOME?.trim() + return path.join(xdgDataHome || path.join(userHome, '.local', 'share'), 'skillmanager') +} + +const dataDirectory = optionalAbsolutePath('SKILLMANAGER_DATA_DIR') || defaultDataDirectory() +const runtimeDirectory = path.join(dataDirectory, 'runtime') +const statePath = path.join(runtimeDirectory, 'state.json') +const lockPath = path.join(runtimeDirectory, 'start.lock') +const logPath = path.join(runtimeDirectory, 'server.log') + +class CliError extends Error { + constructor(code, message, details = undefined) { + super(message) + this.name = 'CliError' + this.code = code + this.details = details + } +} + +function parseArguments(argv) { + const commands = new Set(['start', 'status', 'open', 'stop', 'doctor', 'serve', 'version', 'help']) + let command = argv[0] + let index = 1 + if (!command) { + command = 'open' + index = 0 + } else if (command === '--help' || command === '-h') { + command = 'help' + } else if (command === '--version' || command === '-v') { + command = 'version' + } else if (!commands.has(command)) { + throw new CliError('UNKNOWN_COMMAND', `Unknown command: ${command}`) + } + + const configuredPort = Number(process.env.SKILLMANAGER_PORT || 3456) + const options = { + json: false, + noOpen: false, + project: null, + target: null, + port: Number.isInteger(configuredPort) && configuredPort > 0 && configuredPort <= 65535 + ? configuredPort + : 3456, + foreground: false, + } + for (; index < argv.length; index++) { + const arg = argv[index] + if (arg === '--json') options.json = true + else if (arg === '--no-open') options.noOpen = true + else if (arg === '--foreground') options.foreground = true + else if (arg === '--project') { + const value = argv[++index] + if (!value) throw new CliError('MISSING_PROJECT', '--project requires a path') + options.project = value + } else if (arg === '--target') { + const value = argv[++index] + if ( + !value || + !value.startsWith('/') || + value.startsWith('//') || + /[\r\n\0\\]/.test(value) || + value.length > 2048 + ) { + throw new CliError('INVALID_TARGET', '--target must be a local relative dashboard path') + } + options.target = value + } else if (arg === '--port') { + const value = Number(argv[++index]) + if (!Number.isInteger(value) || value < 1 || value > 65535) { + throw new CliError('INVALID_PORT', '--port must be between 1 and 65535') + } + options.port = value + } else if (arg === '--help' || arg === '-h') { + command = 'help' + } else { + throw new CliError('UNKNOWN_OPTION', `Unknown option: ${arg}`) + } + } + return { command, options } +} + +function helpText() { + return `SkillManager ${version} + +Usage: + skillmanager start [--json] [--project ] [--no-open] + skillmanager status [--json] + skillmanager open [--json] [--project ] + skillmanager stop [--json] + skillmanager doctor [--json] + skillmanager serve [--port ] [--foreground] + skillmanager version [--json] + +With no command, SkillManager behaves like \`skillmanager open\`. +JSON mode never opens an external system browser.` +} + +function outputJson(value) { + process.stdout.write(JSON.stringify(value) + '\n') +} + +function isRuntimeState(value) { + return value?.schemaVersion === 1 && + value.product === 'SkillManager' && + typeof value.version === 'string' && + Number.isInteger(value.pid) && value.pid > 0 && + value.host === '127.0.0.1' && + Number.isInteger(value.port) && value.port > 0 && value.port <= 65535 && + value.baseUrl === `http://127.0.0.1:${value.port}` && + typeof value.startedAt === 'string' && + /^instance_[A-Za-z0-9_-]{12,}$/.test(value.instanceId) && + typeof value.processIdentity === 'string' && + typeof value.controlToken === 'string' && value.controlToken.length >= 32 && + typeof value.managed === 'boolean' && + (value.projectRoot === undefined || value.projectRoot === null || typeof value.projectRoot === 'string') && + Array.isArray(value.capabilities) +} + +async function readState() { + try { + const parsed = JSON.parse(await fs.readFile(statePath, 'utf8')) + return isRuntimeState(parsed) + ? { kind: 'valid', state: parsed } + : { kind: 'invalid', state: null } + } catch (error) { + if (error?.code === 'ENOENT') return { kind: 'missing', state: null } + return { kind: 'invalid', state: null } + } +} + +function processAlive(pid) { + try { + process.kill(pid, 0) + return true + } catch (error) { + return error?.code === 'EPERM' + } +} + +async function fetchJson(url, options = {}, timeoutMs = 1500) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(url, { ...options, signal: controller.signal }) + const body = await response.json().catch(() => null) + return { response, body } + } finally { + clearTimeout(timer) + } +} + +async function inspectRuntime() { + const loaded = await readState() + if (loaded.kind === 'missing') return { kind: 'stopped', reason: 'no_state' } + if (loaded.kind === 'invalid') return { kind: 'blocked', reason: 'invalid_state' } + const state = loaded.state + if (!processAlive(state.pid)) return { kind: 'stopped', reason: 'stale_pid', state } + try { + const { response, body } = await fetchJson(`${state.baseUrl}/api/v1/health`) + if ( + response.ok && + body?.product === 'SkillManager' && + body.instanceId === state.instanceId && + body.processIdentity === state.processIdentity + ) { + return { kind: 'running', state, health: body } + } + return { kind: 'blocked', reason: 'identity_mismatch', state } + } catch { + return { kind: 'blocked', reason: 'unreachable_process', state } + } +} + +function publicRunning(state, reused = false, health = null) { + return { + schemaVersion: 1, + status: 'running', + version: state.version, + pid: state.pid, + host: state.host, + port: state.port, + baseUrl: state.baseUrl, + startedAt: state.startedAt, + processIdentity: state.processIdentity, + managed: state.managed, + projectConfigured: state.projectConfigured, + reused, + capabilities: Array.isArray(state.capabilities) ? state.capabilities : capabilities, + ...(health?.scanner ? { scanner: health.scanner } : {}), + } +} -if (!fs.existsSync(serverEntry)) { - console.error('\x1b[31m[claude-skill-hub] Build output missing.\x1b[0m') - console.error('Expected:', serverEntry) - console.error('Run `npm run build` in the package directory, or reinstall.') - process.exit(1) +function publicStopped(reason = 'not_running') { + return { + schemaVersion: 1, + status: 'stopped', + version, + reason, + capabilities, + } } -const child = spawn(process.execPath, [serverEntry], { - stdio: 'inherit', - env: process.env, - cwd: process.cwd(), -}) +async function removeStateIfOwned(expectedInstanceId) { + const loaded = await readState() + if (loaded.kind !== 'valid' || loaded.state.instanceId !== expectedInstanceId) return false + await fs.unlink(statePath).catch(() => {}) + return true +} + +async function acquireStartLock(timeoutMs = 15_000) { + await fs.mkdir(runtimeDirectory, { recursive: true, mode: 0o700 }) + if (process.platform !== 'win32') await fs.chmod(runtimeDirectory, 0o700) + const deadline = Date.now() + timeoutMs + const lockId = crypto.randomBytes(16).toString('hex') + while (Date.now() < deadline) { + try { + const handle = await fs.open(lockPath, 'wx', 0o600) + await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now(), lockId })) + await handle.close() + return async () => { + try { + const lock = JSON.parse(await fs.readFile(lockPath, 'utf8')) + if (lock.pid === process.pid && lock.lockId === lockId) { + await fs.unlink(lockPath) + } + } catch {} + } + } catch (error) { + if (error?.code !== 'EEXIST') throw error + try { + const lock = JSON.parse(await fs.readFile(lockPath, 'utf8')) + const stale = !Number.isInteger(lock.pid) || + !processAlive(lock.pid) || + Date.now() - Number(lock.createdAt || 0) > 30_000 + if (stale) { + await fs.unlink(lockPath).catch(() => {}) + continue + } + } catch { + // Another starter may have created the lock but not finished writing + // its JSON yet. Only reap an invalid lock after a grace period. + try { + const stat = await fs.stat(lockPath) + if (Date.now() - stat.mtimeMs > 5_000) { + await fs.unlink(lockPath).catch(() => {}) + continue + } + } catch {} + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + } + throw new CliError('START_LOCK_TIMEOUT', 'Timed out waiting for another SkillManager start operation') +} + +async function resolveProject(projectValue) { + if (!projectValue) return null + const candidate = path.resolve(projectValue) + let real + try { + real = await fs.realpath(candidate) + const stat = await fs.stat(real) + if (!stat.isDirectory()) throw new Error('not a directory') + } catch { + throw new CliError('INVALID_PROJECT', 'The supplied project path is not an existing directory') + } + return real +} + +function ensureBuild() { + if (!fsSync.existsSync(serverEntry)) { + throw new CliError('BUILD_MISSING', 'Packaged server files are missing; reinstall SkillManager') + } +} + +async function startService(options) { + ensureBuild() + const project = await resolveProject(options.project) + const releaseLock = await acquireStartLock() + try { + const current = await inspectRuntime() + if (current.kind === 'running') { + if (current.state.version !== version) { + throw new CliError( + 'INCOMPATIBLE_INSTANCE', + `A different SkillManager version is already running (${current.state.version})`, + ) + } + if (project && current.state.projectRoot !== project) { + await stopManagedState(current.state) + } else { + return { state: current.state, reused: true } + } + } + if (current.kind === 'blocked') { + throw new CliError( + 'RUNTIME_STATE_BLOCKED', + `Cannot safely start: ${current.reason}. Run \`skillmanager doctor\` for details.`, + ) + } + if (current.state) await removeStateIfOwned(current.state.instanceId) + + const instanceId = `instance_${crypto.randomBytes(18).toString('base64url')}` + const controlToken = crypto.randomBytes(32).toString('base64url') + await fs.mkdir(runtimeDirectory, { recursive: true, mode: 0o700 }) + const outputFd = fsSync.openSync(logPath, 'a', 0o600) + const errorFd = fsSync.openSync(logPath, 'a', 0o600) + let child + try { + child = spawn(process.execPath, [...serverNodeArgs, serverEntry], { + cwd: project || process.cwd(), + detached: true, + windowsHide: true, + stdio: ['ignore', outputFd, errorFd], + env: { + ...process.env, + PORT: String(options.port || 3456), + SKILLMANAGER_INSTANCE_ID: instanceId, + SKILLMANAGER_CONTROL_TOKEN: controlToken, + SKILLMANAGER_MANAGED: '1', + SKILLMANAGER_NO_OPEN: '1', + SKILL_HUB_NO_OPEN: '1', + SKILLMANAGER_VERSION: version, + ...(project ? { SKILLMANAGER_PROJECT_ROOT: project } : {}), + }, + }) + } finally { + fsSync.closeSync(outputFd) + fsSync.closeSync(errorFd) + } + child.unref() + + const deadline = Date.now() + 20_000 + while (Date.now() < deadline) { + if (!processAlive(child.pid)) { + throw new CliError('SERVER_EXITED', 'SkillManager server exited before becoming healthy') + } + const loaded = await readState() + if (loaded.kind === 'valid' && loaded.state.instanceId === instanceId) { + const inspected = await inspectRuntime() + if (inspected.kind === 'running') return { state: inspected.state, reused: false } + if (inspected.kind === 'blocked' && inspected.reason === 'identity_mismatch') { + throw new CliError('IDENTITY_MISMATCH', 'Started process did not match its runtime identity') + } + } + await new Promise((resolve) => setTimeout(resolve, 150)) + } + throw new CliError('START_TIMEOUT', 'Timed out waiting for SkillManager health check') + } finally { + await releaseLock() + } +} + +async function createLaunch(state, target) { + const { response, body } = await fetchJson(`${state.baseUrl}/api/v1/control/launch`, { + method: 'POST', + headers: { + Authorization: `Bearer ${state.controlToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ target }), + }) + if (!response.ok || !body?.ok || typeof body.launchUrl !== 'string') { + throw new CliError('LAUNCH_FAILED', body?.error || 'Could not create a launch URL') + } + return body +} + +function openSystemBrowser(url) { + let child + if (process.platform === 'darwin') { + child = spawn('open', [url], { detached: true, stdio: 'ignore' }) + } else if (process.platform === 'win32') { + child = spawn('cmd.exe', ['/d', '/s', '/c', 'start', '', url], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }) + } else { + child = spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }) + } + child.on('error', () => {}) + child.unref() +} -child.on('exit', (code) => process.exit(code ?? 0)) +async function canBind(port) { + return new Promise((resolve) => { + const server = net.createServer() + server.once('error', () => resolve(false)) + server.listen(port, '127.0.0.1', () => { + server.close(() => resolve(true)) + }) + }) +} + +async function doctorReport() { + const checks = [] + const nodeMajor = Number(process.versions.node.split('.')[0]) + checks.push({ + id: 'node', + status: nodeMajor >= 20 ? 'pass' : 'fail', + detail: `Node ${process.versions.node}`, + }) + + const git = spawnSync('git', ['--version'], { encoding: 'utf8', windowsHide: true }) + checks.push({ + id: 'git', + status: git.status === 0 ? 'pass' : 'warn', + detail: git.status === 0 ? git.stdout.trim() : 'Git unavailable; sync is disabled', + }) + + const requiredArtifacts = [ + ['server', packagedServerEntry], + ['web', path.join(packageRoot, 'dist', 'web', 'index.html')], + ['manifest', path.join(packageRoot, 'dist', 'build-manifest.json')], + ] + const missing = requiredArtifacts.filter(([, target]) => !fsSync.existsSync(target)).map(([label]) => label) + checks.push({ + id: 'release-files', + status: missing.length === 0 ? 'pass' : 'fail', + detail: missing.length === 0 ? 'Packaged server, web, and manifest found' : `Missing: ${missing.join(', ')}`, + }) + + let dataWritable = false + try { + await fs.mkdir(runtimeDirectory, { recursive: true, mode: 0o700 }) + const probe = path.join(runtimeDirectory, `.doctor-${crypto.randomBytes(8).toString('hex')}`) + const handle = await fs.open(probe, 'wx', 0o600) + await handle.writeFile('ok') + await handle.close() + await fs.unlink(probe) + dataWritable = true + } catch {} + checks.push({ + id: 'data-directory', + status: dataWritable ? 'pass' : 'fail', + detail: dataWritable ? 'Platform data directory is writable' : 'Platform data directory is not writable', + }) + + const runtime = await inspectRuntime() + if (runtime.kind === 'running') { + checks.push({ id: 'runtime', status: 'pass', detail: `Healthy on 127.0.0.1:${runtime.state.port}` }) + checks.push({ + id: 'scanner', + status: runtime.health.scanner?.status === 'ready' ? 'pass' : 'warn', + detail: runtime.health.scanner?.status === 'ready' + ? `Scanner ready (${runtime.health.scanner.totalSkills ?? 0} Skills)` + : `Scanner ${runtime.health.scanner?.status || 'unknown'}`, + }) + checks.push({ id: 'port', status: 'pass', detail: `Managed runtime owns port ${runtime.state.port}` }) + } else { + checks.push({ + id: 'runtime', + status: runtime.kind === 'blocked' ? 'fail' : 'warn', + detail: runtime.kind === 'blocked' ? `Runtime blocked: ${runtime.reason}` : 'Runtime is stopped', + }) + const portFree = await canBind(3456) + checks.push({ + id: 'port', + status: portFree ? 'pass' : 'warn', + detail: portFree ? 'Default port 3456 is available' : 'Default port 3456 is occupied; start will try a bounded fallback', + }) + checks.push({ id: 'scanner', status: 'warn', detail: 'Scanner not checked because runtime is stopped' }) + } + + return { + schemaVersion: 1, + ok: checks.every((check) => check.status !== 'fail'), + product: 'SkillManager', + version, + platform: process.platform, + checks, + } +} -const forward = (sig) => () => child.kill(sig) -process.on('SIGINT', forward('SIGINT')) -process.on('SIGTERM', forward('SIGTERM')) +async function stopService() { + const runtime = await inspectRuntime() + if (runtime.kind === 'stopped') { + if (runtime.state) await removeStateIfOwned(runtime.state.instanceId) + return { ...publicStopped(runtime.reason), stopped: false } + } + if (runtime.kind === 'blocked') { + throw new CliError( + 'STOP_IDENTITY_UNVERIFIED', + `Refusing to stop a process whose identity cannot be verified (${runtime.reason})`, + ) + } + if (!runtime.state.managed) { + throw new CliError('UNMANAGED_INSTANCE', 'Refusing to stop a foreground instance not managed by SkillManager start') + } + await stopManagedState(runtime.state) + return { ...publicStopped('stopped_by_user'), stopped: true } +} + +async function stopManagedState(state) { + if (!state.managed) { + throw new CliError('UNMANAGED_INSTANCE', 'Refusing to stop a foreground instance not managed by SkillManager start') + } + try { + process.kill(state.pid, 'SIGTERM') + } catch (error) { + if (error?.code !== 'ESRCH') throw error + } + const deadline = Date.now() + 10_000 + while (Date.now() < deadline && processAlive(state.pid)) { + await new Promise((resolve) => setTimeout(resolve, 100)) + } + if (processAlive(state.pid)) { + throw new CliError('STOP_TIMEOUT', 'SkillManager did not stop after SIGTERM') + } + await removeStateIfOwned(state.instanceId) +} + +async function runCommand(command, options) { + if (command === 'help') { + process.stdout.write(helpText() + '\n') + return + } + if (command === 'version') { + if (options.json) outputJson({ schemaVersion: 1, product: 'SkillManager', version }) + else process.stdout.write(`SkillManager ${version}\n`) + return + } + if (command === 'status') { + const runtime = await inspectRuntime() + const result = runtime.kind === 'running' + ? publicRunning(runtime.state, false, runtime.health) + : runtime.kind === 'stopped' + ? publicStopped(runtime.reason) + : { + schemaVersion: 1, + status: 'error', + version, + code: 'RUNTIME_STATE_BLOCKED', + reason: runtime.reason, + capabilities, + } + if (options.json) outputJson(result) + else if (result.status === 'running') process.stdout.write(`SkillManager is running at ${result.baseUrl}\n`) + else process.stdout.write(`SkillManager is ${result.status}: ${result.reason || result.code}\n`) + if (result.status === 'error') process.exitCode = 1 + return + } + if (command === 'doctor') { + const report = await doctorReport() + if (options.json) outputJson(report) + else { + process.stdout.write(`SkillManager doctor: ${report.ok ? 'OK' : 'issues found'}\n`) + for (const check of report.checks) { + const marker = check.status === 'pass' ? '✓' : check.status === 'warn' ? '!' : '✗' + process.stdout.write(` ${marker} ${check.id}: ${check.detail}\n`) + } + } + if (!report.ok) process.exitCode = 1 + return + } + if (command === 'serve') { + ensureBuild() + const runtime = await inspectRuntime() + if (runtime.kind === 'running') { + throw new CliError('INSTANCE_ALREADY_RUNNING', `SkillManager is already running at ${runtime.state.baseUrl}`) + } + if (runtime.kind === 'blocked') { + throw new CliError('RUNTIME_STATE_BLOCKED', `Cannot safely serve: ${runtime.reason}`) + } + if (runtime.state) await removeStateIfOwned(runtime.state.instanceId) + process.env.PORT = String(options.port) + process.env.SKILLMANAGER_INSTANCE_ID ||= `instance_${crypto.randomBytes(18).toString('base64url')}` + process.env.SKILLMANAGER_CONTROL_TOKEN ||= crypto.randomBytes(32).toString('base64url') + process.env.SKILLMANAGER_MANAGED ||= '0' + process.env.SKILLMANAGER_NO_OPEN = '1' + await import(pathToFileURL(serverEntry).href) + return + } + if (command === 'stop') { + const result = await stopService() + if (options.json) outputJson(result) + else process.stdout.write(result.stopped ? 'SkillManager stopped.\n' : 'SkillManager was not running.\n') + return + } + if (command === 'start') { + const started = await startService(options) + let opened = false + if (!options.noOpen && !options.json) { + const launch = await createLaunch(started.state, options.project ? '/?view=current-project' : '/') + openSystemBrowser(launch.launchUrl) + opened = true + } + const result = { ...publicRunning(started.state, started.reused), opened } + if (options.json) outputJson(result) + else process.stdout.write( + `${started.reused ? 'Reused' : 'Started'} SkillManager at ${started.state.baseUrl}${opened ? ' and opened it' : ''}.\n`, + ) + return + } + if (command === 'open') { + const started = await startService({ ...options, noOpen: true }) + const launch = await createLaunch( + started.state, + options.target || (options.project ? '/?view=current-project' : '/'), + ) + if (!options.json) openSystemBrowser(launch.launchUrl) + const result = { + ...publicRunning(started.state, started.reused), + launchUrl: launch.launchUrl, + expiresInSeconds: launch.expiresInSeconds, + opened: !options.json, + } + if (options.json) outputJson(result) + else process.stdout.write(`Opened SkillManager at ${started.state.baseUrl}.\n`) + } +} + +let parsed +try { + parsed = parseArguments(process.argv.slice(2)) + await runCommand(parsed.command, parsed.options) +} catch (error) { + const json = parsed?.options?.json || process.argv.includes('--json') + const safeError = error instanceof CliError + ? error + : new CliError('UNEXPECTED_ERROR', error instanceof Error ? error.message : 'Unexpected error') + if (json) { + outputJson({ + schemaVersion: 1, + ok: false, + status: 'error', + code: safeError.code, + error: safeError.message, + ...(safeError.details ? { details: safeError.details } : {}), + }) + } else { + process.stderr.write(`[SkillManager] ${safeError.message}\n`) + } + process.exitCode = 1 +} diff --git a/bin/prepare.js b/bin/prepare.js index 54e34b5..577e361 100755 --- a/bin/prepare.js +++ b/bin/prepare.js @@ -15,14 +15,14 @@ if (fs.existsSync(built)) { } // Skip in CI/publish contexts where devDeps may be unavailable -if (process.env.SKILL_HUB_SKIP_BUILD === '1') { +if (process.env.SKILLMANAGER_SKIP_BUILD === '1' || process.env.SKILL_HUB_SKIP_BUILD === '1') { process.exit(0) } -console.log('[claude-skill-hub] Building frontend and server (first run)...') +console.log('[SkillManager] Building frontend and server (first run)...') try { execSync('npm run build', { cwd: root, stdio: 'inherit' }) } catch (err) { - console.error('[claude-skill-hub] Build failed:', err.message) + console.error('[SkillManager] Build failed:', err.message) process.exit(1) } diff --git a/bin/skillmanager-mcp.js b/bin/skillmanager-mcp.js new file mode 100755 index 0000000..bc18ab3 --- /dev/null +++ b/bin/skillmanager-mcp.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import fs from 'fs' +import path from 'path' +import { fileURLToPath, pathToFileURL } from 'url' + +const binDirectory = path.dirname(fileURLToPath(import.meta.url)) +const entry = path.resolve(binDirectory, '../dist/server/mcp/index.js') + +if (!fs.existsSync(entry)) { + console.error('[SkillManager MCP] Packaged MCP server is missing; reinstall SkillManager.') + process.exit(1) +} + +await import(pathToFileURL(entry).href) diff --git a/branding/README.md b/branding/README.md new file mode 100644 index 0000000..b104dfe --- /dev/null +++ b/branding/README.md @@ -0,0 +1,14 @@ +# SkillManager 标识方案 + +选定方案:`03-convergence-rails.svg`(汇流轨道)。三个入口代表不同 Agent 或散落安装,最终汇入一个方形主版本;它直接对应 SkillManager 的“一个主版本,多端共享,可预览回滚”产品心智,并且在 16px 仍能识别。 + +六个方向: + +1. **Canonical Link**:两个链环共享中心,最直接表达软链,但容易与通用“链接”图标混淆。 +2. **Shared Spine**:三个安装位挂到同一主干,信息准确,横向占比稍大。 +3. **Convergence Rails**:多端汇入唯一主版本,产品含义最完整,轮廓最独特,选为正式方案。 +4. **One Source**:一个中心源服务四端,适合表达共享,但缩小时节点偏多。 +5. **Safe Loop**:回滚环加安全盾,强调可恢复,但弱化了 Skill 管理本身。 +6. **Agent Mesh**:多 Agent 网络,扩展感强,但不够突出唯一主版本。 + +色彩:深海军蓝 `#0B1020` 代表本地控制与可信边界;安全黄 `#FACC15` 代表需要用户注意和确认的操作。正式彩色源文件为 `skillmanager-mark.svg`,浅底版本为 `skillmanager-mark-light.svg`。 diff --git a/branding/logo-showcase.html b/branding/logo-showcase.html new file mode 100644 index 0000000..6510676 --- /dev/null +++ b/branding/logo-showcase.html @@ -0,0 +1,32 @@ + + + + + + SkillManager Logo Study + + +
+
SkillManager / identity study / 2026
+

One source.
Every agent.

+

不是给“AI”再画一个闪光图标,而是把 SkillManager 的真实工作画出来:把散落在不同 Agent 目录里的 Skills,安全地汇到一个可追踪的主版本。

+
+
01

Canonical Link

两个端点共享同一个链接中心。直白,但更像通用链接工具。

+
02

Shared Spine

多个 Agent 挂到一条主干,强调“共享而非复制”。

+
SELECTED03

Convergence Rails

三处安装汇入一个主版本。最贴合产品,也最容易形成独立识别。

+
04

One Source

中心源服务多个端点,结构完整,但小尺寸节点较多。

+
05

Safe Loop

把预览、确认、回滚画成闭环,安全感强,管理含义稍弱。

+
06

Agent Mesh

多 Agent 网络结构,适合生态叙事,但没有突出唯一主版本。

+
+
Selected mark / scale check
+
diff --git a/branding/logo-variants/01-canonical-link.svg b/branding/logo-variants/01-canonical-link.svg new file mode 100644 index 0000000..7076153 --- /dev/null +++ b/branding/logo-variants/01-canonical-link.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/branding/logo-variants/02-shared-spine.svg b/branding/logo-variants/02-shared-spine.svg new file mode 100644 index 0000000..2d06c37 --- /dev/null +++ b/branding/logo-variants/02-shared-spine.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/branding/logo-variants/03-convergence-rails.svg b/branding/logo-variants/03-convergence-rails.svg new file mode 100644 index 0000000..0831749 --- /dev/null +++ b/branding/logo-variants/03-convergence-rails.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/branding/logo-variants/04-one-source.svg b/branding/logo-variants/04-one-source.svg new file mode 100644 index 0000000..29caecf --- /dev/null +++ b/branding/logo-variants/04-one-source.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/branding/logo-variants/05-safe-loop.svg b/branding/logo-variants/05-safe-loop.svg new file mode 100644 index 0000000..c2453eb --- /dev/null +++ b/branding/logo-variants/05-safe-loop.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/branding/logo-variants/06-agent-mesh.svg b/branding/logo-variants/06-agent-mesh.svg new file mode 100644 index 0000000..f9c0536 --- /dev/null +++ b/branding/logo-variants/06-agent-mesh.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/branding/skillmanager-mark-light.svg b/branding/skillmanager-mark-light.svg new file mode 100644 index 0000000..66d29e7 --- /dev/null +++ b/branding/skillmanager-mark-light.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/branding/skillmanager-mark.svg b/branding/skillmanager-mark.svg new file mode 100644 index 0000000..311b61f --- /dev/null +++ b/branding/skillmanager-mark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dist/server/index.js b/dist/server/index.js deleted file mode 100644 index f4ca7dd..0000000 --- a/dist/server/index.js +++ /dev/null @@ -1,163 +0,0 @@ -import Fastify from 'fastify'; -import cors from '@fastify/cors'; -import websocket from '@fastify/websocket'; -import fastifyStatic from '@fastify/static'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import fs from 'fs'; -import { skillRoutes } from './routes/skills.js'; -import { manageRoutes } from './routes/manage.js'; -import { versionRoutes } from './routes/versions.js'; -import { similarityRoutes } from './routes/similarity.js'; -import { trashRoutes } from './routes/trash.js'; -import { syncRoutes } from './routes/sync.js'; -import { startWatcher } from './scanner/watcher.js'; -import { invalidateCache } from './routes/skills.js'; -import { fullScan } from './scanner/discovery.js'; -import { purgeExpired as purgeExpiredTrash } from './trash/store.js'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const app = Fastify({ logger: false }); -await app.register(cors, { origin: true }); -await app.register(websocket); -await app.register(skillRoutes); -await app.register(manageRoutes); -await app.register(versionRoutes); -await app.register(similarityRoutes); -await app.register(trashRoutes); -await app.register(syncRoutes); -// Health check -app.get('/api/health', async () => ({ status: 'ok' })); -// WebSocket for real-time updates -const wsClients = new Set(); -app.register(async function (fastify) { - fastify.get('/ws', { websocket: true }, (socket) => { - wsClients.add(socket); - socket.on('close', () => wsClients.delete(socket)); - }); -}); -function broadcast(data) { - const msg = JSON.stringify(data); - for (const ws of wsClients) { - if (ws.readyState === 1) { - ws.send(msg); - } - } -} -// Start file watcher -let debounceTimer = null; -startWatcher((event) => { - if (debounceTimer) - clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - invalidateCache(); - broadcast({ type: 'change', event }); - }, 500); -}); -// Serve built frontend static files (production mode) -// Try several possible locations for the dist/web directory. Must check both -// index.html AND assets/ so we don't accidentally pick the source web/ dir in -// dev mode — the source index.html references /src/main.tsx which only works -// under vite, and serving it from fastify leaves the page blank. -const candidates = [ - path.resolve(__dirname, '../web'), // dist/server/ → dist/web/ (production layout) - path.resolve(__dirname, '../../dist/web'), // server/ (dev) → project/dist/web/ - path.resolve(process.cwd(), 'dist/web'), // cwd/dist/web/ -]; -const staticRoot = candidates.find((p) => { - try { - return (fs.existsSync(path.join(p, 'index.html')) && - fs.existsSync(path.join(p, 'assets'))); - } - catch { - return false; - } -}); -if (staticRoot) { - await app.register(fastifyStatic, { - root: staticRoot, - prefix: '/', - wildcard: false, - }); - // SPA fallback: any non-/api, non-/ws route → serve index.html - app.setNotFoundHandler((req, reply) => { - if (req.url.startsWith('/api') || req.url.startsWith('/ws')) { - reply.status(404).send({ error: 'Not found' }); - return; - } - reply.sendFile('index.html'); - }); -} -// Startup self-check: warn loudly if frontend is missing -if (!staticRoot) { - console.warn('\n\x1b[33m⚠️ Frontend build not found. Running in API-only mode.\x1b[0m'); - console.warn(' Looked in:\n - ' + candidates.join('\n - ')); - console.warn(' Run `npm run build` in the package directory.\n'); -} -// Try a range of ports on EADDRINUSE so a stale process doesn't brick startup. -async function listenWithRetry(startPort) { - const maxAttempts = 5; - for (let i = 0; i < maxAttempts; i++) { - const port = startPort + i; - try { - await app.listen({ port, host: '127.0.0.1' }); - return port; - } - catch (err) { - if (err?.code === 'EADDRINUSE' && i < maxAttempts - 1) { - console.warn(`\x1b[33m⚠️ Port ${port} in use, trying ${port + 1}...\x1b[0m`); - continue; - } - throw err; - } - } - throw new Error(`All ports ${startPort}-${startPort + maxAttempts - 1} in use`); -} -const basePort = parseInt(process.env.PORT || '3456'); -try { - const actualPort = await listenWithRetry(basePort); - const url = `http://localhost:${actualPort}`; - // Purge expired trash entries on startup (best-effort, non-blocking failures) - try { - const removed = await purgeExpiredTrash(); - if (removed > 0) { - console.log(`\x1b[90m🗑 Purged ${removed} expired trash entr${removed === 1 ? 'y' : 'ies'}\x1b[0m`); - } - } - catch { } - // Run an initial scan so the banner shows real numbers - let scanSummary = ''; - try { - const result = await fullScan(); - const paths = result.scannedPaths; - const foundPaths = paths.filter((p) => p.count > 0); - scanSummary = - `\x1b[32m✅ Found ${result.stats.total} skills\x1b[0m ` + - `(${foundPaths.length}/${paths.length} locations, ${result.durationMs}ms)`; - if (result.stats.total === 0) { - scanSummary += '\n\x1b[33m⚠️ No skills found. Run `curl ' + url + '/api/debug` to see scanned paths.\x1b[0m'; - } - } - catch (e) { - scanSummary = `\x1b[31m❌ Initial scan failed: ${e?.message || e}\x1b[0m`; - } - console.log(`\n🚀 Claude Skill Hub running at \x1b[36m${url}\x1b[0m`); - if (staticRoot) { - console.log(`🌐 Web UI: \x1b[36m${url}\x1b[0m`); - } - console.log(`🔍 Debug: \x1b[36m${url}/api/debug\x1b[0m`); - console.log(scanSummary); - console.log(`👀 File watcher active`); - console.log(`\x1b[90m💡 下次启动直接敲: \x1b[0m\x1b[36mskill-hub\x1b[0m\x1b[90m (或访问 ${url})\x1b[0m\n`); - if (staticRoot && process.env.SKILL_HUB_NO_OPEN !== '1') { - const { exec } = await import('child_process'); - const cmd = process.platform === 'darwin' ? 'open' - : process.platform === 'win32' ? 'start' - : 'xdg-open'; - exec(`${cmd} ${url}`, () => { }); - } -} -catch (err) { - console.error('\x1b[31m❌ Failed to start server:\x1b[0m', err); - process.exit(1); -} diff --git a/dist/server/routes/manage.js b/dist/server/routes/manage.js deleted file mode 100644 index e04c0e5..0000000 --- a/dist/server/routes/manage.js +++ /dev/null @@ -1,205 +0,0 @@ -import fs from 'fs/promises'; -import path from 'path'; -import os from 'os'; -import { invalidateCache } from './skills.js'; -import { createSnapshot } from '../versioning/store.js'; -import { moveToTrash } from '../trash/store.js'; -const homedir = os.homedir(); -const settingsPath = path.join(homedir, '.claude', 'settings.json'); -async function readSettings() { - try { - const raw = await fs.readFile(settingsPath, 'utf-8'); - return JSON.parse(raw); - } - catch { - return {}; - } -} -async function writeSettings(settings) { - await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); -} -export async function manageRoutes(app) { - // Toggle skill enabled/disabled - app.put('/api/skills/:id/toggle', async (req) => { - const { enabled, skillName } = req.body; - const settings = await readSettings(); - if (!settings.permissions) - settings.permissions = {}; - if (!settings.permissions.deny) - settings.permissions.deny = []; - const rule = `Skill(${skillName})`; - const idx = settings.permissions.deny.indexOf(rule); - if (enabled && idx >= 0) { - // Remove from deny list to enable - settings.permissions.deny.splice(idx, 1); - } - else if (!enabled && idx < 0) { - // Add to deny list to disable - settings.permissions.deny.push(rule); - } - await writeSettings(settings); - invalidateCache(); - return { ok: true, enabled }; - }); - // Update SKILL.md content - app.put('/api/skills/:id/content', async (req) => { - const { realPath, content } = req.body; - const skillMdPath = path.join(realPath, 'SKILL.md'); - // Verify the file exists - try { - await fs.access(skillMdPath); - } - catch { - return { ok: false, error: 'SKILL.md not found' }; - } - // Auto-snapshot before overwriting (save the old version) - const skillName = path.basename(realPath); - try { - await createSnapshot(realPath, skillName, '编辑前自动备份', 'auto'); - } - catch { } - await fs.writeFile(skillMdPath, content, 'utf-8'); - // Snapshot the new version - try { - await createSnapshot(realPath, skillName, '通过编辑器保存', 'auto'); - } - catch { } - invalidateCache(); - return { ok: true }; - }); - // Copy skill to another location - app.post('/api/skills/copy', async (req) => { - const { sourcePath, targetScope, projectPath, skillName } = req.body; - let targetDir; - if (targetScope === 'global') { - targetDir = path.join(homedir, '.claude', 'skills', skillName); - } - else if (projectPath) { - targetDir = path.join(projectPath, '.claude', 'skills', skillName); - } - else { - return { ok: false, error: 'Project path required for project scope' }; - } - // Resolve source if symlink - let realSource; - try { - realSource = await fs.realpath(sourcePath); - } - catch { - realSource = sourcePath; - } - // Check if target already exists - try { - await fs.access(targetDir); - return { ok: false, error: '目标位置已存在同名 Skill' }; - } - catch { - // Good — doesn't exist - } - // Copy directory recursively - await copyDir(realSource, targetDir); - invalidateCache(); - return { ok: true, targetDir }; - }); - // Move skill (copy + delete source) - app.post('/api/skills/move', async (req) => { - const { sourcePath, targetScope, projectPath, skillName } = req.body; - let targetDir; - if (targetScope === 'global') { - targetDir = path.join(homedir, '.claude', 'skills', skillName); - } - else if (projectPath) { - targetDir = path.join(projectPath, '.claude', 'skills', skillName); - } - else { - return { ok: false, error: 'Project path required for project scope' }; - } - let realSource; - try { - realSource = await fs.realpath(sourcePath); - } - catch { - realSource = sourcePath; - } - try { - await fs.access(targetDir); - return { ok: false, error: '目标位置已存在同名 Skill' }; - } - catch { } - await copyDir(realSource, targetDir); - // Remove the source (if symlink, just remove the link; if dir, remove recursively) - const stat = await fs.lstat(sourcePath); - if (stat.isSymbolicLink()) { - await fs.unlink(sourcePath); - } - else { - await fs.rm(sourcePath, { recursive: true }); - } - invalidateCache(); - return { ok: true, targetDir }; - }); - // Delete skill (soft delete → recycle bin; 7-day TTL) - app.delete('/api/skills/:id', async (req, reply) => { - const skillPath = req.body.path; - const skillName = req.body.skillName; - try { - const meta = await moveToTrash(skillPath, skillName); - invalidateCache(); - return { ok: true, trashId: meta.id, expiresAt: meta.expiresAt }; - } - catch (err) { - reply.status(500); - return { ok: false, error: err?.message || '删除失败' }; - } - }); - // Batch delete — move many skills to trash in one call - app.post('/api/skills/batch/delete', async (req, reply) => { - const items = Array.isArray(req.body?.items) ? req.body.items : []; - if (items.length === 0) { - reply.status(400); - return { ok: false, error: '未提供要删除的 skill' }; - } - const results = []; - for (const item of items) { - if (!item || typeof item.path !== 'string') { - results.push({ id: item?.id || '(unknown)', ok: false, error: '参数不完整' }); - continue; - } - try { - const meta = await moveToTrash(item.path, item.skillName); - results.push({ - id: item.id, - skillName: item.skillName, - ok: true, - trashId: meta.id, - }); - } - catch (err) { - results.push({ - id: item.id, - skillName: item.skillName, - ok: false, - error: err?.message || '删除失败', - }); - } - } - invalidateCache(); - const okCount = results.filter((r) => r.ok).length; - const failCount = results.length - okCount; - return { ok: failCount === 0, okCount, failCount, results }; - }); -} -async function copyDir(src, dest) { - await fs.mkdir(dest, { recursive: true }); - const entries = await fs.readdir(src, { withFileTypes: true }); - for (const entry of entries) { - const srcPath = path.join(src, entry.name); - const destPath = path.join(dest, entry.name); - if (entry.isDirectory()) { - await copyDir(srcPath, destPath); - } - else { - await fs.copyFile(srcPath, destPath); - } - } -} diff --git a/dist/server/routes/similarity.js b/dist/server/routes/similarity.js deleted file mode 100644 index c55528b..0000000 --- a/dist/server/routes/similarity.js +++ /dev/null @@ -1,69 +0,0 @@ -import fs from 'fs/promises'; -import path from 'path'; -import os from 'os'; -import { detectSimilarSkills } from '../scanner/similarity.js'; -import { getCachedResult } from './skills.js'; -import { fullScan } from '../scanner/discovery.js'; -const STORE_DIR = path.join(os.homedir(), '.claude', 'skill-hub'); -const IGNORED_FILE = path.join(STORE_DIR, 'ignored-pairs.json'); -async function loadIgnored() { - try { - const raw = await fs.readFile(IGNORED_FILE, 'utf-8'); - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } - catch { - return []; - } -} -async function saveIgnored(list) { - await fs.mkdir(STORE_DIR, { recursive: true }); - await fs.writeFile(IGNORED_FILE, JSON.stringify(list, null, 2), 'utf-8'); -} -export async function similarityRoutes(app) { - app.get('/api/similar', async (req) => { - let result = getCachedResult(); - if (!result) - result = await fullScan(); - const ignored = await loadIgnored(); - const threshold = req.query.threshold ? parseFloat(req.query.threshold) : 0.25; - const groups = detectSimilarSkills(result.skills, { - threshold, - ignoredPairs: ignored.map((p) => [p.a, p.b]), - }); - return { - threshold, - ignoredCount: ignored.length, - groups, - }; - }); - app.post('/api/similar/ignore', async (req, reply) => { - const { a, b } = req.body || {}; - if (!a || !b) { - return reply.status(400).send({ error: 'Missing skill ids (a, b)' }); - } - const list = await loadIgnored(); - // Normalize so (a,b) and (b,a) are the same record - const [x, y] = a < b ? [a, b] : [b, a]; - if (!list.some((p) => p.a === x && p.b === y)) { - list.push({ a: x, b: y, ignoredAt: new Date().toISOString() }); - await saveIgnored(list); - } - return { ok: true, ignoredCount: list.length }; - }); - app.post('/api/similar/unignore', async (req, reply) => { - const { a, b } = req.body || {}; - if (!a || !b) { - return reply.status(400).send({ error: 'Missing skill ids (a, b)' }); - } - const list = await loadIgnored(); - const [x, y] = a < b ? [a, b] : [b, a]; - const next = list.filter((p) => !(p.a === x && p.b === y)); - if (next.length !== list.length) - await saveIgnored(next); - return { ok: true, ignoredCount: next.length }; - }); - app.get('/api/similar/ignored', async () => { - return { ignored: await loadIgnored() }; - }); -} diff --git a/dist/server/routes/skills.js b/dist/server/routes/skills.js deleted file mode 100644 index ab7a963..0000000 --- a/dist/server/routes/skills.js +++ /dev/null @@ -1,99 +0,0 @@ -import os from 'os'; -import { fullScan } from '../scanner/discovery.js'; -import { AGENTS } from '../scanner/agents.js'; -let cachedResult = null; -export function getCachedResult() { - return cachedResult; -} -export async function skillRoutes(app) { - // Trigger full scan - app.get('/api/scan', async () => { - cachedResult = await fullScan(); - return cachedResult; - }); - // Get all skills (with optional filters) - app.get('/api/skills', async (req) => { - if (!cachedResult) { - cachedResult = await fullScan(); - } - let skills = [...cachedResult.skills]; - const { scope, source, agent, search } = req.query; - if (scope && scope !== 'all') { - skills = skills.filter((s) => s.scope === scope); - } - if (source && source !== 'all') { - skills = skills.filter((s) => s.source === source); - } - if (agent && agent !== 'all') { - skills = skills.filter((s) => s.agent === agent); - } - if (search) { - const q = search.toLowerCase(); - skills = skills.filter((s) => s.name.toLowerCase().includes(q) || - s.description.toLowerCase().includes(q)); - } - return { skills, stats: cachedResult.stats }; - }); - // Get single skill detail - app.get('/api/skills/:id', async (req, reply) => { - if (!cachedResult) { - cachedResult = await fullScan(); - } - const skill = cachedResult.skills.find((s) => s.id === req.params.id); - if (!skill) { - return reply.status(404).send({ error: 'Skill not found' }); - } - return skill; - }); - // Get the agent registry (id/name/icon) — used by the frontend filter UI - app.get('/api/agents', async () => { - return AGENTS.map((a) => ({ id: a.id, name: a.name, icon: a.icon })); - }); - // Get discovered projects - app.get('/api/projects', async () => { - if (!cachedResult) { - cachedResult = await fullScan(); - } - return cachedResult.projects; - }); - // Get conflicts - app.get('/api/conflicts', async () => { - if (!cachedResult) { - cachedResult = await fullScan(); - } - return cachedResult.conflicts; - }); - // Get stats - app.get('/api/stats', async () => { - if (!cachedResult) { - cachedResult = await fullScan(); - } - return cachedResult.stats; - }); - // Diagnostic endpoint — useful for debugging "only found 1 skill" reports - app.get('/api/debug', async () => { - if (!cachedResult) { - cachedResult = await fullScan(); - } - return { - version: '0.3.0', - node: process.version, - platform: process.platform, - cwd: process.cwd(), - homedir: os.homedir(), - env: { - SKILL_HUB_EXTRA_PATHS: process.env.SKILL_HUB_EXTRA_PATHS || null, - PORT: process.env.PORT || null, - }, - scan: { - durationMs: cachedResult.durationMs, - totalSkills: cachedResult.stats.total, - scannedPaths: cachedResult.scannedPaths, - }, - stats: cachedResult.stats, - }; - }); -} -export function invalidateCache() { - cachedResult = null; -} diff --git a/dist/server/routes/trash.js b/dist/server/routes/trash.js deleted file mode 100644 index 95ba95c..0000000 --- a/dist/server/routes/trash.js +++ /dev/null @@ -1,41 +0,0 @@ -import { listTrash, restoreFromTrash, purgeOne, purgeExpired, TrashConflictError, TrashNotFoundError, } from '../trash/store.js'; -import { invalidateCache } from './skills.js'; -export async function trashRoutes(app) { - // List trash entries (also purges expired as a side effect) - app.get('/api/trash', async () => { - const items = await listTrash(); - return { ok: true, items }; - }); - // Restore a trash entry back to its original location - app.post('/api/trash/:id/restore', async (req, reply) => { - const { id } = req.params; - const force = req.query.force === 'true' || req.query.force === '1'; - try { - const meta = await restoreFromTrash(id, force); - invalidateCache(); - return { ok: true, meta }; - } - catch (err) { - if (err instanceof TrashConflictError) { - reply.status(409); - return { ok: false, error: err.message, code: 'CONFLICT', targetPath: err.targetPath }; - } - if (err instanceof TrashNotFoundError) { - reply.status(404); - return { ok: false, error: err.message, code: 'NOT_FOUND' }; - } - reply.status(500); - return { ok: false, error: err?.message || '还原失败' }; - } - }); - // Permanently delete a single trash entry - app.delete('/api/trash/:id', async (req) => { - const ok = await purgeOne(req.params.id); - return { ok }; - }); - // Manual purge of expired entries - app.post('/api/trash/purge-expired', async () => { - const removed = await purgeExpired(); - return { ok: true, removed }; - }); -} diff --git a/dist/server/routes/versions.js b/dist/server/routes/versions.js deleted file mode 100644 index 9a8ce28..0000000 --- a/dist/server/routes/versions.js +++ /dev/null @@ -1,60 +0,0 @@ -import { createSnapshot, getHistory, getVersion, diffVersions, diffWithCurrent, rollback, deleteVersion, } from '../versioning/store.js'; -import { invalidateCache } from './skills.js'; -export async function versionRoutes(app) { - // 创建快照 - app.post('/api/versions/snapshot', async (req) => { - const { skillPath, skillName, message } = req.body; - try { - const meta = await createSnapshot(skillPath, skillName, message, 'manual'); - return { ok: true, version: meta }; - } - catch (e) { - return { ok: false, error: e.message }; - } - }); - // 获取版本历史 - app.get('/api/versions/history', async (req) => { - const { skillPath } = req.query; - const history = await getHistory(skillPath); - return { history }; - }); - // 获取某个版本的完整内容 - app.get('/api/versions/detail', async (req) => { - const { skillPath, versionId } = req.query; - const version = await getVersion(skillPath, versionId); - if (!version) - return { ok: false, error: 'Version not found' }; - return { ok: true, version }; - }); - // 对比两个版本 - app.get('/api/versions/diff', async (req) => { - const { skillPath, oldId, newId } = req.query; - const diff = await diffVersions(skillPath, oldId, newId); - if (!diff) - return { ok: false, error: 'Diff failed' }; - return { ok: true, diff }; - }); - // 对比某个版本和当前文件 - app.get('/api/versions/diff-current', async (req) => { - const { skillPath, versionId } = req.query; - const diff = await diffWithCurrent(skillPath, versionId); - if (!diff) - return { ok: false, error: 'Diff failed' }; - return { ok: true, diff }; - }); - // 回滚到指定版本 - app.post('/api/versions/rollback', async (req) => { - const { skillPath, versionId } = req.body; - const success = await rollback(skillPath, versionId); - if (!success) - return { ok: false, error: 'Rollback failed' }; - invalidateCache(); - return { ok: true }; - }); - // 删除版本 - app.delete('/api/versions', async (req) => { - const { skillPath, versionId } = req.query; - const success = await deleteVersion(skillPath, versionId); - return { ok: success }; - }); -} diff --git a/dist/server/scanner/discovery.js b/dist/server/scanner/discovery.js deleted file mode 100644 index 182c807..0000000 --- a/dist/server/scanner/discovery.js +++ /dev/null @@ -1,421 +0,0 @@ -import fs from 'fs/promises'; -import path from 'path'; -import os from 'os'; -import crypto from 'crypto'; -import { parseSkillMd, listSkillFiles, getSkillMdPath } from './parser.js'; -import { resolveSymlink, identifySource } from './symlink.js'; -import { AGENTS, allAgentGlobalAbsPaths, allAgentProjectRelPaths, isValidAgentId, } from './agents.js'; -const homedir = os.homedir(); -function makeId(p) { - return crypto.createHash('md5').update(p).digest('hex').slice(0, 12); -} -/** - * YAML frontmatter can legitimately parse `name` / `description` / `model` - * fields as non-string values (numbers, booleans, objects, arrays). If we let - * those through, React renders them and throws error #31. Force-coerce every - * value that the UI is going to render. - */ -function toSafeString(v) { - if (v == null) - return ''; - if (typeof v === 'string') - return v; - if (typeof v === 'number' || typeof v === 'boolean') - return String(v); - if (Array.isArray(v)) - return v.map(toSafeString).join(', '); - if (typeof v === 'object') { - try { - return JSON.stringify(v); - } - catch { - return '[object]'; - } - } - return String(v); -} -function sanitizeFrontmatter(fm) { - const out = {}; - for (const [k, v] of Object.entries(fm)) { - // Keep arrays/objects as-is for fields the UI treats as data (e.g. `paths`), - // but coerce the ones we know get rendered as plain text. - if (k === 'name' || k === 'description' || k === 'model' || k === 'effort' || k === 'agent' || k === 'context') { - out[k] = toSafeString(v); - } - else { - out[k] = v; - } - } - return out; -} -async function dirExists(p) { - try { - const s = await fs.stat(p); - return s.isDirectory(); - } - catch { - return false; - } -} -async function scanSkillDir(skillDir, scope, agent, projectName, projectPath, disabledSkills) { - const skills = []; - let entries; - try { - entries = await fs.readdir(skillDir, { withFileTypes: true }); - } - catch { - return skills; - } - for (const entry of entries) { - const entryPath = path.join(skillDir, entry.name); - const symlinkInfo = await resolveSymlink(entryPath); - const realPath = symlinkInfo.realPath; - let isDir = false; - try { - const stat = await fs.stat(realPath); - isDir = stat.isDirectory(); - } - catch { - continue; - } - if (!isDir) - continue; - const skillMdPath = getSkillMdPath(realPath); - let skillMdExists = false; - try { - await fs.access(skillMdPath); - skillMdExists = true; - } - catch { } - if (!skillMdExists) { - const files = await listSkillFiles(realPath); - if (files.length === 0) - continue; - } - let frontmatter = {}; - let content = ''; - let rawContent = ''; - if (skillMdExists) { - try { - const parsed = await parseSkillMd(skillMdPath); - frontmatter = parsed.frontmatter; - content = parsed.content; - rawContent = parsed.rawContent; - } - catch { } - } - const files = await listSkillFiles(realPath); - const source = symlinkInfo.isSymlink - ? identifySource(realPath, homedir) - : 'local'; - let lastModified = new Date().toISOString(); - try { - const stat = await fs.stat(skillMdExists ? skillMdPath : realPath); - lastModified = stat.mtime.toISOString(); - } - catch { } - const safeFrontmatter = sanitizeFrontmatter(frontmatter); - const skillName = toSafeString(safeFrontmatter.name) || entry.name; - const description = toSafeString(safeFrontmatter.description); - // Frontmatter `agent:` overrides the path-based guess when it's a known id. - const fmAgent = toSafeString(safeFrontmatter.agent).toLowerCase().trim(); - const resolvedAgent = fmAgent && isValidAgentId(fmAgent) ? fmAgent : agent; - skills.push({ - id: makeId(entryPath), - name: skillName, - description, - scope, - agent: resolvedAgent, - source, - path: entryPath, - realPath, - symlinkTarget: symlinkInfo.isSymlink ? symlinkInfo.target : undefined, - projectName, - projectPath, - frontmatter: safeFrontmatter, - content: toSafeString(rawContent || content), - files, - enabled: disabledSkills ? !disabledSkills.has(skillName) : true, - hasConflict: false, - lastModified, - }); - } - return skills; -} -async function getDisabledSkills() { - const disabled = new Set(); - const settingsPath = path.join(homedir, '.claude', 'settings.json'); - try { - const raw = await fs.readFile(settingsPath, 'utf-8'); - const settings = JSON.parse(raw); - const deny = settings?.permissions?.deny || []; - for (const rule of deny) { - const match = rule.match(/^Skill\((.+)\)$/); - if (match) - disabled.add(match[1]); - } - } - catch { } - return disabled; -} -async function hasAnyAgentSkills(projectRoot) { - for (const rel of allAgentProjectRelPaths()) { - if (await dirExists(path.join(projectRoot, rel))) - return true; - } - return false; -} -async function discoverProjects() { - const projects = []; - // 1. ~/.claude/projects/ (mangled path dirs — Claude tracks projects it's been opened in) - const projectsDir = path.join(homedir, '.claude', 'projects'); - try { - const entries = await fs.readdir(projectsDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) - continue; - const projectPath = entry.name.replace(/^-/, '/').replace(/-/g, '/'); - if (await dirExists(projectPath)) { - if (projectPath === homedir) - continue; - if (await hasAnyAgentSkills(projectPath)) { - projects.push({ - name: path.basename(projectPath), - path: projectPath, - }); - } - } - } - } - catch { } - // 2. Common project root dirs — expanded list - const commonDirs = [ - path.join(homedir, 'Documents'), - path.join(homedir, 'Projects'), - path.join(homedir, 'Developer'), - path.join(homedir, 'Code'), - path.join(homedir, 'code'), - path.join(homedir, 'workspace'), - path.join(homedir, 'dev'), - path.join(homedir, 'Dev'), - path.join(homedir, 'work'), - path.join(homedir, 'repos'), - path.join(homedir, 'src'), - ]; - for (const dir of commonDirs) { - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) - continue; - const projectPath = path.join(dir, entry.name); - if (await hasAnyAgentSkills(projectPath)) { - if (!projects.some((p) => p.path === projectPath)) { - projects.push({ name: entry.name, path: projectPath }); - } - } - } - } - catch { } - } - // 3. CWD + walk up 3 levels — skip the user's home directory, whose - // `.claude/skills/` etc. are the *global* paths, not project paths. - // Running `skill-hub` from home otherwise causes every global skill to - // be double-counted as "lhc (cwd)/" in the scan report. - let cwd = process.cwd(); - for (let i = 0; i < 4; i++) { - if (cwd !== homedir && (await hasAnyAgentSkills(cwd))) { - if (!projects.some((p) => p.path === cwd)) { - projects.push({ name: path.basename(cwd) + ' (cwd)', path: cwd }); - } - } - const parent = path.dirname(cwd); - if (parent === cwd) - break; - cwd = parent; - } - return projects; -} -/** - * Find every `skills/` directory that belongs to a plugin the user has - * actually enabled. - * - * Claude Code tracks enabled plugins in ~/.claude/plugins/config.json under - * `repositories`. Anything living only under ~/.claude/plugins/marketplaces/ - * is a *candidate* from a marketplace — Claude Code does not load those, they - * are just the source catalog. Earlier versions of this scanner walked the - * entire plugins/ tree and reported those candidates as installed plugin - * skills, which was very confusing for users who had never enabled a plugin. - */ -async function discoverPluginSkillDirs() { - const result = []; - const pluginsRoot = path.join(homedir, '.claude', 'plugins'); - const configPath = path.join(pluginsRoot, 'config.json'); - // Extract installLocation of every enabled plugin. - const installLocations = []; - try { - const raw = await fs.readFile(configPath, 'utf-8'); - const config = JSON.parse(raw); - const repos = config?.repositories || {}; - for (const meta of Object.values(repos)) { - if (meta && typeof meta === 'object') { - const loc = meta.installLocation; - if (typeof loc === 'string' && loc.length > 0) { - installLocations.push(loc); - } - } - } - } - catch { } - // No plugins enabled → nothing to scan. Skip the marketplace catalog entirely. - if (installLocations.length === 0) - return result; - async function walk(dir, depth) { - if (depth > 4) - return; - let entries; - try { - entries = await fs.readdir(dir, { withFileTypes: true }); - } - catch { - return; - } - for (const entry of entries) { - if (!entry.isDirectory()) - continue; - if (entry.name === 'node_modules' || entry.name.startsWith('.git')) - continue; - const sub = path.join(dir, entry.name); - if (entry.name === 'skills') { - result.push(sub); - continue; - } - await walk(sub, depth + 1); - } - } - // Only walk inside each enabled plugin's install location, not the whole - // plugins/ tree. - for (const loc of installLocations) { - if (await dirExists(loc)) { - await walk(loc, 0); - } - } - return result; -} -function detectConflicts(skills) { - const byName = new Map(); - for (const skill of skills) { - const existing = byName.get(skill.name) || []; - existing.push(skill); - byName.set(skill.name, existing); - } - const conflicts = []; - for (const [name, group] of byName) { - if (group.length > 1) { - group.forEach((s) => (s.hasConflict = true)); - conflicts.push({ name, skills: group }); - } - } - return conflicts; -} -function parseExtraPaths() { - const raw = process.env.SKILL_HUB_EXTRA_PATHS; - if (!raw) - return []; - return raw - .split(/[:,]/) - .map((p) => p.trim()) - .filter(Boolean) - .map((p) => (p.startsWith('~') ? path.join(homedir, p.slice(1)) : p)); -} -export async function fullScan() { - const start = Date.now(); - const disabledSkills = await getDisabledSkills(); - const allSkills = []; - const scannedPaths = []; - async function scanAndReport(label, dir, scope, agent, projectName, projectPath) { - const exists = await dirExists(dir); - if (!exists) { - scannedPaths.push({ label, path: dir, exists: false, count: 0 }); - return []; - } - try { - const skills = await scanSkillDir(dir, scope, agent, projectName, projectPath, disabledSkills); - scannedPaths.push({ label, path: dir, exists: true, count: skills.length }); - return skills; - } - catch (e) { - scannedPaths.push({ - label, - path: dir, - exists: true, - count: 0, - error: e?.message || String(e), - }); - return []; - } - } - // 1. Global skills — loop over every agent's global paths - for (const { agent, path: globalDir } of allAgentGlobalAbsPaths(homedir)) { - allSkills.push(...(await scanAndReport(`global:${agent.id}`, globalDir, 'global', agent.id))); - } - // 2. Plugin skills — Claude Code only for now - const pluginSkillDirs = await discoverPluginSkillDirs(); - for (const pluginDir of pluginSkillDirs) { - const pluginName = path.relative(path.join(homedir, '.claude', 'plugins'), pluginDir); - allSkills.push(...(await scanAndReport(`plugin:${pluginName}`, pluginDir, 'plugin', 'claude-code'))); - } - // 3. Project skills — for each project, scan every agent's project paths - const discoveredProjects = await discoverProjects(); - const projects = []; - for (const proj of discoveredProjects) { - let projectTotal = 0; - for (const agent of AGENTS) { - for (const rel of agent.projectPaths) { - const skillsDir = path.join(proj.path, rel); - const projectSkills = await scanAndReport(`project:${proj.name}:${agent.id}`, skillsDir, 'project', agent.id, proj.name, proj.path); - allSkills.push(...projectSkills); - projectTotal += projectSkills.length; - } - } - projects.push({ - name: proj.name, - path: proj.path, - skillCount: projectTotal, - }); - } - // 4. Extra paths from SKILL_HUB_EXTRA_PATHS — agent unknown - for (const extra of parseExtraPaths()) { - allSkills.push(...(await scanAndReport(`extra:${path.basename(extra)}`, extra, 'project', 'unknown'))); - } - // Deduplicate by realPath (symlinks can point to the same skill from multiple roots) - const seen = new Set(); - const dedupedSkills = []; - for (const s of allSkills) { - if (seen.has(s.realPath)) - continue; - seen.add(s.realPath); - dedupedSkills.push(s); - } - const conflicts = detectConflicts(dedupedSkills); - const bySource = {}; - const byAgent = {}; - for (const s of dedupedSkills) { - bySource[s.source] = (bySource[s.source] || 0) + 1; - byAgent[s.agent] = (byAgent[s.agent] || 0) + 1; - } - return { - skills: dedupedSkills, - projects, - conflicts, - stats: { - total: dedupedSkills.length, - global: dedupedSkills.filter((s) => s.scope === 'global').length, - project: dedupedSkills.filter((s) => s.scope === 'project').length, - bySource, - byAgent, - }, - scannedPaths, - durationMs: Date.now() - start, - }; -} diff --git a/dist/server/scanner/parser.js b/dist/server/scanner/parser.js deleted file mode 100644 index 7ca6d57..0000000 --- a/dist/server/scanner/parser.js +++ /dev/null @@ -1,26 +0,0 @@ -import fs from 'fs/promises'; -import path from 'path'; -import matter from 'gray-matter'; -export async function parseSkillMd(skillMdPath) { - const raw = await fs.readFile(skillMdPath, 'utf-8'); - const { data, content } = matter(raw); - return { - frontmatter: data, - content: content.trim(), - rawContent: raw, - }; -} -export async function listSkillFiles(skillDir) { - try { - const entries = await fs.readdir(skillDir, { withFileTypes: true }); - return entries - .filter((e) => e.isFile()) - .map((e) => e.name); - } - catch { - return []; - } -} -export function getSkillMdPath(skillDir) { - return path.join(skillDir, 'SKILL.md'); -} diff --git a/dist/server/scanner/similarity.js b/dist/server/scanner/similarity.js deleted file mode 100644 index 7b40f6a..0000000 --- a/dist/server/scanner/similarity.js +++ /dev/null @@ -1,227 +0,0 @@ -// Built-in synonyms — extend via ~/.claude/skill-hub/synonyms.json if needed. -const BUILTIN_SYNONYMS = { - 小红书: ['xhs', 'rednote', '种草', '小红薯', 'xiaohongshu'], - 视频: ['video', 'vlog', 'movie', 'mp4'], - 图片: ['image', 'img', 'picture', 'pic', 'photo'], - 飞书: ['lark', 'feishu'], - 公众号: ['wechat', 'mp', 'gzh', 'wx'], - 推特: ['twitter', 'x', 'tweet'], - 邮件: ['email', 'mail', 'gmail'], - 日报: ['daily', 'report', 'digest'], - 会议: ['meeting', 'minutes', '纪要'], - 文档: ['doc', 'document', 'markdown', 'md'], - 表格: ['sheet', 'spreadsheet', 'excel', 'xlsx', 'csv'], - 幻灯片: ['ppt', 'pptx', 'slide', 'presentation'], - 翻译: ['translate', 'translation', 'i18n'], - 生成: ['generate', 'create', 'make', '制作', '创建'], - 抓取: ['fetch', 'scrape', 'crawl', '爬取'], - 发布: ['publish', 'post', 'deploy', '推送'], - 分析: ['analyze', 'analysis', 'analytics', '拆解'], - 测试: ['test', 'testing', 'qa'], - 代码: ['code', 'coding', 'program'], - 设计: ['design', 'designer', 'ui'], - 人格: ['personality', 'mbti', 'sbti', 'persona'], - 访谈: ['interview', '采访'], - 发票: ['invoice', 'receipt', '收据'], -}; -// Stop words — too common to be useful as similarity signal. -const STOP_WORDS = new Set([ - // Chinese stop tokens - '使用', '当用', '用户', '提到', '触发', '支持', '需要', '可以', '自动', '一个', '这个', '进行', - '工具', '技能', '功能', '场景', '命令', '文件', '内容', '数据', '信息', '结果', - '或者', '按照', '根据', '然后', '基于', '提供', '我们', '所以', '因此', '以及', - // English stop tokens - 'skill', 'use', 'used', 'using', 'usage', 'when', 'user', 'users', 'the', 'and', 'for', 'with', - 'from', 'this', 'that', 'these', 'those', 'can', 'any', 'all', 'auto', 'support', 'supports', - 'tool', 'tools', 'create', 'creates', 'creating', 'generate', 'generates', 'make', 'makes', - 'do', 'does', 'get', 'gets', 'set', 'sets', 'should', 'would', 'could', 'will', 'want', - 'add', 'adds', 'added', 'adding', 'need', 'needs', 'needed', 'guidance', 'guide', 'guides', - 'help', 'helps', 'helper', 'ensure', 'allow', 'allows', 'asks', 'ask', 'asked', - 'mention', 'mentions', 'mentioned', 'provide', 'provides', 'provided', 'request', 'requests', - 'requested', 'include', 'includes', 'included', 'contains', 'content', 'text', 'task', - 'tasks', 'file', 'files', 'code', 'new', 'each', 'also', 'other', 'over', 'into', 'more', - 'most', 'some', 'such', 'than', 'only', 'very', 'like', 'just', 'between', 'based', - 'handle', 'handles', 'handled', 'process', 'processing', 'processed', 'system', 'systems', -]); -/** - * Normalize a raw token into its canonical form using the synonym table. - * Everything is lowercased; Chinese is left as-is. - */ -function buildSynonymIndex(synonyms) { - const index = new Map(); - for (const [canonical, aliases] of Object.entries(synonyms)) { - index.set(canonical.toLowerCase(), canonical.toLowerCase()); - for (const a of aliases) { - index.set(a.toLowerCase(), canonical.toLowerCase()); - } - } - return index; -} -/** - * Tokenize a string into a set of normalized tokens. - * - English: split on non-word; drop short/stopword - * - Chinese (CJK range): generate character bigrams so "小红书" contributes - * "小红" + "红书" tokens that can match against the "小红书" synonym key after merging. - * Also emit the full 3-gram when possible. - */ -export function tokenize(text, synonymIndex) { - const out = new Set(); - if (!text) - return out; - const lower = text.toLowerCase(); - // 1. English / ASCII words - const words = lower.match(/[a-z][a-z0-9_-]{1,}/g) || []; - for (const w of words) { - if (STOP_WORDS.has(w)) - continue; - if (w.length < 3) - continue; - out.add(synonymIndex.get(w) || w); - } - // 2. Chinese n-grams (bigram + trigram on CJK spans) - const cjkSpans = lower.match(/[\u4e00-\u9fff]+/g) || []; - for (const span of cjkSpans) { - // Try to match synonym keys first by sliding full length - for (const key of synonymIndex.keys()) { - if (/^[\u4e00-\u9fff]+$/.test(key) && span.includes(key)) { - out.add(synonymIndex.get(key)); - } - } - // Bigrams - for (let i = 0; i < span.length - 1; i++) { - const bg = span.slice(i, i + 2); - if (STOP_WORDS.has(bg)) - continue; - out.add(bg); - } - } - return out; -} -function jaccard(a, b) { - if (a.size === 0 || b.size === 0) - return 0; - let intersect = 0; - for (const x of a) - if (b.has(x)) - intersect++; - const union = a.size + b.size - intersect; - return union === 0 ? 0 : intersect / union; -} -/** - * Build a skill → signature token set, using name + description + frontmatter.keywords. - */ -function signatureFor(skill, synonymIndex) { - const parts = []; - parts.push(skill.name); - if (skill.description) - parts.push(skill.description); - const kw = skill.frontmatter?.keywords; - if (Array.isArray(kw)) - parts.push(kw.join(' ')); - return tokenize(parts.join(' '), synonymIndex); -} -/** - * Cluster skills into similarity groups via single-linkage clustering over - * pairs whose Jaccard similarity exceeds the threshold. - */ -export function detectSimilarSkills(skills, options = {}) { - const threshold = options.threshold ?? 0.25; - const synonyms = { ...BUILTIN_SYNONYMS, ...(options.extraSynonyms || {}) }; - const synonymIndex = buildSynonymIndex(synonyms); - const ignored = new Set(); - for (const [a, b] of options.ignoredPairs || []) { - ignored.add(a < b ? `${a}|${b}` : `${b}|${a}`); - } - // 1. Compute signatures - const sigs = new Map(); - for (const s of skills) { - sigs.set(s.id, signatureFor(s, synonymIndex)); - } - // 2. Collect pairs above threshold. Skip exact-name duplicates (already shown as conflicts). - const pairs = []; - for (let i = 0; i < skills.length; i++) { - for (let j = i + 1; j < skills.length; j++) { - const a = skills[i]; - const b = skills[j]; - if (a.name === b.name) - continue; // covered by conflict detection - const pairKey = a.id < b.id ? `${a.id}|${b.id}` : `${b.id}|${a.id}`; - if (ignored.has(pairKey)) - continue; - const sa = sigs.get(a.id); - const sb = sigs.get(b.id); - const score = jaccard(sa, sb); - if (score >= threshold) { - const shared = []; - for (const t of sa) - if (sb.has(t)) - shared.push(t); - pairs.push({ a, b, score, sharedTokens: shared }); - } - } - } - // 3. Union-Find to cluster via single linkage - const parent = new Map(); - const find = (x) => { - if (parent.get(x) !== x) - parent.set(x, find(parent.get(x))); - return parent.get(x); - }; - const union = (a, b) => { - const ra = find(a); - const rb = find(b); - if (ra !== rb) - parent.set(ra, rb); - }; - for (const s of skills) - parent.set(s.id, s.id); - for (const p of pairs) - union(p.a.id, p.b.id); - // 4. Gather clusters that have ≥ 2 members - const clusters = new Map(); - for (const s of skills) { - const r = find(s.id); - const arr = clusters.get(r) || []; - arr.push(s); - clusters.set(r, arr); - } - const groups = []; - for (const [rootId, members] of clusters) { - if (members.length < 2) - continue; - // Compute shared token intersection + avg similarity for display - const memberSigs = members.map((m) => sigs.get(m.id)); - let sharedTokens = new Set(memberSigs[0]); - for (let i = 1; i < memberSigs.length; i++) { - const next = new Set(); - for (const t of sharedTokens) - if (memberSigs[i].has(t)) - next.add(t); - sharedTokens = next; - } - // Avg of all pairwise similarities within the cluster - let sum = 0; - let count = 0; - for (let i = 0; i < members.length; i++) { - for (let j = i + 1; j < members.length; j++) { - sum += jaccard(memberSigs[i], memberSigs[j]); - count++; - } - } - const avg = count === 0 ? 0 : sum / count; - groups.push({ - id: rootId, - skills: members.sort((a, b) => a.name.localeCompare(b.name)), - sharedTokens: Array.from(sharedTokens).slice(0, 8), - averageSimilarity: Math.round(avg * 100) / 100, - }); - } - // Sort: highest similarity first, then largest group - groups.sort((x, y) => { - if (y.averageSimilarity !== x.averageSimilarity) { - return y.averageSimilarity - x.averageSimilarity; - } - return y.skills.length - x.skills.length; - }); - return groups; -} diff --git a/dist/server/scanner/symlink.js b/dist/server/scanner/symlink.js deleted file mode 100644 index 95e3048..0000000 --- a/dist/server/scanner/symlink.js +++ /dev/null @@ -1,24 +0,0 @@ -import fs from 'fs/promises'; -export async function resolveSymlink(filePath) { - try { - const stat = await fs.lstat(filePath); - if (stat.isSymbolicLink()) { - const target = await fs.readlink(filePath); - const realPath = await fs.realpath(filePath); - return { isSymlink: true, target, realPath }; - } - return { isSymlink: false, realPath: filePath }; - } - catch { - return { isSymlink: false, realPath: filePath }; - } -} -export function identifySource(realPath, homedir) { - if (realPath.includes('.newmax/skills')) - return 'newmax'; - if (realPath.includes('.agents/skills')) - return 'agents'; - if (realPath.startsWith(homedir)) - return 'local'; - return 'unknown'; -} diff --git a/dist/server/scanner/watcher.js b/dist/server/scanner/watcher.js deleted file mode 100644 index 11e02a7..0000000 --- a/dist/server/scanner/watcher.js +++ /dev/null @@ -1,45 +0,0 @@ -import chokidar from 'chokidar'; -import fs from 'fs'; -import path from 'path'; -import os from 'os'; -import { allAgentGlobalAbsPaths } from './agents.js'; -const homedir = os.homedir(); -let watcher = null; -export function startWatcher(callback) { - if (watcher) - return; - const watchPaths = [ - ...allAgentGlobalAbsPaths(homedir).map((x) => x.path), - path.join(homedir, '.newmax', 'skills'), - ]; - // Only watch paths that exist - const validPaths = watchPaths.filter((p) => { - try { - fs.statSync(p); - return true; - } - catch { - return false; - } - }); - if (validPaths.length === 0) - return; - watcher = chokidar.watch(validPaths, { - depth: 2, - ignoreInitial: true, - persistent: true, - followSymlinks: true, - }); - watcher - .on('add', (p) => callback({ type: 'add', path: p })) - .on('change', (p) => callback({ type: 'change', path: p })) - .on('unlink', (p) => callback({ type: 'unlink', path: p })) - .on('addDir', (p) => callback({ type: 'addDir', path: p })) - .on('unlinkDir', (p) => callback({ type: 'unlinkDir', path: p })); -} -export function stopWatcher() { - if (watcher) { - watcher.close(); - watcher = null; - } -} diff --git a/dist/server/trash/store.js b/dist/server/trash/store.js deleted file mode 100644 index 5adc1a4..0000000 --- a/dist/server/trash/store.js +++ /dev/null @@ -1,335 +0,0 @@ -/** - * Skill Trash (Recycle Bin) - * - * 设计:软删除 → ~/.skill-hub/trash// - * - 普通目录:整个 skill 目录被 rename 进 trash//payload/ - * - 符号链接:不搬运实际文件,只在 meta 里记录 target,还原时重建 symlink - * - meta: trash//.trash-meta.json - * - 过期策略:7 天 TTL,懒清理(列表接口和启动时触发 purge) - */ -import fs from 'fs/promises'; -import path from 'path'; -import os from 'os'; -import crypto from 'crypto'; -function trashRoot() { - // Read HOME lazily so tests can override process.env.HOME between calls. - const home = process.env.HOME || process.env.USERPROFILE || os.homedir(); - return path.join(home, '.skill-hub', 'trash'); -} -export const TRASH_TTL_MS = 7 * 24 * 60 * 60 * 1000; -function newId() { - const now = Date.now().toString(36); - const rand = crypto.randomBytes(4).toString('hex'); - return `${now}-${rand}`; -} -async function ensureRoot() { - await fs.mkdir(trashRoot(), { recursive: true }); -} -function entryDir(id) { - return path.join(trashRoot(), id); -} -function metaPath(id) { - return path.join(entryDir(id), '.trash-meta.json'); -} -function payloadPath(id) { - return path.join(entryDir(id), 'payload'); -} -/** - * Move a directory into the trash (or restore one back) with Windows-aware - * fallbacks. Node's `fs.rename` surfaces EPERM/EBUSY/EACCES on Windows any - * time the source directory contains a file held open by another process — - * Claude Code watching ~/.claude/skills/, OneDrive syncing, antivirus - * scanning, VS Code previewing. These locks are typically transient - * (< 1 second), so we retry with backoff, then fall back to a recursive - * copy + remove which only needs read access on the source and doesn't - * need exclusive handles. - * - * Error messages on final failure include a hint list so Windows users - * don't see a bare `EPERM` with no direction. - */ -const RETRYABLE_RENAME_CODES = new Set(['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY']); -async function sleep(ms) { - return new Promise((r) => setTimeout(r, ms)); -} -function windowsHint() { - if (process.platform !== 'win32') - return ''; - return ('\n\n提示:Windows 上通常是文件被其他进程占用。常见原因:\n' + - ' 1. Claude Code / Codex / Cursor 客户端仍在运行 — 请在任务管理器里彻底退出\n' + - ' 2. VS Code 打开了这个 skill 的文件\n' + - ' 3. OneDrive / 坚果云 正在同步 — 请暂停同步后重试\n' + - ' 4. 360 / 火绒 / Windows Defender 正在扫描目录\n' + - ' 5. Windows Search 索引服务持有文件句柄\n' + - '关闭相关软件后再试一次;如果仍然失败,可以在资源管理器里手动删除。'); -} -async function renameWithFallback(src, dest) { - // Fast path: plain rename with retries for transient Windows locks. - const delays = [0, 80, 200, 450]; // ms; total ~730ms worst case - let lastErr = null; - for (let i = 0; i < delays.length; i++) { - if (delays[i] > 0) - await sleep(delays[i]); - try { - await fs.rename(src, dest); - return; - } - catch (err) { - lastErr = err; - if (err?.code === 'EXDEV') - break; // cross-device — skip retries, go straight to fallback - if (!RETRYABLE_RENAME_CODES.has(err?.code)) - throw err; - } - } - // Fallback: recursive copy + remove. Works across devices and doesn't - // need exclusive access to the source directory. - try { - await copyDir(src, dest); - } - catch (err) { - const base = lastErr?.message || err?.message || 'copy failed'; - throw new Error(`${base}${windowsHint()}`); - } - try { - // maxRetries lets fs.rm itself handle short-lived Windows locks during cleanup - await fs.rm(src, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); - } - catch (err) { - // Source copy succeeded but cleanup failed — surface a specific message so - // the user knows the file was copied (data is safe) but the original - // still exists and needs manual cleanup. - throw new Error(`文件已复制到回收站,但原路径清理失败: ${err?.message || err}\n` + - `源路径:${src}\n` + - `请在资源管理器里手动删除上面这个目录。${windowsHint()}`); - } -} -async function readMeta(id) { - try { - const raw = await fs.readFile(metaPath(id), 'utf-8'); - return JSON.parse(raw); - } - catch { - return null; - } -} -async function writeMeta(meta) { - await fs.writeFile(metaPath(meta.id), JSON.stringify(meta, null, 2), 'utf-8'); -} -async function dirSize(dir) { - let total = 0; - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const e of entries) { - const full = path.join(dir, e.name); - if (e.isDirectory()) { - total += await dirSize(full); - } - else if (e.isFile()) { - try { - const st = await fs.stat(full); - total += st.size; - } - catch { } - } - } - } - catch { } - return total; -} -function inferScope(skillPath) { - const home = os.homedir(); - const globalRoot = path.join(home, '.claude', 'skills'); - if (skillPath.startsWith(globalRoot + path.sep) || skillPath === globalRoot) { - return { scope: 'global' }; - } - // Project skills live at /.claude/skills/ - const marker = path.sep + '.claude' + path.sep + 'skills' + path.sep; - const idx = skillPath.indexOf(marker); - if (idx >= 0) { - return { scope: 'project', projectPath: skillPath.slice(0, idx) }; - } - return { scope: 'unknown' }; -} -/** - * 将 skill 移入回收站。返回 trash 条目元信息。 - */ -export async function moveToTrash(skillPath, skillName) { - await ensureRoot(); - const stat = await fs.lstat(skillPath); - const id = newId(); - const dir = entryDir(id); - await fs.mkdir(dir, { recursive: true }); - const now = new Date(); - const expires = new Date(now.getTime() + TRASH_TTL_MS); - const { scope, projectPath } = inferScope(skillPath); - const name = skillName || path.basename(skillPath); - if (stat.isSymbolicLink()) { - const target = await fs.readlink(skillPath); - // 先写 meta,成功后再 unlink 原链接,避免中途失败丢信息 - const meta = { - id, - skillName: name, - originalPath: skillPath, - scope, - projectPath, - isSymlink: true, - symlinkTarget: target, - deletedAt: now.toISOString(), - expiresAt: expires.toISOString(), - }; - await writeMeta(meta); - await fs.unlink(skillPath); - return meta; - } - // 普通目录:rename 到 payload/。Windows 上需要重试 + cp 兜底(见 renameWithFallback) - const dest = payloadPath(id); - await renameWithFallback(skillPath, dest); - const size = await dirSize(dest).catch(() => 0); - const meta = { - id, - skillName: name, - originalPath: skillPath, - scope, - projectPath, - isSymlink: false, - deletedAt: now.toISOString(), - expiresAt: expires.toISOString(), - sizeBytes: size, - }; - await writeMeta(meta); - return meta; -} -/** - * 列出回收站条目(顺便触发过期清理) - */ -export async function listTrash() { - await ensureRoot(); - await purgeExpired(); - const entries = []; - let dirs = []; - try { - dirs = await fs.readdir(trashRoot()); - } - catch { - return entries; - } - const nowMs = Date.now(); - for (const id of dirs) { - const meta = await readMeta(id); - if (!meta) - continue; - const expiresMs = new Date(meta.expiresAt).getTime(); - const daysRemaining = Math.max(0, Math.ceil((expiresMs - nowMs) / (24 * 60 * 60 * 1000))); - entries.push({ ...meta, daysRemaining }); - } - entries.sort((a, b) => new Date(b.deletedAt).getTime() - new Date(a.deletedAt).getTime()); - return entries; -} -/** - * 还原一个条目到原位置。 - * - 若目标已存在且 force=false → 抛 TrashConflictError - * - 若 force=true → 先把目标移进新 trash 条目(保底),再还原 - */ -export class TrashConflictError extends Error { - targetPath; - constructor(targetPath) { - super(`目标位置已存在:${targetPath}`); - this.targetPath = targetPath; - this.name = 'TrashConflictError'; - } -} -export class TrashNotFoundError extends Error { - constructor(id) { - super(`回收站条目不存在:${id}`); - this.name = 'TrashNotFoundError'; - } -} -export async function restoreFromTrash(id, force = false) { - const meta = await readMeta(id); - if (!meta) - throw new TrashNotFoundError(id); - // 检查目标路径冲突 - let targetExists = false; - try { - await fs.lstat(meta.originalPath); - targetExists = true; - } - catch { } - if (targetExists) { - if (!force) - throw new TrashConflictError(meta.originalPath); - // force:把目标先扔进回收站作为保底 - await moveToTrash(meta.originalPath, meta.skillName); - } - // 确保父目录存在 - await fs.mkdir(path.dirname(meta.originalPath), { recursive: true }); - if (meta.isSymlink && meta.symlinkTarget) { - await fs.symlink(meta.symlinkTarget, meta.originalPath); - } - else { - const src = payloadPath(id); - await renameWithFallback(src, meta.originalPath); - } - // 清理 trash 条目目录 - await fs.rm(entryDir(id), { recursive: true, force: true }); - return meta; -} -/** - * 永久删除一个条目 - */ -export async function purgeOne(id) { - const dir = entryDir(id); - try { - await fs.rm(dir, { recursive: true, force: true }); - return true; - } - catch { - return false; - } -} -/** - * 扫描并删除所有已过期条目 - */ -export async function purgeExpired() { - await ensureRoot(); - let dirs = []; - try { - dirs = await fs.readdir(trashRoot()); - } - catch { - return 0; - } - const now = Date.now(); - let removed = 0; - for (const id of dirs) { - const meta = await readMeta(id); - if (!meta) { - // 坏掉的条目(没 meta)直接清理 - await fs.rm(entryDir(id), { recursive: true, force: true }).catch(() => { }); - continue; - } - if (new Date(meta.expiresAt).getTime() <= now) { - await fs.rm(entryDir(id), { recursive: true, force: true }).catch(() => { }); - removed++; - } - } - return removed; -} -async function copyDir(src, dest) { - await fs.mkdir(dest, { recursive: true }); - const entries = await fs.readdir(src, { withFileTypes: true }); - for (const entry of entries) { - const s = path.join(src, entry.name); - const d = path.join(dest, entry.name); - if (entry.isSymbolicLink()) { - const target = await fs.readlink(s); - await fs.symlink(target, d); - } - else if (entry.isDirectory()) { - await copyDir(s, d); - } - else if (entry.isFile()) { - await fs.copyFile(s, d); - } - } -} diff --git a/dist/server/types.js b/dist/server/types.js deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/server/types.js +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/server/versioning/store.js b/dist/server/versioning/store.js deleted file mode 100644 index 7bbb5cd..0000000 --- a/dist/server/versioning/store.js +++ /dev/null @@ -1,343 +0,0 @@ -/** - * Skill Version Store - * - * 设计思路:类 Git 的轻量级版本管理 - * - 每个 Skill 的版本存储在 ~/.skill-hub/versions// - * - 每个版本是一个 JSON 文件,包含完整快照 + 元数据 - * - 支持:创建快照、查看历史、对比 diff、回滚 - */ -import fs from 'fs/promises'; -import path from 'path'; -import os from 'os'; -import crypto from 'crypto'; -const STORE_ROOT = path.join(os.homedir(), '.skill-hub', 'versions'); -// --- Helpers --- -function skillHash(skillPath) { - return crypto.createHash('md5').update(skillPath).digest('hex').slice(0, 12); -} -function versionId() { - const now = Date.now().toString(36); - const rand = crypto.randomBytes(3).toString('hex'); - return `${now}-${rand}`; -} -function contentHash(content) { - return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16); -} -function storeDir(skillPath) { - return path.join(STORE_ROOT, skillHash(skillPath)); -} -// --- File helpers --- -/** - * 递归读取目录下所有文本文件,key 为相对路径 - */ -async function readDirRecursive(baseDir, currentDir, result) { - let entries; - try { - entries = await fs.readdir(currentDir, { withFileTypes: true }); - } - catch { - return; - } - for (const entry of entries) { - const fullPath = path.join(currentDir, entry.name); - const relPath = path.relative(baseDir, fullPath); - if (entry.isDirectory()) { - // 跳过 .git 等隐藏目录 - if (entry.name.startsWith('.')) - continue; - await readDirRecursive(baseDir, fullPath, result); - } - else if (entry.isFile() && relPath !== 'SKILL.md') { - try { - const buf = await fs.readFile(fullPath); - // 简单判断是否为文本:检查前 8KB 有无 NULL 字节 - const sample = buf.subarray(0, 8192); - if (!sample.includes(0)) { - result[relPath] = buf.toString('utf-8'); - } - } - catch { - // 跳过无法读取的文件 - } - } - } -} -/** - * 递归恢复文件,key 为相对路径(含子目录) - */ -async function restoreFiles(baseDir, files) { - for (const [relPath, fileContent] of Object.entries(files)) { - const fullPath = path.join(baseDir, relPath); - // 确保父目录存在 - await fs.mkdir(path.dirname(fullPath), { recursive: true }); - await fs.writeFile(fullPath, fileContent, 'utf-8'); - } -} -// --- Core API --- -export async function initStore() { - await fs.mkdir(STORE_ROOT, { recursive: true }); -} -/** - * 创建一个版本快照 - */ -export async function createSnapshot(skillPath, skillName, message, source = 'manual') { - await initStore(); - const dir = storeDir(skillPath); - await fs.mkdir(dir, { recursive: true }); - // 读取当前 SKILL.md - let realPath; - try { - realPath = await fs.realpath(skillPath); - } - catch { - realPath = skillPath; - } - const skillMdPath = path.join(realPath, 'SKILL.md'); - let content = ''; - try { - content = await fs.readFile(skillMdPath, 'utf-8'); - } - catch { - throw new Error('SKILL.md not found'); - } - // 递归读取整个目录下所有文本文件 - const files = {}; - await readDirRecursive(realPath, realPath, files); - const cHash = contentHash(content + JSON.stringify(files)); - // 检查是否和最新版本相同(避免重复快照) - const history = await getHistory(skillPath); - if (history.length > 0 && history[0].contentHash === cHash) { - return history[0]; // 内容没变,返回最新版本 - } - const id = versionId(); - const version = { - id, - skillPath, - skillName, - timestamp: new Date().toISOString(), - message, - source, - contentHash: cHash, - content, - files, - }; - await fs.writeFile(path.join(dir, `${id}.json`), JSON.stringify(version, null, 2), 'utf-8'); - return { - id: version.id, - skillPath: version.skillPath, - skillName: version.skillName, - timestamp: version.timestamp, - message: version.message, - source: version.source, - contentHash: version.contentHash, - }; -} -/** - * 获取版本历史(按时间倒序) - */ -export async function getHistory(skillPath) { - const dir = storeDir(skillPath); - let entries; - try { - entries = await fs.readdir(dir); - } - catch { - return []; - } - const versions = []; - for (const file of entries) { - if (!file.endsWith('.json')) - continue; - try { - const raw = await fs.readFile(path.join(dir, file), 'utf-8'); - const v = JSON.parse(raw); - versions.push({ - id: v.id, - skillPath: v.skillPath, - skillName: v.skillName, - timestamp: v.timestamp, - message: v.message, - source: v.source, - contentHash: v.contentHash, - }); - } - catch { } - } - return versions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); -} -/** - * 获取某个版本的完整内容 - */ -export async function getVersion(skillPath, versionId) { - const dir = storeDir(skillPath); - const filePath = path.join(dir, `${versionId}.json`); - try { - const raw = await fs.readFile(filePath, 'utf-8'); - return JSON.parse(raw); - } - catch { - return null; - } -} -/** - * 对比两个版本的 diff - */ -export async function diffVersions(skillPath, oldId, newId) { - const oldVer = await getVersion(skillPath, oldId); - const newVer = await getVersion(skillPath, newId); - if (!oldVer || !newVer) - return null; - const lines = computeDiff(oldVer.content, newVer.content); - const stats = { - additions: lines.filter((l) => l.type === 'add').length, - deletions: lines.filter((l) => l.type === 'remove').length, - unchanged: lines.filter((l) => l.type === 'same').length, - }; - return { - oldVersion: { id: oldVer.id, skillPath: oldVer.skillPath, skillName: oldVer.skillName, timestamp: oldVer.timestamp, message: oldVer.message, source: oldVer.source, contentHash: oldVer.contentHash }, - newVersion: { id: newVer.id, skillPath: newVer.skillPath, skillName: newVer.skillName, timestamp: newVer.timestamp, message: newVer.message, source: newVer.source, contentHash: newVer.contentHash }, - lines, - stats, - }; -} -/** - * 对比当前文件和某个版本的 diff - */ -export async function diffWithCurrent(skillPath, versionId) { - const ver = await getVersion(skillPath, versionId); - if (!ver) - return null; - let realPath; - try { - realPath = await fs.realpath(skillPath); - } - catch { - realPath = skillPath; - } - let currentContent = ''; - try { - currentContent = await fs.readFile(path.join(realPath, 'SKILL.md'), 'utf-8'); - } - catch { - return null; - } - const lines = computeDiff(ver.content, currentContent); - const stats = { - additions: lines.filter((l) => l.type === 'add').length, - deletions: lines.filter((l) => l.type === 'remove').length, - unchanged: lines.filter((l) => l.type === 'same').length, - }; - return { - oldVersion: { id: ver.id, skillPath: ver.skillPath, skillName: ver.skillName, timestamp: ver.timestamp, message: ver.message, source: ver.source, contentHash: ver.contentHash }, - newVersion: { id: 'current', skillPath, skillName: ver.skillName, timestamp: new Date().toISOString(), message: '当前版本', source: 'auto', contentHash: contentHash(currentContent) }, - lines, - stats, - }; -} -/** - * 回滚到指定版本 - */ -export async function rollback(skillPath, versionId) { - const ver = await getVersion(skillPath, versionId); - if (!ver) - return false; - let realPath; - try { - realPath = await fs.realpath(skillPath); - } - catch { - realPath = skillPath; - } - // 先对当前状态创建一个自动快照(回滚前的安全网) - try { - await createSnapshot(skillPath, ver.skillName, `回滚前自动备份 (回滚目标: ${versionId})`, 'auto'); - } - catch { } - // 恢复 SKILL.md - await fs.writeFile(path.join(realPath, 'SKILL.md'), ver.content, 'utf-8'); - // 恢复其他文件(含子目录) - await restoreFiles(realPath, ver.files); - // 创建回滚后的快照 - await createSnapshot(skillPath, ver.skillName, `回滚到版本 ${versionId}`, 'auto'); - return true; -} -/** - * 删除某个版本 - */ -export async function deleteVersion(skillPath, versionId) { - const dir = storeDir(skillPath); - try { - await fs.unlink(path.join(dir, `${versionId}.json`)); - return true; - } - catch { - return false; - } -} -// --- Diff Algorithm (Myers-like simplified) --- -function computeDiff(oldText, newText) { - const oldLines = oldText.split('\n'); - const newLines = newText.split('\n'); - const result = []; - // LCS-based diff - const lcs = longestCommonSubsequence(oldLines, newLines); - let oi = 0; - let ni = 0; - let li = 0; - while (oi < oldLines.length || ni < newLines.length) { - if (li < lcs.length && oi < oldLines.length && ni < newLines.length && oldLines[oi] === lcs[li] && newLines[ni] === lcs[li]) { - result.push({ type: 'same', lineNumber: { old: oi + 1, new: ni + 1 }, content: oldLines[oi] }); - oi++; - ni++; - li++; - } - else if (oi < oldLines.length && (li >= lcs.length || oldLines[oi] !== lcs[li])) { - result.push({ type: 'remove', lineNumber: { old: oi + 1 }, content: oldLines[oi] }); - oi++; - } - else if (ni < newLines.length && (li >= lcs.length || newLines[ni] !== lcs[li])) { - result.push({ type: 'add', lineNumber: { new: ni + 1 }, content: newLines[ni] }); - ni++; - } - } - return result; -} -function longestCommonSubsequence(a, b) { - const m = a.length; - const n = b.length; - // Optimize for large files: limit to first 2000 lines - const maxLen = 2000; - const aa = a.slice(0, maxLen); - const bb = b.slice(0, maxLen); - const mm = aa.length; - const nn = bb.length; - const dp = Array.from({ length: mm + 1 }, () => Array(nn + 1).fill(0)); - for (let i = 1; i <= mm; i++) { - for (let j = 1; j <= nn; j++) { - if (aa[i - 1] === bb[j - 1]) { - dp[i][j] = dp[i - 1][j - 1] + 1; - } - else { - dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); - } - } - } - // Backtrack - const result = []; - let i = mm; - let j = nn; - while (i > 0 && j > 0) { - if (aa[i - 1] === bb[j - 1]) { - result.unshift(aa[i - 1]); - i--; - j--; - } - else if (dp[i - 1][j] > dp[i][j - 1]) { - i--; - } - else { - j--; - } - } - return result; -} diff --git a/dist/web/index.html b/dist/web/index.html deleted file mode 100644 index 9d70b66..0000000 --- a/dist/web/index.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - Skill Hub - - - - - - -
-
-
-
正在加载 Skill Hub...
-
如果这个画面持续不消失,打开 DevTools Console 查看错误,或访问 /api/debug
-
- - - diff --git a/docs/codex-plugin-release.md b/docs/codex-plugin-release.md new file mode 100644 index 0000000..0361a31 --- /dev/null +++ b/docs/codex-plugin-release.md @@ -0,0 +1,46 @@ +# SkillManager Codex Plugin 发布手册 + +## 发布目标 + +`release/codex-marketplace/` 本身就是 Marketplace 根目录: + +- `.agents/plugins/marketplace.json`:Marketplace `backtthefuture`。 +- `plugins/skillmanager/`:可独立运行的插件。 +- `plugins/skillmanager/release-manifest.json`:源码、版本、目标平台、原生二进制与内容哈希证明。 + +插件运行时不依赖源码仓库的 `dist/`、`node_modules/`,也不依赖全局安装的 `skillmanager`。服务端和 MCP 被构建成两个 Node.js bundle;前端作为静态文件随包分发;同一发布物携带 `@napi-rs/keyring` 声明的 12 个官方原生绑定,并在运行时按操作系统、CPU 与 Linux libc 自动选择。 + +## 构建与门禁 + +```bash +npm ci +npm run plugin:pack:verify +python3 /scripts/validate_plugin.py release/codex-marketplace/plugins/skillmanager +``` + +`plugin:pack:verify` 必须在隔离临时目录完成以下检查: + +1. Marketplace、Plugin、Skill 与 MCP 配置合法且互相一致。 +2. MCP 完成初始化、工具发现、`get_status`、`list_skills` 和 `open_dashboard`。 +3. Dashboard 链接只使用 `127.0.0.1`,60 秒过期且只能消费一次。 +4. 本地服务健康、版本一致、可停止;发布物包含全部锁定原生绑定,且当前系统对应的绑定能够真实加载。 +5. 发布物不含软链、开发依赖、源码绝对路径或常见凭据。 + +所有文件系统测试必须设置 `SKILLMANAGER_FIXTURE_ROOT` 和 `SKILLMANAGER_DATA_DIR`,不得扫描或改写真实用户 Skills。 + +## 用户安装路径 + +将 `release/codex-marketplace/` 作为下载目录或独立发布分支的仓库根目录后: + +```bash +codex plugin marketplace add +codex plugin add skillmanager@backtthefuture +``` + +用户需要新建 Codex 对话,再点击推荐提示或输入“打开 SkillManager”。Codex 调用插件 MCP,MCP 启动或复用本地服务,并返回一个可点击的一次性链接;不要引导用户打开任何 `file://` 页面。 + +## 发布边界 + +- 发布物是通用包;源码 CI 在 macOS、Ubuntu、Windows 的 Node.js 20/22 上分别执行同一隔离门禁。FreeBSD、Linux ARM/RISC-V 与 musl 目标只完成锁定制品和哈希的静态校验,不能表述为真实运行验收。 +- 本地验收必须使用测试隔离目录。公开 Git Marketplace 的最终验收可以临时安装远程插件,但要先记录并保留用户现有的个人插件状态。 +- 对外发布时应把 `release/codex-marketplace/` 放在 Marketplace 来源根目录;不要把开发仓库根目录误当成 Marketplace 根目录。 diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..05d11fb --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,33 @@ +import js from '@eslint/js' +import globals from 'globals' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { + ignores: [ + 'dist/**', + 'node_modules/**', + 'release/**', + 'coverage/**', + ], + }, + js.configs.recommended, + tseslint.configs.recommended, + { + languageOptions: { + globals: { + ...globals.node, + ...globals.browser, + }, + }, + rules: { + 'no-empty': ['error', { allowEmptyCatch: true }], + 'no-console': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, + }, +) diff --git a/package-lock.json b/package-lock.json index a83336f..673a2a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,52 +1,63 @@ { - "name": "claude-skill-hub", - "version": "0.3.0", + "name": "skillmanager", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "claude-skill-hub", - "version": "0.3.0", + "name": "skillmanager", + "version": "0.4.1", + "license": "MIT", "dependencies": { - "@fastify/cors": "^11.0.1", - "@fastify/static": "^8.1.0", - "@fastify/websocket": "^11.0.2", + "@fastify/static": "^10.1.2", + "@fastify/websocket": "^11.3.0", + "@modelcontextprotocol/server": "^2.0.0", + "@napi-rs/keyring": "^1.3.0", "chokidar": "^4.0.3", - "fastify": "^5.3.3", - "glob": "^11.0.2", - "gray-matter": "^4.0.3" + "fastify": "^5.11.0", + "gray-matter": "^4.0.3", + "zod": "^4.4.3" }, "bin": { "claude-skill-hub": "bin/cli.js", - "skill-hub": "bin/cli.js" + "skill-hub": "bin/cli.js", + "skillmanager": "bin/cli.js" }, "devDependencies": { - "@tailwindcss/vite": "^4.1.4", - "@types/node": "^22.15.3", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", + "@eslint/js": "^9.39.5", + "@modelcontextprotocol/client": "^2.0.0", + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^22.20.1", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.4.1", - "concurrently": "^9.1.2", + "concurrently": "^9.2.4", + "esbuild": "^0.28.1", + "eslint": "^9.39.5", + "globals": "^17.8.0", "lucide-react": "^0.509.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "tailwindcss": "^4.1.4", - "tsx": "^4.19.4", - "typescript": "^5.8.3", - "vite": "^6.3.4" + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tailwindcss": "^4.3.3", + "tar": "^7.5.22", + "tsx": "^4.23.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.65.0", + "vite": "^6.4.3" }, "engines": { "node": ">=20" } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -55,9 +66,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -65,21 +76,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -96,14 +107,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -113,14 +124,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -130,9 +141,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -140,29 +151,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -182,9 +193,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -192,9 +203,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -202,9 +213,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -212,27 +223,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -274,33 +285,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -308,23 +319,23 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -339,9 +350,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -356,9 +367,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -373,9 +384,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -390,9 +401,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -407,9 +418,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -424,9 +435,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -441,9 +452,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -458,9 +469,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -475,9 +486,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -492,9 +503,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -509,9 +520,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -526,9 +537,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -543,9 +554,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -560,9 +571,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -577,9 +588,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -594,9 +605,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -611,9 +622,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -628,9 +639,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -645,9 +656,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -662,9 +673,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -679,9 +690,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -696,9 +707,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -713,9 +724,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -730,9 +741,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -747,9 +758,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -763,6 +774,279 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@fastify/accept-negotiator": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", @@ -800,26 +1084,6 @@ "fast-uri": "^3.0.0" } }, - "node_modules/@fastify/cors": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", - "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "fastify-plugin": "^5.0.0", - "toad-cache": "^3.7.0" - } - }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -934,9 +1198,9 @@ } }, "node_modules/@fastify/static": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@fastify/static/-/static-8.3.0.tgz", - "integrity": "sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.2.tgz", + "integrity": "sha512-G/g18cG9tLutT/OVyN1AIsHIl9L1UwmJ+S3dkyhVpplIx0nEMicd7RGQ+uJLyhKKF4a3tTcQydccn3Mop1fX+Q==", "funding": [ { "type": "github", @@ -950,17 +1214,35 @@ "license": "MIT", "dependencies": { "@fastify/accept-negotiator": "^2.0.0", + "@fastify/error": "^4.0.0", "@fastify/send": "^4.0.0", - "content-disposition": "^0.5.4", - "fastify-plugin": "^5.0.0", + "content-disposition": "^2.0.1", + "fastify-plugin": "^6.0.0", "fastq": "^1.17.1", - "glob": "^11.0.0" + "glob": "^13.0.0" + } + }, + "node_modules/@fastify/static/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@fastify/websocket": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz", - "integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.0.tgz", + "integrity": "sha512-g89ag4BCcD9YP5wBZXixzoLnuf5j89p/sXFcfpCiv2pdEkYYukBEoK3heVzqsp0EAtszVDc2BBZG0KZqeAShIA==", "funding": [ { "type": "github", @@ -974,17 +1256,87 @@ "license": "MIT", "dependencies": { "duplexify": "^4.1.3", - "fastify-plugin": "^5.0.0", + "fastify-plugin": "^6.0.0", "ws": "^8.16.0" } }, - "node_modules/@isaacs/cliui": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", - "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", - "license": "BlueOak-1.0.0", + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { - "node": ">=18" + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@jridgewell/gen-mapping": { @@ -1046,37 +1398,300 @@ "node": ">=8" } }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT" - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", - "cpu": [ - "arm" - ], + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", "cpu": [ "arm64" ], @@ -1410,49 +2025,49 @@ ] }, "node_modules/@tailwindcss/node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", - "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.2" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", - "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-x64": "4.2.2", - "@tailwindcss/oxide-freebsd-x64": "4.2.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-x64-musl": "4.2.2", - "@tailwindcss/oxide-wasm32-wasi": "4.2.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", - "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], @@ -1467,9 +2082,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", - "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ "arm64" ], @@ -1484,9 +2099,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", - "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -1501,9 +2116,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", - "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], @@ -1518,9 +2133,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", - "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ "arm" ], @@ -1535,9 +2150,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", - "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ "arm64" ], @@ -1552,9 +2167,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", - "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], @@ -1569,9 +2184,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", - "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], @@ -1586,9 +2201,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", - "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], @@ -1603,9 +2218,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", - "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1621,21 +2236,87 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", - "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -1650,9 +2331,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", - "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -1667,15 +2348,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", - "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.2", - "@tailwindcss/oxide": "4.2.2", - "tailwindcss": "4.2.2" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -1733,10 +2414,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { - "version": "22.19.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", - "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1744,9 +2432,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "dev": true, "license": "MIT", "dependencies": { @@ -1754,16 +2442,282 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" } }, - "node_modules/@vitejs/plugin-react": { + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", @@ -1790,6 +2744,29 @@ "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", "license": "MIT" }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -1897,9 +2874,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.17", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", - "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", + "version": "2.11.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.10.tgz", + "integrity": "sha512-35JEvJ5/KKlbCHjMCsONI2w6HE88STjVdHk+C7d8LtcFxUjZR1KeLP9izofn2qs0KUxX5r4z73bwH/rd+JHacw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1910,21 +2887,21 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -1942,10 +2919,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -1955,10 +2932,20 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { - "version": "1.0.30001787", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", - "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "dev": true, "funding": [ { @@ -2021,6 +3008,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -2056,16 +3053,23 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", + "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", "dev": true, "license": "MIT", "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", + "shell-quote": "1.9.0", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" @@ -2082,15 +3086,16 @@ } }, "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/convert-source-map": { @@ -2117,6 +3122,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2152,6 +3158,13 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2193,9 +3206,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.334", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", - "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", + "version": "1.5.399", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", + "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", "dev": true, "license": "ISC" }, @@ -2216,23 +3229,23 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" } }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2243,32 +3256,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -2287,6 +3300,182 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -2300,6 +3489,75 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -2324,6 +3582,13 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-json-stringify": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", @@ -2348,6 +3613,13 @@ "rfdc": "^1.2.0" } }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-querystring": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", @@ -2358,9 +3630,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -2374,9 +3646,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastify": { - "version": "5.8.4", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz", - "integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==", + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.11.0.tgz", + "integrity": "sha512-Y/Ecx1yt0hYzrQR+QVLbxaUN8wCX+MQzMevh29r5RRvlOIArmi4+WAXXaGl5Hw5gBr2wduZpZaT3gRCnXoyVgA==", "funding": [ { "type": "github", @@ -2395,8 +3667,8 @@ "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", - "fast-json-stringify": "^6.0.0", - "find-my-way": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", @@ -2407,9 +3679,9 @@ } }, "node_modules/fastify-plugin": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", - "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", "funding": [ { "type": "github", @@ -2422,6 +3694,46 @@ ], "license": "MIT" }, + "node_modules/fastify/node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fastify/node_modules/fast-uri": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.2.tgz", + "integrity": "sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastify/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2461,10 +3773,23 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/find-my-way": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", - "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -2475,22 +3800,44 @@ "node": ">=20" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": ">=14" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2526,41 +3873,30 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-tsconfig": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "resolve-pkg-maps": "^1.0.0" + "is-glob": "^4.0.3" }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + "engines": { + "node": ">=10.13.0" } }, - "node_modules/glob": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", - "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "BlueOak-1.0.0", - "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, + "node_modules/globals": { + "version": "17.8.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", + "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==", + "dev": true, + "license": "MIT", "engines": { - "node": "20 || >=22" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/graceful-fs": { @@ -2601,18 +3937,55 @@ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" + "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/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">=6" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" } }, "node_modules/inherits": { @@ -2639,6 +4012,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2649,37 +4032,46 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, - "node_modules/jackspeak": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", - "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^9.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.7", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.7.tgz", + "integrity": "sha512-hq1OB1bALKfydZNoViyg6hPVGV4i93ny9Op+n4zP5RSf7SCZEXa/TsG2O3IEr7+WlHRTPnpqDmHfMH6qXAD60w==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2688,9 +4080,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -2713,6 +4105,13 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-ref-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", @@ -2738,6 +4137,13 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2751,6 +4157,16 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -2760,6 +4176,20 @@ "node": ">=0.10.0" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -3058,6 +4488,29 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3124,6 +4577,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3132,9 +4598,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -3150,13 +4616,23 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -3175,16 +4651,84 @@ "wrappy": "1" } }, - "node_modules/package-json-from-dist": { + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=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==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3272,10 +4816,20 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -3293,7 +4847,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3301,6 +4855,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -3317,6 +4881,16 @@ ], "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -3324,9 +4898,9 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "dev": true, "license": "MIT", "engines": { @@ -3334,16 +4908,16 @@ } }, "node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.5" + "react": "^19.2.8" } }, "node_modules/react-refresh": { @@ -3411,14 +4985,14 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + "engines": { + "node": ">=4" } }, "node_modules/ret": { @@ -3522,9 +5096,9 @@ "license": "MIT" }, "node_modules/safe-regex2": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz", - "integrity": "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", "funding": [ { "type": "github", @@ -3614,6 +5188,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -3626,15 +5201,16 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "dev": true, "license": "MIT", "engines": { @@ -3644,18 +5220,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -3751,6 +5315,19 @@ "node": ">=0.10.0" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -3768,16 +5345,16 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -3788,6 +5365,33 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/thread-stream": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", @@ -3845,6 +5449,19 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3853,14 +5470,13 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -3872,6 +5488,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3886,6 +5515,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -3924,6 +5577,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3931,9 +5594,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "dependencies": { @@ -4493,6 +6156,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -4504,6 +6168,16 @@ "node": ">= 8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -4529,9 +6203,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -4594,6 +6268,28 @@ "engines": { "node": ">=12" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index d978072..c453258 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,22 @@ { - "name": "claude-skill-hub", - "version": "0.3.0", - "description": "Claude Code Skill Manager - 一键扫描并管理所有 Claude Agent Skills", + "name": "skillmanager", + "version": "0.4.1", + "description": "SkillManager - local-first management and diagnostics for Agent Skills", + "license": "MIT", "type": "module", "bin": { + "skillmanager": "bin/cli.js", "claude-skill-hub": "bin/cli.js", "skill-hub": "bin/cli.js" }, "files": [ + ".codex-plugin", + ".mcp.json", + "PRIVACY.md", "bin", "dist", + "scripts", + "skills", "server", "web", "tsconfig.json", @@ -20,34 +27,60 @@ "dev": "concurrently \"npm run dev:server\" \"npm run dev:web\"", "dev:server": "tsx watch server/index.ts", "dev:web": "vite", - "build": "vite build && tsc -p tsconfig.server.json --noCheck", - "start": "node dist/server/index.js" + "lint": "eslint .", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.server.json --noEmit", + "test": "node scripts/run-tests.mjs unit", + "test:integration": "node scripts/run-tests.mjs integration", + "build": "npm run typecheck && vite build && tsc -p tsconfig.server.json && node scripts/write-build-manifest.mjs", + "prepack": "npm run build", + "start": "node dist/server/index.js", + "pack:verify": "node scripts/verify-package.mjs", + "plugin:build": "npm run build && node scripts/build-codex-plugin.mjs", + "plugin:pack:verify": "npm run plugin:build && node scripts/verify-codex-plugin.mjs", + "audit:release": "node scripts/audit-release.mjs" }, "dependencies": { - "fastify": "^5.3.3", - "@fastify/static": "^8.1.0", - "@fastify/cors": "^11.0.1", - "@fastify/websocket": "^11.0.2", - "gray-matter": "^4.0.3", + "@fastify/static": "^10.1.2", + "@fastify/websocket": "^11.3.0", + "@modelcontextprotocol/server": "^2.0.0", + "@napi-rs/keyring": "^1.3.0", "chokidar": "^4.0.3", - "glob": "^11.0.2" + "fastify": "^5.11.0", + "gray-matter": "^4.0.3", + "zod": "^4.4.3" }, "devDependencies": { - "typescript": "^5.8.3", - "tsx": "^4.19.4", - "concurrently": "^9.1.2", - "vite": "^6.3.4", + "@eslint/js": "^9.39.5", + "@modelcontextprotocol/client": "^2.0.0", + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^22.20.1", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.4.1", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", - "@types/node": "^22.15.3", - "tailwindcss": "^4.1.4", - "@tailwindcss/vite": "^4.1.4", - "lucide-react": "^0.509.0" + "concurrently": "^9.2.4", + "esbuild": "^0.28.1", + "eslint": "^9.39.5", + "globals": "^17.8.0", + "lucide-react": "^0.509.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tailwindcss": "^4.3.3", + "tar": "^7.5.22", + "tsx": "^4.23.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.65.0", + "vite": "^6.4.3" }, "engines": { "node": ">=20" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Backtthefuture/skillmanager.git" + }, + "homepage": "https://github.com/Backtthefuture/skillmanager#readme", + "bugs": { + "url": "https://github.com/Backtthefuture/skillmanager/issues" } } diff --git a/public-marketplace/.gitattributes b/public-marketplace/.gitattributes new file mode 100644 index 0000000..ca81744 --- /dev/null +++ b/public-marketplace/.gitattributes @@ -0,0 +1,5 @@ +plugins/skillmanager/dist/** -whitespace +plugins/skillmanager/third-party-licenses/** -whitespace +plugins/skillmanager/runtime/** -diff +*.node -diff +*.png -diff diff --git a/public-marketplace/SECURITY.md b/public-marketplace/SECURITY.md new file mode 100644 index 0000000..91b25a1 --- /dev/null +++ b/public-marketplace/SECURITY.md @@ -0,0 +1,11 @@ +# Security policy + +## Supported version + +The latest published SkillManager Plugin version receives security fixes. + +## Reporting a vulnerability + +Please open a minimal GitHub issue asking for a private contact path. Do not post tokens, cookies, private Skill contents, local control credentials, full home-directory paths, or exploit details in a public issue. + +SkillManager binds its Dashboard to `127.0.0.1`, uses short-lived single-use launch links, stores optional GitHub credentials in the operating-system credential store, and requires preview/confirmation for filesystem mutations. A report should state which boundary can be bypassed and include a redacted reproduction. diff --git a/public-marketplace/docs/.nojekyll b/public-marketplace/docs/.nojekyll new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/public-marketplace/docs/.nojekyll @@ -0,0 +1 @@ + diff --git a/public-marketplace/docs/assets/dashboard-convergence.png b/public-marketplace/docs/assets/dashboard-convergence.png new file mode 100644 index 0000000..354e837 Binary files /dev/null and b/public-marketplace/docs/assets/dashboard-convergence.png differ diff --git a/public-marketplace/docs/assets/skillmanager-mark.svg b/public-marketplace/docs/assets/skillmanager-mark.svg new file mode 100644 index 0000000..311b61f --- /dev/null +++ b/public-marketplace/docs/assets/skillmanager-mark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public-marketplace/docs/index.html b/public-marketplace/docs/index.html new file mode 100644 index 0000000..e469a0f --- /dev/null +++ b/public-marketplace/docs/index.html @@ -0,0 +1,58 @@ + + + + + + + SkillManager — One source. Every agent. + + + + +
+ SkillManager + +
+
+
+
+
Codex Plugin · Local-first
+

把散落的 Skills,
收回一个主版本。

+

SkillManager 在 Codex 内盘点本机 Skills,解释它们在哪些 Agent 中可用,并用“预览 → 确认 → 执行 → 回滚”整理重复副本。你的文件仍在你的电脑上。

+ +
+
+
+ +

整理建议

可回滚
+
Svideo-transcript3 处安装重复副本
+
Sskill-creator2 个 Agent共享主版本
+
Sdaily-reviewCodex + Claude Code已收敛
+
+
+
+
+
+

不是“一键乱改”,
而是四步可核对。

默认小白模式只说人话:哪个是主版本、哪些是重复副本、整理后会影响谁。真正写入前必须生成计划,检查文件是否变化,并保留恢复入口。

+
+
01 / DISCOVER

发现

扫描已存在的 Agent 目录,不把“支持某 Agent”误说成“已安装该 Agent”。

+
02 / PREVIEW

预览

列出唯一主版本、将替换的副本和将建立的共享链接。

+
03 / APPLY

确认

只有用户确认后才执行,并用陈旧状态检查阻止误覆盖。

+
04 / ROLLBACK

回滚

保留快照或废纸篓记录,整理不满意可以恢复。

+
+
+
+

在 Codex 里说人话,
在本机完成整理。

这是隔离演示数据上的真实 SkillManager Dashboard,不是概念图。简单模式会把“可以安全整理”和“需要你决定”分开,默认不覆盖有分歧的副本。

+
SkillManager Skill 整理中心真实界面
+

REAL PRODUCT UI / ISOLATED FIXTURE DATA / NO PERSONAL SKILLS

+
+
+
Public Git Marketplace

两条命令,装进 Codex。

要求 Node.js 20 或更高版本。安装完成后新建 Codex 任务,输入“打开 SkillManager”。不要再打开本地 file:// 页面。

+
TERMINAL
$codex plugin marketplace add Backtthefuture/skillmanager-plugin
$codex plugin add skillmanager@backtthefuture
+
12个锁定的原生钥匙串目标
3 × 2macOS / Ubuntu / Windows,Node 20 / 22 CI
127.0.0.1Dashboard 只监听本机回环地址
+

本地优先,不等于含糊其辞。

SkillManager 不运行托管数据收集服务。只有你主动使用 GitHub 同步时才访问 GitHub;令牌存进系统凭据库,本地配置只保留引用。

阅读隐私说明
+
+ + + + diff --git a/public-marketplace/docs/privacy.html b/public-marketplace/docs/privacy.html new file mode 100644 index 0000000..1313cef --- /dev/null +++ b/public-marketplace/docs/privacy.html @@ -0,0 +1,14 @@ + +隐私说明 — SkillManager +
+ ← 返回 SkillManager +

隐私说明

生效日期:2026 年 8 月 2 日 · 适用于 SkillManager v0.4.1

+
一句话版本:SkillManager 是本地优先软件,不运行托管数据收集服务;网络访问只发生在你主动调用的功能中。
+

1. 本机读取的数据

SkillManager 会读取你授权范围内的 Agent Skill 目录,用于识别名称、描述、文件结构、安装位置、重复关系和软链状态。MCP 默认只返回必要的元数据与诊断摘要,不返回 Skill 全文、绝对主目录路径、本地控制凭据或会话 Cookie。

+

2. 本地保存的数据

配置、操作计划、恢复快照和废纸篓记录保存在你的电脑上。Dashboard 只监听 127.0.0.1;打开链接带有短时、单次使用的本地会话保护。

+

3. 凭据

如果你配置 GitHub 同步,GitHub Token 会写入操作系统凭据库;普通配置文件只保存凭据引用。诊断输出会排除 Token、Cookie 和本地控制密钥。

+

4. 网络访问

盘点和本地整理不需要访问 SkillManager 托管服务器,因为不存在该类服务器。只有你主动使用 GitHub 仓库检查、上传或下载时,软件才会连接 GitHub API。GitHub 对数据的处理受其自身政策约束。

+

5. 遥测与广告

v0.4.1 不包含产品分析 SDK、广告 SDK 或后台遥测上报。公开网页由 GitHub Pages 托管,访问网页时 GitHub 可能按其政策处理常规访问日志;这与本地插件运行相互独立。

+

6. 删除与控制

你可以停止本地服务、删除插件、移除本地配置和恢复记录,或撤销 GitHub Token。SkillManager 不持有可替你删除的云端用户数据库。

+

7. 联系

隐私问题请通过 公开仓库 Issues 提交;请先删去路径、Token、Cookie、私有 Skill 正文和其他敏感信息。

+
diff --git a/public-marketplace/docs/site.js b/public-marketplace/docs/site.js new file mode 100644 index 0000000..9596316 --- /dev/null +++ b/public-marketplace/docs/site.js @@ -0,0 +1,27 @@ +document.querySelectorAll('[data-copy]').forEach((button) => { + button.addEventListener('click', async () => { + const target = document.querySelector(button.dataset.copy) + if (!target) return + const text = Array.from(target.querySelectorAll('.command span:last-child')) + .map((element) => element.textContent.trim()) + .join('\n') + let copied = false + try { + await navigator.clipboard.writeText(text) + copied = true + } catch { + const textarea = document.createElement('textarea') + textarea.value = text + textarea.setAttribute('readonly', '') + textarea.style.position = 'fixed' + textarea.style.opacity = '0' + document.body.appendChild(textarea) + textarea.select() + copied = document.execCommand('copy') + textarea.remove() + } + const previous = button.textContent + button.textContent = copied ? '已复制' : '请手动复制' + window.setTimeout(() => { button.textContent = previous }, 1400) + }) +}) diff --git a/public-marketplace/docs/styles.css b/public-marketplace/docs/styles.css new file mode 100644 index 0000000..134d3bf --- /dev/null +++ b/public-marketplace/docs/styles.css @@ -0,0 +1,21 @@ +:root{--ink:#0b1020;--paper:#f6f3ea;--yellow:#facc15;--muted:#62697a;--line:rgba(11,16,32,.14);--white:#fff;--green:#31c48d} +*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink);font-family:"Avenir Next","Segoe UI","Helvetica Neue",Arial,sans-serif;-webkit-font-smoothing:antialiased} +a{color:inherit}.shell{width:min(1180px,calc(100% - 40px));margin-inline:auto}.topbar{display:flex;align-items:center;gap:28px;height:84px;border-bottom:1px solid var(--line)} +.brand{display:flex;align-items:center;gap:12px;text-decoration:none;font-weight:800;letter-spacing:-.03em}.brand img{width:36px;height:36px}.nav{display:flex;align-items:center;gap:24px;margin-left:auto;font-size:14px}.nav a{text-decoration:none;color:var(--muted)}.nav a:hover{color:var(--ink)} +.button{display:inline-flex;align-items:center;justify-content:center;min-height:46px;padding:0 18px;border:1px solid var(--ink);border-radius:999px;background:var(--ink);color:white;text-decoration:none;font-weight:750}.button.alt{background:transparent;color:var(--ink)} +.hero{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(380px,.9fr);gap:76px;align-items:center;padding:94px 0 86px}.kicker,.mono{font-family:ui-monospace,"SFMono-Regular",Consolas,monospace}.kicker{display:flex;align-items:center;gap:10px;font-size:12px;font-weight:800;letter-spacing:.14em;text-transform:uppercase}.kicker::before{content:"";width:28px;height:8px;border-radius:99px;background:var(--yellow)} +h1{margin:22px 0 24px;font:800 clamp(52px,7.2vw,92px)/.93 Georgia,"Times New Roman",serif;letter-spacing:-.065em}.hero-copy{max-width:640px;color:var(--muted);font-size:19px;line-height:1.7}.actions{display:flex;flex-wrap:wrap;gap:12px;margin-top:34px} +.stage{position:relative;padding:18px;border:1px solid rgba(255,255,255,.12);border-radius:30px;background:var(--ink);color:white;box-shadow:0 28px 80px rgba(11,16,32,.22)}.stage::after{content:"LOCAL ONLY";position:absolute;right:24px;top:24px;color:var(--yellow);font:800 10px/1 ui-monospace,monospace;letter-spacing:.12em} +.window{overflow:hidden;border-radius:18px;background:#11182d}.window-top{display:flex;align-items:center;gap:7px;height:48px;padding:0 17px;border-bottom:1px solid rgba(255,255,255,.08)}.window-top i{width:8px;height:8px;border-radius:50%;background:#303a55}.window-top i:first-child{background:var(--yellow)} +.window-body{display:grid;grid-template-columns:112px 1fr;min-height:380px}.rail{padding:20px 12px;background:#0d1427}.rail span{display:block;margin-bottom:7px;padding:10px;border-radius:9px;color:#8790aa;font-size:11px}.rail span.active{background:rgba(250,204,21,.13);color:var(--yellow)} +.panel{padding:27px 24px}.panel-head{display:flex;justify-content:space-between;align-items:center}.panel h3{margin:0;font-size:22px}.status{padding:6px 9px;border:1px solid rgba(49,196,141,.35);border-radius:999px;color:var(--green);font-size:10px}.skill-row{display:grid;grid-template-columns:32px 1fr auto;gap:12px;align-items:center;margin-top:18px;padding:14px;border:1px solid rgba(255,255,255,.08);border-radius:13px}.skill-icon{display:grid;place-items:center;width:32px;height:32px;border-radius:9px;background:var(--yellow);color:var(--ink);font-weight:900}.skill-row strong{display:block;font-size:13px}.skill-row small{color:#7d879f}.link-state{color:#a7b0c3;font:10px/1.3 ui-monospace,monospace}.link-state.good{color:var(--green)} +.section{padding:92px 0;border-top:1px solid var(--line)}.section-head{display:grid;grid-template-columns:.8fr 1.2fr;gap:48px;margin-bottom:44px}.section h2,.legal h1{margin:0;font:800 clamp(38px,5vw,64px)/1 Georgia,serif;letter-spacing:-.045em}.section-head p{margin:0;color:var(--muted);font-size:18px;line-height:1.7} +.steps{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.step{min-height:250px;padding:24px;border:1px solid var(--line);border-radius:20px;background:rgba(255,255,255,.48)}.step .num{color:#9a7b00;font:800 12px/1 ui-monospace,monospace}.step h3{margin:58px 0 10px;font-size:22px}.step p{margin:0;color:var(--muted);line-height:1.55} +.install{display:grid;grid-template-columns:.72fr 1.28fr;gap:44px;align-items:start}.install h2{font-size:52px}.code-stack{overflow:hidden;border-radius:22px;background:var(--ink);color:white}.code-head{display:flex;align-items:center;justify-content:space-between;padding:15px 18px;border-bottom:1px solid rgba(255,255,255,.1);color:#8993ac;font:11px/1 ui-monospace,monospace}.copy{border:0;background:none;color:var(--yellow);font:inherit;cursor:pointer}.command{display:flex;gap:14px;padding:22px 20px;border-bottom:1px solid rgba(255,255,255,.08);font:13px/1.7 ui-monospace,monospace;overflow:auto}.command:last-child{border:0}.prompt{color:var(--yellow)} +.proof{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:46px}.proof article{padding:22px;border-left:3px solid var(--yellow);background:rgba(255,255,255,.45)}.proof strong{display:block;font-size:30px}.proof span{color:var(--muted)} +.product-frame{overflow:hidden;padding:12px;border-radius:28px;background:var(--ink);box-shadow:0 24px 70px rgba(11,16,32,.18)}.product-frame img{display:block;width:100%;height:auto;border-radius:17px}.caption{margin-top:14px;color:var(--muted);font:12px/1.5 ui-monospace,monospace} +.privacy-band{margin:0 auto 90px;padding:38px;border-radius:26px;background:var(--yellow);display:grid;grid-template-columns:1fr auto;gap:30px;align-items:center}.privacy-band h2{margin:0 0 8px;font:800 36px/1.05 Georgia,serif}.privacy-band p{margin:0;max-width:720px;line-height:1.6}.privacy-band .button{background:var(--ink)} +footer{display:flex;gap:24px;align-items:center;padding:28px 0 42px;border-top:1px solid var(--line);color:var(--muted);font-size:13px}footer .brand{color:var(--ink);margin-right:auto}footer a{text-decoration:none} +.legal{width:min(800px,calc(100% - 40px));margin:0 auto;padding:80px 0 110px}.legal .back{display:inline-block;margin-bottom:52px;color:var(--muted);text-decoration:none}.legal .meta{margin:20px 0 58px;color:var(--muted)}.legal h2{margin:45px 0 12px;font-size:24px}.legal p,.legal li{color:#4f5668;line-height:1.75}.legal code{padding:2px 6px;border-radius:6px;background:rgba(11,16,32,.07);font-family:ui-monospace,monospace}.legal pre{overflow:auto;padding:20px;border-radius:15px;background:var(--ink);color:white;line-height:1.6}.notice{margin:34px 0;padding:22px;border-left:4px solid var(--yellow);background:white} +@media(max-width:900px){.hero{grid-template-columns:1fr;gap:48px;padding-top:64px}.stage{max-width:620px}.section-head,.install{grid-template-columns:1fr}.steps{grid-template-columns:1fr 1fr}.proof{grid-template-columns:1fr}.nav a:not(.button){display:none}} +@media(max-width:560px){.shell{width:min(100% - 24px,1180px)}.topbar{height:70px}.nav{gap:8px}.hero{padding:48px 0 62px}.hero-copy{font-size:17px}.window-body{grid-template-columns:82px 1fr}.rail{padding:14px 7px}.rail span{padding:8px 6px}.panel{padding:20px 12px}.skill-row{grid-template-columns:28px 1fr}.link-state{display:none}.steps{grid-template-columns:1fr}.step{min-height:200px}.step h3{margin-top:35px}.privacy-band{grid-template-columns:1fr;padding:26px}.section{padding:64px 0}footer{flex-wrap:wrap}footer .brand{width:100%}} diff --git a/public-marketplace/docs/support.html b/public-marketplace/docs/support.html new file mode 100644 index 0000000..11d94a7 --- /dev/null +++ b/public-marketplace/docs/support.html @@ -0,0 +1,22 @@ + +支持 — SkillManager +
+ ← 返回 SkillManager +

支持与排错

SkillManager Public Git Marketplace · v0.4.1

+

安装

+
codex plugin marketplace add Backtthefuture/skillmanager-plugin
+codex plugin add skillmanager@backtthefuture
+

安装后请新建 Codex 任务,输入“打开 SkillManager”。插件会返回一个 60 秒内有效、只能使用一次的本地链接。

+

一直加载或打开了 file://

+
  1. 关闭旧的 file://.../web/index.html 标签页。
  2. 在 Codex 新任务中输入“打开 SkillManager”。
  3. 确认返回地址以 http://127.0.0.1: 开头。
  4. 仍失败时,让 Codex“诊断 SkillManager 本地服务”,不要公开粘贴 Cookie 或控制凭据。
+

确认安装状态

+
codex plugin marketplace list --json
+codex plugin list --json
+

如果仓库有新版本,先执行 Marketplace 更新,再重新安装对应插件版本:

+
codex plugin marketplace upgrade backtthefuture
+

安装后图标为空

+
  1. 升级 Marketplace,并重新安装 SkillManager。
  2. 完全退出并重新打开 Codex,再新建一个任务。
  3. 仍为空时,确认安装版本至少为 0.4.1;该版本同时声明 Plugin 与 Skill 两级图标。
+

提交问题

请到 skillmanager-plugin Issues。建议附上操作系统、CPU、Node.js 版本、Codex 版本、复现步骤和已脱敏的错误信息。

+
发布前请脱敏:删除 Token、Cookie、私有 Skill 正文、用户名、完整主目录路径和私有仓库地址。
+

源码与许可证

开发源码位于 Backtthefuture/skillmanager,发布仓库只保存经验证的通用 Plugin 包。许可证为 MIT。

+
diff --git a/public-marketplace/docs/terms.html b/public-marketplace/docs/terms.html new file mode 100644 index 0000000..79a99d5 --- /dev/null +++ b/public-marketplace/docs/terms.html @@ -0,0 +1,14 @@ + +使用条款 — SkillManager +
+ ← 返回 SkillManager +

使用条款

生效日期:2026 年 8 月 2 日 · 适用于 SkillManager v0.4.1

+

1. 软件许可

SkillManager 以 MIT License 发布。你可以在该许可证范围内使用、复制、修改和分发软件;源码仓库中的 LICENSE 是权威文本。

+

2. 本地操作责任

SkillManager 会在你确认后修改、移动或链接本机 Skill 文件。软件提供预览、陈旧状态检查和恢复机制,但你仍应核对操作范围,并对重要目录保留独立备份。

+

3. 可用性

软件按“现状”提供,不承诺无错误、不间断或适合所有 Agent、文件系统和组织策略。预览结果是基于扫描时状态生成的;外部程序同时改动文件可能使计划失效。

+

4. 第三方服务

可选 GitHub 同步依赖 GitHub 服务、API、账户权限与平台政策。SkillManager 不控制第三方服务的可用性、价格或数据处理。

+

5. 禁止滥用

你不得使用 SkillManager 未经授权访问、复制或修改他人的文件、仓库或凭据,也不得绕过系统和组织的访问控制。

+

6. 责任限制

在适用法律允许的范围内,作者不对因使用或无法使用本软件产生的间接、附带、特殊或后果性损失承担责任。MIT License 中的免责声明仍然适用。

+

7. 更新

功能与条款可能随新版本调整。页面会标出生效日期;继续使用更新版本前,请检查对应发布说明。

+

8. 支持

请通过 支持页 排查问题或提交 Issue。不要在公开 Issue 中粘贴凭据、私有仓库内容或完整本机路径。

+
diff --git a/release/claude-skill-hub.tgz b/release/claude-skill-hub.tgz deleted file mode 100644 index 548e1a6..0000000 Binary files a/release/claude-skill-hub.tgz and /dev/null differ diff --git a/scripts/audit-release.mjs b/scripts/audit-release.mjs new file mode 100644 index 0000000..fb500be --- /dev/null +++ b/scripts/audit-release.mjs @@ -0,0 +1,37 @@ +import { spawnSync } from 'child_process' +import path from 'path' +import { fileURLToPath } from 'url' +import { npmInvocation } from './npm-invocation.mjs' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(scriptDir, '..') +const invocation = npmInvocation(['audit', '--json', '--audit-level=high']) +const result = spawnSync( + invocation.command, + invocation.args, + { cwd: repoRoot, encoding: 'utf8' }, +) + +if (result.error) throw result.error +let report +try { + report = JSON.parse(result.stdout) +} catch { + console.error(result.stdout) + console.error(result.stderr) + throw new Error('npm audit did not return valid JSON') +} + +const counts = report.metadata?.vulnerabilities || {} +const blocking = (counts.high || 0) + (counts.critical || 0) +const directBlocking = Object.values(report.vulnerabilities || {}).filter( + (item) => item.isDirect && (item.severity === 'high' || item.severity === 'critical'), +) + +console.log(JSON.stringify({ + ok: blocking === 0 && directBlocking.length === 0, + vulnerabilities: counts, + directBlocking: directBlocking.map((item) => item.name), +}, null, 2)) + +if (blocking > 0 || directBlocking.length > 0) process.exit(1) diff --git a/scripts/build-codex-plugin.mjs b/scripts/build-codex-plugin.mjs new file mode 100644 index 0000000..70f9a69 --- /dev/null +++ b/scripts/build-codex-plugin.mjs @@ -0,0 +1,536 @@ +import crypto from 'node:crypto' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { builtinModules } from 'node:module' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' +import { x as extractTar } from 'tar' +import { KEYRING_TARGETS } from './keyring-targets.mjs' +import { npmInvocation } from './npm-invocation.mjs' + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(scriptDirectory, '..') +const releaseRoot = path.join(repoRoot, 'release', 'codex-marketplace') +const pluginRoot = path.join(releaseRoot, 'plugins', 'skillmanager') +const marketplacePath = path.join(releaseRoot, '.agents', 'plugins', 'marketplace.json') + +function assertSafeReleaseRoot() { + const expected = path.join(repoRoot, 'release', 'codex-marketplace') + if (releaseRoot !== expected || !releaseRoot.startsWith(path.join(repoRoot, 'release') + path.sep)) { + throw new Error(`Refusing to clean unexpected release path: ${releaseRoot}`) + } +} + +async function readJson(target) { + return JSON.parse(await fs.readFile(target, 'utf8')) +} + +async function exists(target) { + try { + await fs.access(target) + return true + } catch { + return false + } +} + +async function walkFiles(root, base = root) { + const entries = await fs.readdir(root, { withFileTypes: true }) + const files = [] + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const absolute = path.join(root, entry.name) + if (entry.isSymbolicLink()) throw new Error(`Release source contains a symlink: ${absolute}`) + if (entry.isDirectory()) files.push(...(await walkFiles(absolute, base))) + if (entry.isFile()) files.push(path.relative(base, absolute).replaceAll(path.sep, '/')) + } + return files +} + +async function hashFile(target) { + return crypto.createHash('sha256').update(await fs.readFile(target)).digest('hex') +} + +async function contentInventory(root, excluded = new Set()) { + const files = (await walkFiles(root)).filter((relative) => !excluded.has(relative)) + const digest = crypto.createHash('sha256') + let totalBytes = 0 + const hashes = {} + for (const relative of files) { + const content = await fs.readFile(path.join(root, relative)) + totalBytes += content.length + hashes[relative] = crypto.createHash('sha256').update(content).digest('hex') + digest.update(relative) + digest.update('\0') + digest.update(content) + digest.update('\0') + } + return { + fileCount: files.length, + totalBytes, + sha256: digest.digest('hex'), + hashes, + } +} + +function runNpmPack(specs, destination) { + const invocation = npmInvocation([ + 'pack', + '--json', + '--ignore-scripts', + '--pack-destination', + destination, + ...specs, + ]) + const result = spawnSync(invocation.command, invocation.args, { + cwd: repoRoot, + encoding: 'utf8', + stdio: 'pipe', + }) + if (result.error) throw result.error + if (result.status !== 0) { + throw new Error(`npm pack failed (${result.status})\n${result.stdout || ''}\n${result.stderr || ''}`) + } + try { + return JSON.parse(result.stdout) + } catch { + throw new Error(`npm pack did not return JSON:\n${result.stdout}`) + } +} + +async function writeNativeKeyringBindings(outputRoot) { + const packageRoot = path.join(repoRoot, 'node_modules', '@napi-rs', 'keyring') + const packageJson = await readJson(path.join(packageRoot, 'package.json')) + const lock = await readJson(path.join(repoRoot, 'package-lock.json')) + const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-keyring-pack-')) + const descriptors = [] + try { + const specs = KEYRING_TARGETS.map(({ packageName }) => { + const expectedVersion = packageJson.optionalDependencies?.[packageName] + if (!expectedVersion) throw new Error(`Keyring loader does not declare ${packageName}`) + return `${packageName}@${expectedVersion}` + }) + const packs = runNpmPack(specs, temporaryRoot) + const packsByName = new Map(packs.map((item) => [item.name, item])) + + await fs.mkdir(outputRoot, { recursive: true }) + for (const target of KEYRING_TARGETS) { + const lockMetadata = lock.packages?.[`node_modules/${target.packageName}`] + const packed = packsByName.get(target.packageName) + if (!lockMetadata?.version || !lockMetadata.integrity || !packed) { + throw new Error(`Missing locked package metadata for ${target.packageName}`) + } + if (packed.version !== lockMetadata.version || packed.integrity !== lockMetadata.integrity) { + throw new Error(`Registry artifact does not match package-lock.json for ${target.packageName}`) + } + const extractionRoot = path.join(temporaryRoot, target.key) + await fs.mkdir(extractionRoot, { recursive: true }) + await extractTar({ + file: path.join(temporaryRoot, packed.filename), + cwd: extractionRoot, + strip: 1, + }) + const nativePackage = await readJson(path.join(extractionRoot, 'package.json')) + if (nativePackage.name !== target.packageName || nativePackage.version !== lockMetadata.version) { + throw new Error(`Extracted metadata mismatch for ${target.packageName}`) + } + const nativeFiles = (await walkFiles(extractionRoot)).filter((relative) => relative.endsWith('.node')) + if (nativeFiles.length !== 1) { + throw new Error(`Expected one native binary in ${target.packageName}; found ${nativeFiles.length}`) + } + const filename = path.basename(nativeFiles[0]) + const destination = path.join(outputRoot, filename) + await fs.copyFile(path.join(extractionRoot, nativeFiles[0]), destination) + descriptors.push({ + key: target.key, + path: `runtime/keyring/${filename}`, + sha256: await hashFile(destination), + package: target.packageName, + packageVersion: nativePackage.version, + integrity: packed.integrity, + license: nativePackage.license || packageJson.license || 'UNKNOWN', + }) + } + } finally { + await fs.rm(temporaryRoot, { recursive: true, force: true }) + } + return { bindings: descriptors, loaderVersion: packageJson.version } +} + +function keyringShim(relativeBindingPaths) { + return { + name: 'skillmanager-native-keyring', + setup(context) { + context.onResolve({ filter: /^@napi-rs\/keyring$/ }, () => ({ + path: 'skillmanager-native-keyring', + namespace: 'skillmanager-release', + })) + context.onLoad( + { filter: /^skillmanager-native-keyring$/, namespace: 'skillmanager-release' }, + () => ({ + loader: 'js', + contents: [ + "import { createRequire } from 'node:module'", + "import { readFileSync } from 'node:fs'", + 'const nativeRequire = createRequire(import.meta.url)', + `const bindingPaths = ${JSON.stringify(relativeBindingPaths)}`, + "function isMusl() {", + " if (process.platform !== 'linux') return false", + " try { return readFileSync('/usr/bin/ldd', 'utf8').includes('musl') } catch {}", + " try {", + " if (typeof process.report?.getReport === 'function') {", + " process.report.excludeNetwork = true", + " const report = process.report.getReport()", + " if (report?.header?.glibcVersionRuntime) return false", + " if (Array.isArray(report?.sharedObjects)) return report.sharedObjects.some((entry) => entry.includes('libc.musl-') || entry.includes('ld-musl-'))", + " }", + " } catch {}", + " return false", + "}", + "function targetKey() {", + " if (process.platform === 'darwin' && ['arm64', 'x64'].includes(process.arch)) return `darwin-${process.arch}`", + " if (process.platform === 'win32' && ['arm64', 'ia32', 'x64'].includes(process.arch)) return `win32-${process.arch}-msvc`", + " if (process.platform === 'freebsd' && process.arch === 'x64') return 'freebsd-x64'", + " if (process.platform === 'linux') {", + " if (process.arch === 'arm') return 'linux-arm-gnueabihf'", + " if (process.arch === 'riscv64') return 'linux-riscv64-gnu'", + " if (['arm64', 'x64'].includes(process.arch)) return `linux-${process.arch}-${isMusl() ? 'musl' : 'gnu'}`", + " }", + " throw new Error(`Unsupported SkillManager keyring target: ${process.platform}-${process.arch}`)", + "}", + "const selectedTarget = targetKey()", + "const selectedPath = bindingPaths[selectedTarget]", + "if (!selectedPath) throw new Error(`SkillManager release is missing keyring binding for ${selectedTarget}`)", + 'const binding = nativeRequire(selectedPath)', + 'export const Entry = binding.Entry', + ].join('\n'), + }), + ) + }, + } +} + +async function bundleEntry(entryPoint, outfile, relativeBindingPaths) { + const result = await build({ + entryPoints: [entryPoint], + outfile, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node20', + legalComments: 'external', + metafile: true, + logLevel: 'info', + banner: { + js: 'import { createRequire as __skillmanagerCreateRequire } from "node:module"; const require = __skillmanagerCreateRequire(import.meta.url);', + }, + plugins: [keyringShim(relativeBindingPaths)], + }) + const unexpectedExternal = Object.values(result.metafile.outputs) + .flatMap((output) => output.imports) + .filter((item) => item.external) + .filter((item) => ( + !item.path.startsWith('node:') && + !builtinModules.includes(item.path) && + !['bufferutil', 'utf-8-validate'].includes(item.path) + )) + if (unexpectedExternal.length > 0) { + throw new Error(`Bundled entry has unexpected external imports: ${unexpectedExternal.map((item) => item.path).join(', ')}`) + } +} + +function packageNameFromLockPath(lockPath) { + const marker = 'node_modules/' + const index = lockPath.lastIndexOf(marker) + return index === -1 ? lockPath : lockPath.slice(index + marker.length) +} + +function safeLicenseDirectory(name, version) { + return `${name.replace(/^@/, '').replaceAll('/', '__')}@${version || 'unknown'}` +} + +async function writeThirdPartyLicenses(nativeBindings) { + const lock = await readJson(path.join(repoRoot, 'package-lock.json')) + const outputRoot = path.join(pluginRoot, 'third-party-licenses') + const notices = [] + const noticed = new Set() + await fs.mkdir(outputRoot, { recursive: true }) + + for (const [lockPath, metadata] of Object.entries(lock.packages || {})) { + if (!lockPath.includes('node_modules/') || metadata.dev === true) continue + const sourceRoot = path.join(repoRoot, lockPath) + if (!(await exists(sourceRoot))) continue + const name = metadata.name || packageNameFromLockPath(lockPath) + const version = metadata.version || 'unknown' + const license = metadata.license || 'SEE PACKAGE' + const destination = path.join(outputRoot, safeLicenseDirectory(name, version)) + const entries = await fs.readdir(sourceRoot, { withFileTypes: true }) + const licenseFiles = entries + .filter((entry) => entry.isFile() && /^(licen[cs]e|copying|notice)(\..*)?$/i.test(entry.name)) + .map((entry) => entry.name) + .sort() + if (licenseFiles.length > 0) { + await fs.mkdir(destination, { recursive: true }) + for (const filename of licenseFiles) { + await fs.copyFile(path.join(sourceRoot, filename), path.join(destination, filename)) + } + } + notices.push(`- ${name}@${version} — ${license}${licenseFiles.length ? ` — ${licenseFiles.join(', ')}` : ''}`) + noticed.add(`${name}@${version}`) + } + + for (const binding of nativeBindings) { + const id = `${binding.package}@${binding.packageVersion}` + if (!noticed.has(id)) notices.push(`- ${id} — ${binding.license}`) + } + + notices.sort() + await fs.writeFile( + path.join(pluginRoot, 'THIRD_PARTY_NOTICES.md'), + [ + '# Third-party notices', + '', + 'SkillManager 的发布 bundle 包含下列生产依赖。对应许可证文本位于 `third-party-licenses/`。', + '', + ...notices, + '', + ].join('\n'), + ) +} + +async function writeJson(target, value) { + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(target, JSON.stringify(value, null, 2) + '\n') +} + +assertSafeReleaseRoot() + +const sourcePackage = await readJson(path.join(repoRoot, 'package.json')) +const sourcePlugin = await readJson(path.join(repoRoot, '.codex-plugin', 'plugin.json')) +const sourceBuild = await readJson(path.join(repoRoot, 'dist', 'build-manifest.json')) +if (sourceBuild.packageName !== sourcePackage.name || sourceBuild.version !== sourcePackage.version) { + throw new Error('Run `npm run build` before building the Codex Plugin release') +} +if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(sourcePlugin.version)) { + throw new Error('Public Plugin version must be stable SemVer without build metadata') +} +if (sourcePlugin.version !== sourcePackage.version) { + throw new Error('Plugin version must match package.json') +} + +await fs.rm(releaseRoot, { recursive: true, force: true }) +await fs.mkdir(pluginRoot, { recursive: true }) + +for (const [source, destination] of [ + ['.codex-plugin', '.codex-plugin'], + ['assets', 'assets'], + ['skills', 'skills'], + ['dist/web', 'dist/web'], +]) { + await fs.cp(path.join(repoRoot, source), path.join(pluginRoot, destination), { + recursive: true, + dereference: false, + }) +} +for (const relative of [ + '.mcp.json', + 'LICENSE', + 'PRIVACY.md', + 'bin/cli.js', + 'bin/skillmanager-mcp.js', +]) { + const destination = path.join(pluginRoot, relative) + await fs.mkdir(path.dirname(destination), { recursive: true }) + await fs.copyFile(path.join(repoRoot, relative), destination) +} +await fs.chmod(path.join(pluginRoot, 'bin', 'cli.js'), 0o755) +await fs.chmod(path.join(pluginRoot, 'bin', 'skillmanager-mcp.js'), 0o755) + +const runtimeKeyringRoot = path.join(pluginRoot, 'runtime', 'keyring') +const nativeRuntime = await writeNativeKeyringBindings(runtimeKeyringRoot) +const bindingPathsForServer = Object.fromEntries(nativeRuntime.bindings.map((binding) => [ + binding.key, + `../../${binding.path}`, +])) +const bindingPathsForMcp = Object.fromEntries(nativeRuntime.bindings.map((binding) => [ + binding.key, + `../../../${binding.path}`, +])) + +await bundleEntry( + path.join(repoRoot, 'server', 'index.ts'), + path.join(pluginRoot, 'dist', 'server', 'index.js'), + bindingPathsForServer, +) +await bundleEntry( + path.join(repoRoot, 'server', 'mcp', 'index.ts'), + path.join(pluginRoot, 'dist', 'server', 'mcp', 'index.js'), + bindingPathsForMcp, +) + +const runtimePackage = { + name: sourcePackage.name, + version: sourcePlugin.version, + description: sourcePackage.description, + license: sourcePackage.license, + type: 'module', + private: true, + bin: sourcePackage.bin, + engines: sourcePackage.engines, + repository: sourcePackage.repository, + homepage: sourcePackage.homepage, +} +await writeJson(path.join(pluginRoot, 'package.json'), runtimePackage) +await writeThirdPartyLicenses(nativeRuntime.bindings) + +const distInventory = await contentInventory(path.join(pluginRoot, 'dist')) +await writeJson(path.join(pluginRoot, 'dist', 'build-manifest.json'), { + schemaVersion: 2, + packageName: sourcePackage.name, + version: sourcePlugin.version, + sourcePackageVersion: sourcePackage.version, + gitCommit: sourceBuild.gitCommit, + sourceDirty: sourceBuild.sourceDirty, + sourceTreeHash: sourceBuild.sourceTreeHash, + target: { + kind: 'universal-node20', + supported: KEYRING_TARGETS.map((target) => target.key), + builtOn: `${process.platform}-${process.arch}`, + node: process.versions.node, + }, + artifacts: distInventory.hashes, +}) + +await writeJson(marketplacePath, { + name: 'backtthefuture', + interface: { + displayName: 'Backtthefuture', + }, + plugins: [{ + name: 'skillmanager', + source: { + source: 'local', + path: './plugins/skillmanager', + }, + policy: { + installation: 'AVAILABLE', + authentication: 'ON_INSTALL', + }, + category: 'Productivity', + }], +}) + +await fs.cp( + path.join(repoRoot, 'public-marketplace', 'docs'), + path.join(releaseRoot, 'docs'), + { recursive: true, dereference: false }, +) +await fs.copyFile(path.join(repoRoot, 'LICENSE'), path.join(releaseRoot, 'LICENSE')) +await fs.copyFile( + path.join(repoRoot, 'public-marketplace', '.gitattributes'), + path.join(releaseRoot, '.gitattributes'), +) +await fs.copyFile( + path.join(repoRoot, 'public-marketplace', 'SECURITY.md'), + path.join(releaseRoot, 'SECURITY.md'), +) + +await fs.writeFile( + path.join(releaseRoot, 'README.md'), + [ + '

SkillManager

', + '

SkillManager for Codex

', + '', + '

把散落在不同 Agent 目录里的 Skills,安全整理成一个可追踪的主版本。

', + '', + '## 安装', + '', + '```bash', + 'codex plugin marketplace add Backtthefuture/skillmanager-plugin', + 'codex plugin add skillmanager@backtthefuture', + '```', + '', + '安装后新建 Codex 任务,输入 **“打开 SkillManager”**。插件会启动或复用只监听 `127.0.0.1` 的本地服务,并返回 60 秒内有效、仅可使用一次的安全链接。不要打开源码中的 `file://web/index.html`。', + '', + '## 能做什么', + '', + '- 区分“Agent 已安装”和“某个 Skill 支持该 Agent”,避免虚假的安装数量。', + '- 发现重复副本,选择唯一主版本,把其他 Agent 位置整理为共享链接。', + '- 所有写入遵循预览、确认、陈旧状态检查、恢复快照或废纸篓记录。', + '- MCP 只暴露诊断和打开入口,不默认返回 Skill 正文或本机绝对路径。', + '', + '## 平台', + '', + '发布物要求 Node.js 20+,内含 `@napi-rs/keyring` 锁定的 12 个官方目标:macOS ARM/Intel、Windows ARM64/x64/ia32、Linux x64/ARM64 的 glibc/musl、Linux ARM/RISC-V 与 FreeBSD x64。源码 CI 在 macOS、Ubuntu、Windows 的 Node.js 20/22 上分别运行隔离验收;其余目标为制品完整性静态校验。', + '', + '## 安全与隐私', + '', + '- Dashboard 仅监听 `127.0.0.1`。', + '- GitHub 同步是可选功能,只有用户主动预览并确认后执行。', + '- GitHub Token 存放在操作系统凭据库,本地配置只保存引用。', + '- SkillManager 不运行托管数据收集服务。', + '', + '[产品页](https://backtthefuture.github.io/skillmanager-plugin/) · [隐私说明](https://backtthefuture.github.io/skillmanager-plugin/privacy.html) · [使用条款](https://backtthefuture.github.io/skillmanager-plugin/terms.html) · [支持](https://backtthefuture.github.io/skillmanager-plugin/support.html)', + '', + '## 开发与验证', + '', + '开发源码位于 [Backtthefuture/skillmanager](https://github.com/Backtthefuture/skillmanager)。本仓库是经验证的 Public Git Marketplace 发布物。发布证明见 `plugins/skillmanager/release-manifest.json`。', + '', + '同一发布物内含 macOS、Windows、Linux 与 FreeBSD 的官方钥匙串绑定,启动时会自动选择。源码 CI 会在 macOS、Ubuntu、Windows 的 Node.js 20/22 上分别执行隔离验收。', + '', + '## License', + '', + 'MIT', + '', + ].join('\n'), +) + +const releaseContents = await contentInventory(pluginRoot, new Set(['release-manifest.json'])) +await writeJson(path.join(pluginRoot, 'release-manifest.json'), { + schemaVersion: 1, + marketplace: 'backtthefuture', + plugin: 'skillmanager', + version: sourcePlugin.version, + builtAt: new Date().toISOString(), + source: { + packageVersion: sourcePackage.version, + gitCommit: sourceBuild.gitCommit, + dirty: sourceBuild.sourceDirty, + treeHash: sourceBuild.sourceTreeHash, + }, + target: { + kind: 'universal-node20', + supported: KEYRING_TARGETS.map((target) => target.key), + builtOn: `${process.platform}-${process.arch}`, + node: process.versions.node, + }, + runtime: { + strategy: 'esbuild-bundle-with-universal-native-keyring', + nodeModulesIncluded: false, + loaderVersion: nativeRuntime.loaderVersion, + bindings: Object.fromEntries(nativeRuntime.bindings.map((binding) => [binding.key, binding])), + }, + security: { + bindHost: '127.0.0.1', + launchTtlSeconds: 60, + launchSingleUse: true, + secretsIncluded: false, + }, + contents: { + fileCount: releaseContents.fileCount, + totalBytes: releaseContents.totalBytes, + sha256: releaseContents.sha256, + }, +}) + +console.log(JSON.stringify({ + ok: true, + marketplace: marketplacePath, + plugin: pluginRoot, + version: sourcePlugin.version, + target: 'universal-node20', + nativeTargets: nativeRuntime.bindings.length, + bytes: releaseContents.totalBytes, + files: releaseContents.fileCount + 1, +}, null, 2)) diff --git a/scripts/keyring-targets.mjs b/scripts/keyring-targets.mjs new file mode 100644 index 0000000..72433c8 --- /dev/null +++ b/scripts/keyring-targets.mjs @@ -0,0 +1,57 @@ +import fs from 'node:fs' + +export const KEYRING_TARGETS = Object.freeze([ + { key: 'darwin-arm64', packageName: '@napi-rs/keyring-darwin-arm64' }, + { key: 'darwin-x64', packageName: '@napi-rs/keyring-darwin-x64' }, + { key: 'freebsd-x64', packageName: '@napi-rs/keyring-freebsd-x64' }, + { key: 'linux-arm-gnueabihf', packageName: '@napi-rs/keyring-linux-arm-gnueabihf' }, + { key: 'linux-arm64-gnu', packageName: '@napi-rs/keyring-linux-arm64-gnu' }, + { key: 'linux-arm64-musl', packageName: '@napi-rs/keyring-linux-arm64-musl' }, + { key: 'linux-riscv64-gnu', packageName: '@napi-rs/keyring-linux-riscv64-gnu' }, + { key: 'linux-x64-gnu', packageName: '@napi-rs/keyring-linux-x64-gnu' }, + { key: 'linux-x64-musl', packageName: '@napi-rs/keyring-linux-x64-musl' }, + { key: 'win32-arm64-msvc', packageName: '@napi-rs/keyring-win32-arm64-msvc' }, + { key: 'win32-ia32-msvc', packageName: '@napi-rs/keyring-win32-ia32-msvc' }, + { key: 'win32-x64-msvc', packageName: '@napi-rs/keyring-win32-x64-msvc' }, +]) + +function reportSuggestsMusl() { + try { + if (typeof process.report?.getReport !== 'function') return null + process.report.excludeNetwork = true + const report = process.report.getReport() + if (report?.header?.glibcVersionRuntime) return false + if (Array.isArray(report?.sharedObjects)) { + return report.sharedObjects.some((entry) => entry.includes('libc.musl-') || entry.includes('ld-musl-')) + } + } catch {} + return null +} + +export function isMuslRuntime() { + if (process.platform !== 'linux') return false + try { + return fs.readFileSync('/usr/bin/ldd', 'utf8').includes('musl') + } catch {} + return reportSuggestsMusl() ?? false +} + +export function currentKeyringTarget() { + if (process.platform === 'darwin' && ['arm64', 'x64'].includes(process.arch)) { + return `darwin-${process.arch}` + } + if (process.platform === 'win32' && ['arm64', 'ia32', 'x64'].includes(process.arch)) { + return `win32-${process.arch}-msvc` + } + if (process.platform === 'freebsd' && process.arch === 'x64') { + return 'freebsd-x64' + } + if (process.platform === 'linux') { + if (process.arch === 'arm') return 'linux-arm-gnueabihf' + if (process.arch === 'riscv64') return 'linux-riscv64-gnu' + if (['arm64', 'x64'].includes(process.arch)) { + return `linux-${process.arch}-${isMuslRuntime() ? 'musl' : 'gnu'}` + } + } + throw new Error(`Unsupported SkillManager keyring target: ${process.platform}-${process.arch}`) +} diff --git a/scripts/npm-invocation.d.mts b/scripts/npm-invocation.d.mts new file mode 100644 index 0000000..0885593 --- /dev/null +++ b/scripts/npm-invocation.d.mts @@ -0,0 +1,15 @@ +export interface NpmInvocationOptions { + npmExecPath?: string | null + nodeExecPath?: string + platform?: NodeJS.Platform +} + +export interface NpmInvocation { + command: string + args: string[] +} + +export declare function npmInvocation( + args: string[], + options?: NpmInvocationOptions, +): NpmInvocation diff --git a/scripts/npm-invocation.mjs b/scripts/npm-invocation.mjs new file mode 100644 index 0000000..d38af2f --- /dev/null +++ b/scripts/npm-invocation.mjs @@ -0,0 +1,20 @@ +export function npmInvocation(args, options = {}) { + const npmExecPath = Object.hasOwn(options, 'npmExecPath') + ? options.npmExecPath + : process.env.npm_execpath + const nodeExecPath = options.nodeExecPath || process.execPath + const platform = options.platform || process.platform + + if (typeof npmExecPath === 'string' && npmExecPath.trim()) { + return { + command: nodeExecPath, + args: [npmExecPath.trim(), ...args], + } + } + + if (platform === 'win32') { + throw new Error('npm_execpath is required on Windows; run this verifier through an npm script') + } + + return { command: 'npm', args } +} diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 0000000..27e7c01 --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,45 @@ +import fs from 'fs/promises' +import path from 'path' +import { spawnSync } from 'child_process' +import { fileURLToPath } from 'url' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(scriptDir, '..') +const suite = process.argv[2] + +if (suite !== 'unit' && suite !== 'integration') { + console.error('Usage: node scripts/run-tests.mjs ') + process.exit(2) +} + +const suiteRoot = path.join(repoRoot, 'tests', suite) + +async function collect(dir) { + const result = [] + const entries = await fs.readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + const target = path.join(dir, entry.name) + if (entry.isDirectory()) result.push(...(await collect(target))) + if (entry.isFile() && /\.test\.(?:ts|js|mjs)$/.test(entry.name)) result.push(target) + } + return result +} + +const files = (await collect(suiteRoot)).sort() +if (files.length === 0) { + console.error(`No ${suite} test files found in ${suiteRoot}`) + process.exit(1) +} + +const child = spawnSync( + process.execPath, + ['--import', 'tsx', '--test', ...files], + { + cwd: repoRoot, + env: { ...process.env, SKILLMANAGER_TEST_MODE: '1' }, + stdio: 'inherit', + }, +) + +if (child.error) throw child.error +process.exit(child.status ?? 1) diff --git a/scripts/verify-codex-plugin.mjs b/scripts/verify-codex-plugin.mjs new file mode 100644 index 0000000..74ec5a1 --- /dev/null +++ b/scripts/verify-codex-plugin.mjs @@ -0,0 +1,381 @@ +import crypto from 'node:crypto' +import fs from 'node:fs/promises' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { Client } from '@modelcontextprotocol/client' +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio' +import { KEYRING_TARGETS, currentKeyringTarget } from './keyring-targets.mjs' + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(scriptDirectory, '..') +const releaseRoot = path.join(repoRoot, 'release', 'codex-marketplace') +const sourcePluginRoot = path.join(releaseRoot, 'plugins', 'skillmanager') +const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-plugin-verify-')) + +async function readJson(target) { + return JSON.parse(await fs.readFile(target, 'utf8')) +} + +async function walk(root, base = root) { + const result = [] + const entries = await fs.readdir(root, { withFileTypes: true }) + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const absolute = path.join(root, entry.name) + const relative = path.relative(base, absolute).replaceAll(path.sep, '/') + const stat = await fs.lstat(absolute) + if (stat.isSymbolicLink()) throw new Error(`Release contains a symlink: ${relative}`) + if (stat.isDirectory()) result.push(...(await walk(absolute, base))) + if (stat.isFile()) result.push(relative) + } + return result +} + +async function inventory(root, excluded = new Set()) { + const files = (await walk(root)).filter((relative) => !excluded.has(relative)) + const digest = crypto.createHash('sha256') + let totalBytes = 0 + for (const relative of files) { + const content = await fs.readFile(path.join(root, relative)) + totalBytes += content.length + digest.update(relative) + digest.update('\0') + digest.update(content) + digest.update('\0') + } + return { fileCount: files.length, totalBytes, sha256: digest.digest('hex') } +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: 'utf8', + stdio: options.capture === false ? 'inherit' : 'pipe', + ...options, + }) + if (result.error) throw result.error + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed (${result.status})\n${result.stdout || ''}\n${result.stderr || ''}`) + } + return result +} + +async function freePort() { + const server = net.createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + const port = typeof address === 'object' && address ? address.port : 0 + await new Promise((resolve) => server.close(resolve)) + return port +} + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function quotedYamlValue(content, key) { + const match = content.match(new RegExp(`^\\s*${key}:\\s*"([^"]+)"\\s*$`, 'm')) + return match?.[1] ?? null +} + +function parseCliJson(result) { + try { + return JSON.parse(result.stdout.trim()) + } catch { + throw new Error(`CLI did not return JSON: ${result.stdout}`) + } +} + +async function auditTextFiles(pluginRoot, files) { + const textExtensions = new Set(['.css', '.html', '.js', '.json', '.md', '.txt', '.yaml', '.yml']) + const forbiddenSecrets = [ + /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/, + /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/, + /\bsk-[A-Za-z0-9_-]{24,}\b/, + /\bAKIA[0-9A-Z]{16}\b/, + ] + for (const relative of files) { + if (!textExtensions.has(path.extname(relative).toLowerCase())) continue + const content = await fs.readFile(path.join(pluginRoot, relative), 'utf8') + assert(!content.includes(repoRoot), `Release leaks its source repository path in ${relative}`) + assert(!/file:\/\/\/(?:Users|home|private|var\/folders)\//.test(content), `Release embeds a local file URL in ${relative}`) + for (const pattern of forbiddenSecrets) { + assert(!pattern.test(content), `Release may contain a secret in ${relative}`) + } + } +} + +async function assertPng(relative, expectedWidth, expectedHeight) { + const content = await fs.readFile(path.join(sourcePluginRoot, relative)) + const signature = content.subarray(0, 8).toString('hex') + assert(signature === '89504e470d0a1a0a', `${relative} is not a PNG file`) + assert(content.readUInt32BE(16) === expectedWidth, `${relative} width is invalid`) + assert(content.readUInt32BE(20) === expectedHeight, `${relative} height is invalid`) +} + +const marketplace = await readJson(path.join(releaseRoot, '.agents', 'plugins', 'marketplace.json')) +assert(marketplace.name === 'backtthefuture', 'Marketplace name must be backtthefuture') +assert(marketplace.interface?.displayName === 'Backtthefuture', 'Marketplace display name is missing') +assert(Array.isArray(marketplace.plugins) && marketplace.plugins.length === 1, 'Marketplace must contain exactly one plugin') +const marketplacePlugin = marketplace.plugins[0] +assert(marketplacePlugin.name === 'skillmanager', 'Marketplace plugin name is invalid') +assert(marketplacePlugin.source?.source === 'local', 'Marketplace source must be local') +assert(marketplacePlugin.source?.path === './plugins/skillmanager', 'Marketplace plugin path is invalid') +assert(marketplacePlugin.policy?.installation === 'AVAILABLE', 'Marketplace installation policy is invalid') +assert(marketplacePlugin.policy?.authentication === 'ON_INSTALL', 'Marketplace authentication policy is invalid') +assert(marketplacePlugin.category === 'Productivity', 'Marketplace category is invalid') + +const pluginManifest = await readJson(path.join(sourcePluginRoot, '.codex-plugin', 'plugin.json')) +assert(pluginManifest.name === 'skillmanager', 'Plugin manifest name is invalid') +assert(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(pluginManifest.version), 'Plugin version must be stable SemVer') +assert(Array.isArray(pluginManifest.interface?.defaultPrompt), 'Plugin prompts are missing') +assert(pluginManifest.interface.defaultPrompt.every((prompt) => /[\u3400-\u9fff]/.test(prompt)), 'Every default prompt must be Chinese') +assert(pluginManifest.skills === './skills/', 'Plugin Skill path is invalid') +assert(pluginManifest.mcpServers === './.mcp.json', 'Plugin MCP path is invalid') +assert(JSON.stringify(pluginManifest.interface?.screenshots) === JSON.stringify(['./assets/screenshots/dashboard-convergence.png']), 'Plugin screenshot metadata is invalid') + +const skillRoot = path.join(sourcePluginRoot, 'skills', 'skillmanager') +const skillInterface = await fs.readFile(path.join(skillRoot, 'agents', 'openai.yaml'), 'utf8') +const skillIconSmall = quotedYamlValue(skillInterface, 'icon_small') +const skillIconLarge = quotedYamlValue(skillInterface, 'icon_large') +assert(skillIconSmall === './assets/icon.png', 'Skill small icon metadata is invalid') +assert(skillIconLarge === './assets/logo.png', 'Skill large icon metadata is invalid') +assert(quotedYamlValue(skillInterface, 'brand_color') === '#FACC15', 'Skill brand color is invalid') +assert(quotedYamlValue(skillInterface, 'default_prompt')?.includes('$skillmanager'), 'Skill default prompt must mention $skillmanager') +for (const [label, relative] of [['small', skillIconSmall], ['large', skillIconLarge]]) { + const absolute = path.resolve(skillRoot, relative) + assert(absolute.startsWith(`${skillRoot}${path.sep}`), `Skill ${label} icon escapes the Skill root`) + assert((await fs.stat(absolute)).isFile(), `Skill ${label} icon does not resolve to a file`) +} +assert( + (await fs.readFile(path.join(skillRoot, 'assets', 'icon.png'))).equals(await fs.readFile(path.join(sourcePluginRoot, 'assets', 'icon.png'))), + 'Skill small icon differs from the Plugin icon', +) +assert( + (await fs.readFile(path.join(skillRoot, 'assets', 'logo.png'))).equals(await fs.readFile(path.join(sourcePluginRoot, 'assets', 'logo.png'))), + 'Skill large icon differs from the Plugin logo', +) + +const mcpConfig = await readJson(path.join(sourcePluginRoot, '.mcp.json')) +assert(mcpConfig.mcpServers?.skillmanager?.command === './bin/skillmanager-mcp.js', 'MCP command must be plugin-relative') +assert(mcpConfig.mcpServers?.skillmanager?.cwd === '.', 'MCP cwd must be plugin-relative') + +const runtimePackage = await readJson(path.join(sourcePluginRoot, 'package.json')) +assert(runtimePackage.version === pluginManifest.version, 'Runtime and plugin versions differ') +assert(runtimePackage.devDependencies === undefined, 'Release package must not include devDependencies') +assert(runtimePackage.dependencies === undefined, 'Bundled release must not require npm dependencies') + +const releaseManifest = await readJson(path.join(sourcePluginRoot, 'release-manifest.json')) +assert(releaseManifest.marketplace === 'backtthefuture', 'Release provenance marketplace is invalid') +assert(releaseManifest.version === pluginManifest.version, 'Release provenance version is invalid') +assert(releaseManifest.target?.kind === 'universal-node20', 'Release target must be universal Node.js 20+') +const expectedTargets = KEYRING_TARGETS.map((target) => target.key) +assert(JSON.stringify(releaseManifest.target?.supported) === JSON.stringify(expectedTargets), 'Release target matrix is incomplete') +assert(releaseManifest.runtime?.nodeModulesIncluded === false, 'Release must not include node_modules') +assert(releaseManifest.security?.bindHost === '127.0.0.1', 'Release bind policy is invalid') +assert(releaseManifest.security?.launchTtlSeconds === 60, 'Release launch TTL is invalid') +assert(releaseManifest.security?.launchSingleUse === true, 'Release launch links must be single-use') + +const sourceFiles = await walk(sourcePluginRoot) +assert(!sourceFiles.some((relative) => relative.startsWith('node_modules/')), 'Release includes node_modules') +assert(!sourceFiles.some((relative) => relative.startsWith('server/') || relative.startsWith('web/')), 'Release includes development source trees') +for (const required of [ + '.codex-plugin/plugin.json', + '.mcp.json', + 'assets/icon.png', + 'assets/logo.png', + 'assets/logo-dark.png', + 'assets/screenshots/dashboard-convergence.png', + 'skills/skillmanager/SKILL.md', + 'skills/skillmanager/agents/openai.yaml', + 'skills/skillmanager/assets/icon.png', + 'skills/skillmanager/assets/logo.png', + 'bin/cli.js', + 'bin/skillmanager-mcp.js', + 'dist/server/index.js', + 'dist/server/mcp/index.js', + 'dist/web/index.html', + 'dist/build-manifest.json', + 'THIRD_PARTY_NOTICES.md', + 'release-manifest.json', +]) { + assert(sourceFiles.includes(required), `Release is missing ${required}`) +} +assert(releaseManifest.contents.totalBytes < 40 * 1024 * 1024, 'Release runtime exceeds the 40 MB budget') +const currentInventory = await inventory(sourcePluginRoot, new Set(['release-manifest.json'])) +assert(currentInventory.fileCount === releaseManifest.contents.fileCount, 'Release file count does not match provenance') +assert(currentInventory.totalBytes === releaseManifest.contents.totalBytes, 'Release byte count does not match provenance') +assert(currentInventory.sha256 === releaseManifest.contents.sha256, 'Release content hash does not match provenance') +await auditTextFiles(sourcePluginRoot, sourceFiles) +await assertPng('assets/icon.png', 256, 256) +await assertPng('assets/logo.png', 1024, 1024) +await assertPng('assets/logo-dark.png', 1024, 1024) +await assertPng('assets/screenshots/dashboard-convergence.png', 1274, 717) + +const sourceLock = await readJson(path.join(repoRoot, 'package-lock.json')) +const bindings = releaseManifest.runtime?.bindings +assert(bindings && typeof bindings === 'object', 'Release native binding map is missing') +assert(JSON.stringify(Object.keys(bindings)) === JSON.stringify(expectedTargets), 'Release native binding map is incomplete') +for (const target of KEYRING_TARGETS) { + const binding = bindings[target.key] + assert(binding?.key === target.key, `Release binding identity mismatch for ${target.key}`) + assert(binding?.package === target.packageName, `Release package mismatch for ${target.key}`) + assert(sourceFiles.includes(binding.path), `Release native keyring binding is missing for ${target.key}`) + const nativeAbsolute = path.join(sourcePluginRoot, binding.path) + const nativeHash = crypto.createHash('sha256').update(await fs.readFile(nativeAbsolute)).digest('hex') + assert(nativeHash === binding.sha256, `Native keyring hash mismatch for ${target.key}`) + const lockMetadata = sourceLock.packages?.[`node_modules/${target.packageName}`] + assert(lockMetadata?.version === binding.packageVersion, `Locked native version mismatch for ${target.key}`) + assert(lockMetadata?.integrity === binding.integrity, `Locked native integrity mismatch for ${target.key}`) +} + +const currentNativeTarget = currentKeyringTarget() +const currentBinding = bindings[currentNativeTarget] +assert(currentBinding, `Release lacks the current native target ${currentNativeTarget}`) +const nativeAbsolute = path.join(sourcePluginRoot, currentBinding.path) +run(process.execPath, [ + '--input-type=module', + '--eval', + `import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const binding = require(${JSON.stringify(nativeAbsolute)}); if (typeof binding.Entry !== 'function') process.exit(1)`, +], { cwd: temporaryRoot }) + +const isolatedMarketplace = path.join(temporaryRoot, 'marketplace') +await fs.cp(releaseRoot, isolatedMarketplace, { recursive: true, dereference: false }) +const isolatedPlugin = path.join(isolatedMarketplace, 'plugins', 'skillmanager') +const fixtureRoot = path.join(temporaryRoot, 'fixture') +const dataDirectory = path.join(temporaryRoot, 'data') +await fs.mkdir(path.join(fixtureRoot, '.agents', 'skills', 'release-fixture'), { recursive: true }) +await fs.writeFile( + path.join(fixtureRoot, '.agents', 'skills', 'release-fixture', 'SKILL.md'), + '---\nname: Release Fixture\ndescription: Use when validating the isolated Codex Plugin release.\n---\nPRIVATE_RELEASE_BODY\n', +) +const port = await freePort() +const environment = Object.fromEntries( + Object.entries({ + ...process.env, + SKILLMANAGER_FIXTURE_ROOT: fixtureRoot, + SKILLMANAGER_DATA_DIR: dataDirectory, + SKILLMANAGER_PORT: String(port), + SKILLMANAGER_NO_OPEN: '1', + SKILL_HUB_NO_OPEN: '1', + SKILLMANAGER_TEST_MODE: '1', + }).filter((entry) => typeof entry[1] === 'string'), +) + +let client = null +let lifecycleStarted = false +try { + const transport = new StdioClientTransport({ + command: process.execPath, + args: [path.join(isolatedPlugin, 'bin', 'skillmanager-mcp.js')], + cwd: isolatedPlugin, + env: environment, + stderr: 'pipe', + }) + client = new Client({ name: 'skillmanager-codex-plugin-verifier', version: '1.0.0' }) + await client.connect(transport) + const tools = await client.listTools() + const expectedTools = [ + 'diagnose_library', + 'diagnose_skill', + 'get_deep_link', + 'get_skill_summary', + 'get_status', + 'list_skills', + 'open_dashboard', + ] + assert(JSON.stringify(tools.tools.map((tool) => tool.name).sort()) === JSON.stringify(expectedTools), 'Isolated MCP tool list is incomplete') + + const status = await client.callTool({ + name: 'get_status', + arguments: { response_format: 'json' }, + }) + assert(status.structuredContent?.status === 'stopped', 'Isolated MCP status should initially be stopped') + + const listing = await client.callTool({ + name: 'list_skills', + arguments: { agent: 'codex', limit: 10, offset: 0, response_format: 'json' }, + }) + const listingText = JSON.stringify(listing.structuredContent) + assert(!listing.isError && listingText.includes('Release Fixture'), 'Isolated MCP could not scan its fixture') + assert(!listingText.includes(fixtureRoot) && !listingText.includes('PRIVATE_RELEASE_BODY'), 'Isolated MCP leaked private fixture data') + + const opened = await client.callTool({ + name: 'open_dashboard', + arguments: { view: 'skills', response_format: 'json' }, + }) + lifecycleStarted = true + assert(!opened.isError, 'Isolated MCP open_dashboard failed') + const openedOutput = opened.structuredContent + assert(openedOutput.expiresInSeconds === 60, 'Isolated launch TTL is invalid') + assert(openedOutput.target === '/?view=skills', 'Isolated launch target is invalid') + const launchUrl = new URL(openedOutput.launchUrl) + assert(launchUrl.protocol === 'http:' && launchUrl.hostname === '127.0.0.1', 'Launch URL is not loopback-only HTTP') + const resourceLink = opened.content.find((item) => item.type === 'resource_link') + assert(resourceLink?.uri === openedOutput.launchUrl, 'MCP response lacks a matching clickable resource link') + + const launch = await fetch(openedOutput.launchUrl, { redirect: 'manual' }) + assert(launch.status === 303, 'One-time launch did not redirect') + assert(launch.headers.get('location') === '/?view=skills', 'One-time launch redirected to an unexpected target') + assert(/HttpOnly/i.test(launch.headers.get('set-cookie') || ''), 'One-time launch did not set an HttpOnly session') + const replay = await fetch(openedOutput.launchUrl, { redirect: 'manual' }) + assert(replay.status === 410, 'One-time launch could be replayed') + + const healthResponse = await fetch(`http://127.0.0.1:${port}/api/v1/health`) + const health = await healthResponse.json() + assert(healthResponse.ok && health.status === 'ok' && health.product === 'SkillManager', 'Isolated release health check failed') + assert(health.version === pluginManifest.version, 'Isolated runtime version does not match plugin version') + + const runningStatus = await client.callTool({ + name: 'get_status', + arguments: { response_format: 'json' }, + }) + assert(runningStatus.structuredContent?.status === 'running', 'MCP did not reuse the isolated runtime') + + const doctor = parseCliJson(run( + process.execPath, + [path.join(isolatedPlugin, 'bin', 'cli.js'), 'doctor', '--json'], + { cwd: isolatedPlugin, env: environment }, + )) + assert(doctor.ok === true, 'Isolated release doctor reported a failure') + + const stopped = parseCliJson(run( + process.execPath, + [path.join(isolatedPlugin, 'bin', 'cli.js'), 'stop', '--json'], + { cwd: isolatedPlugin, env: environment }, + )) + lifecycleStarted = false + assert(stopped.status === 'stopped' && stopped.stopped === true, 'Isolated release did not stop cleanly') + + console.log(JSON.stringify({ + ok: true, + marketplace: 'backtthefuture', + plugin: `skillmanager@${pluginManifest.version}`, + releaseBytes: releaseManifest.contents.totalBytes, + sourcePathLeak: false, + secretLeak: false, + symlinks: false, + developmentDependencies: false, + nativeKeyring: `loadable:${currentNativeTarget}`, + packagedNativeTargets: expectedTargets.length, + mcpInitialize: 'verified', + mcpTools: 'verified', + lifecycle: 'verified', + launchRedirect: 'single-use', + health: 'verified', + }, null, 2)) +} finally { + if (client) await client.close().catch(() => {}) + if (lifecycleStarted) { + spawnSync(process.execPath, [path.join(isolatedPlugin, 'bin', 'cli.js'), 'stop', '--json'], { + cwd: isolatedPlugin, + env: environment, + stdio: 'ignore', + }) + } + await fs.rm(temporaryRoot, { recursive: true, force: true }) +} diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..18ef402 --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,246 @@ +import fs from 'fs/promises' +import path from 'path' +import os from 'os' +import net from 'net' +import crypto from 'crypto' +import { spawnSync } from 'child_process' +import { fileURLToPath } from 'url' +import { Client } from '@modelcontextprotocol/client' +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio' +import { npmInvocation } from './npm-invocation.mjs' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(scriptDir, '..') +const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-pack-')) + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: repoRoot, + encoding: 'utf8', + stdio: options.capture ? 'pipe' : 'inherit', + ...options, + }) + if (result.error) throw result.error + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(' ')} failed with exit ${result.status}\n${result.stdout || ''}\n${result.stderr || ''}`, + ) + } + return result +} + +function runNpm(args, options = {}) { + const invocation = npmInvocation(args) + return run(invocation.command, invocation.args, options) +} + +async function freePort() { + const server = net.createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + const port = typeof address === 'object' && address ? address.port : 0 + await new Promise((resolve) => server.close(resolve)) + return port +} + +async function verifyArtifacts(packageRoot, manifest) { + for (const [relative, expectedHash] of Object.entries(manifest.artifacts)) { + const content = await fs.readFile(path.join(packageRoot, 'dist', relative)) + const actualHash = crypto.createHash('sha256').update(content).digest('hex') + if (actualHash !== expectedHash) { + throw new Error(`Artifact hash mismatch: dist/${relative}`) + } + } +} + +let installedCli = null +let lifecycleEnv = null +let lifecycleStarted = false +let mcpClient = null +try { + runNpm(['run', 'build']) + const firstManifest = await fs.readFile( + path.join(repoRoot, 'dist', 'build-manifest.json'), + 'utf8', + ) + + const packDir = path.join(temporaryRoot, 'pack') + await fs.mkdir(packDir, { recursive: true }) + const packed = runNpm(['pack', '--json', '--pack-destination', packDir], { capture: true }) + const secondManifest = await fs.readFile( + path.join(repoRoot, 'dist', 'build-manifest.json'), + 'utf8', + ) + if (firstManifest !== secondManifest) { + throw new Error('Two consecutive builds produced different build manifests') + } + const jsonMatch = packed.stdout.match(/\[\s*\{[\s\S]*\}\s*\]\s*$/) + if (!jsonMatch) throw new Error('npm pack did not return a JSON package manifest') + const packResult = JSON.parse(jsonMatch[0]) + const tarball = path.join(packDir, packResult[0].filename) + + const installRoot = path.join(temporaryRoot, 'install') + await fs.mkdir(installRoot, { recursive: true }) + runNpm([ + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--prefix', + installRoot, + tarball, + ]) + + const packageRoot = path.join(installRoot, 'node_modules', 'skillmanager') + const required = [ + 'bin/cli.js', + 'bin/skillmanager-mcp.js', + '.codex-plugin/plugin.json', + '.mcp.json', + 'PRIVACY.md', + 'skills/skillmanager/SKILL.md', + 'skills/skillmanager/agents/openai.yaml', + 'dist/server/index.js', + 'dist/server/mcp/index.js', + 'dist/web/index.html', + 'dist/build-manifest.json', + ] + for (const relative of required) { + await fs.access(path.join(packageRoot, relative)) + } + + const installedPackage = JSON.parse( + await fs.readFile(path.join(packageRoot, 'package.json'), 'utf8'), + ) + const manifest = JSON.parse( + await fs.readFile(path.join(packageRoot, 'dist', 'build-manifest.json'), 'utf8'), + ) + if (manifest.packageName !== installedPackage.name || manifest.version !== installedPackage.version) { + throw new Error('Package metadata does not match dist/build-manifest.json') + } + for (const alias of ['skillmanager', 'skill-hub', 'claude-skill-hub']) { + if (installedPackage.bin?.[alias] !== 'bin/cli.js') { + throw new Error(`Missing compatible CLI alias: ${alias}`) + } + } + await verifyArtifacts(packageRoot, manifest) + + const fixtureRoot = path.join(temporaryRoot, 'fixture') + const dataDir = path.join(temporaryRoot, 'data') + await fs.mkdir(fixtureRoot, { recursive: true }) + const fixtureSkill = path.join(fixtureRoot, '.agents', 'skills', 'pack-fixture') + await fs.mkdir(fixtureSkill, { recursive: true }) + await fs.writeFile( + path.join(fixtureSkill, 'SKILL.md'), + '---\nname: Pack Fixture\ndescription: Use when verifying the packaged MCP server.\n---\nPRIVATE_PACK_BODY\n', + ) + const port = await freePort() + installedCli = path.join(packageRoot, 'bin', 'cli.js') + lifecycleEnv = { + ...process.env, + SKILLMANAGER_FIXTURE_ROOT: fixtureRoot, + SKILLMANAGER_DATA_DIR: dataDir, + SKILLMANAGER_PORT: String(port), + SKILLMANAGER_NO_OPEN: '1', + SKILL_HUB_NO_OPEN: '1', + SKILLMANAGER_TEST_MODE: '1', + } + const lifecycleOptions = { + cwd: packageRoot, + env: lifecycleEnv, + capture: true, + } + const mcpTransport = new StdioClientTransport({ + command: process.execPath, + args: [path.join(packageRoot, 'bin', 'skillmanager-mcp.js')], + cwd: packageRoot, + env: Object.fromEntries( + Object.entries(lifecycleEnv).filter((entry) => typeof entry[1] === 'string'), + ), + stderr: 'pipe', + }) + mcpClient = new Client({ name: 'skillmanager-pack-verifier', version: '1.0.0' }) + await mcpClient.connect(mcpTransport) + const mcpTools = await mcpClient.listTools() + const expectedTools = [ + 'diagnose_library', + 'diagnose_skill', + 'get_deep_link', + 'get_skill_summary', + 'get_status', + 'list_skills', + 'open_dashboard', + ] + if (JSON.stringify(mcpTools.tools.map((tool) => tool.name).sort()) !== JSON.stringify(expectedTools)) { + throw new Error('Packaged MCP tool list is incomplete') + } + const mcpListing = await mcpClient.callTool({ + name: 'list_skills', + arguments: { agent: 'codex', limit: 10, offset: 0, response_format: 'json' }, + }) + const mcpListingText = JSON.stringify(mcpListing.structuredContent) + if ( + mcpListing.isError || + !mcpListingText.includes('Pack Fixture') || + mcpListingText.includes(fixtureRoot) || + mcpListingText.includes('PRIVATE_PACK_BODY') + ) { + throw new Error('Packaged MCP listing failed redaction or fixture discovery') + } + await mcpClient.close() + mcpClient = null + + const start = JSON.parse(run(process.execPath, [installedCli, 'start', '--json'], lifecycleOptions).stdout) + lifecycleStarted = true + if (start.status !== 'running' || start.port !== port || start.reused !== false) { + throw new Error('Packaged CLI start returned an invalid lifecycle state') + } + const reused = JSON.parse(run(process.execPath, [installedCli, 'start', '--json'], lifecycleOptions).stdout) + if (reused.pid !== start.pid || reused.reused !== true) { + throw new Error('Packaged CLI did not reuse the running instance') + } + const status = JSON.parse(run(process.execPath, [installedCli, 'status', '--json'], lifecycleOptions).stdout) + if (status.status !== 'running' || status.pid !== start.pid || 'controlToken' in status) { + throw new Error('Packaged CLI status contract is invalid') + } + const opened = JSON.parse(run(process.execPath, [installedCli, 'open', '--json'], lifecycleOptions).stdout) + const launch = await fetch(opened.launchUrl, { redirect: 'manual' }) + if (launch.status !== 303 || !/HttpOnly/i.test(launch.headers.get('set-cookie') || '')) { + throw new Error('Packaged CLI launch URL did not establish a secure browser session') + } + const healthResponse = await fetch(`${start.baseUrl}/api/v1/health`) + const health = await healthResponse.json() + if (!healthResponse.ok || health.status !== 'ok' || health.product !== 'SkillManager') { + throw new Error('Packaged server returned an invalid health response') + } + const doctor = JSON.parse(run(process.execPath, [installedCli, 'doctor', '--json'], lifecycleOptions).stdout) + if (!doctor.ok) throw new Error('Packaged CLI doctor reported a release failure') + const stopped = JSON.parse(run(process.execPath, [installedCli, 'stop', '--json'], lifecycleOptions).stdout) + lifecycleStarted = false + if (stopped.status !== 'stopped' || stopped.stopped !== true) { + throw new Error('Packaged CLI stop returned an invalid lifecycle state') + } + + console.log(JSON.stringify({ + ok: true, + package: `${installedPackage.name}@${installedPackage.version}`, + entryCount: packResult[0].entryCount, + sourceDirty: manifest.sourceDirty, + packagedServer: 'healthy', + packagedLifecycle: 'verified', + packagedMcp: 'verified', + }, null, 2)) +} finally { + if (mcpClient) await mcpClient.close().catch(() => {}) + if (lifecycleStarted && installedCli && lifecycleEnv) { + spawnSync(process.execPath, [installedCli, 'stop', '--json'], { + cwd: path.dirname(path.dirname(installedCli)), + env: lifecycleEnv, + stdio: 'ignore', + }) + } + await fs.rm(temporaryRoot, { recursive: true, force: true }) +} diff --git a/scripts/write-build-manifest.mjs b/scripts/write-build-manifest.mjs new file mode 100644 index 0000000..fc404ce --- /dev/null +++ b/scripts/write-build-manifest.mjs @@ -0,0 +1,112 @@ +import fs from 'fs/promises' +import path from 'path' +import crypto from 'crypto' +import { spawnSync } from 'child_process' +import { fileURLToPath } from 'url' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(scriptDir, '..') +const distRoot = path.join(repoRoot, 'dist') +const manifestPath = path.join(distRoot, 'build-manifest.json') + +async function exists(target) { + try { + await fs.access(target) + return true + } catch { + return false + } +} + +async function filesUnder(target, relativeBase = repoRoot) { + if (!(await exists(target))) return [] + const stat = await fs.stat(target) + if (stat.isFile()) return [path.relative(relativeBase, target)] + + const result = [] + const entries = await fs.readdir(target, { withFileTypes: true }) + for (const entry of entries) { + const child = path.join(target, entry.name) + if (entry.isDirectory()) result.push(...(await filesUnder(child, relativeBase))) + if (entry.isFile()) result.push(path.relative(relativeBase, child)) + } + return result +} + +async function hashFiles(relativeFiles, base = repoRoot) { + const digest = crypto.createHash('sha256') + for (const relative of [...relativeFiles].sort()) { + digest.update(relative.replaceAll(path.sep, '/')) + digest.update('\0') + digest.update(await fs.readFile(path.join(base, relative))) + digest.update('\0') + } + return digest.digest('hex') +} + +async function artifactMap() { + const relativeFiles = (await filesUnder(distRoot, distRoot)) + .filter((relative) => relative !== 'build-manifest.json') + .sort() + const artifacts = {} + for (const relative of relativeFiles) { + artifacts[relative.replaceAll(path.sep, '/')] = crypto + .createHash('sha256') + .update(await fs.readFile(path.join(distRoot, relative))) + .digest('hex') + } + return artifacts +} + +const sourceEntries = [ + 'bin', + 'scripts', + 'server', + 'web', + 'skills', + '.codex-plugin', + '.mcp.json', + 'package.json', + 'package-lock.json', + 'tsconfig.json', + 'tsconfig.server.json', + 'vite.config.ts', + 'eslint.config.js', +] +const sourceFiles = [] +for (const entry of sourceEntries) { + sourceFiles.push(...(await filesUnder(path.join(repoRoot, entry)))) +} + +const pkg = JSON.parse(await fs.readFile(path.join(repoRoot, 'package.json'), 'utf8')) +const commitResult = spawnSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8', +}) +const statusResult = spawnSync('git', ['status', '--porcelain', '--untracked-files=all'], { + cwd: repoRoot, + encoding: 'utf8', +}) +const sourceStatus = (statusResult.stdout || '') + .split(/\r?\n/) + .filter(Boolean) + .filter((line) => { + const name = line.slice(3).replace(/^"|"$/g, '') + return !name.startsWith('dist/') && !name.startsWith('release/') + }) + +const manifest = { + schemaVersion: 1, + packageName: pkg.name, + version: pkg.version, + gitCommit: commitResult.status === 0 ? commitResult.stdout.trim() : null, + sourceDirty: sourceStatus.length > 0, + sourceTreeHash: await hashFiles(sourceFiles), + artifacts: await artifactMap(), +} + +await fs.mkdir(distRoot, { recursive: true }) +const temporaryPath = manifestPath + '.tmp' +await fs.writeFile(temporaryPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8') +await fs.rename(temporaryPath, manifestPath) +console.log(`Wrote ${path.relative(repoRoot, manifestPath)}`) diff --git a/server/config/migration.ts b/server/config/migration.ts new file mode 100644 index 0000000..16a22e6 --- /dev/null +++ b/server/config/migration.ts @@ -0,0 +1,112 @@ +import fs from 'fs/promises' +import path from 'path' +import crypto from 'crypto' +import { atomicWriteFile } from '../core/atomic.js' +import { dataDir, userHome } from './paths.js' + +interface MigrationEntry { + label: string + status: 'copied' | 'missing' | 'already-present' | 'failed' + error?: string +} + +export interface MigrationReport { + schemaVersion: 1 + entries: MigrationEntry[] + legacyDataPreserved: true +} + +const migrations = [ + { + label: 'versions', + source: path.join(userHome, '.skill-hub', 'versions'), + target: path.join(dataDir, 'versions'), + }, + { + label: 'trash', + source: path.join(userHome, '.skill-hub', 'trash'), + target: path.join(dataDir, 'trash'), + }, + { + label: 'diagnostic-ignores', + source: path.join(userHome, '.claude', 'skill-hub', 'ignored-pairs.json'), + target: path.join(dataDir, 'diagnostics', 'ignored-pairs.json'), + }, + { + label: 'sync-config', + source: path.join(userHome, '.config', 'skill-hub', 'credentials.json'), + target: path.join(dataDir, 'config', 'sync.json'), + }, + { + label: 'sync-vault', + source: path.join(userHome, '.local', 'share', 'skill-hub', 'vault'), + target: path.join(dataDir, 'vault'), + }, +] as const + +async function exists(target: string): Promise { + try { + await fs.lstat(target) + return true + } catch { + return false + } +} + +async function copyWithoutOverwrite( + label: string, + source: string, + target: string, +): Promise { + if (!(await exists(source))) return { label, status: 'missing' } + if (await exists(target)) return { label, status: 'already-present' } + + const parent = path.dirname(target) + const temporary = path.join( + parent, + `.skillmanager-migration-${crypto.randomBytes(8).toString('hex')}`, + ) + await fs.mkdir(parent, { recursive: true }) + try { + await fs.cp(source, temporary, { + recursive: true, + errorOnExist: true, + force: false, + preserveTimestamps: true, + }) + await fs.rename(temporary, target) + if (label === 'sync-config' && process.platform !== 'win32') { + await fs.chmod(target, 0o600) + } + return { label, status: 'copied' } + } catch (error: any) { + await fs.rm(temporary, { recursive: true, force: true }).catch(() => {}) + return { + label, + status: 'failed', + error: error?.code || error?.name || 'migration_failed', + } + } +} + +export async function migrateLegacyData(): Promise { + const entries: MigrationEntry[] = [] + for (const migration of migrations) { + entries.push(await copyWithoutOverwrite( + migration.label, + migration.source, + migration.target, + )) + } + const report: MigrationReport = { + schemaVersion: 1, + entries, + legacyDataPreserved: true, + } + await fs.mkdir(dataDir, { recursive: true }) + await atomicWriteFile( + path.join(dataDir, 'migration-v1.json'), + JSON.stringify(report, null, 2) + '\n', + ) + return report +} diff --git a/server/config/paths.ts b/server/config/paths.ts new file mode 100644 index 0000000..fc8abd6 --- /dev/null +++ b/server/config/paths.ts @@ -0,0 +1,43 @@ +import os from 'os' +import path from 'path' + +function optionalAbsolutePath(name: string): string | null { + const value = process.env[name]?.trim() + if (!value) return null + if (!path.isAbsolute(value)) { + throw new Error(`${name} must be an absolute path`) + } + return path.resolve(value) +} + +export const fixtureRoot = optionalAbsolutePath('SKILLMANAGER_FIXTURE_ROOT') +export const userHome = fixtureRoot ?? os.homedir() +export const explicitProjectRoot = optionalAbsolutePath('SKILLMANAGER_PROJECT_ROOT') + +function defaultDataDir(): string { + if (fixtureRoot) return path.join(fixtureRoot, 'data') + + if (process.platform === 'darwin') { + return path.join(userHome, 'Library', 'Application Support', 'SkillManager') + } + + if (process.platform === 'win32') { + const appData = process.env.APPDATA?.trim() + return path.join(appData || path.join(userHome, 'AppData', 'Roaming'), 'SkillManager') + } + + const xdgDataHome = process.env.XDG_DATA_HOME?.trim() + return path.join(xdgDataHome || path.join(userHome, '.local', 'share'), 'skillmanager') +} + +export const dataDir = optionalAbsolutePath('SKILLMANAGER_DATA_DIR') ?? defaultDataDir() +export const isFixtureMode = fixtureRoot !== null + +export function assertFixturePath(targetPath: string): void { + if (!fixtureRoot) return + const relative = path.relative(fixtureRoot, path.resolve(targetPath)) + if (relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative))) { + return + } + throw new Error(`Fixture mode blocked path outside SKILLMANAGER_FIXTURE_ROOT: ${targetPath}`) +} diff --git a/server/core/action-plans.ts b/server/core/action-plans.ts new file mode 100644 index 0000000..65b0f0a --- /dev/null +++ b/server/core/action-plans.ts @@ -0,0 +1,1592 @@ +import fs from 'fs/promises' +import crypto from 'crypto' +import path from 'path' +import { + authorizeSkillWrite, + hashContent, + SkillAccessError, + type AuthorizedSkill, +} from './authorization.js' +import { atomicWriteFile } from './atomic.js' +import { getCatalog, invalidateCatalog } from './catalog.js' +import { createSnapshot } from '../versioning/store.js' +import { + getTrashState, + isValidTrashId, + moveToTrash, + purgeOne, + restoreFromTrash, + restoreFromTrashDetailed, + TRASH_TTL_MS, + type TrashMeta, +} from '../trash/store.js' +import { parseSkillMd } from '../scanner/parser.js' +import { getVersion, type Version } from '../versioning/store.js' +import { isPathInside } from './authorization.js' +import { auditSkillPackageBoundary, hashSkillDirectory } from '../scanner/package-hash.js' +import { convergenceContextKey } from '../scanner/identity.js' +import type { ScanResult, Skill, SkillInstallation } from '../types.js' +import { + commitConvergence, + completeConvergenceRollback, + getConvergenceTransaction, + listProjectionRecords, + newConvergenceId, + projectionRecordId, + setConvergenceTransactionStatus, + type ConvergenceTransaction, + type ProjectionRecord, +} from '../projections/store.js' + +export const ACTION_PLAN_TTL_MS = 5 * 60 * 1000 + +export type ActionPlanAction = + | 'edit' + | 'delete' + | 'restore' + | 'copy' + | 'move' + | 'trash-restore' + | 'converge' + | 'converge-rollback' + +export interface ActionPlan { + id: string + action: ActionPlanAction + createdAt: string + expiresAt: string + skillIds: string[] + preconditions: { resourceId: string; contentHash: string | null }[] + changes: { + operation: 'create' | 'update' | 'delete' | 'move' | 'link' | 'unlink' + resourceLabel: string + beforeHash: string | null + afterHash: string | null + }[] + risks: string[] + advisories?: Array<{ + code: 'EXACT_COPY_WOULD_BE_CREATED' + message: string + recommendedAction: 'share-existing' + canonicalSkillId: string + }> + recovery: { + snapshotId?: string + trashId?: string + trashIds?: string[] + transactionId?: string + retentionDays?: number + expiresAt?: string + } + requiresExplicitConfirmation: true + preview?: { + beforeContent: string + afterContent: string + stats: { beforeLines: number; afterLines: number } + } +} + +export interface ConvergenceBatchPlanItem { + groupId: string + name: string + canonicalSkillId: string + duplicateSkillIds: string[] + planId: string + entranceCount: number + canonicalPath: string +} + +export interface ConvergenceBatchPlan { + id: string + action: 'converge-batch' + createdAt: string + expiresAt: string + groups: ConvergenceBatchPlanItem[] + groupCount: number + entranceCount: number + permanentDeleteCount: 0 + recoveryDays: number + requiresExplicitConfirmation: true +} + +export interface ConvergenceBatchApplyResult { + ok: true + action: 'converge-batch' + status: 'complete' | 'partial' | 'failed' + successes: Array<{ + groupId: string + name: string + transactionId: string + projectionCount: number + recoveryExpiresAt: string | null + }> + failures: Array<{ + groupId: string + name: string + code: string + error: string + }> +} + +interface StoredPlan { + plan: ActionPlan + installationId?: string + payload: + | { kind: 'edit'; content: string } + | { kind: 'restore'; versionId: string; versionHash: string } + | { kind: 'trash-restore'; trashId: string; trashHash: string; targetHash: string | null } + | { kind: 'transfer'; targetRootId: string; targetName: string } + | { + kind: 'converge' + canonicalSkillId: string + canonicalInstallationId: string + canonicalPath: string + packageHash: string + targets: Array<{ + skillId: string + installationId: string + targetPath: string + stateHash: string + }> + } + | { + kind: 'converge-rollback' + transactionId: string + projectionStateHashes: Array<{ targetPath: string; stateHash: string }> + } + | { + kind: 'delete' + items: { skillId: string; installationId: string; contentHash: string }[] + } + | null +} + +interface StoredConvergenceBatch { + plan: ConvergenceBatchPlan +} + +export class ActionPlanError extends Error { + constructor( + public readonly statusCode: number, + public readonly code: string, + message: string, + ) { + super(message) + this.name = 'ActionPlanError' + } +} + +function planId(): string { + return `plan_${crypto.randomBytes(18).toString('base64url')}` +} + +function iso(ms: number): string { + return new Date(ms).toISOString() +} + +function lineCount(content: string): number { + return content.length === 0 ? 0 : content.split('\n').length +} + +export const TRASH_RETENTION_DAYS = Math.round(TRASH_TTL_MS / (24 * 60 * 60 * 1000)) + +async function assertNoManagedProjectionDependents( + action: 'delete' | 'move', + items: AuthorizedSkill[], +): Promise { + const records = await listProjectionRecords() + for (const item of items) { + const canonicalPath = path.resolve(item.skillDir) + const dependents = records.filter( + (record) => path.resolve(record.canonicalPath) === canonicalPath, + ) + if (dependents.length > 0) { + throw new ActionPlanError( + 409, + 'CANONICAL_IN_USE', + `${item.skill.name} 是 ${dependents.length} 个共用入口的主版本。请先在“Skill 整理”中撤销共用或迁移主版本,再${action === 'delete' ? '删除' : '移动'}。`, + ) + } + } +} + +function sameTransferContext( + source: SkillInstallation, + target: ScanResult['authorizedRoots'][number], +): boolean { + if (source.scope !== target.scope) return false + if (source.scope === 'project') { + return Boolean(source.projectId && source.projectId === target.projectId) + } + return source.scope === 'global' +} + +export interface ActionPlanDependencies { + createDirectoryProjection?: ( + canonicalPath: string, + targetPath: string, + ) => Promise<'symlink' | 'junction'> +} + +function authorizeProjectionTargetPath(catalog: ScanResult, targetPath: string) { + const resolvedTarget = path.resolve(targetPath) + const root = catalog.authorizedRoots.find((candidate) => ( + candidate.editable && + candidate.scope !== 'plugin' && + isPathInside(candidate.path, resolvedTarget) && + path.resolve(candidate.path) !== resolvedTarget + )) + if (!root) { + throw new ActionPlanError(403, 'TARGET_NOT_AUTHORIZED', 'Projection target is outside writable Skill roots') + } + if (path.resolve(path.dirname(resolvedTarget)) !== path.resolve(root.path)) { + throw new ActionPlanError(403, 'TARGET_BOUNDARY', 'Projection target must be a direct child of its Skill root') + } + return { root, targetPath: resolvedTarget } +} + +async function authorizeProjectionMutation( + catalog: ScanResult, + skill: Skill, + installation: SkillInstallation, +): Promise<{ targetPath: string; isSymlink: boolean }> { + if (installation.scope === 'plugin') { + throw new ActionPlanError(403, 'PLUGIN_READ_ONLY', 'Plugin projections are read-only') + } + const { targetPath } = authorizeProjectionTargetPath(catalog, installation.path) + let stat + let currentTarget: string + try { + stat = await fs.lstat(targetPath) + currentTarget = await fs.realpath(targetPath) + } catch { + throw new ActionPlanError(409, 'STALE_PLAN', 'Projection target is missing or unreadable') + } + if (!stat.isDirectory() && !stat.isSymbolicLink()) { + throw new ActionPlanError(409, 'INVALID_PROJECTION_TARGET', 'Projection target is not a Skill directory') + } + if (currentTarget !== skill.realPath || stat.isSymbolicLink() !== installation.isSymlink) { + throw new ActionPlanError(409, 'STALE_PLAN', 'Projection target changed after scanning') + } + if (!stat.isSymbolicLink() && !installation.canDeleteCanonical) { + throw new ActionPlanError(403, 'INSTALLATION_READ_ONLY', 'A real duplicate directory is not writable') + } + return { targetPath, isSymlink: stat.isSymbolicLink() } +} + +async function createDirectoryProjection( + canonicalPath: string, + targetPath: string, +): Promise<'symlink' | 'junction'> { + const canonical = await fs.realpath(canonicalPath) + const canonicalStat = await fs.stat(canonical) + if (!canonicalStat.isDirectory()) { + throw new ActionPlanError(409, 'CANONICAL_MISSING', 'Canonical Skill is not a directory') + } + const target = path.resolve(targetPath) + if (isPathInside(canonical, target) || isPathInside(target, canonical)) { + throw new ActionPlanError(409, 'TARGET_OVERLAP', 'Canonical and projection paths overlap') + } + try { + await fs.lstat(target) + throw new ActionPlanError(409, 'TARGET_EXISTS', 'Projection target appeared before link creation') + } catch (error: any) { + if (error instanceof ActionPlanError) throw error + if (error?.code !== 'ENOENT') throw error + } + + const lexicalParent = path.dirname(target) + const realParent = await fs.realpath(lexicalParent) + if (process.platform === 'win32') { + await fs.symlink(canonical, target, 'junction') + return 'junction' + } + const relativeTarget = path.relative(realParent, canonical) || '.' + await fs.symlink(relativeTarget, target, 'dir') + return 'symlink' +} + +async function verifyDirectoryProjection(targetPath: string, canonicalPath: string): Promise { + let stat + let realTarget: string + let realCanonical: string + try { + stat = await fs.lstat(targetPath) + ;[realTarget, realCanonical] = await Promise.all([ + fs.realpath(targetPath), + fs.realpath(canonicalPath), + ]) + } catch { + throw new ActionPlanError(409, 'PROJECTION_BROKEN', 'Projection or canonical path is unreadable') + } + if (!stat.isSymbolicLink() || realTarget !== realCanonical) { + throw new ActionPlanError(409, 'PROJECTION_WRONG_TARGET', 'Projection does not point to the canonical Skill') + } + await parseSkillMd(path.join(targetPath, 'SKILL.md')) +} + +async function removeDirectoryProjection(targetPath: string, canonicalPath: string): Promise { + await verifyDirectoryProjection(targetPath, canonicalPath) + if (process.platform === 'win32') { + await fs.rmdir(targetPath).catch(async () => fs.unlink(targetPath)) + } else { + await fs.unlink(targetPath) + } +} + +export class ActionPlanService { + private readonly plans = new Map() + private readonly convergenceBatches = new Map() + private readonly createDirectoryProjection: NonNullable + + constructor(dependencies: ActionPlanDependencies = {}) { + this.createDirectoryProjection = dependencies.createDirectoryProjection || createDirectoryProjection + } + + async previewEdit(input: { + skillId: string + installationId?: string + content: string + }, now = Date.now()): Promise { + if (typeof input.content !== 'string') { + throw new ActionPlanError(400, 'INVALID_CONTENT', 'Edit content must be a string') + } + if (Buffer.byteLength(input.content, 'utf8') > 5 * 1024 * 1024) { + throw new ActionPlanError(413, 'CONTENT_TOO_LARGE', 'SKILL.md exceeds the 5 MB edit limit') + } + const authorized = await authorizeSkillWrite(input.skillId, input.installationId) + const beforeContent = await fs.readFile(authorized.skillMdPath, 'utf8') + const afterHash = hashContent(input.content) + const id = planId() + const plan: ActionPlan = { + id, + action: 'edit', + createdAt: iso(now), + expiresAt: iso(now + ACTION_PLAN_TTL_MS), + skillIds: [authorized.skill.id], + preconditions: [{ resourceId: authorized.skill.id, contentHash: authorized.contentHash }], + changes: [{ + operation: 'update', + resourceLabel: `${authorized.skill.name}/SKILL.md`, + beforeHash: authorized.contentHash, + afterHash, + }], + risks: ['将覆盖当前 SKILL.md;执行前会创建恢复快照'], + recovery: {}, + requiresExplicitConfirmation: true, + preview: { + beforeContent, + afterContent: input.content, + stats: { + beforeLines: lineCount(beforeContent), + afterLines: lineCount(input.content), + }, + }, + } + this.plans.set(id, { + plan, + installationId: authorized.installation.id, + payload: { kind: 'edit', content: input.content }, + }) + return plan + } + + async previewRestore(input: { + skillId: string + installationId?: string + versionId: string + }, now = Date.now()): Promise { + if (!/^[a-z0-9-]{3,120}$/i.test(input.versionId)) { + throw new ActionPlanError(400, 'INVALID_VERSION_ID', 'Invalid version ID') + } + const authorized = await authorizeSkillWrite(input.skillId, input.installationId) + const version = await getVersion(authorized.skillDir, input.versionId) + if (!version) throw new ActionPlanError(404, 'VERSION_NOT_FOUND', 'Version not found') + const beforeContent = await fs.readFile(authorized.skillMdPath, 'utf8') + const id = planId() + const plan: ActionPlan = { + id, + action: 'restore', + createdAt: iso(now), + expiresAt: iso(now + ACTION_PLAN_TTL_MS), + skillIds: [authorized.skill.id], + preconditions: [{ resourceId: authorized.skill.id, contentHash: authorized.contentHash }], + changes: [{ + operation: 'update', + resourceLabel: `${authorized.skill.name}/SKILL.md → ${input.versionId}`, + beforeHash: authorized.contentHash, + afterHash: version.contentHash, + }], + risks: ['将恢复该版本保存的文本文件;执行前会创建当前状态快照'], + recovery: {}, + requiresExplicitConfirmation: true, + preview: { + beforeContent, + afterContent: version.content, + stats: { + beforeLines: lineCount(beforeContent), + afterLines: lineCount(version.content), + }, + }, + } + this.plans.set(id, { + plan, + installationId: authorized.installation.id, + payload: { + kind: 'restore', + versionId: input.versionId, + versionHash: version.contentHash, + }, + }) + return plan + } + + async previewTransfer( + action: 'copy' | 'move', + input: { skillId: string; installationId?: string; targetRootId: string }, + now = Date.now(), + ): Promise { + if (!/^root_[a-f0-9]{20}$/.test(input.targetRootId)) { + throw new ActionPlanError(400, 'INVALID_TARGET_ID', 'Invalid target root ID') + } + const authorized = await authorizeSkillWrite(input.skillId, input.installationId) + const targetRoot = authorized.catalog.authorizedRoots.find( + (root) => root.id === input.targetRootId && root.editable, + ) + if (!targetRoot) throw new ActionPlanError(403, 'TARGET_NOT_AUTHORIZED', 'Target root is not writable') + if (action === 'move') { + await assertNoManagedProjectionDependents('move', [authorized]) + } + + const targetName = path.basename(authorized.installation.path) + if (!targetName || targetName === '.' || targetName === '..') { + throw new ActionPlanError(400, 'INVALID_TARGET_NAME', 'Invalid target Skill directory name') + } + const targetDir = path.join(targetRoot.path, targetName) + if (!isPathInside(targetRoot.path, targetDir) || isPathInside(authorized.skillDir, targetDir)) { + throw new ActionPlanError(403, 'TARGET_BOUNDARY', 'Target path is outside the selected root') + } + try { + await fs.lstat(targetDir) + throw new ActionPlanError(409, 'TARGET_EXISTS', 'Target already contains a Skill with this directory name') + } catch (error: any) { + if (error instanceof ActionPlanError) throw error + if (error?.code !== 'ENOENT') throw error + } + + const id = planId() + const plan: ActionPlan = { + id, + action, + createdAt: iso(now), + expiresAt: iso(now + ACTION_PLAN_TTL_MS), + skillIds: [authorized.skill.id], + preconditions: [{ resourceId: authorized.skill.id, contentHash: authorized.contentHash }], + changes: [ + { + operation: action === 'copy' ? 'create' : 'move', + resourceLabel: `${authorized.skill.name} → ${targetRoot.projectName || targetRoot.agent}`, + beforeHash: null, + afterHash: authorized.contentHash, + }, + ], + risks: action === 'move' + ? ['目标完整写入并验证后,源安装位置才会移入回收站'] + : [ + '将创建一个独立副本;目标已存在时不会覆盖', + ...(sameTransferContext(authorized.installation, targetRoot) + ? ['目标与当前 Skill 属于同一作用域,复制后会形成内容完全相同的独立副本。推荐改用共用入口。'] + : []), + ], + advisories: action === 'copy' && sameTransferContext(authorized.installation, targetRoot) + ? [{ + code: 'EXACT_COPY_WOULD_BE_CREATED', + message: '这次复制会制造一份同作用域的完整重复副本。建议保留当前主版本,并让目标位置共用它。', + recommendedAction: 'share-existing', + canonicalSkillId: authorized.skill.id, + }] + : undefined, + recovery: {}, + requiresExplicitConfirmation: true, + } + this.plans.set(id, { + plan, + installationId: authorized.installation.id, + payload: { kind: 'transfer', targetRootId: targetRoot.id, targetName }, + }) + return plan + } + + async previewConverge(input: { + canonicalSkillId: string + duplicateSkillIds: string[] + }, now = Date.now(), catalogSnapshot?: ScanResult): Promise { + if (!/^skill_[a-f0-9]{24}$/.test(input.canonicalSkillId)) { + throw new ActionPlanError(400, 'INVALID_SKILL_ID', 'Invalid canonical Skill ID') + } + if ( + !Array.isArray(input.duplicateSkillIds) || + input.duplicateSkillIds.length < 1 || + input.duplicateSkillIds.length > 20 + ) { + throw new ActionPlanError(400, 'INVALID_DUPLICATE_SET', 'Select between 1 and 20 duplicate Skills') + } + const duplicateIds = Array.from(new Set(input.duplicateSkillIds)) + if ( + duplicateIds.length !== input.duplicateSkillIds.length || + duplicateIds.includes(input.canonicalSkillId) || + duplicateIds.some((id) => !/^skill_[a-f0-9]{24}$/.test(id)) + ) { + throw new ActionPlanError(400, 'INVALID_DUPLICATE_SET', 'Duplicate Skill selection is invalid') + } + + const catalog = catalogSnapshot || await getCatalog(true) + const canonical = catalog.skills.find((skill) => skill.id === input.canonicalSkillId) + if (!canonical) throw new ActionPlanError(404, 'SKILL_NOT_FOUND', 'Canonical Skill not found') + if (!canonical.packageHash) { + throw new ActionPlanError(409, 'PACKAGE_HASH_UNAVAILABLE', 'Canonical Skill package could not be hashed safely') + } + const canonicalInstallation = canonical.installations.find((installation) => ( + installation.canDeleteCanonical && + !installation.isSymlink && + installation.scope !== 'plugin' + )) + if (!canonicalInstallation) { + throw new ActionPlanError(403, 'CANONICAL_READ_ONLY', 'Choose a directly managed, writable Skill as the source of truth') + } + const authorizedCanonical = await authorizeSkillWrite(canonical.id, canonicalInstallation.id) + const canonicalContext = convergenceContextKey(canonical) + const currentPackageHash = await hashSkillDirectory(authorizedCanonical.skillDir) + if (currentPackageHash !== canonical.packageHash) { + throw new ActionPlanError(409, 'STALE_SKILL', 'Canonical Skill changed during convergence preview') + } + const canonicalBoundary = await auditSkillPackageBoundary(authorizedCanonical.skillDir) + if (!canonicalBoundary.safe) { + throw new ActionPlanError( + 409, + 'UNSAFE_PACKAGE_LINK', + `Canonical Skill contains an unsafe nested link at ${canonicalBoundary.relativePath || 'unknown path'}`, + ) + } + + const targets: Array<{ + skillId: string + installationId: string + targetPath: string + stateHash: string + skillName: string + agent: string + scope: string + }> = [] + for (const duplicateId of duplicateIds) { + const duplicate = catalog.skills.find((skill) => skill.id === duplicateId) + if (!duplicate) throw new ActionPlanError(404, 'SKILL_NOT_FOUND', 'Duplicate Skill not found') + if (!duplicate.packageHash || duplicate.packageHash !== canonical.packageHash) { + throw new ActionPlanError( + 409, + 'PACKAGE_HASH_MISMATCH', + `${duplicate.name} is not an exact package copy and cannot be auto-converged`, + ) + } + if (convergenceContextKey(duplicate) !== canonicalContext) { + throw new ActionPlanError( + 409, + 'SCOPE_BOUNDARY', + `${duplicate.name} crosses a global/project boundary and cannot be auto-converged`, + ) + } + if (duplicate.installations.some((installation) => installation.scope === 'plugin')) { + throw new ActionPlanError(403, 'PLUGIN_READ_ONLY', 'Plugin installations cannot be converged') + } + if (duplicate.installations.length === 0) { + throw new ActionPlanError(409, 'INSTALLATION_NOT_FOUND', 'Duplicate Skill has no installation') + } + const duplicateBoundary = await auditSkillPackageBoundary(duplicate.realPath) + if (!duplicateBoundary.safe) { + throw new ActionPlanError( + 409, + 'UNSAFE_PACKAGE_LINK', + `${duplicate.name} contains an unsafe nested link at ${duplicateBoundary.relativePath || 'unknown path'}`, + ) + } + for (const installation of duplicate.installations) { + const authorized = await authorizeProjectionMutation(catalog, duplicate, installation) + if (path.resolve(authorized.targetPath) === path.resolve(authorizedCanonical.skillDir)) { + throw new ActionPlanError(409, 'TARGET_OVERLAP', 'A duplicate target overlaps the canonical Skill') + } + const stateHash = await filesystemStateHash(authorized.targetPath) + if (!stateHash) throw new ActionPlanError(409, 'STALE_SKILL', 'Duplicate target disappeared during preview') + targets.push({ + skillId: duplicate.id, + installationId: installation.id, + targetPath: authorized.targetPath, + stateHash, + skillName: duplicate.name, + agent: installation.agent, + scope: installation.scope, + }) + } + } + + const uniqueTargets = Array.from(new Map(targets.map((target) => [path.resolve(target.targetPath), target])).values()) + const id = planId() + const plan: ActionPlan = { + id, + action: 'converge', + createdAt: iso(now), + expiresAt: iso(now + ACTION_PLAN_TTL_MS), + skillIds: [canonical.id, ...duplicateIds], + preconditions: [ + { resourceId: canonical.id, contentHash: canonical.packageHash }, + ...uniqueTargets.map((target) => ({ + resourceId: target.installationId, + contentHash: target.stateHash, + })), + ], + changes: uniqueTargets.map((target) => ({ + operation: 'link', + resourceLabel: `${target.skillName} · ${target.agent}/${target.scope} → ${canonical.name}`, + beforeHash: target.stateHash, + afterHash: canonical.packageHash, + })), + risks: [ + `将把 ${uniqueTargets.length} 个现有安装入口移入可恢复回收站,再创建逐 Skill 目录软链`, + '只会替换本计划中列出的入口;真源、插件缓存和其他非选中目录不会修改', + `原独立副本保留 ${TRASH_RETENTION_DAYS} 天,期限内可逐组撤销`, + 'Codex/Claude Code 可能需要新会话或重新加载后才刷新 Skill 列表', + ], + recovery: { retentionDays: TRASH_RETENTION_DAYS }, + requiresExplicitConfirmation: true, + } + this.plans.set(id, { + plan, + payload: { + kind: 'converge', + canonicalSkillId: canonical.id, + canonicalInstallationId: canonicalInstallation.id, + canonicalPath: authorizedCanonical.skillDir, + packageHash: canonical.packageHash, + targets: uniqueTargets.map(({ skillId, installationId, targetPath, stateHash }) => ({ + skillId, + installationId, + targetPath, + stateHash, + })), + }, + }) + return plan + } + + async previewConvergeBatch(input: { + groups: Array<{ + canonicalSkillId: string + duplicateSkillIds: string[] + }> + }, now = Date.now()): Promise { + if (!Array.isArray(input.groups) || input.groups.length < 1 || input.groups.length > 50) { + throw new ActionPlanError(400, 'INVALID_BATCH', 'Select between 1 and 50 safe Skill groups') + } + + const selectedSkillIds = input.groups.flatMap((group) => [ + group.canonicalSkillId, + ...(Array.isArray(group.duplicateSkillIds) ? group.duplicateSkillIds : []), + ]) + if (new Set(selectedSkillIds).size !== selectedSkillIds.length) { + throw new ActionPlanError(400, 'OVERLAPPING_BATCH_GROUPS', 'A Skill can appear in only one organization group') + } + + const catalog = await getCatalog(true) + const createdPlanIds: string[] = [] + const items: ConvergenceBatchPlanItem[] = [] + try { + for (const requested of input.groups) { + const requestedIds = new Set([ + requested.canonicalSkillId, + ...(Array.isArray(requested.duplicateSkillIds) ? requested.duplicateSkillIds : []), + ]) + const group = catalog.convergenceGroups.find((candidate) => ( + candidate.kind === 'exact-copy' && + candidate.eligible && + candidate.canonicalCandidateSkillIds.includes(requested.canonicalSkillId) && + candidate.skillIds.length === requestedIds.size && + candidate.skillIds.every((skillId) => requestedIds.has(skillId)) + )) + if (!group) { + throw new ActionPlanError( + 409, + 'BATCH_GROUP_NOT_ELIGIBLE', + 'Batch organization only accepts complete, same-scope, exact-copy groups', + ) + } + const plan = await this.previewConverge(requested, now, catalog) + createdPlanIds.push(plan.id) + const canonical = group.skills.find((skill) => skill.id === requested.canonicalSkillId) + if (!canonical) throw new ActionPlanError(409, 'STALE_SKILL', 'Recommended main version disappeared') + items.push({ + groupId: group.id, + name: group.name, + canonicalSkillId: requested.canonicalSkillId, + duplicateSkillIds: [...requested.duplicateSkillIds], + planId: plan.id, + entranceCount: plan.changes.length, + canonicalPath: canonical.realPath, + }) + } + } catch (error) { + for (const id of createdPlanIds) this.plans.delete(id) + throw error + } + + const id = `batch_${crypto.randomBytes(18).toString('base64url')}` + const plan: ConvergenceBatchPlan = { + id, + action: 'converge-batch', + createdAt: iso(now), + expiresAt: iso(now + ACTION_PLAN_TTL_MS), + groups: items, + groupCount: items.length, + entranceCount: items.reduce((sum, item) => sum + item.entranceCount, 0), + permanentDeleteCount: 0, + recoveryDays: TRASH_RETENTION_DAYS, + requiresExplicitConfirmation: true, + } + this.convergenceBatches.set(id, { plan }) + return plan + } + + async applyConvergeBatch( + batchId: string, + now = Date.now(), + ): Promise { + if (!/^batch_[A-Za-z0-9_-]{12,}$/.test(batchId)) { + throw new ActionPlanError(400, 'INVALID_BATCH_ID', 'Invalid organization batch ID') + } + const stored = this.convergenceBatches.get(batchId) + if (!stored || new Date(stored.plan.expiresAt).getTime() <= now) { + this.convergenceBatches.delete(batchId) + throw new ActionPlanError(409, 'PLAN_EXPIRED', 'Organization plan is missing or expired') + } + + const successes: ConvergenceBatchApplyResult['successes'] = [] + const failures: ConvergenceBatchApplyResult['failures'] = [] + for (const group of stored.plan.groups) { + try { + const result = await this.apply(group.planId, now) + successes.push({ + groupId: group.groupId, + name: group.name, + transactionId: result.recovery.transactionId || '', + projectionCount: result.verification.projectionCount || group.entranceCount, + recoveryExpiresAt: result.recovery.expiresAt || null, + }) + } catch (error) { + failures.push({ + groupId: group.groupId, + name: group.name, + code: error instanceof ActionPlanError || error instanceof SkillAccessError + ? error.code + : 'ORGANIZATION_FAILED', + error: error instanceof Error ? error.message : String(error), + }) + } finally { + this.plans.delete(group.planId) + } + } + this.convergenceBatches.delete(batchId) + return { + ok: true, + action: 'converge-batch', + status: failures.length === 0 + ? 'complete' + : successes.length === 0 + ? 'failed' + : 'partial', + successes, + failures, + } + } + + async previewConvergeRollback(transactionId: string, now = Date.now()): Promise { + if (!/^convergence_[A-Za-z0-9_-]{12,}$/.test(transactionId)) { + throw new ActionPlanError(400, 'INVALID_TRANSACTION_ID', 'Invalid convergence transaction ID') + } + const transaction = await getConvergenceTransaction(transactionId) + if (!transaction || transaction.status !== 'applied') { + throw new ActionPlanError(404, 'TRANSACTION_NOT_FOUND', 'Applied convergence transaction not found') + } + + const projectionStateHashes = [] + for (const projection of transaction.projections) { + await verifyDirectoryProjection(projection.targetPath, transaction.canonicalPath) + const trashState = await getTrashState(projection.trashId) + if (!trashState) { + throw new ActionPlanError(409, 'RECOVERY_EXPIRED', 'An original duplicate is no longer available in trash') + } + const stateHash = await filesystemStateHash(projection.targetPath) + if (!stateHash) throw new ActionPlanError(409, 'STALE_TRANSACTION', 'Projection disappeared before rollback') + projectionStateHashes.push({ targetPath: projection.targetPath, stateHash }) + } + + const id = planId() + const plan: ActionPlan = { + id, + action: 'converge-rollback', + createdAt: iso(now), + expiresAt: iso(now + ACTION_PLAN_TTL_MS), + skillIds: [transaction.canonicalSkillId], + preconditions: projectionStateHashes.map((entry) => ({ + resourceId: `projection:${projectionRecordId(entry.targetPath)}`, + contentHash: entry.stateHash, + })), + changes: transaction.projections.map((projection) => ({ + operation: 'unlink', + resourceLabel: `恢复 ${path.basename(projection.targetPath)} 的原独立副本`, + beforeHash: transaction.packageHash, + afterHash: transaction.packageHash, + })), + risks: ['将移除本次收敛创建的软链,并从回收站恢复原独立副本。'], + recovery: { + transactionId, + expiresAt: transaction.recoveryExpiresAt, + }, + requiresExplicitConfirmation: true, + } + this.plans.set(id, { + plan, + payload: { kind: 'converge-rollback', transactionId, projectionStateHashes }, + }) + return plan + } + + async previewDelete(input: { + skillId: string + installationId?: string + }, now = Date.now()): Promise { + return this.previewBatchDelete([input], now) + } + + async previewBatchDelete( + items: { skillId: string; installationId?: string }[], + now = Date.now(), + ): Promise { + if (!Array.isArray(items) || items.length === 0 || items.length > 50) { + throw new ActionPlanError(400, 'INVALID_BATCH', 'Select between 1 and 50 Skills') + } + const uniqueIds = new Set(items.map((item) => item.skillId)) + if (uniqueIds.size !== items.length) { + throw new ActionPlanError(400, 'DUPLICATE_SKILL_ID', 'Duplicate Skill ID in batch') + } + const authorizedItems = [] + for (const item of items) { + authorizedItems.push(await authorizeSkillWrite(item.skillId, item.installationId)) + } + await assertNoManagedProjectionDependents('delete', authorizedItems) + const id = planId() + const plan: ActionPlan = { + id, + action: 'delete', + createdAt: iso(now), + expiresAt: iso(now + ACTION_PLAN_TTL_MS), + skillIds: authorizedItems.map((item) => item.skill.id), + preconditions: authorizedItems.map((item) => ({ + resourceId: item.skill.id, + contentHash: item.contentHash, + })), + changes: authorizedItems.map((item) => ({ + operation: 'delete', + resourceLabel: item.skill.name, + beforeHash: item.contentHash, + afterHash: null, + })), + risks: [ + `将把 ${authorizedItems.length} 个安装位置移入 SkillManager 回收站`, + ...(authorizedItems.some((item) => item.skill.installations.length > 1) + ? ['其中至少一个物理 Skill 有多个安装入口,请确认删除的是所选入口'] + : []), + ], + recovery: {}, + requiresExplicitConfirmation: true, + } + this.plans.set(id, { + plan, + payload: { + kind: 'delete', + items: authorizedItems.map((item) => ({ + skillId: item.skill.id, + installationId: item.installation.id, + contentHash: item.contentHash, + })), + }, + }) + return plan + } + + async previewTrashRestore(trashId: string, now = Date.now()): Promise { + if (!isValidTrashId(trashId)) { + throw new ActionPlanError(400, 'INVALID_TRASH_ID', 'Invalid opaque trash ID') + } + const state = await getTrashState(trashId) + if (!state) throw new ActionPlanError(404, 'TRASH_NOT_FOUND', 'Trash entry not found') + await authorizeTrashRestoreTarget(state.meta) + const targetHash = await filesystemStateHash(state.meta.originalPath) + const targetExists = targetHash !== null + const id = planId() + const plan: ActionPlan = { + id, + action: 'trash-restore', + createdAt: iso(now), + expiresAt: iso(now + ACTION_PLAN_TTL_MS), + skillIds: [], + preconditions: [ + { resourceId: `trash:${trashId}`, contentHash: state.contentHash }, + { resourceId: `restore-target:${trashId}`, contentHash: targetHash }, + ], + changes: [ + ...(targetExists + ? [{ + operation: 'move' as const, + resourceLabel: `${state.meta.skillName}(当前同名安装 → 回收站)`, + beforeHash: targetHash, + afterHash: null, + }] + : []), + { + operation: 'create', + resourceLabel: `${state.meta.skillName}(从回收站恢复)`, + beforeHash: null, + afterHash: state.contentHash, + }, + ], + risks: targetExists + ? ['目标位置已有内容;确认后会先把现有内容移入新的回收站条目,再恢复所选版本'] + : ['将在原授权安装根中恢复该 Skill'], + recovery: {}, + requiresExplicitConfirmation: true, + } + this.plans.set(id, { + plan, + payload: { + kind: 'trash-restore', + trashId, + trashHash: state.contentHash, + targetHash, + }, + }) + return plan + } + + async apply(planIdValue: string, now = Date.now()): Promise<{ + ok: true + action: ActionPlanAction + verification: { + contentHash?: string + packageHash?: string + removed?: boolean + projectionCount?: number + rolledBack?: boolean + } + recovery: { + snapshotId?: string + trashId?: string + trashIds?: string[] + transactionId?: string + retentionDays?: number + expiresAt?: string + } + }> { + const stored = this.plans.get(planIdValue) + if (!stored || new Date(stored.plan.expiresAt).getTime() <= now) { + this.plans.delete(planIdValue) + throw new ActionPlanError(409, 'PLAN_EXPIRED', 'Action plan is missing or expired') + } + + if (stored.plan.action === 'converge') { + if (stored.payload?.kind !== 'converge') { + throw new ActionPlanError(500, 'PLAN_CORRUPT', 'Convergence plan payload missing') + } + const payload = stored.payload + const catalog = await getCatalog(true) + const canonical = catalog.skills.find((skill) => skill.id === payload.canonicalSkillId) + if (!canonical) { + this.plans.delete(planIdValue) + throw new ActionPlanError(409, 'STALE_PLAN', 'Canonical Skill no longer exists') + } + const canonicalAuthorized = await authorizeSkillWrite( + canonical.id, + payload.canonicalInstallationId, + ) + if (canonicalAuthorized.skillDir !== payload.canonicalPath) { + this.plans.delete(planIdValue) + throw new ActionPlanError(409, 'STALE_PLAN', 'Canonical Skill path changed after preview') + } + const currentPackageHash = await hashSkillDirectory(canonicalAuthorized.skillDir) + if (currentPackageHash !== payload.packageHash) { + this.plans.delete(planIdValue) + throw new ActionPlanError(409, 'STALE_PLAN', 'Canonical Skill package changed after preview') + } + const canonicalBoundary = await auditSkillPackageBoundary(canonicalAuthorized.skillDir) + if (!canonicalBoundary.safe) { + this.plans.delete(planIdValue) + throw new ActionPlanError(409, 'UNSAFE_PACKAGE_LINK', 'Canonical Skill link boundary changed after preview') + } + + const authorizedTargets = [] + for (const target of payload.targets) { + const skill = catalog.skills.find((entry) => entry.id === target.skillId) + const installation = skill?.installations.find((entry) => entry.id === target.installationId) + if (!skill || !installation) { + this.plans.delete(planIdValue) + throw new ActionPlanError(409, 'STALE_PLAN', 'A duplicate installation disappeared after preview') + } + if (skill.packageHash !== payload.packageHash) { + this.plans.delete(planIdValue) + throw new ActionPlanError(409, 'STALE_PLAN', `${skill.name} is no longer an exact package copy`) + } + const authorized = await authorizeProjectionMutation(catalog, skill, installation) + if (path.resolve(authorized.targetPath) !== path.resolve(target.targetPath)) { + this.plans.delete(planIdValue) + throw new ActionPlanError(409, 'STALE_PLAN', 'A projection path changed after preview') + } + if (await filesystemStateHash(authorized.targetPath) !== target.stateHash) { + this.plans.delete(planIdValue) + throw new ActionPlanError(409, 'STALE_PLAN', `${skill.name} changed after preview`) + } + authorizedTargets.push({ ...authorized, skill, installation }) + } + + const orderedTargets = [...authorizedTargets].sort((a, b) => ( + Number(b.installation.isSymlink) - Number(a.installation.isSymlink) + )) + const moved: Array<{ + trashId: string + expiresAt: string + target: typeof orderedTargets[number] + }> = [] + const created: Array<{ + targetPath: string + strategy: 'symlink' | 'junction' + }> = [] + try { + for (const target of orderedTargets) { + const trash = await moveToTrash(target.targetPath, target.skill.name) + moved.push({ trashId: trash.id, expiresAt: trash.expiresAt, target }) + } + + for (const item of moved) { + const strategy = await this.createDirectoryProjection( + canonicalAuthorized.skillDir, + item.target.targetPath, + ) + created.push({ targetPath: item.target.targetPath, strategy }) + await verifyDirectoryProjection(item.target.targetPath, canonicalAuthorized.skillDir) + } + + const timestamp = new Date().toISOString() + const records: ProjectionRecord[] = created.map((entry) => { + const target = moved.find((item) => item.target.targetPath === entry.targetPath)!.target + return { + id: projectionRecordId(entry.targetPath), + canonicalPath: canonicalAuthorized.skillDir, + targetPath: entry.targetPath, + agentId: target.installation.agent, + scope: target.installation.scope === 'project' ? 'project' : 'global', + strategy: entry.strategy, + sourcePackageHash: payload.packageHash, + createdAt: timestamp, + lastVerifiedAt: timestamp, + } + }) + const transaction: ConvergenceTransaction = { + id: newConvergenceId(), + canonicalSkillId: canonical.id, + canonicalPath: canonicalAuthorized.skillDir, + packageHash: payload.packageHash, + createdAt: timestamp, + recoveryExpiresAt: moved.map((item) => item.expiresAt).sort()[0], + status: 'applied', + projections: moved.map((item) => ({ + recordId: projectionRecordId(item.target.targetPath), + targetPath: item.target.targetPath, + trashId: item.trashId, + skillId: item.target.skill.id, + installationId: item.target.installation.id, + })), + } + await commitConvergence(records, transaction) + this.plans.delete(planIdValue) + invalidateCatalog() + return { + ok: true, + action: 'converge', + verification: { + packageHash: payload.packageHash, + projectionCount: records.length, + }, + recovery: { + transactionId: transaction.id, + trashId: moved[0]?.trashId, + trashIds: moved.map((item) => item.trashId), + retentionDays: TRASH_RETENTION_DAYS, + expiresAt: transaction.recoveryExpiresAt, + }, + } + } catch (error) { + const rollbackErrors: string[] = [] + for (const entry of [...created].reverse()) { + try { + await removeDirectoryProjection(entry.targetPath, canonicalAuthorized.skillDir) + } catch (rollbackError: any) { + rollbackErrors.push(rollbackError?.message || String(rollbackError)) + } + } + for (const item of [...moved].reverse()) { + try { + await restoreFromTrash(item.trashId) + } catch (rollbackError: any) { + rollbackErrors.push(rollbackError?.message || String(rollbackError)) + } + } + this.plans.delete(planIdValue) + invalidateCatalog() + if (rollbackErrors.length > 0) { + throw new ActionPlanError( + 500, + 'CONVERGENCE_ROLLBACK_FAILED', + `Convergence failed and automatic recovery was incomplete: ${rollbackErrors.join('; ')}`, + ) + } + if (error instanceof ActionPlanError) throw error + throw new ActionPlanError( + 500, + 'CONVERGENCE_FAILED', + `Convergence failed and all moved entries were restored: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + if (stored.plan.action === 'converge-rollback') { + if (stored.payload?.kind !== 'converge-rollback') { + throw new ActionPlanError(500, 'PLAN_CORRUPT', 'Convergence rollback payload missing') + } + const payload = stored.payload + const transaction = await getConvergenceTransaction(payload.transactionId) + if (!transaction || transaction.status !== 'applied') { + this.plans.delete(planIdValue) + throw new ActionPlanError(409, 'STALE_PLAN', 'Convergence transaction is no longer applied') + } + const catalog = await getCatalog(true) + for (const projection of transaction.projections) { + authorizeProjectionTargetPath(catalog, projection.targetPath) + await verifyDirectoryProjection(projection.targetPath, transaction.canonicalPath) + const expected = payload.projectionStateHashes.find( + (entry) => path.resolve(entry.targetPath) === path.resolve(projection.targetPath), + ) + if (!expected || await filesystemStateHash(projection.targetPath) !== expected.stateHash) { + this.plans.delete(planIdValue) + throw new ActionPlanError(409, 'STALE_PLAN', 'A managed projection changed after rollback preview') + } + if (!await getTrashState(projection.trashId)) { + this.plans.delete(planIdValue) + throw new ActionPlanError(409, 'RECOVERY_EXPIRED', 'An original duplicate is no longer recoverable') + } + } + + const displacedLinkTrashIds: string[] = [] + try { + for (const projection of transaction.projections) { + const restored = await restoreFromTrashDetailed(projection.trashId, true) + if (restored.displacedTrashId) displacedLinkTrashIds.push(restored.displacedTrashId) + } + await completeConvergenceRollback( + transaction.id, + transaction.projections.map((projection) => projection.recordId), + ) + for (const trashId of displacedLinkTrashIds) await purgeOne(trashId).catch(() => false) + } catch (error) { + await setConvergenceTransactionStatus(transaction.id, 'partial').catch(() => {}) + this.plans.delete(planIdValue) + invalidateCatalog() + throw new ActionPlanError( + 500, + 'ROLLBACK_PARTIAL', + `Rollback stopped with recoverable data left in trash: ${error instanceof Error ? error.message : String(error)}`, + ) + } + this.plans.delete(planIdValue) + invalidateCatalog() + return { + ok: true, + action: 'converge-rollback', + verification: { rolledBack: true, projectionCount: transaction.projections.length }, + recovery: { transactionId: transaction.id }, + } + } + + if (stored.plan.action === 'trash-restore') { + if (stored.payload?.kind !== 'trash-restore') { + throw new ActionPlanError(500, 'PLAN_CORRUPT', 'Trash restore plan payload missing') + } + const state = await getTrashState(stored.payload.trashId) + if (!state || state.contentHash !== stored.payload.trashHash) { + this.plans.delete(planIdValue) + throw new ActionPlanError(409, 'STALE_PLAN', 'Trash entry changed after preview') + } + await authorizeTrashRestoreTarget(state.meta) + const targetHash = await filesystemStateHash(state.meta.originalPath) + if (targetHash !== stored.payload.targetHash) { + this.plans.delete(planIdValue) + throw new ActionPlanError(409, 'STALE_PLAN', 'Restore target changed after preview') + } + const result = await restoreFromTrashDetailed( + stored.payload.trashId, + targetHash !== null, + ) + this.plans.delete(planIdValue) + invalidateCatalog() + return { + ok: true, + action: 'trash-restore', + verification: { contentHash: await filesystemStateHash(result.meta.originalPath) || undefined }, + recovery: result.displacedTrashId ? { trashId: result.displacedTrashId } : {}, + } + } + + if (stored.plan.action === 'delete') { + if (stored.payload?.kind !== 'delete') { + throw new ActionPlanError(500, 'PLAN_CORRUPT', 'Delete plan payload missing') + } + const authorizedItems = [] + for (const item of stored.payload.items) { + const authorizedItem = await authorizeSkillWrite(item.skillId, item.installationId) + if (authorizedItem.contentHash !== item.contentHash) { + this.plans.delete(planIdValue) + throw new ActionPlanError(409, 'STALE_PLAN', `${authorizedItem.skill.name} changed after preview`) + } + authorizedItems.push(authorizedItem) + } + await assertNoManagedProjectionDependents('delete', authorizedItems) + const trashIds: string[] = [] + try { + for (const item of authorizedItems) { + const trash = await moveToTrash(item.installation.path, item.skill.name) + trashIds.push(trash.id) + } + } catch (error) { + for (const trashId of [...trashIds].reverse()) { + await restoreFromTrash(trashId).catch(() => {}) + } + throw error + } + this.plans.delete(planIdValue) + invalidateCatalog() + return { + ok: true, + action: 'delete', + verification: { removed: true }, + recovery: { + trashId: trashIds[0], + trashIds, + }, + } + } + + if (!stored.installationId) { + throw new ActionPlanError(500, 'PLAN_CORRUPT', 'Installation reference missing') + } + const skillId = stored.plan.skillIds[0] + let authorized + try { + authorized = await authorizeSkillWrite(skillId, stored.installationId) + } catch (error) { + if (error instanceof SkillAccessError && error.statusCode === 409) { + throw new ActionPlanError(409, 'STALE_PLAN', error.message) + } + throw error + } + const expectedHash = stored.plan.preconditions[0]?.contentHash + if (authorized.contentHash !== expectedHash) { + this.plans.delete(planIdValue) + throw new ActionPlanError(409, 'STALE_PLAN', 'Skill content changed after preview') + } + + if (stored.plan.action === 'edit') { + const content = stored.payload?.kind === 'edit' ? stored.payload.content : null + if (content == null) throw new ActionPlanError(500, 'PLAN_CORRUPT', 'Edit plan payload missing') + const beforeContent = await fs.readFile(authorized.skillMdPath, 'utf8') + const snapshot = await createSnapshot( + authorized.skillDir, + authorized.skill.name, + 'SkillManager 编辑前自动快照', + 'auto', + ) + try { + await atomicWriteFile(authorized.skillMdPath, content) + await parseSkillMd(authorized.skillMdPath) + const verified = await fs.readFile(authorized.skillMdPath, 'utf8') + if (verified !== content) throw new Error('Post-write verification mismatch') + await createSnapshot( + authorized.skillDir, + authorized.skill.name, + 'SkillManager 已确认编辑', + 'auto', + ) + } catch (error) { + await atomicWriteFile(authorized.skillMdPath, beforeContent).catch(() => {}) + throw error + } + this.plans.delete(planIdValue) + invalidateCatalog() + return { + ok: true, + action: 'edit', + verification: { contentHash: hashContent(content) }, + recovery: { snapshotId: snapshot.id }, + } + } + + if (stored.plan.action === 'restore') { + if (stored.payload?.kind !== 'restore') { + throw new ActionPlanError(500, 'PLAN_CORRUPT', 'Restore plan payload missing') + } + const version = await getVersion(authorized.skillDir, stored.payload.versionId) + if (!version || version.contentHash !== stored.payload.versionHash) { + this.plans.delete(planIdValue) + throw new ActionPlanError(409, 'STALE_PLAN', 'Target version changed after preview') + } + const snapshot = await createSnapshot( + authorized.skillDir, + authorized.skill.name, + `SkillManager 回滚前快照 (${version.id})`, + 'auto', + ) + await restoreVersionAtomically(authorized.skillDir, version) + await createSnapshot( + authorized.skillDir, + authorized.skill.name, + `SkillManager 已确认回滚到 ${version.id}`, + 'auto', + ) + this.plans.delete(planIdValue) + invalidateCatalog() + return { + ok: true, + action: 'restore', + verification: { contentHash: hashContent(version.content) }, + recovery: { snapshotId: snapshot.id }, + } + } + + if (stored.plan.action === 'copy' || stored.plan.action === 'move') { + if (stored.payload?.kind !== 'transfer') { + throw new ActionPlanError(500, 'PLAN_CORRUPT', 'Transfer plan payload missing') + } + const transferPayload = stored.payload + const source = await authorizeSkillWrite( + stored.plan.skillIds[0], + stored.installationId, + ) + if (stored.plan.action === 'move') { + await assertNoManagedProjectionDependents('move', [source]) + } + const targetRoot = authorized.catalog.authorizedRoots.find( + (root) => root.id === transferPayload.targetRootId && root.editable, + ) + if (!targetRoot) throw new ActionPlanError(409, 'STALE_PLAN', 'Target root is no longer writable') + let currentTargetRoot: string + try { + currentTargetRoot = await fs.realpath(targetRoot.path) + } catch { + throw new ActionPlanError(409, 'STALE_PLAN', 'Target root no longer exists') + } + if (currentTargetRoot !== targetRoot.realPath) { + throw new ActionPlanError(409, 'STALE_PLAN', 'Target root changed after preview') + } + const targetDir = path.join(targetRoot.path, transferPayload.targetName) + try { + await fs.lstat(targetDir) + throw new ActionPlanError(409, 'STALE_PLAN', 'Target appeared after preview') + } catch (error: any) { + if (error instanceof ActionPlanError) throw error + if (error?.code !== 'ENOENT') throw error + } + + const stageDir = await stageSkillCopy(authorized.skillDir, targetRoot.path) + let trashId: string | undefined + try { + await fs.rename(stageDir, targetDir) + if (stored.plan.action === 'move') { + try { + trashId = (await moveToTrash( + authorized.installation.path, + authorized.skill.name, + )).id + } catch (error) { + await fs.rm(targetDir, { recursive: true, force: true }).catch(() => {}) + throw error + } + } + } catch (error) { + await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {}) + throw error + } + this.plans.delete(planIdValue) + invalidateCatalog() + return { + ok: true, + action: stored.plan.action, + verification: { contentHash: authorized.contentHash }, + recovery: trashId ? { trashId } : {}, + } + } + + throw new ActionPlanError(500, 'PLAN_CORRUPT', 'Unsupported action plan') + } +} + +export const actionPlans = new ActionPlanService() + +async function restoreVersionAtomically(skillDir: string, version: Version): Promise { + const parent = path.dirname(skillDir) + const name = path.basename(skillDir) + const suffix = crypto.randomBytes(8).toString('hex') + const temporaryDir = path.join(parent, `.${name}.skillmanager-restore-${suffix}`) + const backupDir = path.join(parent, `.${name}.skillmanager-backup-${suffix}`) + + await fs.cp(skillDir, temporaryDir, { + recursive: true, + preserveTimestamps: true, + errorOnExist: true, + }) + try { + await atomicWriteFile(path.join(temporaryDir, 'SKILL.md'), version.content) + for (const [relative, content] of Object.entries(version.files)) { + const target = path.resolve(temporaryDir, relative) + if (!isPathInside(temporaryDir, target)) { + throw new ActionPlanError(400, 'INVALID_SNAPSHOT_PATH', 'Snapshot contains an unsafe file path') + } + await fs.mkdir(path.dirname(target), { recursive: true }) + await atomicWriteFile(target, content) + } + await parseSkillMd(path.join(temporaryDir, 'SKILL.md')) + + await fs.rename(skillDir, backupDir) + try { + await fs.rename(temporaryDir, skillDir) + } catch (error) { + await fs.rename(backupDir, skillDir).catch(() => {}) + throw error + } + await fs.rm(backupDir, { recursive: true, force: true }).catch(() => {}) + } catch (error) { + await fs.rm(temporaryDir, { recursive: true, force: true }).catch(() => {}) + throw error + } +} + +async function stageSkillCopy(sourceDir: string, targetRoot: string): Promise { + await fs.mkdir(targetRoot, { recursive: true }) + const stageDir = await fs.mkdtemp(path.join(targetRoot, '.skillmanager-transfer-')) + try { + await copyTreeSecure(sourceDir, stageDir) + await parseSkillMd(path.join(stageDir, 'SKILL.md')) + return stageDir + } catch (error) { + await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {}) + throw error + } +} + +async function copyTreeSecure(sourceDir: string, targetDir: string): Promise { + const entries = await fs.readdir(sourceDir, { withFileTypes: true }) + for (const entry of entries) { + const source = path.join(sourceDir, entry.name) + const target = path.join(targetDir, entry.name) + if (entry.isSymbolicLink()) { + throw new ActionPlanError( + 400, + 'NESTED_SYMLINK_UNSUPPORTED', + `Copy/move blocked because the Skill contains a symlink: ${entry.name}`, + ) + } + if (entry.isDirectory()) { + await fs.mkdir(target, { recursive: true }) + await copyTreeSecure(source, target) + } else if (entry.isFile()) { + await fs.copyFile(source, target) + } + } +} + +async function authorizeTrashRestoreTarget(meta: TrashMeta): Promise { + const targetPath = path.resolve(meta.originalPath) + const catalog = await getCatalog(true) + const root = catalog.authorizedRoots.find((candidate) => ( + candidate.editable && + candidate.scope !== 'plugin' && + path.resolve(candidate.path) === path.dirname(targetPath) + )) + if (!root) { + throw new ActionPlanError( + 403, + 'TRASH_TARGET_NOT_AUTHORIZED', + 'The original location is no longer a writable Skill root', + ) + } + let currentRoot: string + try { + currentRoot = await fs.realpath(root.path) + } catch { + throw new ActionPlanError(409, 'STALE_PLAN', 'The original Skill root no longer exists') + } + if (currentRoot !== root.realPath) { + throw new ActionPlanError(409, 'STALE_PLAN', 'The original Skill root changed after scanning') + } + if (meta.isSymlink) { + if (!meta.symlinkTarget) { + throw new ActionPlanError(400, 'INVALID_TRASH_ENTRY', 'Symlink trash entry has no target') + } + const symlinkTarget = path.isAbsolute(meta.symlinkTarget) + ? meta.symlinkTarget + : path.resolve(root.path, meta.symlinkTarget) + let realTarget: string + try { + realTarget = await fs.realpath(symlinkTarget) + } catch { + throw new ActionPlanError(409, 'STALE_PLAN', 'The saved symlink target no longer exists') + } + if (!isPathInside(currentRoot, realTarget)) { + throw new ActionPlanError(403, 'SYMLINK_BOUNDARY', 'The saved symlink target is outside the authorized root') + } + } +} + +async function filesystemStateHash(target: string): Promise { + let stat + try { + stat = await fs.lstat(target) + } catch (error: any) { + if (error?.code === 'ENOENT') return null + throw error + } + const hash = crypto.createHash('sha256') + if (stat.isSymbolicLink()) { + hash.update('link\0') + hash.update(await fs.readlink(target)) + return hash.digest('hex') + } + if (stat.isFile()) { + hash.update('file\0') + hash.update(await fs.readFile(target)) + return hash.digest('hex') + } + if (!stat.isDirectory()) { + hash.update(`other\0${stat.mode}`) + return hash.digest('hex') + } + hash.update('directory\0') + for (const name of (await fs.readdir(target)).sort()) { + hash.update(name) + hash.update((await filesystemStateHash(path.join(target, name))) || 'missing') + } + return hash.digest('hex') +} diff --git a/server/core/atomic.ts b/server/core/atomic.ts new file mode 100644 index 0000000..4d419bd --- /dev/null +++ b/server/core/atomic.ts @@ -0,0 +1,29 @@ +import fs from 'fs/promises' +import path from 'path' +import crypto from 'crypto' + +export async function atomicWriteFile(filePath: string, content: string): Promise { + const directory = path.dirname(filePath) + const temporaryPath = path.join( + directory, + `.${path.basename(filePath)}.skillmanager-${crypto.randomBytes(8).toString('hex')}.tmp`, + ) + let mode: number | undefined + try { + mode = (await fs.stat(filePath)).mode + } catch {} + + try { + const handle = await fs.open(temporaryPath, 'wx', mode) + try { + await handle.writeFile(content, 'utf8') + await handle.sync() + } finally { + await handle.close() + } + await fs.rename(temporaryPath, filePath) + } catch (error) { + await fs.rm(temporaryPath, { force: true }).catch(() => {}) + throw error + } +} diff --git a/server/core/authorization.ts b/server/core/authorization.ts new file mode 100644 index 0000000..7021175 --- /dev/null +++ b/server/core/authorization.ts @@ -0,0 +1,98 @@ +import fs from 'fs/promises' +import path from 'path' +import crypto from 'crypto' +import { getCatalog } from './catalog.js' +import type { ScanResult, Skill, SkillInstallation } from '../types.js' + +export class SkillAccessError extends Error { + constructor( + public readonly statusCode: number, + public readonly code: string, + message: string, + ) { + super(message) + this.name = 'SkillAccessError' + } +} + +export function isPathInside(root: string, target: string): boolean { + const relative = path.relative(root, target) + return relative === '' || ( + relative !== '..' && + !relative.startsWith('..' + path.sep) && + !path.isAbsolute(relative) + ) +} + +export function hashContent(content: string): string { + return crypto.createHash('sha256').update(content).digest('hex') +} + +export interface AuthorizedSkill { + catalog: ScanResult + skill: Skill + installation: SkillInstallation + skillDir: string + skillMdPath: string + contentHash: string +} + +export async function authorizeSkillWrite( + skillId: string, + installationId?: string, +): Promise { + if (!/^skill_[a-f0-9]{24}$/.test(skillId)) { + throw new SkillAccessError(400, 'INVALID_SKILL_ID', 'Invalid opaque Skill ID') + } + + const catalog = await getCatalog() + const skill = catalog.skills.find((entry) => entry.id === skillId) + if (!skill) throw new SkillAccessError(404, 'SKILL_NOT_FOUND', 'Skill not found') + + const installation = installationId + ? skill.installations.find((entry) => entry.id === installationId) + : skill.installations.find((entry) => entry.editable) + if (!installation) { + throw new SkillAccessError(403, 'INSTALLATION_NOT_FOUND', 'Writable installation not found') + } + if (!installation.editable || installation.scope === 'plugin') { + throw new SkillAccessError(403, 'SKILL_READ_ONLY', installation.readOnlyReason || 'Skill is read-only') + } + + const root = catalog.authorizedRoots.find((entry) => entry.id === installation.authorizationRootId) + if (!root || !root.editable) { + throw new SkillAccessError(403, 'ROOT_NOT_AUTHORIZED', 'Skill root is not authorized for writes') + } + if (!isPathInside(root.path, installation.path)) { + throw new SkillAccessError(403, 'PATH_OUTSIDE_ROOT', 'Installation path is outside its authorized root') + } + + let currentRoot: string + let currentTarget: string + try { + currentRoot = await fs.realpath(root.path) + currentTarget = await fs.realpath(installation.path) + } catch { + throw new SkillAccessError(409, 'STALE_SKILL', 'Skill installation changed after scanning') + } + if (!isPathInside(currentRoot, currentTarget) || currentTarget !== skill.realPath) { + throw new SkillAccessError(403, 'SYMLINK_BOUNDARY', 'Resolved Skill path is outside the authorized root') + } + + const skillMdPath = path.join(currentTarget, 'SKILL.md') + let content: string + try { + content = await fs.readFile(skillMdPath, 'utf8') + } catch { + throw new SkillAccessError(409, 'SKILL_FILE_MISSING', 'SKILL.md is missing or unreadable') + } + + return { + catalog, + skill, + installation, + skillDir: currentTarget, + skillMdPath, + contentHash: hashContent(content), + } +} diff --git a/server/core/catalog.ts b/server/core/catalog.ts new file mode 100644 index 0000000..411ee01 --- /dev/null +++ b/server/core/catalog.ts @@ -0,0 +1,31 @@ +import { fullScan } from '../scanner/discovery.js' +import type { ScanResult, Skill } from '../types.js' + +export type CatalogMode = 'all' | 'current-project' + +const cachedCatalogs = new Map() + +export function getCachedCatalog(mode: CatalogMode = 'all'): ScanResult | null { + return cachedCatalogs.get(mode) || null +} + +export async function getCatalog( + refresh = false, + mode: CatalogMode = 'all', +): Promise { + if (refresh || !cachedCatalogs.has(mode)) { + cachedCatalogs.set(mode, await fullScan({ + projectDiscovery: mode === 'current-project' ? 'current-only' : 'all', + })) + } + return cachedCatalogs.get(mode)! +} + +export function invalidateCatalog(): void { + cachedCatalogs.clear() +} + +export async function findSkillById(skillId: string): Promise { + const catalog = await getCatalog() + return catalog.skills.find((skill) => skill.id === skillId) || null +} diff --git a/server/core/dashboard-context.ts b/server/core/dashboard-context.ts new file mode 100644 index 0000000..863ff0f --- /dev/null +++ b/server/core/dashboard-context.ts @@ -0,0 +1,178 @@ +import type { ScanResult } from '../types.js' + +export type DashboardView = + | 'dashboard' + | 'current-project' + | 'doctor' + | 'skills' + | 'versions' + | 'conflicts' + | 'convergence' + +export interface DashboardIntent { + view: DashboardView + skillId?: string + findingId?: string + groupId?: string +} + +export class DashboardRouteError extends Error { + constructor( + public readonly code: 'INVALID_ROUTE' | 'MISSING_TARGET' | 'TARGET_NOT_FOUND', + message: string, + ) { + super(message) + this.name = 'DashboardRouteError' + } +} + +const VIEWS = new Set([ + 'dashboard', + 'current-project', + 'doctor', + 'skills', + 'versions', + 'conflicts', + 'convergence', +]) +const SKILL_ID = /^skill_[a-f0-9]{24}$/ +const FINDING_ID = /^finding_[a-f0-9]{20}$/ +const GROUP_ID = /^group_[a-f0-9]{20}$/ + +function optionalSingle(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +export function parseDashboardQuery(query: Record): DashboardIntent { + const unknownKeys = Object.keys(query).filter( + (key) => !['view', 'skill', 'finding', 'group'].includes(key), + ) + if (unknownKeys.length > 0) { + throw new DashboardRouteError('INVALID_ROUTE', 'The Dashboard link contains unsupported parameters') + } + + const rawView = optionalSingle(query.view) || 'dashboard' + if (!VIEWS.has(rawView as DashboardView)) { + throw new DashboardRouteError('INVALID_ROUTE', 'The Dashboard view is not supported') + } + const intent: DashboardIntent = { + view: rawView as DashboardView, + skillId: optionalSingle(query.skill), + findingId: optionalSingle(query.finding), + groupId: optionalSingle(query.group), + } + const targetCount = [intent.skillId, intent.findingId, intent.groupId] + .filter(Boolean).length + if (targetCount > 1) { + throw new DashboardRouteError('INVALID_ROUTE', 'A Dashboard link can target only one item') + } + + if (intent.skillId && !SKILL_ID.test(intent.skillId)) { + throw new DashboardRouteError('INVALID_ROUTE', 'The Skill ID format is invalid') + } + if (intent.findingId && !FINDING_ID.test(intent.findingId)) { + throw new DashboardRouteError('INVALID_ROUTE', 'The finding ID format is invalid') + } + if (intent.groupId && !GROUP_ID.test(intent.groupId)) { + throw new DashboardRouteError('INVALID_ROUTE', 'The conflict group ID format is invalid') + } + + if ( + (intent.view === 'dashboard' || intent.view === 'current-project') && + targetCount > 0 + ) { + throw new DashboardRouteError('INVALID_ROUTE', 'This Dashboard view does not accept a target') + } + if (intent.view === 'doctor' && intent.skillId) { + throw new DashboardRouteError('INVALID_ROUTE', 'Doctor links accept only a finding ID') + } + if ((intent.view === 'skills' || intent.view === 'versions') && (intent.findingId || intent.groupId)) { + throw new DashboardRouteError('INVALID_ROUTE', 'Skill links accept only a Skill ID') + } + if (intent.view === 'conflicts' && (intent.skillId || intent.findingId)) { + throw new DashboardRouteError('INVALID_ROUTE', 'Conflict links accept only a group ID') + } + if (intent.view === 'convergence' && (intent.skillId || intent.findingId)) { + throw new DashboardRouteError('INVALID_ROUTE', 'Convergence links accept only a group ID') + } + if (intent.view === 'versions' && !intent.skillId) { + throw new DashboardRouteError('MISSING_TARGET', 'A version-history link requires a Skill ID') + } + return intent +} + +export function dashboardTarget(input: DashboardIntent): string { + const query: Record = { view: input.view } + if (input.skillId) query.skill = input.skillId + if (input.findingId) query.finding = input.findingId + if (input.groupId) query.group = input.groupId + const parsed = parseDashboardQuery(query) + if (parsed.view === 'dashboard') return '/' + const params = new URLSearchParams({ view: parsed.view }) + if (parsed.skillId) params.set('skill', parsed.skillId) + if (parsed.findingId) params.set('finding', parsed.findingId) + if (parsed.groupId) params.set('group', parsed.groupId) + return `/?${params.toString()}` +} + +export function resolveDashboardContext( + catalog: ScanResult, + query: Record, +) { + const intent = parseDashboardQuery(query) + let target: Record | null = null + + if (intent.skillId) { + const skill = catalog.skills.find((candidate) => candidate.id === intent.skillId) + if (!skill) { + throw new DashboardRouteError( + 'TARGET_NOT_FOUND', + 'This Skill link is no longer present in the current scan', + ) + } + target = { kind: 'skill', id: skill.id, name: skill.name } + } else if (intent.findingId) { + const finding = catalog.health.diagnostics.find( + (candidate) => candidate.id === intent.findingId, + ) + if (!finding) { + throw new DashboardRouteError( + 'TARGET_NOT_FOUND', + 'This diagnostic finding changed or is no longer present', + ) + } + target = { + kind: 'finding', + id: finding.id, + type: finding.type, + severity: finding.severity, + title: finding.title, + affectedSkillIds: finding.affectedSkillIds, + } + } else if (intent.groupId) { + const group = intent.view === 'convergence' + ? catalog.convergenceGroups.find((candidate) => candidate.id === intent.groupId) + : catalog.conflicts.find((candidate) => candidate.id === intent.groupId) + if (!group) { + throw new DashboardRouteError( + 'TARGET_NOT_FOUND', + 'This group changed or is no longer present', + ) + } + target = { + kind: intent.view === 'convergence' ? 'convergence' : 'conflict', + id: group.id, + name: group.name, + skillIds: group.skills.map((skill) => skill.id), + } + } else if (intent.view === 'current-project') { + target = { kind: 'current-project', ...catalog.currentProject } + } + + return { + schemaVersion: 1, + ok: true as const, + route: intent, + target, + } +} diff --git a/server/core/opaque-ids.ts b/server/core/opaque-ids.ts new file mode 100644 index 0000000..cef3ddd --- /dev/null +++ b/server/core/opaque-ids.ts @@ -0,0 +1,47 @@ +import crypto from 'crypto' + +function opaqueId(prefix: string, value: unknown): string { + return `${prefix}_${crypto + .createHash('sha256') + .update(JSON.stringify(value)) + .digest('hex') + .slice(0, 20)}` +} + +export function diagnosticId(input: { + type: string + severity: string + title: string + detail: string + affectedSkillIds: string[] +}): string { + return opaqueId('finding', { + type: input.type, + severity: input.severity, + title: input.title, + detail: input.detail, + affectedSkillIds: [...input.affectedSkillIds].sort(), + }) +} + +export function conflictId(name: string, skillIds: string[]): string { + return opaqueId('group', { name, skillIds: [...skillIds].sort() }) +} + +export function convergenceGroupId( + kind: string, + key: string, + skillIds: string[], +): string { + return opaqueId('group', { kind, key, skillIds: [...skillIds].sort() }) +} + +export function projectionIssueId(input: { + agent: string + scope: string + targetPath: string + status: string + managedBy: string +}): string { + return opaqueId('projection', input) +} diff --git a/server/index.ts b/server/index.ts index 15e0f9c..97b489c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,28 +1,64 @@ import Fastify from 'fastify' -import cors from '@fastify/cors' import websocket from '@fastify/websocket' import fastifyStatic from '@fastify/static' import path from 'path' import { fileURLToPath } from 'url' import fs from 'fs' +import crypto from 'crypto' import { skillRoutes } from './routes/skills.js' import { manageRoutes } from './routes/manage.js' import { versionRoutes } from './routes/versions.js' import { similarityRoutes } from './routes/similarity.js' import { trashRoutes } from './routes/trash.js' import { syncRoutes } from './routes/sync.js' -import { startWatcher } from './scanner/watcher.js' +import { actionRoutes } from './routes/actions.js' +import { startWatcher, stopWatcher } from './scanner/watcher.js' import { invalidateCache } from './routes/skills.js' import { fullScan } from './scanner/discovery.js' import { purgeExpired as purgeExpiredTrash } from './trash/store.js' import type { WebSocket } from 'ws' +import { SessionManager } from './security/session.js' +import { registerHttpSecurity, requireWebSocketSession } from './security/http.js' +import { migrateLegacyData } from './config/migration.js' +import { + PACKAGE_VERSION, + PRODUCT_NAME, + RUNTIME_CAPABILITIES, +} from './runtime/identity.js' +import { + removeRuntimeStateIfOwned, + writeRuntimeState, +} from './runtime/state.js' +import { explicitProjectRoot } from './config/paths.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const app = Fastify({ logger: false }) +const app = Fastify({ + logger: false, + // A local browser can keep HTTP/1.1 sockets alive indefinitely. Managed + // shutdowns must not wait on those client-owned connections. + forceCloseConnections: true, +}) +const sessions = new SessionManager() +const instanceId = /^instance_[A-Za-z0-9_-]{12,}$/.test( + process.env.SKILLMANAGER_INSTANCE_ID || '', +) + ? process.env.SKILLMANAGER_INSTANCE_ID! + : `instance_${crypto.randomBytes(18).toString('base64url')}` +const controlToken = (process.env.SKILLMANAGER_CONTROL_TOKEN || '').length >= 32 + ? process.env.SKILLMANAGER_CONTROL_TOKEN! + : crypto.randomBytes(32).toString('base64url') +const managed = process.env.SKILLMANAGER_MANAGED === '1' +const processIdentity = `skillmanager:${PACKAGE_VERSION}:${instanceId}` +let scannerHealth: { + status: 'initializing' | 'ready' | 'error' + totalSkills?: number + durationMs?: number + error?: string +} = { status: 'initializing' } -await app.register(cors, { origin: true }) +await registerHttpSecurity(app, sessions, controlToken) await app.register(websocket) await app.register(skillRoutes) await app.register(manageRoutes) @@ -30,15 +66,29 @@ await app.register(versionRoutes) await app.register(similarityRoutes) await app.register(trashRoutes) await app.register(syncRoutes) +await app.register(actionRoutes) -// Health check -app.get('/api/health', async () => ({ status: 'ok' })) +// Public health contains only process identity and redacted scanner readiness. +const healthResponse = () => ({ + status: 'ok' as const, + product: PRODUCT_NAME, + version: PACKAGE_VERSION, + instanceId, + processIdentity, + managed, + scanner: scannerHealth, +}) +app.get('/api/health', async () => healthResponse()) +app.get('/api/v1/health', async () => healthResponse()) // WebSocket for real-time updates const wsClients = new Set() app.register(async function (fastify) { - fastify.get('/ws', { websocket: true }, (socket) => { + fastify.get('/ws', { + websocket: true, + preValidation: (request, reply) => requireWebSocketSession(request, reply, sessions), + }, (socket) => { wsClients.add(socket) socket.on('close', () => wsClients.delete(socket)) }) @@ -53,16 +103,17 @@ function broadcast(data: any) { } } -// Start file watcher +// The watcher starts after the initial scan so startup does not make both +// subsystems crawl the same library concurrently. let debounceTimer: ReturnType | null = null -startWatcher((event) => { +function handleWatchEvent(event: { type: string; path: string }) { if (debounceTimer) clearTimeout(debounceTimer) debounceTimer = setTimeout(() => { invalidateCache() broadcast({ type: 'change', event }) }, 500) -}) +} // Serve built frontend static files (production mode) // Try several possible locations for the dist/web directory. Must check both @@ -134,10 +185,56 @@ async function listenWithRetry(startPort: number): Promise { } const basePort = parseInt(process.env.PORT || '3456') +let runtimeStateOwned = false +let shuttingDown = false + +async function shutdown(exitCode = 0): Promise { + if (shuttingDown) return + shuttingDown = true + if (debounceTimer) { + clearTimeout(debounceTimer) + debounceTimer = null + } + for (const client of wsClients) { + try { + client.terminate() + } catch {} + } + wsClients.clear() + await stopWatcher().catch(() => {}) + await app.close().catch(() => {}) + if (runtimeStateOwned) await removeRuntimeStateIfOwned(instanceId).catch(() => {}) + process.exit(exitCode) +} try { + const migration = await migrateLegacyData() + const migrationFailures = migration.entries.filter((entry) => entry.status === 'failed') + if (migrationFailures.length > 0) { + console.warn(`SkillManager legacy migration had ${migrationFailures.length} failure(s). Old data was not deleted.`) + } const actualPort = await listenWithRetry(basePort) - const url = `http://localhost:${actualPort}` + const url = `http://127.0.0.1:${actualPort}` + await writeRuntimeState({ + schemaVersion: 1, + product: 'SkillManager', + version: PACKAGE_VERSION, + pid: process.pid, + host: '127.0.0.1', + port: actualPort, + baseUrl: url, + startedAt: new Date().toISOString(), + instanceId, + processIdentity, + managed, + controlToken, + projectConfigured: Boolean(explicitProjectRoot), + projectRoot: explicitProjectRoot, + capabilities: [...RUNTIME_CAPABILITIES], + }) + runtimeStateOwned = true + process.once('SIGINT', () => { void shutdown(0) }) + process.once('SIGTERM', () => { void shutdown(0) }) // Purge expired trash entries on startup (best-effort, non-blocking failures) try { @@ -159,27 +256,27 @@ try { if (result.stats.total === 0) { scanSummary += '\n\x1b[33m⚠️ No skills found. Run `curl ' + url + '/api/debug` to see scanned paths.\x1b[0m' } + scannerHealth = { + status: 'ready', + totalSkills: result.stats.total, + durationMs: result.durationMs, + } } catch (e: any) { scanSummary = `\x1b[31m❌ Initial scan failed: ${e?.message || e}\x1b[0m` + scannerHealth = { + status: 'error', + error: e?.code || e?.name || 'scan_failed', + } } + const watcherStarted = !shuttingDown && startWatcher(handleWatchEvent) - console.log(`\n🚀 Claude Skill Hub running at \x1b[36m${url}\x1b[0m`) - if (staticRoot) { - console.log(`🌐 Web UI: \x1b[36m${url}\x1b[0m`) - } - console.log(`🔍 Debug: \x1b[36m${url}/api/debug\x1b[0m`) + console.log(`\n🚀 SkillManager running at \x1b[36m${url}\x1b[0m`) + console.log(`🌐 Open: \x1b[36mskillmanager open\x1b[0m`) console.log(scanSummary) - console.log(`👀 File watcher active`) - console.log(`\x1b[90m💡 下次启动直接敲: \x1b[0m\x1b[36mskill-hub\x1b[0m\x1b[90m (或访问 ${url})\x1b[0m\n`) - - if (staticRoot && process.env.SKILL_HUB_NO_OPEN !== '1') { - const { exec } = await import('child_process') - const cmd = process.platform === 'darwin' ? 'open' - : process.platform === 'win32' ? 'start' - : 'xdg-open' - exec(`${cmd} ${url}`, () => {}) - } + console.log(watcherStarted ? `👀 File watcher active` : `👀 File watcher inactive`) + console.log(`\x1b[90m💡 下次启动直接敲: \x1b[0m\x1b[36mskillmanager\x1b[0m\x1b[90m\x1b[0m\n`) } catch (err) { + if (runtimeStateOwned) await removeRuntimeStateIfOwned(instanceId).catch(() => {}) console.error('\x1b[31m❌ Failed to start server:\x1b[0m', err) process.exit(1) } diff --git a/server/mcp/catalog-provider.ts b/server/mcp/catalog-provider.ts new file mode 100644 index 0000000..fdbe5d6 --- /dev/null +++ b/server/mcp/catalog-provider.ts @@ -0,0 +1,46 @@ +import fs from 'fs/promises' +import { fileURLToPath } from 'url' +import type { McpServer } from '@modelcontextprotocol/server' +import type { ScanResult } from '../types.js' + +let catalogModulePromise: Promise | null = null +let configuredProjectRoot: string | null = null + +async function validFileRoot(uri: string): Promise { + if (!uri.startsWith('file:')) return null + try { + const root = await fs.realpath(fileURLToPath(uri)) + return (await fs.stat(root)).isDirectory() ? root : null + } catch { + return null + } +} + +export async function clientProjectRoot(server: McpServer): Promise { + try { + const response = await server.server.listRoots() + for (const root of response.roots) { + const candidate = await validFileRoot(root.uri) + if (candidate) return candidate + } + } catch {} + return null +} + +async function scanner(projectRoot: string | null): Promise { + if (!catalogModulePromise) { + if (projectRoot) { + configuredProjectRoot = projectRoot + process.env.SKILLMANAGER_PROJECT_ROOT = projectRoot + } + catalogModulePromise = import('../scanner/discovery.js') + } + return catalogModulePromise +} + +export async function scanCatalog(projectRoot: string | null): Promise { + if (configuredProjectRoot && projectRoot && configuredProjectRoot !== projectRoot) { + throw new Error('The MCP connection workspace root changed; reconnect SkillManager for the new project') + } + return (await scanner(projectRoot)).fullScan() +} diff --git a/server/mcp/contracts.ts b/server/mcp/contracts.ts new file mode 100644 index 0000000..3ed13c5 --- /dev/null +++ b/server/mcp/contracts.ts @@ -0,0 +1,209 @@ +import os from 'os' +import type { ScanResult, Skill, SkillInstallation } from '../types.js' +export { dashboardTarget } from '../core/dashboard-context.js' + +export type ResponseFormat = 'json' | 'markdown' + +export interface ToolResourceLink { + uri: string + name: string + title: string + description: string +} + +function redactMetadata(value: unknown): string | null { + if (typeof value !== 'string') return null + let output = value + const homes = [os.homedir(), process.env.SKILLMANAGER_FIXTURE_ROOT] + for (const home of homes) { + if (home) output = output.replaceAll(home, '$HOME') + } + return output +} + +function projectIdForInstallation( + installation: SkillInstallation, + catalog: ScanResult, +): string | null { + if (!installation.projectPath) return null + return catalog.projects.find((project) => project.path === installation.projectPath)?.id || null +} + +export function publicSkillSummary(skill: Skill, catalog: ScanResult) { + return { + id: skill.id, + name: redactMetadata(skill.name) || skill.id, + description: redactMetadata(skill.description) || '', + category: skill.category, + enabled: skill.enabled, + hasConflict: skill.hasConflict, + contentHash: skill.contentHash, + editable: skill.editable, + readOnlyReason: skill.readOnlyReason || null, + exposedTo: skill.exposedTo, + fileCount: skill.files.length, + lastModified: skill.lastModified, + invocation: { + userInvocable: skill.frontmatter['user-invocable'] ?? null, + disableModelInvocation: skill.frontmatter['disable-model-invocation'] ?? null, + argumentHint: redactMetadata(skill.frontmatter['argument-hint']), + model: redactMetadata(skill.frontmatter.model), + effort: redactMetadata(skill.frontmatter.effort), + context: redactMetadata(skill.frontmatter.context), + allowedTools: redactMetadata(skill.frontmatter['allowed-tools']), + }, + installations: skill.installations.map((installation) => ({ + id: installation.id, + scope: installation.scope, + agent: installation.agent, + source: installation.source, + projectId: installation.projectId || projectIdForInstallation(installation, catalog), + isSymlink: installation.isSymlink, + editable: installation.editable, + readOnlyReason: installation.readOnlyReason || null, + exposedTo: installation.exposedTo, + })), + } +} + +export function publicFinding(finding: ScanResult['health']['diagnostics'][number]) { + return { + id: finding.id, + type: finding.type, + severity: finding.severity, + title: finding.title, + detail: finding.detail, + affectedSkillIds: finding.affectedSkillIds, + } +} + +export function publicLibraryDiagnosis(catalog: ScanResult) { + return { + scannedAt: new Date().toISOString(), + durationMs: catalog.durationMs, + health: { + level: catalog.health.level, + score: catalog.health.score, + summary: catalog.health.summary, + }, + stats: catalog.stats, + findings: catalog.health.diagnostics.map(publicFinding), + conflictGroups: catalog.conflicts.map((group) => ({ + id: group.id, + name: group.name, + skills: group.skills.map((skill) => ({ id: skill.id, name: skill.name })), + })), + mergeSuggestions: catalog.mergeSuggestions.map((suggestion) => ({ + category: suggestion.category, + categoryName: suggestion.categoryName, + reason: suggestion.reason, + similarity: suggestion.similarity, + skills: suggestion.skills, + })), + } +} + +export function publicSkillDiagnosis(skill: Skill, catalog: ScanResult) { + const confirmed: { code: string; evidence: string }[] = [] + const likely: { code: string; evidence: string }[] = [] + const needsRuntimeTest: { code: string; evidence: string }[] = [] + const blocked: { code: string; evidence: string }[] = [] + + confirmed.push({ + code: 'INSTALLATIONS_FOUND', + evidence: `${skill.installations.length} installation(s), exposed to ${skill.exposedTo.join(', ') || 'no recognized agent'}`, + }) + if (!skill.enabled) { + confirmed.push({ code: 'DISABLED', evidence: 'The Skill is disabled in the detected Agent configuration' }) + likely.push({ code: 'DISABLED_CAUSES_NO_TRIGGER', evidence: 'A disabled Skill cannot be selected by that Agent' }) + } + if (!skill.exposedTo.includes('codex')) { + confirmed.push({ code: 'NOT_EXPOSED_TO_CODEX', evidence: 'No active installation exposes this Skill to Codex' }) + likely.push({ code: 'CODEX_CANNOT_DISCOVER', evidence: 'Codex is unlikely to trigger a Skill it cannot discover' }) + } + if (!skill.description || skill.description.trim().length < 10) { + confirmed.push({ code: 'WEAK_DESCRIPTION', evidence: 'The frontmatter description is missing or shorter than 10 characters' }) + likely.push({ code: 'TRIGGER_INTENT_UNCLEAR', evidence: 'The Skill does not describe enough trigger context for reliable selection' }) + } + if (skill.hasConflict) { + const competitors = catalog.conflicts + .flatMap((group) => group.skills) + .filter((candidate) => candidate.id !== skill.id && candidate.name === skill.name) + likely.push({ + code: 'SAME_NAME_COMPETITION', + evidence: `${competitors.length} different physical Skill(s) use the same name`, + }) + } + for (const finding of catalog.health.diagnostics.filter((item) => item.affectedSkillIds.includes(skill.id))) { + confirmed.push({ code: `FINDING_${finding.type.toUpperCase()}`, evidence: finding.title }) + } + if (!skill.editable) { + confirmed.push({ code: 'READ_ONLY', evidence: skill.readOnlyReason || 'No writable authorized installation' }) + } + needsRuntimeTest.push({ + code: 'PROMPT_MATCH_TEST', + evidence: 'Static inspection cannot prove which Skill a model will choose for a specific prompt; test the exact prompt in a new Codex thread', + }) + if (skill.installations.length === 0) { + blocked.push({ code: 'NO_INSTALLATION', evidence: 'No active installation is available for runtime testing' }) + } + + return { + skill: publicSkillSummary(skill, catalog), + assessment: blocked.length > 0 + ? 'blocked' + : likely.length > 0 + ? 'likely_issue' + : 'needs_runtime_test', + confirmed, + likely, + needs_runtime_test: needsRuntimeTest, + blocked, + } +} + +export function paginate(items: T[], limit: number, offset: number) { + const page = items.slice(offset, offset + limit) + const hasMore = offset + page.length < items.length + return { + total_count: items.length, + count: page.length, + offset, + items: page, + has_more: hasMore, + next_offset: hasMore ? offset + page.length : null, + } +} + +export function formatToolResult( + structuredContent: Record, + format: ResponseFormat, + markdown: string, + resourceLink?: ToolResourceLink, +) { + const text = format === 'json' + ? JSON.stringify(structuredContent, null, 2) + : markdown + const content: ( + | { type: 'text'; text: string } + | { + type: 'resource_link' + uri: string + name: string + title: string + description: string + mimeType: string + } + )[] = [{ type: 'text', text }] + if (resourceLink) { + content.push({ + type: 'resource_link', + ...resourceLink, + mimeType: 'text/html', + }) + } + return { + content, + structuredContent, + } +} diff --git a/server/mcp/index.ts b/server/mcp/index.ts new file mode 100644 index 0000000..9d707d9 --- /dev/null +++ b/server/mcp/index.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env node +import { serveStdio } from '@modelcontextprotocol/server/stdio' +import { createSkillManagerMcpServer } from './server.js' + +const handle = serveStdio(createSkillManagerMcpServer, { + legacy: 'serve', + onerror: (error) => { + console.error(`[SkillManager MCP] ${error.name}`) + }, +}) + +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => { + void handle.close().finally(() => process.exit(0)) + }) +} diff --git a/server/mcp/runtime-client.ts b/server/mcp/runtime-client.ts new file mode 100644 index 0000000..4b0aa64 --- /dev/null +++ b/server/mcp/runtime-client.ts @@ -0,0 +1,81 @@ +import { execFile } from 'child_process' +import fs from 'fs/promises' +import path from 'path' +import { fileURLToPath } from 'url' + +export class RuntimeClientError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message) + this.name = 'RuntimeClientError' + } +} + +let cachedPackageRoot: string | null = null + +async function packageRoot(): Promise { + if (cachedPackageRoot) return cachedPackageRoot + const current = path.dirname(fileURLToPath(import.meta.url)) + const candidates = [ + path.resolve(current, '../..'), + path.resolve(current, '../../..'), + ] + for (const candidate of candidates) { + try { + await fs.access(path.join(candidate, 'bin', 'cli.js')) + cachedPackageRoot = candidate + return candidate + } catch {} + } + throw new RuntimeClientError('PACKAGE_ROOT_NOT_FOUND', 'SkillManager package files are unavailable') +} + +async function executeCli(args: string[]): Promise> { + const root = await packageRoot() + return new Promise((resolve, reject) => { + execFile( + process.execPath, + [path.join(root, 'bin', 'cli.js'), ...args], + { + cwd: root, + env: { + ...process.env, + SKILLMANAGER_NO_OPEN: '1', + SKILL_HUB_NO_OPEN: '1', + }, + timeout: 25_000, + maxBuffer: 1024 * 1024, + }, + (error, stdout) => { + let output: Record | null = null + try { + output = JSON.parse(stdout.trim()) as Record + } catch {} + if (error || !output) { + const code = typeof output?.code === 'string' ? output.code : 'CLI_FAILED' + const message = typeof output?.error === 'string' + ? output.error + : 'SkillManager lifecycle command failed; run skillmanager doctor --json' + reject(new RuntimeClientError(code, message)) + return + } + resolve(output) + }, + ) + }) +} + +export async function getRuntimeStatus(): Promise> { + return executeCli(['status', '--json']) +} + +export async function openRuntimeDashboard(input: { + target: string + projectRoot?: string | null +}): Promise> { + const args = ['open', '--json', '--target', input.target] + if (input.projectRoot) args.push('--project', input.projectRoot) + return executeCli(args) +} diff --git a/server/mcp/server.ts b/server/mcp/server.ts new file mode 100644 index 0000000..48d2af5 --- /dev/null +++ b/server/mcp/server.ts @@ -0,0 +1,409 @@ +import { McpServer } from '@modelcontextprotocol/server' +import * as z from 'zod/v4' +import { + clientProjectRoot, + scanCatalog, +} from './catalog-provider.js' +import { + dashboardTarget, + formatToolResult, + paginate, + publicLibraryDiagnosis, + publicSkillDiagnosis, + publicSkillSummary, + type ResponseFormat, +} from './contracts.js' +import { + getRuntimeStatus, + openRuntimeDashboard, + RuntimeClientError, +} from './runtime-client.js' +import { PACKAGE_VERSION } from '../runtime/identity.js' +import { + DashboardRouteError, + resolveDashboardContext, + type DashboardView, +} from '../core/dashboard-context.js' + +const ResponseFormatSchema = z.enum(['markdown', 'json']).default('markdown') +const SkillIdSchema = z.string().regex(/^skill_[a-f0-9]{24}$/, 'Use an opaque Skill ID returned by SkillManager') +const FindingIdSchema = z.string().regex(/^finding_[a-f0-9]{20}$/, 'Use an opaque finding ID returned by SkillManager') +const GroupIdSchema = z.string().regex(/^group_[a-f0-9]{20}$/, 'Use an opaque conflict group ID returned by SkillManager') +const ProjectIdSchema = z.string().regex(/^project_[a-f0-9]{20}$/, 'Use an opaque project ID returned by SkillManager') +const ViewSchema = z.enum(['dashboard', 'current-project', 'doctor', 'skills', 'versions', 'conflicts']) +const annotations = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +} as const +const localLaunchAnnotations = { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +} as const + +type ToolResult = ReturnType | { + content: { type: 'text'; text: string }[] + isError: true +} + +function safeToolError(error: unknown): ToolResult { + if (error instanceof RuntimeClientError) { + return { + isError: true, + content: [{ + type: 'text', + text: `${error.code}: ${error.message}`, + }], + } + } + const message = error instanceof Error && error.message.startsWith('The MCP connection workspace') + ? error.message + : 'SkillManager could not complete this local inspection. Run `skillmanager doctor --json` and retry.' + return { + isError: true, + content: [{ type: 'text', text: message }], + } +} + +function publicStatus(raw: Record) { + return { + schemaVersion: 1, + status: raw.status, + version: raw.version, + pid: raw.pid ?? null, + host: raw.host ?? null, + port: raw.port ?? null, + baseUrl: raw.baseUrl ?? null, + startedAt: raw.startedAt ?? null, + managed: raw.managed ?? null, + projectConfigured: raw.projectConfigured ?? false, + capabilities: Array.isArray(raw.capabilities) ? raw.capabilities : [], + scanner: raw.scanner ?? null, + reason: raw.reason ?? null, + } +} + +function linkMarkdown(title: string, output: Record): string { + return `# ${title}\n\nOpen this one-time local link within 60 seconds:\n\n${output.launchUrl}` +} + +function dashboardResourceLink( + launchUrl: unknown, + title: string, +) { + if (typeof launchUrl !== 'string') return undefined + return { + uri: launchUrl, + name: 'skillmanager-dashboard', + title, + description: '60 秒内有效、仅可使用一次的本地 SkillManager 安全入口', + } +} + +async function prepareDashboardLaunch( + server: McpServer, + input: { + view: DashboardView + skillId?: string + findingId?: string + groupId?: string + }, +) { + try { + const target = dashboardTarget(input) + const projectRoot = await clientProjectRoot(server) + if (input.skillId || input.findingId || input.groupId) { + const query: Record = { view: input.view } + if (input.skillId) query.skill = input.skillId + if (input.findingId) query.finding = input.findingId + if (input.groupId) query.group = input.groupId + resolveDashboardContext(await scanCatalog(projectRoot), query) + } + return { target, projectRoot } + } catch (error) { + if (error instanceof DashboardRouteError) { + throw new RuntimeClientError(error.code, error.message) + } + throw error + } +} + +export function createSkillManagerMcpServer(): McpServer { + const server = new McpServer( + { name: 'skillmanager-mcp-server', version: PACKAGE_VERSION }, + { + instructions: 'Inspect local Agent Skills with opaque IDs. Never request absolute paths, Skill bodies, tokens, or write confirmation through MCP. Open the local Dashboard for previews and confirmed writes.', + }, + ) + + server.registerTool( + 'get_status', + { + title: 'Get SkillManager Status', + description: 'Return the local SkillManager lifecycle state, version, capabilities, and redacted scanner readiness. Does not start the service or return control credentials.', + inputSchema: z.object({ response_format: ResponseFormatSchema }).strict(), + annotations, + }, + async ({ response_format }): Promise => { + try { + const output = publicStatus(await getRuntimeStatus()) + return formatToolResult( + output, + response_format as ResponseFormat, + `# SkillManager status\n\n- Status: ${output.status}\n- Version: ${output.version}\n- Scanner: ${(output.scanner as { status?: string } | null)?.status || 'not running'}`, + ) + } catch (error) { + return safeToolError(error) + } + }, + ) + + const DashboardInputSchema = z.object({ + view: ViewSchema.default('dashboard').describe('Dashboard view to open'), + skill_id: SkillIdSchema.optional(), + finding_id: FindingIdSchema.optional(), + group_id: GroupIdSchema.optional(), + response_format: ResponseFormatSchema, + }).strict() + + server.registerTool( + 'open_dashboard', + { + title: 'Open SkillManager Dashboard', + description: 'Start or reuse the local SkillManager service and return a 60-second single-use Dashboard launch URL. This may start one local process but does not edit, delete, overwrite, or sync any Skill.', + inputSchema: DashboardInputSchema, + annotations: localLaunchAnnotations, + }, + async ({ view, skill_id, finding_id, group_id, response_format }): Promise => { + try { + const { target, projectRoot } = await prepareDashboardLaunch(server, { + view, + skillId: skill_id, + findingId: finding_id, + groupId: group_id, + }) + const raw = await openRuntimeDashboard({ target, projectRoot }) + const output = { + schemaVersion: 1, + status: raw.status, + reused: raw.reused, + launchUrl: raw.launchUrl, + expiresInSeconds: raw.expiresInSeconds, + target, + } + return formatToolResult( + output, + response_format as ResponseFormat, + linkMarkdown('SkillManager Dashboard', output), + dashboardResourceLink(output.launchUrl, '打开 SkillManager'), + ) + } catch (error) { + return safeToolError(error) + } + }, + ) + + server.registerTool( + 'list_skills', + { + title: 'List Local Skills', + description: 'List redacted Skill summaries with optional Agent, scope, project, and keyword filters. Returns opaque IDs and pagination metadata; never returns absolute paths or full SKILL.md content.', + inputSchema: z.object({ + agent: z.string().trim().min(1).max(64).optional(), + scope: z.enum(['global', 'project', 'plugin']).optional(), + project_id: ProjectIdSchema.optional(), + query: z.string().trim().max(200).optional(), + limit: z.number().int().min(1).max(100).default(20), + offset: z.number().int().min(0).default(0), + response_format: ResponseFormatSchema, + }).strict(), + annotations, + }, + async ({ agent, scope, project_id, query, limit, offset, response_format }): Promise => { + try { + const projectRoot = await clientProjectRoot(server) + const catalog = await scanCatalog(projectRoot) + const normalizedQuery = query?.toLowerCase() + const summaries = catalog.skills + .filter((skill) => !agent || skill.exposedTo.includes(agent as never)) + .filter((skill) => !scope || skill.installations.some((installation) => installation.scope === scope)) + .map((skill) => publicSkillSummary(skill, catalog)) + .filter((skill) => !project_id || skill.installations.some((installation) => installation.projectId === project_id)) + .filter((skill) => !normalizedQuery || ( + skill.name.toLowerCase().includes(normalizedQuery) || + skill.description.toLowerCase().includes(normalizedQuery) + )) + .sort((a, b) => a.name.localeCompare(b.name)) + const output = { + schemaVersion: 1, + filters: { + agent: agent || null, + scope: scope || null, + projectId: project_id || null, + query: query || null, + }, + ...paginate(summaries, limit, offset), + } + const lines = output.items.map((skill) => ( + `- **${skill.name}** (${skill.id}) — ${skill.description || 'No description'}` + )) + return formatToolResult( + output, + response_format as ResponseFormat, + `# Skills\n\n${output.total_count} total; showing ${output.count}.\n\n${lines.join('\n') || 'No matching Skills.'}`, + ) + } catch (error) { + return safeToolError(error) + } + }, + ) + + server.registerTool( + 'get_skill_summary', + { + title: 'Get Skill Summary', + description: 'Return metadata, invocation settings, Agent exposure, and authorized installation summaries for one opaque Skill ID. Does not return full Skill text, file contents, symlink targets, or absolute paths.', + inputSchema: z.object({ + skill_id: SkillIdSchema, + response_format: ResponseFormatSchema, + }).strict(), + annotations, + }, + async ({ skill_id, response_format }): Promise => { + try { + const projectRoot = await clientProjectRoot(server) + const catalog = await scanCatalog(projectRoot) + const skill = catalog.skills.find((candidate) => candidate.id === skill_id) + if (!skill) return safeToolError(new RuntimeClientError('SKILL_NOT_FOUND', 'No current Skill matches that opaque ID; list Skills again')) + const output = { + schemaVersion: 1, + skill: publicSkillSummary(skill, catalog), + } + return formatToolResult( + output, + response_format as ResponseFormat, + `# ${output.skill.name}\n\n${output.skill.description || 'No description'}\n\n- ID: ${output.skill.id}\n- Exposed to: ${output.skill.exposedTo.join(', ') || 'none'}\n- Editable: ${output.skill.editable ? 'yes' : 'no'}`, + ) + } catch (error) { + return safeToolError(error) + } + }, + ) + + server.registerTool( + 'diagnose_library', + { + title: 'Diagnose Skill Library', + description: 'Return redacted library health, conflicts, merge suggestions, and paginated diagnostic findings. Findings are static evidence and do not claim a model runtime decision.', + inputSchema: z.object({ + severity: z.enum(['info', 'warn', 'danger']).optional(), + limit: z.number().int().min(1).max(100).default(20), + offset: z.number().int().min(0).default(0), + response_format: ResponseFormatSchema, + }).strict(), + annotations, + }, + async ({ severity, limit, offset, response_format }): Promise => { + try { + const projectRoot = await clientProjectRoot(server) + const catalog = await scanCatalog(projectRoot) + const diagnosis = publicLibraryDiagnosis(catalog) + const findings = severity + ? diagnosis.findings.filter((finding) => finding.severity === severity) + : diagnosis.findings + const output = { + schemaVersion: 1, + scannedAt: diagnosis.scannedAt, + durationMs: diagnosis.durationMs, + health: diagnosis.health, + stats: diagnosis.stats, + findings: paginate(findings, limit, offset), + conflictGroups: diagnosis.conflictGroups.slice(0, 20), + mergeSuggestions: diagnosis.mergeSuggestions.slice(0, 20), + truncatedGroups: diagnosis.conflictGroups.length > 20 || diagnosis.mergeSuggestions.length > 20, + } + const findingLines = output.findings.items.map((finding) => ( + `- **${finding.severity.toUpperCase()}** ${finding.title} (${finding.id})` + )) + return formatToolResult( + output, + response_format as ResponseFormat, + `# Skill library diagnosis\n\n- Health: ${output.health.level} (${output.health.score}/100)\n- Summary: ${output.health.summary}\n\n${findingLines.join('\n') || 'No matching findings.'}`, + ) + } catch (error) { + return safeToolError(error) + } + }, + ) + + server.registerTool( + 'diagnose_skill', + { + title: 'Diagnose One Skill', + description: 'Diagnose why one Skill may not trigger using static evidence. Separates confirmed facts, likely causes, runtime tests still needed, and blockers; never claims a model choice was proven.', + inputSchema: z.object({ + skill_id: SkillIdSchema, + response_format: ResponseFormatSchema, + }).strict(), + annotations, + }, + async ({ skill_id, response_format }): Promise => { + try { + const projectRoot = await clientProjectRoot(server) + const catalog = await scanCatalog(projectRoot) + const skill = catalog.skills.find((candidate) => candidate.id === skill_id) + if (!skill) return safeToolError(new RuntimeClientError('SKILL_NOT_FOUND', 'No current Skill matches that opaque ID; list Skills again')) + const diagnosis = publicSkillDiagnosis(skill, catalog) + const output = { schemaVersion: 1, ...diagnosis } + return formatToolResult( + output, + response_format as ResponseFormat, + `# Trigger diagnosis: ${skill.name}\n\n- Assessment: ${diagnosis.assessment}\n- Confirmed: ${diagnosis.confirmed.length}\n- Likely causes: ${diagnosis.likely.length}\n- Runtime tests needed: ${diagnosis.needs_runtime_test.length}`, + ) + } catch (error) { + return safeToolError(error) + } + }, + ) + + server.registerTool( + 'get_deep_link', + { + title: 'Get SkillManager Deep Link', + description: 'Start or reuse SkillManager and return one 60-second single-use launch URL for an opaque Skill, diagnostic finding, version page, current project, or conflict group. Does not perform the underlying write action.', + inputSchema: DashboardInputSchema, + annotations: localLaunchAnnotations, + }, + async ({ view, skill_id, finding_id, group_id, response_format }): Promise => { + try { + const { target, projectRoot } = await prepareDashboardLaunch(server, { + view, + skillId: skill_id, + findingId: finding_id, + groupId: group_id, + }) + const raw = await openRuntimeDashboard({ target, projectRoot }) + const output = { + schemaVersion: 1, + target, + launchUrl: raw.launchUrl, + expiresInSeconds: raw.expiresInSeconds, + reused: raw.reused, + } + return formatToolResult( + output, + response_format as ResponseFormat, + linkMarkdown('SkillManager deep link', output), + dashboardResourceLink(output.launchUrl, '打开 SkillManager 指定页面'), + ) + } catch (error) { + return safeToolError(error) + } + }, + ) + + return server +} diff --git a/server/projections/store.ts b/server/projections/store.ts new file mode 100644 index 0000000..112ad30 --- /dev/null +++ b/server/projections/store.ts @@ -0,0 +1,269 @@ +import crypto from 'crypto' +import fs from 'fs/promises' +import path from 'path' +import { atomicWriteFile } from '../core/atomic.js' +import { dataDir } from '../config/paths.js' +import type { AgentId } from '../scanner/agents.js' +import type { ProjectionStrategy } from '../types.js' +import { getTrashMeta } from '../trash/store.js' + +const SCHEMA_VERSION = 1 + +export interface ProjectionRecord { + id: string + canonicalPath: string + targetPath: string + agentId: AgentId + scope: 'global' | 'project' + strategy: Extract + sourcePackageHash: string + createdAt: string + lastVerifiedAt: string +} + +export interface ConvergenceTransactionProjection { + recordId: string + targetPath: string + trashId: string + skillId: string + installationId: string +} + +export interface ConvergenceTransaction { + id: string + canonicalSkillId: string + canonicalPath: string + packageHash: string + createdAt: string + /** Earliest expiry among the original duplicate backups. */ + recoveryExpiresAt?: string + status: 'applied' | 'rolled-back' | 'partial' + projections: ConvergenceTransactionProjection[] +} + +export interface ConvergenceRecoveryStatus { + recoveryExpiresAt: string | null + rollbackAvailable: boolean + daysRemaining: number + reason: 'available' | 'expired' | 'missing-backup' | 'not-applied' +} + +interface ProjectionState { + schemaVersion: 1 + records: ProjectionRecord[] + transactions: ConvergenceTransaction[] +} + +function statePath(): string { + return path.join(dataDir, 'projections.json') +} + +function emptyState(): ProjectionState { + return { schemaVersion: SCHEMA_VERSION, records: [], transactions: [] } +} + +function validRecord(value: unknown): value is ProjectionRecord { + if (!value || typeof value !== 'object') return false + const record = value as ProjectionRecord + return /^projection_[a-f0-9]{20}$/.test(record.id) && + path.isAbsolute(record.canonicalPath) && + path.isAbsolute(record.targetPath) && + ['global', 'project'].includes(record.scope) && + ['symlink', 'junction', 'managed-copy'].includes(record.strategy) && + /^[a-f0-9]{64}$/.test(record.sourcePackageHash) && + typeof record.createdAt === 'string' && + typeof record.lastVerifiedAt === 'string' +} + +function validTransaction(value: unknown): value is ConvergenceTransaction { + if (!value || typeof value !== 'object') return false + const transaction = value as ConvergenceTransaction + return /^convergence_[A-Za-z0-9_-]{12,}$/.test(transaction.id) && + /^skill_[a-f0-9]{24}$/.test(transaction.canonicalSkillId) && + path.isAbsolute(transaction.canonicalPath) && + /^[a-f0-9]{64}$/.test(transaction.packageHash) && + (transaction.recoveryExpiresAt === undefined || ( + typeof transaction.recoveryExpiresAt === 'string' && + Number.isFinite(new Date(transaction.recoveryExpiresAt).getTime()) + )) && + ['applied', 'rolled-back', 'partial'].includes(transaction.status) && + Array.isArray(transaction.projections) && + transaction.projections.every((projection) => ( + /^projection_[a-f0-9]{20}$/.test(projection.recordId) && + path.isAbsolute(projection.targetPath) && + typeof projection.trashId === 'string' && + /^skill_[a-f0-9]{24}$/.test(projection.skillId) && + /^installation_[a-f0-9]{24}$/.test(projection.installationId) + )) +} + +async function readState(): Promise { + try { + const parsed = JSON.parse(await fs.readFile(statePath(), 'utf8')) as Partial + if (parsed.schemaVersion !== SCHEMA_VERSION) return emptyState() + return { + schemaVersion: SCHEMA_VERSION, + records: Array.isArray(parsed.records) ? parsed.records.filter(validRecord) : [], + transactions: Array.isArray(parsed.transactions) + ? parsed.transactions.filter(validTransaction) + : [], + } + } catch { + return emptyState() + } +} + +async function writeState(state: ProjectionState): Promise { + await fs.mkdir(dataDir, { recursive: true }) + await atomicWriteFile(statePath(), JSON.stringify(state, null, 2) + '\n') +} + +let mutationQueue: Promise = Promise.resolve() + +function mutateState(mutator: (state: ProjectionState) => T | Promise): Promise { + const task = mutationQueue.then(async () => { + const state = await readState() + const result = await mutator(state) + await writeState(state) + return result + }) + mutationQueue = task.catch(() => {}) + return task +} + +export function projectionRecordId(targetPath: string): string { + return `projection_${crypto.createHash('sha256').update(path.resolve(targetPath)).digest('hex').slice(0, 20)}` +} + +export function newConvergenceId(): string { + return `convergence_${crypto.randomBytes(18).toString('base64url')}` +} + +export async function listProjectionRecords(): Promise { + return (await readState()).records +} + +export async function upsertProjectionRecords(records: ProjectionRecord[]): Promise { + await mutateState((state) => { + const incoming = new Map(records.map((record) => [record.id, record])) + state.records = state.records + .filter((record) => !incoming.has(record.id)) + .concat(records) + }) +} + +export async function removeProjectionRecords(ids: string[]): Promise { + const selected = new Set(ids) + await mutateState((state) => { + state.records = state.records.filter((record) => !selected.has(record.id)) + }) +} + +export async function saveConvergenceTransaction(transaction: ConvergenceTransaction): Promise { + await mutateState((state) => { + state.transactions = state.transactions + .filter((entry) => entry.id !== transaction.id) + .concat(transaction) + }) +} + +export async function commitConvergence( + records: ProjectionRecord[], + transaction: ConvergenceTransaction, +): Promise { + await mutateState((state) => { + const incoming = new Map(records.map((record) => [record.id, record])) + state.records = state.records + .filter((record) => !incoming.has(record.id)) + .concat(records) + state.transactions = state.transactions + .filter((entry) => entry.id !== transaction.id) + .concat(transaction) + }) +} + +export async function getConvergenceTransaction(id: string): Promise { + return (await readState()).transactions.find((entry) => entry.id === id) || null +} + +export async function listConvergenceTransactions(): Promise { + return (await readState()).transactions + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) +} + +export async function getConvergenceRecoveryStatus( + transaction: ConvergenceTransaction, + now = Date.now(), +): Promise { + const trashEntries = await Promise.all( + transaction.projections.map((projection) => getTrashMeta(projection.trashId)), + ) + const discoveredExpiries = trashEntries + .map((entry) => entry?.expiresAt) + .filter((value): value is string => Boolean(value)) + const recoveryExpiresAt = transaction.recoveryExpiresAt || ( + discoveredExpiries.length > 0 + ? discoveredExpiries.sort()[0] + : null + ) + const expiresAtMs = recoveryExpiresAt + ? new Date(recoveryExpiresAt).getTime() + : Number.NaN + const allBackupsPresent = ( + transaction.projections.length > 0 && + trashEntries.every(Boolean) + ) + + if (transaction.status !== 'applied') { + return { + recoveryExpiresAt, + rollbackAvailable: false, + daysRemaining: 0, + reason: 'not-applied', + } + } + if (!allBackupsPresent) { + return { + recoveryExpiresAt, + rollbackAvailable: false, + daysRemaining: 0, + reason: 'missing-backup', + } + } + if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now) { + return { + recoveryExpiresAt, + rollbackAvailable: false, + daysRemaining: 0, + reason: 'expired', + } + } + return { + recoveryExpiresAt, + rollbackAvailable: true, + daysRemaining: Math.max(1, Math.ceil((expiresAtMs - now) / (24 * 60 * 60 * 1000))), + reason: 'available', + } +} + +export async function setConvergenceTransactionStatus( + id: string, + status: ConvergenceTransaction['status'], +): Promise { + await mutateState((state) => { + const transaction = state.transactions.find((entry) => entry.id === id) + if (transaction) transaction.status = status + }) +} + +export async function completeConvergenceRollback( + transactionId: string, + recordIds: string[], +): Promise { + const selected = new Set(recordIds) + await mutateState((state) => { + state.records = state.records.filter((record) => !selected.has(record.id)) + const transaction = state.transactions.find((entry) => entry.id === transactionId) + if (transaction) transaction.status = 'rolled-back' + }) +} diff --git a/server/routes/actions.ts b/server/routes/actions.ts new file mode 100644 index 0000000..2ddecfc --- /dev/null +++ b/server/routes/actions.ts @@ -0,0 +1,191 @@ +import type { FastifyInstance, FastifyReply } from 'fastify' +import { + actionPlans, + ActionPlanError, +} from '../core/action-plans.js' +import { SkillAccessError } from '../core/authorization.js' + +function sendActionError(reply: FastifyReply, error: unknown) { + if (error instanceof ActionPlanError || error instanceof SkillAccessError) { + return reply.status(error.statusCode).send({ + ok: false, + code: error.code, + error: error.message, + }) + } + throw error +} + +export async function actionRoutes(app: FastifyInstance) { + app.post<{ + Body: { canonicalSkillId?: string; duplicateSkillIds?: string[] } + }>('/api/v1/actions/converge/preview', async (request, reply) => { + try { + const plan = await actionPlans.previewConverge({ + canonicalSkillId: request.body?.canonicalSkillId || '', + duplicateSkillIds: request.body?.duplicateSkillIds || [], + }) + return { schemaVersion: 1, ok: true, plan } + } catch (error) { + return sendActionError(reply, error) + } + }) + + app.post<{ + Body: { + groups?: Array<{ + canonicalSkillId?: string + duplicateSkillIds?: string[] + }> + } + }>('/api/v1/actions/converge-batch/preview', async (request, reply) => { + try { + const plan = await actionPlans.previewConvergeBatch({ + groups: (request.body?.groups || []).map((group) => ({ + canonicalSkillId: group.canonicalSkillId || '', + duplicateSkillIds: group.duplicateSkillIds || [], + })), + }) + return { schemaVersion: 1, ok: true, plan } + } catch (error) { + return sendActionError(reply, error) + } + }) + + app.post<{ + Params: { batchId: string } + Body: { confirm?: boolean } + }>('/api/v1/actions/converge-batch/:batchId/apply', async (request, reply) => { + if (request.body?.confirm !== true) { + return reply.status(400).send({ + ok: false, + code: 'EXPLICIT_CONFIRMATION_REQUIRED', + error: 'Set confirm=true after reviewing the organization plan', + }) + } + try { + return await actionPlans.applyConvergeBatch(request.params.batchId) + } catch (error) { + return sendActionError(reply, error) + } + }) + + app.post<{ + Body: { transactionId?: string } + }>('/api/v1/actions/converge-rollback/preview', async (request, reply) => { + try { + const plan = await actionPlans.previewConvergeRollback( + request.body?.transactionId || '', + ) + return { schemaVersion: 1, ok: true, plan } + } catch (error) { + return sendActionError(reply, error) + } + }) + + app.post<{ + Body: { trashId?: string } + }>('/api/v1/actions/trash-restore/preview', async (request, reply) => { + try { + const plan = await actionPlans.previewTrashRestore(request.body?.trashId || '') + return { ok: true, plan } + } catch (error) { + return sendActionError(reply, error) + } + }) + + app.post<{ + Body: { skillId?: string; installationId?: string; content?: string } + }>('/api/v1/actions/edit/preview', async (request, reply) => { + try { + const plan = await actionPlans.previewEdit({ + skillId: request.body?.skillId || '', + installationId: request.body?.installationId, + content: request.body?.content as string, + }) + return { ok: true, plan } + } catch (error) { + return sendActionError(reply, error) + } + }) + + app.post<{ + Body: { items?: { skillId?: string; installationId?: string }[] } + }>('/api/v1/actions/delete-batch/preview', async (request, reply) => { + try { + const plan = await actionPlans.previewBatchDelete( + (request.body?.items || []).map((item) => ({ + skillId: item.skillId || '', + installationId: item.installationId, + })), + ) + return { ok: true, plan } + } catch (error) { + return sendActionError(reply, error) + } + }) + + for (const action of ['copy', 'move'] as const) { + app.post<{ + Body: { skillId?: string; installationId?: string; targetRootId?: string } + }>(`/api/v1/actions/${action}/preview`, async (request, reply) => { + try { + const plan = await actionPlans.previewTransfer(action, { + skillId: request.body?.skillId || '', + installationId: request.body?.installationId, + targetRootId: request.body?.targetRootId || '', + }) + return { ok: true, plan } + } catch (error) { + return sendActionError(reply, error) + } + }) + } + + app.post<{ + Body: { skillId?: string; installationId?: string; versionId?: string } + }>('/api/v1/actions/restore/preview', async (request, reply) => { + try { + const plan = await actionPlans.previewRestore({ + skillId: request.body?.skillId || '', + installationId: request.body?.installationId, + versionId: request.body?.versionId || '', + }) + return { ok: true, plan } + } catch (error) { + return sendActionError(reply, error) + } + }) + + app.post<{ + Body: { skillId?: string; installationId?: string } + }>('/api/v1/actions/delete/preview', async (request, reply) => { + try { + const plan = await actionPlans.previewDelete({ + skillId: request.body?.skillId || '', + installationId: request.body?.installationId, + }) + return { ok: true, plan } + } catch (error) { + return sendActionError(reply, error) + } + }) + + app.post<{ + Params: { planId: string } + Body: { confirm?: boolean } + }>('/api/v1/actions/:planId/apply', async (request, reply) => { + if (request.body?.confirm !== true) { + return reply.status(400).send({ + ok: false, + code: 'EXPLICIT_CONFIRMATION_REQUIRED', + error: 'Set confirm=true after reviewing the action plan', + }) + } + try { + return await actionPlans.apply(request.params.planId) + } catch (error) { + return sendActionError(reply, error) + } + }) +} diff --git a/server/routes/manage.ts b/server/routes/manage.ts index c10b3fb..47d965a 100644 --- a/server/routes/manage.ts +++ b/server/routes/manage.ts @@ -1,252 +1,27 @@ -import type { FastifyInstance } from 'fastify' -import fs from 'fs/promises' -import path from 'path' -import os from 'os' -import { invalidateCache } from './skills.js' -import { createSnapshot } from '../versioning/store.js' -import { moveToTrash } from '../trash/store.js' +import type { FastifyInstance, FastifyReply } from 'fastify' -const homedir = os.homedir() -const settingsPath = path.join(homedir, '.claude', 'settings.json') - -async function readSettings(): Promise { - try { - const raw = await fs.readFile(settingsPath, 'utf-8') - return JSON.parse(raw) - } catch { - return {} - } -} - -async function writeSettings(settings: any): Promise { - await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf-8') +function previewRequired(reply: FastifyReply) { + return reply.status(410).send({ + ok: false, + code: 'PREVIEW_REQUIRED', + error: 'Legacy path-based writes are disabled. Use /api/v1/actions/*/preview.', + }) } export async function manageRoutes(app: FastifyInstance) { - // Toggle skill enabled/disabled - app.put<{ - Params: { id: string } - Body: { enabled: boolean; skillName: string } - }>('/api/skills/:id/toggle', async (req) => { - const { enabled, skillName } = req.body - const settings = await readSettings() - - if (!settings.permissions) settings.permissions = {} - if (!settings.permissions.deny) settings.permissions.deny = [] - - const rule = `Skill(${skillName})` - const idx = settings.permissions.deny.indexOf(rule) - - if (enabled && idx >= 0) { - // Remove from deny list to enable - settings.permissions.deny.splice(idx, 1) - } else if (!enabled && idx < 0) { - // Add to deny list to disable - settings.permissions.deny.push(rule) - } - - await writeSettings(settings) - invalidateCache() - return { ok: true, enabled } - }) - - // Update SKILL.md content - app.put<{ - Params: { id: string } - Body: { realPath: string; content: string } - }>('/api/skills/:id/content', async (req) => { - const { realPath, content } = req.body - const skillMdPath = path.join(realPath, 'SKILL.md') - - // Verify the file exists - try { - await fs.access(skillMdPath) - } catch { - return { ok: false, error: 'SKILL.md not found' } - } - - // Auto-snapshot before overwriting (save the old version) - const skillName = path.basename(realPath) - try { - await createSnapshot(realPath, skillName, '编辑前自动备份', 'auto') - } catch {} - - await fs.writeFile(skillMdPath, content, 'utf-8') - - // Snapshot the new version - try { - await createSnapshot(realPath, skillName, '通过编辑器保存', 'auto') - } catch {} - - invalidateCache() - return { ok: true } + app.put('/api/skills/:id/toggle', async (_request, reply) => { + return reply.status(410).send({ + ok: false, + code: 'SAFE_TOGGLE_UNAVAILABLE', + error: 'Agent enable/disable writes are disabled until they use a recoverable action plan', + }) }) - // Copy skill to another location - app.post<{ - Body: { - sourcePath: string - targetScope: 'global' | 'project' - projectPath?: string - skillName: string - } - }>('/api/skills/copy', async (req) => { - const { sourcePath, targetScope, projectPath, skillName } = req.body - - let targetDir: string - if (targetScope === 'global') { - targetDir = path.join(homedir, '.claude', 'skills', skillName) - } else if (projectPath) { - targetDir = path.join(projectPath, '.claude', 'skills', skillName) - } else { - return { ok: false, error: 'Project path required for project scope' } - } - - // Resolve source if symlink - let realSource: string - try { - realSource = await fs.realpath(sourcePath) - } catch { - realSource = sourcePath - } - - // Check if target already exists - try { - await fs.access(targetDir) - return { ok: false, error: '目标位置已存在同名 Skill' } - } catch { - // Good — doesn't exist - } - - // Copy directory recursively - await copyDir(realSource, targetDir) - invalidateCache() - return { ok: true, targetDir } - }) - - // Move skill (copy + delete source) - app.post<{ - Body: { - sourcePath: string - targetScope: 'global' | 'project' - projectPath?: string - skillName: string - } - }>('/api/skills/move', async (req) => { - const { sourcePath, targetScope, projectPath, skillName } = req.body - - let targetDir: string - if (targetScope === 'global') { - targetDir = path.join(homedir, '.claude', 'skills', skillName) - } else if (projectPath) { - targetDir = path.join(projectPath, '.claude', 'skills', skillName) - } else { - return { ok: false, error: 'Project path required for project scope' } - } - - let realSource: string - try { - realSource = await fs.realpath(sourcePath) - } catch { - realSource = sourcePath - } - - try { - await fs.access(targetDir) - return { ok: false, error: '目标位置已存在同名 Skill' } - } catch {} - - await copyDir(realSource, targetDir) - - // Remove the source (if symlink, just remove the link; if dir, remove recursively) - const stat = await fs.lstat(sourcePath) - if (stat.isSymbolicLink()) { - await fs.unlink(sourcePath) - } else { - await fs.rm(sourcePath, { recursive: true }) - } - - invalidateCache() - return { ok: true, targetDir } - }) - - // Delete skill (soft delete → recycle bin; 7-day TTL) - app.delete<{ - Params: { id: string } - Body: { path: string; skillName?: string } - }>('/api/skills/:id', async (req, reply) => { - const skillPath = req.body.path - const skillName = req.body.skillName - - try { - const meta = await moveToTrash(skillPath, skillName) - invalidateCache() - return { ok: true, trashId: meta.id, expiresAt: meta.expiresAt } - } catch (err: any) { - reply.status(500) - return { ok: false, error: err?.message || '删除失败' } - } - }) - - // Batch delete — move many skills to trash in one call - app.post<{ - Body: { items: { id: string; path: string; skillName?: string }[] } - }>('/api/skills/batch/delete', async (req, reply) => { - const items = Array.isArray(req.body?.items) ? req.body.items : [] - if (items.length === 0) { - reply.status(400) - return { ok: false, error: '未提供要删除的 skill' } - } - - const results: { - id: string - skillName?: string - ok: boolean - trashId?: string - error?: string - }[] = [] - - for (const item of items) { - if (!item || typeof item.path !== 'string') { - results.push({ id: item?.id || '(unknown)', ok: false, error: '参数不完整' }) - continue - } - try { - const meta = await moveToTrash(item.path, item.skillName) - results.push({ - id: item.id, - skillName: item.skillName, - ok: true, - trashId: meta.id, - }) - } catch (err: any) { - results.push({ - id: item.id, - skillName: item.skillName, - ok: false, - error: err?.message || '删除失败', - }) - } - } - - invalidateCache() - - const okCount = results.filter((r) => r.ok).length - const failCount = results.length - okCount - return { ok: failCount === 0, okCount, failCount, results } - }) -} - -async function copyDir(src: string, dest: string): Promise { - await fs.mkdir(dest, { recursive: true }) - const entries = await fs.readdir(src, { withFileTypes: true }) - for (const entry of entries) { - const srcPath = path.join(src, entry.name) - const destPath = path.join(dest, entry.name) - if (entry.isDirectory()) { - await copyDir(srcPath, destPath) - } else { - await fs.copyFile(srcPath, destPath) - } - } + // These routes intentionally remain as compatibility tombstones so older + // UIs fail closed instead of writing client-supplied absolute paths. + app.put('/api/skills/:id/content', async (_request, reply) => previewRequired(reply)) + app.post('/api/skills/copy', async (_request, reply) => previewRequired(reply)) + app.post('/api/skills/move', async (_request, reply) => previewRequired(reply)) + app.delete('/api/skills/:id', async (_request, reply) => previewRequired(reply)) + app.post('/api/skills/batch/delete', async (_request, reply) => previewRequired(reply)) } diff --git a/server/routes/similarity.ts b/server/routes/similarity.ts index fa0a9ff..481424e 100644 --- a/server/routes/similarity.ts +++ b/server/routes/similarity.ts @@ -1,12 +1,14 @@ import type { FastifyInstance } from 'fastify' import fs from 'fs/promises' import path from 'path' -import os from 'os' import { detectSimilarSkills } from '../scanner/similarity.js' import { getCachedResult } from './skills.js' import { fullScan } from '../scanner/discovery.js' +import { dataDir } from '../config/paths.js' +import { atomicWriteFile } from '../core/atomic.js' +import { getCatalog } from '../core/catalog.js' -const STORE_DIR = path.join(os.homedir(), '.claude', 'skill-hub') +const STORE_DIR = path.join(dataDir, 'diagnostics') const IGNORED_FILE = path.join(STORE_DIR, 'ignored-pairs.json') interface IgnoredPair { @@ -27,7 +29,15 @@ async function loadIgnored(): Promise { async function saveIgnored(list: IgnoredPair[]): Promise { await fs.mkdir(STORE_DIR, { recursive: true }) - await fs.writeFile(IGNORED_FILE, JSON.stringify(list, null, 2), 'utf-8') + await atomicWriteFile(IGNORED_FILE, JSON.stringify(list, null, 2) + '\n') +} + +async function validatePair(a: string, b: string): Promise { + if (!/^skill_[a-f0-9]{24}$/.test(a) || !/^skill_[a-f0-9]{24}$/.test(b) || a === b) { + return false + } + const skills = new Set((await getCatalog()).skills.map((skill) => skill.id)) + return skills.has(a) && skills.has(b) } export async function similarityRoutes(app: FastifyInstance) { @@ -55,6 +65,9 @@ export async function similarityRoutes(app: FastifyInstance) { if (!a || !b) { return reply.status(400).send({ error: 'Missing skill ids (a, b)' }) } + if (!(await validatePair(a, b))) { + return reply.status(400).send({ ok: false, code: 'INVALID_SKILL_PAIR', error: 'Invalid Skill pair' }) + } const list = await loadIgnored() // Normalize so (a,b) and (b,a) are the same record const [x, y] = a < b ? [a, b] : [b, a] @@ -70,6 +83,9 @@ export async function similarityRoutes(app: FastifyInstance) { if (!a || !b) { return reply.status(400).send({ error: 'Missing skill ids (a, b)' }) } + if (!(await validatePair(a, b))) { + return reply.status(400).send({ ok: false, code: 'INVALID_SKILL_PAIR', error: 'Invalid Skill pair' }) + } const list = await loadIgnored() const [x, y] = a < b ? [a, b] : [b, a] const next = list.filter((p) => !(p.a === x && p.b === y)) diff --git a/server/routes/skills.ts b/server/routes/skills.ts index 81e8504..7566a0e 100644 --- a/server/routes/skills.ts +++ b/server/routes/skills.ts @@ -1,41 +1,90 @@ import type { FastifyInstance } from 'fastify' -import os from 'os' -import { fullScan } from '../scanner/discovery.js' import { AGENTS } from '../scanner/agents.js' -import type { ScanResult } from '../types.js' +import { + getCachedCatalog, + getCatalog, + invalidateCatalog, +} from '../core/catalog.js' +import { userHome } from '../config/paths.js' +import { redactObject, redactPath } from '../security/redaction.js' +import { PACKAGE_VERSION } from '../runtime/identity.js' +import { + DashboardRouteError, + parseDashboardQuery, + resolveDashboardContext, +} from '../core/dashboard-context.js' +import { + getConvergenceRecoveryStatus, + listConvergenceTransactions, +} from '../projections/store.js' -let cachedResult: ScanResult | null = null - -export function getCachedResult(): ScanResult | null { - return cachedResult +export function getCachedResult() { + return getCachedCatalog() } export async function skillRoutes(app: FastifyInstance) { // Trigger full scan - app.get('/api/scan', async () => { - cachedResult = await fullScan() - return cachedResult + app.get<{ Querystring: { mode?: string } }>('/api/scan', async (req, reply) => { + if (req.query.mode && req.query.mode !== 'current-project') { + return reply.status(400).send({ + schemaVersion: 1, + ok: false, + code: 'INVALID_SCAN_MODE', + error: 'mode must be current-project when supplied', + }) + } + return getCatalog( + true, + req.query.mode === 'current-project' ? 'current-project' : 'all', + ) + }) + + app.get<{ + Querystring: Record + }>('/api/v1/dashboard-context', async (req, reply) => { + try { + const intent = parseDashboardQuery(req.query) + const catalog = await getCatalog( + false, + intent.view === 'current-project' ? 'current-project' : 'all', + ) + return resolveDashboardContext(catalog, req.query) + } catch (error) { + if (error instanceof DashboardRouteError) { + return reply + .status(error.code === 'TARGET_NOT_FOUND' ? 404 : 400) + .send({ + schemaVersion: 1, + ok: false, + code: error.code, + error: error.message, + recovery: { + href: '/', + label: '返回首页并重新扫描', + }, + }) + } + throw error + } }) // Get all skills (with optional filters) app.get<{ Querystring: { scope?: string; source?: string; agent?: string; category?: string; search?: string } }>('/api/skills', async (req) => { - if (!cachedResult) { - cachedResult = await fullScan() - } + const cachedResult = await getCatalog() let skills = [...cachedResult.skills] const { scope, source, agent, category, search } = req.query if (scope && scope !== 'all') { - skills = skills.filter((s) => s.scope === scope) + skills = skills.filter((s) => s.installations.some((i) => i.scope === scope)) } if (source && source !== 'all') { skills = skills.filter((s) => s.source === source) } if (agent && agent !== 'all') { - skills = skills.filter((s) => s.agent === agent) + skills = skills.filter((s) => s.exposedTo.includes(agent as any)) } if (category && category !== 'all') { skills = skills.filter((s) => s.category === category) @@ -54,9 +103,7 @@ export async function skillRoutes(app: FastifyInstance) { // Get single skill detail app.get<{ Params: { id: string } }>('/api/skills/:id', async (req, reply) => { - if (!cachedResult) { - cachedResult = await fullScan() - } + const cachedResult = await getCatalog() const skill = cachedResult.skills.find((s) => s.id === req.params.id) if (!skill) { return reply.status(404).send({ error: 'Skill not found' }) @@ -69,49 +116,94 @@ export async function skillRoutes(app: FastifyInstance) { return AGENTS.map((a) => ({ id: a.id, name: a.name, icon: a.icon })) }) + app.get('/api/v1/targets', async () => { + const catalog = await getCatalog() + return { + ok: true, + targets: catalog.authorizedRoots + .filter((root) => root.editable) + .map((root) => ({ + id: root.id, + scope: root.scope, + agent: root.agent, + projectId: root.projectId, + label: root.scope === 'global' + ? `${root.agent} · 全局` + : `${root.projectName || '项目'} · ${root.agent}`, + })), + } + }) + + app.get('/api/v1/convergence', async () => { + const [catalog, transactions] = await Promise.all([ + getCatalog(), + listConvergenceTransactions(), + ]) + const transactionRows = await Promise.all(transactions.map(async (transaction) => { + const recovery = await getConvergenceRecoveryStatus(transaction) + return { + id: transaction.id, + canonicalSkillId: transaction.canonicalSkillId, + canonicalPath: transaction.canonicalPath, + packageHash: transaction.packageHash, + createdAt: transaction.createdAt, + status: transaction.status, + projectionCount: transaction.projections.length, + targetPaths: transaction.projections.map((projection) => projection.targetPath), + ...recovery, + } + })) + return { + schemaVersion: 1, + ok: true, + groups: catalog.convergenceGroups, + projectionIssues: catalog.projectionIssues, + agentDetections: catalog.agentDetections, + transactions: transactionRows, + } + }) + // Get discovered projects app.get('/api/projects', async () => { - if (!cachedResult) { - cachedResult = await fullScan() - } + const cachedResult = await getCatalog() return cachedResult.projects }) // Get conflicts app.get('/api/conflicts', async () => { - if (!cachedResult) { - cachedResult = await fullScan() - } + const cachedResult = await getCatalog() return cachedResult.conflicts }) // Get stats app.get('/api/stats', async () => { - if (!cachedResult) { - cachedResult = await fullScan() - } + const cachedResult = await getCatalog() return cachedResult.stats }) // Diagnostic endpoint — useful for debugging "only found 1 skill" reports app.get('/api/debug', async () => { - if (!cachedResult) { - cachedResult = await fullScan() - } + const cachedResult = await getCatalog() return { - version: '0.3.0', + version: PACKAGE_VERSION, node: process.version, platform: process.platform, - cwd: process.cwd(), - homedir: os.homedir(), + cwd: redactPath(process.cwd(), userHome), + homedir: '$HOME', env: { - SKILL_HUB_EXTRA_PATHS: process.env.SKILL_HUB_EXTRA_PATHS || null, + extraPathsConfigured: Boolean( + process.env.SKILLMANAGER_EXTRA_PATHS || process.env.SKILL_HUB_EXTRA_PATHS, + ), PORT: process.env.PORT || null, }, scan: { durationMs: cachedResult.durationMs, totalSkills: cachedResult.stats.total, - scannedPaths: cachedResult.scannedPaths, + scannedPaths: cachedResult.scannedPaths.map((entry) => ({ + ...entry, + path: redactPath(entry.path, userHome), + error: entry.error ? redactObject(entry.error, userHome) : undefined, + })), }, stats: cachedResult.stats, health: cachedResult.health, @@ -121,5 +213,5 @@ export async function skillRoutes(app: FastifyInstance) { } export function invalidateCache() { - cachedResult = null + invalidateCatalog() } diff --git a/server/routes/sync.ts b/server/routes/sync.ts index c3f080a..d92b7d2 100644 --- a/server/routes/sync.ts +++ b/server/routes/sync.ts @@ -1,24 +1,55 @@ -import type { FastifyInstance } from 'fastify' +import type { FastifyInstance, FastifyReply } from 'fastify' import { readConfig, writeConfig, deleteConfig, toPublic, parseRepoUrl, + CredentialStoreError, type SyncConfig, } from '../sync/config.js' import { validateRepo } from '../sync/github.js' import { ensureVault, getVaultStatus } from '../sync/vault.js' -import { previewUpload, executeUpload } from '../sync/upload.js' -import { computeDownloadListing, applyDownload } from '../sync/download.js' +import { + syncUploadPlans, + SyncUploadPlanError, +} from '../sync/upload-plans.js' +import { computeDownloadListing } from '../sync/download.js' +import { + syncDownloadPlans, + SyncDownloadPlanError, +} from '../sync/download-plans.js' import { buildExportTarball } from '../sync/export.js' import { fullScan } from '../scanner/discovery.js' -import { getCachedResult, invalidateCache } from './skills.js' +import { getCachedResult } from './skills.js' + +const CREDENTIAL_ERROR = Symbol('credential-error') + +function sendCredentialError(reply: FastifyReply, error: unknown) { + if (error instanceof CredentialStoreError) { + return reply.status(503).send({ + ok: false, + code: error.code, + error: '系统凭据存储暂时不可用,请解锁系统钥匙串后重试。', + }) + } + throw error +} + +async function loadConfig(reply: FastifyReply): Promise { + try { + return await readConfig() + } catch (error) { + sendCredentialError(reply, error) + return CREDENTIAL_ERROR + } +} export async function syncRoutes(app: FastifyInstance) { // Get current config (redacted — never returns the token) - app.get('/api/sync/config', async () => { - const config = await readConfig() + app.get('/api/sync/config', async (_req, reply) => { + const config = await loadConfig(reply) + if (config === CREDENTIAL_ERROR) return return toPublic(config) }) @@ -72,19 +103,28 @@ export async function syncRoutes(app: FastifyInstance) { token, lastValidatedAt: new Date().toISOString(), } - await writeConfig(config) + try { + await writeConfig(config) + } catch (error) { + return sendCredentialError(reply, error) + } return { ok: true, config: toPublic(config) } }) // Disconnect — remove config - app.delete('/api/sync/config', async () => { - await deleteConfig() + app.delete('/api/sync/config', async (_req, reply) => { + try { + await deleteConfig() + } catch (error) { + return sendCredentialError(reply, error) + } return { ok: true } }) // Vault status — returns current clone state (file count, HEAD, last fetch) app.get('/api/sync/vault/status', async (_req, reply) => { - const config = await readConfig() + const config = await loadConfig(reply) + if (config === CREDENTIAL_ERROR) return if (!config) { return reply.status(400).send({ ok: false, error: '尚未配置同步仓库' }) } @@ -94,7 +134,8 @@ export async function syncRoutes(app: FastifyInstance) { // Vault init / refresh — clones if missing, fetches otherwise. Idempotent. app.post('/api/sync/vault/init', async (_req, reply) => { - const config = await readConfig() + const config = await loadConfig(reply) + if (config === CREDENTIAL_ERROR) return if (!config) { return reply.status(400).send({ ok: false, error: '尚未配置同步仓库' }) } @@ -107,37 +148,67 @@ export async function syncRoutes(app: FastifyInstance) { // Upload preview — computes diff and secret scan, no writes app.post('/api/sync/upload/preview', async (_req, reply) => { - const config = await readConfig() + const config = await loadConfig(reply) + if (config === CREDENTIAL_ERROR) return if (!config) return reply.status(400).send({ ok: false, error: '尚未配置同步仓库' }) - - // Use cached scan if fresh, otherwise do a full scan - let scan = getCachedResult() - if (!scan) scan = await fullScan() - - const result = await previewUpload(config, scan.skills) - if (!result.ok) return reply.status(500).send({ ok: false, error: result.error }) - return { ok: true, preview: result.preview } + try { + const plan = await syncUploadPlans.preview(config) + return { ok: true, plan, preview: plan.preview } + } catch (error) { + if (error instanceof SyncUploadPlanError) { + return reply.status(error.statusCode).send({ ok: false, code: error.code, error: error.message }) + } + throw error + } }) - // Upload execute — materializes vault, commits, pushes - app.post<{ Body: { allowSecrets?: boolean } }>('/api/sync/upload', async (req, reply) => { - const config = await readConfig() + // Upload apply — executes only the exact server-bound preview the user confirmed. + app.post<{ + Params: { planId: string } + Body: { confirm?: boolean; allowSecrets?: boolean } + }>('/api/sync/upload/:planId/apply', async (req, reply) => { + const config = await loadConfig(reply) + if (config === CREDENTIAL_ERROR) return if (!config) return reply.status(400).send({ ok: false, error: '尚未配置同步仓库' }) - - // Always re-scan for the execute path — don't trust stale cache for writes - const scan = await fullScan() - const allowSecrets = Boolean(req.body?.allowSecrets) - - const result = await executeUpload(config, scan.skills, { allowSecrets }) - if (!result.ok) { - return reply.status(400).send({ ok: false, error: result.error, preview: result.preview }) + if (req.body?.confirm !== true) { + return reply.status(400).send({ + ok: false, + code: 'EXPLICIT_CONFIRMATION_REQUIRED', + error: 'confirm=true required', + }) + } + try { + return await syncUploadPlans.apply( + config, + req.params.planId, + Boolean(req.body?.allowSecrets), + ) + } catch (error) { + if (error instanceof SyncUploadPlanError) { + return reply.status(error.statusCode).send({ + ok: false, + code: error.code, + error: error.message, + preview: error.preview, + }) + } + throw error } - return { ok: true, sha: result.sha, noop: result.noop, preview: result.preview } + }) + + // Legacy direct push is intentionally disabled. + app.post('/api/sync/upload', async (_req, reply) => { + return reply.status(410).send({ + ok: false, + code: 'PREVIEW_REQUIRED', + error: 'Use /api/sync/upload/preview and confirm the returned plan', + }) }) // Download listing — 4-state table app.get('/api/sync/download/listing', async (_req, reply) => { - const config = await readConfig() + const config = await loadConfig(reply) + if (config === CREDENTIAL_ERROR) return if (!config) return reply.status(400).send({ ok: false, error: '尚未配置同步仓库' }) let scan = getCachedResult() @@ -148,27 +219,49 @@ export async function syncRoutes(app: FastifyInstance) { return { ok: true, listing: result.listing } }) - // Download apply — copy selected skills from vault into local agent dirs - app.post<{ Body: { keys?: string[] } }>('/api/sync/download', async (req, reply) => { - const config = await readConfig() + app.post<{ Body: { keys?: string[] } }>('/api/sync/download/preview', async (req, reply) => { + const config = await loadConfig(reply) + if (config === CREDENTIAL_ERROR) return if (!config) return reply.status(400).send({ ok: false, error: '尚未配置同步仓库' }) + try { + const plan = await syncDownloadPlans.preview(config, req.body?.keys || []) + return { ok: true, plan } + } catch (error) { + if (error instanceof SyncDownloadPlanError) { + return reply.status(error.statusCode).send({ ok: false, code: error.code, error: error.message }) + } + throw error + } + }) - const keys = req.body?.keys || [] - if (!Array.isArray(keys) || keys.length === 0) { - return reply.status(400).send({ ok: false, error: '未选择要下载的 Skill' }) + app.post<{ + Params: { planId: string } + Body: { confirm?: boolean } + }>('/api/sync/download/:planId/apply', async (req, reply) => { + const config = await loadConfig(reply) + if (config === CREDENTIAL_ERROR) return + if (!config) return reply.status(400).send({ ok: false, error: '尚未配置同步仓库' }) + if (req.body?.confirm !== true) { + return reply.status(400).send({ ok: false, code: 'EXPLICIT_CONFIRMATION_REQUIRED', error: 'confirm=true required' }) } - // Sanitize: each key must match `/` with no traversal - for (const k of keys) { - if (typeof k !== 'string' || k.includes('..') || k.includes('\\') || k.split('/').length !== 2) { - return reply.status(400).send({ ok: false, error: `非法 key: ${k}` }) + try { + return await syncDownloadPlans.apply(config, req.params.planId) + } catch (error) { + if (error instanceof SyncDownloadPlanError) { + return reply.status(error.statusCode).send({ ok: false, code: error.code, error: error.message }) } + throw error } + }) - const result = await applyDownload(config, keys) - if (!result.ok) return reply.status(500).send({ ok: false, error: result.error }) - // Scanner cache is now stale — next scan request will re-read disk - invalidateCache() - return { ok: true, applied: result.applied } + // Legacy direct apply is intentionally disabled. + app.post<{ Body: { keys?: string[] } }>('/api/sync/download', async (req, reply) => { + void req + return reply.status(410).send({ + ok: false, + code: 'PREVIEW_REQUIRED', + error: 'Use /api/sync/download/preview and confirm the returned plan', + }) }) // Export — packages all syncable skills into a tar.gz. No GitHub needed. @@ -191,7 +284,8 @@ export async function syncRoutes(app: FastifyInstance) { // Re-validate stored config (used by "refresh" button) app.post('/api/sync/revalidate', async (_req, reply) => { - const config = await readConfig() + const config = await loadConfig(reply) + if (config === CREDENTIAL_ERROR) return if (!config) { return reply.status(400).send({ ok: false, error: '尚未配置同步仓库' }) } @@ -204,7 +298,11 @@ export async function syncRoutes(app: FastifyInstance) { defaultBranch: result.repo.defaultBranch, lastValidatedAt: new Date().toISOString(), } - await writeConfig(updated) + try { + await writeConfig(updated) + } catch (error) { + return sendCredentialError(reply, error) + } return { ok: true, config: toPublic(updated) } }) } diff --git a/server/routes/trash.ts b/server/routes/trash.ts index 6f84b9c..b3bc4f0 100644 --- a/server/routes/trash.ts +++ b/server/routes/trash.ts @@ -1,13 +1,8 @@ import type { FastifyInstance } from 'fastify' import { listTrash, - restoreFromTrash, - purgeOne, purgeExpired, - TrashConflictError, - TrashNotFoundError, } from '../trash/store.js' -import { invalidateCache } from './skills.js' export async function trashRoutes(app: FastifyInstance) { // List trash entries (also purges expired as a side effect) @@ -16,35 +11,22 @@ export async function trashRoutes(app: FastifyInstance) { return { ok: true, items } }) - // Restore a trash entry back to its original location - app.post<{ - Params: { id: string } - Querystring: { force?: string } - }>('/api/trash/:id/restore', async (req, reply) => { - const { id } = req.params - const force = req.query.force === 'true' || req.query.force === '1' - try { - const meta = await restoreFromTrash(id, force) - invalidateCache() - return { ok: true, meta } - } catch (err: any) { - if (err instanceof TrashConflictError) { - reply.status(409) - return { ok: false, error: err.message, code: 'CONFLICT', targetPath: err.targetPath } - } - if (err instanceof TrashNotFoundError) { - reply.status(404) - return { ok: false, error: err.message, code: 'NOT_FOUND' } - } - reply.status(500) - return { ok: false, error: err?.message || '还原失败' } - } + // Legacy direct writes fail closed. Restore now uses a server-bound plan. + app.post('/api/trash/:id/restore', async (_req, reply) => { + return reply.status(410).send({ + ok: false, + code: 'PREVIEW_REQUIRED', + error: 'Use /api/v1/actions/trash-restore/preview and confirm the returned plan', + }) }) - // Permanently delete a single trash entry - app.delete<{ Params: { id: string } }>('/api/trash/:id', async (req) => { - const ok = await purgeOne(req.params.id) - return { ok } + // Manual irreversible purge is disabled in the MVP. Entries expire after 7 days. + app.delete('/api/trash/:id', async (_req, reply) => { + return reply.status(410).send({ + ok: false, + code: 'MANUAL_PURGE_DISABLED', + error: 'Manual permanent deletion is disabled; the entry will expire automatically', + }) }) // Manual purge of expired entries diff --git a/server/routes/versions.ts b/server/routes/versions.ts index 803a907..6bffb15 100644 --- a/server/routes/versions.ts +++ b/server/routes/versions.ts @@ -1,85 +1,119 @@ -import type { FastifyInstance } from 'fastify' +import type { FastifyInstance, FastifyReply } from 'fastify' import { createSnapshot, getHistory, getVersion, diffVersions, diffWithCurrent, - rollback, - deleteVersion, } from '../versioning/store.js' -import { invalidateCache } from './skills.js' +import { findSkillById } from '../core/catalog.js' +import { authorizeSkillWrite, SkillAccessError } from '../core/authorization.js' + +function validVersionId(versionId: string): boolean { + return /^[a-z0-9-]{3,120}$/i.test(versionId) +} + +function accessError(reply: FastifyReply, error: unknown) { + if (error instanceof SkillAccessError) { + return reply.status(error.statusCode).send({ + ok: false, + code: error.code, + error: error.message, + }) + } + throw error +} + +async function readableSkillPath(skillId: string): Promise { + if (!/^skill_[a-f0-9]{24}$/.test(skillId)) return null + const skill = await findSkillById(skillId) + return skill?.realPath || null +} export async function versionRoutes(app: FastifyInstance) { - // 创建快照 app.post<{ - Body: { skillPath: string; skillName: string; message: string } - }>('/api/versions/snapshot', async (req) => { - const { skillPath, skillName, message } = req.body + Body: { skillId?: string; message?: string } + }>('/api/versions/snapshot', async (request, reply) => { try { - const meta = await createSnapshot(skillPath, skillName, message, 'manual') + const authorized = await authorizeSkillWrite(request.body?.skillId || '') + const message = request.body?.message?.trim() + if (!message) { + return reply.status(400).send({ ok: false, code: 'MESSAGE_REQUIRED', error: 'Snapshot message required' }) + } + const meta = await createSnapshot( + authorized.skillDir, + authorized.skill.name, + message, + 'manual', + ) return { ok: true, version: meta } - } catch (e: any) { - return { ok: false, error: e.message } + } catch (error) { + return accessError(reply, error) } }) - // 获取版本历史 app.get<{ - Querystring: { skillPath: string } - }>('/api/versions/history', async (req) => { - const { skillPath } = req.query - const history = await getHistory(skillPath) - return { history } + Querystring: { skillId?: string } + }>('/api/versions/history', async (request, reply) => { + const skillPath = await readableSkillPath(request.query.skillId || '') + if (!skillPath) return reply.status(404).send({ ok: false, code: 'SKILL_NOT_FOUND', error: 'Skill not found' }) + return { ok: true, history: await getHistory(skillPath) } }) - // 获取某个版本的完整内容 app.get<{ - Querystring: { skillPath: string; versionId: string } - }>('/api/versions/detail', async (req) => { - const { skillPath, versionId } = req.query + Querystring: { skillId?: string; versionId?: string } + }>('/api/versions/detail', async (request, reply) => { + const skillPath = await readableSkillPath(request.query.skillId || '') + const versionId = request.query.versionId || '' + if (!skillPath) return reply.status(404).send({ ok: false, code: 'SKILL_NOT_FOUND', error: 'Skill not found' }) + if (!validVersionId(versionId)) return reply.status(400).send({ ok: false, code: 'INVALID_VERSION_ID', error: 'Invalid version ID' }) const version = await getVersion(skillPath, versionId) - if (!version) return { ok: false, error: 'Version not found' } + if (!version) return reply.status(404).send({ ok: false, code: 'VERSION_NOT_FOUND', error: 'Version not found' }) return { ok: true, version } }) - // 对比两个版本 app.get<{ - Querystring: { skillPath: string; oldId: string; newId: string } - }>('/api/versions/diff', async (req) => { - const { skillPath, oldId, newId } = req.query + Querystring: { skillId?: string; oldId?: string; newId?: string } + }>('/api/versions/diff', async (request, reply) => { + const skillPath = await readableSkillPath(request.query.skillId || '') + const oldId = request.query.oldId || '' + const newId = request.query.newId || '' + if (!skillPath) return reply.status(404).send({ ok: false, code: 'SKILL_NOT_FOUND', error: 'Skill not found' }) + if (!validVersionId(oldId) || !validVersionId(newId)) { + return reply.status(400).send({ ok: false, code: 'INVALID_VERSION_ID', error: 'Invalid version ID' }) + } const diff = await diffVersions(skillPath, oldId, newId) - if (!diff) return { ok: false, error: 'Diff failed' } + if (!diff) return reply.status(404).send({ ok: false, code: 'DIFF_FAILED', error: 'Diff failed' }) return { ok: true, diff } }) - // 对比某个版本和当前文件 app.get<{ - Querystring: { skillPath: string; versionId: string } - }>('/api/versions/diff-current', async (req) => { - const { skillPath, versionId } = req.query + Querystring: { skillId?: string; versionId?: string } + }>('/api/versions/diff-current', async (request, reply) => { + const skillPath = await readableSkillPath(request.query.skillId || '') + const versionId = request.query.versionId || '' + if (!skillPath) return reply.status(404).send({ ok: false, code: 'SKILL_NOT_FOUND', error: 'Skill not found' }) + if (!validVersionId(versionId)) { + return reply.status(400).send({ ok: false, code: 'INVALID_VERSION_ID', error: 'Invalid version ID' }) + } const diff = await diffWithCurrent(skillPath, versionId) - if (!diff) return { ok: false, error: 'Diff failed' } + if (!diff) return reply.status(404).send({ ok: false, code: 'DIFF_FAILED', error: 'Diff failed' }) return { ok: true, diff } }) - // 回滚到指定版本 - app.post<{ - Body: { skillPath: string; versionId: string } - }>('/api/versions/rollback', async (req) => { - const { skillPath, versionId } = req.body - const success = await rollback(skillPath, versionId) - if (!success) return { ok: false, error: 'Rollback failed' } - invalidateCache() - return { ok: true } + app.post('/api/versions/rollback', async (_request, reply) => { + return reply.status(410).send({ + ok: false, + code: 'PREVIEW_REQUIRED', + error: 'Use /api/v1/actions/restore/preview before applying a rollback', + }) }) - // 删除版本 - app.delete<{ - Querystring: { skillPath: string; versionId: string } - }>('/api/versions', async (req) => { - const { skillPath, versionId } = req.query - const success = await deleteVersion(skillPath, versionId) - return { ok: success } + app.delete('/api/versions', async (_request, reply) => { + return reply.status(410).send({ + ok: false, + code: 'PREVIEW_REQUIRED', + error: 'Permanent version deletion is disabled in the secure MVP', + }) }) } diff --git a/server/runtime/identity.ts b/server/runtime/identity.ts new file mode 100644 index 0000000..0690f9a --- /dev/null +++ b/server/runtime/identity.ts @@ -0,0 +1,28 @@ +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +function readPackageVersion(): string { + const current = path.dirname(fileURLToPath(import.meta.url)) + const candidates = [ + path.resolve(current, '../../package.json'), + path.resolve(current, '../../../package.json'), + ] + for (const candidate of candidates) { + try { + const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8')) + if (typeof parsed.version === 'string') return parsed.version + } catch {} + } + return process.env.SKILLMANAGER_VERSION || 'unknown' +} + +export const PRODUCT_NAME = 'SkillManager' +export const PACKAGE_VERSION = readPackageVersion() +export const RUNTIME_CAPABILITIES = [ + 'scan', + 'diagnose', + 'versions', + 'trash', + 'sync', +] as const diff --git a/server/runtime/state.ts b/server/runtime/state.ts new file mode 100644 index 0000000..474b328 --- /dev/null +++ b/server/runtime/state.ts @@ -0,0 +1,65 @@ +import fs from 'fs/promises' +import path from 'path' +import { dataDir } from '../config/paths.js' +import { atomicWriteFile } from '../core/atomic.js' + +export interface RuntimeState { + schemaVersion: 1 + product: 'SkillManager' + version: string + pid: number + host: '127.0.0.1' + port: number + baseUrl: string + startedAt: string + instanceId: string + processIdentity: string + managed: boolean + controlToken: string + projectConfigured: boolean + /** Private 0600 runtime metadata. Never expose through public status APIs. */ + projectRoot?: string | null + capabilities: string[] +} + +export const runtimeDirectory = path.join(dataDir, 'runtime') +export const runtimeStatePath = path.join(runtimeDirectory, 'state.json') + +function isRuntimeState(value: any): value is RuntimeState { + return value?.schemaVersion === 1 && + value.product === 'SkillManager' && + typeof value.version === 'string' && + Number.isInteger(value.pid) && value.pid > 0 && + value.host === '127.0.0.1' && + Number.isInteger(value.port) && value.port > 0 && value.port <= 65535 && + value.baseUrl === `http://127.0.0.1:${value.port}` && + typeof value.startedAt === 'string' && + /^instance_[A-Za-z0-9_-]{12,}$/.test(value.instanceId) && + typeof value.processIdentity === 'string' && + typeof value.controlToken === 'string' && value.controlToken.length >= 32 && + typeof value.managed === 'boolean' && + (value.projectRoot === undefined || value.projectRoot === null || typeof value.projectRoot === 'string') && + Array.isArray(value.capabilities) +} + +export async function readRuntimeState(): Promise { + try { + const value = JSON.parse(await fs.readFile(runtimeStatePath, 'utf8')) + return isRuntimeState(value) ? value : null + } catch { + return null + } +} + +export async function writeRuntimeState(state: RuntimeState): Promise { + await fs.mkdir(runtimeDirectory, { recursive: true, mode: 0o700 }) + if (process.platform !== 'win32') await fs.chmod(runtimeDirectory, 0o700) + await atomicWriteFile(runtimeStatePath, JSON.stringify(state, null, 2) + '\n') + if (process.platform !== 'win32') await fs.chmod(runtimeStatePath, 0o600) +} + +export async function removeRuntimeStateIfOwned(instanceId: string): Promise { + const current = await readRuntimeState() + if (current?.instanceId !== instanceId) return + await fs.unlink(runtimeStatePath).catch(() => {}) +} diff --git a/server/scanner/agent-detection.ts b/server/scanner/agent-detection.ts new file mode 100644 index 0000000..92463ed --- /dev/null +++ b/server/scanner/agent-detection.ts @@ -0,0 +1,142 @@ +import fs from 'fs/promises' +import fsConstants from 'fs' +import path from 'path' +import type { AgentDetection } from '../types.js' +import { AGENTS, type AgentId } from './agents.js' + +const COMMANDS: Partial> = { + 'claude-code': ['claude'], + codex: ['codex'], + cursor: ['cursor'], + 'gemini-cli': ['gemini'], + 'github-copilot': ['copilot'], + windsurf: ['windsurf'], + continue: ['cn'], + opencode: ['opencode'], + openclaw: ['openclaw'], + cline: ['cline'], + goose: ['goose'], + 'kimi-cli': ['kimi'], + 'qwen-code': ['qwen'], +} + +const MAC_APPLICATIONS: Partial> = { + cursor: ['/Applications/Cursor.app'], + windsurf: ['/Applications/Windsurf.app'], + trae: ['/Applications/Trae.app'], +} + +const NON_CONFIGURATION_ENTRIES = new Set([ + 'skills', + '.skill-lock.json', + '.skillshare-manifest.json', + '.skillmanager-projections.json', +]) + +async function exists(target: string): Promise { + try { + await fs.access(target) + return true + } catch { + return false + } +} + +async function commandExists(command: string): Promise { + const pathValue = process.env.PATH || '' + const extensions = process.platform === 'win32' + ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') + : [''] + for (const segment of pathValue.split(path.delimiter).filter(Boolean)) { + for (const extension of extensions) { + const candidate = path.join(segment, process.platform === 'win32' ? command + extension : command) + try { + await fs.access(candidate, fsConstants.constants.X_OK) + return true + } catch {} + } + } + return false +} + +function agentRoot(home: string, globalPath: string): string { + const absolute = path.join(home, globalPath) + return path.basename(absolute) === 'skills' ? path.dirname(absolute) : absolute +} + +async function rootHasConfiguration(root: string): Promise { + try { + const entries = await fs.readdir(root) + return entries.some((entry) => !NON_CONFIGURATION_ENTRIES.has(entry)) + } catch { + return false + } +} + +export async function detectAgentInstallations(input: { + home: string + fixtureMode: boolean + visibleCounts: Record +}): Promise { + const result: AgentDetection[] = [] + + for (const agent of AGENTS) { + if (agent.id === 'universal') { + result.push({ + agentId: agent.id, + name: agent.name, + icon: agent.icon, + status: 'shared-standard', + evidence: ['shared-standard'], + visibleSkillCount: input.visibleCounts[agent.id] || 0, + }) + continue + } + + const evidence: AgentDetection['evidence'] = [] + const roots = Array.from(new Set(agent.globalPaths.map((entry) => agentRoot(input.home, entry)))) + const skillRoots = agent.globalPaths.map((entry) => path.join(input.home, entry)) + + if (!input.fixtureMode) { + for (const command of COMMANDS[agent.id] || []) { + if (await commandExists(command)) { + evidence.push('command') + break + } + } + if (process.platform === 'darwin') { + for (const application of MAC_APPLICATIONS[agent.id] || []) { + if (await exists(application)) { + evidence.push('application') + break + } + } + } + } + + for (const root of roots) { + if (await rootHasConfiguration(root)) { + evidence.push('configuration') + break + } + } + + const skillsOnly = input.visibleCounts[agent.id] > 0 || + (await Promise.all(skillRoots.map(exists))).some(Boolean) + if (skillsOnly && evidence.length === 0) evidence.push('skills-only') + + const installed = evidence.some((item) => ( + item === 'command' || item === 'configuration' || item === 'application' + )) + result.push({ + agentId: agent.id, + name: agent.name, + icon: agent.icon, + status: installed ? 'installed' : skillsOnly ? 'residue' : 'not-detected', + evidence: Array.from(new Set(evidence)), + visibleSkillCount: input.visibleCounts[agent.id] || 0, + }) + } + + return result +} diff --git a/server/scanner/agents.ts b/server/scanner/agents.ts index 26359f9..4137e03 100644 --- a/server/scanner/agents.ts +++ b/server/scanner/agents.ts @@ -424,3 +424,23 @@ export function allAgentGlobalAbsPaths(homedir: string): { agent: AgentDef; path for (const a of AGENTS) for (const rel of a.globalPaths) out.push({ agent: a, path: path.join(homedir, rel) }) return out } + +const SHARED_AGENTS_PATH_EXPOSURES: AgentId[] = [ + 'universal', + 'amp', + 'cline', + 'codex', + 'cursor', + 'deepagents', + 'firebender', + 'gemini-cli', + 'github-copilot', + 'kimi-cli', + 'opencode', + 'replit', + 'warp', +] + +export function exposedAgentIdsFor(agent: AgentId): AgentId[] { + return agent === 'universal' ? [...SHARED_AGENTS_PATH_EXPOSURES] : [agent] +} diff --git a/server/scanner/discovery.ts b/server/scanner/discovery.ts index 2eef1e3..bdf8b28 100644 --- a/server/scanner/discovery.ts +++ b/server/scanner/discovery.ts @@ -1,6 +1,7 @@ import fs from 'fs/promises' +import type { Dirent } from 'fs' +import { execFile } from 'child_process' import path from 'path' -import os from 'os' import crypto from 'crypto' import { parseSkillMd, listSkillFiles, getSkillMdPath } from './parser.js' import { resolveSymlink, identifySource } from './symlink.js' @@ -8,18 +9,80 @@ import { AGENTS, allAgentGlobalAbsPaths, allAgentProjectRelPaths, + exposedAgentIdsFor, isValidAgentId, type AgentId, } from './agents.js' import { classifyAll } from './taxonomy.js' import { detectSimilarSkills } from './similarity.js' import { computeHealth } from './health.js' -import type { Skill, Project, ConflictGroup, ScanResult, ScanPathReport } from '../types.js' - -const homedir = os.homedir() +import { hashSkillDirectory } from './package-hash.js' +import { buildConvergenceGroups, sourceIdentityFromFrontmatter } from './identity.js' +import { detectAgentInstallations } from './agent-detection.js' +import { decorateProjectionState } from './projections.js' +import type { + AgentExposure, + AuthorizedRoot, + Skill, + SkillInstallation, + Project, + ConflictGroup, + ScanResult, + ScanPathReport, + ProjectionIssue, +} from '../types.js' +import { explicitProjectRoot, fixtureRoot, userHome } from '../config/paths.js' +import { conflictId, projectionIssueId } from '../core/opaque-ids.js' + +const homedir = userHome function makeId(p: string): string { - return crypto.createHash('md5').update(p).digest('hex').slice(0, 12) + return `skill_${crypto.createHash('sha256').update(p).digest('hex').slice(0, 24)}` +} + +function makeInstallationId(p: string): string { + return `installation_${crypto.createHash('sha256').update(p).digest('hex').slice(0, 24)}` +} + +function makeRootId(scope: string, rootPath: string): string { + return `root_${crypto.createHash('sha256').update(`${scope}\0${rootPath}`).digest('hex').slice(0, 20)}` +} + +function makeProjectId(projectPath: string): string { + return `project_${crypto.createHash('sha256').update(projectPath).digest('hex').slice(0, 20)}` +} + +function hashSkillContent(content: string): string | null { + if (!content) return null + return crypto.createHash('sha256').update(content).digest('hex') +} + +function isPathInside(root: string, target: string): boolean { + const relative = path.relative(root, target) + return relative === '' || ( + relative !== '..' && + !relative.startsWith('..' + path.sep) && + !path.isAbsolute(relative) + ) +} + +function exposuresFor( + agent: AgentId, + frontmatterAgent: AgentId | null, + scope: 'global' | 'project' | 'plugin', +): AgentExposure[] { + if (scope === 'plugin') { + return [{ agentId: agent, reason: 'plugin' }] + } + const shared = agent === 'universal' + const exposures: AgentExposure[] = exposedAgentIdsFor(agent).map((agentId) => ({ + agentId, + reason: shared ? 'shared-agents-path' : 'direct-path', + })) + if (frontmatterAgent && !exposures.some((entry) => entry.agentId === frontmatterAgent)) { + exposures.push({ agentId: frontmatterAgent, reason: 'frontmatter' }) + } + return exposures } /** @@ -73,10 +136,20 @@ async function scanSkillDir( projectName?: string, projectPath?: string, disabledSkills?: Set, + authorizationRootId?: string, + projectionIssues: ProjectionIssue[] = [], ): Promise { const skills: Skill[] = [] + let pluginRealRoot: string | null = null + if (scope === 'plugin') { + try { + pluginRealRoot = await fs.realpath(skillDir) + } catch { + return skills + } + } - let entries: Awaited> + let entries: Dirent[] try { entries = await fs.readdir(skillDir, { withFileTypes: true }) } catch { @@ -88,6 +161,24 @@ async function scanSkillDir( const symlinkInfo = await resolveSymlink(entryPath) const realPath = symlinkInfo.realPath + if (symlinkInfo.broken) { + if (scope !== 'plugin') { + const issueInput = { + agent, + scope: scope === 'project' ? 'project' as const : 'global' as const, + targetPath: entryPath, + status: 'broken' as const, + managedBy: 'external' as const, + } + projectionIssues.push({ + ...issueInput, + id: projectionIssueId(issueInput), + detail: '发现无法解析的目录软链;原始内容未被修改。', + }) + } + continue + } + if (pluginRealRoot && !isPathInside(pluginRealRoot, realPath)) continue let isDir = false try { @@ -125,9 +216,11 @@ async function scanSkillDir( } const files = await listSkillFiles(realPath) - const source = symlinkInfo.isSymlink - ? identifySource(realPath, homedir) - : 'local' + const source = scope === 'plugin' + ? 'plugin' + : symlinkInfo.isSymlink + ? identifySource(realPath, homedir) + : 'local' let lastModified = new Date().toISOString() try { @@ -141,10 +234,44 @@ async function scanSkillDir( // Frontmatter `agent:` overrides the path-based guess when it's a known id. const fmAgent = toSafeString((safeFrontmatter as any).agent).toLowerCase().trim() - const resolvedAgent: AgentId = fmAgent && isValidAgentId(fmAgent) ? fmAgent : agent + const resolvedAgent: AgentId = scope === 'plugin' + ? agent + : fmAgent && isValidAgentId(fmAgent) ? fmAgent : agent + const frontmatterAgent = scope !== 'plugin' && fmAgent && isValidAgentId(fmAgent) + ? fmAgent + : null + const exposedTo = exposuresFor(agent, frontmatterAgent, scope) + const installation: SkillInstallation = { + id: makeInstallationId(entryPath), + authorizationRootId: authorizationRootId || makeRootId(scope, skillDir), + scope, + agent: resolvedAgent, + source, + path: entryPath, + realPath, + isSymlink: symlinkInfo.isSymlink, + symlinkTarget: symlinkInfo.isSymlink ? symlinkInfo.target : undefined, + projectName, + projectPath, + projectId: projectPath ? makeProjectId(projectPath) : undefined, + exposedTo, + strategy: scope === 'plugin' + ? 'plugin' + : symlinkInfo.isSymlink + ? 'symlink' + : agent === 'universal' + ? 'native-shared' + : 'direct', + status: 'valid', + managedBy: 'external', + editable: false, + canEditCanonical: false, + canUnlinkProjection: false, + canDeleteCanonical: false, + } skills.push({ - id: makeId(entryPath), + id: makeId(realPath), name: skillName, description, scope, @@ -162,6 +289,14 @@ async function scanSkillDir( enabled: disabledSkills ? !disabledSkills.has(skillName) : true, hasConflict: false, lastModified, + contentHash: hashSkillContent(rawContent || content), + packageHash: null, + sourceIdentity: sourceIdentityFromFrontmatter(safeFrontmatter as any), + identitySignals: [], + convergenceGroupIds: [], + installations: [installation], + exposedTo: exposedTo.map((entry) => entry.agentId), + editable: false, }) } @@ -190,9 +325,21 @@ async function hasAnyAgentSkills(projectRoot: string): Promise { return false } -async function discoverProjects(): Promise<{ name: string; path: string }[]> { +type ProjectDiscovery = 'all' | 'current-only' + +async function discoverProjects(mode: ProjectDiscovery): Promise<{ name: string; path: string }[]> { const projects: { name: string; path: string }[] = [] + if (mode === 'current-only') { + if (explicitProjectRoot && await hasAnyAgentSkills(explicitProjectRoot)) { + projects.push({ + name: path.basename(explicitProjectRoot), + path: explicitProjectRoot, + }) + } + return projects + } + // 1. ~/.claude/projects/ (mangled path dirs — Claude tracks projects it's been opened in) const projectsDir = path.join(homedir, '.claude', 'projects') try { @@ -213,19 +360,21 @@ async function discoverProjects(): Promise<{ name: string; path: string }[]> { } catch {} // 2. Common project root dirs — expanded list - const commonDirs = [ - path.join(homedir, 'Documents'), - path.join(homedir, 'Projects'), - path.join(homedir, 'Developer'), - path.join(homedir, 'Code'), - path.join(homedir, 'code'), - path.join(homedir, 'workspace'), - path.join(homedir, 'dev'), - path.join(homedir, 'Dev'), - path.join(homedir, 'work'), - path.join(homedir, 'repos'), - path.join(homedir, 'src'), - ] + const commonDirs = fixtureRoot + ? [path.join(homedir, 'projects')] + : [ + path.join(homedir, 'Documents'), + path.join(homedir, 'Projects'), + path.join(homedir, 'Developer'), + path.join(homedir, 'Code'), + path.join(homedir, 'code'), + path.join(homedir, 'workspace'), + path.join(homedir, 'dev'), + path.join(homedir, 'Dev'), + path.join(homedir, 'work'), + path.join(homedir, 'repos'), + path.join(homedir, 'src'), + ] for (const dir of commonDirs) { try { @@ -246,16 +395,27 @@ async function discoverProjects(): Promise<{ name: string; path: string }[]> { // `.claude/skills/` etc. are the *global* paths, not project paths. // Running `skill-hub` from home otherwise causes every global skill to // be double-counted as "lhc (cwd)/" in the scan report. - let cwd = process.cwd() - for (let i = 0; i < 4; i++) { - if (cwd !== homedir && (await hasAnyAgentSkills(cwd))) { - if (!projects.some((p) => p.path === cwd)) { - projects.push({ name: path.basename(cwd) + ' (cwd)', path: cwd }) + if (explicitProjectRoot) { + if (await hasAnyAgentSkills(explicitProjectRoot)) { + if (!projects.some((project) => project.path === explicitProjectRoot)) { + projects.push({ + name: path.basename(explicitProjectRoot), + path: explicitProjectRoot, + }) } } - const parent = path.dirname(cwd) - if (parent === cwd) break - cwd = parent + } else if (!fixtureRoot) { + let cwd = process.cwd() + for (let i = 0; i < 4; i++) { + if (cwd !== homedir && (await hasAnyAgentSkills(cwd))) { + if (!projects.some((p) => p.path === cwd)) { + projects.push({ name: path.basename(cwd) + ' (cwd)', path: cwd }) + } + } + const parent = path.dirname(cwd) + if (parent === cwd) break + cwd = parent + } } return projects @@ -272,8 +432,14 @@ async function discoverProjects(): Promise<{ name: string; path: string }[]> { * entire plugins/ tree and reported those candidates as installed plugin * skills, which was very confusing for users who had never enabled a plugin. */ -async function discoverPluginSkillDirs(): Promise { - const result: string[] = [] +interface PluginSkillDir { + dir: string + label: string + agent: AgentId +} + +async function discoverClaudePluginSkillDirs(): Promise { + const result: PluginSkillDir[] = [] const pluginsRoot = path.join(homedir, '.claude', 'plugins') const configPath = path.join(pluginsRoot, 'config.json') @@ -298,7 +464,7 @@ async function discoverPluginSkillDirs(): Promise { async function walk(dir: string, depth: number) { if (depth > 4) return - let entries: Awaited> + let entries: Dirent[] try { entries = await fs.readdir(dir, { withFileTypes: true }) } catch { @@ -309,7 +475,11 @@ async function discoverPluginSkillDirs(): Promise { if (entry.name === 'node_modules' || entry.name.startsWith('.git')) continue const sub = path.join(dir, entry.name) if (entry.name === 'skills') { - result.push(sub) + result.push({ + dir: sub, + label: `claude-plugin:${path.relative(pluginsRoot, sub)}`, + agent: 'claude-code', + }) continue } await walk(sub, depth + 1) @@ -327,6 +497,122 @@ async function discoverPluginSkillDirs(): Promise { return result } +interface CodexPluginListEntry { + pluginId?: unknown + name?: unknown + marketplaceName?: unknown + version?: unknown + installed?: unknown + enabled?: unknown +} + +function isSafePluginSegment(value: unknown): value is string { + return typeof value === 'string' && + value.length > 0 && + value.length <= 160 && + value !== '.' && + value !== '..' && + !value.includes('/') && + !value.includes('\\') && + !value.includes('\0') +} + +async function readCodexPluginList(): Promise { + if (fixtureRoot) { + const fixtureListPath = process.env.SKILLMANAGER_CODEX_PLUGIN_LIST_PATH?.trim() + if (!fixtureListPath || !path.isAbsolute(fixtureListPath)) return { installed: [] } + const resolved = path.resolve(fixtureListPath) + if (!isPathInside(fixtureRoot, resolved)) return { installed: [] } + try { + return JSON.parse(await fs.readFile(resolved, 'utf8')) + } catch { + return { installed: [] } + } + } + + return await new Promise((resolve) => { + execFile( + 'codex', + ['plugin', 'list', '--json'], + { timeout: 5_000, maxBuffer: 1024 * 1024, windowsHide: true }, + (error, stdout) => { + if (error) { + resolve({ installed: [] }) + return + } + try { + resolve(JSON.parse(stdout)) + } catch { + resolve({ installed: [] }) + } + }, + ) + }) +} + +async function discoverCodexPluginSkillDirs(): Promise { + const payload = await readCodexPluginList() + const entries = payload && typeof payload === 'object' && Array.isArray((payload as any).installed) + ? (payload as { installed: CodexPluginListEntry[] }).installed + : [] + const result: PluginSkillDir[] = [] + const cacheRoot = path.join(homedir, '.codex', 'plugins', 'cache') + + for (const entry of entries) { + if (!entry || entry.installed !== true || entry.enabled !== true) continue + if ( + !isSafePluginSegment(entry.pluginId) || + !isSafePluginSegment(entry.name) || + !isSafePluginSegment(entry.marketplaceName) || + !isSafePluginSegment(entry.version) + ) continue + + const pluginRoot = path.join(cacheRoot, entry.marketplaceName, entry.name, entry.version) + if (!isPathInside(cacheRoot, pluginRoot) || !(await dirExists(pluginRoot))) continue + + let skillsSetting = './skills/' + try { + const manifest = JSON.parse( + await fs.readFile(path.join(pluginRoot, '.codex-plugin', 'plugin.json'), 'utf8'), + ) as { name?: unknown; version?: unknown; skills?: unknown } + if (manifest.name !== entry.name || manifest.version !== entry.version) continue + if (manifest.skills !== undefined) { + if (typeof manifest.skills !== 'string' || !manifest.skills.trim()) continue + skillsSetting = manifest.skills.trim() + } + } catch { + continue + } + + const skillsDir = path.resolve(pluginRoot, skillsSetting) + if (!isPathInside(pluginRoot, skillsDir) || !(await dirExists(skillsDir))) continue + try { + const [realPluginRoot, realSkillsDir] = await Promise.all([ + fs.realpath(pluginRoot), + fs.realpath(skillsDir), + ]) + if (!isPathInside(realPluginRoot, realSkillsDir)) continue + } catch { + continue + } + result.push({ + dir: skillsDir, + label: `codex-plugin:${entry.pluginId}`, + agent: 'codex', + }) + } + + return result +} + +async function discoverPluginSkillDirs(): Promise { + const [claude, codex] = await Promise.all([ + discoverClaudePluginSkillDirs(), + discoverCodexPluginSkillDirs(), + ]) + return [...claude, ...codex] +} + function detectConflicts(skills: Skill[]): ConflictGroup[] { const byName = new Map() for (const skill of skills) { @@ -343,14 +629,18 @@ function detectConflicts(skills: Skill[]): ConflictGroup[] { const realPaths = new Set(group.map((s) => s.realPath)) if (realPaths.size <= 1) continue group.forEach((s) => (s.hasConflict = true)) - conflicts.push({ name, skills: group }) + conflicts.push({ + id: conflictId(name, group.map((skill) => skill.id)), + name, + skills: group, + }) } } return conflicts } function parseExtraPaths(): string[] { - const raw = process.env.SKILL_HUB_EXTRA_PATHS + const raw = process.env.SKILLMANAGER_EXTRA_PATHS || process.env.SKILL_HUB_EXTRA_PATHS if (!raw) return [] return raw .split(/[:,]/) @@ -359,11 +649,32 @@ function parseExtraPaths(): string[] { .map((p) => (p.startsWith('~') ? path.join(homedir, p.slice(1)) : p)) } -export async function fullScan(): Promise { +async function forEachWithConcurrency( + values: T[], + limit: number, + worker: (value: T) => Promise, +): Promise { + let index = 0 + const runners = Array.from({ length: Math.min(limit, values.length) }, async () => { + while (index < values.length) { + const current = values[index] + index += 1 + await worker(current) + } + }) + await Promise.all(runners) +} + +export async function fullScan(options: { + projectDiscovery?: ProjectDiscovery +} = {}): Promise { const start = Date.now() + const projectDiscovery = options.projectDiscovery || 'all' const disabledSkills = await getDisabledSkills() const allSkills: Skill[] = [] const scannedPaths: ScanPathReport[] = [] + const authorizedRoots: AuthorizedRoot[] = [] + const initialProjectionIssues: ProjectionIssue[] = [] async function scanAndReport( label: string, @@ -379,7 +690,33 @@ export async function fullScan(): Promise { return [] } try { - const skills = await scanSkillDir(dir, scope, agent, projectName, projectPath, disabledSkills) + const rootId = makeRootId(scope, dir) + let realRoot = dir + try { + realRoot = await fs.realpath(dir) + } catch {} + if (!authorizedRoots.some((root) => root.id === rootId)) { + authorizedRoots.push({ + id: rootId, + path: dir, + realPath: realRoot, + scope, + editable: scope !== 'plugin', + agent, + projectName, + projectId: projectPath ? makeProjectId(projectPath) : undefined, + }) + } + const skills = await scanSkillDir( + dir, + scope, + agent, + projectName, + projectPath, + disabledSkills, + rootId, + initialProjectionIssues, + ) scannedPaths.push({ label, path: dir, exists: true, count: skills.length }) return skills } catch (e: any) { @@ -401,17 +738,16 @@ export async function fullScan(): Promise { ) } - // 2. Plugin skills — Claude Code only for now + // 2. Plugin skills — enabled installations only, never marketplace catalogs const pluginSkillDirs = await discoverPluginSkillDirs() - for (const pluginDir of pluginSkillDirs) { - const pluginName = path.relative(path.join(homedir, '.claude', 'plugins'), pluginDir) + for (const plugin of pluginSkillDirs) { allSkills.push( - ...(await scanAndReport(`plugin:${pluginName}`, pluginDir, 'plugin', 'claude-code')), + ...(await scanAndReport(plugin.label, plugin.dir, 'plugin', plugin.agent)), ) } // 3. Project skills — for each project, scan every agent's project paths - const discoveredProjects = await discoverProjects() + const discoveredProjects = await discoverProjects(projectDiscovery) const projects: Project[] = [] for (const proj of discoveredProjects) { @@ -432,6 +768,7 @@ export async function fullScan(): Promise { } } projects.push({ + id: makeProjectId(proj.path), name: proj.name, path: proj.path, skillCount: projectTotal, @@ -439,22 +776,79 @@ export async function fullScan(): Promise { } // 4. Extra paths from SKILL_HUB_EXTRA_PATHS — agent unknown - for (const extra of parseExtraPaths()) { - allSkills.push( - ...(await scanAndReport(`extra:${path.basename(extra)}`, extra, 'project', 'unknown')), - ) + if (projectDiscovery === 'all') { + for (const extra of parseExtraPaths()) { + allSkills.push( + ...(await scanAndReport(`extra:${path.basename(extra)}`, extra, 'project', 'unknown')), + ) + } } // Deduplicate by realPath (symlinks can point to the same skill from multiple roots) - const seen = new Set() - const dedupedSkills: Skill[] = [] + const byRealPath = new Map() for (const s of allSkills) { - if (seen.has(s.realPath)) continue - seen.add(s.realPath) - dedupedSkills.push(s) + const existing = byRealPath.get(s.realPath) + if (!existing) { + byRealPath.set(s.realPath, s) + continue + } + for (const installation of s.installations) { + if (!existing.installations.some((item) => item.id === installation.id)) { + existing.installations.push(installation) + } + } + existing.exposedTo = Array.from(new Set([...existing.exposedTo, ...s.exposedTo])) + + // Prefer a directly managed, non-plugin installation for legacy fields. + if (existing.scope === 'plugin' && s.scope !== 'plugin') { + existing.scope = s.scope + existing.agent = s.agent + existing.source = s.source + existing.path = s.path + existing.symlinkTarget = s.symlinkTarget + existing.projectName = s.projectName + existing.projectPath = s.projectPath + } } + const dedupedSkills = Array.from(byRealPath.values()) + + await forEachWithConcurrency(dedupedSkills, 8, async (skill) => { + skill.packageHash = await hashSkillDirectory(skill.realPath) + skill.sourceIdentity = sourceIdentityFromFrontmatter(skill.frontmatter) + }) + + const rootsById = new Map(authorizedRoots.map((root) => [root.id, root])) + for (const skill of dedupedSkills) { + for (const installation of skill.installations) { + const root = rootsById.get(installation.authorizationRootId) + if (!root || !root.editable || installation.scope === 'plugin') { + installation.editable = false + installation.readOnlyReason = 'plugin_or_unmanaged_root' + continue + } + const linkInsideRoot = isPathInside(root.path, installation.path) + const targetInsideRoot = isPathInside(root.realPath, installation.realPath) + installation.editable = linkInsideRoot && targetInsideRoot + if (!installation.editable) { + installation.readOnlyReason = targetInsideRoot + ? 'installation_outside_authorized_root' + : 'symlink_target_outside_authorized_root' + } + } + skill.editable = skill.installations.some((installation) => installation.editable) + skill.readOnlyReason = skill.editable + ? undefined + : skill.installations[0]?.readOnlyReason || 'no_writable_installation' + } + + const projectionIssues = await decorateProjectionState({ + skills: dedupedSkills, + roots: authorizedRoots, + initialIssues: initialProjectionIssues, + }) const conflicts = detectConflicts(dedupedSkills) + const convergenceGroups = buildConvergenceGroups(dedupedSkills) // Classify skills into categories + generate merge suggestions const { skills: classifiedSkills, categories, mergeSuggestions, byCategory } = @@ -476,25 +870,66 @@ export async function fullScan(): Promise { const byAgent: Record = {} for (const s of classifiedSkills) { bySource[s.source] = (bySource[s.source] || 0) + 1 - byAgent[s.agent] = (byAgent[s.agent] || 0) + 1 + for (const agentId of s.exposedTo) { + byAgent[agentId] = (byAgent[agentId] || 0) + 1 + } } - return { + const agentDetections = await detectAgentInstallations({ + home: homedir, + fixtureMode: fixtureRoot !== null, + visibleCounts: byAgent, + }) + + const result: ScanResult = { + scanMode: projectDiscovery === 'current-only' ? 'current-project' : 'all', skills: classifiedSkills, projects, + currentProject: explicitProjectRoot + ? (() => { + const current = projects.find((project) => project.path === explicitProjectRoot) + return current + ? { + status: 'registered' as const, + configured: true, + id: current.id, + name: current.name, + skillCount: current.skillCount, + } + : { + status: 'configured_empty' as const, + configured: true, + id: null, + name: path.basename(explicitProjectRoot), + skillCount: 0, + } + })() + : { + status: 'unavailable' as const, + configured: false, + id: null, + name: null, + skillCount: 0, + }, conflicts, + convergenceGroups, + projectionIssues, + agentDetections, categories, mergeSuggestions, health, stats: { total: classifiedSkills.length, - global: classifiedSkills.filter((s) => s.scope === 'global').length, - project: classifiedSkills.filter((s) => s.scope === 'project').length, + global: classifiedSkills.filter((s) => s.installations.some((i) => i.scope === 'global')).length, + project: classifiedSkills.filter((s) => s.installations.some((i) => i.scope === 'project')).length, bySource, byAgent, byCategory, }, scannedPaths, + authorizedRoots, durationMs: Date.now() - start, } + Object.defineProperty(result, 'authorizedRoots', { enumerable: false }) + return result } diff --git a/server/scanner/health.ts b/server/scanner/health.ts index 1eaa735..cde26e8 100644 --- a/server/scanner/health.ts +++ b/server/scanner/health.ts @@ -1,20 +1,12 @@ /** * Skill Health Diagnostics * - * Based on research from "When Single-Agent with Skills Replace Multi-Agent - * Systems and When They Fail" (Xiaoxiao Li, UBC, arXiv:2601.04748): - * - * - ≤ 20 skills: ~90%+ accuracy (green) - * - 21-50 skills: 70-90% accuracy (yellow) - * - > 50 skills: accuracy drops steeply (red) - * - * Semantic confusability is more damaging than raw quantity: - * - 1 semantic competitor per skill: -7-30% accuracy - * - 2 competitors: -17-63% accuracy - * - * Hierarchical routing recovers +37-40% absolute at 120 skills. + * Research motivates directional warnings about library size and semantic + * competition. The score below is a product heuristic, not a prediction of + * one user's model accuracy or proof of which Skill a model selected. */ import type { Skill, ConflictGroup } from '../types.js' +import { diagnosticId } from '../core/opaque-ids.js' import type { SimilarityGroup } from './similarity.js' import type { CategorySummary, MergeSuggestion } from './taxonomy.js' @@ -26,6 +18,7 @@ export type HealthLevel = 'green' | 'yellow' | 'red' export type DiagnosticSeverity = 'info' | 'warn' | 'danger' export interface Diagnostic { + id: string type: | 'quantity' | 'semantic_clash' @@ -48,7 +41,7 @@ export interface HealthReport { } // --------------------------------------------------------------------------- -// Thresholds (from the paper) +// Product heuristic thresholds // --------------------------------------------------------------------------- const GREEN_MAX = 20 @@ -68,7 +61,7 @@ export function computeHealth( _categories: CategorySummary[], mergeSuggestions: MergeSuggestion[], ): HealthReport { - const diagnostics: Diagnostic[] = [] + const diagnostics: Omit[] = [] const total = skills.length // ---- 1. Quantity diagnostic ---- @@ -77,7 +70,7 @@ export function computeHealth( type: 'quantity', severity: 'danger', title: `Skill 总量过多 (${total} 个)`, - detail: `研究表明超过 50 个 Skill 后触发准确率急剧下降。当前 ${total} 个,建议精简到 30 以下。`, + detail: `Skill 数量增加会提高候选之间的选择竞争风险。当前 ${total} 个,建议优先处理同名、语义重叠和作用域过宽的项目。`, affectedSkillIds: [], }) } else if (total > GREEN_MAX) { @@ -85,7 +78,7 @@ export function computeHealth( type: 'quantity', severity: 'warn', title: `Skill 数量偏多 (${total} 个)`, - detail: `当前 ${total} 个 Skill,触发准确率约 ${estimateAccuracy(total)}%。精简到 20 以下可保持 90%+ 准确率。`, + detail: `当前 ${total} 个 Skill。数量本身不是运行时结论,建议结合相似度、冲突和实际提示词测试决定是否精简。`, affectedSkillIds: [], }) } @@ -126,7 +119,7 @@ export function computeHealth( type: 'missing_description', severity: noDesc.length > total * 0.3 ? 'warn' : 'info', title: `${noDesc.length} 个 Skill 缺少有效描述 (${pct}%)`, - detail: `好的 description 可将触发率从 20% 提升到 50%。这些 Skill 缺少描述或描述不足 10 字。`, + detail: `这些 Skill 缺少描述或描述不足 10 字,静态上难以表达清楚的触发边界;是否影响实际选择仍需用真实提示词测试。`, affectedSkillIds: noDesc.map((s) => s.id), }) } @@ -243,9 +236,9 @@ export function computeHealth( // Summary text let summary: string if (level === 'green') { - summary = `Skill 配置健康 (${total} 个),触发准确率预计 ${estimateAccuracy(total)}%+` + summary = `未发现高优先级静态风险 (${total} 个 Skill),实际触发仍需提示词测试` } else if (level === 'yellow') { - summary = `Skill 数量偏多 (${total} 个),建议精简。预计准确率约 ${estimateAccuracy(total)}%` + summary = `发现需要关注的静态配置风险 (${total} 个 Skill),建议先处理冲突与描述边界` } else { summary = `Skill 配置需要优化 (${total} 个),存在 ${diagnostics.filter((d) => d.severity === 'danger').length} 个严重问题` } @@ -258,16 +251,13 @@ export function computeHealth( } diagnostics.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]) - return { level, score, summary, diagnostics } -} - -/** - * Rough accuracy estimate based on the paper's findings. - * Uses a simplified curve fit: accuracy ≈ 96 * e^(-0.005 * (n-5)^1.3) - * Clamped to [15, 96]. - */ -function estimateAccuracy(n: number): number { - if (n <= 5) return 96 - const raw = 96 * Math.exp(-0.005 * Math.pow(n - 5, 1.3)) - return Math.max(15, Math.min(96, Math.round(raw))) + return { + level, + score, + summary, + diagnostics: diagnostics.map((diagnostic) => ({ + ...diagnostic, + id: diagnosticId(diagnostic), + })), + } } diff --git a/server/scanner/identity.ts b/server/scanner/identity.ts new file mode 100644 index 0000000..6ec92fe --- /dev/null +++ b/server/scanner/identity.ts @@ -0,0 +1,235 @@ +import type { + ConvergenceGroup, + Skill, + SkillFrontmatter, + SkillIdentitySignal, +} from '../types.js' +import { convergenceGroupId } from '../core/opaque-ids.js' + +function normalizedName(value: string): string { + return value.trim().toLocaleLowerCase().replace(/\s+/g, ' ') +} + +export function convergenceContextKey(skill: Skill): string { + const scopes = new Set(skill.installations.map((installation) => installation.scope)) + if (scopes.has('plugin')) return 'plugin' + if (scopes.has('global') && scopes.has('project')) return 'mixed' + if (scopes.has('global')) return 'global' + const projectIds = Array.from(new Set(skill.installations.map((installation) => ( + installation.projectId || installation.projectPath || 'unknown-project' + )))).sort() + return projectIds.length === 1 ? `project:${projectIds[0]}` : `mixed-project:${projectIds.join(',')}` +} + +function stringValue(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +function normalizeRepository(value: string): string { + let result = value.trim() + if (/^[\w.-]+\/[\w.-]+$/.test(result)) result = `https://github.com/${result}` + result = result.replace(/^git\+/, '').replace(/\.git$/, '').replace(/\/$/, '') + return result.toLocaleLowerCase() +} + +export function sourceIdentityFromFrontmatter(frontmatter: SkillFrontmatter): string | null { + const raw = frontmatter as Record + const metadata = raw.metadata && typeof raw.metadata === 'object' + ? raw.metadata as Record + : {} + const source = + stringValue(metadata.source) || + stringValue(metadata.repository) || + stringValue(metadata.repo) || + stringValue(raw.repository) || + stringValue(raw.repo) || + stringValue(raw.source) + if (!source) return null + + const ref = + stringValue(metadata.revision) || + stringValue(metadata.ref) || + stringValue(raw.revision) || + stringValue(raw.ref) + const subpath = + stringValue(metadata.skill_path) || + stringValue(metadata.skillPath) || + stringValue(metadata.subpath) || + stringValue(raw.skill_path) || + stringValue(raw.subpath) + + return [normalizeRepository(source), ref || '', subpath || ''].join('#') +} + +function addSignal(skill: Skill, signal: SkillIdentitySignal): void { + if (!skill.identitySignals.includes(signal)) skill.identitySignals.push(signal) +} + +function groupName(skills: Skill[]): string { + const names = Array.from(new Set(skills.map((skill) => skill.name))) + return names.length === 1 ? names[0] : names.join(' / ') +} + +function mutableInstallationCount(skill: Skill): number { + return skill.installations.filter((installation) => ( + installation.scope !== 'plugin' && + (installation.canDeleteCanonical || installation.canUnlinkProjection) + )).length +} + +function canBeCanonical(skill: Skill): boolean { + return skill.installations.some((installation) => ( + installation.scope !== 'plugin' && + !installation.isSymlink && + installation.canDeleteCanonical + )) +} + +function canBeReplaced(skill: Skill): boolean { + return skill.installations.length > 0 && skill.installations.every((installation) => ( + installation.scope !== 'plugin' && + (installation.canDeleteCanonical || installation.canUnlinkProjection) + )) +} + +function canonicalPreference(skill: Skill): number { + if (skill.installations.some((installation) => ( + installation.scope === 'global' && + installation.agent === 'universal' && + !installation.isSymlink && + installation.canDeleteCanonical + ))) return 0 + if (skill.realPath.split(/[\\/]/).join('/').includes('/.agents/skills/')) return 1 + if (skill.installations.some((installation) => installation.scope === 'global')) return 2 + return 3 +} + +export function buildConvergenceGroups(skills: Skill[]): ConvergenceGroup[] { + for (const skill of skills) { + skill.identitySignals = skill.installations.length > 1 ? ['shared-entry'] : [] + skill.convergenceGroupIds = [] + } + + const groups: ConvergenceGroup[] = [] + const exactMemberships = new Set() + const byHash = new Map() + for (const skill of skills) { + if (!skill.packageHash) continue + const entries = byHash.get(skill.packageHash) || [] + entries.push(skill) + byHash.set(skill.packageHash, entries) + } + + for (const [packageHash, candidates] of byHash) { + const physical = Array.from(new Map(candidates.map((skill) => [skill.realPath, skill])).values()) + if (physical.length < 2) continue + const ids = physical.map((skill) => skill.id) + const contexts = new Set(physical.map(convergenceContextKey)) + const sameContext = contexts.size === 1 + const canonicalCandidates = (sameContext ? physical : []).filter((candidate) => ( + canBeCanonical(candidate) && + physical.every((skill) => skill.id === candidate.id || canBeReplaced(skill)) + )).sort((a, b) => ( + canonicalPreference(a) - canonicalPreference(b) || a.realPath.localeCompare(b.realPath) + )) + const eligible = canonicalCandidates.length > 0 && + physical.filter((skill) => mutableInstallationCount(skill) > 0).length >= 2 + const id = convergenceGroupId('exact-copy', packageHash, ids) + const group: ConvergenceGroup = { + id, + kind: 'exact-copy', + name: groupName(physical), + packageHash, + sourceIdentity: physical.every((skill) => skill.sourceIdentity === physical[0].sourceIdentity) + ? physical[0].sourceIdentity + : null, + skillIds: ids, + skills: physical, + canonicalCandidateSkillIds: canonicalCandidates.map((skill) => skill.id), + recommendedCanonicalSkillId: canonicalCandidates[0]?.id || null, + eligible, + reason: eligible + ? '完整 Skill 目录哈希一致,可在预览并确认后收敛为一个真源。' + : !sameContext + ? '副本跨越全局与项目作用域,可能需要独立演进,只提供诊断。' + : '包含只读、插件或无法安全替换的安装位置,只提供诊断。', + } + groups.push(group) + for (const skill of physical) { + addSignal(skill, 'exact-copy') + skill.convergenceGroupIds.push(id) + exactMemberships.add(`${normalizedName(skill.name)}\0${skill.id}`) + } + } + + const bySource = new Map() + for (const skill of skills) { + if (!skill.sourceIdentity) continue + const entries = bySource.get(skill.sourceIdentity) || [] + entries.push(skill) + bySource.set(skill.sourceIdentity, entries) + } + for (const [sourceIdentity, candidates] of bySource) { + const physical = Array.from(new Map(candidates.map((skill) => [skill.realPath, skill])).values()) + const hashes = new Set(physical.map((skill) => skill.packageHash).filter(Boolean)) + if (physical.length < 2 || hashes.size < 2) continue + const ids = physical.map((skill) => skill.id) + const id = convergenceGroupId('source-drift', sourceIdentity, ids) + groups.push({ + id, + kind: 'source-drift', + name: groupName(physical), + packageHash: null, + sourceIdentity, + skillIds: ids, + skills: physical, + canonicalCandidateSkillIds: [], + recommendedCanonicalSkillId: null, + eligible: false, + reason: '来源相同但目录内容不同,必须先查看差异并选择版本,禁止自动收敛。', + }) + for (const skill of physical) { + addSignal(skill, 'source-drift') + skill.convergenceGroupIds.push(id) + } + } + + const byName = new Map() + for (const skill of skills) { + const key = normalizedName(skill.name) + const entries = byName.get(key) || [] + entries.push(skill) + byName.set(key, entries) + } + for (const [name, candidates] of byName) { + const physical = Array.from(new Map(candidates.map((skill) => [skill.realPath, skill])).values()) + if (physical.length < 2) continue + const allCoveredByExact = physical.every((skill) => exactMemberships.has(`${name}\0${skill.id}`)) + const sourceIdentities = new Set(physical.map((skill) => skill.sourceIdentity).filter(Boolean)) + if (allCoveredByExact || (sourceIdentities.size === 1 && physical.every((skill) => skill.sourceIdentity))) { + continue + } + const ids = physical.map((skill) => skill.id) + const id = convergenceGroupId('name-conflict', name, ids) + groups.push({ + id, + kind: 'name-conflict', + name: physical[0].name, + packageHash: null, + sourceIdentity: null, + skillIds: ids, + skills: physical, + canonicalCandidateSkillIds: [], + recommendedCanonicalSkillId: null, + eligible: false, + reason: '名称相同但内容或来源不能证明一致,必须人工判断,禁止自动合并。', + }) + for (const skill of physical) { + addSignal(skill, 'name-conflict') + skill.convergenceGroupIds.push(id) + } + } + + const order = { 'exact-copy': 0, 'source-drift': 1, 'name-conflict': 2 } + return groups.sort((a, b) => order[a.kind] - order[b.kind] || a.name.localeCompare(b.name)) +} diff --git a/server/scanner/package-hash.ts b/server/scanner/package-hash.ts new file mode 100644 index 0000000..7ac362b --- /dev/null +++ b/server/scanner/package-hash.ts @@ -0,0 +1,179 @@ +import crypto from 'crypto' +import fs from 'fs/promises' +import path from 'path' + +const DEFAULT_MAX_FILES = 2_000 +const DEFAULT_MAX_BYTES = 64 * 1024 * 1024 + +const IGNORED_DIRECTORIES = new Set([ + '.git', + '.hg', + '.svn', + '.venv', + '__pycache__', + 'node_modules', +]) + +const IGNORED_FILES = new Set([ + '.DS_Store', + '.skillmanager-source.json', + '.skills-manager-source.json', +]) + +class HashLimitError extends Error {} + +export interface SkillPackageBoundaryAudit { + safe: boolean + issue?: 'root-is-link' | 'broken-link' | 'external-link' | 'unreadable' + relativePath?: string +} + +function normalizedRelative(root: string, target: string): string { + return path.relative(root, target).split(path.sep).join('/') +} + +/** + * Hash the complete Skill package without following nested symlinks. + * + * The scanner uses this only as a conservative exact-copy signal. Unreadable + * or unexpectedly large packages return null and are never auto-converged. + */ +export async function hashSkillDirectory( + root: string, + options: { maxFiles?: number; maxBytes?: number } = {}, +): Promise { + const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES + const hash = crypto.createHash('sha256') + let files = 0 + let bytes = 0 + + async function walk(target: string): Promise { + const stat = await fs.lstat(target) + const relative = normalizedRelative(root, target) || '.' + + if (stat.isSymbolicLink()) { + files += 1 + if (files > maxFiles) throw new HashLimitError('too many files') + hash.update(`link\0${relative}\0`) + hash.update(await fs.readlink(target)) + hash.update('\0') + return + } + + if (stat.isFile()) { + files += 1 + bytes += stat.size + if (files > maxFiles || bytes > maxBytes) { + throw new HashLimitError('package exceeds hash limits') + } + hash.update(`file\0${relative}\0${stat.size}\0`) + hash.update(await fs.readFile(target)) + hash.update('\0') + return + } + + if (!stat.isDirectory()) { + hash.update(`other\0${relative}\0`) + return + } + + hash.update(`dir\0${relative}\0`) + const entries = (await fs.readdir(target, { withFileTypes: true })) + .filter((entry) => ( + entry.isDirectory() + ? !IGNORED_DIRECTORIES.has(entry.name) + : !IGNORED_FILES.has(entry.name) + )) + .sort((a, b) => a.name.localeCompare(b.name)) + + for (const entry of entries) { + await walk(path.join(target, entry.name)) + } + } + + try { + const rootStat = await fs.lstat(root) + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return null + await walk(root) + return hash.digest('hex') + } catch { + return null + } +} + +/** + * Reject Skill packages whose nested links are broken or escape the package. + * + * Convergence makes one package visible from several Agent roots. Keeping this + * check separate from hashing prevents an otherwise identical external link + * from being treated as safe merely because its link text matches. + */ +export async function auditSkillPackageBoundary(root: string): Promise { + const resolvedRoot = path.resolve(root) + const insideRoot = (target: string) => { + const relative = path.relative(resolvedRoot, target) + return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + } + + async function walk(target: string): Promise { + let stat + try { + stat = await fs.lstat(target) + } catch { + return { + safe: false, + issue: 'unreadable', + relativePath: normalizedRelative(resolvedRoot, target), + } + } + + if (stat.isSymbolicLink()) { + if (path.resolve(target) === resolvedRoot) { + return { safe: false, issue: 'root-is-link', relativePath: '.' } + } + let realTarget: string + try { + realTarget = await fs.realpath(target) + } catch { + return { + safe: false, + issue: 'broken-link', + relativePath: normalizedRelative(resolvedRoot, target), + } + } + if (!insideRoot(realTarget)) { + return { + safe: false, + issue: 'external-link', + relativePath: normalizedRelative(resolvedRoot, target), + } + } + return { safe: true } + } + + if (!stat.isDirectory()) return { safe: true } + let entries + try { + entries = (await fs.readdir(target, { withFileTypes: true })) + .filter((entry) => ( + entry.isDirectory() + ? !IGNORED_DIRECTORIES.has(entry.name) + : !IGNORED_FILES.has(entry.name) + )) + } catch { + return { + safe: false, + issue: 'unreadable', + relativePath: normalizedRelative(resolvedRoot, target), + } + } + for (const entry of entries) { + const result = await walk(path.join(target, entry.name)) + if (!result.safe) return result + } + return { safe: true } + } + + return walk(resolvedRoot) +} diff --git a/server/scanner/projections.ts b/server/scanner/projections.ts new file mode 100644 index 0000000..2f76ce6 --- /dev/null +++ b/server/scanner/projections.ts @@ -0,0 +1,167 @@ +import fs from 'fs/promises' +import path from 'path' +import type { + AuthorizedRoot, + ProjectionIssue, + ProjectionStatus, + Skill, +} from '../types.js' +import { isPathInside } from '../core/authorization.js' +import { projectionIssueId } from '../core/opaque-ids.js' +import { listProjectionRecords } from '../projections/store.js' +import { userHome } from '../config/paths.js' + +async function skillsCliManagedNames(): Promise> { + const candidates = [path.join(userHome, '.agents', '.skill-lock.json')] + const xdgState = process.env.XDG_STATE_HOME?.trim() + if (xdgState && path.isAbsolute(xdgState)) { + candidates.unshift(path.join(xdgState, 'skills', '.skill-lock.json')) + } + for (const candidate of candidates) { + try { + const parsed = JSON.parse(await fs.readFile(candidate, 'utf8')) as { + version?: unknown + skills?: unknown + } + if (typeof parsed.version === 'number' && parsed.skills && typeof parsed.skills === 'object') { + return new Set(Object.keys(parsed.skills as Record)) + } + } catch {} + } + return new Set() +} + +function issue(input: Omit): ProjectionIssue { + return { + ...input, + id: projectionIssueId({ + agent: input.agent, + scope: input.scope, + targetPath: input.targetPath, + status: input.status, + managedBy: input.managedBy, + }), + } +} + +async function inspectRecordTarget( + targetPath: string, + canonicalPath: string, + strategy: 'symlink' | 'junction' | 'managed-copy', + packageHash: string, + matchedSkill?: Skill, +): Promise> { + let stat + try { + stat = await fs.lstat(targetPath) + } catch { + return 'missing' + } + + if (strategy === 'managed-copy') { + if (!stat.isDirectory() || stat.isSymbolicLink()) return 'wrong-target' + return matchedSkill?.packageHash === packageHash ? 'valid' : 'copy-drift' + } + + if (!stat.isSymbolicLink()) return 'wrong-target' + try { + return (await fs.realpath(targetPath)) === canonicalPath ? 'valid' : 'wrong-target' + } catch { + return 'broken' + } +} + +export async function decorateProjectionState(input: { + skills: Skill[] + roots: AuthorizedRoot[] + initialIssues: ProjectionIssue[] +}): Promise { + const records = await listProjectionRecords() + const recordsByTarget = new Map(records.map((record) => [path.resolve(record.targetPath), record])) + const rootsById = new Map(input.roots.map((root) => [root.id, root])) + const matchedRecords = new Set() + const cliManaged = await skillsCliManagedNames() + const sharedRoot = path.join(userHome, '.agents', 'skills') + const issues = [...input.initialIssues] + + for (const skill of input.skills) { + for (const installation of skill.installations) { + const root = rootsById.get(installation.authorizationRootId) + const insideWritableRoot = Boolean( + root && root.editable && installation.scope !== 'plugin' && + isPathInside(root.path, installation.path), + ) + const record = recordsByTarget.get(path.resolve(installation.path)) + if (record) { + matchedRecords.add(record.id) + installation.managedBy = 'skillmanager' + installation.strategy = record.strategy + installation.status = await inspectRecordTarget( + installation.path, + record.canonicalPath, + record.strategy, + record.sourcePackageHash, + skill, + ) + } else if ( + isPathInside(sharedRoot, skill.realPath) && + cliManaged.has(path.basename(skill.realPath)) + ) { + installation.managedBy = 'skills-cli' + } + + installation.canEditCanonical = installation.editable + installation.canUnlinkProjection = insideWritableRoot && installation.isSymlink + installation.canDeleteCanonical = insideWritableRoot && installation.editable && !installation.isSymlink + + if (installation.status !== 'valid' && installation.status !== 'not-managed') { + issues.push(issue({ + installationId: installation.id, + agent: installation.agent, + scope: installation.scope === 'project' ? 'project' : 'global', + targetPath: installation.path, + status: installation.status, + managedBy: installation.managedBy, + detail: installation.status === 'copy-drift' + ? '托管复制与真源目录哈希不一致。' + : installation.status === 'wrong-target' + ? '托管投影不再指向记录中的真源。' + : '投影目标不可读取。', + })) + } + } + } + + for (const record of records) { + if (matchedRecords.has(record.id)) continue + const root = input.roots.find((candidate) => ( + candidate.editable && + candidate.scope !== 'plugin' && + isPathInside(candidate.path, record.targetPath) + )) + if (!root) continue + const status = await inspectRecordTarget( + record.targetPath, + record.canonicalPath, + record.strategy, + record.sourcePackageHash, + ) + if (status === 'valid') continue + issues.push(issue({ + agent: record.agentId, + scope: record.scope, + targetPath: record.targetPath, + status, + managedBy: 'skillmanager', + detail: status === 'missing' + ? 'SkillManager 创建的投影已经不存在。' + : status === 'broken' + ? 'SkillManager 创建的软链目标已经不存在。' + : status === 'copy-drift' + ? '托管复制与记录中的真源哈希不同。' + : '投影存在,但目标与记录中的真源不一致。', + })) + } + + return Array.from(new Map(issues.map((entry) => [entry.id, entry])).values()) +} diff --git a/server/scanner/symlink.ts b/server/scanner/symlink.ts index bcdaa80..09d23d3 100644 --- a/server/scanner/symlink.ts +++ b/server/scanner/symlink.ts @@ -1,10 +1,10 @@ import fs from 'fs/promises' -import path from 'path' export interface SymlinkInfo { isSymlink: boolean target?: string realPath: string + broken: boolean } export async function resolveSymlink(filePath: string): Promise { @@ -13,18 +13,20 @@ export async function resolveSymlink(filePath: string): Promise { // Always canonicalize — a symlink anywhere in the parent chain must be // resolved so entries pointing at the same physical skill share one realPath. let realPath: string + let broken = false try { realPath = await fs.realpath(filePath) } catch { realPath = filePath + broken = stat.isSymbolicLink() } if (stat.isSymbolicLink()) { const target = await fs.readlink(filePath) - return { isSymlink: true, target, realPath } + return { isSymlink: true, target, realPath, broken } } - return { isSymlink: false, realPath } + return { isSymlink: false, realPath, broken: false } } catch { - return { isSymlink: false, realPath: filePath } + return { isSymlink: false, realPath: filePath, broken: false } } } diff --git a/server/scanner/watcher.ts b/server/scanner/watcher.ts index 2a491e4..4b63591 100644 --- a/server/scanner/watcher.ts +++ b/server/scanner/watcher.ts @@ -1,17 +1,17 @@ -import chokidar from 'chokidar' +import chokidar, { type FSWatcher } from 'chokidar' import fs from 'fs' import path from 'path' -import os from 'os' import { allAgentGlobalAbsPaths } from './agents.js' +import { userHome } from '../config/paths.js' -const homedir = os.homedir() +const homedir = userHome export type WatchCallback = (event: { type: string; path: string }) => void -let watcher: chokidar.FSWatcher | null = null +let watcher: FSWatcher | null = null -export function startWatcher(callback: WatchCallback): void { - if (watcher) return +export function startWatcher(callback: WatchCallback): boolean { + if (watcher) return true const watchPaths = [ ...allAgentGlobalAbsPaths(homedir).map((x) => x.path), @@ -28,10 +28,12 @@ export function startWatcher(callback: WatchCallback): void { } }) - if (validPaths.length === 0) return + if (validPaths.length === 0) return false watcher = chokidar.watch(validPaths, { - depth: 2, + // The scanner only reads SKILL.md and immediate sibling files. Avoid + // traversing downloads, virtualenvs, repositories, and other nested assets. + depth: 1, ignoreInitial: true, persistent: true, followSymlinks: true, @@ -43,11 +45,19 @@ export function startWatcher(callback: WatchCallback): void { .on('unlink', (p) => callback({ type: 'unlink', path: p })) .on('addDir', (p) => callback({ type: 'addDir', path: p })) .on('unlinkDir', (p) => callback({ type: 'unlinkDir', path: p })) + return true } -export function stopWatcher(): void { - if (watcher) { - watcher.close() - watcher = null - } +export async function stopWatcher(timeoutMs = 1_000): Promise { + const current = watcher + watcher = null + if (!current) return + let timer: ReturnType | null = null + await Promise.race([ + current.close().catch(() => {}), + new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs) + }), + ]) + if (timer) clearTimeout(timer) } diff --git a/server/security/http.ts b/server/security/http.ts new file mode 100644 index 0000000..5cecff4 --- /dev/null +++ b/server/security/http.ts @@ -0,0 +1,140 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' +import crypto from 'crypto' +import { + SESSION_COOKIE, + SessionManager, + parseCookie, + sessionCookie, +} from './session.js' + +const PUBLIC_API_PATHS = new Set(['/api/health', '/api/v1/health']) +const CONTROL_API_PATHS = new Set(['/api/v1/control/launch']) + +function normalizedHost(hostHeader: string | undefined): string | null { + if (!hostHeader || /[\s\\/@]/.test(hostHeader)) return null + try { + const parsed = new URL(`http://${hostHeader}`) + if (parsed.username || parsed.password) return null + return parsed.hostname.toLowerCase() + } catch { + return null + } +} + +export function isLoopbackHost(hostHeader: string | undefined): boolean { + const host = normalizedHost(hostHeader) + return host === '127.0.0.1' || host === 'localhost' || host === '[::1]' +} + +function originMatchesHost(originHeader: string | undefined, hostHeader: string | undefined): boolean { + if (!originHeader || !hostHeader) return false + try { + const origin = new URL(originHeader) + if (origin.protocol !== 'http:' && origin.protocol !== 'https:') return false + if (!isLoopbackHost(origin.host)) return false + return origin.host.toLowerCase() === hostHeader.toLowerCase() + } catch { + return false + } +} + +export function requestHasSession(request: FastifyRequest, sessions: SessionManager): boolean { + const sessionId = parseCookie(request.headers.cookie, SESSION_COOKIE) + return sessions.isValidSession(sessionId) +} + +function isMutation(method: string): boolean { + return method !== 'GET' && method !== 'HEAD' +} + +export async function registerHttpSecurity( + app: FastifyInstance, + sessions: SessionManager, + controlToken: string, +): Promise { + app.addHook('onRequest', async (request, reply) => { + if (!isLoopbackHost(request.headers.host)) { + return reply.status(421).send({ + ok: false, + code: 'INVALID_HOST', + error: 'SkillManager only accepts loopback Host headers', + }) + } + + const pathname = request.url.split('?')[0] + if ( + !pathname.startsWith('/api/') || + PUBLIC_API_PATHS.has(pathname) || + CONTROL_API_PATHS.has(pathname) + ) return + + if (!requestHasSession(request, sessions)) { + return reply.status(401).send({ + ok: false, + code: 'SESSION_REQUIRED', + error: 'Open SkillManager with a fresh launch URL', + }) + } + + if (isMutation(request.method) && !originMatchesHost(request.headers.origin, request.headers.host)) { + return reply.status(403).send({ + ok: false, + code: 'ORIGIN_MISMATCH', + error: 'Cross-origin write request rejected', + }) + } + }) + + app.get<{ Params: { nonce: string } }>('/launch/:nonce', async (request, reply) => { + const launch = sessions.consumeLaunchNonce(request.params.nonce) + if (!launch) { + return reply.status(410).type('text/plain; charset=utf-8').send( + 'This SkillManager launch link is invalid, expired, or already used.', + ) + } + return reply + .header('Cache-Control', 'no-store') + .header('Set-Cookie', sessionCookie(launch.sessionId)) + .redirect(launch.target, 303) + }) + + app.post<{ Body: { target?: string } }>('/api/v1/control/launch', async (request, reply) => { + const authorization = request.headers.authorization || '' + const candidate = authorization.startsWith('Bearer ') + ? authorization.slice('Bearer '.length) + : '' + const expected = Buffer.from(controlToken) + const supplied = Buffer.from(candidate) + if ( + supplied.length !== expected.length || + !crypto.timingSafeEqual(supplied, expected) + ) { + return reply.status(401).send({ + ok: false, + code: 'CONTROL_AUTH_REQUIRED', + error: 'Valid local control authorization required', + }) + } + const nonce = sessions.createLaunchNonce(request.body?.target || '/') + const baseUrl = `http://${request.headers.host}` + return { + ok: true, + launchUrl: `${baseUrl}/launch/${nonce}`, + expiresInSeconds: 60, + } + }) +} + +export async function requireWebSocketSession( + request: FastifyRequest, + reply: FastifyReply, + sessions: SessionManager, +): Promise { + if (!requestHasSession(request, sessions)) { + await reply.status(401).send({ + ok: false, + code: 'SESSION_REQUIRED', + error: 'WebSocket session required', + }) + } +} diff --git a/server/security/redaction.ts b/server/security/redaction.ts new file mode 100644 index 0000000..f1bf315 --- /dev/null +++ b/server/security/redaction.ts @@ -0,0 +1,39 @@ +import path from 'path' + +const SECRET_KEY = /(token|secret|password|authorization|cookie|credential)/i +const CONTENT_KEY = /(^|_)(content|rawContent|body)$/i + +export function redactPath(value: string, home: string): string { + const resolvedHome = path.resolve(home) + const resolvedValue = path.resolve(value) + const relative = path.relative(resolvedHome, resolvedValue) + if (relative === '') return '$HOME' + if (relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative)) { + return path.join('$HOME', relative).replaceAll(path.sep, '/') + } + return `/${path.basename(value)}` +} + +export function redactText(value: string, home: string): string { + const variants = new Set([home, path.resolve(home)]) + let result = value + for (const variant of variants) { + if (variant) result = result.replaceAll(variant, '$HOME') + } + return result +} + +export function redactObject(value: unknown, home: string, key = ''): unknown { + if (SECRET_KEY.test(key) || CONTENT_KEY.test(key)) return '[REDACTED]' + if (typeof value === 'string') return redactText(value, home) + if (Array.isArray(value)) return value.map((item) => redactObject(item, home)) + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([entryKey, entryValue]) => [ + entryKey, + redactObject(entryValue, home, entryKey), + ]), + ) + } + return value +} diff --git a/server/security/session.ts b/server/security/session.ts new file mode 100644 index 0000000..be9a6c1 --- /dev/null +++ b/server/security/session.ts @@ -0,0 +1,110 @@ +import crypto from 'crypto' + +export const SESSION_COOKIE = 'skillmanager_session' +export const LAUNCH_NONCE_TTL_MS = 60_000 +export const SESSION_TTL_MS = 8 * 60 * 60 * 1000 + +interface LaunchNonce { + expiresAt: number + target: string +} + +interface LocalSession { + expiresAt: number +} + +function randomId(bytes = 32): string { + return crypto.randomBytes(bytes).toString('base64url') +} + +function safeTarget(target: string): string { + if ( + !target.startsWith('/') || + target.startsWith('//') || + target.includes('\\') || + /[\r\n\0]/.test(target) || + target.length > 2048 + ) return '/' + try { + const parsed = new URL(target, 'http://127.0.0.1') + if (parsed.origin !== 'http://127.0.0.1') return '/' + return `${parsed.pathname}${parsed.search}` + } catch { + return '/' + } +} + +export class SessionManager { + private readonly nonces = new Map() + private readonly sessions = new Map() + + createLaunchNonce(target = '/', now = Date.now()): string { + this.purgeExpired(now) + const nonce = randomId(24) + this.nonces.set(nonce, { + expiresAt: now + LAUNCH_NONCE_TTL_MS, + target: safeTarget(target), + }) + return nonce + } + + consumeLaunchNonce( + nonce: string, + now = Date.now(), + ): { sessionId: string; target: string; expiresAt: number } | null { + this.purgeExpired(now) + const launch = this.nonces.get(nonce) + if (!launch || launch.expiresAt <= now) { + this.nonces.delete(nonce) + return null + } + this.nonces.delete(nonce) + const sessionId = randomId() + const expiresAt = now + SESSION_TTL_MS + this.sessions.set(sessionId, { expiresAt }) + return { sessionId, target: launch.target, expiresAt } + } + + isValidSession(sessionId: string | null, now = Date.now()): boolean { + if (!sessionId) return false + const session = this.sessions.get(sessionId) + if (!session || session.expiresAt <= now) { + this.sessions.delete(sessionId) + return false + } + return true + } + + revoke(sessionId: string): void { + this.sessions.delete(sessionId) + } + + private purgeExpired(now: number): void { + for (const [nonce, launch] of this.nonces) { + if (launch.expiresAt <= now) this.nonces.delete(nonce) + } + for (const [sessionId, session] of this.sessions) { + if (session.expiresAt <= now) this.sessions.delete(sessionId) + } + } +} + +export function parseCookie(header: string | undefined, name: string): string | null { + if (!header) return null + for (const part of header.split(';')) { + const separator = part.indexOf('=') + if (separator < 0) continue + if (part.slice(0, separator).trim() !== name) continue + try { + return decodeURIComponent(part.slice(separator + 1).trim()) + } catch { + return null + } + } + return null +} + +export function sessionCookie(sessionId: string): string { + const maxAge = Math.floor(SESSION_TTL_MS / 1000) + return `${SESSION_COOKIE}=${encodeURIComponent(sessionId)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}` +} diff --git a/server/sync/config.ts b/server/sync/config.ts index 3755874..2e8a107 100644 --- a/server/sync/config.ts +++ b/server/sync/config.ts @@ -1,24 +1,32 @@ import fs from 'fs/promises' import path from 'path' -import os from 'os' - -/** - * Sync config (GitHub repo URL + PAT). - * - * Stored at ~/.config/skill-hub/credentials.json with mode 0600. Same security - * model as ~/.netrc or ~/.config/gh/hosts.yml — readable only by the current - * user. v2 can upgrade to the OS keychain if users ask. - */ +import { dataDir } from '../config/paths.js' +import { atomicWriteFile } from '../core/atomic.js' export interface SyncConfig { repoUrl: string owner: string name: string defaultBranch: string + /** Runtime-only secret loaded from the OS credential store. */ token: string lastValidatedAt: string | null } +interface StoredSyncConfigV2 { + schemaVersion: 2 + repoUrl: string + owner: string + name: string + defaultBranch: string + credentialRef: string + lastValidatedAt: string | null +} + +interface LegacySyncConfig extends Omit { + token: string +} + export interface PublicSyncConfig { connected: boolean repoUrl: string | null @@ -26,37 +34,183 @@ export interface PublicSyncConfig { name: string | null defaultBranch: string | null hasToken: boolean + credentialStorage: 'os-keyring' lastValidatedAt: string | null } -const CONFIG_DIR = path.join(os.homedir(), '.config', 'skill-hub') -const CONFIG_PATH = path.join(CONFIG_DIR, 'credentials.json') +export interface CredentialBackend { + get(reference: string): Promise + set(reference: string, secret: string): Promise + delete(reference: string): Promise +} + +export class CredentialStoreError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message) + this.name = 'CredentialStoreError' + } +} + +const KEYRING_SERVICE = 'com.backtthefuture.skillmanager.github' + +export class KeyringCredentialBackend implements CredentialBackend { + private async entry(reference: string) { + try { + const { Entry } = await import('@napi-rs/keyring') + return new Entry(KEYRING_SERVICE, reference) + } catch { + throw new CredentialStoreError( + 'KEYRING_UNAVAILABLE', + 'The operating-system credential store is unavailable', + ) + } + } + + async get(reference: string): Promise { + try { + return (await this.entry(reference)).getPassword() + } catch (error) { + if (error instanceof CredentialStoreError) throw error + throw new CredentialStoreError('KEYRING_READ_FAILED', 'Could not read the token from the OS credential store') + } + } + + async set(reference: string, secret: string): Promise { + try { + ;(await this.entry(reference)).setPassword(secret) + } catch (error) { + if (error instanceof CredentialStoreError) throw error + throw new CredentialStoreError('KEYRING_WRITE_FAILED', 'Could not save the token in the OS credential store') + } + } + + async delete(reference: string): Promise { + try { + ;(await this.entry(reference)).deletePassword() + } catch (error) { + if (error instanceof CredentialStoreError) throw error + } + } +} + +const CONFIG_DIR = path.join(dataDir, 'config') +const CONFIG_PATH = path.join(CONFIG_DIR, 'sync.json') + +function credentialReference(owner: string, name: string): string { + return `github:${owner}/${name}` +} + +function isStoredV2(value: any): value is StoredSyncConfigV2 { + return value?.schemaVersion === 2 && + typeof value.repoUrl === 'string' && + typeof value.owner === 'string' && + typeof value.name === 'string' && + typeof value.credentialRef === 'string' +} -export async function readConfig(): Promise { - try { - const raw = await fs.readFile(CONFIG_PATH, 'utf-8') - const parsed = JSON.parse(raw) as SyncConfig - if (!parsed.repoUrl || !parsed.token) return null - return parsed - } catch { - return null +function isLegacy(value: any): value is LegacySyncConfig { + return typeof value?.repoUrl === 'string' && + typeof value.owner === 'string' && + typeof value.name === 'string' && + typeof value.token === 'string' && + value.token.length > 0 +} + +export class SyncConfigStore { + constructor( + private readonly configPath = CONFIG_PATH, + private readonly credentials: CredentialBackend = new KeyringCredentialBackend(), + ) {} + + private async readJson(): Promise { + try { + return JSON.parse(await fs.readFile(this.configPath, 'utf8')) + } catch { + return null + } } + + private async writeStored(config: StoredSyncConfigV2): Promise { + await fs.mkdir(path.dirname(this.configPath), { recursive: true, mode: 0o700 }) + await atomicWriteFile(this.configPath, JSON.stringify(config, null, 2) + '\n') + if (process.platform !== 'win32') await fs.chmod(this.configPath, 0o600) + } + + async read(): Promise { + const stored = await this.readJson() + if (!stored) return null + + if (isLegacy(stored)) { + const reference = credentialReference(stored.owner, stored.name) + await this.credentials.set(reference, stored.token) + await this.writeStored({ + schemaVersion: 2, + repoUrl: stored.repoUrl, + owner: stored.owner, + name: stored.name, + defaultBranch: stored.defaultBranch, + credentialRef: reference, + lastValidatedAt: stored.lastValidatedAt, + }) + return stored + } + + if (!isStoredV2(stored)) return null + const token = await this.credentials.get(stored.credentialRef) + if (!token) return null + return { + repoUrl: stored.repoUrl, + owner: stored.owner, + name: stored.name, + defaultBranch: stored.defaultBranch, + token, + lastValidatedAt: stored.lastValidatedAt, + } + } + + async write(config: SyncConfig): Promise { + const reference = credentialReference(config.owner, config.name) + const previous = await this.readJson() + await this.credentials.set(reference, config.token) + await this.writeStored({ + schemaVersion: 2, + repoUrl: config.repoUrl, + owner: config.owner, + name: config.name, + defaultBranch: config.defaultBranch, + credentialRef: reference, + lastValidatedAt: config.lastValidatedAt, + }) + if (isStoredV2(previous) && previous.credentialRef !== reference) { + await this.credentials.delete(previous.credentialRef) + } + } + + async delete(): Promise { + const stored = await this.readJson() + if (isStoredV2(stored)) await this.credentials.delete(stored.credentialRef) + if (isLegacy(stored)) { + await this.credentials.delete(credentialReference(stored.owner, stored.name)) + } + await fs.unlink(this.configPath).catch(() => {}) + } +} + +const defaultStore = new SyncConfigStore() + +export function readConfig(): Promise { + return defaultStore.read() } -export async function writeConfig(config: SyncConfig): Promise { - await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 }) - // Write with restrictive mode. On Linux/macOS the mode sticks on create; we - // also chmod after to handle the case where the file already existed. - await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 }) - try { - await fs.chmod(CONFIG_PATH, 0o600) - } catch {} +export function writeConfig(config: SyncConfig): Promise { + return defaultStore.write(config) } -export async function deleteConfig(): Promise { - try { - await fs.unlink(CONFIG_PATH) - } catch {} +export function deleteConfig(): Promise { + return defaultStore.delete() } /** Redacted view, safe to return from API. */ @@ -69,6 +223,7 @@ export function toPublic(config: SyncConfig | null): PublicSyncConfig { name: null, defaultBranch: null, hasToken: false, + credentialStorage: 'os-keyring', lastValidatedAt: null, } } @@ -79,6 +234,7 @@ export function toPublic(config: SyncConfig | null): PublicSyncConfig { name: config.name, defaultBranch: config.defaultBranch, hasToken: Boolean(config.token), + credentialStorage: 'os-keyring', lastValidatedAt: config.lastValidatedAt, } } @@ -94,15 +250,12 @@ export function parseRepoUrl(input: string): { owner: string; name: string } | n const s = input.trim() if (!s) return null - // git@github.com:owner/repo(.git)? const sshMatch = s.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/) if (sshMatch) return { owner: sshMatch[1], name: sshMatch[2] } - // https://github.com/owner/repo(.git)? const httpsMatch = s.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/) if (httpsMatch) return { owner: httpsMatch[1], name: httpsMatch[2] } - // bare owner/repo const bareMatch = s.match(/^([^/\s]+)\/([^/\s]+?)(?:\.git)?$/) if (bareMatch) return { owner: bareMatch[1], name: bareMatch[2] } diff --git a/server/sync/download-plans.ts b/server/sync/download-plans.ts new file mode 100644 index 0000000..bfc1c95 --- /dev/null +++ b/server/sync/download-plans.ts @@ -0,0 +1,220 @@ +import crypto from 'crypto' +import path from 'path' +import { ACTION_PLAN_TTL_MS } from '../core/action-plans.js' +import { invalidateCatalog } from '../core/catalog.js' +import { fullScan } from '../scanner/discovery.js' +import type { Skill } from '../types.js' +import type { SyncConfig } from './config.js' +import { + applyDownloadFromVault, + computeDownloadListing, + type SyncSkillRow, +} from './download.js' +import { vaultDirFor } from './vault.js' +import { vaultDirForSkill } from './layout.js' +import type { ScanResult } from '../types.js' +import { hashSkillDirectory } from '../scanner/package-hash.js' + +interface StoredSyncPlan { + plan: SyncDownloadPlan + keys: string[] + rowSignatures: Record + localHashes: Record +} + +export interface SyncDownloadPlan { + id: string + action: 'sync-download' + createdAt: string + expiresAt: string + changes: { + operation: 'create' | 'update' + resourceLabel: string + beforeHash: string | null + afterHash: string | null + }[] + risks: string[] + advisories: Array<{ + code: 'EXACT_COPY_AVAILABLE' + key: string + canonicalSkillId: string + canonicalName: string + message: string + }> + requiresExplicitConfirmation: true + vaultHead: string | null +} + +export class SyncDownloadPlanError extends Error { + constructor( + public readonly statusCode: number, + public readonly code: string, + message: string, + ) { + super(message) + this.name = 'SyncDownloadPlanError' + } +} + +function signature(row: SyncSkillRow): string { + return crypto.createHash('sha256').update(JSON.stringify({ + state: row.state, + filesInRemote: row.filesInRemote, + filesInLocal: row.filesInLocal, + filesDiffering: row.filesDiffering, + remoteModifiedAt: row.remoteModifiedAt, + localModifiedAt: row.localModifiedAt, + })).digest('hex') +} + +function localHashes(skills: Skill[], keys: string[]): Record { + const byKey = new Map(skills.map((skill) => [vaultDirForSkill(skill), skill.contentHash])) + return Object.fromEntries(keys.map((key) => [key, byKey.get(key) ?? null])) +} + +export class SyncDownloadPlanService { + private readonly plans = new Map() + private readonly dependencies: { + scan: () => Promise + listing: typeof computeDownloadListing + apply: typeof applyDownloadFromVault + vaultDir: typeof vaultDirFor + } + + constructor(dependencies: Partial = {}) { + this.dependencies = { + scan: fullScan, + listing: computeDownloadListing, + apply: applyDownloadFromVault, + vaultDir: vaultDirFor, + ...dependencies, + } + } + + async preview(config: SyncConfig, keys: string[], now = Date.now()): Promise { + if (!Array.isArray(keys) || keys.length === 0 || keys.length > 50) { + throw new SyncDownloadPlanError(400, 'INVALID_SELECTION', 'Select between 1 and 50 remote Skills') + } + const unique = Array.from(new Set(keys)) + if (unique.length !== keys.length) { + throw new SyncDownloadPlanError(400, 'DUPLICATE_KEY', 'Duplicate remote Skill key') + } + for (const key of keys) { + if (!/^[^/\\.][^/\\]*\/[^/\\.][^/\\]*$/.test(key) || key.includes('..')) { + throw new SyncDownloadPlanError(400, 'INVALID_KEY', `Invalid remote Skill key: ${key}`) + } + } + + const scan = await this.dependencies.scan() + const listingResult = await this.dependencies.listing(config, scan.skills) + if (!listingResult.ok) { + throw new SyncDownloadPlanError(502, 'SYNC_LISTING_FAILED', listingResult.error) + } + const rows = new Map(listingResult.listing.rows.map((row) => [row.key, row])) + const selectedRows = keys.map((key) => { + const row = rows.get(key) + if (!row || row.excluded || (row.state !== 'remote_only' && row.state !== 'different')) { + throw new SyncDownloadPlanError(409, 'ROW_NOT_DOWNLOADABLE', `${key} is not downloadable`) + } + return row + }) + const vaultPath = this.dependencies.vaultDir(config) + const advisories: SyncDownloadPlan['advisories'] = [] + for (const row of selectedRows) { + if (row.state !== 'remote_only') continue + const remoteHash = await hashSkillDirectory(path.join(vaultPath, ...row.key.split('/'))) + if (!remoteHash) continue + const existing = scan.skills.find((skill) => ( + skill.packageHash === remoteHash && + skill.scope !== 'plugin' && + skill.installations.some((installation) => installation.scope === 'global') + )) + if (!existing) continue + advisories.push({ + code: 'EXACT_COPY_AVAILABLE', + key: row.key, + canonicalSkillId: existing.id, + canonicalName: existing.name, + message: `${row.key} 与本机已有的 ${existing.name} 内容完全相同。推荐复用现有主版本,避免再次生成独立副本。`, + }) + } + + const id = `syncplan_${crypto.randomBytes(18).toString('base64url')}` + const plan: SyncDownloadPlan = { + id, + action: 'sync-download', + createdAt: new Date(now).toISOString(), + expiresAt: new Date(now + ACTION_PLAN_TTL_MS).toISOString(), + changes: selectedRows.map((row) => ({ + operation: row.state === 'remote_only' ? 'create' : 'update', + resourceLabel: row.key, + beforeHash: localHashes(scan.skills, [row.key])[row.key], + afterHash: signature(row), + })), + risks: [ + `${selectedRows.filter((row) => row.state === 'different').length} 个本地 Skill 将被替换`, + '所有远端文件会先写入临时目录并验证;替换前的目录进入回收站', + ], + advisories, + requiresExplicitConfirmation: true, + vaultHead: listingResult.listing.vaultHead, + } + this.plans.set(id, { + plan, + keys, + rowSignatures: Object.fromEntries(selectedRows.map((row) => [row.key, signature(row)])), + localHashes: localHashes(scan.skills, keys), + }) + return plan + } + + async apply(config: SyncConfig, planId: string, now = Date.now()) { + const stored = this.plans.get(planId) + if (!stored || new Date(stored.plan.expiresAt).getTime() <= now) { + this.plans.delete(planId) + throw new SyncDownloadPlanError(409, 'PLAN_EXPIRED', 'Sync download plan is missing or expired') + } + + const scan = await this.dependencies.scan() + const listingResult = await this.dependencies.listing(config, scan.skills) + if (!listingResult.ok) { + throw new SyncDownloadPlanError(502, 'SYNC_LISTING_FAILED', listingResult.error) + } + if (listingResult.listing.vaultHead !== stored.plan.vaultHead) { + this.plans.delete(planId) + throw new SyncDownloadPlanError(409, 'STALE_PLAN', 'Remote vault changed after preview') + } + const rows = new Map(listingResult.listing.rows.map((row) => [row.key, row])) + const currentLocalHashes = localHashes(scan.skills, stored.keys) + for (const key of stored.keys) { + const row = rows.get(key) + if ( + !row || + signature(row) !== stored.rowSignatures[key] || + currentLocalHashes[key] !== stored.localHashes[key] + ) { + this.plans.delete(planId) + throw new SyncDownloadPlanError(409, 'STALE_PLAN', `${key} changed after preview`) + } + } + + const result = await this.dependencies.apply( + this.dependencies.vaultDir(config), + stored.keys, + ) + if (!result.ok) throw new SyncDownloadPlanError(500, 'SYNC_APPLY_FAILED', result.error) + this.plans.delete(planId) + invalidateCatalog() + return { + ok: true, + action: 'sync-download' as const, + applied: result.applied.map((item) => ({ + key: item.key, + files: item.files, + trashId: item.trashId, + })), + } + } +} + +export const syncDownloadPlans = new SyncDownloadPlanService() diff --git a/server/sync/download.ts b/server/sync/download.ts index f790753..e9f6568 100644 --- a/server/sync/download.ts +++ b/server/sync/download.ts @@ -5,6 +5,11 @@ import type { Skill } from '../types.js' import type { SyncConfig } from './config.js' import { ensureVault, vaultDirFor, listVaultFiles, readVaultFile } from './vault.js' import { isSyncableSkill, materializeSkill, vaultDirForSkill } from './layout.js' +import { AGENTS } from '../scanner/agents.js' +import { userHome } from '../config/paths.js' +import { moveToTrash, restoreFromTrash } from '../trash/store.js' +import { parseSkillMd } from '../scanner/parser.js' +import { isPathInside } from '../core/authorization.js' /** * Download-side diff computation. @@ -232,21 +237,15 @@ export async function computeDownloadListing( * agent → path map as the scanner, picking the first global path for each * agent (or a sensible default for universal). */ -function localTargetDir(agent: string, name: string): string { - const home = process.env.HOME || process.env.USERPROFILE || '' - // This mirrors server/scanner/agents.ts's global paths. Keep in sync manually. - const agentHomePath: Record = { - 'claude-code': '.claude/skills', - codex: '.codex/skills', - antigravity: '.gemini/antigravity/skills', - augment: '.augment/skills', - bob: '.bob/skills', - openclaw: '.openclaw/skills', - codebuddy: '.codebuddy/skills', - universal: '.agents/skills', +export function localTargetDir(agent: string, name: string): string | null { + if (!name || name === '.' || name === '..' || /[\\/\0]/.test(name)) return null + if (agent === 'codex' || agent === 'universal') { + return path.join(userHome, '.agents', 'skills', name) } - const agentDir = agentHomePath[agent] || '.claude/skills' - return path.join(home, agentDir, name) + const definition = AGENTS.find((entry) => entry.id === agent) + const agentDir = definition?.globalPaths[0] + if (!definition || !agentDir || agent === 'unknown') return null + return path.join(userHome, agentDir, name) } /** @@ -263,45 +262,114 @@ export async function applyDownload( > { const ensured = await ensureVault(config) if (!ensured.ok) return { ok: false, error: ensured.error } - const vaultPath = vaultDirFor(config) + return applyDownloadFromVault(vaultDirFor(config), selectedKeys) +} - const applied: { key: string; files: number; targetDir: string }[] = [] +interface StagedDownload { + key: string + name: string + targetDir: string + stageDir: string + files: number +} - for (const key of selectedKeys) { - const [agent, ...nameParts] = key.split('/') - const name = nameParts.join('/') - if (!agent || !name) continue - const targetDir = localTargetDir(agent, name) +interface AppliedDownload extends StagedDownload { + trashId?: string +} - // Read every file for this skill from the vault - const allFiles = await listVaultFiles(vaultPath) - const prefix = `${key}/` - const skillFiles = allFiles.filter((f) => f.startsWith(prefix)) - if (skillFiles.length === 0) continue +export async function applyDownloadFromVault( + vaultPath: string, + selectedKeys: string[], + options: { failAfterApplied?: number } = {}, +): Promise< + | { ok: true; applied: { key: string; files: number; targetDir: string; trashId?: string }[] } + | { ok: false; error: string } +> { + const allFiles = await listVaultFiles(vaultPath) + const staged: StagedDownload[] = [] + const applied: AppliedDownload[] = [] - // Ensure target dir exists - await fs.mkdir(targetDir, { recursive: true }) + try { + const targets = new Set() + for (const key of selectedKeys) { + const parts = key.split('/') + if (parts.length !== 2) throw new Error(`非法 key: ${key}`) + const [agent, name] = parts + const targetDir = localTargetDir(agent, name) + if (!targetDir) throw new Error(`不支持或未知的 Agent: ${agent}`) + if (targets.has(targetDir)) throw new Error(`下载目标重复: ${key}`) + targets.add(targetDir) - // Clear existing skill dir contents (user chose to overwrite). Don't - // delete the directory itself — preserves perms/symlinks to the dir. - try { - const existing = await fs.readdir(targetDir) - for (const entry of existing) { - await fs.rm(path.join(targetDir, entry), { recursive: true, force: true }) + const prefix = `${key}/` + const skillFiles = allFiles.filter((file) => file.startsWith(prefix)) + if (skillFiles.length === 0) throw new Error(`远端 Skill 不存在: ${key}`) + const targetRoot = path.dirname(targetDir) + await fs.mkdir(targetRoot, { recursive: true }) + const stageDir = await fs.mkdtemp(path.join(targetRoot, '.skillmanager-download-')) + + for (const relative of skillFiles) { + const within = relative.slice(prefix.length) + const normalized = path.posix.normalize(within) + if ( + !within || + normalized === '..' || + normalized.startsWith('../') || + path.posix.isAbsolute(normalized) || + within.includes('\\') + ) { + throw new Error(`远端包含非法路径: ${relative}`) + } + const destination = path.resolve(stageDir, ...normalized.split('/')) + if (!isPathInside(stageDir, destination)) throw new Error(`远端路径越界: ${relative}`) + await fs.mkdir(path.dirname(destination), { recursive: true }) + await fs.copyFile(path.join(vaultPath, relative), destination) } - } catch {} + await parseSkillMd(path.join(stageDir, 'SKILL.md')) + staged.push({ key, name, targetDir, stageDir, files: skillFiles.length }) + } - let written = 0 - for (const rel of skillFiles) { - const within = rel.slice(prefix.length) - const content = await fs.readFile(path.join(vaultPath, rel)) - const dest = path.join(targetDir, within) - await fs.mkdir(path.dirname(dest), { recursive: true }) - await fs.writeFile(dest, content) - written++ + for (const item of staged) { + let trashId: string | undefined + try { + try { + await fs.lstat(item.targetDir) + trashId = (await moveToTrash(item.targetDir, item.name)).id + } catch (error: any) { + if (error?.code !== 'ENOENT') throw error + } + await fs.rename(item.stageDir, item.targetDir) + applied.push({ ...item, trashId }) + if ( + options.failAfterApplied != null && + applied.length >= options.failAfterApplied + ) { + throw new Error('Injected download failure') + } + } catch (error) { + await fs.rm(item.targetDir, { recursive: true, force: true }).catch(() => {}) + if (trashId) await restoreFromTrash(trashId).catch(() => {}) + throw error + } } - applied.push({ key, files: written, targetDir }) - } - return { ok: true, applied } + return { + ok: true, + applied: applied.map(({ key, files, targetDir, trashId }) => ({ + key, + files, + targetDir, + trashId, + })), + } + } catch (error: any) { + for (const item of [...applied].reverse()) { + await fs.rm(item.targetDir, { recursive: true, force: true }).catch(() => {}) + if (item.trashId) await restoreFromTrash(item.trashId).catch(() => {}) + } + return { ok: false, error: error?.message || String(error) } + } finally { + for (const item of staged) { + await fs.rm(item.stageDir, { recursive: true, force: true }).catch(() => {}) + } + } } diff --git a/server/sync/export.ts b/server/sync/export.ts index 926aedf..213f5c9 100644 --- a/server/sync/export.ts +++ b/server/sync/export.ts @@ -18,10 +18,10 @@ import { isSyncableSkill, materializeSkill, buildManifest, DEFAULT_GITIGNORE } f * 5. Clean up staging. */ -const README_CONTENT = `# Skill Hub Backup +const README_CONTENT = `# SkillManager Backup This archive is an offline backup of your Claude / Codex / universal skills, -created by Skill Hub (https://github.com/Backtthefuture/huangshu). +created by SkillManager (https://github.com/Backtthefuture/skillmanager). Layout: //SKILL.md @@ -34,7 +34,7 @@ agent skills directory on your machine: - codex → ~/.codex/skills/ - universal → ~/.agents/skills/ -Or: set up Skill Hub + connect a GitHub repo, push this content there, then +Or: set up SkillManager + connect a GitHub repo, push this content there, then use the "从 GitHub 下载" feature on your new machine. ` @@ -57,7 +57,7 @@ export async function buildExportTarball(skills: Skill[]): Promise { + const code = character.charCodeAt(0) + return character === '/' || character === '\\' || code <= 31 ? '_' : character + }).join('').trim() cleaned = cleaned.replace(/\.\.+/g, '_') // collapse any `..` or longer runs if (cleaned.startsWith('.')) cleaned = '_' + cleaned.slice(1) return cleaned || 'unnamed' @@ -161,7 +164,7 @@ function looksLikeText(buf: Buffer): boolean { * things that are almost never intentionally checked in, but keeps images and * docs since many skills have templates with them. */ -export const DEFAULT_GITIGNORE = `# Skill Hub — default ignore patterns +export const DEFAULT_GITIGNORE = `# SkillManager — default ignore patterns # Local caches node_modules/ __pycache__/ @@ -188,7 +191,7 @@ Thumbs.db */ export interface VaultManifest { schemaVersion: 1 - generator: 'skill-hub' + generator: 'skillmanager' lastUploadAt: string lastUploadHostname: string } @@ -196,7 +199,7 @@ export interface VaultManifest { export function buildManifest(hostname: string): VaultManifest { return { schemaVersion: 1, - generator: 'skill-hub', + generator: 'skillmanager', lastUploadAt: new Date().toISOString(), lastUploadHostname: hostname, } diff --git a/server/sync/upload-plans.ts b/server/sync/upload-plans.ts new file mode 100644 index 0000000..0b64826 --- /dev/null +++ b/server/sync/upload-plans.ts @@ -0,0 +1,138 @@ +import crypto from 'crypto' +import { ACTION_PLAN_TTL_MS } from '../core/action-plans.js' +import { fullScan } from '../scanner/discovery.js' +import type { ScanResult } from '../types.js' +import type { SyncConfig } from './config.js' +import { + executeUpload, + previewUpload, + uploadPreviewHash, + type UploadPreview, +} from './upload.js' + +interface StoredUploadPlan { + plan: SyncUploadPlan + previewHash: string +} + +export interface SyncUploadPlan { + id: string + action: 'sync-upload' + createdAt: string + expiresAt: string + changes: { + operation: 'create' | 'update' | 'delete' + resourceLabel: string + beforeHash: null + afterHash: null + }[] + risks: string[] + requiresExplicitConfirmation: true + preview: UploadPreview +} + +export class SyncUploadPlanError extends Error { + constructor( + public readonly statusCode: number, + public readonly code: string, + message: string, + public readonly preview?: UploadPreview, + ) { + super(message) + this.name = 'SyncUploadPlanError' + } +} + +export class SyncUploadPlanService { + private readonly plans = new Map() + private readonly dependencies: { + scan: () => Promise + preview: typeof previewUpload + execute: typeof executeUpload + } + + constructor(dependencies: Partial = {}) { + this.dependencies = { + scan: fullScan, + preview: previewUpload, + execute: executeUpload, + ...dependencies, + } + } + + async preview(config: SyncConfig, now = Date.now()): Promise { + const scan = await this.dependencies.scan() + const result = await this.dependencies.preview(config, scan.skills) + if (!result.ok) { + throw new SyncUploadPlanError(502, 'SYNC_PREVIEW_FAILED', result.error) + } + const id = `syncupload_${crypto.randomBytes(18).toString('base64url')}` + const changes = result.preview.skillChanges + .filter((change) => change.status !== 'unchanged') + .map((change) => ({ + operation: change.status === 'add' + ? 'create' as const + : change.status === 'delete' + ? 'delete' as const + : 'update' as const, + resourceLabel: change.vaultDir, + beforeHash: null, + afterHash: null, + })) + const plan: SyncUploadPlan = { + id, + action: 'sync-upload', + createdAt: new Date(now).toISOString(), + expiresAt: new Date(now + ACTION_PLAN_TTL_MS).toISOString(), + changes, + risks: [ + `${result.preview.totals.skillsAdded} 个新增、${result.preview.totals.skillsUpdated} 个更新、${result.preview.totals.skillsDeleted} 个远端删除`, + '确认时会重新获取远端 HEAD 并重新计算本地文件;任何变化都会使计划失效', + ...(result.preview.scan.danger > 0 + ? [`检测到 ${result.preview.scan.danger} 个疑似密钥,默认阻止上传`] + : []), + ], + requiresExplicitConfirmation: true, + preview: result.preview, + } + this.plans.set(id, { plan, previewHash: uploadPreviewHash(result.preview) }) + return plan + } + + async apply( + config: SyncConfig, + planId: string, + allowSecrets: boolean, + now = Date.now(), + ) { + const stored = this.plans.get(planId) + if (!stored || new Date(stored.plan.expiresAt).getTime() <= now) { + this.plans.delete(planId) + throw new SyncUploadPlanError(409, 'PLAN_EXPIRED', 'Sync upload plan is missing or expired') + } + const scan = await this.dependencies.scan() + const result = await this.dependencies.execute(config, scan.skills, { + allowSecrets, + expectedPreviewHash: stored.previewHash, + }) + if (!result.ok) { + if (result.code === 'STALE_PLAN') this.plans.delete(planId) + throw new SyncUploadPlanError( + result.code === 'STALE_PLAN' ? 409 : 400, + result.code || 'SYNC_UPLOAD_FAILED', + result.error, + result.preview, + ) + } + this.plans.delete(planId) + return { + ok: true, + action: 'sync-upload' as const, + sha: result.sha, + noop: result.noop, + preview: result.preview, + } + } +} + +export const syncUploadPlans = new SyncUploadPlanService() diff --git a/server/sync/upload.ts b/server/sync/upload.ts index b259b36..a0a45e7 100644 --- a/server/sync/upload.ts +++ b/server/sync/upload.ts @@ -48,6 +48,7 @@ export interface SkillChangeSummary { } export interface UploadPreview { + vaultHead: string | null localSkillCount: number syncableSkillCount: number excludedSkillCount: number @@ -77,6 +78,7 @@ interface PlannedFile { interface PlanResult { vaultPath: string + vaultHead: string | null planned: Map // vault-relative → planned file toDelete: string[] // vault-relative files that should be removed skillChanges: SkillChangeSummary[] @@ -209,6 +211,7 @@ async function planUpload( ok: true, plan: { vaultPath, + vaultHead: ensured.status.headSha, planned, toDelete, skillChanges, @@ -219,6 +222,46 @@ async function planUpload( } } +function buildUploadPreview( + plan: PlanResult, + localSkillCount: number, + scan: ScanSummary, +): UploadPreview { + return { + vaultHead: plan.vaultHead, + localSkillCount, + syncableSkillCount: plan.syncableSkillCount, + excludedSkillCount: plan.excludedSkillCount, + skillChanges: plan.skillChanges, + totals: { + skillsAdded: plan.skillChanges.filter((c) => c.status === 'add').length, + skillsUpdated: plan.skillChanges.filter((c) => c.status === 'update').length, + skillsDeleted: plan.skillChanges.filter((c) => c.status === 'delete').length, + skillsUnchanged: plan.skillChanges.filter((c) => c.status === 'unchanged').length, + filesAdded: plan.skillChanges.reduce((n, c) => n + c.filesAdded, 0), + filesUpdated: plan.skillChanges.reduce((n, c) => n + c.filesUpdated, 0), + filesDeleted: plan.skillChanges.reduce((n, c) => n + c.filesDeleted, 0), + }, + scan, + skippedFiles: plan.skippedFiles, + } +} + +export function uploadPreviewHash(preview: UploadPreview): string { + const stable = { + ...preview, + skillChanges: [...preview.skillChanges].sort((a, b) => a.vaultDir.localeCompare(b.vaultDir)), + skippedFiles: [...preview.skippedFiles].sort((a, b) => a.relPath.localeCompare(b.relPath)), + scan: { + ...preview.scan, + findings: [...preview.scan.findings].sort((a, b) => ( + a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column + )), + }, + } + return crypto.createHash('sha256').update(JSON.stringify(stable)).digest('hex') +} + export async function previewUpload( config: SyncConfig, skills: Skill[], @@ -235,37 +278,19 @@ export async function previewUpload( } } - const totals = { - skillsAdded: plan.skillChanges.filter((c) => c.status === 'add').length, - skillsUpdated: plan.skillChanges.filter((c) => c.status === 'update').length, - skillsDeleted: plan.skillChanges.filter((c) => c.status === 'delete').length, - skillsUnchanged: plan.skillChanges.filter((c) => c.status === 'unchanged').length, - filesAdded: plan.skillChanges.reduce((n, c) => n + c.filesAdded, 0), - filesUpdated: plan.skillChanges.reduce((n, c) => n + c.filesUpdated, 0), - filesDeleted: plan.skillChanges.reduce((n, c) => n + c.filesDeleted, 0), - } - return { ok: true, - preview: { - localSkillCount: skills.length, - syncableSkillCount: plan.syncableSkillCount, - excludedSkillCount: plan.excludedSkillCount, - skillChanges: plan.skillChanges, - totals, - scan: summarize(findings), - skippedFiles: plan.skippedFiles, - }, + preview: buildUploadPreview(plan, skills.length, summarize(findings)), } } export async function executeUpload( config: SyncConfig, skills: Skill[], - opts: { allowSecrets: boolean }, + opts: { allowSecrets: boolean; expectedPreviewHash?: string }, ): Promise< | { ok: true; preview: UploadPreview; sha: string; noop: boolean } - | { ok: false; error: string; preview?: UploadPreview } + | { ok: false; code?: string; error: string; preview?: UploadPreview } > { const planned = await planUpload(config, skills) if (!planned.ok) return { ok: false, error: planned.error } @@ -280,29 +305,23 @@ export async function executeUpload( } const scanSummary = summarize(findings) - if (scanSummary.danger > 0 && !opts.allowSecrets) { - // Construct a partial preview so the UI can show what we found - const totals = { - skillsAdded: plan.skillChanges.filter((c) => c.status === 'add').length, - skillsUpdated: plan.skillChanges.filter((c) => c.status === 'update').length, - skillsDeleted: plan.skillChanges.filter((c) => c.status === 'delete').length, - skillsUnchanged: plan.skillChanges.filter((c) => c.status === 'unchanged').length, - filesAdded: plan.skillChanges.reduce((n, c) => n + c.filesAdded, 0), - filesUpdated: plan.skillChanges.reduce((n, c) => n + c.filesUpdated, 0), - filesDeleted: plan.skillChanges.reduce((n, c) => n + c.filesDeleted, 0), + const currentPreview = buildUploadPreview(plan, skills.length, scanSummary) + if ( + opts.expectedPreviewHash && + uploadPreviewHash(currentPreview) !== opts.expectedPreviewHash + ) { + return { + ok: false, + code: 'STALE_PLAN', + error: '本地 Skill 或远端仓库在预览后发生变化,请重新预览。', + preview: currentPreview, } + } + if (scanSummary.danger > 0 && !opts.allowSecrets) { return { ok: false, error: `检测到 ${scanSummary.danger} 个疑似密钥,出于安全考虑已阻止上传。如果是误报,请在前端勾选"我已确认,继续上传"后重试。`, - preview: { - localSkillCount: skills.length, - syncableSkillCount: plan.syncableSkillCount, - excludedSkillCount: plan.excludedSkillCount, - skillChanges: plan.skillChanges, - totals, - scan: scanSummary, - skippedFiles: plan.skippedFiles, - }, + preview: currentPreview, } } @@ -344,29 +363,11 @@ export async function executeUpload( const pushed = await commitAndPush(config, message) if (!pushed.ok) return { ok: false, error: pushed.error } - const totals = { - skillsAdded: plan.skillChanges.filter((c) => c.status === 'add').length, - skillsUpdated: plan.skillChanges.filter((c) => c.status === 'update').length, - skillsDeleted: plan.skillChanges.filter((c) => c.status === 'delete').length, - skillsUnchanged: plan.skillChanges.filter((c) => c.status === 'unchanged').length, - filesAdded: plan.skillChanges.reduce((n, c) => n + c.filesAdded, 0), - filesUpdated: plan.skillChanges.reduce((n, c) => n + c.filesUpdated, 0), - filesDeleted: plan.skillChanges.reduce((n, c) => n + c.filesDeleted, 0), - } - return { ok: true, sha: 'sha' in pushed ? pushed.sha : '', noop: pushed.noop, - preview: { - localSkillCount: skills.length, - syncableSkillCount: plan.syncableSkillCount, - excludedSkillCount: plan.excludedSkillCount, - skillChanges: plan.skillChanges, - totals, - scan: scanSummary, - skippedFiles: plan.skippedFiles, - }, + preview: currentPreview, } } @@ -379,7 +380,7 @@ function composeCommitMessage(changes: SkillChangeSummary[], hostname: string): if (updated) parts.push(`~${updated}`) if (deleted) parts.push(`-${deleted}`) const summary = parts.length ? parts.join(' ') : 'no-op' - return `skill-hub upload: ${summary} (from ${hostname})` + return `SkillManager upload: ${summary} (from ${hostname})` } async function removeEmptyDirs(root: string): Promise { diff --git a/server/sync/vault.ts b/server/sync/vault.ts index 3e44cb3..9e82117 100644 --- a/server/sync/vault.ts +++ b/server/sync/vault.ts @@ -1,12 +1,12 @@ import { spawn } from 'child_process' import fs from 'fs/promises' import path from 'path' -import os from 'os' import type { SyncConfig } from './config.js' +import { dataDir } from '../config/paths.js' /** * Git-backed vault. The vault is a plain clone of the user's GitHub repo at - * ~/.local/share/skill-hub/vault/__/. It's separate from the + * SkillManager platform data directory under vault/__/. It's separate from the * live skills directories (`~/.claude/skills/`, etc.) so: * - the file watcher doesn't see half-written merges * - a failed push doesn't corrupt live skills @@ -19,9 +19,7 @@ import type { SyncConfig } from './config.js' */ function vaultRoot(): string { - // Read HOME lazily — tests may override process.env.HOME between calls. - const home = process.env.HOME || process.env.USERPROFILE || os.homedir() - return path.join(home, '.local', 'share', 'skill-hub', 'vault') + return path.join(dataDir, 'vault') } export function vaultDirFor(config: Pick): string { @@ -51,11 +49,11 @@ export async function runGit(args: string[], opts: RunOpts = {}): Promise/ + * 设计:软删除 → SkillManager 平台数据目录的 trash// * - 普通目录:整个 skill 目录被 rename 进 trash//payload/ * - 符号链接:不搬运实际文件,只在 meta 里记录 target,还原时重建 symlink * - meta: trash//.trash-meta.json @@ -9,13 +9,11 @@ */ import fs from 'fs/promises' import path from 'path' -import os from 'os' import crypto from 'crypto' +import { dataDir, userHome } from '../config/paths.js' function trashRoot(): string { - // Read HOME lazily so tests can override process.env.HOME between calls. - const home = process.env.HOME || process.env.USERPROFILE || os.homedir() - return path.join(home, '.skill-hub', 'trash') + return path.join(dataDir, 'trash') } export const TRASH_TTL_MS = 7 * 24 * 60 * 60 * 1000 @@ -36,6 +34,12 @@ export interface TrashEntry extends TrashMeta { daysRemaining: number } +const TRASH_ID_PATTERN = /^[a-z0-9]+-[a-f0-9]{8}$/ + +export function isValidTrashId(id: string): boolean { + return TRASH_ID_PATTERN.test(id) +} + function newId(): string { const now = Date.now().toString(36) const rand = crypto.randomBytes(4).toString('hex') @@ -47,6 +51,7 @@ async function ensureRoot(): Promise { } function entryDir(id: string): string { + if (!isValidTrashId(id)) throw new Error('Invalid trash ID') return path.join(trashRoot(), id) } @@ -134,7 +139,54 @@ async function renameWithFallback(src: string, dest: string): Promise { async function readMeta(id: string): Promise { try { const raw = await fs.readFile(metaPath(id), 'utf-8') - return JSON.parse(raw) as TrashMeta + const meta = JSON.parse(raw) as TrashMeta + if (meta.id !== id || !path.isAbsolute(meta.originalPath)) return null + return meta + } catch { + return null + } +} + +export async function getTrashMeta(id: string): Promise { + return readMeta(id) +} + +async function hashTree(target: string, relative = ''): Promise { + const stat = await fs.lstat(target) + const hash = crypto.createHash('sha256') + hash.update(relative) + if (stat.isSymbolicLink()) { + hash.update('\0link\0') + hash.update(await fs.readlink(target)) + return hash.digest('hex') + } + if (stat.isFile()) { + hash.update('\0file\0') + hash.update(await fs.readFile(target)) + return hash.digest('hex') + } + if (!stat.isDirectory()) { + hash.update('\0other\0') + hash.update(String(stat.mode)) + return hash.digest('hex') + } + hash.update('\0directory\0') + const entries = (await fs.readdir(target)).sort() + for (const name of entries) { + hash.update(name) + hash.update(await hashTree(path.join(target, name), path.join(relative, name))) + } + return hash.digest('hex') +} + +export async function getTrashState(id: string): Promise<{ + meta: TrashMeta + contentHash: string +} | null> { + const meta = await readMeta(id) + if (!meta) return null + try { + return { meta, contentHash: await hashTree(entryDir(id)) } } catch { return null } @@ -164,7 +216,7 @@ async function dirSize(dir: string): Promise { } function inferScope(skillPath: string): { scope: TrashMeta['scope']; projectPath?: string } { - const home = os.homedir() + const home = userHome const globalRoot = path.join(home, '.claude', 'skills') if (skillPath.startsWith(globalRoot + path.sep) || skillPath === globalRoot) { return { scope: 'global' } @@ -215,10 +267,7 @@ export async function moveToTrash(skillPath: string, skillName?: string): Promis // 普通目录:rename 到 payload/。Windows 上需要重试 + cp 兜底(见 renameWithFallback) const dest = payloadPath(id) - await renameWithFallback(skillPath, dest) - - const size = await dirSize(dest).catch(() => 0) - const meta: TrashMeta = { + const provisionalMeta: TrashMeta = { id, skillName: name, originalPath: skillPath, @@ -227,9 +276,20 @@ export async function moveToTrash(skillPath: string, skillName?: string): Promis isSymlink: false, deletedAt: now.toISOString(), expiresAt: expires.toISOString(), - sizeBytes: size, } - await writeMeta(meta) + // Metadata must exist before the source moves. If the process dies after + // rename, the payload is still discoverable and recoverable. + await writeMeta(provisionalMeta) + try { + await renameWithFallback(skillPath, dest) + } catch (error) { + await fs.rm(dir, { recursive: true, force: true }).catch(() => {}) + throw error + } + + const size = await dirSize(dest).catch(() => 0) + const meta: TrashMeta = { ...provisionalMeta, sizeBytes: size } + await writeMeta(meta).catch(() => {}) return meta } @@ -280,7 +340,12 @@ export class TrashNotFoundError extends Error { } } -export async function restoreFromTrash(id: string, force = false): Promise { +export interface TrashRestoreResult { + meta: TrashMeta + displacedTrashId?: string +} + +export async function restoreFromTrashDetailed(id: string, force = false): Promise { const meta = await readMeta(id) if (!meta) throw new TrashNotFoundError(id) @@ -291,33 +356,49 @@ export async function restoreFromTrash(id: string, force = false): Promise {}) + } + throw error } // 清理 trash 条目目录 await fs.rm(entryDir(id), { recursive: true, force: true }) - return meta + return { meta, displacedTrashId } +} + +export async function restoreFromTrash(id: string, force = false): Promise { + return (await restoreFromTrashDetailed(id, force)).meta } /** * 永久删除一个条目 */ export async function purgeOne(id: string): Promise { - const dir = entryDir(id) try { + const dir = entryDir(id) await fs.rm(dir, { recursive: true, force: true }) return true } catch { @@ -339,6 +420,7 @@ export async function purgeExpired(): Promise { const now = Date.now() let removed = 0 for (const id of dirs) { + if (!isValidTrashId(id)) continue const meta = await readMeta(id) if (!meta) { // 坏掉的条目(没 meta)直接清理 diff --git a/server/types.ts b/server/types.ts index 056c76f..41e406f 100644 --- a/server/types.ts +++ b/server/types.ts @@ -17,13 +17,88 @@ import type { AgentId } from './scanner/agents.js' import type { CategorySummary, MergeSuggestion } from './scanner/taxonomy.js' import type { HealthReport } from './scanner/health.js' +export interface AgentExposure { + agentId: AgentId + reason: 'direct-path' | 'shared-agents-path' | 'frontmatter' | 'plugin' +} + +export type ProjectionStrategy = + | 'direct' + | 'native-shared' + | 'symlink' + | 'junction' + | 'managed-copy' + | 'plugin' + +export type ProjectionStatus = + | 'valid' + | 'broken' + | 'wrong-target' + | 'copy-drift' + | 'missing' + | 'not-managed' + +export type ProjectionManager = 'skillmanager' | 'skills-cli' | 'external' + +export type SkillIdentitySignal = + | 'shared-entry' + | 'exact-copy' + | 'source-drift' + | 'name-conflict' + +export type AgentDetectionStatus = 'installed' | 'residue' | 'not-detected' | 'shared-standard' + +export interface AgentDetection { + agentId: AgentId + name: string + icon: string + status: AgentDetectionStatus + evidence: Array<'command' | 'configuration' | 'application' | 'skills-only' | 'shared-standard'> + visibleSkillCount: number +} + +export interface SkillInstallation { + id: string + authorizationRootId: string + scope: 'global' | 'project' | 'plugin' + agent: AgentId + source: 'local' | 'newmax' | 'agents' | 'symlink' | 'plugin' | 'unknown' + path: string + realPath: string + isSymlink: boolean + symlinkTarget?: string + projectName?: string + projectPath?: string + projectId?: string + exposedTo: AgentExposure[] + strategy: ProjectionStrategy + status: ProjectionStatus + managedBy: ProjectionManager + editable: boolean + readOnlyReason?: string + canEditCanonical: boolean + canUnlinkProjection: boolean + canDeleteCanonical: boolean +} + +export interface AuthorizedRoot { + id: string + path: string + realPath: string + scope: 'global' | 'project' | 'plugin' + editable: boolean + agent: AgentId + projectName?: string + projectId?: string +} + export interface Skill { id: string name: string description: string scope: 'global' | 'project' | 'plugin' agent: AgentId - source: 'local' | 'newmax' | 'agents' | 'symlink' | 'unknown' + source: 'local' | 'newmax' | 'agents' | 'symlink' | 'plugin' | 'unknown' category: string path: string realPath: string @@ -36,19 +111,63 @@ export interface Skill { enabled: boolean hasConflict: boolean lastModified: string + contentHash: string | null + packageHash: string | null + sourceIdentity: string | null + identitySignals: SkillIdentitySignal[] + convergenceGroupIds: string[] + installations: SkillInstallation[] + exposedTo: AgentId[] + editable: boolean + readOnlyReason?: string +} + +export interface ConvergenceGroup { + id: string + kind: 'exact-copy' | 'source-drift' | 'name-conflict' + name: string + packageHash: string | null + sourceIdentity: string | null + skillIds: string[] + skills: Skill[] + canonicalCandidateSkillIds: string[] + recommendedCanonicalSkillId: string | null + eligible: boolean + reason: string +} + +export interface ProjectionIssue { + id: string + installationId?: string + agent: AgentId + scope: 'global' | 'project' + targetPath: string + status: Extract + managedBy: ProjectionManager + detail: string } export interface Project { + id: string name: string path: string skillCount: number } export interface ConflictGroup { + id: string name: string skills: Skill[] } +export interface CurrentProject { + status: 'unavailable' | 'registered' | 'configured_empty' + configured: boolean + id: string | null + name: string | null + skillCount: number +} + export interface ScanPathReport { label: string path: string @@ -58,9 +177,14 @@ export interface ScanPathReport { } export interface ScanResult { + scanMode: 'all' | 'current-project' skills: Skill[] projects: Project[] + currentProject: CurrentProject conflicts: ConflictGroup[] + convergenceGroups: ConvergenceGroup[] + projectionIssues: ProjectionIssue[] + agentDetections: AgentDetection[] categories: CategorySummary[] mergeSuggestions: MergeSuggestion[] health: HealthReport @@ -73,5 +197,7 @@ export interface ScanResult { byCategory: Record } scannedPaths: ScanPathReport[] + /** Internal authorization data. Non-enumerable when returned by the scanner. */ + authorizedRoots: AuthorizedRoot[] durationMs: number } diff --git a/server/versioning/store.ts b/server/versioning/store.ts index 10e55d5..8a99570 100644 --- a/server/versioning/store.ts +++ b/server/versioning/store.ts @@ -2,16 +2,17 @@ * Skill Version Store * * 设计思路:类 Git 的轻量级版本管理 - * - 每个 Skill 的版本存储在 ~/.skill-hub/versions// + * - 每个 Skill 的版本存储在 SkillManager 平台数据目录的 versions// * - 每个版本是一个 JSON 文件,包含完整快照 + 元数据 * - 支持:创建快照、查看历史、对比 diff、回滚 */ import fs from 'fs/promises' +import type { Dirent } from 'fs' import path from 'path' -import os from 'os' import crypto from 'crypto' +import { dataDir } from '../config/paths.js' -const STORE_ROOT = path.join(os.homedir(), '.skill-hub', 'versions') +const STORE_ROOT = path.join(dataDir, 'versions') export interface VersionMeta { id: string // 短 hash,类似 git commit id @@ -61,6 +62,20 @@ function storeDir(skillPath: string): string { return path.join(STORE_ROOT, skillHash(skillPath)) } +async function canonicalSkillPath(skillPath: string): Promise { + try { + return await fs.realpath(skillPath) + } catch { + return path.resolve(skillPath) + } +} + +async function candidateStoreDirs(skillPath: string): Promise { + const canonical = await canonicalSkillPath(skillPath) + const lexical = path.resolve(skillPath) + return Array.from(new Set([storeDir(canonical), storeDir(lexical)])) +} + // --- File helpers --- /** @@ -71,7 +86,7 @@ async function readDirRecursive( currentDir: string, result: Record, ): Promise { - let entries: Awaited> + let entries: Dirent[] try { entries = await fs.readdir(currentDir, { withFileTypes: true }) } catch { @@ -130,18 +145,10 @@ export async function createSnapshot( source: 'auto' | 'manual' = 'manual', ): Promise { await initStore() - - const dir = storeDir(skillPath) + const realPath = await canonicalSkillPath(skillPath) + const dir = storeDir(realPath) await fs.mkdir(dir, { recursive: true }) - // 读取当前 SKILL.md - let realPath: string - try { - realPath = await fs.realpath(skillPath) - } catch { - realPath = skillPath - } - const skillMdPath = path.join(realPath, 'SKILL.md') let content = '' try { @@ -157,7 +164,7 @@ export async function createSnapshot( const cHash = contentHash(content + JSON.stringify(files)) // 检查是否和最新版本相同(避免重复快照) - const history = await getHistory(skillPath) + const history = await getHistory(realPath) if (history.length > 0 && history[0].contentHash === cHash) { return history[0] // 内容没变,返回最新版本 } @@ -165,7 +172,7 @@ export async function createSnapshot( const id = versionId() const version: Version = { id, - skillPath, + skillPath: realPath, skillName, timestamp: new Date().toISOString(), message, @@ -196,30 +203,33 @@ export async function createSnapshot( * 获取版本历史(按时间倒序) */ export async function getHistory(skillPath: string): Promise { - const dir = storeDir(skillPath) - let entries: string[] - try { - entries = await fs.readdir(dir) - } catch { - return [] - } - const versions: VersionMeta[] = [] - for (const file of entries) { - if (!file.endsWith('.json')) continue + const seen = new Set() + for (const dir of await candidateStoreDirs(skillPath)) { + let entries: string[] try { - const raw = await fs.readFile(path.join(dir, file), 'utf-8') - const v: Version = JSON.parse(raw) - versions.push({ - id: v.id, - skillPath: v.skillPath, - skillName: v.skillName, - timestamp: v.timestamp, - message: v.message, - source: v.source, - contentHash: v.contentHash, - }) - } catch {} + entries = await fs.readdir(dir) + } catch { + continue + } + for (const file of entries) { + if (!file.endsWith('.json')) continue + try { + const raw = await fs.readFile(path.join(dir, file), 'utf-8') + const v: Version = JSON.parse(raw) + if (seen.has(v.id)) continue + seen.add(v.id) + versions.push({ + id: v.id, + skillPath: v.skillPath, + skillName: v.skillName, + timestamp: v.timestamp, + message: v.message, + source: v.source, + contentHash: v.contentHash, + }) + } catch {} + } } return versions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) @@ -229,14 +239,14 @@ export async function getHistory(skillPath: string): Promise { * 获取某个版本的完整内容 */ export async function getVersion(skillPath: string, versionId: string): Promise { - const dir = storeDir(skillPath) - const filePath = path.join(dir, `${versionId}.json`) - try { - const raw = await fs.readFile(filePath, 'utf-8') - return JSON.parse(raw) - } catch { - return null + for (const dir of await candidateStoreDirs(skillPath)) { + const filePath = path.join(dir, `${versionId}.json`) + try { + const raw = await fs.readFile(filePath, 'utf-8') + return JSON.parse(raw) + } catch {} } + return null } /** @@ -276,12 +286,7 @@ export async function diffWithCurrent( const ver = await getVersion(skillPath, versionId) if (!ver) return null - let realPath: string - try { - realPath = await fs.realpath(skillPath) - } catch { - realPath = skillPath - } + const realPath = await canonicalSkillPath(skillPath) let currentContent = '' try { @@ -312,12 +317,7 @@ export async function rollback(skillPath: string, versionId: string): Promise { - const dir = storeDir(skillPath) - try { - await fs.unlink(path.join(dir, `${versionId}.json`)) - return true - } catch { - return false + for (const dir of await candidateStoreDirs(skillPath)) { + try { + await fs.unlink(path.join(dir, `${versionId}.json`)) + return true + } catch {} } + return false } // --- Diff Algorithm (Myers-like simplified) --- @@ -382,9 +382,6 @@ function computeDiff(oldText: string, newText: string): DiffLine[] { } function longestCommonSubsequence(a: string[], b: string[]): string[] { - const m = a.length - const n = b.length - // Optimize for large files: limit to first 2000 lines const maxLen = 2000 const aa = a.slice(0, maxLen) diff --git a/skills/skillmanager/SKILL.md b/skills/skillmanager/SKILL.md new file mode 100644 index 0000000..f5cae4a --- /dev/null +++ b/skills/skillmanager/SKILL.md @@ -0,0 +1,46 @@ +--- +name: skillmanager +description: 使用 SkillManager 检查、诊断和打开本机 Agent Skills。当用户说“打开 SkillManager / Skill 管理器”,要求检查 Skills、查找重复或重叠项、了解 Skill 为什么没有触发、查看健康状态、检查 Codex 可见性,或打开当前项目的 Skill 配置时使用。 +--- + +# SkillManager + +把 SkillManager 作为本机 Agent Skills 的“先读取、后操作”控制台。 + +## 判断该走哪条路径 + +1. 尝试打开 Dashboard 前先调用 `get_status`,不要盲目重复启动服务。 +2. 用户询问清单、筛选、重复项、健康状态或触发问题时,先调用对应的只读工具: + - `list_skills`:列出和筛选 Skills。 + - `get_skill_summary`:查看单个 Skill 的元数据与 Agent 可见性。 + - `diagnose_library`:检查冲突、重叠和健康问题。 + - `diagnose_skill`:诊断单个 Skill 的触发问题。 +3. 只有用户明确要看界面、比较细节、查看诊断项或版本,或需要在浏览器确认写操作时,才调用 `open_dashboard` 或 `get_deep_link`。 + +绝不能通过 `file://` 打开 `web/index.html`、`dist/web/index.html` 或其他 SkillManager 页面。这些只是源码或构建产物,并不是可运行的 Dashboard。必须使用 `open_dashboard`、`get_deep_link` 或 `skillmanager open --project <目录>`,让托管服务签发一次性本地会话。 + +## 保持证据边界 + +结论必须使用以下标签: + +- `confirmed`:直接观察到的元数据、安装、可见性、冲突或配置状态。 +- `likely`:可能解释当前行为的静态信号,但不是运行时证明。 +- `needs_runtime_test`:需要在新的 Codex 对话里用原提示词实测。 +- `blocked`:缺少必要安装、运行环境或授权。 + +不能声称静态检查已经证明模型实际选择了哪个 Skill。 + +## 写操作留在 Dashboard + +P0 MCP 工具不会编辑、删除、覆盖、恢复或推送 Skill 数据。遇到这类请求时: + +1. 用一句话说明准备做什么。 +2. 打开对应的 Dashboard 深链接。 +3. 让浏览器展示服务端生成的计划、差异、风险、有效期与恢复路径。 +4. 要求用户在浏览器里明确确认。 + +不要用 shell 命令或客户端提供的路径绕过 ActionPlan 流程。 + +## 保护本地数据 + +只返回不透明 ID 和摘要。不要暴露用户目录绝对路径、控制凭据、GitHub token、完整 `SKILL.md` 正文、辅助文件内容,或用户没有要求的项目清单。 diff --git a/skills/skillmanager/agents/openai.yaml b/skills/skillmanager/agents/openai.yaml new file mode 100644 index 0000000..005c769 --- /dev/null +++ b/skills/skillmanager/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "SkillManager" + short_description: "检查、诊断并整理本机 Agent Skills" + icon_small: "./assets/icon.png" + icon_large: "./assets/logo.png" + brand_color: "#FACC15" + default_prompt: "使用 $skillmanager 检查我的 Skills,并打开本地管理界面。" diff --git a/skills/skillmanager/assets/icon.png b/skills/skillmanager/assets/icon.png new file mode 100644 index 0000000..dbdc226 Binary files /dev/null and b/skills/skillmanager/assets/icon.png differ diff --git a/skills/skillmanager/assets/logo.png b/skills/skillmanager/assets/logo.png new file mode 100644 index 0000000..38953d1 Binary files /dev/null and b/skills/skillmanager/assets/logo.png differ diff --git a/tests/browser-fixtures/home/.agents/skills/browser-release-demo/SKILL.md b/tests/browser-fixtures/home/.agents/skills/browser-release-demo/SKILL.md new file mode 100644 index 0000000..c441a59 --- /dev/null +++ b/tests/browser-fixtures/home/.agents/skills/browser-release-demo/SKILL.md @@ -0,0 +1,8 @@ +--- +name: Browser Release Demo +description: Use when verifying the isolated SkillManager Codex Plugin UI in the in-app browser. +--- + +# Browser Release Demo + +This fixture proves the release scanner is serving test-only data. diff --git a/tests/browser-fixtures/home/.agents/skills/skill-creator/SKILL.md b/tests/browser-fixtures/home/.agents/skills/skill-creator/SKILL.md new file mode 100644 index 0000000..7ea86ab --- /dev/null +++ b/tests/browser-fixtures/home/.agents/skills/skill-creator/SKILL.md @@ -0,0 +1,8 @@ +--- +name: Skill Creator +description: Use when creating or improving an Agent Skill with clear triggers and verification. +--- + +# Skill Creator + +Demo fixture for the SkillManager public release screenshot. diff --git a/tests/browser-fixtures/home/.agents/skills/video-transcript/SKILL.md b/tests/browser-fixtures/home/.agents/skills/video-transcript/SKILL.md new file mode 100644 index 0000000..5ba16f8 --- /dev/null +++ b/tests/browser-fixtures/home/.agents/skills/video-transcript/SKILL.md @@ -0,0 +1,8 @@ +--- +name: Video Transcript +description: Use when turning a public video into a structured, source-linked transcript. +--- + +# Video Transcript + +Demo fixture for the SkillManager public release screenshot. diff --git a/tests/browser-fixtures/home/.claude/skills/daily-review/SKILL.md b/tests/browser-fixtures/home/.claude/skills/daily-review/SKILL.md new file mode 100644 index 0000000..3f5015d --- /dev/null +++ b/tests/browser-fixtures/home/.claude/skills/daily-review/SKILL.md @@ -0,0 +1,8 @@ +--- +name: Daily Review +description: Use when reviewing today's work and choosing the next concrete action. +--- + +# Daily Review + +Claude Code copy used to demonstrate a convergence candidate. diff --git a/tests/browser-fixtures/home/.claude/skills/meeting-notes/SKILL.md b/tests/browser-fixtures/home/.claude/skills/meeting-notes/SKILL.md new file mode 100644 index 0000000..c0cef5c --- /dev/null +++ b/tests/browser-fixtures/home/.claude/skills/meeting-notes/SKILL.md @@ -0,0 +1,8 @@ +--- +name: Meeting Notes +description: Use when turning meeting notes into decisions, owners, and concrete follow-up actions. +--- + +# Meeting Notes + +Demo fixture with identical content in two Agent locations. diff --git a/tests/browser-fixtures/home/.claude/skills/video-transcript b/tests/browser-fixtures/home/.claude/skills/video-transcript new file mode 120000 index 0000000..57e7518 --- /dev/null +++ b/tests/browser-fixtures/home/.claude/skills/video-transcript @@ -0,0 +1 @@ +../../.agents/skills/video-transcript \ No newline at end of file diff --git a/tests/browser-fixtures/home/.codex/skills/daily-review/SKILL.md b/tests/browser-fixtures/home/.codex/skills/daily-review/SKILL.md new file mode 100644 index 0000000..46ca078 --- /dev/null +++ b/tests/browser-fixtures/home/.codex/skills/daily-review/SKILL.md @@ -0,0 +1,8 @@ +--- +name: Daily Review +description: Use when reviewing today's work and choosing the next concrete action. +--- + +# Daily Review + +Codex copy used to demonstrate a convergence candidate. diff --git a/tests/browser-fixtures/home/.codex/skills/meeting-notes/SKILL.md b/tests/browser-fixtures/home/.codex/skills/meeting-notes/SKILL.md new file mode 100644 index 0000000..c0cef5c --- /dev/null +++ b/tests/browser-fixtures/home/.codex/skills/meeting-notes/SKILL.md @@ -0,0 +1,8 @@ +--- +name: Meeting Notes +description: Use when turning meeting notes into decisions, owners, and concrete follow-up actions. +--- + +# Meeting Notes + +Demo fixture with identical content in two Agent locations. diff --git a/tests/browser-fixtures/home/.codex/skills/video-transcript b/tests/browser-fixtures/home/.codex/skills/video-transcript new file mode 120000 index 0000000..57e7518 --- /dev/null +++ b/tests/browser-fixtures/home/.codex/skills/video-transcript @@ -0,0 +1 @@ +../../.agents/skills/video-transcript \ No newline at end of file diff --git a/tests/browser-fixtures/home/.cursor/skills/research-brief/SKILL.md b/tests/browser-fixtures/home/.cursor/skills/research-brief/SKILL.md new file mode 100644 index 0000000..f7f96d4 --- /dev/null +++ b/tests/browser-fixtures/home/.cursor/skills/research-brief/SKILL.md @@ -0,0 +1,8 @@ +--- +name: Research Brief +description: Use when turning verified sources into a concise research brief with explicit uncertainty. +--- + +# Research Brief + +Demo fixture for the SkillManager public release screenshot. diff --git a/tests/browser-fixtures/narrow-viewport.html b/tests/browser-fixtures/narrow-viewport.html new file mode 100644 index 0000000..7050f19 --- /dev/null +++ b/tests/browser-fixtures/narrow-viewport.html @@ -0,0 +1,35 @@ + + + + + + SkillManager 390px browser fixture + + + + + + diff --git a/tests/integration/action-batch-delete.test.ts b/tests/integration/action-batch-delete.test.ts new file mode 100644 index 0000000..51cbfdb --- /dev/null +++ b/tests/integration/action-batch-delete.test.ts @@ -0,0 +1,55 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +test('batch delete validates every item before mutating any installation', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-batch-delete-')) + t.after(async () => fs.rm(root, { recursive: true, force: true })) + process.env.SKILLMANAGER_FIXTURE_ROOT = root + process.env.SKILLMANAGER_DATA_DIR = path.join(root, 'data') + + const skillsRoot = path.join(root, '.agents', 'skills') + const firstDir = path.join(skillsRoot, 'first') + const secondDir = path.join(skillsRoot, 'second') + await fs.mkdir(firstDir, { recursive: true }) + await fs.mkdir(secondDir, { recursive: true }) + await fs.writeFile(path.join(firstDir, 'SKILL.md'), '---\nname: First\ndescription: first\n---\nFirst.\n') + await fs.writeFile(path.join(secondDir, 'SKILL.md'), '---\nname: Second\ndescription: second\n---\nSecond.\n') + + const { getCatalog } = await import('../../server/core/catalog.js') + const { ActionPlanError, ActionPlanService } = await import('../../server/core/action-plans.js') + const service = new ActionPlanService() + let catalog = await getCatalog(true) + const first = catalog.skills.find((skill) => skill.name === 'First') + const second = catalog.skills.find((skill) => skill.name === 'Second') + assert.ok(first) + assert.ok(second) + + const stalePlan = await service.previewBatchDelete([ + { skillId: first.id }, + { skillId: second.id }, + ]) + await fs.appendFile(path.join(secondDir, 'SKILL.md'), 'Externally changed.\n') + await assert.rejects( + service.apply(stalePlan.id), + (error: unknown) => error instanceof ActionPlanError && error.code === 'STALE_PLAN', + ) + await fs.access(firstDir) + await fs.access(secondDir) + + catalog = await getCatalog(true) + const refreshedFirst = catalog.skills.find((skill) => skill.name === 'First') + const refreshedSecond = catalog.skills.find((skill) => skill.name === 'Second') + assert.ok(refreshedFirst) + assert.ok(refreshedSecond) + const validPlan = await service.previewBatchDelete([ + { skillId: refreshedFirst.id }, + { skillId: refreshedSecond.id }, + ]) + const result = await service.apply(validPlan.id) + assert.equal(result.recovery.trashIds?.length, 2) + await assert.rejects(fs.access(firstDir)) + await assert.rejects(fs.access(secondDir)) +}) diff --git a/tests/integration/action-plans.test.ts b/tests/integration/action-plans.test.ts new file mode 100644 index 0000000..fd5c81b --- /dev/null +++ b/tests/integration/action-plans.test.ts @@ -0,0 +1,59 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +test('edit plans reject stale content and apply atomically with a snapshot', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-plan-edit-')) + t.after(async () => fs.rm(root, { recursive: true, force: true })) + process.env.SKILLMANAGER_FIXTURE_ROOT = root + process.env.SKILLMANAGER_DATA_DIR = path.join(root, 'data') + + const skillDir = path.join(root, '.agents', 'skills', 'editable') + const skillFile = path.join(skillDir, 'SKILL.md') + await fs.mkdir(skillDir, { recursive: true }) + await fs.writeFile(skillFile, '---\nname: Editable\ndescription: before\n---\nBefore.\n', 'utf8') + + const { getCatalog } = await import('../../server/core/catalog.js') + const { ActionPlanError, ActionPlanService, ACTION_PLAN_TTL_MS } = await import( + '../../server/core/action-plans.js' + ) + const { getHistory } = await import('../../server/versioning/store.js') + const service = new ActionPlanService() + const catalog = await getCatalog(true) + const skill = catalog.skills.find((entry) => entry.name === 'Editable') + assert.ok(skill) + + const stalePlan = await service.previewEdit({ + skillId: skill.id, + content: '---\nname: Editable\ndescription: planned\n---\nPlanned.\n', + }) + await fs.writeFile(skillFile, '---\nname: Editable\ndescription: external\n---\nExternal.\n', 'utf8') + await assert.rejects( + service.apply(stalePlan.id), + (error: unknown) => error instanceof ActionPlanError && error.code === 'STALE_PLAN', + ) + assert.match(await fs.readFile(skillFile, 'utf8'), /External/) + + const appliedContent = '---\nname: Editable\ndescription: applied\n---\nApplied.\n' + const editPlan = await service.previewEdit({ skillId: skill.id, content: appliedContent }) + const editResult = await service.apply(editPlan.id) + assert.equal(editResult.action, 'edit') + assert.ok(editResult.recovery.snapshotId) + assert.equal(await fs.readFile(skillFile, 'utf8'), appliedContent) + assert.equal( + (await getHistory(skillDir)).some((version) => version.id === editResult.recovery.snapshotId), + true, + ) + + const expiryTime = Date.now() + const expiredPlan = await service.previewEdit( + { skillId: skill.id, content: appliedContent + '\n' }, + expiryTime, + ) + await assert.rejects( + service.apply(expiredPlan.id, expiryTime + ACTION_PLAN_TTL_MS), + (error: unknown) => error instanceof ActionPlanError && error.code === 'PLAN_EXPIRED', + ) +}) diff --git a/tests/integration/action-recovery.test.ts b/tests/integration/action-recovery.test.ts new file mode 100644 index 0000000..e0ad401 --- /dev/null +++ b/tests/integration/action-recovery.test.ts @@ -0,0 +1,54 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +test('restore and delete plans return recoverable snapshots or trash IDs', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-plan-recovery-')) + t.after(async () => fs.rm(root, { recursive: true, force: true })) + process.env.SKILLMANAGER_FIXTURE_ROOT = root + process.env.SKILLMANAGER_DATA_DIR = path.join(root, 'data') + + const skillDir = path.join(root, '.agents', 'skills', 'recoverable-plan') + const skillFile = path.join(skillDir, 'SKILL.md') + const original = '---\nname: Recoverable Plan\ndescription: original\n---\nOriginal.\n' + await fs.mkdir(skillDir, { recursive: true }) + await fs.writeFile(skillFile, original, 'utf8') + + const { getCatalog } = await import('../../server/core/catalog.js') + const { ActionPlanService } = await import('../../server/core/action-plans.js') + const { createSnapshot } = await import('../../server/versioning/store.js') + const { listTrash } = await import('../../server/trash/store.js') + const service = new ActionPlanService() + let catalog = await getCatalog(true) + let skill = catalog.skills.find((entry) => entry.name === 'Recoverable Plan') + assert.ok(skill) + const originalVersion = await createSnapshot(skillDir, skill.name, 'original', 'manual') + + const changed = '---\nname: Recoverable Plan\ndescription: changed\n---\nChanged.\n' + const editPlan = await service.previewEdit({ skillId: skill.id, content: changed }) + await service.apply(editPlan.id) + + catalog = await getCatalog(true) + skill = catalog.skills.find((entry) => entry.name === 'Recoverable Plan') + assert.ok(skill) + const restorePlan = await service.previewRestore({ + skillId: skill.id, + versionId: originalVersion.id, + }) + const restoreResult = await service.apply(restorePlan.id) + assert.equal(restoreResult.action, 'restore') + assert.ok(restoreResult.recovery.snapshotId) + assert.equal(await fs.readFile(skillFile, 'utf8'), original) + + catalog = await getCatalog(true) + skill = catalog.skills.find((entry) => entry.name === 'Recoverable Plan') + assert.ok(skill) + const deletePlan = await service.previewDelete({ skillId: skill.id }) + const deleteResult = await service.apply(deletePlan.id) + assert.equal(deleteResult.action, 'delete') + assert.ok(deleteResult.recovery.trashId) + await assert.rejects(fs.access(skillDir)) + assert.equal((await listTrash()).some((entry) => entry.id === deleteResult.recovery.trashId), true) +}) diff --git a/tests/integration/action-transfer.test.ts b/tests/integration/action-transfer.test.ts new file mode 100644 index 0000000..8ab8d77 --- /dev/null +++ b/tests/integration/action-transfer.test.ts @@ -0,0 +1,91 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +test('copy and move use authorized target IDs and reject nested symlinks', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-transfer-')) + t.after(async () => fs.rm(root, { recursive: true, force: true })) + process.env.SKILLMANAGER_FIXTURE_ROOT = root + process.env.SKILLMANAGER_DATA_DIR = path.join(root, 'data') + + const sourceDir = path.join(root, '.agents', 'skills', 'source-skill') + const claudeRoot = path.join(root, '.claude', 'skills') + const codexRoot = path.join(root, '.codex', 'skills') + await fs.mkdir(sourceDir, { recursive: true }) + await fs.mkdir(claudeRoot, { recursive: true }) + await fs.mkdir(codexRoot, { recursive: true }) + await fs.writeFile( + path.join(sourceDir, 'SKILL.md'), + '---\nname: Source Skill\ndescription: transfer\n---\nSource.\n', + 'utf8', + ) + + const { getCatalog } = await import('../../server/core/catalog.js') + const { ActionPlanError, ActionPlanService } = await import('../../server/core/action-plans.js') + const service = new ActionPlanService() + let catalog = await getCatalog(true) + let skill = catalog.skills.find((entry) => entry.name === 'Source Skill') + assert.ok(skill) + const claudeTarget = catalog.authorizedRoots.find( + (entry) => entry.agent === 'claude-code' && entry.scope === 'global', + ) + const codexTarget = catalog.authorizedRoots.find( + (entry) => entry.agent === 'codex' && entry.scope === 'global', + ) + assert.ok(claudeTarget) + assert.ok(codexTarget) + + const copyPlan = await service.previewTransfer('copy', { + skillId: skill.id, + targetRootId: claudeTarget.id, + }) + assert.equal(copyPlan.advisories?.[0]?.code, 'EXACT_COPY_WOULD_BE_CREATED') + assert.equal(copyPlan.advisories?.[0]?.recommendedAction, 'share-existing') + const copied = await service.apply(copyPlan.id) + assert.equal(copied.action, 'copy') + assert.match( + await fs.readFile(path.join(claudeRoot, 'source-skill', 'SKILL.md'), 'utf8'), + /Source/, + ) + + catalog = await getCatalog(true) + const canonicalSourceDir = await fs.realpath(sourceDir) + skill = catalog.skills.find((entry) => entry.realPath === canonicalSourceDir) + assert.ok(skill) + const movePlan = await service.previewTransfer('move', { + skillId: skill.id, + targetRootId: codexTarget.id, + }) + const moved = await service.apply(movePlan.id) + assert.equal(moved.action, 'move') + assert.ok(moved.recovery.trashId) + await assert.rejects(fs.access(sourceDir)) + assert.match( + await fs.readFile(path.join(codexRoot, 'source-skill', 'SKILL.md'), 'utf8'), + /Source/, + ) + + const unsafeDir = path.join(root, '.agents', 'skills', 'unsafe-copy') + await fs.mkdir(unsafeDir, { recursive: true }) + await fs.writeFile( + path.join(unsafeDir, 'SKILL.md'), + '---\nname: Unsafe Copy\ndescription: symlink\n---\nUnsafe.\n', + 'utf8', + ) + await fs.symlink(path.join(root, 'outside.txt'), path.join(unsafeDir, 'outside-link')) + catalog = await getCatalog(true) + const unsafe = catalog.skills.find((entry) => entry.name === 'Unsafe Copy') + assert.ok(unsafe) + const unsafePlan = await service.previewTransfer('copy', { + skillId: unsafe.id, + targetRootId: claudeTarget.id, + }) + await assert.rejects( + service.apply(unsafePlan.id), + (error: unknown) => + error instanceof ActionPlanError && error.code === 'NESTED_SYMLINK_UNSUPPORTED', + ) + assert.equal(await fs.readFile(path.join(unsafeDir, 'SKILL.md'), 'utf8').then(() => true), true) +}) diff --git a/tests/integration/convergence-api.test.ts b/tests/integration/convergence-api.test.ts new file mode 100644 index 0000000..94ec2f9 --- /dev/null +++ b/tests/integration/convergence-api.test.ts @@ -0,0 +1,157 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import Fastify from 'fastify' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +test('convergence API exposes structured state and requires explicit apply confirmation', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-convergence-api-')) + t.after(async () => fs.rm(root, { recursive: true, force: true })) + process.env.SKILLMANAGER_FIXTURE_ROOT = root + process.env.SKILLMANAGER_DATA_DIR = path.join(root, 'data') + + const paths = [ + path.join(root, '.agents', 'skills', 'api-copy'), + path.join(root, '.codex', 'skills', 'api-copy'), + ] + const batchPaths = [ + path.join(root, '.agents', 'skills', 'api-batch'), + path.join(root, '.codex', 'skills', 'api-batch'), + ] + for (const target of [...paths, ...batchPaths]) { + const isBatch = target.endsWith('api-batch') + await fs.mkdir(target, { recursive: true }) + await fs.writeFile( + path.join(target, 'SKILL.md'), + `---\nname: ${isBatch ? 'API Batch' : 'API Copy'}\ndescription: exact fixture\n---\nSame.\n`, + 'utf8', + ) + await fs.writeFile(path.join(target, 'reference.md'), 'same\n', 'utf8') + } + + const { skillRoutes } = await import('../../server/routes/skills.js') + const { actionRoutes } = await import('../../server/routes/actions.js') + const app = Fastify() + await skillRoutes(app) + await actionRoutes(app) + await app.ready() + t.after(() => app.close()) + + const scanResponse = await app.inject({ method: 'GET', url: '/api/scan' }) + assert.equal(scanResponse.statusCode, 200) + const group = scanResponse.json().convergenceGroups.find( + (entry: any) => entry.kind === 'exact-copy' && entry.name === 'API Copy', + ) + assert.ok(group) + assert.match(group.id, /^group_[a-f0-9]{20}$/) + assert.ok(group.recommendedCanonicalSkillId) + + const stateResponse = await app.inject({ method: 'GET', url: '/api/v1/convergence' }) + assert.equal(stateResponse.statusCode, 200) + assert.equal(stateResponse.json().schemaVersion, 1) + assert.equal(stateResponse.json().ok, true) + assert.ok(Array.isArray(stateResponse.json().agentDetections)) + assert.ok(Array.isArray(stateResponse.json().projectionIssues)) + + const previewResponse = await app.inject({ + method: 'POST', + url: '/api/v1/actions/converge/preview', + payload: { + canonicalSkillId: group.recommendedCanonicalSkillId, + duplicateSkillIds: group.skillIds.filter( + (skillId: string) => skillId !== group.recommendedCanonicalSkillId, + ), + }, + }) + assert.equal(previewResponse.statusCode, 200, previewResponse.body) + const plan = previewResponse.json().plan + assert.equal(plan.action, 'converge') + assert.equal(plan.requiresExplicitConfirmation, true) + assert.ok(plan.changes.every((change: any) => change.operation === 'link')) + + const refused = await app.inject({ + method: 'POST', + url: `/api/v1/actions/${plan.id}/apply`, + payload: { confirm: false }, + }) + assert.equal(refused.statusCode, 400) + assert.equal(refused.json().code, 'EXPLICIT_CONFIRMATION_REQUIRED') + + const applied = await app.inject({ + method: 'POST', + url: `/api/v1/actions/${plan.id}/apply`, + payload: { confirm: true }, + }) + assert.equal(applied.statusCode, 200, applied.body) + assert.equal(applied.json().verification.projectionCount, 1) + assert.match(applied.json().recovery.transactionId, /^convergence_/) + + const afterState = await app.inject({ method: 'GET', url: '/api/v1/convergence' }) + const transaction = afterState.json().transactions.find( + (entry: any) => entry.id === applied.json().recovery.transactionId, + ) + assert.equal(transaction.status, 'applied') + assert.equal(transaction.projectionCount, 1) + assert.equal(transaction.rollbackAvailable, true) + assert.equal(transaction.reason, 'available') + assert.match(transaction.recoveryExpiresAt, /^\d{4}-\d{2}-\d{2}T/) + assert.ok(transaction.daysRemaining >= 1) + + const rollbackPreview = await app.inject({ + method: 'POST', + url: '/api/v1/actions/converge-rollback/preview', + payload: { transactionId: transaction.id }, + }) + assert.equal(rollbackPreview.statusCode, 200, rollbackPreview.body) + const rollback = await app.inject({ + method: 'POST', + url: `/api/v1/actions/${rollbackPreview.json().plan.id}/apply`, + payload: { confirm: true }, + }) + assert.equal(rollback.statusCode, 200, rollback.body) + assert.equal(rollback.json().verification.rolledBack, true) + assert.equal((await fs.lstat(paths[1])).isSymbolicLink(), false) + + const refreshed = await app.inject({ method: 'GET', url: '/api/scan' }) + const safeGroups = refreshed.json().convergenceGroups.filter( + (entry: any) => entry.kind === 'exact-copy' && entry.eligible && ['API Copy', 'API Batch'].includes(entry.name), + ) + assert.equal(safeGroups.length, 2) + const batchPreview = await app.inject({ + method: 'POST', + url: '/api/v1/actions/converge-batch/preview', + payload: { + groups: safeGroups.map((entry: any) => ({ + canonicalSkillId: entry.recommendedCanonicalSkillId, + duplicateSkillIds: entry.skillIds.filter( + (skillId: string) => skillId !== entry.recommendedCanonicalSkillId, + ), + })), + }, + }) + assert.equal(batchPreview.statusCode, 200, batchPreview.body) + assert.equal(batchPreview.json().plan.groupCount, 2) + assert.equal(batchPreview.json().plan.permanentDeleteCount, 0) + assert.equal(batchPreview.json().plan.recoveryDays, 7) + + const refusedBatch = await app.inject({ + method: 'POST', + url: `/api/v1/actions/converge-batch/${batchPreview.json().plan.id}/apply`, + payload: { confirm: false }, + }) + assert.equal(refusedBatch.statusCode, 400) + assert.equal(refusedBatch.json().code, 'EXPLICIT_CONFIRMATION_REQUIRED') + + const appliedBatch = await app.inject({ + method: 'POST', + url: `/api/v1/actions/converge-batch/${batchPreview.json().plan.id}/apply`, + payload: { confirm: true }, + }) + assert.equal(appliedBatch.statusCode, 200, appliedBatch.body) + assert.equal(appliedBatch.json().status, 'complete') + assert.equal(appliedBatch.json().successes.length, 2) + assert.equal(appliedBatch.json().failures.length, 0) + assert.equal((await fs.lstat(paths[1])).isSymbolicLink(), true) + assert.equal((await fs.lstat(batchPaths[1])).isSymbolicLink(), true) +}) diff --git a/tests/integration/convergence.test.ts b/tests/integration/convergence.test.ts new file mode 100644 index 0000000..dc2f5ec --- /dev/null +++ b/tests/integration/convergence.test.ts @@ -0,0 +1,430 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +async function writeSkill( + root: string, + agentRoot: string, + directory: string, + name: string, + body: string, + extra = 'same support file\n', +) { + const target = path.join(root, agentRoot, 'skills', directory) + await fs.mkdir(target, { recursive: true }) + await fs.writeFile( + path.join(target, 'SKILL.md'), + `---\nname: ${name}\ndescription: convergence fixture\n---\n${body}\n`, + 'utf8', + ) + await fs.writeFile(path.join(target, 'support.txt'), extra, 'utf8') + return target +} + +test('convergence is exact-copy only, transactional, verifiable, and recoverable', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-convergence-')) + t.after(async () => fs.rm(root, { recursive: true, force: true })) + process.env.SKILLMANAGER_FIXTURE_ROOT = root + process.env.SKILLMANAGER_DATA_DIR = path.join(root, 'data') + process.env.SKILLMANAGER_PROJECT_ROOT = path.join(root, 'projects', 'demo') + + const happyCanonical = await writeSkill(root, '.agents', 'happy', 'Happy', 'same package') + const happyDuplicate = await writeSkill(root, '.codex', 'happy', 'Happy', 'same package') + await writeSkill(root, '.agents', 'conflict-a', 'Conflict', 'version A') + await writeSkill(root, '.codex', 'conflict-b', 'Conflict', 'version B') + const failureCanonical = await writeSkill(root, '.agents', 'failure', 'Failure', 'same failure package') + const failureCodex = await writeSkill(root, '.codex', 'failure', 'Failure', 'same failure package') + const failureClaude = await writeSkill(root, '.claude', 'failure', 'Failure', 'same failure package') + const batchOneCanonical = await writeSkill(root, '.agents', 'batch-one', 'Batch One', 'same batch one') + const batchOneDuplicate = await writeSkill(root, '.codex', 'batch-one', 'Batch One', 'same batch one') + const batchTwoCanonical = await writeSkill(root, '.agents', 'batch-two', 'Batch Two', 'same batch two') + const batchTwoDuplicate = await writeSkill(root, '.codex', 'batch-two', 'Batch Two', 'same batch two') + const deleteRaceCanonical = await writeSkill(root, '.agents', 'delete-race', 'Delete Race', 'same delete race') + const deleteRaceDuplicate = await writeSkill(root, '.codex', 'delete-race', 'Delete Race', 'same delete race') + const moveRaceCanonical = await writeSkill(root, '.agents', 'move-race', 'Move Race', 'same move race') + const moveRaceDuplicate = await writeSkill(root, '.codex', 'move-race', 'Move Race', 'same move race') + await fs.writeFile(path.join(root, '.claude', 'settings.json'), '{}\n', 'utf8') + await writeSkill(root, '.agents', 'extra-drift', 'Extra Drift', 'same SKILL.md', 'support A\n') + await writeSkill(root, '.codex', 'extra-drift', 'Extra Drift', 'same SKILL.md', 'support B\n') + const unsafeCanonical = await writeSkill(root, '.agents', 'unsafe', 'Unsafe', 'same unsafe package') + await writeSkill(root, '.codex', 'unsafe', 'Unsafe', 'same unsafe package') + await writeSkill(root, '.agents', 'cross-scope', 'Cross Scope', 'same scoped package') + const projectCrossScope = path.join(root, 'projects', 'demo', '.agents', 'skills', 'cross-scope') + await fs.mkdir(projectCrossScope, { recursive: true }) + await fs.writeFile( + path.join(projectCrossScope, 'SKILL.md'), + '---\nname: Cross Scope\ndescription: convergence fixture\n---\nsame scoped package\n', + 'utf8', + ) + await fs.writeFile(path.join(projectCrossScope, 'support.txt'), 'same support file\n', 'utf8') + + const outside = path.join(root, 'outside') + await fs.mkdir(outside, { recursive: true }) + await fs.writeFile(path.join(outside, 'secret.txt'), 'must never be projected', 'utf8') + for (const target of [unsafeCanonical, path.join(root, '.codex', 'skills', 'unsafe')]) { + await fs.symlink(path.relative(target, outside), path.join(target, 'external')) + } + await fs.mkdir(path.join(root, '.cursor', 'skills'), { recursive: true }) + await fs.symlink( + path.join(root, 'missing-target'), + path.join(root, '.cursor', 'skills', 'broken'), + ) + + const { getCatalog } = await import('../../server/core/catalog.js') + const { ActionPlanError, ActionPlanService } = await import('../../server/core/action-plans.js') + const { hashSkillDirectory } = await import('../../server/scanner/package-hash.js') + const { + getConvergenceRecoveryStatus, + getConvergenceTransaction, + } = await import('../../server/projections/store.js') + + const service = new ActionPlanService() + let catalog = await getCatalog(true) + const happyGroup = catalog.convergenceGroups.find( + (group) => group.kind === 'exact-copy' && group.name === 'Happy', + ) + assert.ok(happyGroup) + assert.equal(happyGroup.eligible, true) + assert.equal(happyGroup.skills.length, 2) + assert.ok(happyGroup.recommendedCanonicalSkillId) + assert.ok(happyGroup.canonicalCandidateSkillIds.includes(happyGroup.recommendedCanonicalSkillId)) + + const conflictGroup = catalog.convergenceGroups.find( + (group) => group.kind === 'name-conflict' && group.name === 'Conflict', + ) + assert.ok(conflictGroup) + assert.equal(conflictGroup.eligible, false) + assert.equal(catalog.convergenceGroups.some( + (group) => group.kind === 'exact-copy' && group.name === 'Conflict', + ), false) + assert.equal(catalog.convergenceGroups.some( + (group) => group.kind === 'exact-copy' && group.name === 'Extra Drift', + ), false) + assert.ok(catalog.convergenceGroups.some( + (group) => group.kind === 'name-conflict' && group.name === 'Extra Drift', + )) + await assert.rejects( + service.previewConverge({ + canonicalSkillId: conflictGroup.skills[0].id, + duplicateSkillIds: [conflictGroup.skills[1].id], + }), + (error: unknown) => error instanceof ActionPlanError && error.code === 'PACKAGE_HASH_MISMATCH', + ) + + const unsafeGroup = catalog.convergenceGroups.find( + (group) => group.kind === 'exact-copy' && group.name === 'Unsafe', + ) + assert.ok(unsafeGroup) + await assert.rejects( + service.previewConverge({ + canonicalSkillId: unsafeGroup.recommendedCanonicalSkillId!, + duplicateSkillIds: unsafeGroup.skillIds.filter( + (skillId) => skillId !== unsafeGroup.recommendedCanonicalSkillId, + ), + }), + (error: unknown) => error instanceof ActionPlanError && error.code === 'UNSAFE_PACKAGE_LINK', + ) + assert.equal((await fs.lstat(unsafeCanonical)).isDirectory(), true) + assert.equal(await fs.readFile(path.join(outside, 'secret.txt'), 'utf8'), 'must never be projected') + + const crossScopeGroup = catalog.convergenceGroups.find( + (group) => group.kind === 'exact-copy' && group.name === 'Cross Scope', + ) + assert.ok(crossScopeGroup) + assert.equal(crossScopeGroup.eligible, false) + assert.deepEqual(crossScopeGroup.canonicalCandidateSkillIds, []) + assert.match(crossScopeGroup.reason, /全局与项目作用域/) + const globalCrossScope = crossScopeGroup.skills.find((skill) => ( + skill.installations.some((installation) => installation.scope === 'global') + )) + const scopedCrossScope = crossScopeGroup.skills.find((skill) => ( + skill.installations.some((installation) => installation.scope === 'project') + )) + assert.ok(globalCrossScope && scopedCrossScope) + await assert.rejects( + service.previewConverge({ + canonicalSkillId: globalCrossScope.id, + duplicateSkillIds: [scopedCrossScope.id], + }), + (error: unknown) => error instanceof ActionPlanError && error.code === 'SCOPE_BOUNDARY', + ) + await assert.rejects( + service.previewConvergeBatch({ + groups: [{ + canonicalSkillId: globalCrossScope.id, + duplicateSkillIds: [scopedCrossScope.id], + }], + }), + (error: unknown) => error instanceof ActionPlanError && error.code === 'BATCH_GROUP_NOT_ELIGIBLE', + ) + await assert.rejects( + service.previewConvergeBatch({ + groups: [{ + canonicalSkillId: conflictGroup.skills[0].id, + duplicateSkillIds: [conflictGroup.skills[1].id], + }], + }), + (error: unknown) => error instanceof ActionPlanError && error.code === 'BATCH_GROUP_NOT_ELIGIBLE', + ) + + assert.ok(catalog.projectionIssues.some((issue) => ( + issue.status === 'broken' && issue.targetPath.endsWith(path.join('skills', 'broken')) + ))) + assert.equal( + catalog.agentDetections.find((entry) => entry.agentId === 'codex')?.status, + 'residue', + ) + assert.equal( + catalog.agentDetections.find((entry) => entry.agentId === 'claude-code')?.status, + 'installed', + ) + assert.equal( + catalog.agentDetections.find((entry) => entry.agentId === 'universal')?.status, + 'shared-standard', + ) + + const canonicalId = happyGroup.recommendedCanonicalSkillId! + const convergePlan = await service.previewConverge({ + canonicalSkillId: canonicalId, + duplicateSkillIds: happyGroup.skillIds.filter((skillId) => skillId !== canonicalId), + }) + assert.equal(convergePlan.action, 'converge') + assert.ok(convergePlan.changes.every((change) => change.operation === 'link')) + const applied = await service.apply(convergePlan.id) + assert.equal(applied.action, 'converge') + assert.equal(applied.verification.projectionCount, 1) + assert.ok(applied.recovery.transactionId) + assert.equal((await fs.lstat(happyDuplicate)).isSymbolicLink(), true) + assert.equal(await fs.realpath(happyDuplicate), await fs.realpath(happyCanonical)) + + catalog = await getCatalog(true) + const converged = catalog.skills.find((skill) => skill.name === 'Happy') + assert.ok(converged) + assert.equal(converged.installations.length, 2) + const managedProjection = converged.installations.find( + (installation) => path.resolve(installation.path) === path.resolve(happyDuplicate), + ) + assert.equal(managedProjection?.managedBy, 'skillmanager') + assert.equal(managedProjection?.status, 'valid') + assert.equal(managedProjection?.canUnlinkProjection, true) + + const canonicalInstallation = converged.installations.find( + (installation) => path.resolve(installation.path) === path.resolve(happyCanonical), + ) + assert.ok(canonicalInstallation) + await assert.rejects( + service.previewDelete({ + skillId: converged.id, + installationId: canonicalInstallation.id, + }), + (error: unknown) => error instanceof ActionPlanError && error.code === 'CANONICAL_IN_USE', + ) + const claudeRoot = catalog.authorizedRoots.find((candidate) => ( + candidate.agent === 'claude-code' && candidate.scope === 'global' + )) + assert.ok(claudeRoot) + await assert.rejects( + service.previewTransfer('move', { + skillId: converged.id, + installationId: canonicalInstallation.id, + targetRootId: claudeRoot.id, + }), + (error: unknown) => error instanceof ActionPlanError && error.code === 'CANONICAL_IN_USE', + ) + + const activeTransaction = await getConvergenceTransaction(applied.recovery.transactionId!) + assert.ok(activeTransaction?.recoveryExpiresAt) + const recovery = await getConvergenceRecoveryStatus(activeTransaction!) + assert.equal(recovery.rollbackAvailable, true) + assert.equal(recovery.reason, 'available') + assert.ok(recovery.daysRemaining >= 1) + const afterExpiry = await getConvergenceRecoveryStatus( + activeTransaction!, + new Date(activeTransaction!.recoveryExpiresAt!).getTime() + 1, + ) + assert.equal(afterExpiry.rollbackAvailable, false) + assert.equal(afterExpiry.reason, 'expired') + + const rollbackPlan = await service.previewConvergeRollback(applied.recovery.transactionId!) + assert.equal(rollbackPlan.action, 'converge-rollback') + const rolledBack = await service.apply(rollbackPlan.id) + assert.equal(rolledBack.verification.rolledBack, true) + assert.equal((await fs.lstat(happyDuplicate)).isSymbolicLink(), false) + assert.equal( + await hashSkillDirectory(happyDuplicate), + await hashSkillDirectory(happyCanonical), + ) + + catalog = await getCatalog(true) + const happyAgain = catalog.convergenceGroups.find( + (group) => group.kind === 'exact-copy' && group.name === 'Happy', + ) + assert.ok(happyAgain?.recommendedCanonicalSkillId) + const secondPlan = await service.previewConverge({ + canonicalSkillId: happyAgain.recommendedCanonicalSkillId, + duplicateSkillIds: happyAgain.skillIds.filter( + (skillId) => skillId !== happyAgain.recommendedCanonicalSkillId, + ), + }) + const secondApplied = await service.apply(secondPlan.id) + await fs.unlink(happyDuplicate) + await fs.mkdir(happyDuplicate) + await fs.writeFile(path.join(happyDuplicate, 'SKILL.md'), 'DO NOT DELETE\n', 'utf8') + await assert.rejects( + service.previewConvergeRollback(secondApplied.recovery.transactionId!), + (error: unknown) => error instanceof ActionPlanError && error.code === 'PROJECTION_WRONG_TARGET', + ) + assert.equal(await fs.readFile(path.join(happyDuplicate, 'SKILL.md'), 'utf8'), 'DO NOT DELETE\n') + catalog = await getCatalog(true) + assert.ok(catalog.projectionIssues.some((issue) => ( + issue.status === 'wrong-target' && path.resolve(issue.targetPath) === path.resolve(happyDuplicate) + ))) + + const failureGroup = catalog.convergenceGroups.find( + (group) => group.kind === 'exact-copy' && group.name === 'Failure', + ) + assert.ok(failureGroup?.recommendedCanonicalSkillId) + let createCalls = 0 + const failingService = new ActionPlanService({ + createDirectoryProjection: async (canonicalPath, targetPath) => { + createCalls += 1 + if (createCalls === 2) throw new Error('injected projection failure') + if (process.platform === 'win32') { + await fs.symlink(canonicalPath, targetPath, 'junction') + return 'junction' + } + await fs.symlink(canonicalPath, targetPath, 'dir') + return 'symlink' + }, + }) + const failingPlan = await failingService.previewConverge({ + canonicalSkillId: failureGroup.recommendedCanonicalSkillId, + duplicateSkillIds: failureGroup.skillIds.filter( + (skillId) => skillId !== failureGroup.recommendedCanonicalSkillId, + ), + }) + await assert.rejects( + failingService.apply(failingPlan.id), + (error: unknown) => error instanceof ActionPlanError && error.code === 'CONVERGENCE_FAILED', + ) + for (const target of [failureCodex, failureClaude]) { + assert.equal((await fs.lstat(target)).isSymbolicLink(), false) + assert.equal( + await hashSkillDirectory(target), + await hashSkillDirectory(failureCanonical), + ) + } + + catalog = await getCatalog(true) + const batchOneGroup = catalog.convergenceGroups.find( + (group) => group.kind === 'exact-copy' && group.name === 'Batch One', + ) + const batchTwoGroup = catalog.convergenceGroups.find( + (group) => group.kind === 'exact-copy' && group.name === 'Batch Two', + ) + assert.ok(batchOneGroup?.recommendedCanonicalSkillId) + assert.ok(batchTwoGroup?.recommendedCanonicalSkillId) + const partialService = new ActionPlanService({ + createDirectoryProjection: async (canonicalPath, targetPath) => { + if (targetPath.includes('batch-two')) throw new Error('injected batch group failure') + if (process.platform === 'win32') { + await fs.symlink(canonicalPath, targetPath, 'junction') + return 'junction' + } + await fs.symlink(canonicalPath, targetPath, 'dir') + return 'symlink' + }, + }) + const batchPlan = await partialService.previewConvergeBatch({ + groups: [batchOneGroup, batchTwoGroup].map((group) => ({ + canonicalSkillId: group!.recommendedCanonicalSkillId!, + duplicateSkillIds: group!.skillIds.filter( + (skillId) => skillId !== group!.recommendedCanonicalSkillId, + ), + })), + }) + assert.equal(batchPlan.groupCount, 2) + assert.equal(batchPlan.permanentDeleteCount, 0) + assert.equal(batchPlan.recoveryDays, 7) + const batchResult = await partialService.applyConvergeBatch(batchPlan.id) + assert.equal(batchResult.status, 'partial') + assert.equal(batchResult.successes.length, 1) + assert.equal(batchResult.failures.length, 1) + assert.equal((await fs.lstat(batchOneDuplicate)).isSymbolicLink(), true) + assert.equal(await fs.realpath(batchOneDuplicate), await fs.realpath(batchOneCanonical)) + assert.equal((await fs.lstat(batchTwoDuplicate)).isSymbolicLink(), false) + assert.equal( + await hashSkillDirectory(batchTwoDuplicate), + await hashSkillDirectory(batchTwoCanonical), + ) + const batchRollback = await partialService.previewConvergeRollback( + batchResult.successes[0].transactionId, + ) + await partialService.apply(batchRollback.id) + assert.equal((await fs.lstat(batchOneDuplicate)).isSymbolicLink(), false) + + catalog = await getCatalog(true) + const deleteRaceGroup = catalog.convergenceGroups.find( + (group) => group.kind === 'exact-copy' && group.name === 'Delete Race', + ) + assert.ok(deleteRaceGroup?.recommendedCanonicalSkillId) + const deleteCanonical = deleteRaceGroup.skills.find( + (skill) => skill.id === deleteRaceGroup.recommendedCanonicalSkillId, + )! + const deleteCanonicalInstallation = deleteCanonical.installations.find( + (installation) => !installation.isSymlink && installation.canDeleteCanonical, + )! + const staleDeletePlan = await service.previewDelete({ + skillId: deleteCanonical.id, + installationId: deleteCanonicalInstallation.id, + }) + const deleteConvergePlan = await service.previewConverge({ + canonicalSkillId: deleteRaceGroup.recommendedCanonicalSkillId, + duplicateSkillIds: deleteRaceGroup.skillIds.filter( + (skillId) => skillId !== deleteRaceGroup.recommendedCanonicalSkillId, + ), + }) + const deleteConverged = await service.apply(deleteConvergePlan.id) + await assert.rejects( + service.apply(staleDeletePlan.id), + (error: unknown) => error instanceof ActionPlanError && error.code === 'CANONICAL_IN_USE', + ) + assert.equal((await fs.lstat(deleteRaceCanonical)).isDirectory(), true) + assert.equal((await fs.lstat(deleteRaceDuplicate)).isSymbolicLink(), true) + const deleteRollback = await service.previewConvergeRollback(deleteConverged.recovery.transactionId!) + await service.apply(deleteRollback.id) + + catalog = await getCatalog(true) + const moveRaceGroup = catalog.convergenceGroups.find( + (group) => group.kind === 'exact-copy' && group.name === 'Move Race', + ) + assert.ok(moveRaceGroup?.recommendedCanonicalSkillId) + const moveCanonical = moveRaceGroup.skills.find( + (skill) => skill.id === moveRaceGroup.recommendedCanonicalSkillId, + )! + const moveCanonicalInstallation = moveCanonical.installations.find( + (installation) => !installation.isSymlink && installation.canDeleteCanonical, + )! + const staleMovePlan = await service.previewTransfer('move', { + skillId: moveCanonical.id, + installationId: moveCanonicalInstallation.id, + targetRootId: claudeRoot.id, + }) + const moveConvergePlan = await service.previewConverge({ + canonicalSkillId: moveRaceGroup.recommendedCanonicalSkillId, + duplicateSkillIds: moveRaceGroup.skillIds.filter( + (skillId) => skillId !== moveRaceGroup.recommendedCanonicalSkillId, + ), + }) + const moveConverged = await service.apply(moveConvergePlan.id) + await assert.rejects( + service.apply(staleMovePlan.id), + (error: unknown) => error instanceof ActionPlanError && error.code === 'CANONICAL_IN_USE', + ) + assert.equal((await fs.lstat(moveRaceCanonical)).isDirectory(), true) + assert.equal((await fs.lstat(moveRaceDuplicate)).isSymbolicLink(), true) + const moveRollback = await service.previewConvergeRollback(moveConverged.recovery.transactionId!) + await service.apply(moveRollback.id) +}) diff --git a/tests/integration/current-project-scan.test.ts b/tests/integration/current-project-scan.test.ts new file mode 100644 index 0000000..18f03a3 --- /dev/null +++ b/tests/integration/current-project-scan.test.ts @@ -0,0 +1,53 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +test('current-project scan never expands an empty configured project', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-current-project-')) + t.after(async () => fs.rm(root, { recursive: true, force: true })) + + const currentProject = path.join(root, 'workspace', 'empty-project') + const unrelatedSkill = path.join( + root, + 'projects', + 'unrelated', + '.agents', + 'skills', + 'private-project-skill', + ) + const globalSkill = path.join(root, '.agents', 'skills', 'global-fixture') + await fs.mkdir(currentProject, { recursive: true }) + for (const [dir, name] of [ + [unrelatedSkill, 'Unrelated Project Skill'], + [globalSkill, 'Global Fixture'], + ]) { + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile( + path.join(dir, 'SKILL.md'), + `---\nname: ${name}\ndescription: Use when testing project isolation.\n---\n`, + ) + } + + process.env.SKILLMANAGER_FIXTURE_ROOT = root + process.env.SKILLMANAGER_DATA_DIR = path.join(root, 'data') + process.env.SKILLMANAGER_PROJECT_ROOT = currentProject + const { fullScan } = await import('../../server/scanner/discovery.js') + const result = await fullScan({ projectDiscovery: 'current-only' }) + + assert.equal(result.scanMode, 'current-project') + assert.deepEqual(result.skills.map((skill) => skill.name), ['Global Fixture']) + assert.deepEqual(result.projects, []) + assert.deepEqual(result.currentProject, { + status: 'configured_empty', + configured: true, + id: null, + name: 'empty-project', + skillCount: 0, + }) + assert.equal( + result.scannedPaths.some((report) => report.path.startsWith(path.dirname(unrelatedSkill))), + false, + ) +}) diff --git a/tests/integration/dashboard-context.test.ts b/tests/integration/dashboard-context.test.ts new file mode 100644 index 0000000..1dd9b75 --- /dev/null +++ b/tests/integration/dashboard-context.test.ts @@ -0,0 +1,100 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import Fastify from 'fastify' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +test('Dashboard context resolves current and opaque targets without path leakage', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-dashboard-context-')) + const projectRoot = path.join(root, 'projects', 'demo') + t.after(async () => fs.rm(root, { recursive: true, force: true })) + + const fixtures = [ + [path.join(root, '.agents', 'skills', 'main-skill'), 'Main Fixture', 'Use when testing dashboard links.'], + [path.join(root, '.claude', 'skills', 'conflict-one'), 'Same Name', 'short'], + [path.join(root, '.claude', 'skills', 'conflict-two'), 'Same Name', 'tiny'], + [path.join(root, '.agents', 'skills', 'exact-copy'), 'Exact Copy', 'same package'], + [path.join(root, '.codex', 'skills', 'exact-copy'), 'Exact Copy', 'same package'], + [path.join(projectRoot, '.agents', 'skills', 'project-skill'), 'Project Fixture', 'Use when testing current projects.'], + ] as const + for (const [dir, name, description] of fixtures) { + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile( + path.join(dir, 'SKILL.md'), + `---\nname: ${name}\ndescription: ${description}\n---\nSECRET_BODY\n`, + ) + } + + process.env.SKILLMANAGER_FIXTURE_ROOT = root + process.env.SKILLMANAGER_DATA_DIR = path.join(root, 'data') + process.env.SKILLMANAGER_PROJECT_ROOT = projectRoot + const { skillRoutes } = await import('../../server/routes/skills.js') + const app = Fastify() + await skillRoutes(app) + await app.ready() + t.after(() => app.close()) + + const scan = await app.inject({ method: 'GET', url: '/api/scan' }) + assert.equal(scan.statusCode, 200) + const catalog = scan.json() + const skill = catalog.skills.find((candidate: any) => candidate.name === 'Main Fixture') + const finding = catalog.health.diagnostics[0] + const group = catalog.conflicts[0] + const convergence = catalog.convergenceGroups.find( + (candidate: any) => candidate.kind === 'exact-copy' && candidate.name === 'Exact Copy', + ) + assert.match(skill.id, /^skill_[a-f0-9]{24}$/) + assert.match(finding.id, /^finding_[a-f0-9]{20}$/) + assert.match(group.id, /^group_[a-f0-9]{20}$/) + assert.match(convergence.id, /^group_[a-f0-9]{20}$/) + + const cases = [ + `/api/v1/dashboard-context?view=skills&skill=${skill.id}`, + `/api/v1/dashboard-context?view=versions&skill=${skill.id}`, + `/api/v1/dashboard-context?view=doctor&finding=${finding.id}`, + `/api/v1/dashboard-context?view=conflicts&group=${group.id}`, + `/api/v1/dashboard-context?view=convergence&group=${convergence.id}`, + '/api/v1/dashboard-context?view=current-project', + ] + for (const url of cases) { + const response = await app.inject({ method: 'GET', url }) + assert.equal(response.statusCode, 200, response.body) + const serialized = JSON.stringify(response.json()) + assert.equal(serialized.includes(root), false) + assert.equal(serialized.includes('SECRET_BODY'), false) + } + + const current = await app.inject({ + method: 'GET', + url: '/api/v1/dashboard-context?view=current-project', + }) + assert.equal(current.json().target.status, 'registered') + assert.equal(current.json().target.name, 'demo') + + for (const url of [ + `/api/v1/dashboard-context?view=skills&skill=skill_${'0'.repeat(24)}`, + `/api/v1/dashboard-context?view=doctor&finding=finding_${'0'.repeat(20)}`, + `/api/v1/dashboard-context?view=conflicts&group=group_${'0'.repeat(20)}`, + `/api/v1/dashboard-context?view=convergence&group=group_${'0'.repeat(20)}`, + ]) { + const stale = await app.inject({ method: 'GET', url }) + assert.equal(stale.statusCode, 404) + assert.equal(stale.json().code, 'TARGET_NOT_FOUND') + assert.equal(stale.json().recovery.href, '/') + } + + const invalid = await app.inject({ + method: 'GET', + url: `/api/v1/dashboard-context?view=doctor&skill=${skill.id}`, + }) + assert.equal(invalid.statusCode, 400) + assert.equal(invalid.json().code, 'INVALID_ROUTE') + + const missing = await app.inject({ + method: 'GET', + url: '/api/v1/dashboard-context?view=versions', + }) + assert.equal(missing.statusCode, 400) + assert.equal(missing.json().code, 'MISSING_TARGET') +}) diff --git a/tests/integration/fixture-scan.test.ts b/tests/integration/fixture-scan.test.ts new file mode 100644 index 0000000..995c0b0 --- /dev/null +++ b/tests/integration/fixture-scan.test.ts @@ -0,0 +1,175 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +test('fullScan reads only the explicit fixture and project roots', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-scan-')) + t.after(async () => fs.rm(root, { recursive: true, force: true })) + + const projectRoot = path.join(root, 'projects', 'demo') + const otherProjectRoot = path.join(root, 'projects', 'other') + const codexPluginRoot = path.join( + root, + '.codex', + 'plugins', + 'cache', + 'personal', + 'demo-plugin', + '1.0.0', + ) + const unlistedPluginRoot = path.join( + root, + '.codex', + 'plugins', + 'cache', + 'personal', + 'unlisted-plugin', + '2.0.0', + ) + const skills = [ + [path.join(root, '.agents', 'skills', 'shared-skill'), 'Shared Skill'], + [path.join(root, '.codex', 'skills', 'legacy-codex'), 'Legacy Codex'], + [path.join(projectRoot, '.agents', 'skills', 'project-skill'), 'Project Skill'], + [path.join(otherProjectRoot, '.agents', 'skills', 'other-project-skill'), 'Other Project Skill'], + [path.join(codexPluginRoot, 'skills', 'plugin-skill'), 'Codex Plugin Skill'], + [path.join(unlistedPluginRoot, 'skills', 'hidden-skill'), 'Unlisted Plugin Skill'], + ] as const + for (const [dir, name] of skills) { + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile( + path.join(dir, 'SKILL.md'), + `---\nname: ${name}\ndescription: fixture\n---\nFixture only.\n`, + 'utf8', + ) + } + for (const [pluginRoot, name, version] of [ + [codexPluginRoot, 'demo-plugin', '1.0.0'], + [unlistedPluginRoot, 'unlisted-plugin', '2.0.0'], + ] as const) { + await fs.mkdir(path.join(pluginRoot, '.codex-plugin'), { recursive: true }) + await fs.writeFile( + path.join(pluginRoot, '.codex-plugin', 'plugin.json'), + JSON.stringify({ name, version, skills: './skills/' }), + 'utf8', + ) + } + const codexPluginListPath = path.join(root, 'codex-plugin-list.json') + await fs.writeFile( + codexPluginListPath, + JSON.stringify({ + installed: [ + { + pluginId: 'demo-plugin@personal', + name: 'demo-plugin', + marketplaceName: 'personal', + version: '1.0.0', + installed: true, + enabled: true, + }, + { + pluginId: 'unlisted-plugin@personal', + name: 'unlisted-plugin', + marketplaceName: 'personal', + version: '2.0.0', + installed: true, + enabled: false, + }, + ], + }), + 'utf8', + ) + await fs.symlink( + path.join(root, '.agents', 'skills', 'shared-skill'), + path.join(root, '.codex', 'skills', 'shared-alias'), + ) + const outsideDir = path.join(root, 'outside-skill') + await fs.mkdir(outsideDir, { recursive: true }) + await fs.writeFile( + path.join(outsideDir, 'SKILL.md'), + '---\nname: Outside Target\ndescription: fixture\n---\nRead only.\n', + 'utf8', + ) + await fs.symlink( + outsideDir, + path.join(root, '.agents', 'skills', 'outside-link'), + ) + await fs.symlink( + outsideDir, + path.join(codexPluginRoot, 'skills', 'escaped-plugin-skill'), + ) + + process.env.SKILLMANAGER_FIXTURE_ROOT = root + process.env.SKILLMANAGER_DATA_DIR = path.join(root, 'data') + process.env.SKILLMANAGER_PROJECT_ROOT = projectRoot + process.env.SKILLMANAGER_CODEX_PLUGIN_LIST_PATH = codexPluginListPath + const { fullScan } = await import('../../server/scanner/discovery.js') + const result = await fullScan() + + assert.deepEqual( + result.skills.map((skill) => skill.name).sort(), + ['Codex Plugin Skill', 'Legacy Codex', 'Other Project Skill', 'Outside Target', 'Project Skill', 'Shared Skill'], + ) + assert.equal(result.scanMode, 'all') + assert.equal(result.currentProject.status, 'registered') + assert.equal(result.currentProject.name, 'demo') + assert.match(result.currentProject.id || '', /^project_[a-f0-9]{20}$/) + assert.ok(result.health.diagnostics.every((finding) => /^finding_[a-f0-9]{20}$/.test(finding.id))) + const shared = result.skills.find((skill) => skill.name === 'Shared Skill') + assert.ok(shared) + assert.equal(shared.installations.length, 2) + assert.equal(shared.exposedTo.includes('codex'), true) + assert.equal(shared.editable, true) + assert.match(shared.id, /^skill_[a-f0-9]{24}$/) + + const pluginSkill = result.skills.find((skill) => skill.name === 'Codex Plugin Skill') + assert.ok(pluginSkill) + assert.equal(pluginSkill.scope, 'plugin') + assert.equal(pluginSkill.source, 'plugin') + assert.equal(pluginSkill.agent, 'codex') + assert.deepEqual(pluginSkill.exposedTo, ['codex']) + assert.equal(pluginSkill.installations[0]?.exposedTo[0]?.reason, 'plugin') + assert.equal(pluginSkill.editable, false) + assert.equal(pluginSkill.readOnlyReason, 'plugin_or_unmanaged_root') + assert.equal(result.skills.some((skill) => skill.name === 'Unlisted Plugin Skill'), false) + + const outside = result.skills.find((skill) => skill.name === 'Outside Target') + assert.ok(outside) + assert.equal(outside.editable, false) + assert.equal(outside.readOnlyReason, 'symlink_target_outside_authorized_root') + assert.equal(outside.installations.some((installation) => installation.scope === 'plugin'), false) + + const projectSkill = result.skills.find((skill) => skill.name === 'Project Skill') + assert.ok(projectSkill) + assert.equal(projectSkill.installations[0]?.projectId, result.currentProject.id) + + const currentOnly = await fullScan({ projectDiscovery: 'current-only' }) + assert.equal(currentOnly.scanMode, 'current-project') + assert.equal(currentOnly.currentProject.status, 'registered') + assert.equal(currentOnly.skills.some((skill) => skill.name === 'Project Skill'), true) + assert.equal(currentOnly.skills.some((skill) => skill.name === 'Other Project Skill'), false) + assert.deepEqual(currentOnly.projects.map((project) => project.name), ['demo']) + assert.equal( + currentOnly.scannedPaths.some((report) => report.path.startsWith(otherProjectRoot + path.sep)), + false, + ) + for (const report of result.scannedPaths) { + const relative = path.relative(root, report.path) + assert.equal(relative === '..' || relative.startsWith('..' + path.sep), false) + } + + const { authorizeSkillWrite, SkillAccessError } = await import( + '../../server/core/authorization.js' + ) + const authorized = await authorizeSkillWrite(shared.id) + assert.equal(authorized.contentHash, shared.contentHash) + await assert.rejects( + authorizeSkillWrite(outside.id), + (error: unknown) => error instanceof SkillAccessError && error.code === 'INSTALLATION_NOT_FOUND', + ) + await assert.rejects( + authorizeSkillWrite('/tmp'), + (error: unknown) => error instanceof SkillAccessError && error.code === 'INVALID_SKILL_ID', + ) +}) diff --git a/tests/integration/http-security.test.ts b/tests/integration/http-security.test.ts new file mode 100644 index 0000000..ee73e91 --- /dev/null +++ b/tests/integration/http-security.test.ts @@ -0,0 +1,109 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import Fastify from 'fastify' +import { registerHttpSecurity } from '../../server/security/http.js' +import { SessionManager } from '../../server/security/session.js' + +async function securedApp() { + const app = Fastify() + const sessions = new SessionManager() + const controlToken = 'fixture-control-token-that-is-long-enough' + await registerHttpSecurity(app, sessions, controlToken) + app.get('/api/health', async () => ({ status: 'ok' })) + app.get('/api/private', async () => ({ private: true })) + app.post('/api/mutate', async () => ({ mutated: true })) + await app.ready() + return { app, sessions, controlToken } +} + +function cookieFrom(setCookie: string | string[] | undefined): string { + const value = Array.isArray(setCookie) ? setCookie[0] : setCookie + assert.ok(value) + return value.split(';')[0] +} + +test('invalid Host and unauthenticated business API requests are rejected', async (t) => { + const { app } = await securedApp() + t.after(() => app.close()) + + const rebound = await app.inject({ + method: 'GET', + url: '/api/health', + headers: { host: 'attacker.example' }, + }) + assert.equal(rebound.statusCode, 421) + + const health = await app.inject({ method: 'GET', url: '/api/health' }) + assert.equal(health.statusCode, 200) + assert.equal(health.headers['access-control-allow-origin'], undefined) + + const privateResponse = await app.inject({ method: 'GET', url: '/api/private' }) + assert.equal(privateResponse.statusCode, 401) +}) + +test('launch nonce creates one local session and cross-origin writes still fail', async (t) => { + const { app, sessions } = await securedApp() + t.after(() => app.close()) + const nonce = sessions.createLaunchNonce('/?view=current-project') + + const launch = await app.inject({ method: 'GET', url: `/launch/${nonce}` }) + assert.equal(launch.statusCode, 303) + assert.equal(launch.headers.location, '/?view=current-project') + const cookie = cookieFrom(launch.headers['set-cookie']) + + const reused = await app.inject({ method: 'GET', url: `/launch/${nonce}` }) + assert.equal(reused.statusCode, 410) + + const privateResponse = await app.inject({ + method: 'GET', + url: '/api/private', + headers: { cookie }, + }) + assert.equal(privateResponse.statusCode, 200) + + const crossOrigin = await app.inject({ + method: 'POST', + url: '/api/mutate', + headers: { + cookie, + origin: 'https://attacker.example', + }, + }) + assert.equal(crossOrigin.statusCode, 403) + + const sameOrigin = await app.inject({ + method: 'POST', + url: '/api/mutate', + headers: { + cookie, + host: '127.0.0.1:3456', + origin: 'http://127.0.0.1:3456', + }, + }) + assert.equal(sameOrigin.statusCode, 200) +}) + +test('local control launch requires its file-bound bearer token', async (t) => { + const { app, controlToken } = await securedApp() + t.after(() => app.close()) + + const denied = await app.inject({ + method: 'POST', + url: '/api/v1/control/launch', + payload: { target: '/?view=current-project' }, + }) + assert.equal(denied.statusCode, 401) + + const allowed = await app.inject({ + method: 'POST', + url: '/api/v1/control/launch', + headers: { authorization: `Bearer ${controlToken}` }, + payload: { target: '/?view=current-project' }, + }) + assert.equal(allowed.statusCode, 200) + const launchUrl = allowed.json().launchUrl as string + const launchPath = new URL(launchUrl).pathname + const consumed = await app.inject({ method: 'GET', url: launchPath }) + assert.equal(consumed.statusCode, 303) + assert.equal(consumed.headers.location, '/?view=current-project') +}) diff --git a/tests/integration/legacy-write-blocked.test.ts b/tests/integration/legacy-write-blocked.test.ts new file mode 100644 index 0000000..8cbdd87 --- /dev/null +++ b/tests/integration/legacy-write-blocked.test.ts @@ -0,0 +1,47 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import Fastify from 'fastify' + +test('legacy path-based write routes fail closed without touching the supplied path', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-legacy-write-')) + t.after(async () => fs.rm(root, { recursive: true, force: true })) + process.env.SKILLMANAGER_FIXTURE_ROOT = root + process.env.SKILLMANAGER_DATA_DIR = path.join(root, 'data') + const sentinel = path.join(root, 'sentinel.txt') + await fs.writeFile(sentinel, 'unchanged\n', 'utf8') + + const { manageRoutes } = await import('../../server/routes/manage.js') + const { registerHttpSecurity } = await import('../../server/security/http.js') + const { SessionManager } = await import('../../server/security/session.js') + const app = Fastify() + const sessions = new SessionManager() + await registerHttpSecurity(app, sessions, 'fixture-control-token-that-is-long-enough') + await app.register(manageRoutes) + await app.ready() + t.after(() => app.close()) + + const nonce = sessions.createLaunchNonce('/') + const launch = await app.inject({ method: 'GET', url: `/launch/${nonce}` }) + const setCookie = launch.headers['set-cookie'] + assert.ok(typeof setCookie === 'string') + const cookie = setCookie.split(';')[0] + const headers = { + cookie, + host: '127.0.0.1:3456', + origin: 'http://127.0.0.1:3456', + 'content-type': 'application/json', + } + + const response = await app.inject({ + method: 'PUT', + url: '/api/skills/not-an-id/content', + headers, + payload: { realPath: root, path: sentinel, content: 'changed' }, + }) + assert.equal(response.statusCode, 410) + assert.equal(response.json().code, 'PREVIEW_REQUIRED') + assert.equal(await fs.readFile(sentinel, 'utf8'), 'unchanged\n') +}) diff --git a/tests/integration/lifecycle-cli.test.ts b/tests/integration/lifecycle-cli.test.ts new file mode 100644 index 0000000..e8bba78 --- /dev/null +++ b/tests/integration/lifecycle-cli.test.ts @@ -0,0 +1,340 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import net from 'node:net' +import crypto from 'node:crypto' + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(testDirectory, '../..') +const cliPath = path.join(repoRoot, 'bin', 'cli.js') +const sourceServer = path.join(repoRoot, 'server', 'index.ts') +const tsxImport = import.meta.resolve('tsx') +const packageVersion = JSON.parse( + await fs.readFile(path.join(repoRoot, 'package.json'), 'utf8'), +).version as string + +async function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + const port = typeof address === 'object' && address ? address.port : 0 + server.close((error) => error ? reject(error) : resolve(port)) + }) + }) +} + +async function runCli(args: string[], env: NodeJS.ProcessEnv) { + return new Promise<{ code: number; stdout: string; stderr: string; json: any }>((resolve, reject) => { + const child = spawn(process.execPath, [cliPath, ...args], { + cwd: repoRoot, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk) => { stdout += chunk.toString() }) + child.stderr.on('data', (chunk) => { stderr += chunk.toString() }) + child.once('error', reject) + child.once('close', (code) => { + let json = null + try { json = JSON.parse(stdout.trim()) } catch {} + resolve({ code: code ?? -1, stdout, stderr, json }) + }) + }) +} + +async function openStubbornWebSocket(baseUrl: string, cookie: string): Promise { + const url = new URL(baseUrl) + const socket = net.createConnection({ + host: url.hostname, + port: Number(url.port), + }) + await new Promise((resolve, reject) => { + socket.once('connect', resolve) + socket.once('error', reject) + }) + socket.write([ + 'GET /ws HTTP/1.1', + `Host: ${url.host}`, + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Key: ${crypto.randomBytes(16).toString('base64')}`, + 'Sec-WebSocket-Version: 13', + `Cookie: ${cookie}`, + '', + '', + ].join('\r\n')) + + await new Promise((resolve, reject) => { + let response = '' + const timer = setTimeout(() => reject(new Error('WebSocket upgrade timed out')), 2_000) + const onData = (chunk: Buffer) => { + response += chunk.toString('latin1') + if (!response.includes('\r\n\r\n')) return + clearTimeout(timer) + socket.off('data', onData) + if (!response.startsWith('HTTP/1.1 101')) { + reject(new Error(`WebSocket upgrade failed: ${response.split('\r\n')[0]}`)) + return + } + resolve() + } + socket.on('data', onData) + socket.once('error', (error) => { + clearTimeout(timer) + reject(error) + }) + }) + return socket +} + +test('lifecycle CLI is idempotent, validates launch nonces, and handles stale PIDs', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-lifecycle-')) + const fixture = path.join(root, 'fixture') + const data = path.join(root, 'data') + await fs.mkdir(fixture, { recursive: true }) + const largeNestedAssetDir = path.join( + fixture, + '.agents', + 'skills', + 'watcher-fixture', + 'downloads', + ) + await fs.mkdir(largeNestedAssetDir, { recursive: true }) + await fs.writeFile( + path.join(path.dirname(largeNestedAssetDir), 'SKILL.md'), + '---\nname: Watcher Fixture\ndescription: Exercise managed shutdown while a real Skill root exists.\n---\n', + ) + await Promise.all(Array.from({ length: 300 }, (_, index) => ( + fs.writeFile(path.join(largeNestedAssetDir, `asset-${index}.txt`), 'fixture') + ))) + const env = { + ...process.env, + SKILLMANAGER_FIXTURE_ROOT: fixture, + SKILLMANAGER_DATA_DIR: data, + SKILLMANAGER_SERVER_ENTRY: sourceServer, + SKILLMANAGER_SERVER_NODE_ARGS: JSON.stringify(['--import', tsxImport]), + SKILLMANAGER_TEST_MODE: '1', + } + + const versionResult = await runCli(['version', '--json'], env) + assert.deepEqual(versionResult.json, { + schemaVersion: 1, + product: 'SkillManager', + version: packageVersion, + }) + + t.after(async () => { + await runCli(['stop', '--json'], env).catch(() => {}) + await fs.rm(root, { recursive: true, force: true }) + }) + + const [firstStart, secondStart] = await Promise.all([ + runCli(['start', '--json', '--project', fixture], env), + runCli(['start', '--json', '--project', fixture], env), + ]) + const serverLog = async () => fs.readFile(path.join(data, 'runtime', 'server.log'), 'utf8').catch(() => '') + assert.equal(firstStart.code, 0, `${firstStart.stderr || firstStart.stdout}\n${await serverLog()}`) + assert.equal(secondStart.code, 0, `${secondStart.stderr || secondStart.stdout}\n${await serverLog()}`) + assert.equal(firstStart.json.pid, secondStart.json.pid) + assert.equal(firstStart.json.projectConfigured, true) + assert.deepEqual( + [firstStart.json.reused, secondStart.json.reused].sort(), + [false, true], + ) + + const status = await runCli(['status', '--json'], env) + assert.equal(status.json.status, 'running') + assert.equal(status.json.pid, firstStart.json.pid) + assert.equal('controlToken' in status.json, false) + if (process.platform !== 'win32') { + const stateMode = (await fs.stat(path.join(data, 'runtime', 'state.json'))).mode & 0o777 + assert.equal(stateMode, 0o600) + } + + const doctor = await runCli(['doctor', '--json'], env) + assert.equal(doctor.json.schemaVersion, 1) + assert.deepEqual( + new Set(doctor.json.checks.map((check: { id: string }) => check.id)), + new Set(['node', 'git', 'release-files', 'data-directory', 'runtime', 'scanner', 'port']), + ) + assert.equal(JSON.stringify(doctor.json).includes('fixture-control-token'), false) + + const opened = await runCli(['open', '--json'], env) + assert.equal(opened.code, 0, opened.stderr || opened.stdout) + assert.match(opened.json.launchUrl, /^http:\/\/127\.0\.0\.1:\d+\/launch\/[A-Za-z0-9_-]+$/) + const firstUse = await fetch(opened.json.launchUrl, { redirect: 'manual' }) + assert.equal(firstUse.status, 303) + assert.match(firstUse.headers.get('set-cookie') || '', /HttpOnly/) + const sessionCookie = (firstUse.headers.get('set-cookie') || '').split(';')[0] + // Deliberately complete the WebSocket upgrade without implementing the + // protocol. This peer never answers a close frame, reproducing a browser or + // crashed client that would otherwise hold graceful shutdown for ~30s. + const socket = await openStubbornWebSocket(opened.json.baseUrl, sessionCookie) + t.after(() => socket.destroy()) + const replay = await fetch(opened.json.launchUrl, { redirect: 'manual' }) + assert.equal(replay.status, 410) + + const socketClosed = new Promise((resolve) => socket.once('close', () => resolve())) + const stopStartedAt = Date.now() + const stopped = await runCli(['stop', '--json'], env) + assert.equal(stopped.code, 0, stopped.stderr || stopped.stdout) + assert.equal(stopped.json.stopped, true) + assert.ok(Date.now() - stopStartedAt < 5_000, 'managed stop should not wait on a stubborn WebSocket') + await Promise.race([ + socketClosed, + new Promise((_, reject) => setTimeout(() => reject(new Error('WebSocket was not terminated')), 2_000)), + ]) + assert.equal(socket.destroyed, true) + assert.equal((await runCli(['status', '--json'], env)).json.status, 'stopped') + + const runtimeDirectory = path.join(data, 'runtime') + const runtimeState = path.join(runtimeDirectory, 'state.json') + await fs.mkdir(runtimeDirectory, { recursive: true }) + await fs.writeFile(runtimeState, JSON.stringify({ + schemaVersion: 1, + product: 'SkillManager', + version: packageVersion, + pid: 999_999, + host: '127.0.0.1', + port: 65431, + baseUrl: 'http://127.0.0.1:65431', + startedAt: new Date().toISOString(), + instanceId: 'instance_stale_fixture_1234', + processIdentity: `skillmanager:${packageVersion}:instance_stale_fixture_1234`, + managed: true, + controlToken: 'fixture-control-token-that-is-long-enough', + projectConfigured: false, + capabilities: ['scan'], + })) + const stale = await runCli(['status', '--json'], env) + assert.equal(stale.json.status, 'stopped') + assert.equal(stale.json.reason, 'stale_pid') + const restarted = await runCli(['start', '--json'], env) + assert.equal(restarted.code, 0, restarted.stderr || restarted.stdout) + assert.equal(restarted.json.reused, false) + await runCli(['stop', '--json'], env) + + await fs.writeFile(runtimeState, JSON.stringify({ + schemaVersion: 1, + product: 'SkillManager', + version: packageVersion, + pid: process.pid, + host: '127.0.0.1', + port: 65432, + baseUrl: 'http://127.0.0.1:65432', + startedAt: new Date().toISOString(), + instanceId: 'instance_live_unverified_1234', + processIdentity: `skillmanager:${packageVersion}:instance_live_unverified_1234`, + managed: true, + controlToken: 'fixture-control-token-that-is-long-enough', + projectConfigured: false, + capabilities: ['scan'], + })) + const refused = await runCli(['stop', '--json'], env) + assert.equal(refused.code, 1) + assert.equal(refused.json.code, 'STOP_IDENTITY_UNVERIFIED') + await fs.unlink(runtimeState) +}) + +test('a new explicit project replaces the verified managed context without a second instance', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-project-switch-')) + const fixture = path.join(root, 'fixture') + const data = path.join(root, 'data') + const firstProject = path.join(fixture, 'first-project') + const secondProject = path.join(fixture, 'second-project') + await fs.mkdir(firstProject, { recursive: true }) + await fs.mkdir(secondProject, { recursive: true }) + const env = { + ...process.env, + SKILLMANAGER_FIXTURE_ROOT: fixture, + SKILLMANAGER_DATA_DIR: data, + SKILLMANAGER_SERVER_ENTRY: sourceServer, + SKILLMANAGER_SERVER_NODE_ARGS: JSON.stringify(['--import', tsxImport]), + SKILLMANAGER_TEST_MODE: '1', + } + t.after(async () => { + await runCli(['stop', '--json'], env).catch(() => {}) + await fs.rm(root, { recursive: true, force: true }) + }) + + const first = await runCli(['start', '--json', '--project', firstProject], env) + assert.equal(first.code, 0, first.stderr || first.stdout) + const second = await runCli(['start', '--json', '--project', secondProject], env) + assert.equal(second.code, 0, second.stderr || second.stdout) + assert.equal(second.json.reused, false) + assert.notEqual(second.json.processIdentity, first.json.processIdentity) + assert.equal('projectRoot' in second.json, false) + + const privateState = JSON.parse( + await fs.readFile(path.join(data, 'runtime', 'state.json'), 'utf8'), + ) + assert.equal(privateState.projectRoot, await fs.realpath(secondProject)) + const status = await runCli(['status', '--json'], env) + assert.equal(status.json.processIdentity, second.json.processIdentity) + assert.equal('projectRoot' in status.json, false) +}) + +test('foreground serve is healthy but cannot be stopped as a managed daemon', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-serve-')) + const fixture = path.join(root, 'fixture') + const data = path.join(root, 'data') + const port = await freePort() + await fs.mkdir(fixture, { recursive: true }) + const env = { + ...process.env, + SKILLMANAGER_FIXTURE_ROOT: fixture, + SKILLMANAGER_DATA_DIR: data, + SKILLMANAGER_SERVER_ENTRY: sourceServer, + SKILLMANAGER_SERVER_NODE_ARGS: JSON.stringify(['--import', tsxImport]), + SKILLMANAGER_TEST_MODE: '1', + } + const child = spawn(process.execPath, [ + '--import', + tsxImport, + cliPath, + 'serve', + '--port', + String(port), + '--foreground', + ], { + cwd: repoRoot, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let output = '' + child.stdout.on('data', (chunk) => { output += chunk.toString() }) + child.stderr.on('data', (chunk) => { output += chunk.toString() }) + t.after(async () => { + if (child.exitCode === null) child.kill('SIGTERM') + await fs.rm(root, { recursive: true, force: true }) + }) + + const deadline = Date.now() + 15_000 + let status: Awaited> | null = null + while (Date.now() < deadline) { + status = await runCli(['status', '--json'], env) + if (status.json?.status === 'running') break + if (child.exitCode !== null) assert.fail(`serve exited early: ${output}`) + await new Promise((resolve) => setTimeout(resolve, 100)) + } + assert.equal(status?.json.status, 'running', output) + assert.equal(status?.json.managed, false) + assert.equal(status?.json.port, port) + + const refused = await runCli(['stop', '--json'], env) + assert.equal(refused.code, 1) + assert.equal(refused.json.code, 'UNMANAGED_INSTANCE') + assert.equal(child.exitCode, null) + + child.kill('SIGTERM') + await new Promise((resolve) => child.once('close', () => resolve())) + const stopped = await runCli(['status', '--json'], env) + assert.equal(stopped.json.status, 'stopped') +}) diff --git a/tests/integration/mcp-tools.test.ts b/tests/integration/mcp-tools.test.ts new file mode 100644 index 0000000..d0f27df --- /dev/null +++ b/tests/integration/mcp-tools.test.ts @@ -0,0 +1,213 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { Client, InMemoryTransport } from '@modelcontextprotocol/client' + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(testDirectory, '../..') +const cliPath = path.join(repoRoot, 'bin', 'cli.js') +const sourceServer = path.join(repoRoot, 'server', 'index.ts') +const tsxImport = import.meta.resolve('tsx') + +test('P0 MCP tools are structured, redacted, paginated, and browser-safe', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-mcp-')) + const fixture = path.join(root, 'fixture') + const data = path.join(root, 'data') + const skillDir = path.join(fixture, '.agents', 'skills', 'mcp-fixture') + await fs.mkdir(skillDir, { recursive: true }) + await fs.writeFile(path.join(skillDir, 'SKILL.md'), [ + '---', + 'name: MCP Fixture', + 'description: Use when validating the SkillManager MCP fixture.', + '---', + 'TOP_SECRET_SKILL_BODY', + '', + ].join('\n')) + for (const [directory, description] of [ + ['conflict-one', 'short'], + ['conflict-two', 'tiny'], + ]) { + const conflictDir = path.join(fixture, '.claude', 'skills', directory) + await fs.mkdir(conflictDir, { recursive: true }) + await fs.writeFile(path.join(conflictDir, 'SKILL.md'), [ + '---', + 'name: MCP Conflict', + `description: ${description}`, + '---', + `Conflict body ${directory}`, + '', + ].join('\n')) + } + + const env = { + ...process.env, + SKILLMANAGER_FIXTURE_ROOT: fixture, + SKILLMANAGER_DATA_DIR: data, + SKILLMANAGER_SERVER_ENTRY: sourceServer, + SKILLMANAGER_SERVER_NODE_ARGS: JSON.stringify(['--import', tsxImport]), + SKILLMANAGER_TEST_MODE: '1', + } + Object.assign(process.env, env) + + const { createSkillManagerMcpServer } = await import('../../server/mcp/server.js') + const server = createSkillManagerMcpServer() + const client = new Client({ name: 'skillmanager-test-client', version: '1.0.0' }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]) + + t.after(async () => { + spawnSync(process.execPath, [cliPath, 'stop', '--json'], { + cwd: repoRoot, + env, + stdio: 'ignore', + }) + await client.close().catch(() => {}) + await server.close().catch(() => {}) + await fs.rm(root, { recursive: true, force: true }) + }) + + const tools = await client.listTools() + assert.deepEqual( + tools.tools.map((tool) => tool.name).sort(), + [ + 'diagnose_library', + 'diagnose_skill', + 'get_deep_link', + 'get_skill_summary', + 'get_status', + 'list_skills', + 'open_dashboard', + ], + ) + assert.equal(tools.tools.find((tool) => tool.name === 'list_skills')?.annotations?.readOnlyHint, true) + assert.equal(tools.tools.find((tool) => tool.name === 'open_dashboard')?.annotations?.destructiveHint, false) + + const stopped = await client.callTool({ + name: 'get_status', + arguments: { response_format: 'json' }, + }) + assert.equal((stopped.structuredContent as { status?: string })?.status, 'stopped') + + const listed = await client.callTool({ + name: 'list_skills', + arguments: { agent: 'codex', limit: 10, offset: 0, response_format: 'json' }, + }) + assert.equal(listed.isError, undefined) + const listing = listed.structuredContent as { + total_count: number + items: { id: string; name: string }[] + has_more: boolean + } + assert.equal(listing.total_count, 1) + assert.equal(listing.items[0].name, 'MCP Fixture') + assert.match(listing.items[0].id, /^skill_[a-f0-9]{24}$/) + assert.equal(listing.has_more, false) + const listedJson = JSON.stringify(listed.structuredContent) + assert.equal(listedJson.includes(root), false) + assert.equal(listedJson.includes('TOP_SECRET_SKILL_BODY'), false) + assert.equal(listedJson.includes('realPath'), false) + + const summary = await client.callTool({ + name: 'get_skill_summary', + arguments: { skill_id: listing.items[0].id, response_format: 'json' }, + }) + const summaryJson = JSON.stringify(summary.structuredContent) + assert.equal(summaryJson.includes(root), false) + assert.equal(summaryJson.includes('TOP_SECRET_SKILL_BODY'), false) + assert.equal(summaryJson.includes('symlinkTarget'), false) + + const diagnosis = await client.callTool({ + name: 'diagnose_skill', + arguments: { skill_id: listing.items[0].id, response_format: 'json' }, + }) + const diagnosisOutput = diagnosis.structuredContent as { + confirmed: unknown[] + likely: unknown[] + needs_runtime_test: unknown[] + blocked: unknown[] + } + assert.ok(Array.isArray(diagnosisOutput.confirmed)) + assert.ok(Array.isArray(diagnosisOutput.likely)) + assert.ok(diagnosisOutput.needs_runtime_test.length > 0) + assert.ok(Array.isArray(diagnosisOutput.blocked)) + + const libraryDiagnosis = await client.callTool({ + name: 'diagnose_library', + arguments: { limit: 20, offset: 0, response_format: 'json' }, + }) + const libraryOutput = libraryDiagnosis.structuredContent as { + findings: { items: { id: string }[] } + conflictGroups: { id: string }[] + } + assert.match(libraryOutput.findings.items[0].id, /^finding_[a-f0-9]{20}$/) + assert.match(libraryOutput.conflictGroups[0].id, /^group_[a-f0-9]{20}$/) + + const invalidCombination = await client.callTool({ + name: 'get_deep_link', + arguments: { + view: 'doctor', + skill_id: listing.items[0].id, + response_format: 'json', + }, + }) + assert.equal(invalidCombination.isError, true) + + const staleGroup = await client.callTool({ + name: 'get_deep_link', + arguments: { + view: 'conflicts', + group_id: `group_${'0'.repeat(20)}`, + response_format: 'json', + }, + }) + assert.equal(staleGroup.isError, true) + + const opened = await client.callTool({ + name: 'open_dashboard', + arguments: { view: 'skills', skill_id: listing.items[0].id, response_format: 'json' }, + }) + const openedOutput = opened.structuredContent as { + launchUrl: string + target: string + expiresInSeconds: number + } + assert.equal(openedOutput.target, `/?view=skills&skill=${listing.items[0].id}`) + assert.equal(openedOutput.expiresInSeconds, 60) + const dashboardLink = opened.content.find((item) => item.type === 'resource_link') + assert.equal(dashboardLink?.type, 'resource_link') + if (dashboardLink?.type === 'resource_link') { + assert.equal(dashboardLink.uri, openedOutput.launchUrl) + assert.equal(dashboardLink.name, 'skillmanager-dashboard') + assert.equal(dashboardLink.mimeType, 'text/html') + } + const launch = await fetch(openedOutput.launchUrl, { redirect: 'manual' }) + assert.equal(launch.status, 303) + assert.equal(launch.headers.get('location'), openedOutput.target) + + const findingLink = await client.callTool({ + name: 'get_deep_link', + arguments: { + view: 'doctor', + finding_id: libraryOutput.findings.items[0].id, + response_format: 'json', + }, + }) + assert.equal(findingLink.isError, undefined) + assert.equal( + (findingLink.structuredContent as { target: string }).target, + `/?view=doctor&finding=${libraryOutput.findings.items[0].id}`, + ) + + const invalid = await client.callTool({ + name: 'get_skill_summary', + arguments: { skill_id: '../../etc/passwd', response_format: 'json' }, + }) + assert.equal(invalid.isError, true) +}) diff --git a/tests/integration/migration.test.ts b/tests/integration/migration.test.ts new file mode 100644 index 0000000..5abf75c --- /dev/null +++ b/tests/integration/migration.test.ts @@ -0,0 +1,33 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +test('legacy data is copied and never removed', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-migration-')) + t.after(async () => fs.rm(root, { recursive: true, force: true })) + const data = path.join(root, 'new-data') + process.env.SKILLMANAGER_FIXTURE_ROOT = root + process.env.SKILLMANAGER_DATA_DIR = data + + const legacyVersion = path.join(root, '.skill-hub', 'versions', 'abc', 'v1.json') + const legacyConfig = path.join(root, '.config', 'skill-hub', 'credentials.json') + await fs.mkdir(path.dirname(legacyVersion), { recursive: true }) + await fs.mkdir(path.dirname(legacyConfig), { recursive: true }) + await fs.writeFile(legacyVersion, '{"fixture":true}\n', 'utf8') + await fs.writeFile(legacyConfig, '{"token":"fake-test-token"}\n', 'utf8') + + const { migrateLegacyData } = await import('../../server/config/migration.js') + const report = await migrateLegacyData() + assert.equal(report.legacyDataPreserved, true) + assert.equal(report.entries.find((entry) => entry.label === 'versions')?.status, 'copied') + assert.equal(report.entries.find((entry) => entry.label === 'sync-config')?.status, 'copied') + assert.equal(await fs.readFile(legacyVersion, 'utf8'), '{"fixture":true}\n') + assert.equal( + await fs.readFile(path.join(data, 'versions', 'abc', 'v1.json'), 'utf8'), + '{"fixture":true}\n', + ) + const manifest = JSON.parse(await fs.readFile(path.join(data, 'migration-v1.json'), 'utf8')) + assert.equal(JSON.stringify(manifest).includes('fake-test-token'), false) +}) diff --git a/tests/integration/sync-download.test.ts b/tests/integration/sync-download.test.ts new file mode 100644 index 0000000..bed4a8d --- /dev/null +++ b/tests/integration/sync-download.test.ts @@ -0,0 +1,69 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +async function writeRemote(vault: string, key: string, body: string) { + const dir = path.join(vault, ...key.split('/')) + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile( + path.join(dir, 'SKILL.md'), + `---\nname: ${key.split('/')[1]}\ndescription: remote\n---\n${body}\n`, + 'utf8', + ) +} + +test('sync download replaces through staging and keeps a recoverable trash entry', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-sync-')) + t.after(async () => fs.rm(root, { recursive: true, force: true })) + process.env.SKILLMANAGER_FIXTURE_ROOT = root + process.env.SKILLMANAGER_DATA_DIR = path.join(root, 'data') + const vault = path.join(root, 'vault') + await writeRemote(vault, 'codex/demo', 'Remote body.') + + const target = path.join(root, '.agents', 'skills', 'demo') + await fs.mkdir(target, { recursive: true }) + await fs.writeFile(path.join(target, 'SKILL.md'), '# Local body.\n', 'utf8') + + const { applyDownloadFromVault, localTargetDir } = await import('../../server/sync/download.js') + const { listTrash } = await import('../../server/trash/store.js') + assert.equal(localTargetDir('unknown', 'demo'), null) + assert.equal(localTargetDir('codex', 'demo'), target) + + const result = await applyDownloadFromVault(vault, ['codex/demo']) + assert.equal(result.ok, true) + assert.match(await fs.readFile(path.join(target, 'SKILL.md'), 'utf8'), /Remote body/) + assert.equal((await listTrash()).length, 1) +}) + +test('an injected mid-download failure restores every original target', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-sync-rollback-')) + t.after(async () => fs.rm(root, { recursive: true, force: true })) + process.env.SKILLMANAGER_FIXTURE_ROOT = root + process.env.SKILLMANAGER_DATA_DIR = path.join(root, 'data') + const vault = path.join(root, 'vault') + await writeRemote(vault, 'codex/one', 'Remote one.') + await writeRemote(vault, 'codex/two', 'Remote two.') + + for (const name of ['one', 'two']) { + const target = path.join(root, '.agents', 'skills', name) + await fs.mkdir(target, { recursive: true }) + await fs.writeFile(path.join(target, 'SKILL.md'), `# Local ${name}.\n`, 'utf8') + } + + const { applyDownloadFromVault } = await import('../../server/sync/download.js') + const result = await applyDownloadFromVault( + vault, + ['codex/one', 'codex/two'], + { failAfterApplied: 1 }, + ) + assert.equal(result.ok, false) + for (const name of ['one', 'two']) { + const content = await fs.readFile( + path.join(root, '.agents', 'skills', name, 'SKILL.md'), + 'utf8', + ) + assert.equal(content, `# Local ${name}.\n`) + } +}) diff --git a/tests/integration/trash-action-plan.test.ts b/tests/integration/trash-action-plan.test.ts new file mode 100644 index 0000000..377b1dc --- /dev/null +++ b/tests/integration/trash-action-plan.test.ts @@ -0,0 +1,47 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +test('trash restore plans reject changed targets and preserve overwritten data in trash', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-trash-plan-')) + t.after(async () => fs.rm(root, { recursive: true, force: true })) + process.env.SKILLMANAGER_FIXTURE_ROOT = root + process.env.SKILLMANAGER_DATA_DIR = path.join(root, 'data') + + const skillDir = path.join(root, '.agents', 'skills', 'restore-me') + const skillFile = path.join(skillDir, 'SKILL.md') + const original = '---\nname: Restore Me\ndescription: original\n---\nOriginal.\n' + const conflict = '---\nname: Restore Me\ndescription: conflict\n---\nConflict.\n' + await fs.mkdir(skillDir, { recursive: true }) + await fs.writeFile(skillFile, original) + + const { moveToTrash, listTrash, purgeOne } = await import('../../server/trash/store.js') + const { ActionPlanError, ActionPlanService } = await import('../../server/core/action-plans.js') + const trashed = await moveToTrash(skillDir, 'Restore Me') + await fs.mkdir(skillDir, { recursive: true }) + await fs.writeFile(skillFile, conflict) + + const service = new ActionPlanService() + const stalePlan = await service.previewTrashRestore(trashed.id) + await fs.appendFile(skillFile, 'Changed after preview.\n') + await assert.rejects( + service.apply(stalePlan.id), + (error: unknown) => error instanceof ActionPlanError && error.code === 'STALE_PLAN', + ) + assert.match(await fs.readFile(skillFile, 'utf8'), /Changed after preview/) + assert.equal((await listTrash()).some((entry) => entry.id === trashed.id), true) + + const validPlan = await service.previewTrashRestore(trashed.id) + const result = await service.apply(validPlan.id) + assert.equal(result.action, 'trash-restore') + assert.ok(result.recovery.trashId) + assert.equal(await fs.readFile(skillFile, 'utf8'), original) + assert.equal((await listTrash()).some((entry) => entry.id === result.recovery.trashId), true) + + const outside = path.join(root, 'outside-sentinel') + await fs.writeFile(outside, 'keep') + assert.equal(await purgeOne('../../outside-sentinel'), false) + assert.equal(await fs.readFile(outside, 'utf8'), 'keep') +}) diff --git a/tests/integration/trash.test.ts b/tests/integration/trash.test.ts new file mode 100644 index 0000000..7346c3b --- /dev/null +++ b/tests/integration/trash.test.ts @@ -0,0 +1,25 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +test('trash move and restore stay inside the isolated data directory', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-trash-')) + t.after(async () => fs.rm(root, { recursive: true, force: true })) + process.env.SKILLMANAGER_FIXTURE_ROOT = root + process.env.SKILLMANAGER_DATA_DIR = path.join(root, 'data') + + const skillDir = path.join(root, '.agents', 'skills', 'recoverable') + await fs.mkdir(skillDir, { recursive: true }) + await fs.writeFile(path.join(skillDir, 'SKILL.md'), '# Recoverable\n', 'utf8') + + const { listTrash, moveToTrash, restoreFromTrash } = await import('../../server/trash/store.js') + const entry = await moveToTrash(skillDir, 'recoverable') + await assert.rejects(fs.access(skillDir)) + assert.equal((await listTrash()).some((item) => item.id === entry.id), true) + + await restoreFromTrash(entry.id) + assert.equal(await fs.readFile(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Recoverable\n') + assert.equal((await listTrash()).length, 0) +}) diff --git a/tests/unit/agent-visibility.test.ts b/tests/unit/agent-visibility.test.ts new file mode 100644 index 0000000..bba9130 --- /dev/null +++ b/tests/unit/agent-visibility.test.ts @@ -0,0 +1,26 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildAgentVisibilityModel } from '../../web/src/agent-visibility.js' + +test('visibility model separates Codex, shared standard, and compatible platforms', () => { + const model = buildAgentVisibilityModel(282, { + codex: 215, + universal: 147, + cursor: 147, + 'gemini-cli': 147, + unknown: 9, + }) + + assert.deepEqual(model.all, { + value: 'all', + label: '全部 Skills', + icon: '📋', + count: 282, + }) + assert.equal(model.codex.label, 'Codex 可见') + assert.equal(model.codex.count, 215) + assert.equal(model.shared?.label, '共享标准 (.agents)') + assert.equal(model.shared?.count, 147) + assert.deepEqual(model.others.map((item) => item.value), ['cursor', 'gemini-cli']) + assert.equal(model.others.some((item) => item.value === 'unknown'), false) +}) diff --git a/tests/unit/dashboard-context.test.ts b/tests/unit/dashboard-context.test.ts new file mode 100644 index 0000000..b2b32c2 --- /dev/null +++ b/tests/unit/dashboard-context.test.ts @@ -0,0 +1,69 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { + DashboardRouteError, + dashboardTarget, + parseDashboardQuery, +} from '../../server/core/dashboard-context.js' +import { conflictId, diagnosticId } from '../../server/core/opaque-ids.js' + +const skillId = `skill_${'a'.repeat(24)}` +const findingId = `finding_${'b'.repeat(20)}` +const groupId = `group_${'c'.repeat(20)}` + +test('Dashboard routes build only valid opaque deep links', () => { + assert.equal(dashboardTarget({ view: 'dashboard' }), '/') + assert.equal(dashboardTarget({ view: 'current-project' }), '/?view=current-project') + assert.equal( + dashboardTarget({ view: 'doctor', findingId }), + `/?view=doctor&finding=${findingId}`, + ) + assert.equal( + dashboardTarget({ view: 'skills', skillId }), + `/?view=skills&skill=${skillId}`, + ) + assert.equal( + dashboardTarget({ view: 'versions', skillId }), + `/?view=versions&skill=${skillId}`, + ) + assert.equal( + dashboardTarget({ view: 'conflicts', groupId }), + `/?view=conflicts&group=${groupId}`, + ) + assert.equal( + dashboardTarget({ view: 'convergence', groupId }), + `/?view=convergence&group=${groupId}`, + ) + + assert.throws( + () => dashboardTarget({ view: 'versions' }), + (error: unknown) => error instanceof DashboardRouteError && error.code === 'MISSING_TARGET', + ) + assert.throws( + () => parseDashboardQuery({ view: 'doctor', skill: skillId }), + (error: unknown) => error instanceof DashboardRouteError && error.code === 'INVALID_ROUTE', + ) + assert.throws( + () => parseDashboardQuery({ view: 'skills', skill: '/tmp/skill' }), + (error: unknown) => error instanceof DashboardRouteError && error.code === 'INVALID_ROUTE', + ) + assert.throws( + () => parseDashboardQuery({ view: 'dashboard', surprise: '1' }), + (error: unknown) => error instanceof DashboardRouteError && error.code === 'INVALID_ROUTE', + ) +}) + +test('finding IDs expire when diagnostic evidence changes and group IDs are order-stable', () => { + const base = { + type: 'missing_description', + severity: 'warn', + title: 'Weak descriptions', + detail: 'First evidence', + affectedSkillIds: [skillId], + } + assert.notEqual(diagnosticId(base), diagnosticId({ ...base, detail: 'Changed evidence' })) + assert.equal( + conflictId('duplicate', [skillId, `skill_${'d'.repeat(24)}`]), + conflictId('duplicate', [`skill_${'d'.repeat(24)}`, skillId]), + ) +}) diff --git a/tests/unit/frontend-routing.test.ts b/tests/unit/frontend-routing.test.ts new file mode 100644 index 0000000..553acf6 --- /dev/null +++ b/tests/unit/frontend-routing.test.ts @@ -0,0 +1,44 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { + buildDashboardLocation, + parseDashboardLocation, +} from '../../web/src/routing.js' + +const skillId = `skill_${'a'.repeat(24)}` +const findingId = `finding_${'b'.repeat(20)}` +const groupId = `group_${'c'.repeat(20)}` + +test('frontend routes survive refresh-safe parsing and reject ambiguous links', () => { + assert.deepEqual(parseDashboardLocation(''), { + route: { view: 'home', skillId: undefined, findingId: undefined, groupId: undefined }, + error: null, + }) + assert.deepEqual(parseDashboardLocation(`?view=skills&skill=${skillId}`).route, { + view: 'skills', + skillId, + findingId: undefined, + groupId: undefined, + }) + assert.equal( + buildDashboardLocation({ view: 'doctor', findingId }), + `/?view=doctor&finding=${findingId}`, + ) + assert.equal( + buildDashboardLocation({ view: 'convergence', groupId }), + `/?view=convergence&group=${groupId}`, + ) + assert.equal( + parseDashboardLocation(`?view=convergence&group=${groupId}`).route.view, + 'convergence', + ) + assert.equal(parseDashboardLocation('?view=versions').error?.code, 'MISSING_TARGET') + assert.equal( + parseDashboardLocation(`?view=doctor&skill=${skillId}`).error?.code, + 'INVALID_ROUTE', + ) + assert.equal( + parseDashboardLocation('?view=skills&skill=one&skill=two').error?.code, + 'INVALID_ROUTE', + ) +}) diff --git a/tests/unit/nlp.test.ts b/tests/unit/nlp.test.ts new file mode 100644 index 0000000..c03cd5c --- /dev/null +++ b/tests/unit/nlp.test.ts @@ -0,0 +1,38 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { + BUILTIN_SYNONYMS, + buildSynonymIndex, + jaccard, + tokenize, +} from '../../server/scanner/nlp.js' +import { exposedAgentIdsFor } from '../../server/scanner/agents.js' +import { isPathInside } from '../../server/core/authorization.js' +import path from 'node:path' + +test('tokenize normalizes aliases to the same canonical concept', () => { + const index = buildSynonymIndex(BUILTIN_SYNONYMS) + const english = tokenize('Create a Xiaohongshu image', index) + const chinese = tokenize('生成小红书图片', index) + assert.equal(english.has('小红书'), true) + assert.equal(chinese.has('小红书'), true) + assert.equal(english.has('图片'), true) + assert.equal(chinese.has('图片'), true) +}) + +test('jaccard returns a stable similarity ratio', () => { + assert.equal(jaccard(new Set(['a', 'b']), new Set(['b', 'c'])), 1 / 3) + assert.equal(jaccard(new Set(), new Set(['a'])), 0) +}) + +test('the shared .agents path includes Codex exposure', () => { + const exposed = exposedAgentIdsFor('universal') + assert.equal(exposed.includes('codex'), true) + assert.equal(exposed.includes('universal'), true) +}) + +test('path containment rejects traversal siblings', () => { + const root = path.resolve('/fixture/skills') + assert.equal(isPathInside(root, path.join(root, 'safe')), true) + assert.equal(isPathInside(root, path.resolve('/fixture/skills-evil')), false) +}) diff --git a/tests/unit/npm-invocation.test.ts b/tests/unit/npm-invocation.test.ts new file mode 100644 index 0000000..cf7d1eb --- /dev/null +++ b/tests/unit/npm-invocation.test.ts @@ -0,0 +1,31 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { npmInvocation } from '../../scripts/npm-invocation.mjs' + +test('npm child processes use Node plus npm_execpath on Windows', () => { + assert.deepEqual( + npmInvocation(['run', 'build'], { + platform: 'win32', + nodeExecPath: 'C:\\nodejs\\node.exe', + npmExecPath: 'C:\\nodejs\\node_modules\\npm\\bin\\npm-cli.js', + }), + { + command: 'C:\\nodejs\\node.exe', + args: [ + 'C:\\nodejs\\node_modules\\npm\\bin\\npm-cli.js', + 'run', + 'build', + ], + }, + ) +}) + +test('npm child processes retain the POSIX fallback outside npm', () => { + assert.deepEqual( + npmInvocation(['audit', '--json'], { + platform: 'linux', + npmExecPath: null, + }), + { command: 'npm', args: ['audit', '--json'] }, + ) +}) diff --git a/tests/unit/redaction.test.ts b/tests/unit/redaction.test.ts new file mode 100644 index 0000000..913a9e3 --- /dev/null +++ b/tests/unit/redaction.test.ts @@ -0,0 +1,17 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { redactObject, redactPath } from '../../server/security/redaction.js' + +test('debug redaction hides the home name, tokens, and Skill content', () => { + const home = '/Users/private-user' + assert.equal(redactPath('/Users/private-user/.agents/skills', home), '$HOME/.agents/skills') + assert.equal(redactPath('/tmp/outside', home), '/outside') + const redacted = redactObject({ + token: 'top-secret', + nested: { content: 'private skill', path: '/Users/private-user/project' }, + }, home) + assert.deepEqual(redacted, { + token: '[REDACTED]', + nested: { content: '[REDACTED]', path: '$HOME/project' }, + }) +}) diff --git a/tests/unit/session.test.ts b/tests/unit/session.test.ts new file mode 100644 index 0000000..1dd6ba9 --- /dev/null +++ b/tests/unit/session.test.ts @@ -0,0 +1,35 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { + LAUNCH_NONCE_TTL_MS, + SessionManager, + parseCookie, + sessionCookie, +} from '../../server/security/session.js' + +test('launch nonces are single-use and expire after 60 seconds', () => { + const sessions = new SessionManager() + const now = 10_000 + const nonce = sessions.createLaunchNonce('/?view=skills', now) + const launched = sessions.consumeLaunchNonce(nonce, now + 1) + assert.equal(launched?.target, '/?view=skills') + assert.equal(sessions.consumeLaunchNonce(nonce, now + 2), null) + + const expired = sessions.createLaunchNonce('/', now) + assert.equal(sessions.consumeLaunchNonce(expired, now + LAUNCH_NONCE_TTL_MS), null) +}) + +test('unsafe redirect targets fall back to root', () => { + const sessions = new SessionManager() + const launch = sessions.consumeLaunchNonce( + sessions.createLaunchNonce('//attacker.example/path'), + ) + assert.equal(launch?.target, '/') +}) + +test('session cookies are HttpOnly and SameSite Strict', () => { + const header = sessionCookie('abc') + assert.match(header, /HttpOnly/) + assert.match(header, /SameSite=Strict/) + assert.equal(parseCookie(header, 'skillmanager_session'), 'abc') +}) diff --git a/tests/unit/sync-config.test.ts b/tests/unit/sync-config.test.ts new file mode 100644 index 0000000..278e9b8 --- /dev/null +++ b/tests/unit/sync-config.test.ts @@ -0,0 +1,58 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { + SyncConfigStore, + type CredentialBackend, + type SyncConfig, +} from '../../server/sync/config.js' + +class FakeCredentials implements CredentialBackend { + readonly values = new Map() + async get(reference: string) { return this.values.get(reference) || null } + async set(reference: string, secret: string) { this.values.set(reference, secret) } + async delete(reference: string) { this.values.delete(reference) } +} + +const fixtureConfig: SyncConfig = { + repoUrl: 'https://github.com/example/private', + owner: 'example', + name: 'private', + defaultBranch: 'main', + token: 'fake-sensitive-token', + lastValidatedAt: null, +} + +test('sync config persists only a keyring reference, never the token', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-config-')) + t.after(async () => fs.rm(root, { recursive: true, force: true })) + const configPath = path.join(root, 'credentials.json') + const credentials = new FakeCredentials() + const store = new SyncConfigStore(configPath, credentials) + + await store.write(fixtureConfig) + const raw = await fs.readFile(configPath, 'utf8') + assert.equal(raw.includes(fixtureConfig.token), false) + assert.match(raw, /"schemaVersion": 2/) + assert.equal((await store.read())?.token, fixtureConfig.token) + + await store.delete() + assert.equal(credentials.values.size, 0) + await assert.rejects(fs.access(configPath)) +}) + +test('legacy plaintext config migrates to a keyring reference on first read', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-config-legacy-')) + t.after(async () => fs.rm(root, { recursive: true, force: true })) + const configPath = path.join(root, 'credentials.json') + await fs.writeFile(configPath, JSON.stringify(fixtureConfig), 'utf8') + const credentials = new FakeCredentials() + const store = new SyncConfigStore(configPath, credentials) + + assert.equal((await store.read())?.token, fixtureConfig.token) + const migrated = await fs.readFile(configPath, 'utf8') + assert.equal(migrated.includes(fixtureConfig.token), false) + assert.match(migrated, /"credentialRef"/) +}) diff --git a/tests/unit/sync-download-plan.test.ts b/tests/unit/sync-download-plan.test.ts new file mode 100644 index 0000000..bf6ef28 --- /dev/null +++ b/tests/unit/sync-download-plan.test.ts @@ -0,0 +1,117 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import type { ScanResult } from '../../server/types.js' +import type { DownloadListing } from '../../server/sync/download.js' +import type { SyncConfig } from '../../server/sync/config.js' +import { + SyncDownloadPlanError, + SyncDownloadPlanService, +} from '../../server/sync/download-plans.js' + +const config: SyncConfig = { + repoUrl: 'https://github.com/example/fixture', + owner: 'example', + name: 'fixture', + defaultBranch: 'main', + token: 'fixture-only', + lastValidatedAt: null, +} + +function scan(): ScanResult { + return { skills: [] } as unknown as ScanResult +} + +function listing(filesInRemote = 1): DownloadListing { + return { + vaultHead: 'abc123', + rows: [{ + key: 'codex/demo', + agent: 'codex', + name: 'demo', + state: 'remote_only', + localPath: null, + localModifiedAt: null, + remoteModifiedAt: '2026-08-02T00:00:00.000Z', + filesInRemote, + filesInLocal: 0, + filesDiffering: 0, + excluded: false, + excludeReason: null, + }], + totals: { + remoteOnly: 1, + localOnly: 0, + identical: 0, + different: 0, + excluded: 0, + }, + } +} + +test('sync plan applies only when the bound listing is unchanged', async () => { + const service = new SyncDownloadPlanService({ + scan: async () => scan(), + listing: async () => ({ ok: true, listing: listing() }), + apply: async () => ({ + ok: true, + applied: [{ key: 'codex/demo', files: 1, targetDir: '/fixture/demo' }], + }), + vaultDir: () => '/fixture/vault', + }) + const plan = await service.preview(config, ['codex/demo']) + const result = await service.apply(config, plan.id) + assert.equal(result.ok, true) + assert.deepEqual(result.applied, [{ key: 'codex/demo', files: 1, trashId: undefined }]) +}) + +test('sync plan becomes stale when the remote row signature changes', async () => { + let calls = 0 + const service = new SyncDownloadPlanService({ + scan: async () => scan(), + listing: async () => ({ ok: true, listing: listing(++calls) }), + apply: async () => ({ ok: true, applied: [] }), + vaultDir: () => '/fixture/vault', + }) + const plan = await service.preview(config, ['codex/demo']) + await assert.rejects( + service.apply(config, plan.id), + (error: unknown) => + error instanceof SyncDownloadPlanError && error.code === 'STALE_PLAN', + ) +}) + +test('sync download preview advises reusing an exact local main version', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'skillmanager-sync-advice-')) + t.after(async () => fs.rm(root, { recursive: true, force: true })) + const remoteDir = path.join(root, 'vault', 'codex', 'demo') + await fs.mkdir(remoteDir, { recursive: true }) + await fs.writeFile( + path.join(remoteDir, 'SKILL.md'), + '---\nname: Demo\ndescription: exact remote\n---\nSame.\n', + 'utf8', + ) + const { hashSkillDirectory } = await import('../../server/scanner/package-hash.js') + const packageHash = await hashSkillDirectory(remoteDir) + assert.ok(packageHash) + const existing = { + id: 'skill_aaaaaaaaaaaaaaaaaaaaaaaa', + name: 'Existing Demo', + packageHash, + scope: 'global', + installations: [{ scope: 'global' }], + } + const fixtureScan = { skills: [existing] } as unknown as ScanResult + const service = new SyncDownloadPlanService({ + scan: async () => fixtureScan, + listing: async () => ({ ok: true, listing: listing() }), + apply: async () => ({ ok: true, applied: [] }), + vaultDir: () => path.join(root, 'vault'), + }) + const plan = await service.preview(config, ['codex/demo']) + assert.equal(plan.advisories.length, 1) + assert.equal(plan.advisories[0].code, 'EXACT_COPY_AVAILABLE') + assert.equal(plan.advisories[0].canonicalSkillId, existing.id) +}) diff --git a/tests/unit/sync-upload-plan.test.ts b/tests/unit/sync-upload-plan.test.ts new file mode 100644 index 0000000..40c73b9 --- /dev/null +++ b/tests/unit/sync-upload-plan.test.ts @@ -0,0 +1,85 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import type { ScanResult } from '../../server/types.js' +import type { SyncConfig } from '../../server/sync/config.js' +import type { UploadPreview } from '../../server/sync/upload.js' +import { + SyncUploadPlanError, + SyncUploadPlanService, +} from '../../server/sync/upload-plans.js' + +const config: SyncConfig = { + repoUrl: 'https://github.com/example/fixture', + owner: 'example', + name: 'fixture', + defaultBranch: 'main', + token: 'fixture-only', + lastValidatedAt: null, +} + +const scan = { skills: [] } as unknown as ScanResult + +function preview(): UploadPreview { + return { + vaultHead: 'abc123', + localSkillCount: 1, + syncableSkillCount: 1, + excludedSkillCount: 0, + skillChanges: [{ + agent: 'codex', + name: 'demo', + vaultDir: 'codex/demo', + status: 'update', + filesAdded: 0, + filesUpdated: 1, + filesDeleted: 0, + filesUnchanged: 0, + }], + totals: { + skillsAdded: 0, + skillsUpdated: 1, + skillsDeleted: 0, + skillsUnchanged: 0, + filesAdded: 0, + filesUpdated: 1, + filesDeleted: 0, + }, + scan: { totalFindings: 0, danger: 0, warn: 0, findings: [] }, + skippedFiles: [], + } +} + +test('sync upload applies only through the hash bound to its server preview', async () => { + let expectedHash = '' + const service = new SyncUploadPlanService({ + scan: async () => scan, + preview: async () => ({ ok: true, preview: preview() }), + execute: async (_config, _skills, options) => { + expectedHash = options.expectedPreviewHash || '' + return { ok: true, preview: preview(), sha: 'def456', noop: false } + }, + }) + const plan = await service.preview(config) + assert.equal(plan.changes[0].operation, 'update') + const result = await service.apply(config, plan.id, false) + assert.equal(result.sha, 'def456') + assert.match(expectedHash, /^[a-f0-9]{64}$/) +}) + +test('sync upload reports stale when source or remote state differs from preview', async () => { + const service = new SyncUploadPlanService({ + scan: async () => scan, + preview: async () => ({ ok: true, preview: preview() }), + execute: async () => ({ + ok: false, + code: 'STALE_PLAN', + error: 'changed', + preview: { ...preview(), vaultHead: 'different' }, + }), + }) + const plan = await service.preview(config) + await assert.rejects( + service.apply(config, plan.id, false), + (error: unknown) => error instanceof SyncUploadPlanError && error.code === 'STALE_PLAN', + ) +}) diff --git a/tests/unit/web-entry.test.ts b/tests/unit/web-entry.test.ts new file mode 100644 index 0000000..a6706ae --- /dev/null +++ b/tests/unit/web-entry.test.ts @@ -0,0 +1,13 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import path from 'node:path' + +test('source HTML fails visibly under file protocol and points to the secure launcher', async () => { + const html = await fs.readFile(path.resolve('web/index.html'), 'utf8') + + assert.match(html, /window\.location\.protocol === 'file:'/) + assert.match(html, /这是源码预览,不是 SkillManager Dashboard/) + assert.match(html, /http:\/\/127\.0\.0\.1:3456\//) + assert.match(html, /skillmanager open --project <项目目录>/) +}) diff --git a/tsconfig.json b/tsconfig.json index 86baeee..ffed97a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,5 @@ "@server/*": ["server/*"] } }, - "include": ["web/src", "server", "vite.config.ts"] + "include": ["web/src", "server", "tests", "vite.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 8cbbba8..6232ed6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,8 +2,12 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import path from 'path' +import packageJson from './package.json' export default defineConfig({ + define: { + __SKILLMANAGER_VERSION__: JSON.stringify(packageJson.version), + }, plugins: [react(), tailwindcss()], root: 'web', resolve: { diff --git a/web/index.html b/web/index.html index 5020603..c1de071 100644 --- a/web/index.html +++ b/web/index.html @@ -3,8 +3,8 @@ - Skill Hub - + SkillManager +